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:
+32
-3
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user