fix(centralui): pending-updates rail refreshes as soon as a deployment finishes — cache drops its snapshot on Success/Failed and signals the indicator, instead of serving the pre-deploy count for up to TTL + one poll

This commit is contained in:
Joseph Doherty
2026-10-22 17:11:18 -04:00
parent 3e1435dfac
commit 0e5402915b
8 changed files with 305 additions and 12 deletions
@@ -140,6 +140,23 @@
// the same circuit) must move this block, not leave it showing what the
// previous principal was allowed to see.
AuthStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
PendingUpdates.Invalidated += OnPendingUpdatesInvalidated;
}
// Set when the cache reports a finished deployment. A reload that arrives while a poll is
// already running is dropped by the PollGate, so the running poll re-checks this flag and
// goes round once more — otherwise the post-deploy count could wait out a full interval.
private int _reloadRequested;
private void OnPendingUpdatesInvalidated()
{
if (_disposed || !_visible)
{
return;
}
Interlocked.Exchange(ref _reloadRequested, 1);
_ = RunLoadAsync();
}
protected override async Task OnParametersSetAsync()
@@ -267,9 +284,20 @@
/// </summary>
internal Task PollOnceAsync() => RunLoadAsync();
private Task RunLoadAsync() => _pollGate.RunAsync(
LoadAsync,
error => Logger.LogDebug(error, "Pending-updates indicator poll tick failed."));
private async Task RunLoadAsync()
{
await _pollGate.RunAsync(
LoadAsync,
error => Logger.LogDebug(error, "Pending-updates indicator poll tick failed."));
// A reload requested while that poll was running was dropped by the gate: run once more.
if (!_disposed && Interlocked.Exchange(ref _reloadRequested, 0) == 1)
{
await _pollGate.RunAsync(
LoadAsync,
error => Logger.LogDebug(error, "Pending-updates indicator reload failed."));
}
}
private async Task LoadAsync()
{
@@ -342,6 +370,7 @@
{
_disposed = true;
AuthStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
PendingUpdates.Invalidated -= OnPendingUpdatesInvalidated;
lock (_timerLock)
{
@@ -34,4 +34,12 @@ public interface IPendingUpdatesCache
/// <param name="cancellationToken">Cancels this caller's wait only, never the shared flight.</param>
/// <returns>The memoized (or freshly evaluated) fleet-wide snapshot.</returns>
Task<PendingUpdatesSnapshot> GetAsync(bool forceRefresh = false, CancellationToken cancellationToken = default);
/// <summary>
/// Raised (on a thread-pool thread) once deployments have finished on this node and the
/// memoized snapshot was dropped — after a short quiet period, so a bulk deploy's burst of
/// completions raises it once. Subscribers re-read via <see cref="GetAsync"/> instead of
/// waiting out their poll.
/// </summary>
event Action? Invalidated;
}
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
@@ -14,8 +15,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <see cref="IServiceScopeFactory"/> — the evaluator is scoped (it opens repositories), so
/// every flight resolves it from a fresh scope, off any circuit-scoped context.
/// </para>
/// <para>
/// A finished deployment (<see cref="DeploymentStatus.Success"/> or
/// <see cref="DeploymentStatus.Failed"/>, from <see cref="IDeploymentStatusNotifier"/>) drops the
/// memoized snapshot at once and, after <see cref="DefaultInvalidationQuietPeriod"/> without
/// another completion, raises <see cref="Invalidated"/>. Before this, a Deploy all left the rail
/// showing the pre-deploy count for up to the TTL plus one poll. Only deployments run on THIS
/// node are seen; one run through the peer central still clears within the TTL.
/// </para>
/// </summary>
public sealed class PendingUpdatesCache : IPendingUpdatesCache
public sealed class PendingUpdatesCache : IPendingUpdatesCache, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly SingleFlightMemo<PendingUpdatesSnapshot> _memo;
@@ -23,18 +32,50 @@ public sealed class PendingUpdatesCache : IPendingUpdatesCache
/// <summary>Negative TTL after a faulted fleet evaluation, so polling does not hammer a sick DB.</summary>
internal static readonly TimeSpan FaultBackoff = TimeSpan.FromSeconds(10);
/// <summary>How long completions must stop arriving before <see cref="Invalidated"/> is raised.</summary>
internal static readonly TimeSpan DefaultInvalidationQuietPeriod = TimeSpan.FromSeconds(1);
private readonly IDeploymentStatusNotifier _statusNotifier;
private readonly TimeSpan _invalidationQuietPeriod;
private readonly Timer _invalidatedTimer;
private volatile bool _disposed;
/// <inheritdoc/>
public event Action? Invalidated;
/// <summary>
/// Initializes the shared pending-updates cache.
/// </summary>
/// <param name="scopeFactory">Opens a fresh DI scope per fleet evaluation.</param>
/// <param name="options">Supplies <see cref="DeploymentManagerOptions.PendingUpdatesCacheTtl"/>.</param>
public PendingUpdatesCache(IServiceScopeFactory scopeFactory, IOptions<DeploymentManagerOptions> options)
/// <param name="statusNotifier">Reports finished deployments, which invalidate the snapshot.</param>
public PendingUpdatesCache(
IServiceScopeFactory scopeFactory,
IOptions<DeploymentManagerOptions> options,
IDeploymentStatusNotifier statusNotifier)
: this(scopeFactory, options, statusNotifier, DefaultInvalidationQuietPeriod)
{
}
/// <summary>Initializes the cache with an explicit quiet period (tests).</summary>
/// <param name="scopeFactory">Opens a fresh DI scope per fleet evaluation.</param>
/// <param name="options">Supplies <see cref="DeploymentManagerOptions.PendingUpdatesCacheTtl"/>.</param>
/// <param name="statusNotifier">Reports finished deployments, which invalidate the snapshot.</param>
/// <param name="invalidationQuietPeriod">How long completions must stop before <see cref="Invalidated"/> is raised.</param>
internal PendingUpdatesCache(
IServiceScopeFactory scopeFactory,
IOptions<DeploymentManagerOptions> options,
IDeploymentStatusNotifier statusNotifier,
TimeSpan invalidationQuietPeriod)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
ArgumentNullException.ThrowIfNull(options);
_statusNotifier = statusNotifier ?? throw new ArgumentNullException(nameof(statusNotifier));
_invalidationQuietPeriod = invalidationQuietPeriod;
_memo = new SingleFlightMemo<PendingUpdatesSnapshot>(
options.Value.PendingUpdatesCacheTtl, faultBackoff: FaultBackoff);
_invalidatedTimer = new Timer(_ => RaiseInvalidated(), null, Timeout.Infinite, Timeout.Infinite);
_statusNotifier.StatusChanged += OnDeploymentStatusChanged;
}
/// <inheritdoc/>
@@ -42,6 +83,42 @@ public sealed class PendingUpdatesCache : IPendingUpdatesCache
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
_memo.GetAsync(EvaluateAsync, forceRefresh, cancellationToken);
/// <summary>Stops listening for deployment status changes.</summary>
public void Dispose()
{
_disposed = true;
_statusNotifier.StatusChanged -= OnDeploymentStatusChanged;
_invalidatedTimer.Dispose();
}
private void OnDeploymentStatusChanged(DeploymentStatusChange change)
{
if (_disposed || change.Status is not (DeploymentStatus.Success or DeploymentStatus.Failed))
{
return;
}
// Drop the snapshot at once so no caller is served the pre-deploy count, and (re)arm the
// quiet-period timer so a bulk deploy's burst of completions notifies subscribers once.
_memo.Invalidate();
try
{
_invalidatedTimer.Change(_invalidationQuietPeriod, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// Disposed between the check and the re-arm.
}
}
private void RaiseInvalidated()
{
if (!_disposed)
{
Invalidated?.Invoke();
}
}
private async Task<PendingUpdatesSnapshot> EvaluateAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
@@ -39,6 +39,12 @@ internal sealed class SingleFlightMemo<T>
/// <summary>Wall-clock instant until which a faulted <see cref="_current"/> is re-served.</summary>
private DateTimeOffset _faultedUntil;
/// <summary>
/// Bumped by <see cref="Invalidate"/>. A flight records the value it started under and is
/// memoized only if no invalidation happened while it ran.
/// </summary>
private long _generation;
/// <summary>
/// Initializes a memo slot.
/// </summary>
@@ -57,6 +63,21 @@ internal sealed class SingleFlightMemo<T>
/// </summary>
public int FlightCount { get; private set; }
/// <summary>
/// Marks the memoized value stale, so the next <see cref="GetAsync"/> starts a new flight.
/// A flight already in progress still answers the callers waiting on it, but its result is
/// not memoized: it may have read the state this invalidation reports as changed.
/// </summary>
public void Invalidate()
{
lock (_lock)
{
_generation++;
_freshUntil = default;
_faultedUntil = default;
}
}
/// <summary>
/// Returns the memoized value, starting a flight if none is fresh.
/// </summary>
@@ -86,6 +107,7 @@ internal sealed class SingleFlightMemo<T>
TaskCompletionSource<T>? started = null;
Task<T> result;
long generation = 0;
lock (_lock)
{
@@ -115,6 +137,7 @@ internal sealed class SingleFlightMemo<T>
started = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
_current = started.Task;
FlightCount++;
generation = _generation;
result = started.Task;
}
}
@@ -123,20 +146,22 @@ internal sealed class SingleFlightMemo<T>
// never be able to re-enter this slot on the calling thread while the lock is held.
if (started is not null)
{
_ = RunAsync(factory, started, ttlOverride ?? _ttl);
_ = RunAsync(factory, started, ttlOverride ?? _ttl, generation);
}
return cancellationToken.CanBeCanceled ? result.WaitAsync(cancellationToken) : result;
}
private async Task RunAsync(Func<Task<T>> factory, TaskCompletionSource<T> completion, TimeSpan ttl)
private async Task RunAsync(Func<Task<T>> factory, TaskCompletionSource<T> completion, TimeSpan ttl, long generation)
{
try
{
var value = await factory().ConfigureAwait(false);
lock (_lock)
{
_freshUntil = _clock() + ttl;
// Invalidated while running: answer this flight's callers, but do not serve it
// to anyone else.
_freshUntil = generation == _generation ? _clock() + ttl : default;
}
completion.SetResult(value);
}