fix(centralui): P6-11 — request-generation guard on Deployments page
Three fetch initiators (search debounce, paging, push/coalesce reload) could each have a LoadDataAsync flight in flight at once; completion order was not call order, so a slow, stale flight could resolve after a newer one and clobber the grid the operator was already looking at. Add a monotonic _requestGeneration counter, bumped at fetch start and checked at apply — last-write-wins by generation, not by whichever await resolves first. Separately, the filter-debounce timer wrote `_currentPage = 1` directly on its raw System.Threading.Timer callback thread — a Blazor threading violation, since component state may only be mutated on the renderer's sync context — and that write was not covered by the generation guard at all. LoadDataAsync now takes a resetToFirstPage flag, resolves the target page into a local, and applies it only behind the same generation check as every other piece of state, via a new DispatchFilterReloadAsync that marshals through InvokeAsync like the existing push-reload path. Tests (DeploymentsRequestGenerationTests, bUnit): a delayed stale flight arriving after a newer one does not overwrite the grid; a stale filter-debounce flight does not reset the page after a newer navigation. Both driven deterministically via TaskCompletionSource-backed repository stubs. Verified to fail against the unfixed code (swapped via `git show HEAD:<path>`, not `git stash` — repo-wide and unsafe across the parallel worktree wave).
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
|
||||
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="() => LoadDataAsync()" aria-label="Refresh deployments">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,6 +259,19 @@
|
||||
private int _totalPages;
|
||||
private const int PageSize = 25;
|
||||
|
||||
// ── Request-generation guard (P6-11) ────────────────────────────────────────
|
||||
// Three independent initiators can each start a LoadDataAsync flight: a search
|
||||
// debounce, a page click, and the push/coalesce reload. Every flight awaits its
|
||||
// own round-trip to central MS SQL, so COMPLETION order is not CALL order — a
|
||||
// slow, stale flight that started first can resolve after a faster, newer one
|
||||
// and clobber the grid the operator is already looking at. _requestGeneration
|
||||
// is bumped synchronously at the top of every LoadDataAsync call (call order is
|
||||
// still reliable — increments never interleave on the renderer's dispatcher),
|
||||
// and a flight discards its own results at apply time if a newer flight has
|
||||
// since started. Last-write-wins by GENERATION, not by whichever await
|
||||
// resolves first.
|
||||
private long _requestGeneration;
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────
|
||||
private DeploymentStatus? _statusFilter;
|
||||
|
||||
@@ -423,10 +436,48 @@
|
||||
if (_disposed) return;
|
||||
}
|
||||
|
||||
// A changed search term redefines the result set, so the operator's current
|
||||
// page number no longer refers to anything — restart at page 1.
|
||||
_currentPage = 1;
|
||||
_ = DispatchReloadAsync();
|
||||
// This callback runs on the raw System.Threading.Timer thread — it is NOT
|
||||
// the renderer's synchronization context. _currentPage is component state,
|
||||
// so its reset to page 1 must be marshaled through InvokeAsync the same as
|
||||
// the reload itself (P6-11); writing it here directly was a Blazor
|
||||
// threading violation that could also race a same-tick page click on the
|
||||
// renderer thread.
|
||||
_ = DispatchFilterReloadAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals the filter-debounce trailing edge onto the renderer's dispatcher.
|
||||
/// The page-1 reset MUST happen inside <c>InvokeAsync</c>, and behind the SAME
|
||||
/// request-generation guard as the rest of the applied state (P6-11) —
|
||||
/// <see cref="OnFilterDebounceElapsed"/> runs on a raw
|
||||
/// <see cref="System.Threading.Timer"/> callback thread, not the renderer's
|
||||
/// synchronization context, and a slow search flight can resolve after the
|
||||
/// operator has already navigated to a different page by other means. Passing
|
||||
/// <c>resetToFirstPage: true</c> defers the actual <c>_currentPage = 1</c>
|
||||
/// write into <see cref="LoadDataAsync"/>'s apply step, so a stale flight here
|
||||
/// is discarded exactly like a stale flight on any other initiator.
|
||||
/// </summary>
|
||||
private async Task DispatchFilterReloadAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// A changed search term redefines the result set, so the
|
||||
// operator's current page number no longer refers to anything —
|
||||
// restart at page 1 (applied only if this flight is still current;
|
||||
// see the resetToFirstPage remarks on LoadDataAsync).
|
||||
await LoadDataAsync(resetToFirstPage: true);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Component disposed between the guard and the dispatch — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -497,8 +548,27 @@
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
/// <param name="resetToFirstPage">
|
||||
/// True when this flight represents a redefinition of the result set (a new
|
||||
/// search term) rather than a plain reload of whatever page is currently on
|
||||
/// screen. The target page is resolved into a LOCAL <c>pageToQuery</c> rather
|
||||
/// than writing <c>_currentPage</c> up front, so the page number — like every
|
||||
/// other piece of applied state — only lands once this flight has been
|
||||
/// confirmed to still be the newest one (see the generation guard below). A
|
||||
/// stale, slow search flight that resolves after a newer page click must not
|
||||
/// yank the operator back to page 1 out from under a navigation they already
|
||||
/// completed.
|
||||
/// </param>
|
||||
private async Task LoadDataAsync(bool resetToFirstPage = false)
|
||||
{
|
||||
// Captured before any await — see the request-generation guard note on
|
||||
// _requestGeneration above. Bumping it here (rather than at apply time)
|
||||
// means it also marks THIS flight as the newest one in flight, so an
|
||||
// earlier flight still awaiting its own round-trip will find itself
|
||||
// stale when it reaches the check below.
|
||||
var generation = ++_requestGeneration;
|
||||
var pageToQuery = resetToFirstPage ? 1 : _currentPage;
|
||||
|
||||
_loading = _rows.Count == 0; // Only show loading on first load
|
||||
_errorMessage = null;
|
||||
try
|
||||
@@ -520,16 +590,29 @@
|
||||
SiteIdScope: siteScope);
|
||||
|
||||
var page = await DeploymentManagerRepository
|
||||
.QueryDeploymentListPageAsync(filter, _currentPage, PageSize);
|
||||
.QueryDeploymentListPageAsync(filter, pageToQuery, PageSize);
|
||||
|
||||
// The tile counts deliberately ignore the status filter (see
|
||||
// GetDeploymentStatusCountsAsync) so each tile keeps showing its own
|
||||
// total while it is the one selected.
|
||||
_counts = await DeploymentManagerRepository.GetDeploymentStatusCountsAsync(filter);
|
||||
var counts = await DeploymentManagerRepository.GetDeploymentStatusCountsAsync(filter);
|
||||
|
||||
// A newer flight has started since this one began (its generation is
|
||||
// now the current one) — this flight's results are stale. Discard
|
||||
// them rather than overwrite whatever the newer flight already
|
||||
// applied (or is about to apply). This guards _currentPage exactly
|
||||
// like every other applied field — a stale reset-to-page-1 flight
|
||||
// must not clobber a page the operator has since navigated to.
|
||||
if (generation != _requestGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentPage = pageToQuery;
|
||||
_rows = page.Rows;
|
||||
_totalCount = page.TotalCount;
|
||||
_totalPages = page.PageCount(PageSize);
|
||||
_counts = counts;
|
||||
|
||||
// A concurrent retention purge, or a filter that narrowed the set, can
|
||||
// leave the operator past the end. Fall back to the last real page and
|
||||
@@ -539,6 +622,12 @@
|
||||
_currentPage = _totalPages;
|
||||
var reread = await DeploymentManagerRepository
|
||||
.QueryDeploymentListPageAsync(filter, _currentPage, PageSize);
|
||||
|
||||
if (generation != _requestGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rows = reread.Rows;
|
||||
_totalCount = reread.TotalCount;
|
||||
_totalPages = reread.PageCount(PageSize);
|
||||
@@ -546,9 +635,18 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (generation != _requestGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_errorMessage = $"Failed to load deployments: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
|
||||
if (generation == _requestGeneration)
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GoToPageAsync(int page)
|
||||
|
||||
Reference in New Issue
Block a user