Skip to content

Commit fe6b8d1

Browse files
HandyS11claude
andcommitted
refactor(workspace): collapse channel locators onto shared CachingChannelLocator
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ecbcb96 commit fe6b8d1

7 files changed

Lines changed: 271 additions & 335 deletions

File tree

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,10 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using RustPlusBot.Abstractions.Time;
3-
using RustPlusBot.Persistence.Workspace;
43

54
namespace RustPlusBot.Features.Workspace.Locating;
65

7-
/// <summary>
8-
/// Caches the small set of provisioned #alarms channels (rebuilt when the cache goes stale) and resolves
9-
/// (guild, server) → channel id for posting/editing alarm embeds.
10-
/// </summary>
6+
/// <summary>Resolves the #alarms channel id for a (guild, server).</summary>
117
/// <param name="scopeFactory">Opens scopes for the scoped workspace store.</param>
128
/// <param name="clock">Drives the cache TTL.</param>
139
internal sealed class AlarmChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
14-
: IAlarmChannelLocator, IDisposable
15-
{
16-
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
17-
private readonly SemaphoreSlim _refreshGate = new(1, 1);
18-
19-
private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
20-
21-
private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = [];
22-
23-
/// <inheritdoc />
24-
public async Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
25-
{
26-
await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
27-
return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
28-
}
29-
30-
/// <inheritdoc />
31-
public void Dispose() => _refreshGate.Dispose();
32-
33-
private async Task EnsureFreshAsync(CancellationToken cancellationToken)
34-
{
35-
if (clock.UtcNow - _builtAt < CacheTtl)
36-
{
37-
return;
38-
}
39-
40-
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
41-
try
42-
{
43-
if (clock.UtcNow - _builtAt < CacheTtl)
44-
{
45-
return;
46-
}
47-
48-
var scope = scopeFactory.CreateAsyncScope();
49-
await using (scope.ConfigureAwait(false))
50-
{
51-
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
52-
var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerAlarms, cancellationToken)
53-
.ConfigureAwait(false);
54-
55-
var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
56-
foreach (var row in rows)
57-
{
58-
if (row.RustServerId is not { } serverId)
59-
{
60-
continue;
61-
}
62-
63-
byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
64-
}
65-
66-
_byServer = byServer;
67-
_builtAt = clock.UtcNow;
68-
}
69-
}
70-
finally
71-
{
72-
_refreshGate.Release();
73-
}
74-
}
75-
}
10+
: CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerAlarms), IAlarmChannelLocator;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Abstractions.Time;
3+
using RustPlusBot.Persistence.Workspace;
4+
5+
namespace RustPlusBot.Features.Workspace.Locating;
6+
7+
/// <summary>Caches a provisioned channel set per key and resolves (guild, server) → channel id.</summary>
8+
/// <param name="scopeFactory">Opens scopes for the scoped workspace store.</param>
9+
/// <param name="clock">Drives the cache TTL.</param>
10+
/// <param name="channelKey">The workspace channel key to load from the store.</param>
11+
internal abstract class CachingChannelLocator(IServiceScopeFactory scopeFactory, IClock clock, string channelKey)
12+
: IDisposable
13+
{
14+
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
15+
private readonly SemaphoreSlim _refreshGate = new(1, 1);
16+
private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
17+
private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new();
18+
19+
/// <summary>A read-only view of the cached (guild, server) → channel-id map, available after ensure-fresh.</summary>
20+
protected IReadOnlyDictionary<(ulong GuildId, Guid ServerId), ulong> Entries => _byServer;
21+
22+
/// <inheritdoc />
23+
public void Dispose()
24+
{
25+
Dispose(true);
26+
GC.SuppressFinalize(this);
27+
}
28+
29+
/// <summary>Releases managed resources.</summary>
30+
/// <param name="disposing"><see langword="true"/> when called from <see cref="Dispose()"/>.</param>
31+
protected virtual void Dispose(bool disposing)
32+
{
33+
if (disposing)
34+
{
35+
_refreshGate.Dispose();
36+
}
37+
}
38+
39+
/// <summary>
40+
/// Gets the Discord channel id for (<paramref name="guildId"/>, <paramref name="serverId"/>), or
41+
/// <see langword="null"/> if no channel is provisioned.
42+
/// </summary>
43+
/// <param name="guildId">The guild snowflake.</param>
44+
/// <param name="serverId">The server id.</param>
45+
/// <param name="cancellationToken">A cancellation token.</param>
46+
/// <returns>The Discord channel id, or <see langword="null"/> if not provisioned.</returns>
47+
public async Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
48+
{
49+
await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
50+
return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
51+
}
52+
53+
/// <summary>Ensures the cache is current; refreshes from the store when the TTL has elapsed.</summary>
54+
/// <param name="cancellationToken">A cancellation token.</param>
55+
protected async Task EnsureFreshAsync(CancellationToken cancellationToken)
56+
{
57+
if (clock.UtcNow - _builtAt < CacheTtl)
58+
{
59+
return;
60+
}
61+
62+
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
63+
try
64+
{
65+
if (clock.UtcNow - _builtAt < CacheTtl)
66+
{
67+
return;
68+
}
69+
70+
var scope = scopeFactory.CreateAsyncScope();
71+
await using (scope.ConfigureAwait(false))
72+
{
73+
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
74+
var rows = await store.GetChannelsByKeyAsync(channelKey, cancellationToken).ConfigureAwait(false);
75+
76+
var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
77+
foreach (var row in rows)
78+
{
79+
if (row.RustServerId is not { } serverId)
80+
{
81+
continue;
82+
}
83+
84+
byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
85+
}
86+
87+
_byServer = byServer;
88+
_builtAt = clock.UtcNow;
89+
}
90+
}
91+
finally
92+
{
93+
_refreshGate.Release();
94+
}
95+
}
96+
}
Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,10 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using RustPlusBot.Abstractions.Time;
3-
using RustPlusBot.Persistence.Workspace;
43

54
namespace RustPlusBot.Features.Workspace.Locating;
65

7-
/// <summary>
8-
/// Caches the small set of provisioned #events channels (rebuilt when the cache goes stale) and resolves
9-
/// the game-to-Discord direction only (there is no Discord→game path for events).
10-
/// </summary>
6+
/// <summary>Resolves the #events channel id for a (guild, server).</summary>
117
/// <param name="scopeFactory">Opens scopes for the scoped workspace store.</param>
128
/// <param name="clock">Drives the cache TTL.</param>
139
internal sealed class EventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
14-
: IEventChannelLocator, IDisposable
15-
{
16-
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
17-
private readonly SemaphoreSlim _refreshGate = new(1, 1);
18-
19-
private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
20-
21-
private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = [];
22-
23-
/// <inheritdoc />
24-
public void Dispose() => _refreshGate.Dispose();
25-
26-
/// <inheritdoc />
27-
public async Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
28-
{
29-
await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
30-
return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
31-
}
32-
33-
private async Task EnsureFreshAsync(CancellationToken cancellationToken)
34-
{
35-
if (clock.UtcNow - _builtAt < CacheTtl)
36-
{
37-
return;
38-
}
39-
40-
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
41-
try
42-
{
43-
if (clock.UtcNow - _builtAt < CacheTtl)
44-
{
45-
return;
46-
}
47-
48-
var scope = scopeFactory.CreateAsyncScope();
49-
await using (scope.ConfigureAwait(false))
50-
{
51-
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
52-
var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerEvents, cancellationToken)
53-
.ConfigureAwait(false);
54-
55-
var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
56-
foreach (var row in rows)
57-
{
58-
if (row.RustServerId is not { } serverId)
59-
{
60-
continue;
61-
}
62-
63-
byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
64-
}
65-
66-
_byServer = byServer;
67-
_builtAt = clock.UtcNow;
68-
}
69-
}
70-
finally
71-
{
72-
_refreshGate.Release();
73-
}
74-
}
75-
}
10+
: CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerEvents), IEventChannelLocator;
Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,10 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using RustPlusBot.Abstractions.Time;
3-
using RustPlusBot.Persistence.Workspace;
43

54
namespace RustPlusBot.Features.Workspace.Locating;
65

7-
/// <summary>
8-
/// Caches the small set of provisioned #map channels (rebuilt when the cache goes stale) and resolves
9-
/// the game-to-Discord direction only (there is no Discord→game path for the map).
10-
/// </summary>
6+
/// <summary>Resolves the #map channel id for a (guild, server).</summary>
117
/// <param name="scopeFactory">Opens scopes for the scoped workspace store.</param>
128
/// <param name="clock">Drives the cache TTL.</param>
139
internal sealed class MapChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
14-
: IMapChannelLocator, IDisposable
15-
{
16-
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
17-
private readonly SemaphoreSlim _refreshGate = new(1, 1);
18-
19-
private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
20-
21-
private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = [];
22-
23-
/// <inheritdoc />
24-
public void Dispose() => _refreshGate.Dispose();
25-
26-
/// <inheritdoc />
27-
public async Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
28-
{
29-
await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
30-
return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
31-
}
32-
33-
private async Task EnsureFreshAsync(CancellationToken cancellationToken)
34-
{
35-
if (clock.UtcNow - _builtAt < CacheTtl)
36-
{
37-
return;
38-
}
39-
40-
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
41-
try
42-
{
43-
if (clock.UtcNow - _builtAt < CacheTtl)
44-
{
45-
return;
46-
}
47-
48-
var scope = scopeFactory.CreateAsyncScope();
49-
await using (scope.ConfigureAwait(false))
50-
{
51-
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
52-
var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerMap, cancellationToken)
53-
.ConfigureAwait(false);
54-
55-
var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
56-
foreach (var row in rows)
57-
{
58-
if (row.RustServerId is not { } serverId)
59-
{
60-
continue;
61-
}
62-
63-
byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
64-
}
65-
66-
_byServer = byServer;
67-
_builtAt = clock.UtcNow;
68-
}
69-
}
70-
finally
71-
{
72-
_refreshGate.Release();
73-
}
74-
}
75-
}
10+
: CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerMap), IMapChannelLocator;

0 commit comments

Comments
 (0)