Merge pull request #49 from NoiTheCat/dev/more-config

Add configurable runtime settings that were previously hardcoded
This commit is contained in:
Noi 2023-06-10 20:24:55 -07:00 committed by GitHub
commit fa8bb0abd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 81 additions and 54 deletions

View file

@ -13,14 +13,14 @@ class AutoUserDownload : BackgroundService {
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
// Take action if a guild's cache is incomplete... // Take action if a guild's cache is incomplete...
var incompleteCaches = ShardInstance.DiscordClient.Guilds var incompleteCaches = Shard.DiscordClient.Guilds
.Where(g => !g.HasAllMembers) .Where(g => !g.HasAllMembers)
.Select(g => g.Id) .Select(g => g.Id)
.ToHashSet(); .ToHashSet();
// ...and if the guild contains any user data // ...and if the guild contains any user data
IEnumerable<ulong> mustFetch; IEnumerable<ulong> mustFetch;
try { try {
await DbConcurrentOperationsLock.WaitAsync(token); await ConcurrentSemaphore.WaitAsync(token);
using var db = new BotDatabaseContext(); using var db = new BotDatabaseContext();
lock (_failedDownloads) lock (_failedDownloads)
mustFetch = db.UserEntries.AsNoTracking() mustFetch = db.UserEntries.AsNoTracking()
@ -31,7 +31,7 @@ class AutoUserDownload : BackgroundService {
.ToList(); .ToList();
} finally { } finally {
try { try {
DbConcurrentOperationsLock.Release(); ConcurrentSemaphore.Release();
} catch (ObjectDisposedException) { } } catch (ObjectDisposedException) { }
} }
@ -39,9 +39,9 @@ class AutoUserDownload : BackgroundService {
var processStartTime = DateTimeOffset.UtcNow; var processStartTime = DateTimeOffset.UtcNow;
foreach (var item in mustFetch) { foreach (var item in mustFetch) {
// May cause a disconnect in certain situations. Make no further attempts until the next pass if it happens. // May cause a disconnect in certain situations. Make no further attempts until the next pass if it happens.
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) break; if (Shard.DiscordClient.ConnectionState != ConnectionState.Connected) break;
var guild = ShardInstance.DiscordClient.GetGuild(item); var guild = Shard.DiscordClient.GetGuild(item);
if (guild == null) continue; // A guild disappeared...? if (guild == null) continue; // A guild disappeared...?
await Task.Delay(200, CancellationToken.None); // Delay a bit (reduces the possibility of hanging, somehow). await Task.Delay(200, CancellationToken.None); // Delay a bit (reduces the possibility of hanging, somehow).

View file

@ -1,11 +1,15 @@
namespace BirthdayBot.BackgroundServices; namespace BirthdayBot.BackgroundServices;
abstract class BackgroundService { abstract class BackgroundService {
protected static SemaphoreSlim DbConcurrentOperationsLock { get; } = new(ShardManager.MaxConcurrentOperations); protected static SemaphoreSlim ConcurrentSemaphore { get; private set; } = null!;
protected ShardInstance ShardInstance { get; }
public BackgroundService(ShardInstance instance) => ShardInstance = instance; protected ShardInstance Shard { get; }
protected void Log(string message) => ShardInstance.Log(GetType().Name, message); public BackgroundService(ShardInstance instance) {
Shard = instance;
ConcurrentSemaphore ??= new SemaphoreSlim(instance.Config.MaxConcurrentOperations);
}
protected void Log(string message) => Shard.Log(GetType().Name, message);
public abstract Task OnTick(int tickCount, CancellationToken token); public abstract Task OnTick(int tickCount, CancellationToken token);
} }

View file

@ -15,11 +15,11 @@ class BirthdayRoleUpdate : BackgroundService {
/// </summary> /// </summary>
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
try { try {
await DbConcurrentOperationsLock.WaitAsync(token); await ConcurrentSemaphore.WaitAsync(token);
await ProcessBirthdaysAsync(token); await ProcessBirthdaysAsync(token);
} finally { } finally {
try { try {
DbConcurrentOperationsLock.Release(); ConcurrentSemaphore.Release();
} catch (ObjectDisposedException) { } } catch (ObjectDisposedException) { }
} }
} }
@ -27,19 +27,19 @@ class BirthdayRoleUpdate : BackgroundService {
private async Task ProcessBirthdaysAsync(CancellationToken token) { private async Task ProcessBirthdaysAsync(CancellationToken token) {
// For database efficiency, fetch all database information at once before proceeding // For database efficiency, fetch all database information at once before proceeding
using var db = new BotDatabaseContext(); using var db = new BotDatabaseContext();
var shardGuilds = ShardInstance.DiscordClient.Guilds.Select(g => g.Id).ToHashSet(); var shardGuilds = Shard.DiscordClient.Guilds.Select(g => g.Id).ToHashSet();
var presentGuildSettings = db.GuildConfigurations.Where(s => shardGuilds.Contains(s.GuildId)); var presentGuildSettings = db.GuildConfigurations.Where(s => shardGuilds.Contains(s.GuildId));
var guildChecks = presentGuildSettings.ToList().Select(s => Tuple.Create(s.GuildId, s)); var guildChecks = presentGuildSettings.ToList().Select(s => Tuple.Create(s.GuildId, s));
var exceptions = new List<Exception>(); var exceptions = new List<Exception>();
foreach (var (guildId, settings) in guildChecks) { foreach (var (guildId, settings) in guildChecks) {
var guild = ShardInstance.DiscordClient.GetGuild(guildId); var guild = Shard.DiscordClient.GetGuild(guildId);
if (guild == null) continue; // A guild disappeared...? if (guild == null) continue; // A guild disappeared...?
// Check task cancellation here. Processing during a single guild is never interrupted. // Check task cancellation here. Processing during a single guild is never interrupted.
if (token.IsCancellationRequested) throw new TaskCanceledException(); if (token.IsCancellationRequested) throw new TaskCanceledException();
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) { if (Shard.DiscordClient.ConnectionState != ConnectionState.Connected) {
Log("Client is not connected. Stopping early."); Log("Client is not connected. Stopping early.");
return; return;
} }

View file

@ -7,24 +7,26 @@ namespace BirthdayBot.BackgroundServices;
/// Automatically removes database information for guilds that have not been accessed in a long time. /// Automatically removes database information for guilds that have not been accessed in a long time.
/// </summary> /// </summary>
class DataRetention : BackgroundService { class DataRetention : BackgroundService {
const int ProcessInterval = 5400 / ShardBackgroundWorker.Interval; // Process about once per hour and a half private readonly int ProcessInterval;
// Amount of days without updates before data is considered stale and up for deletion. // Amount of days without updates before data is considered stale and up for deletion.
const int StaleGuildThreshold = 180; const int StaleGuildThreshold = 180;
const int StaleUserThreashold = 360; const int StaleUserThreashold = 360;
public DataRetention(ShardInstance instance) : base(instance) { } public DataRetention(ShardInstance instance) : base(instance) {
ProcessInterval = 5400 / Shard.Config.BackgroundInterval; // Process about once per hour and a half
}
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
// On each tick, run only a set group of guilds, each group still processed every ProcessInterval ticks. // On each tick, run only a set group of guilds, each group still processed every ProcessInterval ticks.
if ((tickCount + ShardInstance.ShardId) % ProcessInterval != 0) return; if ((tickCount + Shard.ShardId) % ProcessInterval != 0) return;
try { try {
await DbConcurrentOperationsLock.WaitAsync(token); await ConcurrentSemaphore.WaitAsync(token);
await RemoveStaleEntriesAsync(); await RemoveStaleEntriesAsync();
} finally { } finally {
try { try {
DbConcurrentOperationsLock.Release(); ConcurrentSemaphore.Release();
} catch (ObjectDisposedException) { } } catch (ObjectDisposedException) { }
} }
} }
@ -34,14 +36,14 @@ class DataRetention : BackgroundService {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
// Update guilds // Update guilds
var localGuilds = ShardInstance.DiscordClient.Guilds.Select(g => g.Id).ToList(); var localGuilds = Shard.DiscordClient.Guilds.Select(g => g.Id).ToList();
var updatedGuilds = await db.GuildConfigurations var updatedGuilds = await db.GuildConfigurations
.Where(g => localGuilds.Contains(g.GuildId)) .Where(g => localGuilds.Contains(g.GuildId))
.ExecuteUpdateAsync(upd => upd.SetProperty(p => p.LastSeen, now)); .ExecuteUpdateAsync(upd => upd.SetProperty(p => p.LastSeen, now));
// Update guild users // Update guild users
var updatedUsers = 0; var updatedUsers = 0;
foreach (var guild in ShardInstance.DiscordClient.Guilds) { foreach (var guild in Shard.DiscordClient.Guilds) {
var localUsers = guild.Users.Select(u => u.Id).ToList(); var localUsers = guild.Users.Select(u => u.Id).ToList();
updatedUsers += await db.UserEntries updatedUsers += await db.UserEntries
.Where(gu => gu.GuildId == guild.Id) .Where(gu => gu.GuildId == guild.Id)

View file

@ -5,22 +5,25 @@ namespace BirthdayBot.BackgroundServices;
/// Reports user count statistics to external services on a shard by shard basis. /// Reports user count statistics to external services on a shard by shard basis.
/// </summary> /// </summary>
class ExternalStatisticsReporting : BackgroundService { class ExternalStatisticsReporting : BackgroundService {
const int ProcessInterval = 1200 / ShardBackgroundWorker.Interval; // Process every ~20 minutes readonly int ProcessInterval;
const int ProcessOffset = 300 / ShardBackgroundWorker.Interval; // Begin processing ~5 minutes after shard start readonly int ProcessOffset;
private static readonly HttpClient _httpClient = new(); private static readonly HttpClient _httpClient = new();
public ExternalStatisticsReporting(ShardInstance instance) : base(instance) { } public ExternalStatisticsReporting(ShardInstance instance) : base(instance) {
ProcessInterval = 1200 / Shard.Config.BackgroundInterval; // Process every ~20 minutes
ProcessOffset = 300 / Shard.Config.BackgroundInterval; // No processing until ~5 minutes after shard start
}
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
if (tickCount < ProcessOffset) return; if (tickCount < ProcessOffset) return;
if (tickCount % ProcessInterval != 0) return; if (tickCount % ProcessInterval != 0) return;
var botId = ShardInstance.DiscordClient.CurrentUser.Id; var botId = Shard.DiscordClient.CurrentUser.Id;
if (botId == 0) return; if (botId == 0) return;
var count = ShardInstance.DiscordClient.Guilds.Count; var count = Shard.DiscordClient.Guilds.Count;
var dbotsToken = ShardInstance.Config.DBotsToken; var dbotsToken = Shard.Config.DBotsToken;
if (dbotsToken != null) await SendDiscordBots(dbotsToken, count, botId, token); if (dbotsToken != null) await SendDiscordBots(dbotsToken, count, botId, token);
} }
@ -33,7 +36,7 @@ class ExternalStatisticsReporting : BackgroundService {
var post = new HttpRequestMessage(HttpMethod.Post, uri); var post = new HttpRequestMessage(HttpMethod.Post, uri);
post.Headers.Add("Authorization", apiToken); post.Headers.Add("Authorization", apiToken);
post.Content = new StringContent(string.Format(Body, post.Content = new StringContent(string.Format(Body,
userCount, ShardInstance.Config.ShardTotal, ShardInstance.ShardId), userCount, Shard.Config.ShardTotal, Shard.ShardId),
Encoding.UTF8, "application/json"); Encoding.UTF8, "application/json");
await _httpClient.SendAsync(post, token); await _httpClient.SendAsync(post, token);

View file

@ -6,7 +6,7 @@ class ShardBackgroundWorker : IDisposable {
/// <summary> /// <summary>
/// The interval, in seconds, in which background tasks are attempted to be run within a shard. /// The interval, in seconds, in which background tasks are attempted to be run within a shard.
/// </summary> /// </summary>
public const int Interval = 40; private int Interval { get; }
private readonly Task _workerTask; private readonly Task _workerTask;
private readonly CancellationTokenSource _workerCanceller; private readonly CancellationTokenSource _workerCanceller;
@ -20,6 +20,7 @@ class ShardBackgroundWorker : IDisposable {
public ShardBackgroundWorker(ShardInstance instance) { public ShardBackgroundWorker(ShardInstance instance) {
Instance = instance; Instance = instance;
Interval = instance.Config.BackgroundInterval;
_workerCanceller = new CancellationTokenSource(); _workerCanceller = new CancellationTokenSource();
_workers = new List<BackgroundService>() _workers = new List<BackgroundService>()

View file

@ -25,6 +25,25 @@ class Configuration {
public string SqlPassword { get; } public string SqlPassword { get; }
internal string SqlApplicationName { get; } internal string SqlApplicationName { get; }
/// <summary>
/// Number of seconds between each time the status task runs, in seconds.
/// </summary>
public int StatusInterval { get; }
/// <summary>
/// Number of concurrent shard startups to happen on each check.
/// This value also determines the maximum amount of concurrent background database operations.
/// </summary>
public int MaxConcurrentOperations { get; }
/// <summary>
/// Amount of time to wait between background task runs within each shard.
/// </summary>
public int BackgroundInterval { get; }
/// <summary>
/// Gets whether to show common connect/disconnect events and other related messages.
/// This is disabled in the public instance, but it's worth keeping enabled in self-hosted bots.
/// </summary>
public bool LogConnectionStatus { get; }
public Configuration() { public Configuration() {
var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs()); var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs());
var path = args?.ConfigFile ?? Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) var path = args?.ConfigFile ?? Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)
@ -71,6 +90,11 @@ class Configuration {
SqlUsername = ReadConfKey<string>(jc, nameof(SqlUsername), true); SqlUsername = ReadConfKey<string>(jc, nameof(SqlUsername), true);
SqlPassword = ReadConfKey<string>(jc, nameof(SqlPassword), true); SqlPassword = ReadConfKey<string>(jc, nameof(SqlPassword), true);
SqlApplicationName = $"Shard{ShardStart:00}-{ShardStart + ShardAmount - 1:00}"; SqlApplicationName = $"Shard{ShardStart:00}-{ShardStart + ShardAmount - 1:00}";
StatusInterval = ReadConfKey<int?>(jc, nameof(StatusInterval), false) ?? 90;
MaxConcurrentOperations = ReadConfKey<int?>(jc, nameof(MaxConcurrentOperations), false) ?? 4;
BackgroundInterval = ReadConfKey<int?>(jc, nameof(BackgroundInterval), false) ?? 60;
LogConnectionStatus = ReadConfKey<bool?>(jc, nameof(LogConnectionStatus), false) ?? true;
} }
private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) { private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) {

View file

@ -72,23 +72,27 @@ public sealed class ShardInstance : IDisposable {
private Task Client_Log(LogMessage arg) { private Task Client_Log(LogMessage arg) {
// Suppress certain messages // Suppress certain messages
if (arg.Message != null) { if (arg.Message != null) {
switch (arg.Message) { if (!_manager.Config.LogConnectionStatus) {
case "Connecting": switch (arg.Message) {
case "Connected": case "Connecting":
case "Ready": case "Connected":
case "Disconnecting": case "Ready":
case "Disconnected": case "Disconnecting":
case "Resumed previous session": case "Disconnected":
case "Failed to resume previous session": case "Resumed previous session":
case "Serializer Error": // The exception associated with this log appears a lot as of v3.2-ish case "Failed to resume previous session":
return Task.CompletedTask; case "Serializer Error": // The exception associated with this log appears a lot as of v3.2-ish
return Task.CompletedTask;
}
} }
Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); Log("Discord.Net", $"{arg.Severity}: {arg.Message}");
} }
if (arg.Exception != null) { if (arg.Exception != null) {
if (arg.Exception is GatewayReconnectException if (!_manager.Config.LogConnectionStatus) {
|| arg.Exception.Message == "WebSocket connection was closed") return Task.CompletedTask; if (arg.Exception is GatewayReconnectException || arg.Exception.Message == "WebSocket connection was closed")
return Task.CompletedTask;
}
Log("Discord.Net exception", arg.Exception.ToString()); Log("Discord.Net exception", arg.Exception.ToString());
} }

View file

@ -10,17 +10,6 @@ namespace BirthdayBot;
/// status reports regarding the overall health of the application. /// status reports regarding the overall health of the application.
/// </summary> /// </summary>
class ShardManager : IDisposable { class ShardManager : IDisposable {
/// <summary>
/// Number of seconds between each time the status task runs, in seconds.
/// </summary>
private const int StatusInterval = 90;
/// <summary>
/// Number of concurrent shard startups to happen on each check.
/// This value also determines the maximum amount of concurrent background database operations.
/// </summary>
public const int MaxConcurrentOperations = 4;
/// <summary> /// <summary>
/// Amount of time without a completed background service run before a shard instance /// Amount of time without a completed background service run before a shard instance
/// is considered "dead" and tasked to be removed. /// is considered "dead" and tasked to be removed.
@ -158,14 +147,14 @@ class ShardManager : IDisposable {
} }
// Start null shards, a few at at time // Start null shards, a few at at time
var startAllowance = MaxConcurrentOperations; var startAllowance = Config.MaxConcurrentOperations;
foreach (var id in nullShards) { foreach (var id in nullShards) {
if (startAllowance-- > 0) { if (startAllowance-- > 0) {
_shards[id] = await InitializeShard(id); _shards[id] = await InitializeShard(id);
} else break; } else break;
} }
await Task.Delay(StatusInterval * 1000, _mainCancel.Token); await Task.Delay(Config.StatusInterval * 1000, _mainCancel.Token);
} }
} catch (TaskCanceledException) { } } catch (TaskCanceledException) { }
} }