perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -137,6 +137,57 @@ When deploying artifacts (shared scripts, external system definitions, etc.) to
- Deployment is performed at the **individual instance level**.
- The UI may provide convenience operations (e.g., "deploy all out-of-date instances at Site A"), but these decompose into individual instance deployments.
### Bulk site deployment (`DeploySiteAsync`, WP2.5)
`DeploySiteAsync(siteId, user)` deploys every instance at one site in a single
operation, surfaced as the `MgmtDeploySite` management command and the CLI
`deploy site --site-id`. It is the "deploy all at Site A" convenience above, made
first-class — it still decomposes into individual instance deployments, and each
instance keeps its own deployment id, revision hash, operation lock, and
optimistically-concurrent status record.
It runs the ordinary deployment pipeline in three phases, of which only the middle
one is parallel:
1. **Prepare (serial).** Validate transition, take the operation lock, flatten +
validate, run query-before-redeploy reconciliation, stage the
`PendingDeployment`, insert the `InProgress` record. Every step here touches the
scoped, non-thread-safe `DbContext`, so the phase is strictly serial. All
instances share ONE `FlattenSession`, so a template chain common to N instances
is walked once and the session-global queries (shared scripts, schema library,
the site's data connections) run once for the batch.
2. **Send (bounded parallel).** The `RefreshDeploymentCommand` round-trips run
concurrently up to `SiteDeploymentMaxParallelism` (default 4), each under a
`SiteDeploymentTimeoutPerInstance` deadline (default 120 s). This phase touches
no repository — that is exactly why it is the only phase allowed to run in
parallel. Shape mirrors `ArtifactDeploymentService.DeployCoreAsync`.
3. **Finalize (serial).** Commit terminal statuses, apply post-success side
effects, write audit rows, release each operation lock.
**Not all-or-nothing.** An instance that fails for any reason (wrong state, failed
validation, lock already held by another operation, site round-trip timed out) is
reported as a failed row while the rest proceed, and is individually retryable via
the ordinary single-instance deploy. This matches the artifact-deployment policy:
successful targets are never rolled back because another target failed.
`DeployInstanceAsync` is composed from the same three phase helpers with a batch of
one, so the two entry points cannot drift on deployment identity, idempotency, lock
coverage, or optimistic concurrency.
### Terminal deployment-record retention
Deployment records are insert-only — one row per deploy attempt — so without a
window the table grows for the life of the system and every unfiltered deployment
query degrades with age rather than with page size.
`TerminalDeploymentRecordRetention` (default **365 days**, deliberately generous
because deployment history is operator forensics) bounds it, swept opportunistically
and rate-limited on the deployment-list read path.
Only **terminal** rows (`Success` / `Failed`) are eligible. An `InProgress` row is
never purged regardless of age: it is precisely the row the query-before-redeploy
reconciliation reads to decide whether a prior deploy actually landed at the site,
and expiring it by age would silently disable that idempotency guard.
## Diff View
Before deploying, the Deployment Manager can request a diff from the Template Engine showing:
@@ -87,7 +87,7 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
### Templates
- **ListTemplates** / **GetTemplate**: Query template definitions. `ListTemplates` supports additive offset paging via `Skip` / `Take` (arch-review P2): `Take = null` (the default) preserves the historical unpaged behaviour (unlimited), a set `Take` is clamped to `1..1000` and `Skip` floors at `0`.
- **ListTemplates** / **GetTemplate**: Query template definitions. `ListTemplates` supports additive offset paging via `Skip` / `Take` (arch-review P2): `Take = null` (the default) preserves the historical unpaged behaviour (unlimited), a set `Take` is clamped to `1..1000` and `Skip` floors at `0`. **Paging and projection are DB-side (WP2.5):** the handler reads `TemplateSummary` rows (child collections reduced to counts, no script bodies) via `GetTemplateSummariesAsync`, instead of materialising every template's full five-Include child graph and then discarding all but one page in memory. A caller that needs a template's members already fetches it with `GetTemplate`.
- **CreateTemplate** / **UpdateTemplate** / **DeleteTemplate**: Manage templates.
- **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
- **GetTemplateDiff**: Compare deployed vs. template-derived configuration for an instance.
@@ -115,9 +115,10 @@ The whole folder surface is reachable from the CLI as `template folder list|crea
### Instances
- **ListInstances** / **GetInstance**: Query instances, with filtering by site and area. `ListInstances` supports additive offset paging via `Skip` / `Take` (arch-review P2), applied **after** the in-memory site-scope filter so a site-scoped user never sees an out-of-scope instance surface into their page window; same `Take = null` unlimited default and `1..1000` clamp as `ListTemplates`. (`QueryDeployments` and `ExportBundle` still load the full table internally — a logged scale follow-up, arch-review P2 deferred.)
- **ListInstances** / **GetInstance**: Query instances, with filtering by site and area. `ListInstances` supports additive offset paging via `Skip` / `Take` (arch-review P2), applied **after** the in-memory site-scope filter so a site-scoped user never sees an out-of-scope instance surface into their page window; same `Take = null` unlimited default and `1..1000` clamp as `ListTemplates`. (`ExportBundle` still loads the full table internally — a logged scale follow-up, arch-review P2 deferred. `QueryDeployments` was closed by WP2.5, below.)
- **CreateInstance**: Create a new instance from a template.
- **UpdateInstanceOverrides**: Set attribute overrides on an instance.
- **UpdateInstanceOverrides** (`SetInstanceOverrides`): Set attribute overrides on an instance. Every requested attribute is validated against the instance's template up front (unknown or locked ⇒ the whole batch is rejected before any write), then the batch is applied as **one bulk read of the existing override rows plus one commit** (WP2.5). It previously ran K independent read-modify-commit cycles — each re-reading the instance, the template's attributes and the existing overrides, then committing and writing its own audit row — so the all-or-nothing promise was only as good as the pre-validation. It is now a real single-transaction apply, with one audit row summarising the batch.
- **MgmtDeploySite** (`SiteId`, WP2.5): Bulk-deploy every deployable instance at one site. Site scope is enforced on the SITE (the command's target); every instance the batch touches belongs to that site by construction. Same `Deployer` authority as `MgmtDeployInstance` — it is N of those, not a new class of privilege — and registered as a long-running command so the caller does not time out mid-batch. Semantics, phasing and failure policy: see `Component-DeploymentManager.md` → Bulk site deployment.
- **SetInstanceAlarmOverride** / **DeleteInstanceAlarmOverride** / **ListInstanceAlarmOverrides**: Manage per-instance computed-alarm overrides.
- **SetInstanceNativeAlarmSourceOverride** / **DeleteInstanceNativeAlarmSourceOverride**: Retarget or clear a per-instance native alarm source binding, keyed by `SourceCanonicalName``ConnectionNameOverride` / `SourceReferenceOverride` / `ConditionFilterOverride` each apply only when non-null. Gated to the **Deployment** role.
- **ListInstanceNativeAlarmSourceOverrides** (`InstanceId`): List an instance's native alarm source overrides (read-only).
@@ -144,7 +145,9 @@ The whole folder surface is reachable from the CLI as `template folder list|crea
- **DeployInstance**: Deploy configuration to a specific instance (includes pre-deployment validation).
- **DeployArtifacts**: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site. The command's `SiteId` is honored (arch-review C2): a value routes to the single-site deploy path (`ArtifactDeploymentService.DeployToSiteAsync`), while `null` deploys fleet-wide (`DeployToAllSitesAsync`). Site scope is enforced — a site-scoped (non-Administrator) Deployer may only target a site within its `PermittedSiteIds`, and may **not** deploy fleet-wide (`SiteId is null` is rejected with `SiteScopeViolationException``ManagementUnauthorized`, since `EnforceSiteScope(null)` is a deliberate no-op); fleet-wide deployment requires a system-wide Deployer or Administrator.
- **DeploySite** (`MgmtDeploySite`, WP2.5): Bulk-deploy every deployable instance at one site — see the Instances section above and `Component-DeploymentManager.md`.
- **GetDeploymentStatus**: Query deployment status.
- **QueryDeployments** (`InstanceId?`, `Status?`, `Page`, `PageSize`): List deployment records. **All four arguments are honoured DB-side (WP2.5)** and the result is a `DeploymentRecordSummary` projection. The handler previously ignored `Status`, `Page` and `PageSize` entirely (the CLI had been sending them all along) and loaded the whole insert-only `DeploymentRecords` table; for a site-scoped user it additionally loaded every instance and intersected the two sets in memory. Site scope is now resolved to the set of in-scope instance ids and pushed into the query as an id filter — an **empty** scope stays a real filter (a user permitted no in-scope instances sees nothing), never a no-op. This read path also drives the opportunistic, rate-limited terminal-record retention sweep described in `Component-DeploymentManager.md`.
### Secured Writes (MxGateway, two-person)
@@ -259,7 +262,7 @@ The ManagementActor receives the following services and repositories via DI (inj
| Section | Options Class | Contents |
|---------|--------------|----------|
| `ScadaBridge:ManagementService` | `ManagementServiceOptions` | `CommandTimeout` (`TimeSpan`, default 30 s) — Ask timeout the HTTP endpoint applies when forwarding to the `ManagementActor`. A non-positive configured value falls back to the 30 s default. |
| | | `LongRunningCommandTimeout` (`TimeSpan`, default 5 min) — Ask timeout applied to long-running commands (`ImportBundle`, `PreviewBundle`, `ExportBundle`, `MgmtDeployArtifacts`, `MgmtDeployInstance`); all other commands use `CommandTimeout`. A non-positive configured value falls back to the 5 min default. |
| | | `LongRunningCommandTimeout` (`TimeSpan`, default 5 min) — Ask timeout applied to long-running commands (`ImportBundle`, `PreviewBundle`, `ExportBundle`, `MgmtDeployArtifacts`, `MgmtDeployInstance`, `MgmtDeploySite`); all other commands use `CommandTimeout`. A non-positive configured value falls back to the 5 min default. |
| | | `SecuredWritePendingTtl` (`TimeSpan`, default 24 h) — age after which a `Pending` secured write is transitioned to `Expired` and can no longer be approved/executed; enforced at approve/reject and swept opportunistically on list. A non-positive value disables expiry (arch-review S2). |
## Dependencies
@@ -158,6 +158,19 @@ Mirrors `TriggerExpressionGlobals` in the same way. Used by `ValidationService.C
`CheckExpressionSyntax` memoises its verdict in the Template Engine's process-wide `ScriptCompileVerdictCache`, whose key is the pair **(globals surface, SHA-256 of the code)** — not the code alone. The surface discriminator is load-bearing: a trigger expression vetted against `TriggerCompileSurface` is **not** interchangeable with a `ScriptCompileSurface` script-body verdict (different globals resolve different identifiers), so a code-only key could return a stale "clean" for code never compiled against the caller's surface. Keying by surface makes that cross-surface verdict reuse structurally impossible.
**Eviction is segmented, never wholesale (WP2.5).** The cache is bounded at two
generations of 2048 entries. New verdicts land in the *hot* generation; a hit in
*cold* is promoted back into hot; on overflow hot becomes the new cold and only the
old cold — the half nothing has touched for a full generation — is dropped.
The bound must never be enforced by clearing the cache outright. That reads as
harmless ("a verdict is cheap to recompute") but is not: a recompute is a fresh
Roslyn compile, and every script compile loads an assembly through a
**non-collectible `InteractiveAssemblyLoader`**. Bounding that leak is the entire
reason this cache exists, so dropping every hot entry at the 4096th distinct script
would re-open it for the whole working set at once. Segmenting retains anything in
active use across an eviction while keeping the same ceiling.
#### Parity guard
A reflection-based parity test in `SiteRuntime.Tests` compares the public member names on `ScriptCompileSurface` against `ScriptGlobals` (and `TriggerCompileSurface` against `TriggerExpressionGlobals`). Any drift between the stub and the real globals causes this test to fail, ensuring the stubs cannot silently fall out of sync.
@@ -137,6 +137,64 @@ When an instance is deployed, the Template Engine resolves the full configuratio
5. Resolve data connection bindings — replace connection name references with concrete connection details from the site.
6. Output a flat structure: list of attributes with resolved values and data source addresses, list of alarms with resolved trigger definitions, list of scripts with resolved code and triggers.
### Flatten-session caching and the graph watermark (WP2.5)
The flatten above is driven per instance, so an unmemoised implementation re-walks
the same template chain — one query per link, plus the compositions of every
template it reaches and of every composed chain those reach — once per instance,
and re-issues the three session-global queries (shared scripts, the shared-schema
library, the target site's data connections) each time too.
A **`FlattenSession`** memoises all of that for the lifetime of ONE
flatten/validate operation. Callers that flatten a batch (`DeploySiteAsync`) pass
one shared session; a caller that passes none gets a private single-use session,
which still collapses the repeated composed-chain loads inside a single instance's
flatten.
Cache validity is decided by **`ITemplateGraphWatermark`**, a process-wide set of
monotonic counters bumped by the configuration-database unit of work — the only
place every template-graph writer funnels through (`TemplateService`, the
`ManagementActor` native-alarm-source handlers, and the Transport bundle importer
all commit via the same `SaveChangesAsync`, and the change tracker is inspected
pre-commit to attribute each change to its owning template or instance):
- a memoised **template** is keyed on `(id, template version)`;
- a memoised **chain** is valid only while BOTH the graph's `StructureVersion`
(bumped on add/remove, re-parent, or any composition change — i.e. anything that
can alter chain MEMBERSHIP) and every member's own version are unchanged. Both
halves are load-bearing: structure alone misses an ordinary member edit, member
versions alone miss a re-parent.
Sessions are short-lived by construction, so the design's "template state is
captured at the time of flatten" guarantee is unchanged — the session narrows the
capture window, it never widens it.
The watermark is **in-memory and process-local**, deliberately not persisted and
not replicated between central nodes. A restart, a failover, or any mutation simply
misses and falls back to the authoritative full flatten, so the watermark can only
ever cause EXTRA work, never stale work.
The same watermark backs a **staleness fast path** in `StaleInstanceProbe`: a
previously computed revision hash is reused when the instance's version, the
structure version, and the version of every template that flatten walked are all
unchanged. This matters most where the probe is called per-instance across a whole
bundle import or a fleet-wide staleness sweep.
### Design-time analysis reads
The design-time checks — acyclicity (`CycleDetector`), naming collisions
(`CollisionDetector`) and canonical-name resolution (`TemplateResolver`) — read the
whole template graph but write through none of it and read no script bodies. They
use `GetAllTemplatesForAnalysisAsync`: `AsNoTracking`, with `TemplateScript.Code`
projected away.
The tracked, body-bearing `GetAllTemplatesAsync` remains the read for the two paths
that genuinely need it — the inheritance reconciler (which compares and copies
script bodies and writes through the loaded entities) and the flattener (which must
observe rows an in-flight bundle import has staged on the shared change tracker).
`ReconcileDescendantsAsync` additionally accepts an already-loaded tracked graph so
a caller holding one does not force a second load.
### Native Alarm Source Resolution
The `FlatteningService` resolves native alarm sources alongside alarms, emitting a `ResolvedNativeAlarmSource` (CanonicalName, ConnectionName, SourceReference, ConditionFilter *(optional)*, and `Source``Template` | `Inherited` | `Composed` | `Override`) for each. The resolved set is attached to `FlattenedConfiguration.NativeAlarmSources`.