a4806576af
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
11 KiB
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:
- 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.
- 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.
- 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.GetDeploymentComparisonAsyncre-flattens the instance (validateScripts:false) and comparesRevisionHashwith theDeployedConfigSnapshot(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 oneDeployArtifactsCommandper site and persists aSystemArtifactDeploymentRecordwith 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.Themekit'sThemeShell(nav.side-rail, flex column;.rail-foothasmargin-top:auto;#theme-railis sticky100vhon ≥ lg). The nav items are direct children of.side-rail; nothing scrolls internally, so a long nav grows past the viewport. SingleFlightMemo<T>+KpiSnapshotCachealready give the process-level, per-TTL, single-flight cache shape a polled aggregate needs.
3. Design
3.1 Site artifact revision (Commons + ConfigurationDatabase + DeploymentManager)
Sitegains two nullable columns:ArtifactRevisionHash(string?, max 100) andArtifactsDeployedAt(DateTimeOffset?). MigrationAddSiteArtifactRevision. Additive; a site that has never had artifacts deployed staysnull= Never deployed.- New
ArtifactRevisionHashService(DeploymentManager) computes a deterministic SHA-256 over a canonical projection of a per-siteDeployArtifactsCommand: every artifact class sorted byName(ordinal),DeploymentId/timestamp excluded, alphabetically-declared hashable records, camelCase JSON, nulls omitted — the same determinism contract asRevisionHashService, 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. ArtifactDeploymentServicestampsArtifactRevisionHash+ArtifactsDeployedAton theSiterow only for a site whose deploy succeeded (bothDeployCoreAsyncandRetryForSiteAsync). A failed site keeps its previous stamp, so it keeps showing out of date. The stamp is written in the sameSaveChangesas the deployment record where possible.ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites)returnssiteId → hashfor the current definitions, hoisting the global artifact queries once (same shape asDeployCoreAsync).- 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 sharedFlattenSessionfor the batch (template chains walked once, asDeploySiteAsyncdoes), compares against a projection read of(InstanceId, RevisionHash)fromDeployedConfigSnapshot(newIDeploymentManagerRepository.GetDeployedRevisionHashesAsync()— never the fatConfigurationJsonfor 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 siteSitePendingUpdates(SiteId, SiteIdentifier, SiteName, ArtifactState {NeverDeployed|Current|OutOfDate}, ArtifactsDeployedAt, DeployedInstanceCount, StaleInstanceCount, UnknownInstanceCount), plus totalsStaleInstanceCountandStaleArtifactSiteCount. Instances are those with a deployed snapshot (the same gate Topology uses:State != NotDeployed).IPendingUpdatesCache/PendingUpdatesCache(CentralUI, singleton, mirrorsKpiSnapshotCache): oneSingleFlightMemo<PendingUpdatesSnapshot>with TTLDeploymentManagerOptions.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
deployCLI/HealthKPI 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; migrationAddConfigGraphRowVersions, list inConfigGraphRowVersions) plus a SHA-256 stamp over the secret store'sListAsync(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 freshComputedAt). rowversion is database-wide and engine-bumped, so a peer central node's edit, a bundle import or anExecuteUpdateall 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: everyPendingUpdatesFullEvaluationEveryth evaluation (default 10 ≈ 10 min at the 60 s TTL;1disables 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 lineN of M instances out of date(link to/deployment/topology) when M > 0, since the snapshot already carries it. Badges match Topology's Bootstrap.badgeconvention (the kit'sStatusPillis 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 throughPollGate(cache TTL 60 s ⇒ polling is cheap), disposes its timer with the circuit, renders viaInvokeAsync. - 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/siteswhen 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.razorwraps its items in<div class="rail-scroll">;site.cssadds, ≥ lg only:#theme-rail > .side-rail { height: 100%; }(fill the sticky 100 vh rail so.rail-foot'smargin-top:autoactually 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 intoZB.MOM.WW.Themefor 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 showsartifactRevisionHashnull → 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
PendingUpdatesCacheTtlwhile any Deployer/Admin circuit is open.