161 lines
6.6 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace CompatBot.Database.Providers;
internal static class StatsStorage
{
private static readonly TimeSpan CacheTime = TimeSpan.FromDays(1);
private static readonly MemoryCache CmdStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) });
private static readonly MemoryCache ExplainStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) });
private static readonly MemoryCache GameStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) });
private const char PrefixSeparator = '\0';
private static readonly SemaphoreSlim Barrier = new(1, 1);
private static readonly SemaphoreSlim BucketLock = new(1, 1);
private static readonly (string name, MemoryCache cache)[] AllCaches =
[
(nameof(CmdStatCache), CmdStatCache),
(nameof(ExplainStatCache), ExplainStatCache),
(nameof(GameStatCache), GameStatCache),
];
private static ((int y, int m, int d, int h) Key, string Value) bucketPrefix = ((0, 0, 0, 0), "");
private static string Prefix
{
get
{
var ts = DateTime.UtcNow;
var key = (ts.Year, ts.Month, ts.Day, ts.Hour);
if (bucketPrefix.Key == key)
return bucketPrefix.Value;
if (!BucketLock.Wait(0))
return bucketPrefix.Value;
bucketPrefix = (key, ts.ToString("yyyyMMddHH") + PrefixSeparator);
BucketLock.Release();
return bucketPrefix.Value;
}
}
public static void IncCmdStat(string qualifiedName) => IncStat(qualifiedName, CmdStatCache);
public static void IncExplainStat(string term) => IncStat(term, ExplainStatCache);
public static void IncGameStat(string title) => IncStat(title, GameStatCache);
private static void IncStat(string key, MemoryCache cache)
{
var bucketKey = Prefix + key;
cache.TryGetValue(bucketKey, out int stat);
cache.Set(bucketKey, ++stat, CacheTime);
}
public static List<(string name, int stat)> GetCmdStats() => GetStats(CmdStatCache);
public static List<(string name, int stat)> GetExplainStats() => GetStats(ExplainStatCache);
public static List<(string name, int stat)> GetGameStats() => GetStats(GameStatCache);
private static List<(string name, int stat)> GetStats(MemoryCache cache)
{
return cache.GetCacheKeys<string>()
.Select(c => (name: c.Split(PrefixSeparator, 2)[^1], stat: cache.Get(c) as int?))
.Where(s => s.stat.HasValue)
.GroupBy(s => s.name)
.Select(g => (name: g.Key, stat: (int)g.Sum(s => s.stat)!))
.OrderByDescending(s => s.stat)
.ToList();
}
public static async ValueTask SaveAsync(bool wait = false)
{
if (await Barrier.WaitAsync(0).ConfigureAwait(false))
{
try
{
Config.Log.Debug("Got stats saving lock");
await using var wdb = await BotDb.OpenWriteAsync().ConfigureAwait(false);
foreach (var (category, cache) in AllCaches)
{
var entries = cache.GetCacheEntries<string>();
var savedKeys = new HashSet<string>();
foreach (var (key, value) in entries)
if (savedKeys.Add(key))
{
var keyParts = key.Split(PrefixSeparator, 2);
var bucket = keyParts.Length == 2 ? keyParts[0] : null;
var statKey = keyParts[^1];
var statValue = (int?)value?.Value ?? 0;
var ts = value?.AbsoluteExpiration?.ToUniversalTime().Ticks ?? 0;
var currentEntry = wdb.Stats.FirstOrDefault(e => e.Category == category && e.Bucket == bucket && e.Key == statKey);
if (currentEntry is null)
await wdb.Stats.AddAsync(new()
{
Category = category,
Bucket = bucket,
Key = statKey,
Value = statValue,
ExpirationTimestamp = ts
}).ConfigureAwait(false);
else
{
currentEntry.Value = statValue;
currentEntry.ExpirationTimestamp = ts;
}
}
else
Config.Log.Warn($"Somehow there's another '{key}' in the {category} cache");
}
await wdb.SaveChangesAsync().ConfigureAwait(false);
}
catch(Exception e)
{
Config.Log.Error(e, "Failed to save user stats");
}
finally
{
Barrier.Release();
Config.Log.Debug("Released stats saving lock");
}
}
else if (wait)
{
await Barrier.WaitAsync().ConfigureAwait(false);
Barrier.Release();
}
}
public static async ValueTask RestoreAsync()
{
var now = DateTime.UtcNow;
await using var wdb = await BotDb.OpenWriteAsync().ConfigureAwait(false);
foreach (var (category, cache) in AllCaches)
{
var entries = await wdb.Stats.Where(e => e.Category == category).ToListAsync().ConfigureAwait(false);
foreach (var entry in entries)
{
var time = entry.ExpirationTimestamp.AsUtc();
if (time > now)
{
var key = entry.Key;
if (entry.Bucket is { Length: > 0 } bucket)
key = bucket + PrefixSeparator + key;
cache.Set(key, entry.Value, time);
}
else
{
wdb.Stats.Remove(entry);
}
}
}
await wdb.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false);
}
public static async Task BackgroundSaveAsync()
{
while (!Config.Cts.IsCancellationRequested)
{
await Task.Delay(60 * 60 * 1000, Config.Cts.Token).ConfigureAwait(false);
if (!Config.Cts.IsCancellationRequested)
await SaveAsync().ConfigureAwait(false);
}
}
}