perf(dashboard): LRU cap on the shared live-read session's advised set

This commit is contained in:
Joseph Doherty
2026-08-15 12:21:13 -04:00
parent f1e26fed4f
commit 75e3dc2794
3 changed files with 540 additions and 10 deletions
@@ -15,13 +15,26 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
{
private const string BackendName = "Galaxy";
private const string ClientName = "mxgateway-dashboard";
// 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.
private const int MaxSubscribedTags = 256;
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
private readonly ISessionManager _sessionManager;
private readonly IGatewayAlarmService _alarmService;
private readonly ILogger<DashboardLiveDataService> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);
// Least-recently-read-last advise set: the list holds every currently advised
// tag ordered most- to least-recently read, the dictionary indexes into it.
// Both are only ever touched under _gate, which already serialises all viewers.
private readonly Dictionary<string, LinkedListNode<SubscribedTag>> _subscribed =
new(StringComparer.OrdinalIgnoreCase);
private readonly LinkedList<SubscribedTag> _recency = new();
private GatewaySession? _session;
private int _serverHandle;
@@ -58,15 +71,15 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
(GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken)
.ConfigureAwait(false);
string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray();
string[] toSubscribe = TouchAndCollectNewTags(tagAddresses, out int justReadCount);
if (toSubscribe.Length > 0)
{
await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken)
await EvictForAsync(session, serverHandle, toSubscribe.Length, justReadCount, cancellationToken)
.ConfigureAwait(false);
foreach (string tag in toSubscribe)
{
_subscribed.Add(tag);
}
IReadOnlyList<SubscribeResult> subscribeResults = await session
.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken)
.ConfigureAwait(false);
TrackSubscribed(toSubscribe, subscribeResults);
}
IReadOnlyList<BulkReadResult> results = await session
@@ -107,6 +120,131 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId));
}
// Promotes every already-advised tag in this read to the front of the recency
// list and returns the tags that still need subscribing (distinct, in request
// 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.
private string[] TouchAndCollectNewTags(IReadOnlyCollection<string> tagAddresses, out int justReadCount)
{
int touched = 0;
List<string> toSubscribe = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (string tag in tagAddresses)
{
if (_subscribed.TryGetValue(tag, out LinkedListNode<SubscribedTag>? node))
{
if (!ReferenceEquals(node, _recency.First))
{
_recency.Remove(node);
_recency.AddFirst(node);
}
if (seen.Add(tag))
{
touched++;
}
}
else if (seen.Add(tag))
{
toSubscribe.Add(tag);
}
}
justReadCount = touched;
return [.. toSubscribe];
}
// Drops least-recently-read tags off the back of the advise set until the
// incoming tags fit under MaxSubscribedTags, unadvising them on the worker in
// 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.
private async Task EvictForAsync(
GatewaySession session,
int serverHandle,
int incomingCount,
int justReadCount,
CancellationToken cancellationToken)
{
int overflow = _subscribed.Count + incomingCount - MaxSubscribedTags;
int evictable = _subscribed.Count - justReadCount;
int evictCount = Math.Min(overflow, evictable);
if (evictCount <= 0)
{
return;
}
List<int> evictedHandles = new(evictCount);
for (int i = 0; i < evictCount && _recency.Last is { } oldest; i++)
{
_recency.RemoveLast();
_subscribed.Remove(oldest.Value.TagAddress);
if (oldest.Value.ItemHandle != 0)
{
evictedHandles.Add(oldest.Value.ItemHandle);
}
}
_logger.LogDebug(
"Dashboard advise set hit its cap of {Cap}; evicted {EvictedCount} least-recently-read tags.",
MaxSubscribedTags,
evictCount);
if (evictedHandles.Count == 0)
{
return;
}
try
{
await session.UnsubscribeBulkAsync(serverHandle, evictedHandles, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogDebug(
exception,
"Unadvising {EvictedCount} evicted dashboard tags failed; they stay dropped from tracking.",
evictedHandles.Count);
}
}
// Records the freshly advised tags as the most recently read, keeping each
// tag's item handle so eviction can unadvise it. Tags the worker failed to
// advise are still tracked (matching the pre-cap behaviour of not retrying
// them on every read) but carry no handle, so eviction just forgets them.
// Callers must hold _gate.
private void TrackSubscribed(IReadOnlyList<string> tagAddresses, IReadOnlyList<SubscribeResult> results)
{
Dictionary<string, int> handles = new(results.Count, StringComparer.OrdinalIgnoreCase);
foreach (SubscribeResult result in results)
{
if (result.WasSuccessful && !string.IsNullOrEmpty(result.TagAddress))
{
handles[result.TagAddress] = result.ItemHandle;
}
}
// Inserted back-to-front so the read's first tag ends up most recent.
for (int i = tagAddresses.Count - 1; i >= 0; i--)
{
string tag = tagAddresses[i];
handles.TryGetValue(tag, out int itemHandle);
_subscribed[tag] = _recency.AddFirst(new SubscribedTag(tag, itemHandle));
}
}
// Forgets the whole advise set without unadvising: every call site is one where
// the backing session (and with it every item handle) is already gone.
// Callers must hold _gate.
private void ClearSubscriptions()
{
_subscribed.Clear();
_recency.Clear();
}
// Returns a Ready session + its Register server handle, opening a fresh
// session when none exists or the current one is no longer usable. Callers
// must hold _gate.
@@ -132,7 +270,7 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false);
}
_subscribed.Clear();
ClearSubscriptions();
_session = null;
GatewaySession session = await _sessionManager.OpenSessionAsync(
@@ -178,7 +316,7 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
{
_session = null;
_serverHandle = 0;
_subscribed.Clear();
ClearSubscriptions();
}
private async Task CloseQuietlyAsync(string sessionId)
@@ -212,4 +350,8 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
_gate.Dispose();
}
// One entry of the advise set. ItemHandle is the handle the worker bound for
// the tag, or 0 when the subscribe failed and there is nothing to unadvise.
private readonly record struct SubscribedTag(string TagAddress, int ItemHandle);
}