docs: site artifact revision, pending-updates indicator, rail scroll pane
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user