mirror of
https://github.com/TeamOctolings/Octobot.git
synced 2025-01-31 09:09:00 +03:00
Handle guild data load errors better (#172)
Previously, any errors in guild data load will cause the bot to be unusable in that guild. It didn't help that the end users had no information that something was wrong! Now, any errors will be logged better (with the full path to the file that couldn't be loaded), and the users will receive a message saying that functionality is degraded The old way to save objects was to serialize them directly into streams opened by `File#Create`. This can cause problems if the serialization isn't completed, because `File#Create` overwrites the file with an empty one on the spot. Now, objects are first deserialized into a temporary file, then the original is replaced by the temporary, then the temporary is deleted. Errors during guild data load would sometimes cause the bot to replace the corrupted file with a default one whenever a save is triggered. Now, guilds with load errors won't have their data saved to aid in debugging --------- Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com> Signed-off-by: mctaylors <mctaylxrs@outlook.com>
This commit is contained in:
parent
fb3aebb1e0
commit
cf7007f269
8 changed files with 142 additions and 28 deletions
|
@ -570,6 +570,12 @@
|
||||||
<data name="MessagesClearedFiltered" xml:space="preserve">
|
<data name="MessagesClearedFiltered" xml:space="preserve">
|
||||||
<value>Cleared {0} messages from {1}</value>
|
<value>Cleared {0} messages from {1}</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="DataLoadFailedTitle" xml:space="preserve">
|
||||||
|
<value>An error occurred during guild data load.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DataLoadFailedDescription" xml:space="preserve">
|
||||||
|
<value>This will lead to unexpected behavior. Data will no longer be saved</value>
|
||||||
|
</data>
|
||||||
<data name="CommandExecutionFailed" xml:space="preserve">
|
<data name="CommandExecutionFailed" xml:space="preserve">
|
||||||
<value>An error occurred during command execution, try again later.</value>
|
<value>An error occurred during command execution, try again later.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|
|
@ -570,6 +570,12 @@
|
||||||
<data name="MessagesClearedFiltered" xml:space="preserve">
|
<data name="MessagesClearedFiltered" xml:space="preserve">
|
||||||
<value>Очищено {0} сообщений от {1}</value>
|
<value>Очищено {0} сообщений от {1}</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="DataLoadFailedTitle" xml:space="preserve">
|
||||||
|
<value>Произошла ошибка при загрузке данных сервера.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DataLoadFailedDescription" xml:space="preserve">
|
||||||
|
<value>Это может привести к неожиданному поведению. Данные больше не будут сохраняться.</value>
|
||||||
|
</data>
|
||||||
<data name="CommandExecutionFailed" xml:space="preserve">
|
<data name="CommandExecutionFailed" xml:space="preserve">
|
||||||
<value>Произошла ошибка при выполнении команды, повтори попытку позже.</value>
|
<value>Произошла ошибка при выполнении команды, повтори попытку позже.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|
|
@ -570,6 +570,12 @@
|
||||||
<data name="MessagesClearedFiltered" xml:space="preserve">
|
<data name="MessagesClearedFiltered" xml:space="preserve">
|
||||||
<value>вырезано {0} забавных сообщений от {1}</value>
|
<value>вырезано {0} забавных сообщений от {1}</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="DataLoadFailedTitle" xml:space="preserve">
|
||||||
|
<value>произошёл тотальный разнос в гилддате.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DataLoadFailedDescription" xml:space="preserve">
|
||||||
|
<value>возможно всё съедет с крыши, но знай, что я больше ничё не сохраню.</value>
|
||||||
|
</data>
|
||||||
<data name="CommandExecutionFailed" xml:space="preserve">
|
<data name="CommandExecutionFailed" xml:space="preserve">
|
||||||
<value>произошёл тотальный разнос в команде, удачи.</value>
|
<value>произошёл тотальный разнос в команде, удачи.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|
|
@ -17,10 +17,12 @@ public sealed class GuildData
|
||||||
public readonly JsonNode Settings;
|
public readonly JsonNode Settings;
|
||||||
public readonly string SettingsPath;
|
public readonly string SettingsPath;
|
||||||
|
|
||||||
|
public readonly bool DataLoadFailed;
|
||||||
|
|
||||||
public GuildData(
|
public GuildData(
|
||||||
JsonNode settings, string settingsPath,
|
JsonNode settings, string settingsPath,
|
||||||
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
|
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
|
||||||
Dictionary<ulong, MemberData> memberData, string memberDataPath)
|
Dictionary<ulong, MemberData> memberData, string memberDataPath, bool dataLoadFailed)
|
||||||
{
|
{
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
SettingsPath = settingsPath;
|
SettingsPath = settingsPath;
|
||||||
|
@ -28,6 +30,7 @@ public sealed class GuildData
|
||||||
ScheduledEventsPath = scheduledEventsPath;
|
ScheduledEventsPath = scheduledEventsPath;
|
||||||
MemberData = memberData;
|
MemberData = memberData;
|
||||||
MemberDataPath = memberDataPath;
|
MemberDataPath = memberDataPath;
|
||||||
|
DataLoadFailed = dataLoadFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MemberData GetOrCreateMemberData(Snowflake memberId)
|
public MemberData GetOrCreateMemberData(Snowflake memberId)
|
||||||
|
|
16
src/Messages.Designer.cs
generated
16
src/Messages.Designer.cs
generated
|
@ -997,6 +997,22 @@ namespace Octobot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static string DataLoadFailedTitle
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return ResourceManager.GetString("DataLoadFailedTitle", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string DataLoadFailedDescription
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return ResourceManager.GetString("DataLoadFailedDescription", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static string CommandExecutionFailed
|
internal static string CommandExecutionFailed
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|
|
@ -8,6 +8,7 @@ using Remora.Discord.API.Abstractions.Rest;
|
||||||
using Remora.Discord.API.Gateway.Events;
|
using Remora.Discord.API.Gateway.Events;
|
||||||
using Remora.Discord.Extensions.Embeds;
|
using Remora.Discord.Extensions.Embeds;
|
||||||
using Remora.Discord.Gateway.Responders;
|
using Remora.Discord.Gateway.Responders;
|
||||||
|
using Remora.Rest.Core;
|
||||||
using Remora.Results;
|
using Remora.Results;
|
||||||
|
|
||||||
namespace Octobot.Responders;
|
namespace Octobot.Responders;
|
||||||
|
@ -42,7 +43,6 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
|
||||||
}
|
}
|
||||||
|
|
||||||
var guild = gatewayEvent.Guild.AsT0;
|
var guild = gatewayEvent.Guild.AsT0;
|
||||||
_logger.LogInformation("Joined guild {ID} (\"{Name}\")", guild.ID, guild.Name);
|
|
||||||
|
|
||||||
var data = await _guildData.GetData(guild.ID, ct);
|
var data = await _guildData.GetData(guild.ID, ct);
|
||||||
var cfg = data.Settings;
|
var cfg = data.Settings;
|
||||||
|
@ -51,6 +51,32 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
|
||||||
data.GetOrCreateMemberData(member.User.Value.ID);
|
data.GetOrCreateMemberData(member.User.Value.ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var botResult = await _userApi.GetCurrentUserAsync(ct);
|
||||||
|
if (!botResult.IsDefined(out var bot))
|
||||||
|
{
|
||||||
|
return Result.FromError(botResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.DataLoadFailed)
|
||||||
|
{
|
||||||
|
var errorEmbed = new EmbedBuilder()
|
||||||
|
.WithSmallTitle(Messages.DataLoadFailedTitle, bot)
|
||||||
|
.WithDescription(Messages.DataLoadFailedDescription)
|
||||||
|
.WithFooter(Messages.ContactDevelopers)
|
||||||
|
.WithColour(ColorsList.Red)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
if (!errorEmbed.IsDefined(out var errorBuilt))
|
||||||
|
{
|
||||||
|
return Result.FromError(errorEmbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Result)await _channelApi.CreateMessageAsync(
|
||||||
|
GetEmergencyFeedbackChannel(guild, data), embeds: new[] { errorBuilt }, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Loaded guild {ID} (\"{Name}\")", guild.ID, guild.Name);
|
||||||
|
|
||||||
if (!GuildSettings.ReceiveStartupMessages.Get(cfg))
|
if (!GuildSettings.ReceiveStartupMessages.Get(cfg))
|
||||||
{
|
{
|
||||||
return Result.FromSuccess();
|
return Result.FromSuccess();
|
||||||
|
@ -61,12 +87,6 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
|
||||||
return Result.FromSuccess();
|
return Result.FromSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
var botResult = await _userApi.GetCurrentUserAsync(ct);
|
|
||||||
if (!botResult.IsDefined(out var bot))
|
|
||||||
{
|
|
||||||
return Result.FromError(botResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||||
var i = Random.Shared.Next(1, 4);
|
var i = Random.Shared.Next(1, 4);
|
||||||
|
|
||||||
|
@ -84,4 +104,23 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
|
||||||
return (Result)await _channelApi.CreateMessageAsync(
|
return (Result)await _channelApi.CreateMessageAsync(
|
||||||
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct);
|
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Snowflake GetEmergencyFeedbackChannel(IGuildCreate.IAvailableGuild guild, GuildData data)
|
||||||
|
{
|
||||||
|
var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings);
|
||||||
|
if (!privateFeedback.Empty())
|
||||||
|
{
|
||||||
|
return privateFeedback;
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicFeedback = GuildSettings.PublicFeedbackChannel.Get(data.Settings);
|
||||||
|
if (!publicFeedback.Empty())
|
||||||
|
{
|
||||||
|
return publicFeedback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return guild.SystemChannelID.AsOptional().IsDefined(out var systemChannel)
|
||||||
|
? systemChannel
|
||||||
|
: guild.Channels[0].ID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ public class GuildUnloadedResponder : IResponder<IGuildDelete>
|
||||||
var isDataRemoved = _guildData.UnloadGuildData(guildId);
|
var isDataRemoved = _guildData.UnloadGuildData(guildId);
|
||||||
if (isDataRemoved)
|
if (isDataRemoved)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Left guild {GuildId}", guildId);
|
_logger.LogInformation("Unloaded guild {GuildId}", guildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(Result.FromSuccess());
|
return Task.FromResult(Result.FromSuccess());
|
||||||
|
|
|
@ -43,25 +43,31 @@ public sealed class GuildDataService : IHostedService
|
||||||
{
|
{
|
||||||
var tasks = new List<Task>();
|
var tasks = new List<Task>();
|
||||||
var datas = _datas.Values.ToArray();
|
var datas = _datas.Values.ToArray();
|
||||||
foreach (var data in datas)
|
foreach (var data in datas.Where(data => !data.DataLoadFailed))
|
||||||
{
|
{
|
||||||
await using var settingsStream = File.Create(data.SettingsPath);
|
tasks.Add(SerializeObjectSafelyAsync(data.Settings, data.SettingsPath, ct));
|
||||||
tasks.Add(JsonSerializer.SerializeAsync(settingsStream, data.Settings, cancellationToken: ct));
|
tasks.Add(SerializeObjectSafelyAsync(data.ScheduledEvents, data.ScheduledEventsPath, ct));
|
||||||
|
|
||||||
await using var eventsStream = File.Create(data.ScheduledEventsPath);
|
|
||||||
tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct));
|
|
||||||
|
|
||||||
var memberDatas = data.MemberData.Values.ToArray();
|
var memberDatas = data.MemberData.Values.ToArray();
|
||||||
foreach (var memberData in memberDatas)
|
tasks.AddRange(memberDatas.Select(memberData =>
|
||||||
{
|
SerializeObjectSafelyAsync(memberData, $"{data.MemberDataPath}/{memberData.Id}.json", ct)));
|
||||||
await using var memberDataStream = File.Create($"{data.MemberDataPath}/{memberData.Id}.json");
|
|
||||||
tasks.Add(JsonSerializer.SerializeAsync(memberDataStream, memberData, cancellationToken: ct));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(tasks);
|
await Task.WhenAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tempFilePath = path + ".tmp";
|
||||||
|
await using (var tempFileStream = File.Create(tempFilePath))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(tempFileStream, obj, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(tempFilePath, path, true);
|
||||||
|
File.Delete(tempFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<GuildData> GetData(Snowflake guildId, CancellationToken ct = default)
|
public async Task<GuildData> GetData(Snowflake guildId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct);
|
return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct);
|
||||||
|
@ -88,20 +94,50 @@ public sealed class GuildDataService : IHostedService
|
||||||
await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
|
await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dataLoadFailed = false;
|
||||||
|
|
||||||
await using var settingsStream = File.OpenRead(settingsPath);
|
await using var settingsStream = File.OpenRead(settingsPath);
|
||||||
var jsonSettings
|
JsonNode? jsonSettings = null;
|
||||||
= JsonNode.Parse(settingsStream);
|
try
|
||||||
|
{
|
||||||
|
jsonSettings = JsonNode.Parse(settingsStream);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Guild settings load failed: {Path}", settingsPath);
|
||||||
|
dataLoadFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
await using var eventsStream = File.OpenRead(scheduledEventsPath);
|
await using var eventsStream = File.OpenRead(scheduledEventsPath);
|
||||||
var events
|
Dictionary<ulong, ScheduledEventData>? events = null;
|
||||||
= await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
|
try
|
||||||
|
{
|
||||||
|
events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
|
||||||
eventsStream, cancellationToken: ct);
|
eventsStream, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath);
|
||||||
|
dataLoadFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
var memberData = new Dictionary<ulong, MemberData>();
|
var memberData = new Dictionary<ulong, MemberData>();
|
||||||
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles())
|
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles())
|
||||||
{
|
{
|
||||||
await using var dataStream = dataFileInfo.OpenRead();
|
await using var dataStream = dataFileInfo.OpenRead();
|
||||||
var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
|
MemberData? data;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath,
|
||||||
|
dataFileInfo.Name);
|
||||||
|
dataLoadFailed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (data is null)
|
if (data is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
@ -113,7 +149,8 @@ public sealed class GuildDataService : IHostedService
|
||||||
var finalData = new GuildData(
|
var finalData = new GuildData(
|
||||||
jsonSettings ?? new JsonObject(), settingsPath,
|
jsonSettings ?? new JsonObject(), settingsPath,
|
||||||
events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
|
events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
|
||||||
memberData, memberDataPath);
|
memberData, memberDataPath,
|
||||||
|
dataLoadFailed);
|
||||||
|
|
||||||
_datas.TryAdd(guildId, finalData);
|
_datas.TryAdd(guildId, finalData);
|
||||||
|
|
||||||
|
@ -129,7 +166,8 @@ public sealed class GuildDataService : IHostedService
|
||||||
Directory.CreateDirectory($"{newPath}/..");
|
Directory.CreateDirectory($"{newPath}/..");
|
||||||
Directory.Move(oldPath, newPath);
|
Directory.Move(oldPath, newPath);
|
||||||
|
|
||||||
_logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath, newPath);
|
_logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath,
|
||||||
|
newPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue