docs: site artifact revision, pending-updates indicator, rail scroll pane

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 14:01:07 -04:00
parent c724ed6ee2
commit d8979664f1
6 changed files with 123 additions and 2 deletions
@@ -281,6 +281,56 @@ Component-NotificationService.md). Notification delivery happens on the central
cluster, so no notification artifact or SMTP credential is ever distributed to
sites.
### Per-site artifact revision (2026-09-14)
`Site` carries two nullable columns — `ArtifactRevisionHash` (`string?`, max 100)
and `ArtifactsDeployedAt` (`DateTimeOffset?`) — recording the artifact set last
successfully deployed to that site (migration `AddSiteArtifactRevision`; see
Component-ConfigurationDatabase.md). Both are `null` on a site that has never had
artifacts deployed, including every site immediately after an upgrade that ships
this migration — a redeploy landed the columns, it did not retroactively stamp
them, so the Sites page reads every site **Never deployed** until one artifact
deploy runs. **The two columns are deliberately not transported**: like an
imported instance landing `NotDeployed`, they are environment-specific runtime
state and the Transport round-trip guard excludes them by name. An import that
changes a site's data connections therefore correctly makes the site read out of
date until artifacts are redeployed there.
`ArtifactRevisionHashService` computes a deterministic SHA-256 over a canonical
projection of a per-site `DeployArtifactsCommand` — every artifact class sorted by
`Name` (ordinal), `DeploymentId`/timestamp excluded (so two deploys of identical
definitions hash identically), an external system's `MethodDefinitionsJson`
re-canonicalised with its methods sorted by name, and `TimeSpan` retry intervals
carried as ticks rather than a formatted string. Internal `Hashable*` records MUST
declare their properties in alphabetical (ordinal) order — `System.Text.Json`
serializes in declaration order and does not sort — pinned by a
`HashableRecords_PropertiesDeclaredAlphabetically` test, mirroring
`RevisionHashService`'s own determinism contract. Because a data connection's
configuration is plaintext in memory (the ciphertext is an EF value converter
applied only at rest), a no-op re-save that merely re-encrypts does not change the
hash.
`ArtifactDeploymentService` stamps `ArtifactRevisionHash` + `ArtifactsDeployedAt`
on the `Site` row **only for a site whose deploy succeeded**, on both the
all-sites/single-site sweep (`DeployCoreAsync`) and the per-site retry
(`RetryForSiteAsync`) paths. A failed site keeps its previous stamp (or none), so
it keeps reading out of date. **Ordering is deliberate**: the
`SystemArtifactDeploymentRecord` audit row is persisted first, and the stamp is
applied — and saved — afterward, inside its own try/catch. Stamping first would
let a stamp-save failure (concurrency conflict, constraint violation) escape
uncaught through the record's own `SaveChangesAsync` and turn a wholly successful
deploy into a reported failure; running the record's save first keeps a stamp
failure best-effort — logged at Warning, deploy still reports success, the site
simply keeps reading out of date until the next deploy.
`ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites)` returns
`siteId → hash` for what a deploy would send *right now* — the global artifact
queries are hoisted once for the whole batch, mirroring `DeployCoreAsync`'s own
hoist. A failing per-site data-connection query **aborts the whole batch by
design**: a partial map would silently report the unqueried sites as up to date,
so the pending-updates cache instead discards the faulted flight and the UI shows
the aggregate as unavailable.
## Site-Side Apply Atomicity
Applying a deployment at the site is **all-or-nothing per instance**:
@@ -293,7 +343,67 @@ Applying a deployment at the site is **all-or-nothing per instance**:
- Cross-site version skew for artifacts (shared scripts, external system definitions, data connection definitions, etc.) is **supported** — sites can temporarily run different artifact versions after a partial deployment.
- Artifacts are self-contained and site-independent. A site running an older version of shared scripts continues to operate correctly with its current instance configurations.
- The Central UI clearly indicates which sites have pending artifact updates so engineers can remediate.
- The Central UI clearly indicates which sites have pending artifact updates so engineers can remediate — see *Per-site artifact revision* above and *Pending-updates aggregate* below for the concrete mechanism (the Sites-page Artifacts badge and the rail indicator).
## Pending-updates aggregate (2026-09-14)
`DeploymentService.GetInstanceStalenessAsync(instanceIds)` answers instance
staleness for a whole batch cheaply enough to run fleet-wide on a cache-TTL
cadence, agreeing exactly with the per-instance Topology badge
(`GetDeploymentComparisonAsync`):
- All the flattens share **one `FlattenSession`** for the batch, so a template
chain used by N instances is walked once — the same trick `DeploySiteAsync`
uses.
- The deployed side is read as the `(InstanceId, RevisionHash)` **projection**
(`IDeploymentManagerRepository.GetDeployedRevisionHashesAsync`), never the fat
`ConfigurationJson` for the whole fleet.
- **Mismatch-only normalisation**: only when the projected hash disagrees with
the re-flatten is the full `DeployedConfigSnapshot` read and the I-1 List-value
normalisation applied (see *Deployed vs. Template-Derived State* above) — the
same normalisation `GetDeploymentComparisonAsync` applies, so the aggregate
never disagrees with the Topology badge on an old-form List snapshot, without
paying the fat read fleet-wide.
- `null` means "could not evaluate" (the instance's flatten failed) — counted as
**unknown**, never as stale; a fleet-wide read must not manufacture a pending
update out of a read failure. An instance with no deployed snapshot at all is
omitted, not counted as unknown.
- Per-instance failures are **not** logged individually — the whole batch runs at
most once per cache TTL, so one broken shared template fanning out to N
instances would otherwise emit N Warning lines every minute forever. A single
Warning summarises the batch's failure count instead; per-instance detail stays
at Debug.
`PendingUpdatesEvaluator` (scoped) combines that with
`ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync` into one
`PendingUpdatesSnapshot`: `ComputedAt`, plus per-site `SitePendingUpdates`
(`ArtifactState` — `NeverDeployed` / `Current` / `OutOfDate` —,
`ArtifactsDeployedAt`, `DeployedInstanceCount`, `StaleInstanceCount`,
`UnknownInstanceCount`) and fleet totals `StaleInstanceCount` /
`StaleArtifactSiteCount`. Only instances with a deployed snapshot (`State !=
NotDeployed`, the same gate Topology uses) are evaluated. **`NeverDeployed` is
deliberately not a pending update** — a freshly-created site the operator has not
gotten to yet has nothing drifted, so it is surfaced on the Sites page but never
counted toward the fleet totals or the rail indicator's badge count. Nothing in
the evaluator swallows an exception: a faulted evaluation propagates so the
cache's single-flight memo discards the flight and the Central UI renders the
aggregate as unavailable, rather than manufacturing a false "up to date".
The Central UI holds the snapshot behind a process-wide singleton
`IPendingUpdatesCache` (`PendingUpdatesCache`, mirroring `KpiSnapshotCache`): one
`SingleFlightMemo<PendingUpdatesSnapshot>` keyed by
`ScadaBridge:DeploymentManager:PendingUpdatesCacheTtl` (default 60 s, validated
positive by `DeploymentManagerOptionsValidator`), each flight running the
evaluator in its own DI scope. However many Blazor circuits poll
`GetAsync(forceRefresh)`, the fleet is flattened **at most once per TTL**, and not
at all when no Deployer/Admin circuit is open. See
`docs/plans/2026-09-14-pending-updates-design.md` for the full design, including
the Sites-page badge/re-check UI and the rail-footer indicator
(Component-CentralUI.md → *Site & Data Connection Management* and *Technology*).
Not in scope for this slice (deferred-work register rows 124–125): a
template-graph-watermark short-circuit so an unchanged fleet costs no re-flatten
per TTL, and a `deploy` CLI / Health-dashboard KPI surface for the counts.
## Instance Lifecycle Commands