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

146 lines
11 KiB
Markdown

# 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 `PendingUpdatesFullEvaluationEvery`th 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.