mirror of
https://github.com/TeamOctolings/Octobot.git
synced 2025-01-31 17:19:00 +03:00
Octol1ttle
cf7007f269
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>
188 lines
5.8 KiB
C#
188 lines
5.8 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Octobot.Data;
|
|
using Remora.Rest.Core;
|
|
|
|
namespace Octobot.Services;
|
|
|
|
/// <summary>
|
|
/// Handles saving, loading, initializing and providing <see cref="GuildData" />.
|
|
/// </summary>
|
|
public sealed class GuildDataService : IHostedService
|
|
{
|
|
private readonly ConcurrentDictionary<Snowflake, GuildData> _datas = new();
|
|
private readonly ILogger<GuildDataService> _logger;
|
|
|
|
// https://github.com/dotnet/aspnetcore/issues/39139
|
|
public GuildDataService(
|
|
IHostApplicationLifetime lifetime, ILogger<GuildDataService> logger)
|
|
{
|
|
_logger = logger;
|
|
lifetime.ApplicationStopping.Register(ApplicationStopping);
|
|
}
|
|
|
|
public Task StartAsync(CancellationToken ct)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync(CancellationToken ct)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private void ApplicationStopping()
|
|
{
|
|
SaveAsync(CancellationToken.None).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public async Task SaveAsync(CancellationToken ct)
|
|
{
|
|
var tasks = new List<Task>();
|
|
var datas = _datas.Values.ToArray();
|
|
foreach (var data in datas.Where(data => !data.DataLoadFailed))
|
|
{
|
|
tasks.Add(SerializeObjectSafelyAsync(data.Settings, data.SettingsPath, ct));
|
|
tasks.Add(SerializeObjectSafelyAsync(data.ScheduledEvents, data.ScheduledEventsPath, ct));
|
|
|
|
var memberDatas = data.MemberData.Values.ToArray();
|
|
tasks.AddRange(memberDatas.Select(memberData =>
|
|
SerializeObjectSafelyAsync(memberData, $"{data.MemberDataPath}/{memberData.Id}.json", ct)));
|
|
}
|
|
|
|
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)
|
|
{
|
|
return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct);
|
|
}
|
|
|
|
private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default)
|
|
{
|
|
var path = $"GuildData/{guildId}";
|
|
var memberDataPath = $"{path}/MemberData";
|
|
var settingsPath = $"{path}/Settings.json";
|
|
var scheduledEventsPath = $"{path}/ScheduledEvents.json";
|
|
|
|
MigrateGuildData(guildId, path);
|
|
|
|
Directory.CreateDirectory(path);
|
|
|
|
if (!File.Exists(settingsPath))
|
|
{
|
|
await File.WriteAllTextAsync(settingsPath, "{}", ct);
|
|
}
|
|
|
|
if (!File.Exists(scheduledEventsPath))
|
|
{
|
|
await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
|
|
}
|
|
|
|
var dataLoadFailed = false;
|
|
|
|
await using var settingsStream = File.OpenRead(settingsPath);
|
|
JsonNode? jsonSettings = null;
|
|
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);
|
|
Dictionary<ulong, ScheduledEventData>? events = null;
|
|
try
|
|
{
|
|
events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
|
|
eventsStream, cancellationToken: ct);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath);
|
|
dataLoadFailed = true;
|
|
}
|
|
|
|
var memberData = new Dictionary<ulong, MemberData>();
|
|
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles())
|
|
{
|
|
await using var dataStream = dataFileInfo.OpenRead();
|
|
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)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
memberData.Add(data.Id, data);
|
|
}
|
|
|
|
var finalData = new GuildData(
|
|
jsonSettings ?? new JsonObject(), settingsPath,
|
|
events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
|
|
memberData, memberDataPath,
|
|
dataLoadFailed);
|
|
|
|
_datas.TryAdd(guildId, finalData);
|
|
|
|
return finalData;
|
|
}
|
|
|
|
private void MigrateGuildData(Snowflake guildId, string newPath)
|
|
{
|
|
var oldPath = $"{guildId}";
|
|
|
|
if (Directory.Exists(oldPath))
|
|
{
|
|
Directory.CreateDirectory($"{newPath}/..");
|
|
Directory.Move(oldPath, newPath);
|
|
|
|
_logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath,
|
|
newPath);
|
|
}
|
|
}
|
|
|
|
public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default)
|
|
{
|
|
return (await GetData(guildId, ct)).Settings;
|
|
}
|
|
|
|
public ICollection<Snowflake> GetGuildIds()
|
|
{
|
|
return _datas.Keys;
|
|
}
|
|
|
|
public bool UnloadGuildData(Snowflake id)
|
|
{
|
|
return _datas.TryRemove(id, out _);
|
|
}
|
|
}
|