Skip to content

Commit d5ef79f

Browse files
HandyS11claude
andcommitted
Skip the team-info socket call when every clan member's name is cached
ClanStateService.RecordTeamNamesAsync now checks IClanStore.GetNamesAsync against the full roster first and only calls IRustServerQuery.GetTeamInfoAsync when a roster member's name is still unknown. OnClanChanged fires on every clan edit, including score changes, and score moves on every kill, so the previous unconditional call issued a companion-API RPC per kill per server. In steady state this now costs zero extra RPCs; the socket is only touched when a genuinely unknown member appears. Best-effort semantics are unchanged: a null snapshot, a failing query, or an unknown name still never blocks persistence or the feed post. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c906a92 commit d5ef79f

2 files changed

Lines changed: 49 additions & 15 deletions

File tree

src/RustPlusBot.Features.Clans/State/ClanStateService.cs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,17 @@ private async Task RecordTeamNamesAsync(
154154
{
155155
try
156156
{
157+
var roster = snapshot.Members.Select(m => m.SteamId).ToHashSet();
158+
var known = await store.GetNamesAsync(evt.GuildId, evt.ServerId, roster, cancellationToken)
159+
.ConfigureAwait(false);
160+
if (roster.All(known.ContainsKey))
161+
{
162+
// Every roster member already has a cached name: skip the companion-API round trip
163+
// entirely. OnClanChanged fires on any clan edit, including score changes, and score
164+
// moves on every kill — without this check that would be one RPC per kill per server.
165+
return;
166+
}
167+
157168
var query = services.GetRequiredService<IRustServerQuery>();
158169
var team = await query.GetTeamInfoAsync(evt.GuildId, evt.ServerId, cancellationToken)
159170
.ConfigureAwait(false);
@@ -163,18 +174,8 @@ private async Task RecordTeamNamesAsync(
163174
return;
164175
}
165176

166-
var roster = snapshot.Members.Select(m => m.SteamId).ToHashSet();
167177
var candidates = team.Members
168-
.Where(m => roster.Contains(m.SteamId) && !string.IsNullOrWhiteSpace(m.Name))
169-
.ToList();
170-
if (candidates.Count == 0)
171-
{
172-
return;
173-
}
174-
175-
var known = await store
176-
.GetNamesAsync(evt.GuildId, evt.ServerId, candidates.ConvertAll(m => m.SteamId), cancellationToken)
177-
.ConfigureAwait(false);
178+
.Where(m => roster.Contains(m.SteamId) && !string.IsNullOrWhiteSpace(m.Name));
178179

179180
foreach (var member in candidates.Where(m => !known.ContainsKey(m.SteamId)))
180181
{

tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ await service.ApplyAsync(
267267
Snapshot(members: [Member(111UL), Member(222UL)])),
268268
CancellationToken.None);
269269

270+
// 111 is missing from the cache, so the socket call must happen and its answer recorded.
271+
await _query.Received(1).GetTeamInfoAsync(Guild, Server, Arg.Any<CancellationToken>());
270272
await _store.Received(1).RecordNameAsync(Guild, Server, 111UL, "Alice", Arg.Any<CancellationToken>());
271273

272274
// 222 is already cached, and 333 is on the team but not in the clan.
@@ -276,18 +278,46 @@ await _store.DidNotReceive()
276278
.RecordNameAsync(Guild, Server, 333UL, Arg.Any<string>(), Arg.Any<CancellationToken>());
277279
}
278280

281+
[Fact]
282+
public async Task Skips_the_team_query_when_every_roster_member_already_has_a_cached_name()
283+
{
284+
// OnClanChanged fires on any clan edit, including score changes, and score moves on every
285+
// kill: once the whole roster's names are cached this must cost zero companion-API RPCs.
286+
_store.GetAsync(Guild, Server, Arg.Any<CancellationToken>()).Returns((ClanSnapshot?)null);
287+
_store.GetNamesAsync(Guild, Server, Arg.Any<IReadOnlyCollection<ulong>>(), Arg.Any<CancellationToken>())
288+
.Returns(new Dictionary<ulong, string>
289+
{
290+
[111UL] = "Alice", [222UL] = "Bob"
291+
});
292+
var service = Build();
293+
294+
await service.ApplyAsync(
295+
new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan,
296+
Snapshot(members: [Member(111UL), Member(222UL)])),
297+
CancellationToken.None);
298+
299+
await _query.DidNotReceive()
300+
.GetTeamInfoAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>());
301+
await _store.DidNotReceive().RecordNameAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(),
302+
Arg.Any<string>(), Arg.Any<CancellationToken>());
303+
}
304+
279305
[Fact]
280306
public async Task A_null_team_snapshot_records_nothing_and_changes_nothing_else()
281307
{
282308
_query.GetTeamInfoAsync(Guild, Server, Arg.Any<CancellationToken>())
283309
.Returns((TeamInfoSnapshot?)null);
284-
_store.GetAsync(Guild, Server, Arg.Any<CancellationToken>()).Returns(Snapshot());
285-
var renamed = Snapshot("Bears");
310+
// A non-empty, uncached roster so the harvest actually reaches the socket call whose
311+
// null answer this test is meant to prove is harmless.
312+
_store.GetAsync(Guild, Server, Arg.Any<CancellationToken>())
313+
.Returns(Snapshot(members: [Member(111UL)]));
314+
var renamed = Snapshot("Bears", members: [Member(111UL)]);
286315
var service = Build();
287316

288317
await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed),
289318
CancellationToken.None);
290319

320+
await _query.Received(1).GetTeamInfoAsync(Guild, Server, Arg.Any<CancellationToken>());
291321
await _store.DidNotReceive().RecordNameAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(),
292322
Arg.Any<string>(), Arg.Any<CancellationToken>());
293323
await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any<CancellationToken>());
@@ -299,13 +329,16 @@ public async Task A_failing_team_query_still_persists_the_clan_and_posts_the_fee
299329
{
300330
_query.GetTeamInfoAsync(Guild, Server, Arg.Any<CancellationToken>())
301331
.Returns<TeamInfoSnapshot?>(_ => throw new InvalidOperationException("socket gone"));
302-
_store.GetAsync(Guild, Server, Arg.Any<CancellationToken>()).Returns(Snapshot());
303-
var renamed = Snapshot("Bears");
332+
// A non-empty, uncached roster so the harvest actually reaches the socket call that throws.
333+
_store.GetAsync(Guild, Server, Arg.Any<CancellationToken>())
334+
.Returns(Snapshot(members: [Member(111UL)]));
335+
var renamed = Snapshot("Bears", members: [Member(111UL)]);
304336
var service = Build();
305337

306338
await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed),
307339
CancellationToken.None);
308340

341+
await _query.Received(1).GetTeamInfoAsync(Guild, Server, Arg.Any<CancellationToken>());
309342
await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any<CancellationToken>());
310343
await _poster.Received(1).PostAsync(Channel, "clan.event.renamed", Arg.Any<CancellationToken>());
311344
}

0 commit comments

Comments
 (0)