docs: arch-review remediation — component docs sweep, execution log, residuals register
Final consistency sweep per plan §6: verified component docs against shipped WP1-WP3 + adversarial-review-fix state, corrected drift found in SiteRuntime (recursion-exempt run cap, stale ScriptExecutionActor/AlarmExecutionActor references), TemplateEngine (BundleImporter watermark path), DeploymentManager (phase-2 PendingDeployment staging), CentralUI (shared KPI cache, dedup'd alarm poll, render coalescing), StoreAndForward (rate-limited drop logging), and ConfigurationDatabase (documented DbContext-pooling non-adoption). Updated the docs/components/ developer-reference set (SiteRuntime, SiteEventLogging, InboundAPI) to drop the deleted per-run actor classes. Amended one known-issue for the superseding MaxBatchSize:64 read-page pin. Added CLAUDE.md bullets for stream graceful-completion reconnect, the required site audit DB path, honest CLI HTTP timeouts, bulk DeploySiteAsync, and LocalDb 0.2.1. New execution log records the phase→commit map, gate results, adversarial-review tally, the three test-flake root causes, and the nine-item residuals register.
This commit is contained in:
@@ -139,6 +139,8 @@ Central cluster only. Sites have no user interface.
|
||||
- View diff between deployed and current template-derived configuration.
|
||||
- Deploy updated configuration to individual instances. **Pre-deployment validation** runs automatically before any deployment is sent — validation errors are displayed and block deployment.
|
||||
- Track deployment status (pending, in-progress, success, failed).
|
||||
- **Push-reload coalescing (arch-review WP2.4).** The page reloads its whole table (every deployment record + every instance) on each `DeploymentStatusChange` push, but the notifier fires per status *write* — a site-wide bulk deploy of N instances previously drove 2N+ back-to-back full reloads on the same circuit. Pushes are now leading-edge debounced (500ms): the first push after an idle gap reloads immediately (a single deployment stays as responsive as before), and every push inside the window collapses into one trailing reload.
|
||||
- **Known residual — no server-side paging.** The table still loads and filters every deployment record client-side; server-side paging plus precomputed status counts (deferred-work register item) is the follow-on for large fleets, not shipped in this remediation.
|
||||
|
||||
### System-Wide Artifact Deployment (Deployment Role)
|
||||
- Explicitly deploy shared scripts, external system definitions, database connection definitions, and data connection definitions to all sites or to an individual site. (Notification lists and SMTP configuration are central-only and are not deployed.)
|
||||
@@ -153,6 +155,7 @@ Central cluster only. Sites have no user interface.
|
||||
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side. The bridge actor opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` over the central→site gRPC command channel (`SiteCommandService`).
|
||||
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC data stream directly to the bridge actor — they do not travel on the command channel.
|
||||
- Events are delivered to the Blazor component via callbacks, which call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||||
- **Render coalescing (arch-review WP2.4).** Streamed events no longer trigger an individual dispatcher marshal + `StateHasChanged()` each — a chatty instance used to drive one full render (and two full tree rebuilds) per value change. Events now land in a thread-safe pending map keyed by attribute/alarm name (repeated updates to the same tag inside one window collapse to the latest), and exactly one dispatcher marshal per **250ms coalesce window** drains the map, bumps a version stamp, and renders once. This also fixes a latent thread-safety issue: the render dictionaries were plain (non-concurrent) `Dictionary`s enumerated by the render thread while written from the Akka/gRPC callback thread.
|
||||
- A pulsing "Live" indicator replaces the static "Connected" badge when streaming is active.
|
||||
- Subscribe-on-demand — stream starts when opened, stops when closed.
|
||||
- Read-only per-instance view (one instance per connection); no alarm acknowledgement is available from Debug View.
|
||||
@@ -192,8 +195,10 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
|
||||
- **Data path** — no new site-side code and no central alarm store. The page selects a site, queries its deployed instances, then fans out the existing per-instance `DebugViewSnapshot` Ask **concurrently** (capped with a `SemaphoreSlim`) and aggregates the returned `AlarmStates` client-side. The fan-out is **partial-results tolerant**: instances that time out are listed as "not reporting" while the rest still render. This snapshot fan-out now doubles as the **seed** for a near-real-time live feed (see **Live updates** below) rather than the sole refresh mechanism.
|
||||
- **Live updates** — the page is driven by a **transient, per-site central live alarm cache** (`ISiteAlarmLiveCache`, owned by the Communication component; see [Component-Communication](Component-Communication.md)). On site select the page subscribes to the cache; the cache runs one shared, reference-counted per-site aggregator that **seeds** from the snapshot fan-out and then stays warm on a single **site-wide, alarm-only** `SubscribeSite` gRPC stream (seed-then-stream, dedup by `(InstanceUniqueName, AlarmName, SourceReference)`). Applied deltas raise an in-process change event (mirroring `IDeploymentStatusNotifier`) that the Blazor circuit pushes to the browser via `StateHasChanged()` — no new SignalR hub. `AlarmSummaryService.BuildFromLiveAlarms` rebuilds the roll-up + rows from the cache's current alarm set. The cache is **purely in-memory on the active central node** — there is still **no persisted central alarm store**; on a NodeA↔NodeB failover the new active node re-seeds from scratch.
|
||||
- **View** — roll-up tiles (total active, worst severity, unacked count, per-`AlarmKind` counts) plus a flat, sortable, filterable table. Filters cover instance, `AlarmKind` (Computed / NativeOpcUa / NativeMxAccess), state, acked/unacked, severity threshold, and name search.
|
||||
- **Row virtualization (arch-review WP2.4).** Above `VirtualizeThreshold` (150) visible rows the table switches from a plain `foreach` to Blazor `Virtualize` (`ItemSize=37`, `<tr>` spacers), so a large-site alarm burst renders only the on-screen rows instead of every row in the DOM. Below the threshold the plain `foreach` stays — cheaper than the virtualization machinery and needs no JS interop. Both paths share one row-template, so the switch is invisible to styling/behavior.
|
||||
- **Read-only** — there are no ack / shelve / suppress controls (native alarms remain read-only by design).
|
||||
- **Refresh** — manual refresh button plus the 15s poll timer (mirroring the Health dashboard), now retained as a **fallback + `NotReporting` authority** behind the live cache: when the cache reports `IsLive`, the page renders live-cache state; when a stream is unhealthy, **the aggregator has died (deathwatch resets `IsLive`)**, or a site has not yet seeded, the poll keeps the page fresh so a stream failure never blanks it. Since WP2.3 `IsLive` also tracks the *stream* itself: a site-wide stream that faults or ends gracefully drops `IsLive` on the spot, so the page falls back to polling for the reopen window instead of rendering a snapshot that has quietly stopped updating. When live, the poll updates only the `NotReporting` list and leaves the row set to the delta path, so a slow fan-out can never momentarily revert a fresher live delta (R2 N5). (Aggregated live stream **delivered 2026-07-10** — see `docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`.)
|
||||
- **Poll fan-out is deduplicated too (arch-review WP2.4).** The cold-cache/fallback poll runs through `SharedAlarmSummaryService`, a process-level memoizing façade over `AlarmSummaryService`: one memo slot per site, single-flight, just-under-15s window, so N operators watching the same site's fallback poll cost the node ONE per-instance debug-snapshot fan-out per window, not N. While the live cache is serving a site there is no poll fan-out at all — the façade instead answers straight from `ISiteAlarmLiveCache` (same `BuildFromLiveAlarmsCore` path the live subscription uses), so the aggregator's own seed/reconcile fan-out is the only one running.
|
||||
- **Reuse** — the alarm badge/formatter markup is factored out of Debug View into a shared `AlarmStateBadges` component consumed by both Debug View and this page.
|
||||
|
||||
### Parked Message Management (Deployment Role)
|
||||
@@ -232,6 +237,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
|
||||
- Headline **Notification Outbox KPI tiles** — queue depth, stuck count, and parked count. These are central-computed by the Notification Outbox from the central `Notifications` table (not part of any site health report). The full outbox view is on the dedicated Notification Outbox page.
|
||||
- Headline **Site Call Audit KPI tiles** — buffered count, parked count, and failed-last-interval. These are central-computed by the Site Call Audit component from the central `SiteCalls` table (not part of any site health report). The full cached-call view is on the dedicated Site Calls page.
|
||||
- Headline **Audit KPI tiles** — three tiles in a new "Audit" KPI group: **Audit volume**, **Audit error rate**, and **Audit backlog**. These are sourced from the Audit Log component (#23) and Health Monitoring per the metric definitions in Component-HealthMonitoring.md; the dashboard simply surfaces them. The full audit query view is on the dedicated Audit Log page.
|
||||
- **Shared KPI cache (arch-review WP2.4).** All four KPI families above — Notification Outbox, Site Call Audit, Audit, and their per-site/per-node breakdowns — are read through a process-level `IKpiSnapshotCache`, not queried per page load. Each accessor is independently memoized (8s TTL) with single-flight production, so N Blazor circuits polling the same KPI inside one window (e.g. ten operators with the Health dashboard open) cost the node ONE aggregate SQL round trip per KPI per window, not N. A failed query is never memoized — the next caller re-attempts it, and the calling page's existing per-tile "unavailable" degradation is unchanged. Every KPI-tile page (Health, Notification Outbox, Site Calls, Audit Log) shares the same cache instance.
|
||||
|
||||
### Site Event Log Viewer (Deployment Role)
|
||||
- Query site event logs remotely.
|
||||
|
||||
@@ -432,6 +432,7 @@ The `AuditLog` table is append-only and grows by every script-trust-boundary eve
|
||||
- Connection strings are provided via the Host's `DatabaseConfiguration` options (bound from `appsettings.json`).
|
||||
- EF Core manages connection pooling via the underlying ADO.NET SQL Server provider.
|
||||
- The DbContext is registered as a **scoped** service in the DI container, ensuring each request/operation gets its own instance.
|
||||
- **`DbContext` pooling (`AddDbContextPool`) is deliberately NOT used (arch-review WP2.2 — verified, not overlooked).** Pooling requires a context with a single public constructor taking only `DbContextOptions<TContext>`; `ScadaBridgeDbContext` has a second, runtime constructor taking `IDataProtectionProvider`, because the encrypting value converter for secret-bearing columns is built during `OnModelCreating` from that provider — and the model itself *differs* between the two constructors (no provider ⇒ no encrypting converter), so a pooled activator could silently produce a context that reads secret columns as ciphertext. Adopting pooling would mean moving the protector out of the constructor into a `DbContextOptions` extension — a change to the secrets-at-rest path, not a performance refactor — so it is deferred rather than folded into this pass.
|
||||
- No connection management for the Machine Data Database — that is handled separately by consumers (Inbound API scripts, external system gateway).
|
||||
|
||||
---
|
||||
|
||||
@@ -150,17 +150,22 @@ It runs the ordinary deployment pipeline in three phases, of which only the midd
|
||||
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`.
|
||||
validate, run query-before-redeploy reconciliation, 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).** Concurrently up to `SiteDeploymentMaxParallelism`
|
||||
(default 4), each under a `SiteDeploymentTimeoutPerInstance` deadline
|
||||
(default 120 s): stage the `PendingDeployment` row **immediately before** that
|
||||
instance's `RefreshDeploymentCommand` round-trip, not upfront in phase 1 —
|
||||
`PendingDeployment` carries a 5-minute TTL, and staging every instance in the
|
||||
batch serially in phase 1 would burn a chunk of that TTL for the tail
|
||||
instances before their round-trip even starts (review fix, commit
|
||||
`e0e4b246`). Staging touches the repository but is scoped per-instance, so it
|
||||
does not reintroduce serialization. Shape mirrors
|
||||
`ArtifactDeploymentService.DeployCoreAsync`.
|
||||
3. **Finalize (serial).** Commit terminal statuses, apply post-success side
|
||||
effects, write audit rows, release each operation lock.
|
||||
|
||||
@@ -170,6 +175,15 @@ reported as a failed row while the rest proceed, and is individually retryable v
|
||||
the ordinary single-instance deploy. This matches the artifact-deployment policy:
|
||||
successful targets are never rolled back because another target failed.
|
||||
|
||||
**Cancellation is lock-safe (review fix, commit `e0e4b246`).** A cancelled bulk
|
||||
deploy no longer leaks the per-instance operation lock of any instance that had
|
||||
already taken one — a leaked lock is a wedged semaphore that never releases for
|
||||
the life of the process. Phase 2 does not throw on cancellation; each in-flight
|
||||
instance is instead recorded with a `Failed` outcome so phase 3 (finalize) still
|
||||
runs for it. An escape from phase 1 or phase 3 unwinds every not-yet-finalised
|
||||
entry the same way: `Failed` status plus lock release, so no instance touched by
|
||||
a cancelled bulk deploy can be left holding its lock.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -227,6 +227,7 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
|
||||
- **Pool sizing is instance-scaled and grow-only (WP3.1).** The pool was a fixed 8 threads regardless of load. It is now `clamp(max(ScriptExecutionThreadCount, ceil(enabledInstances / 8)), 1, ScriptExecutionMaxThreadCount)` — the existing `ScriptExecutionThreadCount` (default 8) becomes the **floor**, so configurations at or below 64 instances behave exactly as before, and the new `ScriptExecutionMaxThreadCount` (default 32) is the ceiling. Beyond the ceiling the per-script cap below is the real regulator. `DeploymentManagerActor.UpdateInstanceCounts` calls `EnsureCapacity` on every deploy / undeploy / enable / disable and once per staggered startup batch. Growth only: undeploying leaves idle threads, which cost nothing measurable and avoid drain/steal complexity.
|
||||
- **The deadline is armed at enqueue, not at dequeue (WP3.1).** The run's timeout `CancellationTokenSource` is created on the actor thread *before* the body is queued, so queue wait consumes the script's own budget. A body that dequeues past its deadline **skips execution entirely** and takes the existing timeout path (site event, script-error counter, error reply, completion message): a saturated pool sheds stale work instead of running it late with a fresh full budget.
|
||||
- **Concurrent runs per script are capped (WP3.1).** `MaxConcurrentRunsPerScript` (default 4) bounds runs in flight — queued or executing — for any one script or alarm on-trigger script. Over the cap the **newest** run is shed: the four already in flight are closest to their own deadlines and already charged against them, so nothing is ever reordered and no extra queue is needed (the scheduler's FIFO already is the queue). A shed increments `ISiteHealthCollector.IncrementScriptRunShed` (surfaced as `ScriptRunShedCount`), emits a `script`/`Warning` site event **rate-limited to one per script per minute** so a hot trigger cannot flood `site_events`, and — for an Ask-based `CallScript` — replies with an explicit error so a nested call or inbound-API route fails fast instead of hanging.
|
||||
- **The cap gates only depth-0 launches (review fix).** A trigger fire or a depth-0 `CallScript`/inbound-API route counts against `MaxConcurrentRunsPerScript`; a *nested* `Instance.CallScript` self-recursion (`callDepth > 0`) is exempt — it is already bounded by `MaxScriptCallDepth` instead, and the calling run is holding one of the four cap slots itself while it awaits the callee, so counting the nested launch too would spuriously shed legitimate self-recursion.
|
||||
|
||||
### Handling `Instance.CallScript`
|
||||
- When an external caller (another script run, an alarm on-trigger run, or a routed call from the Inbound API) sends a `CallScript` message to the Script Actor, it launches a run to handle the call.
|
||||
@@ -252,7 +253,7 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
|
||||
- **Expression** trigger evaluation runs on the shared .NET thread pool behind a process-wide concurrency gate (`TriggerEvalGate`, sized by `TriggerEvalMaxConcurrency`, default `max(2, ProcessorCount)`) — **not** on the bounded script-execution pool. This is the WP3.1 fix for arch-review finding #4 (High): when evaluation shared that pool, N script bodies blocked in synchronous I/O stalled *every* Expression trigger on the node — scripts and alarms alike — for an unbounded time, and the evaluation's own 2 s timeout was constructed inside the queued body, so it did not start ticking until dequeue. An alarm that should have raised in milliseconds simply never raised, with neither a raise nor a timeout visible to the operator. Trigger expressions are non-blocking **by construction** (`TriggerExpressionGlobals` exposes only reads over an in-memory snapshot, and the script trust gate has already denied I/O, network, threading, and reflection), so the shared pool is where they belong; a second dedicated pool was considered and rejected as adding threads, gauges, and a second starvation surface for no isolation gain. The evaluation deadline (`TriggerEvalTimeoutSeconds`, default 2, previously hardcoded) is now armed **at enqueue**, so gate-wait time burns the same budget and a saturated gate yields a timely `false` rather than an unbounded stall. Per-actor coalescing (one evaluation in flight, one pending) is unchanged and caps waiters at one per Expression trigger, so the gate queue is bounded by trigger count.
|
||||
- For binary trigger types (ValueMatch / RangeViolation / RateOfChange), when the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**:
|
||||
- Updates the alarm state on the parent Instance Actor (which publishes to the Akka stream).
|
||||
- If an on-trigger script is defined, spawns an Alarm Execution Actor to execute it.
|
||||
- If an on-trigger script is defined, launches its run via `ScriptRunLauncher` (see Alarm On-Trigger Run below) — no per-run child actor.
|
||||
- When the condition clears and the alarm is in **active** state, the alarm transitions to **normal**.
|
||||
- For HiLo triggers, the actor tracks the current `AlarmLevel` (None / Low / LowLow / High / HighHigh). Each level transition emits a fresh `AlarmStateChanged` with the new level and its priority; level escalations (e.g., High → HighHigh) and de-escalations (HighHigh → High) both produce events. The on-trigger script fires only on the Normal → non-None edge, not on escalations between alarm bands.
|
||||
- No script execution on clear in any trigger type.
|
||||
@@ -507,14 +508,14 @@ Per Akka.NET best practices, internal actor communication uses **Tell** (fire-an
|
||||
|
||||
**Ask** is reserved for system boundaries where a synchronous response is needed:
|
||||
|
||||
- **`Instance.CallScript()`**: Ask pattern from Script Execution Actor to sibling Script Actor. The caller needs the return value. Acceptable because script calls are infrequent relative to tag updates.
|
||||
- **`Instance.CallScript()`**: Ask pattern from the running script (via `ScriptRuntimeContext.CallScript`) to sibling Script Actor. The caller needs the return value. Acceptable because script calls are infrequent relative to tag updates.
|
||||
- **`Route.To().Call()`**: Ask from Inbound API to site Instance Actor via Communication Layer. External caller needs a response.
|
||||
- **Debug view snapshot**: Ask from Communication Layer to Instance Actor for initial state.
|
||||
|
||||
## Concurrency & Serialization
|
||||
|
||||
- The Instance Actor processes messages **sequentially** (standard Akka actor model). This means `SetAttribute` calls from concurrent Script Execution Actors are serialized at the Instance Actor, preventing race conditions on attribute state.
|
||||
- Script Execution Actors may run concurrently, but all state mutations (attribute reads/writes, alarm state updates) are mediated through the parent Instance Actor's message queue.
|
||||
- The Instance Actor processes messages **sequentially** (standard Akka actor model). This means `SetAttribute` calls from concurrent script runs are serialized at the Instance Actor, preventing race conditions on attribute state.
|
||||
- Script runs (launched via `ScriptRunLauncher`, no per-run child actor) may run concurrently, but all state mutations (attribute reads/writes, alarm state updates) are mediated through the parent Instance Actor's message queue.
|
||||
- External side effects (external system calls, notifications, database writes) are not serialized — concurrent scripts may produce interleaved side effects. This is acceptable because each side effect is independent.
|
||||
|
||||
## SiteStreamManager and gRPC Integration
|
||||
|
||||
@@ -108,6 +108,12 @@ Notifications are unaffected: they have no tracking table. Their `NotificationId
|
||||
|
||||
On every tracking-table status transition, the site emits a `CachedCallTelemetry` message to the central Site Call Audit component over the site→central channel. Emission is best-effort, at-least-once, and idempotent on `TrackedOperationId`. Because telemetry is best-effort, the site also responds to `CachedCallReconcileRequest` reconciliation pulls — cursor-based per-site reads of tracking rows changed since a cursor — so any missed telemetry self-heals. The site never depends on central; central converges to the site.
|
||||
|
||||
The telemetry-emitting `ICachedCallLifecycleObserver` hook is dispatched through a bounded, single-reader **observer queue** (`StoreAndForwardOptions.ObserverQueueCapacity`, default 10,000, `DropOldest`) — the one unbounded `Channel<T>` left in the system before this bound was added (arch-review WP2.6c). A slow or stuck observer (e.g. a SQLite audit write wedged behind disk contention) can no longer grow this queue without limit; it instead sheds the oldest unprocessed notification, incrementing an `ObserverQueueDroppedCount` counter that counts every drop. **Logging is separately rate-limited (adversarial review finding F3):** a Warning fires for the first drop of an episode, then at most one rollup Warning per minute while drops keep happening — never one Warning-per-dropped-item, which would itself have been a log-flood risk under the exact sustained-drop condition the bound exists to survive. This bounds memory, not delivery: telemetry loss here is covered by the reconciliation pull above, same as any other missed telemetry.
|
||||
|
||||
### Retry Sweep Indexing
|
||||
|
||||
`GetMessagesForRetryAsync` orders candidates `ORDER BY created_at ASC` within the due `status`. The existing `idx_sf_messages_status_due (status, last_attempt_at_ms)` matches the status filter and due-time predicate but not this ordering, forcing a sort/scan on a large sweep. A second covering index, `idx_sf_messages_status_created (status, created_at)`, lets the sweep walk matching rows already in `created_at` order and stop at the batch limit without re-sorting or touching non-pending rows (arch-review WP1.4). Both indexes are retained — `idx_sf_messages_status_due` still backs status+due-time lookups that don't order by `created_at`.
|
||||
|
||||
## Parked Message Management
|
||||
|
||||
- Parked messages remain stored at the site in SQLite.
|
||||
|
||||
@@ -152,11 +152,19 @@ which still collapses the repeated composed-chain loads inside a single instance
|
||||
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):
|
||||
monotonic counters. `TemplateService` and the `ManagementActor` native-alarm-source
|
||||
handlers bump it via `TemplateEngineRepository.SaveChangesAsync`'s change-tracker
|
||||
inspection, which attributes each pending change to its owning template or
|
||||
instance pre-commit. The Transport bundle importer (`BundleImporter`) commits
|
||||
through the raw `DbContext` instead — bypassing that repository — so it calls
|
||||
`ITemplateGraphWatermark.BumpAll()` itself, once per import: after commit on
|
||||
success, after rollback on failure (a bundle import can touch templates,
|
||||
instances, and connections in one transaction, so a scoped bump isn't
|
||||
worthwhile). A data connection edit (`SiteRepository.SaveChangesAsync`) also bumps — via `BumpAll()`,
|
||||
since a connection has no owning template — because `Protocol`/`PrimaryConfiguration`/
|
||||
`BackupConfiguration`/`FailoverRetryCount` are revision-hash inputs (review fix, commit
|
||||
`e0e4b246`, F2c). All three writers converge on the same watermark, so a cached flatten
|
||||
still invalidates correctly regardless of which one changed the graph:
|
||||
|
||||
- a memoised **template** is keyed on `(id, template version)`;
|
||||
- a memoised **chain** is valid only while BOTH the graph's `StructureVersion`
|
||||
@@ -225,6 +233,18 @@ A derived template stores `IsInherited` **placeholder rows** mirroring every mem
|
||||
|
||||
Both delegate to one **order-independent reconcile** that compares a template's stored inherited rows against the inheritance resolver's effective set (the same precedence + HiLo merge the editor preview and deploy use) and only ever touches `IsInherited` placeholder rows — never an authored override (`IsInherited == false`). Because the resolver ignores placeholder rows when picking winners, reconciling one template never changes what another resolves, so the operation needs no particular ordering. The effective value mirrored into each placeholder matches the staleness comparison key per member type, so after a reconcile the "base changed" banner clears. Reconcile is best-effort housekeeping for the *stored authoring rows*: a deploy always re-resolves the chain fresh regardless, so a not-yet-resynced template still deploys correctly.
|
||||
|
||||
### Slim List Projections (WP2.5)
|
||||
|
||||
The `ListTemplates` management command (backing the CLI `template list` and the Central UI
|
||||
template tree) previously materialised every template's full child graph — attributes, alarms,
|
||||
script bodies, compositions, native alarm sources — to page it in memory and discard all but one
|
||||
page. `ITemplateEngineRepository.GetTemplateSummariesAsync(skip, take)` instead pages **in the
|
||||
database** against `TemplateSummary`, a row-shaped projection (id, name, description, parent/folder
|
||||
ids, derived flag) with child collections reduced to **counts** — the only thing a list surface
|
||||
renders. `TemplateSummary.MaxPageSize` (1000) mirrors the existing in-memory page clamp so DB-side
|
||||
paging cannot be used to pull an unbounded result set. Additive-only message-contract evolution,
|
||||
same rule as other Commons message types.
|
||||
|
||||
## Diff Calculation
|
||||
|
||||
The Template Engine can compare:
|
||||
|
||||
Reference in New Issue
Block a user