Files
ScadaBridge/docs/plans/2026-09-14-pending-updates-design.md
T
Joseph Doherty a4806576af feat(pending-updates): short-circuit an unchanged fleet on a DB rowversion watermark (#62)
Owner decision (b), 2026-11-09. PendingUpdatesEvaluator re-flattened every
deployed instance on each cache-TTL expiry even when nothing had changed.

- Migration AddConfigGraphRowVersions: a non-concurrency rowversion column
  + index on the 18 configuration tables the evaluation reads (Templates +
  5 member tables, Instances + 4 override/binding tables, DataConnections,
  DeployedConfigSnapshots, Sites, SharedScripts, ExternalSystemDefinitions,
  ExternalSystemMethods, DatabaseConnectionDefinitions). SQL Server only in
  the model; SQLite test contexts are untouched. Script:
  docs/plans/sql/AddConfigGraphRowVersions.sql. Joins the coordinated
  central-pair migration window (#107 item 4).
- IConfigGraphWatermarkReader / ConfigGraphWatermarkReader: ONE query of
  MAX(RowVersion) + COUNT_BIG(*) per table (count catches deletes, which
  raise no rowversion). rowversion is engine-bumped for any writer, so a
  peer central node's edit moves it — not the per-process watermark MG-3
  removed.
- The secret store (outside the DB) gets its own stamp: SHA-256 over
  ListAsync(includeDeleted: true) metadata.
- Evaluator re-serves the last CLEAN snapshot while watermark + secret
  stamp are unchanged; every PendingUpdatesFullEvaluationEvery'th (default
  10, validated >= 1, 1 = off) is full regardless — the safety net for a
  same-KEK re-wrap or an input table missing from the set. Always full when
  the token is unavailable/unreadable, after an evaluation that could not
  compute a site's artifact hash, on Re-check, and after a deploy completion.
  It only re-serves a real earlier answer, never manufactures one.

Tests: SQL Server (real migrated DB) — token stable without writes, moves
for a peer-connection INSERT/UPDATE and a non-max DELETE, moves for a local
EF write, a detached Update still saves (not a concurrency token), and a
guard that every table the evaluator's repository reads touch is covered.
Evaluator — reuse without flatten (re-served answer still shows the stale
instance), watermark move, 4 secret changes, every-Nth, 5 no-token modes,
unclean evaluation not reused, cache Re-check and deploy completion force
full. Mutation-proven: token compare, clean check, cache force flag, row
count, and an uncovered read each turn tests red.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:38:55 -05:00

11 KiB

Pending Updates: site artifact staleness, rail indicator, scrollable nav — Design

Date: 2026-09-14 · Status: approved for implementation (plan: 2026-09-14-pending-updates.md)

1. Ask

Three Central UI changes, requested together:

  1. The Sites page does not show whether a site's deployed system-wide artifacts (shared scripts, external systems, DB connections, that site's data connections) are out of date. It should carry a Stale / Current badge like the Topology (template-instance) page does for instances.
  2. A single "pending updates" indicator in the rail footer, bottom-left just above the user name, summarising both kinds of pending update: stale instances and sites with stale artifacts.
  3. The expanding rail menu should sit in a scroll pane so a long nav never pushes the footer off-screen.

2. Current state (verified 2026-09-14)

  • Instance staleness = DeploymentService.GetDeploymentComparisonAsync re-flattens the instance (validateScripts:false) and compares RevisionHash with the DeployedConfigSnapshot (after the I-1 List-value normalisation). Topology computes it per loaded page with bounded concurrency, off the 15 s live timer, with a Re-check staleness button. There is no aggregate anywhere.
  • Artifact deployment (ArtifactDeploymentService) builds one DeployArtifactsCommand per site and persists a SystemArtifactDeploymentRecord with a per-site status JSON, but records no revision of what was sent, so "are this site's artifacts current?" is currently unanswerable.
  • The rail is the ZB.MOM.WW.Theme kit's ThemeShell (nav.side-rail, flex column; .rail-foot has margin-top:auto; #theme-rail is sticky 100vh on ≥ lg). The nav items are direct children of .side-rail; nothing scrolls internally, so a long nav grows past the viewport.
  • SingleFlightMemo<T> + KpiSnapshotCache already give the process-level, per-TTL, single-flight cache shape a polled aggregate needs.

3. Design

3.1 Site artifact revision (Commons + ConfigurationDatabase + DeploymentManager)

  • Site gains two nullable columns: ArtifactRevisionHash (string?, max 100) and ArtifactsDeployedAt (DateTimeOffset?). Migration AddSiteArtifactRevision. Additive; a site that has never had artifacts deployed stays null = Never deployed.
  • New ArtifactRevisionHashService (DeploymentManager) computes a deterministic SHA-256 over a canonical projection of a per-site DeployArtifactsCommand: every artifact class sorted by Name (ordinal), DeploymentId/timestamp excluded, alphabetically-declared hashable records, camelCase JSON, nulls omitted — the same determinism contract as RevisionHashService, pinned by a test. Data-connection configuration is plaintext in memory (EF value converter), so a no-op re-save that re-encrypts at rest does not change the hash.
  • ArtifactDeploymentService stamps ArtifactRevisionHash + ArtifactsDeployedAt on the Site row only for a site whose deploy succeeded (both DeployCoreAsync and RetryForSiteAsync). A failed site keeps its previous stamp, so it keeps showing out of date. The stamp is written in the same SaveChanges as the deployment record where possible.
  • ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites) returns siteId → hash for the current definitions, hoisting the global artifact queries once (same shape as DeployCoreAsync).
  • Transport does NOT carry the two columns. They are environment-specific runtime state, exactly like imported instances landing NotDeployed; the round-trip guard exclusion list names them with that reason. An import that changes a site's data connections therefore makes the site read out of date until artifacts are redeployed — the correct outcome.

3.2 Pending-updates aggregate (DeploymentManager, cached in CentralUI)

  • DeploymentService.GetInstanceStalenessAsync(instanceIds) → IReadOnlyDictionary<int, bool?>: one shared FlattenSession for the batch (template chains walked once, as DeploySiteAsync does), compares against a projection read of (InstanceId, RevisionHash) from DeployedConfigSnapshot (new IDeploymentManagerRepository.GetDeployedRevisionHashesAsync() — never the fat ConfigurationJson for the whole fleet). For a hash mismatch only, the full snapshot is read and the I-1 normalised deployed hash recomputed, so the aggregate agrees exactly with the Topology badge on old-form List snapshots. null = could not evaluate (flatten failed) — counted as unknown, never as stale.
  • PendingUpdatesEvaluator (scoped, DeploymentManager) → PendingUpdatesSnapshot: ComputedAt, per site SitePendingUpdates(SiteId, SiteIdentifier, SiteName, ArtifactState {NeverDeployed|Current|OutOfDate}, ArtifactsDeployedAt, DeployedInstanceCount, StaleInstanceCount, UnknownInstanceCount), plus totals StaleInstanceCount and StaleArtifactSiteCount. Instances are those with a deployed snapshot (the same gate Topology uses: State != NotDeployed).
  • IPendingUpdatesCache / PendingUpdatesCache (CentralUI, singleton, mirrors KpiSnapshotCache): one SingleFlightMemo<PendingUpdatesSnapshot> with TTL DeploymentManagerOptions.PendingUpdatesCacheTtl (default 60 s, validated > 0), running the evaluator in its own DI scope. GetAsync(forceRefresh). Consequence: however many circuits poll, the fleet is flattened at most once per TTL, and not at all when nobody is looking.
  • Not in scope (register rows): a deploy CLI/Health KPI surface for the counts.
  • Unchanged-fleet short-circuit (Gitea #62, register row 124, owner decision (b) 2026-11-09). Before the fleet pass the evaluator reads a change token = IConfigGraphWatermarkReader (ONE query: MAX(RowVersion) + COUNT_BIG(*) per table over the 18 input tables — Templates + their five member tables, Instances + their four override/binding tables, DataConnections, DeployedConfigSnapshots, Sites, SharedScripts, ExternalSystemDefinitions, ExternalSystemMethods, DatabaseConnectionDefinitions; migration AddConfigGraphRowVersions, list in ConfigGraphRowVersions) plus a SHA-256 stamp over the secret store's ListAsync(includeDeleted: true) metadata (name, revision, created/updated, tombstone, KEK id, content type, description). Equal to the token read before the last clean full evaluation → the previous snapshot is re-served (with a fresh ComputedAt). rowversion is database-wide and engine-bumped, so a peer central node's edit, a bundle import or an ExecuteUpdate all move it; the count catches a delete (which raises no rowversion). It is deliberately not the per-process template-graph watermark (MG-3). Safety net: every PendingUpdatesFullEvaluationEveryth evaluation (default 10 ≈ 10 min at the 60 s TTL; 1 disables the short-circuit) is full regardless — it covers a same-KEK re-wrap (wrap envelope changes, revision does not) and any input table someone forgets to add (a SQL Server guard test fails if a repository read used by the evaluator touches an uncovered table). Always full: watermark unavailable (non-SQL-Server provider) or unreadable, secret listing fails, the previous evaluation could not compute a site's artifact hash (secret-store outage — re-serving it would pin a transient Out of date), a Re-check (forceRefresh), and the evaluation after a deploy completion. It never manufactures an answer — it only re-serves a real earlier one.

3.3 Sites page (Admin)

  • Each site card shows an Artifacts badge next to the identifier: Current (light), Out of date (warning, bi-exclamation-triangle, tooltip = deployed-at UTC), Never deployed (secondary). Below it a muted line N of M instances out of date (link to /deployment/topology) when M > 0, since the snapshot already carries it. Badges match Topology's Bootstrap .badge convention (the kit's StatusPill is unused in this app).
  • Header gains Re-check (forces GetAsync(forceRefresh:true), disabled while running) and an as of HH:mm:ss UTC caption. A successful Deploy Artifacts (single or all) forces a refresh so the badge flips to Current immediately.
  • The snapshot is loaded after the site list, never blocking it: cards render with a checking… placeholder until it arrives; a snapshot failure shows unavailable on the badge and logs.

3.4 Rail indicator (PendingUpdatesIndicator, MainLayout RailFooter, above .rail-user)

  • Rendered only for a user holding Deployer or Administrator (the two roles that can act on it); hidden otherwise. Polls IPendingUpdatesCache.GetAsync() every 30 s through PollGate (cache TTL 60 s ⇒ polling is cheap), disposes its timer with the circuit, renders via InvokeAsync.
  • States: Checking for updates… (first load) · Up to date (muted, check icon) · Pending updates with a warning count pill and a breakdown line: 3 instances (links to Topology when Deployer) · 1 site (links to /admin/sites when Administrator, plain text otherwise) · Updates unavailable (error, tooltip has the message). Tooltip on the block: as of HH:mm UTC.
  • Site-scoped users see only their permitted sites' counts (SiteScopeService), consistent with Topology.

3.5 Scrollable nav

  • NavMenu.razor wraps its items in <div class="rail-scroll">; site.css adds, ≥ lg only: #theme-rail > .side-rail { height: 100%; } (fill the sticky 100 vh rail so .rail-foot's margin-top:auto actually pins) and .rail-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }. Below lg the rail is inline and scrolls with the page, unchanged. Kit untouched; a register row proposes upstreaming it into ZB.MOM.WW.Theme for the other consumers.

4. Tests

  • Unit: hash determinism (order-insensitive, field-sensitive, alphabetical-declaration guard); stamping on success only; GetInstanceStalenessAsync (shared session used once, mismatch-only normalisation, null on flatten failure); evaluator totals; cache single-flight/TTL/force; bUnit for the indicator (all states, role gating, scope filter), Sites page (badges, re-check, refresh-after-deploy), NavMenu wrapper; Transport round-trip guard exclusions; migration snapshot.
  • Rig: docker/deploy.sh (migration auto-applies), docker/verify-pending-updates.sh (CLI: site list shows artifactRevisionHash null → deploy artifacts → set; edit a shared script → central UI shows out of date; redeploy → current), plus a Playwright smoke for the indicator + Sites badge.

5. Operational notes

  • Production/QA: the migration ships as a delta script (dotnet ef migrations script AddAlarmMesReportable AddSiteArtifactRevision). After upgrade every site reads Never deployed until artifacts are deployed once — deliberate; one Deploy Artifacts to All Sites clears it.
  • Cost: one fleet flatten per PendingUpdatesCacheTtl while any Deployer/Admin circuit is open.