fix(adminui): fleet/hosts poll ticks no longer overlap; cancel on dispose
This commit is contained in:
@@ -106,6 +106,14 @@ else
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
// Overlap guard: a tick whose DB query outruns the refresh interval must not stack behind the
|
||||
// next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard.
|
||||
private int _tickRunning;
|
||||
|
||||
// Page-lifetime cancellation: cancelled in Dispose() so a tick in flight when the circuit goes
|
||||
// away aborts its query instead of running it to completion against a component that's gone.
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
@@ -118,17 +126,18 @@ else
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
await using var db = await DbFactory.CreateDbContextAsync(_cts.Token);
|
||||
// Project the most-recent NodeDeploymentState per node — that's the row the
|
||||
// DriverHostActor most recently touched, regardless of which deployment it was for.
|
||||
var states = await db.NodeDeploymentStates.AsNoTracking()
|
||||
.GroupBy(s => s.NodeId)
|
||||
.Select(g => g.OrderByDescending(s => s.StartedAtUtc).First())
|
||||
.ToListAsync();
|
||||
.ToListAsync(_cts.Token);
|
||||
var byNode = states.ToDictionary(s => s.NodeId);
|
||||
|
||||
// Union with current Akka driver members so a freshly-joined node that has no
|
||||
@@ -151,8 +160,15 @@ else
|
||||
}).ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
// Circuit disposed while a tick was in flight — a cancelled tick is a clean exit, not a
|
||||
// fault (the discarded timer task would otherwise swallow it silently).
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release first: a StateHasChanged on a torn-down circuit must never leave the guard set.
|
||||
Volatile.Write(ref _tickRunning, 0);
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
@@ -168,7 +184,14 @@ else
|
||||
|
||||
private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting";
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
// Cancel BEFORE disposing the timer so a tick already in flight aborts its query rather than
|
||||
// running to completion; the CTS is disposed last, once nothing new can observe the token.
|
||||
_cts.Cancel();
|
||||
_timer?.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private sealed record NodeRow(
|
||||
string NodeId,
|
||||
|
||||
@@ -329,44 +329,66 @@ else
|
||||
// HostsDriverRow instance and would otherwise close a dialog an operator just opened.
|
||||
private string? _controlsDriverId;
|
||||
|
||||
// Overlap guard: a tick whose config load outruns the 5 s interval must not stack behind the
|
||||
// next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard.
|
||||
private int _tickRunning;
|
||||
|
||||
// Page-lifetime cancellation: cancelled in DisposeAsync() so a tick in flight when the circuit
|
||||
// goes away aborts its queries instead of running them against a component that's gone.
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Refresh();
|
||||
await LoadConfigAsync();
|
||||
RebuildDriverGroups();
|
||||
DriverStore.SnapshotChanged += OnSnapshotChanged;
|
||||
_timer = new Timer(_ => _ = InvokeAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Refresh();
|
||||
await LoadConfigAsync();
|
||||
RebuildDriverGroups();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException)
|
||||
{
|
||||
// Circuit disposed while a tick was in flight — ignore (the discarded task would
|
||||
// otherwise swallow this silently). Mirrors DriverStatusPanel's drain-on-dispose.
|
||||
}
|
||||
}), null,
|
||||
_timer = new Timer(_ => _ = InvokeAsync(TickAsync), null,
|
||||
TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
private async Task TickAsync()
|
||||
{
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
Refresh();
|
||||
await LoadConfigAsync();
|
||||
RebuildDriverGroups();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException)
|
||||
{
|
||||
// Circuit disposed while a tick was in flight — ignore (the discarded task would
|
||||
// otherwise swallow this silently). Mirrors DriverStatusPanel's drain-on-dispose.
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _tickRunning, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
await Task.Yield();
|
||||
Refresh();
|
||||
await LoadConfigAsync();
|
||||
RebuildDriverGroups();
|
||||
}
|
||||
catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException)
|
||||
{
|
||||
// Same clean-exit tolerance as the timer path.
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release first: a StateHasChanged on a torn-down circuit must never leave the guard set.
|
||||
Volatile.Write(ref _tickRunning, 0);
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
@@ -379,13 +401,19 @@ else
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await using var db = await DbFactory.CreateDbContextAsync(_cts.Token);
|
||||
_nodes = await db.ClusterNodes.AsNoTracking()
|
||||
.Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort))
|
||||
.ToListAsync();
|
||||
.ToListAsync(_cts.Token);
|
||||
_instances = await db.DriverInstances.AsNoTracking()
|
||||
.Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled))
|
||||
.ToListAsync();
|
||||
.ToListAsync(_cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The circuit went away mid-load. Rethrow so the caller treats it as a clean exit — it
|
||||
// must NOT fall into the swallow-and-clear below, which would blank the cached config.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -487,9 +515,13 @@ else
|
||||
{
|
||||
// Unsubscribe first so the singleton store can't invoke a handler on a disposed component.
|
||||
DriverStore.SnapshotChanged -= OnSnapshotChanged;
|
||||
// Cancel before draining so a tick already in flight aborts its queries rather than running
|
||||
// them to completion against a component that's gone.
|
||||
_cts.Cancel();
|
||||
// Drain the timer so an in-flight callback can't touch a component that's already gone
|
||||
// (System.Threading.Timer's async dispose awaits any in-flight callback — .NET 6+).
|
||||
if (_timer is not null) await _timer.DisposeAsync();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private sealed record MemberRow(
|
||||
|
||||
Reference in New Issue
Block a user