Skip to content

Commit a2dc8bf

Browse files
HandyS11claude
andcommitted
fix: address Copilot review on PR #38 (atomicity, N+1, wording, purge lock)
- DatabaseMaintenanceService: wipe in a single transaction with defer_foreign_keys so an interruption rolls back instead of leaving a partially-cleared DB; throw on an unexpected table identifier instead of silently skipping it (and reporting a misleading success). - DiagnosticsModule /status: replace the per-server GetStateAsync N+1 with a single IConnectionStore.GetStatesForGuildAsync bulk read + dictionary lookup. - MaintenanceModule: clearer danger-off message for the operator-facing /admin group instead of "Developer commands are disabled". - GuildPurgeService: hold the per-guild provisioning lock across the whole purge (teardown + domain deletes) so concurrent reconciliation — including the self-heal triggered by teardown deleting channels — cannot re-provision into a half-purged guild. Adds a lock-free WorkspaceTeardownService core. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e61d226 commit a2dc8bf

9 files changed

Lines changed: 107 additions & 28 deletions

File tree

src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public async Task StatusAsync()
4747
var connections = scope.ServiceProvider.GetRequiredService<IConnectionStore>();
4848

4949
var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false);
50+
var states = (await connections.GetStatesForGuildAsync(Context.Guild.Id).ConfigureAwait(false))
51+
.ToDictionary(state => state.RustServerId);
5052

5153
var embed = new EmbedBuilder()
5254
.WithTitle("Bot status")
@@ -61,11 +63,10 @@ public async Task StatusAsync()
6163
// Cap server fields so the embed stays under Discord's 25-field limit (4 header fields above).
6264
foreach (var server in known.Take(20))
6365
{
64-
var state = await connections.GetStateAsync(Context.Guild.Id, server.Id).ConfigureAwait(false);
65-
var line = state is null
66-
? "unknown"
67-
: string.Create(CultureInfo.InvariantCulture,
68-
$"{state.Status} · {state.PlayerCount?.ToString(CultureInfo.InvariantCulture) ?? "?"} players");
66+
var line = states.TryGetValue(server.Id, out var state)
67+
? string.Create(CultureInfo.InvariantCulture,
68+
$"{state.Status} · {state.PlayerCount?.ToString(CultureInfo.InvariantCulture) ?? "?"} players")
69+
: "unknown";
6970
embed.AddField(server.Name, line);
7071
}
7172

src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ public async Task ResetDatabaseAsync(
3030

3131
if (!options.Value.EnableDangerCommands)
3232
{
33-
await RespondAsync("Developer commands are disabled.", ephemeral: true).ConfigureAwait(false);
33+
await RespondAsync(
34+
"Dangerous maintenance commands are disabled. Set `Workspace:EnableDangerCommands` to enable them.",
35+
ephemeral: true).ConfigureAwait(false);
3436
return;
3537
}
3638

src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.EntityFrameworkCore;
2+
using RustPlusBot.Features.Workspace.Reconciler;
23
using RustPlusBot.Persistence;
34
using RustPlusBot.Persistence.Servers;
45

@@ -8,16 +9,24 @@ namespace RustPlusBot.Features.Workspace.Teardown;
89
/// <param name="context">The bot database context.</param>
910
/// <param name="servers">Server management (RemoveAsync cascades all per-server rows).</param>
1011
/// <param name="teardown">Removes provisioned Discord channels/categories/messages.</param>
12+
/// <param name="provisioningLock">Held across the whole purge to block concurrent reconciliation.</param>
1113
internal sealed class GuildPurgeService(
1214
BotDbContext context,
1315
IServerService servers,
14-
IWorkspaceTeardownService teardown) : IGuildPurgeService
16+
WorkspaceTeardownService teardown,
17+
IProvisioningLock provisioningLock) : IGuildPurgeService
1518
{
1619
/// <inheritdoc />
1720
public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default)
1821
{
19-
// 1) Delete provisioned Discord channels/categories/messages (Discord side + records).
20-
await teardown.ResetGuildAsync(guildId, cancellationToken).ConfigureAwait(false);
22+
// Hold the per-guild provisioning lock across the ENTIRE purge. Otherwise reconciliation could
23+
// interleave after the teardown step — in particular the self-heal that fires when teardown
24+
// deletes channels — and re-provision resources or add rows into a half-purged guild.
25+
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);
26+
27+
// 1) Delete provisioned Discord channels/categories/messages (Discord side + records). Use the
28+
// lock-free core since we already hold the lock (ResetGuildAsync would deadlock re-acquiring).
29+
await teardown.ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false);
2130

2231
// 2) Remove each server; the RustServer FK cascade clears its per-server rows
2332
// (connection state, command/map settings, switches, alarms, storage monitors, credentials).

src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationTo
2424
public async Task ResetGuildAsync(ulong guildId, CancellationToken cancellationToken = default)
2525
{
2626
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);
27+
await ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false);
28+
}
29+
30+
/// <summary>
31+
/// Deletes the guild's provisioned resources and records WITHOUT taking the provisioning lock.
32+
/// The caller MUST already hold the guild's <see cref="IProvisioningLock"/> — <see cref="GuildPurgeService"/>
33+
/// uses this so it can hold the lock across the wider purge. External callers use <see cref="ResetGuildAsync"/>.
34+
/// </summary>
35+
/// <param name="guildId">The guild to reset.</param>
36+
/// <param name="cancellationToken">A cancellation token.</param>
37+
/// <returns>A task.</returns>
38+
internal async Task ResetGuildCoreAsync(ulong guildId, CancellationToken cancellationToken = default)
39+
{
2740
var categories = await store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false);
2841
foreach (var category in categories)
2942
{

src/RustPlusBot.Persistence/Connections/ConnectionStore.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ public sealed class ConnectionStore(BotDbContext context, IClock clock) : IConne
1818
context.ConnectionStates
1919
.SingleOrDefaultAsync(s => s.GuildId == guildId && s.RustServerId == serverId, cancellationToken);
2020

21+
/// <inheritdoc />
22+
public async Task<IReadOnlyList<ConnectionState>> GetStatesForGuildAsync(
23+
ulong guildId,
24+
CancellationToken cancellationToken = default) =>
25+
await context.ConnectionStates
26+
.Where(s => s.GuildId == guildId)
27+
.ToListAsync(cancellationToken)
28+
.ConfigureAwait(false);
29+
2130
/// <inheritdoc />
2231
public async Task<bool> UpsertStatusAsync(
2332
ulong guildId,

src/RustPlusBot.Persistence/Connections/IConnectionStore.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ public interface IConnectionStore
1616
/// <returns>The connection state, or null.</returns>
1717
Task<ConnectionState?> GetStateAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
1818

19+
/// <summary>Gets every server's connection state for a guild in a single query.</summary>
20+
/// <param name="guildId">Owning Discord guild snowflake.</param>
21+
/// <param name="cancellationToken">A cancellation token.</param>
22+
/// <returns>The guild's connection states (empty if none).</returns>
23+
Task<IReadOnlyList<ConnectionState>> GetStatesForGuildAsync(
24+
ulong guildId,
25+
CancellationToken cancellationToken = default);
26+
1927
/// <summary>
2028
/// Upserts the connection state. Returns true only when the persisted (status, player count, active
2129
/// credential) actually changed, so callers can skip a redundant #info refresh.

src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,34 @@ public async Task ClearAllAsync(CancellationToken cancellationToken = default)
1616
.Distinct(StringComparer.Ordinal)
1717
.ToList();
1818

19-
// Keep one connection open across every statement so the FK pragma persists
20-
// (with per-statement connections the pragma would reset before the DELETEs).
21-
await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
22-
try
19+
// Wipe every table in one transaction so an interruption rolls back rather than leaving the
20+
// database partially cleared. defer_foreign_keys defers FK enforcement to commit time (and
21+
// resets itself when the transaction ends), so tables can be cleared in any order — once every
22+
// table is empty the commit-time check has nothing to violate. This also avoids leaving a
23+
// connection-level foreign_keys pragma toggled off on a pooled connection.
24+
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
25+
await using (transaction.ConfigureAwait(false))
2326
{
24-
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", cancellationToken)
27+
await context.Database.ExecuteSqlRawAsync("PRAGMA defer_foreign_keys = ON", cancellationToken)
2528
.ConfigureAwait(false);
2629

2730
foreach (var table in tables)
2831
{
29-
// Deletion order is deliberately irrelevant: PRAGMA foreign_keys = OFF (above) suspends
30-
// FK enforcement for the wipe, so do not "fix" this by adding a topological sort.
31-
// Table names come from the EF model (never user input); the identifier guard keeps
32-
// the raw statement demonstrably injection-safe for the Sonar gate.
32+
// Table names come from the EF model (never user input). Fail loud on an unexpected
33+
// identifier rather than silently skipping it and reporting a misleading success; the
34+
// guard also keeps the raw statement demonstrably injection-safe for the Sonar gate.
3335
if (!IsSafeIdentifier(table!))
3436
{
35-
continue;
37+
throw new InvalidOperationException(
38+
string.Create(CultureInfo.InvariantCulture,
39+
$"Refusing to clear table with an unexpected identifier: '{table}'."));
3640
}
3741

3842
var sql = string.Create(CultureInfo.InvariantCulture, $"DELETE FROM \"{table}\"");
3943
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
4044
}
4145

42-
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", cancellationToken)
43-
.ConfigureAwait(false);
44-
}
45-
finally
46-
{
47-
await context.Database.CloseConnectionAsync().ConfigureAwait(false);
46+
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4847
}
4948
}
5049

tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
using RustPlusBot.Domain.Guilds;
99
using RustPlusBot.Domain.Servers;
1010
using RustPlusBot.Domain.Switches;
11+
using RustPlusBot.Domain.Workspace;
12+
using RustPlusBot.Features.Workspace.Gateway;
13+
using RustPlusBot.Features.Workspace.Reconciler;
1114
using RustPlusBot.Features.Workspace.Teardown;
1215
using RustPlusBot.Persistence;
1316
using RustPlusBot.Persistence.Servers;
17+
using RustPlusBot.Persistence.Workspace;
1418

1519
namespace RustPlusBot.Features.Workspace.Tests.Teardown;
1620

@@ -79,12 +83,20 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact()
7983
});
8084
await context.SaveChangesAsync();
8185

82-
var teardown = Substitute.For<IWorkspaceTeardownService>();
83-
var service = new GuildPurgeService(context, new ServerService(context), teardown);
86+
// Real teardown over fake Discord I/O, sharing the lock the purge holds. An empty category set
87+
// makes the teardown core a no-op on channels while still proving it runs under the held lock.
88+
var gateway = Substitute.For<IWorkspaceGateway>();
89+
var store = Substitute.For<IWorkspaceStore>();
90+
IReadOnlyList<ProvisionedCategory> noCategories = [];
91+
store.GetAllCategoriesAsync(Arg.Any<ulong>(), Arg.Any<CancellationToken>())
92+
.Returns(noCategories);
93+
var provisioningLock = new ProvisioningLock();
94+
var teardown = new WorkspaceTeardownService(gateway, store, provisioningLock);
95+
var service = new GuildPurgeService(context, new ServerService(context), teardown, provisioningLock);
8496

8597
await service.PurgeGuildAsync(1);
8698

87-
await teardown.Received(1).ResetGuildAsync(1, Arg.Any<CancellationToken>());
99+
await store.Received(1).GetAllCategoriesAsync(1, Arg.Any<CancellationToken>());
88100
Assert.Empty(await context.RustServers.Where(s => s.GuildId == 1).ToListAsync());
89101
Assert.Empty(await context.SmartSwitches.ToListAsync());
90102
Assert.Empty(await context.ConnectionStates.ToListAsync());

tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,30 @@ public async Task ListConnectableServers_ExcludesAllInvalidServers()
147147
var after = await store.ListConnectableServersAsync();
148148
Assert.DoesNotContain((10UL, serverId), after);
149149
}
150+
151+
[Fact]
152+
public async Task GetStatesForGuild_ReturnsOnlyThatGuildsStates()
153+
{
154+
var (store, context, conn) = Create();
155+
await using var _ = conn;
156+
await using var __ = context;
157+
158+
var serverA = new RustServer
159+
{
160+
GuildId = 10UL, Name = "A", Ip = "1.1.1.1", Port = 28015
161+
};
162+
var serverB = new RustServer
163+
{
164+
GuildId = 20UL, Name = "B", Ip = "2.2.2.2", Port = 28015
165+
};
166+
context.RustServers.AddRange(serverA, serverB);
167+
await context.SaveChangesAsync();
168+
await store.UpsertStatusAsync(10UL, serverA.Id, ConnectionStatus.Connected, 5, null);
169+
await store.UpsertStatusAsync(20UL, serverB.Id, ConnectionStatus.Connecting, null, null);
170+
171+
var states = await store.GetStatesForGuildAsync(10UL);
172+
173+
Assert.Equal(serverA.Id, Assert.Single(states).RustServerId);
174+
Assert.Empty(await store.GetStatesForGuildAsync(30UL));
175+
}
150176
}

0 commit comments

Comments
 (0)