Update services to use EF queries

This commit is contained in:
Noi 2022-03-21 12:11:30 -07:00
parent a88797cb0a
commit c280904cb8
6 changed files with 104 additions and 178 deletions

View file

@ -9,29 +9,21 @@ class AutoUserDownload : BackgroundService {
public AutoUserDownload(ShardInstance instance) : base(instance) { } public AutoUserDownload(ShardInstance instance) : base(instance) { }
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
foreach (var guild in ShardInstance.DiscordClient.Guilds) { using var db = new BotDatabaseContext();
// Has the potential to disconnect while in the middle of processing.
// Take action if a guild's cache is incomplete...
var incompleteCaches = ShardInstance.DiscordClient.Guilds.Where(g => !g.HasAllMembers).Select(g => (long)g.Id).ToHashSet();
// ...and if the guild contains any user data
var mustFetch = db.UserEntries.Where(e => incompleteCaches.Contains(e.GuildId)).Select(e => e.GuildId).Distinct();
foreach (var item in mustFetch) {
// May cause a disconnect in certain situations. Cancel all further attempts until the next pass if it happens.
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return; if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return;
// Determine if there is action to be taken... var guild = ShardInstance.DiscordClient.GetGuild((ulong)item);
if (!guild.HasAllMembers && await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) { if (guild == null) continue; // A guild disappeared...?
await guild.DownloadUsersAsync().ConfigureAwait(false); // This is already on a separate thread; no need to Task.Run await guild.DownloadUsersAsync().ConfigureAwait(false); // We're already on a seperate thread, no need to use Task.Run
await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang... await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang...
}
} }
} }
/// <summary>
/// Determines if the user database contains any entries corresponding to this guild.
/// </summary>
/// <returns>True if any entries exist.</returns>
private static async Task<bool> GuildUserAnyAsync(ulong guildId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"select true from {GuildUserConfiguration.BackingTable} where guild_id = @Gid limit 1";
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guildId;
await c.PrepareAsync(CancellationToken.None).ConfigureAwait(false);
using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false);
return r.Read();
}
} }

View file

@ -1,7 +1,4 @@
using System.Threading; namespace BirthdayBot.BackgroundServices;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices;
abstract class BackgroundService { abstract class BackgroundService {
protected ShardInstance ShardInstance { get; } protected ShardInstance ShardInstance { get; }

View file

@ -15,62 +15,51 @@ class BirthdayRoleUpdate : BackgroundService {
/// Processes birthday updates for all available guilds synchronously. /// Processes birthday updates for all available guilds synchronously.
/// </summary> /// </summary>
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
var exs = new List<Exception>(); // For database efficiency, fetch all database information at once before proceeding
foreach (var guild in ShardInstance.DiscordClient.Guilds) { // and combine it into the guild IDs that will be processed
using var db = new BotDatabaseContext();
var shardGuilds = ShardInstance.DiscordClient.Guilds.Select(g => (long)g.Id).ToHashSet();
var settings = db.GuildConfigurations.Where(s => shardGuilds.Contains(s.GuildId));
var guildChecks = shardGuilds.Join(settings, o => o, i => i.GuildId, (id, conf) => new { Key = (ulong)id, Value = conf });
var exceptions = new List<Exception>();
foreach (var pair in guildChecks) {
var guild = ShardInstance.DiscordClient.GetGuild(pair.Key);
if (guild == null) continue; // A guild disappeared...?
var guildConf = pair.Value;
// Check task cancellation here. Processing during a single guild is never interrupted.
if (token.IsCancellationRequested) throw new TaskCanceledException();
if (ShardInstance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected) { if (ShardInstance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected) {
Log("Client is not connected. Stopping early."); Log("Client is not connected. Stopping early.");
return; return;
} }
// Check task cancellation here. Processing during a single guild is never interrupted.
if (token.IsCancellationRequested) throw new TaskCanceledException();
try { try {
await ProcessGuildAsync(guild).ConfigureAwait(false); // Verify that role settings and permissions are usable
SocketRole? role = guild.GetRole((ulong)(guildConf.RoleId ?? 0));
if (role == null || !guild.CurrentUser.GuildPermissions.ManageRoles || role.Position >= guild.CurrentUser.Hierarchy) return;
// Load up user configs and begin processing birthdays
await db.Entry(guildConf).Collection(t => t.UserEntries).LoadAsync(CancellationToken.None);
var birthdays = GetGuildCurrentBirthdays(guildConf.UserEntries, guildConf.TimeZone);
// Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply.
// Update roles as appropriate
var announcementList = await UpdateGuildBirthdayRoles(guild, role, birthdays);
// Birthday announcement
var channel = guild.GetTextChannel((ulong)(guildConf.ChannelAnnounceId ?? 0));
if (announcementList.Any()) {
await AnnounceBirthdaysAsync(guildConf, channel, announcementList);
}
} catch (Exception ex) { } catch (Exception ex) {
// Catch all exceptions per-guild but continue processing, throw at end. // Catch all exceptions per-guild but continue processing, throw at end.
exs.Add(ex); exceptions.Add(ex);
} }
} }
if (exs.Count != 0) throw new AggregateException(exs); if (exceptions.Count != 0) throw new AggregateException(exceptions);
}
/// <summary>
/// Main method where actual guild processing occurs.
/// </summary>
private static async Task ProcessGuildAsync(SocketGuild guild) {
// Load guild information - stop if local cache is unavailable.
if (!Common.HasMostMembersDownloaded(guild)) return;
var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
if (gc == null) return;
// Check if role settings are correct before continuing with further processing
SocketRole? role = guild.GetRole(gc.RoleId ?? 0);
if (role == null || !guild.CurrentUser.GuildPermissions.ManageRoles || role.Position >= guild.CurrentUser.Hierarchy) return;
// Determine who's currently having a birthday
var users = await GuildUserConfiguration.LoadAllAsync(guild.Id).ConfigureAwait(false);
var tz = gc.TimeZone;
var birthdays = GetGuildCurrentBirthdays(users, tz);
// Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply.
IEnumerable<SocketGuildUser> announcementList;
// Update roles as appropriate
try {
var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false);
announcementList = updateResult.Item1;
} catch (Discord.Net.HttpException) {
return;
}
// Birthday announcement
var announce = gc.AnnounceMessages;
var announceping = gc.AnnouncePing;
SocketTextChannel? channel = null;
if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value);
if (announcementList.Any()) {
await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList).ConfigureAwait(false);
}
} }
/// <summary> /// <summary>
@ -82,7 +71,8 @@ class BirthdayRoleUpdate : BackgroundService {
#pragma warning restore 618 #pragma warning restore 618
public static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string? defaultTzStr) { public static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string? defaultTzStr) {
var tzdb = DateTimeZoneProviders.Tzdb; var tzdb = DateTimeZoneProviders.Tzdb;
DateTimeZone defaultTz = (defaultTzStr != null ? DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr) : null) ?? tzdb.GetZoneOrNull("UTC")!; DateTimeZone defaultTz = (defaultTzStr != null ? DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr) : null)
?? tzdb.GetZoneOrNull("UTC")!;
var birthdayUsers = new HashSet<ulong>(); var birthdayUsers = new HashSet<ulong>();
foreach (var item in guildUsers) { foreach (var item in guildUsers) {
@ -138,11 +128,9 @@ class BirthdayRoleUpdate : BackgroundService {
/// Sets the birthday role to all applicable users. Unsets it from all others who may have it. /// Sets the birthday role to all applicable users. Unsets it from all others who may have it.
/// </summary> /// </summary>
/// <returns> /// <returns>
/// First item: List of users who had the birthday role applied, used to announce. /// List of users who had the birthday role applied, used to announce.
/// Second item: Counts of users who have had roles added/removed, used for operation reporting.
/// </returns> /// </returns>
private static async Task<(IEnumerable<SocketGuildUser>, (int, int))> UpdateGuildBirthdayRoles( private static async Task<IEnumerable<SocketGuildUser>> UpdateGuildBirthdayRoles(SocketGuild g, SocketRole r, HashSet<ulong> names) {
SocketGuild g, SocketRole r, HashSet<ulong> names) {
// Check members currently with the role. Figure out which users to remove it from. // Check members currently with the role. Figure out which users to remove it from.
var roleRemoves = new List<SocketGuildUser>(); var roleRemoves = new List<SocketGuildUser>();
var roleKeeps = new HashSet<ulong>(); var roleKeeps = new HashSet<ulong>();
@ -165,7 +153,7 @@ class BirthdayRoleUpdate : BackgroundService {
newBirthdays.Add(member); newBirthdays.Add(member);
} }
return (newBirthdays, (newBirthdays.Count, roleRemoves.Count)); return newBirthdays;
} }
public const string DefaultAnnounce = "Please wish a happy birthday to %n!"; public const string DefaultAnnounce = "Please wish a happy birthday to %n!";
@ -174,21 +162,20 @@ class BirthdayRoleUpdate : BackgroundService {
/// <summary> /// <summary>
/// Attempts to send an announcement message. /// Attempts to send an announcement message.
/// </summary> /// </summary>
private static async Task AnnounceBirthdaysAsync( private static async Task AnnounceBirthdaysAsync(GuildConfig settings, SocketTextChannel? c, IEnumerable<SocketGuildUser> names) {
(string?, string?) announce, bool announcePing, SocketTextChannel? c, IEnumerable<SocketGuildUser> names) {
if (c == null) return; if (c == null) return;
if (!c.Guild.CurrentUser.GetPermissions(c).SendMessages) return; if (!c.Guild.CurrentUser.GetPermissions(c).SendMessages) return;
string announceMsg; string announceMsg;
if (names.Count() == 1) announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce; if (names.Count() == 1) announceMsg = settings.AnnounceMessage ?? settings.AnnounceMessagePl ?? DefaultAnnounce;
else announceMsg = announce.Item2 ?? announce.Item1 ?? DefaultAnnouncePl; else announceMsg = settings.AnnounceMessagePl ?? settings.AnnounceMessage ?? DefaultAnnouncePl;
announceMsg = announceMsg.TrimEnd(); announceMsg = announceMsg.TrimEnd();
if (!announceMsg.Contains("%n")) announceMsg += " %n"; if (!announceMsg.Contains("%n")) announceMsg += " %n";
// Build sorted name list // Build sorted name list
var namestrings = new List<string>(); var namestrings = new List<string>();
foreach (var item in names) foreach (var item in names)
namestrings.Add(Common.FormatName(item, announcePing)); namestrings.Add(Common.FormatName(item, settings.AnnouncePing));
namestrings.Sort(StringComparer.OrdinalIgnoreCase); namestrings.Sort(StringComparer.OrdinalIgnoreCase);
var namedisplay = new StringBuilder(); var namedisplay = new StringBuilder();

View file

@ -1,5 +1,4 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using NpgsqlTypes;
using System.Text; using System.Text;
namespace BirthdayBot.BackgroundServices; namespace BirthdayBot.BackgroundServices;
@ -20,96 +19,49 @@ class DataRetention : BackgroundService {
// 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 + ShardInstance.ShardId) % ProcessInterval != 0) return;
try { using var db = new BotDatabaseContext();
// A semaphore is used to restrict this work being done concurrently on other shards var now = DateTimeOffset.UtcNow;
// to avoid putting pressure on the SQL connection pool. Clearing old database information int updatedGuilds = 0, updatedUsers = 0;
// ultimately is a low priority among other tasks.
await _updateLock.WaitAsync(token).ConfigureAwait(false); foreach (var guild in ShardInstance.DiscordClient.Guilds) {
} catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { // Update guild, fetch users from database
// Caller does not expect the exception that SemaphoreSlim throws... var dbGuild = db.GuildConfigurations.Where(s => s.GuildId == (long)guild.Id).FirstOrDefault();
throw new TaskCanceledException(); if (dbGuild == null) continue;
dbGuild.LastSeen = now;
updatedGuilds++;
// Update users
var localIds = guild.Users.Select(u => (long)u.Id);
var dbSavedIds = db.UserEntries.Where(e => e.GuildId == (long)guild.Id).Select(e => e.UserId);
var usersToUpdate = localIds.Intersect(dbSavedIds).ToHashSet();
foreach (var user in db.UserEntries.Where(e => e.GuildId == (long)guild.Id && usersToUpdate.Contains(e.UserId))) {
user.LastSeen = now;
updatedUsers++;
}
} }
try {
// Build a list of all values across all guilds to update // And let go of old data
var updateList = new Dictionary<ulong, List<ulong>>(); var staleGuilds = db.GuildConfigurations.Where(s => now - TimeSpan.FromDays(StaleGuildThreshold) > s.LastSeen);
foreach (var g in ShardInstance.DiscordClient.Guilds) { var staleUsers = db.UserEntries.Where(e => now - TimeSpan.FromDays(StaleUserThreashold) > e.LastSeen);
// Get list of IDs for all users who exist in the database and currently exist in the guild int staleGuildCount = staleGuilds.Count(), staleUserCount = staleUsers.Count();
var userList = GuildUserConfiguration.LoadAllAsync(g.Id); db.GuildConfigurations.RemoveRange(staleGuilds);
var guildUserIds = from gu in g.Users select gu.Id; db.UserEntries.RemoveRange(staleUsers);
var savedUserIds = from cu in await userList.ConfigureAwait(false) select cu.UserId;
var existingCachedIds = savedUserIds.Intersect(guildUserIds); await db.SaveChangesAsync(CancellationToken.None);
updateList[g.Id] = existingCachedIds.ToList();
var resultText = new StringBuilder();
resultText.Append($"Updated {updatedGuilds} guilds, {updatedUsers} users.");
if (staleGuildCount != 0 || staleUserCount != 0) {
resultText.Append(" Discarded ");
if (staleGuildCount != 0) {
resultText.Append($"{staleGuildCount} guilds");
if (staleUserCount != 0) resultText.Append(", ");
} }
if (staleUserCount != 0) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); resultText.Append($"{staleUserCount} users");
// Statement for updating last_seen in guilds
var cUpdateGuild = db.CreateCommand();
cUpdateGuild.CommandText = $"update {GuildConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid";
var pUpdateG = cUpdateGuild.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
cUpdateGuild.Prepare();
// Statement for updating last_seen in guild users
var cUpdateGuildUser = db.CreateCommand();
cUpdateGuildUser.CommandText = $"update {GuildUserConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid and user_id = @Uid";
var pUpdateGU_g = cUpdateGuildUser.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
var pUpdateGU_u = cUpdateGuildUser.Parameters.Add("@Uid", NpgsqlDbType.Bigint);
cUpdateGuildUser.Prepare();
// Do actual updates
int updatedGuilds = 0;
int updatedUsers = 0;
using (var tUpdate = db.BeginTransaction()) {
foreach (var item in updateList) {
var guild = item.Key;
var userlist = item.Value;
pUpdateG.Value = (long)guild;
updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
pUpdateGU_g.Value = (long)guild;
foreach (var userid in userlist) {
pUpdateGU_u.Value = (long)userid;
updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
}
await tUpdate.CommitAsync(CancellationToken.None).ConfigureAwait(false);
} }
var resultText = new StringBuilder(); resultText.Append('.');
resultText.Append($"Updated {updatedGuilds} guilds, {updatedUsers} users.");
// Deletes both guild and user data if it hasn't been seen for over the threshold defined at the top of this file
// Expects referencing tables to have 'on delete cascade'
int staleGuilds, staleUsers;
using (var tRemove = db.BeginTransaction()) {
using (var c = db.CreateCommand()) {
c.CommandText = $"delete from {GuildConfiguration.BackingTable}" +
$" where (now() - interval '{StaleGuildThreshold} days') > last_seen";
staleGuilds = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
using (var c = db.CreateCommand()) {
c.CommandText = $"delete from {GuildUserConfiguration.BackingTable}" +
$" where (now() - interval '{StaleUserThreashold} days') > last_seen";
staleUsers = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
await tRemove.CommitAsync(CancellationToken.None).ConfigureAwait(false);
}
if (staleGuilds != 0 || staleUsers != 0) {
resultText.Append(" Discarded ");
if (staleGuilds != 0) {
resultText.Append($"{staleGuilds} guilds");
if (staleUsers != 0) resultText.Append(", ");
}
if (staleUsers != 0) {
resultText.Append($"{staleUsers} users");
}
resultText.Append('.');
}
Log(resultText.ToString());
} finally {
_updateLock.Release();
} }
Log(resultText.ToString());
} }
} }

View file

@ -1,8 +1,4 @@
using System; using System.Text;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices; namespace BirthdayBot.BackgroundServices;
@ -11,7 +7,7 @@ namespace BirthdayBot.BackgroundServices;
/// </summary> /// </summary>
class ExternalStatisticsReporting : BackgroundService { class ExternalStatisticsReporting : BackgroundService {
const int ProcessInterval = 1200 / ShardBackgroundWorker.Interval; // Process every ~20 minutes const int ProcessInterval = 1200 / ShardBackgroundWorker.Interval; // Process every ~20 minutes
const int ProcessOffset = 300 / ShardBackgroundWorker.Interval; // Begin processing 5 minutes after shard start const int ProcessOffset = 300 / ShardBackgroundWorker.Interval; // Begin processing ~5 minutes after shard start
private static readonly HttpClient _httpClient = new(); private static readonly HttpClient _httpClient = new();

View file

@ -37,7 +37,8 @@ public class BotDatabaseContext : DbContext {
entity.HasOne(d => d.Guild) entity.HasOne(d => d.Guild)
.WithMany(p => p.BlockedUsers) .WithMany(p => p.BlockedUsers)
.HasForeignKey(d => d.GuildId) .HasForeignKey(d => d.GuildId)
.HasConstraintName("banned_users_guild_id_fkey"); .HasConstraintName("banned_users_guild_id_fkey")
.OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<GuildConfig>(entity => { modelBuilder.Entity<GuildConfig>(entity => {
@ -58,7 +59,8 @@ public class BotDatabaseContext : DbContext {
entity.HasOne(d => d.Guild) entity.HasOne(d => d.Guild)
.WithMany(p => p.UserEntries) .WithMany(p => p.UserEntries)
.HasForeignKey(d => d.GuildId) .HasForeignKey(d => d.GuildId)
.HasConstraintName("user_birthdays_guild_id_fkey"); .HasConstraintName("user_birthdays_guild_id_fkey")
.OnDelete(DeleteBehavior.Cascade);
}); });
} }
} }