fix(dashboard): enforce/document the advised-set cap honestly; cover handle-0 and oversize-read paths

This commit is contained in:
Joseph Doherty
2026-08-15 12:31:36 -04:00
parent 7c1ea12331
commit 44ca7c8623
3 changed files with 163 additions and 13 deletions
@@ -19,6 +19,16 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
// One browse page of tags plus headroom. Bounds the standing advise load the
// single dashboard worker carries — and the event churn that advise set feeds —
// however much of a galaxy an operator browses through in one sitting.
//
// The bound is per-read, not absolute: a read may never evict a tag it is itself
// about to return, so a single read of more distinct tags than the cap leaves the
// set that large. The invariant EvictForAsync actually maintains is
//
// |advise set| after a read <= max(MaxSubscribedTags, distinct tags in that read)
//
// and any overshoot is squeezed back out by the next read that subscribes a tag
// (see EvictForAsync). A browse page requests far fewer tags than the cap, so in
// practice the set settles at MaxSubscribedTags.
private const int MaxSubscribedTags = 256;
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
@@ -125,6 +135,11 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
// order). `justReadCount` is how many distinct tags of this read were already
// advised — they now occupy the front of the list and must never be evicted to
// make room for the same read's new tags. Callers must hold _gate.
//
// Every tag of one read is equally recently read; the recency list needs a total
// order anyway, so the whole service uses one tie-break: later in the request wins.
// Promoting in request order gives that here, and TrackSubscribed inserts new tags
// the same way.
private string[] TouchAndCollectNewTags(IReadOnlyCollection<string> tagAddresses, out int justReadCount)
{
int touched = 0;
@@ -161,6 +176,18 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
// one batch. A failed unadvise must not fail the read: the tags are dropped
// from tracking regardless, and the session-invalidation path already handles
// gateway/worker drift. Callers must hold _gate.
//
// Eviction stops at the tags this read just touched (`justReadCount`), so a read
// whose own distinct tags outnumber the cap ends over it — see MaxSubscribedTags
// for the exact invariant. That overshoot is not sticky: the next read that
// subscribes anything computes `overflow` against the oversized set and evicts the
// whole excess in one pass (a 300-tag set plus one new tag evicts 45 and lands
// back at the cap). A read that subscribes nothing new evicts nothing, but it also
// cannot grow the set.
//
// Cancellation mid-eviction follows this file's policy: OperationCanceledException
// is deliberately not caught here or in ReadAsync, so it propagates with the tags
// already dropped from tracking — the same end state as a failed unadvise.
private async Task EvictForAsync(
GatewaySession session,
int serverHandle,
@@ -227,10 +254,10 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
}
}
// Inserted back-to-front so the read's first tag ends up most recent.
for (int i = tagAddresses.Count - 1; i >= 0; i--)
// Request order, so the read's last tag ends up most recent — the same
// tie-break TouchAndCollectNewTags applies to the tags it promotes.
foreach (string tag in tagAddresses)
{
string tag = tagAddresses[i];
handles.TryGetValue(tag, out int itemHandle);
_subscribed[tag] = _recency.AddFirst(new SubscribedTag(tag, itemHandle));
}