diff --git a/Program.cs b/Program.cs
index 2260102..d6d32bc 100644
--- a/Program.cs
+++ b/Program.cs
@@ -2,57 +2,54 @@
using System;
using System.Threading.Tasks;
-namespace BirthdayBot
-{
- class Program
- {
- private static ShardManager _bot;
- public static DateTimeOffset BotStartTime { get; private set; }
+namespace BirthdayBot;
- static async Task Main()
- {
- BotStartTime = DateTimeOffset.UtcNow;
- var cfg = new Configuration();
+class Program {
+ private static ShardManager _bot;
+ public static DateTimeOffset BotStartTime { get; } = DateTimeOffset.UtcNow;
+ static async Task Main() {
+ var cfg = new Configuration();
+ try {
await Database.DoInitialDatabaseSetupAsync();
-
- Console.CancelKeyPress += OnCancelKeyPressed;
- _bot = new ShardManager(cfg);
-
- await Task.Delay(-1);
+ } catch (Npgsql.NpgsqlException e) {
+ Console.WriteLine("Error when attempting to connect to database: " + e.Message);
+ Environment.Exit(1);
}
+
+ Console.CancelKeyPress += OnCancelKeyPressed;
+ _bot = new ShardManager(cfg);
- ///
- /// Sends a formatted message to console.
- ///
- public static void Log(string source, string message)
- {
- var ts = DateTime.UtcNow;
- var ls = new string[]{ "\r\n", "\n" };
- foreach (var item in message.Split(ls, StringSplitOptions.None))
- Console.WriteLine($"{ts:u} [{source}] {item}");
- }
-
- private static void OnCancelKeyPressed(object sender, ConsoleCancelEventArgs e)
- {
- e.Cancel = true;
- Log("Shutdown", "Captured cancel key; sending shutdown.");
- ProgramStop();
- }
-
- private static bool _stopping = false;
- public static void ProgramStop()
- {
- if (_stopping) return;
- _stopping = true;
- Log("Shutdown", "Commencing shutdown...");
-
- var dispose = Task.Run(_bot.Dispose);
- if (!dispose.Wait(90000))
- {
- Log("Shutdown", "Normal shutdown has not concluded after 90 seconds. Will force quit.");
- }
- Environment.Exit(0);
+ await Task.Delay(-1);
+ }
+
+ ///
+ /// Sends a formatted message to console.
+ ///
+ public static void Log(string source, string message) {
+ var ts = DateTime.UtcNow;
+ var ls = new string[] { "\r\n", "\n" };
+ foreach (var item in message.Split(ls, StringSplitOptions.None))
+ Console.WriteLine($"{ts:u} [{source}] {item}");
+ }
+
+ private static void OnCancelKeyPressed(object sender, ConsoleCancelEventArgs e) {
+ e.Cancel = true;
+ Log("Shutdown", "Captured cancel key; sending shutdown.");
+ ProgramStop();
+ }
+
+ private static bool _stopping = false;
+ public static void ProgramStop() {
+ if (_stopping) return;
+ _stopping = true;
+ Log("Shutdown", "Commencing shutdown...");
+
+ var dispose = Task.Run(_bot.Dispose);
+ if (!dispose.Wait(90000)) {
+ Log("Shutdown", "Normal shutdown has not concluded after 90 seconds. Will force quit.");
+ Environment.ExitCode += 0x200;
}
+ Environment.Exit(Environment.ExitCode);
}
}
diff --git a/ShardManager.cs b/ShardManager.cs
index 55ef11e..b1e3a2d 100644
--- a/ShardManager.cs
+++ b/ShardManager.cs
@@ -10,246 +10,226 @@ using System.Threading;
using System.Threading.Tasks;
using static BirthdayBot.UserInterface.CommandsCommon;
-namespace BirthdayBot
-{
+namespace BirthdayBot;
+
+///
+/// More or less the main class for the program. Handles individual shards and provides frequent
+/// status reports regarding the overall health of the application.
+///
+class ShardManager : IDisposable {
///
- /// The highest level part of this bot:
- /// Starts up, looks over, and manages shard instances while containing common resources
- /// and providing common functions for all existing shards.
+ /// Number of seconds between each time the status task runs, in seconds.
///
- class ShardManager : IDisposable
- {
- ///
- /// Number of seconds between each time the manager's watchdog task runs, in seconds.
- ///
- private const int WatchdogInterval = 90;
+ private const int StatusInterval = 90;
- ///
- /// Number of shards allowed to be destroyed before forcing the program to close.
- ///
- private const int MaxDestroyedShards = 10; // TODO make configurable
+ ///
+ /// Number of shards allowed to be destroyed before forcing the program to close.
+ ///
+ private const int MaxDestroyedShards = 10; // TODO make configurable
- ///
- /// Number of concurrent shard startups to happen on each check.
- /// This value is also used in .
- ///
- public const int MaxConcurrentOperations = 5;
+ ///
+ /// Number of concurrent shard startups to happen on each check.
+ /// This value is also used in .
+ ///
+ public const int MaxConcurrentOperations = 5;
- ///
- /// Amount of time without a completed background service run before a shard instance
- /// is considered "dead" and tasked to be removed.
- ///
- private static readonly TimeSpan DeadShardThreshold = new(0, 20, 0);
+ ///
+ /// Amount of time without a completed background service run before a shard instance
+ /// is considered "dead" and tasked to be removed. A fraction of this value is also used
+ /// to determine when a shard is "slow".
+ ///
+ private static readonly TimeSpan DeadShardThreshold = new(0, 20, 0);
- ///
- /// A dictionary with shard IDs as its keys and shard instances as its values.
- /// When initialized, all keys will be created as configured. If an instance is removed,
- /// a key's corresponding value will temporarily become null instead of the key/value
- /// pair being removed.
- ///
- private readonly Dictionary _shards;
+ ///
+ /// A dictionary with shard IDs as its keys and shard instances as its values.
+ /// When initialized, all keys will be created as configured. If an instance is removed,
+ /// a key's corresponding value will temporarily become null instead of the key/value
+ /// pair being removed.
+ ///
+ private readonly Dictionary _shards;
- // Commonly used command handler instances
- private readonly Dictionary _dispatchCommands;
- private readonly UserCommands _cmdsUser;
- private readonly ListingCommands _cmdsListing;
- private readonly HelpInfoCommands _cmdsHelp;
- private readonly ManagerCommands _cmdsMods;
+ private readonly Dictionary _dispatchCommands;
+ private readonly UserCommands _cmdsUser;
+ private readonly ListingCommands _cmdsListing;
+ private readonly HelpInfoCommands _cmdsHelp;
+ private readonly ManagerCommands _cmdsMods;
- // Watchdog stuff
- private readonly Task _watchdogTask;
- private readonly CancellationTokenSource _watchdogCancel;
- private int _destroyedShards = 0;
-
- internal Configuration Config { get; }
+ private readonly Task _statusTask;
+ private readonly CancellationTokenSource _mainCancel;
+ private int _destroyedShards = 0;
- public ShardManager(Configuration cfg)
- {
- var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
- Log($"Birthday Bot v{ver.ToString(3)} is starting...");
+ internal Configuration Config { get; }
- Config = cfg;
+ public ShardManager(Configuration cfg) {
+ var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
+ Log($"Birthday Bot v{ver!.ToString(3)} is starting...");
- // Command handler setup
- _dispatchCommands = new Dictionary(StringComparer.OrdinalIgnoreCase);
- _cmdsUser = new UserCommands(cfg);
- foreach (var item in _cmdsUser.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
- _cmdsListing = new ListingCommands(cfg);
- foreach (var item in _cmdsListing.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
- _cmdsHelp = new HelpInfoCommands(cfg);
- foreach (var item in _cmdsHelp.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
- _cmdsMods = new ManagerCommands(cfg, _cmdsUser.Commands);
- foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
+ Config = cfg;
- _shards = new Dictionary();
- // Create only the specified shards as needed by this instance
- for (int i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++)
- {
- _shards.Add(i, null);
- }
+ // Command handler setup
+ _dispatchCommands = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _cmdsUser = new UserCommands(cfg);
+ foreach (var item in _cmdsUser.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
+ _cmdsListing = new ListingCommands(cfg);
+ foreach (var item in _cmdsListing.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
+ _cmdsHelp = new HelpInfoCommands(cfg);
+ foreach (var item in _cmdsHelp.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
+ _cmdsMods = new ManagerCommands(cfg, _cmdsUser.Commands);
+ foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
- // Start watchdog
- _watchdogCancel = new CancellationTokenSource();
- _watchdogTask = Task.Factory.StartNew(WatchdogLoop, _watchdogCancel.Token,
- TaskCreationOptions.LongRunning, TaskScheduler.Default);
+ // Allocate shards based on configuration
+ _shards = new Dictionary();
+ for (int i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) {
+ _shards.Add(i, null);
}
- public void Dispose()
- {
- _watchdogCancel.Cancel();
- _watchdogTask.Wait(5000);
- if (!_watchdogTask.IsCompleted)
- Log("Warning: Shard status watcher has not ended in time. Continuing...");
-
- Log("Shutting down all shards...");
- var shardDisposes = new List();
- foreach (var item in _shards)
- {
- if (item.Value == null) continue;
- shardDisposes.Add(Task.Run(item.Value.Dispose));
- }
- if (!Task.WhenAll(shardDisposes).Wait(60000))
- {
- Log("Warning: All shards did not properly stop after 60 seconds. Continuing...");
- }
-
- Log($"Shutdown complete. Bot uptime: {Common.BotUptime}");
- }
-
- private void Log(string message) => Program.Log(nameof(ShardManager), message);
-
- ///
- /// Creates and sets up a new shard instance.
- ///
- private async Task InitializeShard(int shardId)
- {
- ShardInstance newInstance;
-
- var clientConf = new DiscordSocketConfig()
- {
- ShardId = shardId,
- TotalShards = Config.ShardTotal,
- LogLevel = LogSeverity.Info,
- DefaultRetryMode = RetryMode.RetryRatelimit,
- MessageCacheSize = 0, // not needed at all
- GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
- };
- var newClient = new DiscordSocketClient(clientConf);
- newInstance = new ShardInstance(this, newClient, _dispatchCommands);
- await newInstance.StartAsync().ConfigureAwait(false);
-
- return newInstance;
- }
-
- private async Task WatchdogLoop()
- {
- try
- {
- while (!_watchdogCancel.IsCancellationRequested)
- {
- Log($"Bot uptime: {Common.BotUptime}");
-
- // Iterate through shard list, extract data
- var guildInfo = new Dictionary();
- var now = DateTimeOffset.UtcNow;
- var nullShards = new List();
- foreach (var item in _shards)
- {
- if (item.Value == null)
- {
- nullShards.Add(item.Key);
- continue;
- }
- var shard = item.Value;
-
- var guildCount = shard.DiscordClient.Guilds.Count;
- var lastRun = now - shard.LastBackgroundRun;
- var lastExec = shard.CurrentExecutingService ?? "null";
-
- guildInfo[item.Key] = (guildCount, lastRun, lastExec);
- }
-
- // Process info
- var guildCounts = guildInfo.Select(i => i.Value.Item1);
- var guildTotal = guildCounts.Sum();
- var guildAverage = guildCounts.Any() ? guildCounts.Average() : 0;
- Log($"Currently in {guildTotal} guilds. Average shard load: {guildAverage:0.0}.");
-
- // Health report
- var goodShards = new List();
- var badShards = new List(); // shards with low connection score OR long time since last work
- var deadShards = new List(); // shards to destroy and reinitialize
- foreach (var item in guildInfo)
- {
- var lastRun = item.Value.Item2;
-
- if (lastRun > DeadShardThreshold / 3)
- {
- badShards.Add(item.Key);
-
- // Consider a shard dead after a long span without background activity
- if (lastRun > DeadShardThreshold)
- deadShards.Add(item.Key);
- }
- else
- {
- goodShards.Add(item.Key);
- }
- }
- string statusDisplay(IEnumerable list, bool detailedInfo)
- {
- if (!list.Any()) return "--";
- var result = new StringBuilder();
- foreach (var item in list)
- {
- result.Append(item.ToString("00") + " ");
- if (detailedInfo)
- {
- result.Remove(result.Length - 1, 1);
- result.Append($"[{Math.Floor(guildInfo[item].Item2.TotalSeconds):000}s");
- result.Append($" {guildInfo[item].Item3}] ");
- }
- }
- if (result.Length > 0) result.Remove(result.Length - 1, 1);
- return result.ToString();
- }
- Log("Stable shards: " + statusDisplay(goodShards, false));
- if (badShards.Count > 0) Log("Unstable shards: " + statusDisplay(badShards, true));
- if (deadShards.Count > 0) Log("Shards to be restarted: " + statusDisplay(deadShards, false));
- if (nullShards.Count > 0) Log("Inactive shards: " + statusDisplay(nullShards, false));
-
- // Remove dead shards
- foreach (var dead in deadShards) {
- // TODO investigate - has this been hanging here?
- _shards[dead].Dispose();
- _shards[dead] = null;
- _destroyedShards++;
- }
- if (Config.QuitOnFails && _destroyedShards > MaxDestroyedShards)
- {
- Program.ProgramStop();
- }
- else
- {
- // Start up any missing shards
- int startAllowance = MaxConcurrentOperations;
- foreach (var id in nullShards)
- {
- // To avoid possible issues with resources strained over so many shards starting at once,
- // initialization is spread out by only starting a few at a time.
- if (startAllowance-- > 0)
- {
- _shards[id] = await InitializeShard(id).ConfigureAwait(false);
- }
- else break;
- }
- }
-
- // All done for now
- await Task.Delay(WatchdogInterval * 1000, _watchdogCancel.Token).ConfigureAwait(false);
- }
- }
- catch (TaskCanceledException) { }
- }
+ // Start status reporting thread
+ _mainCancel = new CancellationTokenSource();
+ _statusTask = Task.Factory.StartNew(StatusLoop, _mainCancel.Token,
+ TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
+
+ public void Dispose() {
+ _mainCancel.Cancel();
+ _statusTask.Wait(10000);
+ if (!_statusTask.IsCompleted)
+ Log("Warning: Main thread did not cleanly finish up in time. Continuing...");
+
+ Log("Shutting down all shards...");
+ var shardDisposes = new List();
+ foreach (var item in _shards) {
+ if (item.Value == null) continue;
+ shardDisposes.Add(Task.Run(item.Value.Dispose));
+ }
+ if (!Task.WhenAll(shardDisposes).Wait(30000)) {
+ Log("Warning: Not all shards terminated cleanly after 30 seconds. Continuing...");
+ }
+
+ Log($"Uptime: {Common.BotUptime}");
+ }
+
+ private void Log(string message) => Program.Log(nameof(ShardManager), message);
+
+ ///
+ /// Creates and sets up a new shard instance.
+ ///
+ private async Task InitializeShard(int shardId) {
+ ShardInstance newInstance;
+
+ var clientConf = new DiscordSocketConfig() {
+ ShardId = shardId,
+ TotalShards = Config.ShardTotal,
+ LogLevel = LogSeverity.Info,
+ DefaultRetryMode = RetryMode.RetryRatelimit,
+ MessageCacheSize = 0, // not needed at all
+ GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
+ };
+ var newClient = new DiscordSocketClient(clientConf);
+ newInstance = new ShardInstance(this, newClient, _dispatchCommands);
+ await newInstance.StartAsync().ConfigureAwait(false);
+
+ return newInstance;
+ }
+
+ #region Status checking and display
+ private struct GuildStatusData {
+ public int GuildCount;
+ public TimeSpan LastTaskRunTime;
+ public string? ExecutingTask;
+ }
+
+ private string StatusDisplay(IEnumerable guildList, Dictionary guildInfo, bool showDetail) {
+ if (!guildList.Any()) return "--";
+ var result = new StringBuilder();
+ foreach (var item in guildList) {
+ result.Append(item.ToString("00") + " ");
+ if (showDetail) {
+ result.Remove(result.Length - 1, 1);
+ result.Append($"[{Math.Floor(guildInfo[item].LastTaskRunTime.TotalSeconds):000}s");
+ if (guildInfo[item].ExecutingTask != null)
+ result.Append($" {guildInfo[item].ExecutingTask}");
+ result.Append("] ");
+ }
+ }
+ if (result.Length > 0) result.Remove(result.Length - 1, 1);
+ return result.ToString();
+ }
+
+ private async Task StatusLoop() {
+ try {
+ while (!_mainCancel.IsCancellationRequested) {
+ Log($"Bot uptime: {Common.BotUptime}");
+
+ // Iterate through shard list, extract data
+ var guildInfo = new Dictionary();
+ var now = DateTimeOffset.UtcNow;
+ var nullShards = new List();
+ foreach (var item in _shards) {
+ if (item.Value == null) {
+ nullShards.Add(item.Key);
+ continue;
+ }
+ var shard = item.Value;
+
+ guildInfo[item.Key] = new GuildStatusData {
+ GuildCount = shard.DiscordClient.Guilds.Count,
+ LastTaskRunTime = now - shard.LastBackgroundRun,
+ ExecutingTask = shard.CurrentExecutingService
+ };
+ }
+
+ // Process info
+ var guildCounts = guildInfo.Select(i => i.Value.GuildCount);
+ var guildTotal = guildCounts.Sum();
+ var guildAverage = guildCounts.Any() ? guildCounts.Average() : 0;
+ Log($"Currently in {guildTotal} guilds. Average shard load: {guildAverage:0.0}.");
+
+ // Health report
+ var goodShards = new List();
+ var badShards = new List(); // shards with low connection score OR long time since last work
+ var deadShards = new List(); // shards to destroy and reinitialize
+ foreach (var item in guildInfo) {
+ var lastRun = item.Value.LastTaskRunTime;
+
+ if (lastRun > DeadShardThreshold / 3) {
+ badShards.Add(item.Key);
+
+ // Consider a shard dead after a long span without background activity
+ if (lastRun > DeadShardThreshold)
+ deadShards.Add(item.Key);
+ } else {
+ goodShards.Add(item.Key);
+ }
+ }
+ Log("Online: " + StatusDisplay(goodShards, guildInfo, false));
+ if (badShards.Count > 0) Log("Slow: " + StatusDisplay(badShards, guildInfo, true));
+ if (deadShards.Count > 0) Log("Dead: " + StatusDisplay(deadShards, guildInfo, false));
+ if (nullShards.Count > 0) Log("Offline: " + StatusDisplay(nullShards, guildInfo, false));
+
+ // Remove dead shards
+ foreach (var dead in deadShards) {
+ _shards[dead].Dispose();
+ _shards[dead] = null;
+ _destroyedShards++;
+ }
+ if (Config.QuitOnFails && _destroyedShards > MaxDestroyedShards) {
+ Environment.ExitCode = 0x04;
+ Program.ProgramStop();
+ } else {
+ // Start up any missing shards
+ int startAllowance = MaxConcurrentOperations;
+ foreach (var id in nullShards) {
+ // To avoid possible issues with resources strained over so many shards starting at once,
+ // initialization is spread out by only starting a few at a time.
+ if (startAllowance-- > 0) {
+ _shards[id] = await InitializeShard(id).ConfigureAwait(false);
+ } else break;
+ }
+ }
+
+ await Task.Delay(StatusInterval * 1000, _mainCancel.Token).ConfigureAwait(false);
+ }
+ } catch (TaskCanceledException) { }
+ }
+ #endregion
}