Compare commits
68 Commits
c6769ee95e
...
15e5887b52
| Author | SHA1 | Date | |
|---|---|---|---|
| 15e5887b52 | |||
| 2b1e220f7c | |||
| 75195fc512 | |||
| da23f2dcae | |||
| 71c7ed91c5 | |||
| 5162408a9f | |||
| ddb3c32e06 | |||
| 817a72108e | |||
| 2cae8773bf | |||
| 524bdbbe90 | |||
| 8f2c2813ea | |||
| 92b587d2a1 | |||
| 3d94bec308 | |||
| 2807b5a3ea | |||
| 07220bc655 | |||
| 019edf1c46 | |||
| b6676a89e6 | |||
| f46cc19736 | |||
| 4d39ad3cd7 | |||
| 9f418a6aea | |||
| dd2391abe2 | |||
| e606b24a55 | |||
| d9a35846d9 | |||
| 8b761841bc | |||
| a2f0086fa1 | |||
| 851b528ca3 | |||
| 40944a0d04 | |||
| 9d668861ad | |||
| 4ae57bdb6b | |||
| 98c9458830 | |||
| 3dc38fb674 | |||
| e3ca77a234 | |||
| caf9f45165 | |||
| 744ff36a7b | |||
| e8a90f18dc | |||
| 230c841d35 | |||
| c26b52ad68 | |||
| d607011e5e | |||
| acc6209665 | |||
| 92fe5acca7 | |||
| 7a7d345b89 | |||
| b2cab59cb9 | |||
| be29bf471e | |||
| 0db253b55f | |||
| 8da60f57ac | |||
| 41a5ecf14a | |||
| 0d2d6de6c6 | |||
| 103f2f1931 | |||
| b5f120c370 | |||
| abf864b4e5 | |||
| 5da27e05c7 | |||
| 65b19516c8 | |||
| 482165cbac | |||
| 90ce099928 | |||
| ef876b38c5 | |||
| 002299c400 | |||
| cde4af77fd | |||
| 4785f3cbef | |||
| 1be08e96cc | |||
| a404854212 | |||
| 73f518d6a6 | |||
| 00a804e6d9 | |||
| 677263af57 | |||
| b5e6821caa | |||
| 75520beece | |||
| 372ea4df1b | |||
| 09108018f1 | |||
| b449fe201a |
@@ -83,7 +83,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- `site_events.id` changed from autoincrement INTEGER to an application-minted **GUID**. Last-writer-wins keys on the primary key, so two nodes independently minting `id=1,2,3…` would destroy each other's events rather than merge them. The event-log read path uses a composite `(timestamp, id)` keyset cursor with an **opaque string** continuation token; `EventLogEntry.Id` and both `ContinuationToken`s are `string`/`string?` on the site↔central Akka DTOs.
|
||||
- `ScadaBridge:OperationTracking:ConnectionString`, `ScadaBridge:SiteEventLog:DatabasePath` and — as of Phase 2 — `ScadaBridge:StoreAndForward:SqliteDbPath` + `ScadaBridge:Database:SiteDbPath` are all **migration-only** — nothing reads them but `SiteLocalDbLegacyMigrator`, which copies a pre-Phase-1 file in once (deterministic `mig-{NodeName}-{legacyId}` event ids, `INSERT OR IGNORE`, runs AFTER `RegisterReplicated` so migrated rows replicate) and renames it `.migrated`. Delete the keys once a node has migrated.
|
||||
- This incidentally fixes a data-loss bug: both legacy databases defaulted to CWD-relative paths **outside** the mounted volume and were discarded on every container recreate.
|
||||
- **Replication is default-OFF and opt-in** via `LocalDb:Replication:PeerAddress` + a matching `ApiKey` on both nodes. `LocalDbSyncAuthInterceptor` is **fail-closed**: no configured key means no sync stream is accepted at all, so a key typo does not degrade to unauthenticated replication — the pair simply stops converging. The sync endpoint shares the existing site gRPC h2c listener (8083); no new port. Rig posture: **site-a replicated, site-b/site-c deliberately not**, so both states are proven side-by-side. Status surfaces on the site health report as `LocalDbReplicationConnected` / `LocalDbOplogBacklog` (both nullable — null means "no data", NOT "disconnected with an empty backlog") and as `localdb_*` Prometheus series. **A transient backlog spike is expected daily and is not a fault:** the `site_events` retention purge (and the storage-cap trim) is CDC-captured like any other write — there is deliberately no purge-exemption path — so the backlog jumps by the deleted batch size and drains as the peer acks it; the purge logs an Information note saying so on a replication-enabled node (`SiteEventLogReplicationCheck`, supplied by the Host from `SiteLocalDbSetup.ReplicationIsConfigured` so SiteEventLogging never reads LocalDb config itself). Healthy = `LocalDbReplicationConnected` stays true and the backlog returns to ~0; see `docs/deployment/topology-guide.md` → *Reading the replication backlog*. Note `ZbTelemetryOptions.Meters` is an **allowlist** (`SiteServiceRegistration.ObservedMeters`); an unlisted meter exports nothing, silently.
|
||||
- **Replication is default-OFF and opt-in** via `LocalDb:Replication:PeerAddress` + a matching `ApiKey` on both nodes. `LocalDbSyncAuthInterceptor` is **fail-closed**: no configured key means no sync stream is accepted at all, so a key typo does not degrade to unauthenticated replication — the pair simply stops converging. The sync endpoint shares the existing site gRPC h2c listener (8083); no new port. Rig posture: **site-a replicated, site-b/site-c deliberately not**, so both states are proven side-by-side. Status surfaces on the site health report as `LocalDbReplicationConnected` / `LocalDbOplogBacklog` (both nullable — null means "no data", NOT "disconnected with an empty backlog") and as `localdb_*` Prometheus series. **An UNCONFIGURED node reports null for both** — the code only started matching that documented contract with register row 80: the engine is registered unconditionally and answers `Connected=false`/`0` while inert, and publishing that made site-b/site-c look like broken replicated sites and starved central's null branches (row 33's conditional KPI emission + sustained-badge anchor exclusion) of the null they exist for. `LocalDbReplicationStatusReporter` now gates on a Host-supplied `SiteLocalDbSetup.ReplicationIsConfigured` flag, so `false` means exactly one thing: **configured, peer session down** = a real fault. Watch the trap that caused it — configured-ness is `PeerAddress` **OR** `ApiKey`, so "no PeerAddress" is NOT "unreplicated" (the pre-existing Host wiring test fixture is a passive-half replicated node for precisely this reason). **A transient backlog spike is expected daily and is not a fault:** the `site_events` retention purge (and the storage-cap trim) is CDC-captured like any other write — there is deliberately no purge-exemption path — so the backlog jumps by the deleted batch size and drains as the peer acks it; the purge logs an Information note saying so on a replication-enabled node (`SiteEventLogReplicationCheck`, supplied by the Host from `SiteLocalDbSetup.ReplicationIsConfigured` so SiteEventLogging never reads LocalDb config itself). Healthy = `LocalDbReplicationConnected` stays true and the backlog returns to ~0; see `docs/deployment/topology-guide.md` → *Reading the replication backlog*. **That spike is PACED as of row 34 (2026-08-15)** — `ScadaBridge:SiteEventLog:MaxPurgedRowsPerTick` (default **250 000**, `0` = unlimited escape hatch) is a per-tick delete ceiling **shared** by the retention purge and the cap trim, and a ceiling-limited tick reschedules after `PurgeCatchUpInterval` (default **5 min**) instead of the daily `PurgeInterval` so retention still converges (~11 ticks ≈ 55 min for the worst case, vs 11 days without the acceleration). Measured worst case, unpaced: a 1 GB event log holds **~2.73 M rows at 393 B/row**, and expiring all of them in one tick took 285 s and queued **2 731 947 oplog rows — 2.7x LocalDb's `MaxOplogRows` default of 1 000 000**, i.e. enough to make the maintenance tick prune the oplog and flag a **full ten-table snapshot resync**. **The subtlety that makes pacing the right fix rather than a bigger `MaxOplogRows`:** the *resident* backlog is `produced − drained`, so a healthy peer blunts it — on a loopback pair the same 2.73 M burst peaked at only **391 k** and drained in 6.0 min. The overrun is therefore **conditional on peer throughput** (breached outright with the peer down; breached at ~1.5 M if the peer runs at half loopback speed), and a 250 k ceiling makes the outcome independent of the peer instead. Drain speed itself is not a bottleneck (7.6–19.8 k tombstones/s measured), so a full 250 k tick clears far inside row 33's 15-minute sustained dwell — verified at 2x a tick (500 k drained in 46 s). The purge loop is now a delay loop rather than a `PeriodicTimer`, so its cadence anchors to the END of the previous purge. Ceiling hits log a rate-limited (15 min) Warning and always increment `scadabridge.site.event_log.purge_ceiling_hits` — read the counter's shape: a run that stops is a backlog converging, hits that never stop mean the ceiling is mis-sized for that site's event rate. Measurement + decision record: `docs/plans/2026-08-15-site-events-purge-burst-measurement.md`. Note `ZbTelemetryOptions.Meters` is an **allowlist** (`SiteServiceRegistration.ObservedMeters`); an unlisted meter exports nothing, silently.
|
||||
- Design: **scadaproj** `docs/plans/2026-07-19-scadabridge-localdb-design.md` (that doc lives in the umbrella repo, not here); Phase 1 plan + Phase 2 gate are here under `docs/plans/`. Phase 2 **deleted** `SiteReplicationActor`, its `ReplicationMessages`, StoreAndForward's `ReplicationService`, and `StoreAndForwardStorage.ReplaceAllAsync` — do not reintroduce them:
|
||||
|
||||
- CDC replication does all three jobs now: config deploys reach the standby as ordinary row changes — **the standby makes no fetch at all** during a deploy (`SiteReconciliationActor`'s node-STARTUP fetch when central reports gaps is a different, surviving path) — and buffer mutations replicate via triggers on `sf_messages`. `ReplaceAllAsync` was a destructive delete-all-then-insert-all resync and is **unsafe to reintroduce**: a mass DELETE on a replicated table would be captured and shipped to the peer. LocalDb's snapshot resync merges per row under LWW and never deletes, which is also why the old N1 directional-authority guard is gone — there is no wipe left to gate.
|
||||
@@ -175,12 +175,12 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`).
|
||||
|
||||
### Cluster & Failover
|
||||
- **`auto-down` downing strategy (decision 2026-07-21 — availability over partition-safety).** Akka's `AutoDowning` provider, `auto-down-unreachable-after` = 15s: the leader among the REACHABLE members downs the unreachable peer, so a hard crash of EITHER node (active/oldest included) fails over to the survivor in ~25s. Accepted trade: a real partition → dual-active until an operator restarts one side. `keep-oldest` remains a supported `SplitBrainResolverStrategy` value (partition-safe, but an oldest-crash is a total outage — Akka's `down-if-alone` only rescues a side with ≥2 members, proven live + in 1.5.62 source). Decision record: `docs/plans/2026-07-21-auto-down-availability-decision.md`.
|
||||
- **`auto-down` downing strategy (decision 2026-07-21 — availability over partition-safety).** Akka's `AutoDowning` provider, `auto-down-unreachable-after` = 15s: the leader among the REACHABLE members downs the unreachable peer, so a hard crash of EITHER node (active/oldest included) fails over to the survivor in ~25s. Accepted trade: a real partition → dual-active until an operator restarts one side. `keep-oldest` remains a supported `SplitBrainResolverStrategy` value (partition-safe, but an oldest-crash is a total outage — Akka's `down-if-alone` only rescues a side with ≥2 members, proven live + in 1.5.62 source). Decision record: `docs/plans/2026-07-21-auto-down-availability-decision.md`. **Ignore the boot-time Akka warning claiming "keep-majority will be used instead"** — drill-verified benign (2026-08-15, decision-doc postscript): the substitution does not happen with the explicit `AutoDowning` provider-class `BuildHocon` sets. But `AutoDowning` IS deprecated, so **every Akka.NET version bump must re-run `DRILL_MODE=active bash docker/failover-drill.sh`** (register row 70).
|
||||
- Both nodes are seed nodes. `min-nr-of-members = 1`.
|
||||
- Failure detection: 2s heartbeat, 10s threshold. Total failover ~25s (drill-measured 2026-07-21 under auto-down: active-crash TAKEOVER in 28s, standby-crash removal in 27s with 0 routing blips — `docker/failover-drill.sh`).
|
||||
- CoordinatedShutdown for graceful singleton handover.
|
||||
- Automatic dual-node recovery from persistent storage.
|
||||
- **Active/standby is decided by `ActiveNodeEvaluator.SelfIsOldestUp`, never by cluster leadership** — see the Architecture note above. `/health/active` is **central-only** (site nodes map no `/health/*` at all) and backs both Traefik's active-node routing and `IActiveNodeGate`, so the proxy and the Inbound API always agree on which node is active. Central reaches a site by dialling `GrpcNodeAAddress`/`GrpcNodeBAddress` explicitly and flipping on error; whichever node answers, the site-internal `ClusterSingletonProxy` lands the work on the active node for free, so central still never needs to track which *site* node is active.
|
||||
- **Active/standby is decided by `ActiveNodeEvaluator.SelfIsOldestUp`, never by cluster leadership** — see the Architecture note above. **Central's** `/health/active` backs both Traefik's active-node routing and `IActiveNodeGate`, so the proxy and the Inbound API always agree on which node is active. **Site nodes DO map `/health/*` too** — the "site nodes map no `/health/*` at all" claim that stood here was stale: `SiteServiceRegistration` registers `akka-cluster` + `localdb` (Ready) and `SitePairActiveNodeHealthCheck` (Active, role-scoped to `site-{SiteId}`, deliberately NOT central's unscoped shared check), and `Program.cs`'s Site branch calls `MapZbHealth()` on the HTTP/1.1 listener so the family overview dashboard can probe every node identically. Nothing in this repo probes a site's `/health/active` (Traefik health-probes central only), and there is **no `IHealthCheckPublisher` anywhere**, so those checks run only on an inbound HTTP probe. Consequence worth knowing: the standby's correct-by-design `active-node` Unhealthy verdict is logged by `DefaultHealthCheckService` at **Error** on every probe, which `LoggerConfigurationFactory` now suppresses with one narrowly-keyed Serilog filter (register row 71 — see `Component-Host.md` → *Standby health-check log noise*; genuine failures of every other check still log at Error). Central reaches a site by dialling `GrpcNodeAAddress`/`GrpcNodeBAddress` explicitly and flipping on error; whichever node answers, the site-internal `ClusterSingletonProxy` lands the work on the active node for free, so central still never needs to track which *site* node is active.
|
||||
- **Seed-node ordering: every node lists ITSELF first (decision 2026-07-22) — the boot-alone gap is CLOSED.** Only `seed-nodes[0]` may self-join to form a new cluster (Akka runs `FirstSeedNodeProcess` for it, `JoinSeedNodeProcess` — which can never form one — for everyone else). All 14 shipped node appsettings now lead with the node's own address, so any node can cold-start alone and become operational unattended (~5s, `seed-node-timeout`); `StartupValidator` fails the boot if the ordering is broken (compares host AND port; Akka does no DNS canonicalisation). Two nodes cold-starting together while mutually reachable converge on ONE cluster via the `InitJoin` handshake — they split only under a genuine boot-time partition, the same class auto-down accepts. **An external self-form timer (`Cluster.Join(SelfAddress)` after a window) was implemented and REJECTED:** it sits outside the join handshake, so on a routine standby restart — where the peer is alive but the join is stalled behind removal of the node's own stale incarnation — it fires mid-join and permanently splits the pair (measured: still split after 90s). Regression tests: `SelfFirstSeedBootstrapTests`. The keep-oldest active-crash total outage was separately closed by the auto-down decision. See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering.
|
||||
- **Simultaneous-cold-start split-brain guard (opt-in, Gitea #33) — the residual self-first cost, closed.** Self-first-on-both means a *truly simultaneous* cold start (shared power/hypervisor event) races `FirstSeedNodeProcess` on BOTH nodes → two 1-node clusters that never merge (in-process loopback tests converge and hide it; real parallel VM starts don't — OtOpcUa reproduced it live). Ported from OtOpcUa (`lmxopcua` `d1dac87f`): a **dark switch `ScadaBridge:Cluster:BootstrapGuard:Enabled` (default OFF, guard-off byte-identical)**. On: `BuildHocon` emits an EMPTY seed list (Akka does not auto-join) and `ClusterBootstrapCoordinator` (`IHostedService`, registered in BOTH the Central and Site composition roots) issues ONE reachability-gated `JoinSeedNodes` — the pure `ClusterBootstrapGuard` core makes the lower canonical `host:port` the **founder** (self-first, forms immediately), the higher node TCP-probes the founder up to `PartnerProbeSeconds` (25s) and joins **peer-first** if reachable else **self-first** (cold-start-alone preserved). Decided BEFORE a single join, **never re-forms mid-handshake** (that was the rejected self-form-timer's flaw); case-insensitive founder tie-break; probe timings validated `>0` when enabled. Residual accepted trade: founder dies in the probe→join window → higher node hangs unjoined, coordinator WARNs, a restart recovers it. Tests: `ClusterBootstrapGuardTests` (pure) + `ClusterBootstrapCoordinatorTests` (real-ActorSystem, incl. both-cold-start-together-form-one-cluster). **ENABLED on the docker rig (all 8 nodes) 2026-08-02** after `deploy.sh`'s simultaneous recreate split site pairs twice on 2026-08-01 with the guard off; live gate PASS — two consecutive simultaneous-start trials (a full redeploy + a full-topology `docker compose restart`), all four pairs converged (every lower-address node founded self-first, every higher node probed-then-joined peer-first), zero splits. Default remains OFF elsewhere (wonder-app-vd03, docker-env2, Host defaults). See `docs/requirements/Component-ClusterInfrastructure.md` → Simultaneous cold start.
|
||||
|
||||
@@ -190,7 +190,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- Debug view: real-time streaming via DebugStreamBridgeActor + gRPC (events via `SiteStreamGrpcClient`, snapshot via the `SiteCommandService` query surface — `QueryReply.DebugViewSnapshot`). Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR.
|
||||
- Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval.
|
||||
- Dead letter monitoring as a health metric.
|
||||
- Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search.
|
||||
- Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search. **The cap measures `site_events` + its four indexes, NOT the consolidated LocalDb file** (register row 81, 2026-08-17): whole-file measurement made the trim unwinnable on a replicated node — each batch freed event pages while adding an equal count of oplog rows + tombstones to the same file, so it stopped 2.5x over a 64 MB cap and a node with a down peer could not enforce the cap at all. `dbstat` (the exact per-table measurement) is **NOT compiled into the bundled `SQLitePCLRaw.lib.e_sqlite3`** — probed and pinned by `SqliteCapabilityTests`, so don't reach for it — the footprint is modelled from sampled row width instead (−0.8%…+12.8%, biased high). Consequence: `MaxStorageMb` no longer bounds the data volume alone; LocalDb bookkeeping is separate overhead bounded by `MaxOplogRows`/`TombstoneRetention` and is deliberately not this component's job. Decision: `docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md`.
|
||||
- Notification Outbox KPIs are central-computed point-in-time from the `Notifications` table (global + per-source-site): queue depth, stuck count, parked count, delivered-last-interval, oldest-pending age.
|
||||
- Stuck = `Pending`/`Retrying` older than a configurable age threshold (default 10 min) — display-only (KPI count + row badge), no escalation/alerting.
|
||||
- Headline KPI tiles surface on the Health dashboard; a new Central UI Notification Outbox page offers a queryable list with Retry/Discard actions on parked notifications.
|
||||
|
||||
@@ -229,7 +229,25 @@ desired — capture the returned task and attach a continuation that calls
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved — the preferred remedy was applied, but the resolution note was never
|
||||
written back (the row was flipped to `Status: Resolved` while this body still read
|
||||
`_Unresolved._`); recorded here during the deferred-register row 49 scoped-lifetime
|
||||
sweep, which re-verified the fix against current code. Both helpers are now `async
|
||||
Task` (`EmitAttemptAuditAsync` / `EmitTerminalAuditAsync`) and **await**
|
||||
`_auditWriter.WriteAsync(evt)` inside the existing `try`/`catch`
|
||||
(`NotificationOutboxActor.cs:731`, `:785`), so (1) the audit task can no longer
|
||||
outlive the dispatcher's per-sweep DI scope, (2) the catch is reachable for faults
|
||||
originating in the awaited body — a writer fault now reaches
|
||||
`_logger.LogWarning` instead of being silently lost as an unobserved task, and
|
||||
(3) the audit-write-never-affects-delivery invariant is preserved by the surrounding
|
||||
catch rather than by the discard. The misleading "PipeTo is not used because the
|
||||
writer never throws" XML-doc is gone; both helpers now carry a `<remarks>` block
|
||||
stating the await-inside-try/catch rationale explicitly. Note this is a
|
||||
*scope-too-narrow* hazard, the inverse of the *scope-too-wide* one in
|
||||
NotificationOutbox-006 — the two are the opposite failure modes of the same
|
||||
per-sweep-scope boundary, and both are now resolved. `CentralAuditWriter.WriteAsync`
|
||||
still opens its own `CreateAsyncScope` (`CentralAuditWriter.cs:127-128`), so the
|
||||
writer remains defensively scope-independent as a second line of defence.
|
||||
|
||||
### NotificationOutbox-005 — Ingest persistence inherits the CD-015 check-then-act race; under contention the second writer throws and the site retries
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"Role": "Central",
|
||||
"NodeName": "central-a",
|
||||
"NodeHostname": "scadabridge-env2-central-a",
|
||||
"RemotingPort": 8081
|
||||
"RemotingPort": 8081,
|
||||
"_metricsPort": "Register row 56 — mirrors docker/central-node-a. Dedicated HTTP/1.1 Prometheus scrape listener; central binds it only when this key is present. In-container port only, no host mapping.",
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"Role": "Central",
|
||||
"NodeName": "central-b",
|
||||
"NodeHostname": "scadabridge-env2-central-b",
|
||||
"RemotingPort": 8081
|
||||
"RemotingPort": 8081,
|
||||
"_metricsPort": "Register row 56 — mirrors docker/central-node-a. Dedicated HTTP/1.1 Prometheus scrape listener; central binds it only when this key is present. In-container port only, no host mapping.",
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -3,6 +3,14 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Same guard as docker/deploy.sh: relative bind mounts mean a worktree deploy silently
|
||||
# rehomes every node's data directory to the worktree's copy, splitting rig state.
|
||||
if [[ "$SCRIPT_DIR" == *"/.claude/worktrees/"* && "${SCADABRIDGE_ALLOW_WORKTREE_DEPLOY:-}" != "1" ]]; then
|
||||
echo "ERROR: refusing to deploy from a git worktree ($SCRIPT_DIR)." >&2
|
||||
echo "Deploy from the main checkout, or set SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1 to override." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ScadaBridge Env2 Docker Deploy ==="
|
||||
|
||||
# Reuse the primary build (same scadabridge:latest image, same network creation)
|
||||
|
||||
@@ -70,6 +70,10 @@ services:
|
||||
container_name: scadabridge-env2-site-x-a
|
||||
environment:
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63 — mirrors docker/docker-compose.yml. Enables the Development-only
|
||||
# DI scope validation (register row 50) on the site nodes, which previously ran
|
||||
# unvalidated because only the central containers set this. Dev/rig only.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9121:8082" # Akka remoting
|
||||
- "9123:8083" # gRPC streaming
|
||||
@@ -90,6 +94,10 @@ services:
|
||||
container_name: scadabridge-env2-site-x-b
|
||||
environment:
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63 — mirrors docker/docker-compose.yml. Enables the Development-only
|
||||
# DI scope validation (register row 50) on the site nodes, which previously ran
|
||||
# unvalidated because only the central containers set this. Dev/rig only.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9122:8082" # Akka remoting
|
||||
- "9124:8083" # gRPC streaming
|
||||
|
||||
@@ -87,6 +87,7 @@ docker/
|
||||
├── build.sh # Build Docker image
|
||||
├── deploy.sh # Build + deploy all containers
|
||||
├── seed-sites.sh # Create test sites with Akka + gRPC addresses
|
||||
├── seed-live-probe.sh # Seed one deployable live-data instance on site-a (stream traffic)
|
||||
├── teardown.sh # Stop and remove containers
|
||||
├── central-node-a/
|
||||
│ ├── appsettings.Central.json # Central node A configuration
|
||||
@@ -160,8 +161,70 @@ docker/deploy.sh
|
||||
|
||||
# 3. Seed test sites (first-time only, after cluster is healthy)
|
||||
docker/seed-sites.sh
|
||||
|
||||
# 4. Seed one deployable live-data instance on site-a, so the rig has real
|
||||
# stream traffic for live probes (see "Live-data probe instance" below)
|
||||
docker/seed-live-probe.sh
|
||||
```
|
||||
|
||||
### Live-data probe instance
|
||||
|
||||
`seed-live-probe.sh` creates the minimum working set the rig needs for any live probe
|
||||
of the data or streaming path: a `RigProbe` template with two attributes bound to
|
||||
**self-changing** opc-plc nodes (`ns=3;s=SpikeData`, `ns=3;s=RandomSignedInt32`, both
|
||||
~1 Hz) plus a 5 s heartbeat script, and a deployed `rig-probe-1` instance on site-a.
|
||||
|
||||
Two traps it exists to avoid:
|
||||
|
||||
- **The custom tags in `infra/opcua/nodes.json` are declared but not simulated.**
|
||||
`Motor.*`, `Tank.*`, `Valve.*`, `TestChildObject.*` read 0 / false / epoch forever.
|
||||
Binding to them gives a `Connected` data connection with `Good` quality and *zero*
|
||||
event traffic, which looks healthy and probes nothing. Use the built-in simulated
|
||||
nodes (`SpikeData`, `RandomSignedInt32`, `StepUp`, `FastUInt1..5`, `SlowUInt1..5`).
|
||||
- **`scadabridge_site_stream_subscribers_active` is not data-connection health.** It counts
|
||||
open central→site gRPC *subscriber streams*, so it sits at 0 on a perfectly healthy site
|
||||
with nobody watching. (Register row 54 renamed it from `scadabridge_site_connection_up`,
|
||||
whose name invited exactly that misreading — it is what made row 48's "0 on all six site
|
||||
nodes" look like a fault when it was healthy steady state.)
|
||||
|
||||
For real data-connection health, the same row added two honest gauges:
|
||||
|
||||
| Series | Meaning |
|
||||
| --- | --- |
|
||||
| `scadabridge_site_data_connections_connected` | Connections currently in the `Connected` state |
|
||||
| `scadabridge_site_data_connections_total` | Connections known to this node, in any state |
|
||||
|
||||
`connected < total` is the alert condition — neither number alone separates "a device is
|
||||
down" from "nothing is configured". Both read **0 on the standby node**, which is healthy:
|
||||
connections are opened by the deployment path on the active node only, so alert on the
|
||||
active node or on `max()` across the pair. The health report's `dataConnectionEndpoints` /
|
||||
`dataConnectionTagQuality` (via `scadabridge health site`) remain the per-connection detail.
|
||||
|
||||
Verify (metrics are on container port 8084 — **all eight nodes** as of register row 56, which
|
||||
gave the two central nodes the same dedicated scrape listener the six site nodes already had;
|
||||
the containers ship no curl/wget):
|
||||
|
||||
```bash
|
||||
scadabridge debug snapshot --id <instance-id>
|
||||
scadabridge health site --identifier site-a
|
||||
scadabridge debug stream --id <instance-id> # open a stream, then in another shell:
|
||||
|
||||
docker exec scadabridge-site-a-a bash -c \
|
||||
'exec 3<>/dev/tcp/localhost/8084 && printf "GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3 && cat <&3' \
|
||||
| grep -E 'stream_subscribers_active|stream_batch_size|data_connections'
|
||||
|
||||
# Central-only series (KPI/outbox counters, bundle-import session gauge + rejection
|
||||
# counter, alarm-cache gauges) — same recipe, central container:
|
||||
docker exec scadabridge-central-a bash -c \
|
||||
'exec 3<>/dev/tcp/localhost/8084 && printf "GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3 && cat <&3' \
|
||||
| grep -E 'import_sessions|alarm_cache'
|
||||
```
|
||||
|
||||
> Central binds 8084 only because `docker/central-node-*/appsettings.Central.json` now sets
|
||||
> `ScadaBridge:Node:MetricsPort`. Unlike a site node, central reads that key **only when
|
||||
> present** — a central deployment without it is unchanged and still serves `/metrics` on its
|
||||
> web port, since the endpoint rides endpoint routing and is served on every bound listener.
|
||||
|
||||
### After Code Changes
|
||||
|
||||
Rebuild and redeploy. The Docker build cache skips NuGet restore when only source files change:
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"Role": "Central",
|
||||
"NodeName": "central-a",
|
||||
"NodeHostname": "scadabridge-central-a",
|
||||
"RemotingPort": 8081
|
||||
"RemotingPort": 8081,
|
||||
"_metricsPort": "Register row 56. Central maps /metrics on every listener it binds, so it was always reachable on the web port (:5000) — but not on 8084, which is the port the live-scrape recipe probes (docker exec ... </dev/tcp/localhost/8084). Declaring it here binds the same dedicated HTTP/1.1 scrape listener the six site nodes use, so one probe recipe works on all eight nodes and central-only series (KPI/outbox counters, the import-session gauge + rejection counter, alarm-cache gauges) are scrapeable live. Central reads this key ONLY when present — unlike a site node, which always binds 8084 — so an existing central without it is unchanged. No host port mapping is needed: the probe runs inside the container.",
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"Role": "Central",
|
||||
"NodeName": "central-b",
|
||||
"NodeHostname": "scadabridge-central-b",
|
||||
"RemotingPort": 8081
|
||||
"RemotingPort": 8081,
|
||||
"_metricsPort": "Register row 56 — see central-node-a. Dedicated HTTP/1.1 Prometheus scrape listener, matching the six site nodes so one probe recipe covers all eight. Central binds this only when the key is present.",
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -3,6 +3,17 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# The compose file's bind mounts are RELATIVE (./site-a-node-a/data etc.), so running
|
||||
# this script from a git worktree silently rehomes every node's data directory to the
|
||||
# worktree's copy — the rig keeps running, but its SQLite stores split from the main
|
||||
# checkout's and all state written there is lost on the next deploy from main.
|
||||
if [[ "$SCRIPT_DIR" == *"/.claude/worktrees/"* && "${SCADABRIDGE_ALLOW_WORKTREE_DEPLOY:-}" != "1" ]]; then
|
||||
echo "ERROR: refusing to deploy from a git worktree ($SCRIPT_DIR)." >&2
|
||||
echo "Bind-mount data dirs would rehome to the worktree copy and split rig state." >&2
|
||||
echo "Deploy from the main checkout, or set SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1 to override." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ScadaBridge Docker Deploy ==="
|
||||
|
||||
# Build image (creates network if needed)
|
||||
|
||||
@@ -213,6 +213,14 @@ services:
|
||||
# file). site-b and site-c deliberately carry neither.
|
||||
<<: [*secrets-store-env, *secrets-hub-env, *secrets-hub-site-env]
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63. The central containers have always set this; the site containers
|
||||
# never did, so the Development-only DI scope validation (register row 50,
|
||||
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
|
||||
# exactly the half of the topology that has six nodes. The site graph now validates
|
||||
# clean, so turning it on here buys live coverage: a captive dependency or an
|
||||
# unconstructible site-only descriptor fails the container at boot instead of
|
||||
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9021:8082" # Akka remoting (host access for debugging)
|
||||
- "9023:8083" # gRPC streaming
|
||||
@@ -241,6 +249,14 @@ services:
|
||||
# file). site-b and site-c deliberately carry neither.
|
||||
<<: [*secrets-store-env, *secrets-hub-env, *secrets-hub-site-env]
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63. The central containers have always set this; the site containers
|
||||
# never did, so the Development-only DI scope validation (register row 50,
|
||||
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
|
||||
# exactly the half of the topology that has six nodes. The site graph now validates
|
||||
# clean, so turning it on here buys live coverage: a captive dependency or an
|
||||
# unconstructible site-only descriptor fails the container at boot instead of
|
||||
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9022:8082" # Akka remoting
|
||||
- "9024:8083" # gRPC streaming
|
||||
@@ -267,6 +283,14 @@ services:
|
||||
environment:
|
||||
<<: *secrets-store-env
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63. The central containers have always set this; the site containers
|
||||
# never did, so the Development-only DI scope validation (register row 50,
|
||||
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
|
||||
# exactly the half of the topology that has six nodes. The site graph now validates
|
||||
# clean, so turning it on here buys live coverage: a captive dependency or an
|
||||
# unconstructible site-only descriptor fails the container at boot instead of
|
||||
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9031:8082" # Akka remoting
|
||||
- "9033:8083" # gRPC streaming
|
||||
@@ -293,6 +317,14 @@ services:
|
||||
environment:
|
||||
<<: *secrets-store-env
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63. The central containers have always set this; the site containers
|
||||
# never did, so the Development-only DI scope validation (register row 50,
|
||||
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
|
||||
# exactly the half of the topology that has six nodes. The site graph now validates
|
||||
# clean, so turning it on here buys live coverage: a captive dependency or an
|
||||
# unconstructible site-only descriptor fails the container at boot instead of
|
||||
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9032:8082" # Akka remoting
|
||||
- "9034:8083" # gRPC streaming
|
||||
@@ -319,6 +351,14 @@ services:
|
||||
environment:
|
||||
<<: *secrets-store-env
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63. The central containers have always set this; the site containers
|
||||
# never did, so the Development-only DI scope validation (register row 50,
|
||||
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
|
||||
# exactly the half of the topology that has six nodes. The site graph now validates
|
||||
# clean, so turning it on here buys live coverage: a captive dependency or an
|
||||
# unconstructible site-only descriptor fails the container at boot instead of
|
||||
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9041:8082" # Akka remoting
|
||||
- "9043:8083" # gRPC streaming
|
||||
@@ -345,6 +385,14 @@ services:
|
||||
environment:
|
||||
<<: *secrets-store-env
|
||||
SCADABRIDGE_CONFIG: Site
|
||||
# Register row 63. The central containers have always set this; the site containers
|
||||
# never did, so the Development-only DI scope validation (register row 50,
|
||||
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
|
||||
# exactly the half of the topology that has six nodes. The site graph now validates
|
||||
# clean, so turning it on here buys live coverage: a captive dependency or an
|
||||
# unconstructible site-only descriptor fails the container at boot instead of
|
||||
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ports:
|
||||
- "9042:8082" # Akka remoting
|
||||
- "9044:8083" # gRPC streaming
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Seed ONE deployable, live-data instance on site-a so the rig always has real
|
||||
# stream traffic for live probes.
|
||||
#
|
||||
# Why this exists: the rig repeatedly ends up with instances that cannot deploy
|
||||
# (deferred-work register row 48 — the seven `soak-*` rows fail pre-deployment
|
||||
# validation because their bound data connections no longer exist on the sites).
|
||||
# With nothing deployed, `debug snapshot` returns `instanceNotFound`, no gRPC site
|
||||
# stream is ever opened, and the R2 batching histogram
|
||||
# `scadabridge.site.stream.batch_size` has nothing to record — so any live probe of
|
||||
# the streaming path is unverifiable. This script rebuilds the minimum working set
|
||||
# from scratch, using only the CLI (no DB edits).
|
||||
#
|
||||
# What it creates (all on site-a / site id 1, against data connection id 1
|
||||
# "OPC PLC Simulator" -> opc.tcp://scadabridge-opcua:50000):
|
||||
# - Template `RigProbe`
|
||||
# * Spike (Double) <- ns=3;s=SpikeData — opc-plc built-in, changes ~1/s
|
||||
# * RandomInt (Int32) <- ns=3;s=RandomSignedInt32 — opc-plc built-in, changes ~1/s
|
||||
# * Heartbeat (Int32) — static, bumped every 5s by the Heartbeat script
|
||||
# * Heartbeat script — Interval 5000ms, trust-policy-clean
|
||||
# - Instance `rig-probe-1`, bound + deployed
|
||||
#
|
||||
# IMPORTANT — pick self-changing tags. The custom tags in `infra/opcua/nodes.json`
|
||||
# (Motor.*, Tank.*, Valve.*, TestChildObject.*) are declared but NOT simulated by
|
||||
# opc-plc: they read 0 / false / epoch and never change on their own, so binding to
|
||||
# them yields a healthy connection with zero event traffic. The built-in simulated
|
||||
# nodes are the ones that move: SpikeData, RandomSignedInt32, StepUp, FastUInt1..5
|
||||
# (1s) and SlowUInt1..5 (10s).
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Infrastructure services running (infra/docker-compose.yml — needs scadabridge-opcua)
|
||||
# - Application containers running (docker/deploy.sh)
|
||||
# - Sites seeded (docker/seed-sites.sh) and central healthy
|
||||
# curl -sf http://localhost:9000/health/ready
|
||||
#
|
||||
# Usage:
|
||||
# docker/seed-live-probe.sh
|
||||
#
|
||||
# Idempotency: this script is NOT idempotent — template and instance names must be
|
||||
# unique, so a second run fails on the create steps. To re-seed, delete the previous
|
||||
# instance and template first:
|
||||
# scadabridge instance delete --id <id> && scadabridge template delete --id <id>
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CLI="dotnet run --project $PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI --"
|
||||
AUTH="--username multi-role --password password"
|
||||
URL="--url http://localhost:9000"
|
||||
|
||||
SITE_ID=1 # site-a
|
||||
CONNECTION_ID=1 # "OPC PLC Simulator" on site-a
|
||||
TEMPLATE_NAME="RigProbe"
|
||||
INSTANCE_NAME="rig-probe-1"
|
||||
|
||||
# Pulls the numeric "id" out of the CLI's JSON reply without needing jq.
|
||||
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
|
||||
|
||||
echo "=== Seeding live-data probe instance on site-a ==="
|
||||
|
||||
echo ""
|
||||
echo "1/6 Creating template $TEMPLATE_NAME..."
|
||||
TEMPLATE_ID=$($CLI $URL $AUTH template create \
|
||||
--name "$TEMPLATE_NAME" \
|
||||
--description "Minimal live-traffic probe: two OPC UA sim tags that change on their own + a 5s heartbeat script" \
|
||||
| json_id)
|
||||
echo " template id = $TEMPLATE_ID"
|
||||
|
||||
echo ""
|
||||
echo "2/6 Adding data-bound attributes..."
|
||||
$CLI $URL $AUTH template attribute add \
|
||||
--template-id "$TEMPLATE_ID" --name Spike --data-type Double \
|
||||
--data-source "ns=3;s=SpikeData" \
|
||||
--description "opc-plc SpikeData — Double, changes every ~1s on its own" > /dev/null
|
||||
$CLI $URL $AUTH template attribute add \
|
||||
--template-id "$TEMPLATE_ID" --name RandomInt --data-type Int32 \
|
||||
--data-source "ns=3;s=RandomSignedInt32" \
|
||||
--description "opc-plc RandomSignedInt32 — Int32, changes every ~1s on its own" > /dev/null
|
||||
|
||||
echo ""
|
||||
echo "3/6 Adding the static heartbeat attribute + its interval script..."
|
||||
$CLI $URL $AUTH template attribute add \
|
||||
--template-id "$TEMPLATE_ID" --name Heartbeat --data-type Int32 --value 0 \
|
||||
--description "Static attribute bumped every 5s by the Heartbeat script — script-sourced traffic" > /dev/null
|
||||
$CLI $URL $AUTH template script add \
|
||||
--template-id "$TEMPLATE_ID" --name Heartbeat \
|
||||
--trigger-type Interval --trigger-config '{"intervalMs":5000}' \
|
||||
--code 'var current = await Instance.GetAttribute("Heartbeat"); var next = (int.TryParse(current?.ToString(), out var n) ? n : 0) + 1; await Instance.SetAttribute("Heartbeat", next.ToString());' > /dev/null
|
||||
|
||||
echo ""
|
||||
echo "4/6 Creating instance $INSTANCE_NAME..."
|
||||
INSTANCE_ID=$($CLI $URL $AUTH instance create \
|
||||
--name "$INSTANCE_NAME" --template-id "$TEMPLATE_ID" --site-id "$SITE_ID" | json_id)
|
||||
echo " instance id = $INSTANCE_ID"
|
||||
|
||||
echo ""
|
||||
echo "5/6 Binding the data-sourced attributes to connection $CONNECTION_ID..."
|
||||
$CLI $URL $AUTH instance set-bindings --id "$INSTANCE_ID" \
|
||||
--bindings "[[\"Spike\",$CONNECTION_ID],[\"RandomInt\",$CONNECTION_ID]]" > /dev/null
|
||||
|
||||
echo ""
|
||||
echo "6/6 Deploying..."
|
||||
$CLI $URL $AUTH instance deploy --id "$INSTANCE_ID"
|
||||
|
||||
echo ""
|
||||
echo "=== Done. Verify with: ==="
|
||||
echo " scadabridge debug snapshot --id $INSTANCE_ID"
|
||||
echo " scadabridge health site --identifier site-a # dataConnectionEndpoints / dataConnectionTagQuality"
|
||||
echo " scadabridge debug stream --id $INSTANCE_ID # opens a gRPC site stream; ~2 events/s"
|
||||
echo ""
|
||||
echo "While a stream is open, the site node serving it reports both signals"
|
||||
echo "(metrics are on container port 8084, and the containers have no curl/wget):"
|
||||
echo " docker exec scadabridge-site-a-a bash -c 'exec 3<>/dev/tcp/localhost/8084 && printf \"GET /metrics HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n\" >&3 && cat <&3' \\"
|
||||
echo " | grep -E 'stream_subscribers_active|stream_batch_size|data_connections'"
|
||||
echo ""
|
||||
echo "NOTE: scadabridge_site_stream_subscribers_active (renamed from the misleading"
|
||||
echo "scadabridge_site_connection_up, register row 54) counts OPEN CENTRAL->SITE gRPC"
|
||||
echo "SUBSCRIBER STREAMS, not OPC UA data connections. It reads 0 on a perfectly healthy"
|
||||
echo "site with no subscriber attached — that is correct, not a fault."
|
||||
echo ""
|
||||
echo "For real data-connection health use the gauges added by the same row:"
|
||||
echo " scadabridge_site_data_connections_connected / _total"
|
||||
echo "connected < total is the alert condition. Both read 0 on the STANDBY node, since"
|
||||
echo "connections are opened by the deployment path on the active node only."
|
||||
@@ -295,7 +295,7 @@ public record AlarmStateChanged(
|
||||
|
||||
### Observability
|
||||
|
||||
`Observability/ScadaBridgeTelemetry` defines the singleton `Meter` named `ZB.MOM.WW.ScadaBridge` and the application-wide instrument definitions. Components call the static emit helpers (`RecordDeploymentApplied`, `SiteConnectionOpened`, etc.) rather than creating their own meters. Instruments are no-ops until an OTel listener attaches, so uninstrumented hosts pay no overhead.
|
||||
`Observability/ScadaBridgeTelemetry` defines the singleton `Meter` named `ZB.MOM.WW.ScadaBridge` and the application-wide instrument definitions. Components call the static emit helpers (`RecordDeploymentApplied`, `SiteStreamSubscriberOpened`, etc.) rather than creating their own meters. Instruments are no-ops until an OTel listener attaches, so uninstrumented hosts pay no overhead.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ The Configuration Database component is the exclusive EF Core data-access layer
|
||||
The component lives in `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/` and is central-only — site clusters never load it. Its responsibilities break down into four areas:
|
||||
|
||||
- **DbContext + Fluent mappings** — `ScadaBridgeDbContext` maps ~30 Commons POCO entity types to SQL Server using `IEntityTypeConfiguration<T>` classes in `Configurations/`, registered wholesale via `modelBuilder.ApplyConfigurationsFromAssembly(...)`.
|
||||
- **Repository implementations** — eleven scoped repositories implement the interfaces declared in Commons, covering every domain area from template authoring to audit log ingest.
|
||||
- **Repository implementations** — scoped repositories implement the interfaces declared in Commons, covering every domain area from template authoring to audit log ingest.
|
||||
- **Config-change audit** — `AuditService` implements `IAuditService`, staging an `AuditLogEntry` into the change tracker so it commits atomically with the entity change; `AuditCorrelationContext` threads a `BundleImportId` through `AsyncLocal<T>` so bundle-import audit rows are correlated without cross-contaminating concurrent import sessions.
|
||||
- **Partition maintenance** — `AuditLogPartitionMaintenance` implements `IPartitionMaintenance`, rolling `pf_AuditLog_Month` forward by issuing `ALTER PARTITION FUNCTION … SPLIT RANGE` for each missing future monthly boundary.
|
||||
|
||||
@@ -91,9 +91,11 @@ The predecessor `UX_AuditLog_EventId` — a single-column unique index on `[PRIM
|
||||
|
||||
**`SiteCallEntityTypeConfiguration`** maps `SiteCall` to `dbo.SiteCalls` with a `TrackedOperationId` PK stored as `varchar(36)` (GUID in `"D"` format) so the column shape matches the wire format and the site SQLite store — one consistent format for operational debugging.
|
||||
|
||||
**`BundleUnlockAttemptEntityTypeConfiguration`** maps `BundleUnlockAttempt` to the table of the same name — the fleet-wide sliding window behind Transport's per-client bundle-unlock throttle (register row 52; the counters used to be per-node and per-process, so the effective cap was N × the configured limit). Operational history, not audit: non-partitioned, `[PRIMARY]` filegroup, no DB-role restriction. One named index, `IX_BundleUnlockAttempt_Client_Attempted`, serves both the windowed count and the application's opportunistic prune, which is bounded and best-effort — a store failure in this path is deliberately fail-open at the Transport layer, so nothing here may block an unlock. The table deliberately records no bundle identity; the durable record of a failed unlock is the `BundleImportUnlockFailed` audit row.
|
||||
|
||||
### Repository implementations
|
||||
|
||||
All eleven repositories follow the same shape: they take `ScadaBridgeDbContext` by constructor injection, work with Commons POCO types, and never commit — callers invoke `SaveChangesAsync()` to commit the unit of work.
|
||||
All repositories follow the same shape: they take `ScadaBridgeDbContext` by constructor injection, work with Commons POCO types, and never commit — callers invoke `SaveChangesAsync()` to commit the unit of work.
|
||||
|
||||
**`AuditLogRepository`** is the most specialized. Its `InsertIfNotExistsAsync` bypasses the change tracker and issues raw interpolated SQL because the computed columns must not appear in the INSERT column list:
|
||||
|
||||
@@ -152,7 +154,7 @@ services.AddConfigurationDatabase(
|
||||
configuration["ScadaBridge:Database:ConfigurationDb"]!);
|
||||
```
|
||||
|
||||
This registers `ScadaBridgeDbContext` as scoped (with the runtime encryption overload), all eleven repository interfaces bound to their implementations, `IAuditCorrelationContext` → `AuditCorrelationContext`, `IAuditService` → `AuditService`, `IInstanceLocator` → `InstanceLocator`, `IPartitionMaintenance` → `AuditLogPartitionMaintenance`, and the Data Protection key ring persisted to the database.
|
||||
This registers `ScadaBridgeDbContext` as scoped (with the runtime encryption overload), every repository interface bound to its implementation, `IAuditCorrelationContext` → `AuditCorrelationContext`, `IAuditService` → `AuditService`, `IInstanceLocator` → `InstanceLocator`, `IPartitionMaintenance` → `AuditLogPartitionMaintenance`, and the Data Protection key ring persisted to the database.
|
||||
|
||||
The obsolete zero-argument overload throws `InvalidOperationException` at startup (marked `error: true` on the `[Obsolete]` attribute) so a misconfigured host fails fast with a clear message rather than silently producing an empty DI registration.
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ A separate in-flight set `_subscribesInFlight` tracks the initial `SubscribeAsyn
|
||||
|
||||
### Creating a connection
|
||||
|
||||
The Site Runtime's `DeploymentManagerActor` sends `CreateConnectionCommand` to `DataConnectionManagerActor` during instance deployment:
|
||||
The Site Runtime's `DeploymentManagerActor` sends `CreateConnectionCommand` to `DataConnectionManagerActor` whenever it creates an `InstanceActor` — the config comes from the `Connections` map inside that instance's flattened config, not from the site's `data_connection_definitions` table (which has no production reader). The manager never reads storage and has no independent view of which connections a site should have, so a start path that fails to push is indistinguishable here from a connection that genuinely does not exist: that was register row **85**, where two of the four Instance-Actor start paths omitted the push and every subscribe from the resulting instances was answered `Unknown connection: <name>` forever. The push now lives inside `CreateInstanceActor` on the Site Runtime side. Sends are hash-guarded there, so an unchanged connection produces no message at all:
|
||||
|
||||
```csharp
|
||||
// Commons/Messages/DataConnection/CreateConnectionCommand.cs
|
||||
|
||||
@@ -27,6 +27,17 @@ The component code lives in `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/`:
|
||||
|
||||
The singleton reads all deployed configurations from SQLite in `PreStart`, compiles shared scripts off-thread, and then creates `InstanceActor` children in batches. The default batch size is 20, with a 100 ms delay between batches (`StartupBatchSize` and `StartupBatchDelayMs` in `SiteRuntimeOptions`). Without staggering, 500 instances each subscribing to OPC UA tags simultaneously would produce a reconnection storm that overwhelms the OPC UA server.
|
||||
|
||||
### Instance reconcile sweep
|
||||
|
||||
The staggered startup above reads storage exactly once. Anything that writes a `deployed_configurations` row afterwards without going through a deploy command therefore changed the store but not the runtime — and nothing said so. Two real cases (deferred register rows **78** and **72**):
|
||||
|
||||
- a config seeded by **LocalDb CDC replication** (a fresh standby filled in from its peer, or a snapshot resync) landed after the boot load and was invisible to the startup reconcile, so its enabled instances never started;
|
||||
- a config the startup reconcile **refreshed** because it was stale left the running `InstanceActor` serving the previously compiled revision.
|
||||
|
||||
`DeploymentManagerActor` therefore runs a periodic sweep (`InstanceSweepIntervalSeconds`, default 60; `0` disables) that re-reads the durable rows off-thread and, on the actor thread, for every **enabled** stored config either starts a missing `InstanceActor` or — when the running one's revision no longer matches the stored one — re-applies the stored config **through the ordinary redeploy path**, so nothing about redeploy semantics is re-implemented. It defers to any in-flight deploy/warm/termination, never starts a disabled instance, stays inert until boot-time creation has finished, makes one repair attempt per (instance, revision) pair so it can never become a restart loop, and is completely silent when there is nothing to repair. Repairs emit Warning `deployment` site events naming the trigger (and, for a stale-revision restart, both revisions). Design note: `docs/plans/2026-08-15-instance-sweep-self-heal-design.md`.
|
||||
|
||||
Register row **85** was the same gap one layer down: the sweep started the instance, but the instance came up with **no DCL connection actors**, so every tag subscribe was answered `Unknown connection: <name>`. The connection push (`EnsureDclConnections`) was the *caller's* job and two of the four Instance-Actor start paths omitted it; it now lives inside `CreateInstanceActor`. See *Deployment flow* below and §7 of the design note.
|
||||
|
||||
### Actor supervision and lifetimes
|
||||
|
||||
| Actor | Kind | Supervises children with | On exception |
|
||||
@@ -74,10 +85,11 @@ Prior to WP3.1 (2026-08-15) each invocation spawned a short-lived `ScriptExecuti
|
||||
|
||||
Central sends a `DeployInstanceCommand` carrying a JSON `FlattenedConfiguration` to the site singleton. The singleton:
|
||||
|
||||
1. Calls `EnsureDclConnections` to push any new or changed connection definitions to the DCL manager (hash-guarded: unchanged configs are skipped).
|
||||
2. Calls `CreateInstanceActor`, which does `Context.ActorOf(props, instanceName)`.
|
||||
3. Runs an off-thread `Task` that calls `SiteStorageService.StoreDeployedConfigAsync` and clears static overrides and native alarm state. Nothing is pushed to the peer: as of LocalDb Phase 2 those three tables are replicated, so the writes themselves reach the standby.
|
||||
4. Pipes back a `DeployPersistenceResult`; only on success does it tell the deployer `DeploymentStatus.Success`. If persistence fails, the optimistically-created actor is stopped and the error is returned to central (`SiteRuntime-005`).
|
||||
1. Calls `CreateInstanceActor`, which first calls `EnsureDclConnections` to push any new or changed connection definitions to the DCL manager (hash-guarded: unchanged configs are skipped), then does `Context.ActorOf(props, instanceName)`.
|
||||
2. Runs an off-thread `Task` that calls `SiteStorageService.StoreDeployedConfigAsync` and clears static overrides and native alarm state. Nothing is pushed to the peer: as of LocalDb Phase 2 those three tables are replicated, so the writes themselves reach the standby.
|
||||
3. Pipes back a `DeployPersistenceResult`; only on success does it tell the deployer `DeploymentStatus.Success`. If persistence fails, the optimistically-created actor is stopped and the error is returned to central (`SiteRuntime-005`).
|
||||
|
||||
> **`CreateInstanceActor` is the single funnel for the connection push too (register row 85)**: the `EnsureDclConnections` call moved *into* `CreateInstanceActor` on 2026-08-17. It used to sit at each caller, and only two of the four callers made it — the boot-time batch and this deploy path. `StartStoredInstance` (row-67 ensure-started and the row-78 sweep) and `HandleEnableResult` did not, so a self-healed or newly-enabled instance came up with no connection actors and every tag subscribe was answered `Unknown connection: <name>` indefinitely. See the *Instance reconcile sweep* section and `docs/plans/2026-08-15-instance-sweep-self-heal-design.md` §7.
|
||||
|
||||
For redeployment (instance already running), the existing actor is stopped and watched:
|
||||
|
||||
@@ -205,7 +217,7 @@ Both `AlarmActor` and `NativeAlarmActor` tell the `InstanceActor` an `AlarmState
|
||||
| `static_attribute_overrides` | Runtime attribute writes (`SetAttribute` on static attrs) | Yes — cleared by `ClearStaticOverridesAsync` |
|
||||
| `native_alarm_state` | Mirrored native alarm conditions (survives failover) | Yes — cleared by `ClearNativeAlarmsForInstanceAsync` |
|
||||
| `shared_scripts` | Shared script code from artifact deployments | No |
|
||||
| `external_systems` | External system definitions | No |
|
||||
| `external_systems` | External system definitions (incl. `timeout_seconds`, and `max_retries`/`retry_delay_ms` — the deployed store-and-forward retry policy, `0` = use the engine default) | No |
|
||||
| `database_connections` | Database connection strings | No |
|
||||
| `data_connection_definitions` | OPC UA / MxGateway endpoint definitions | No |
|
||||
| `notification_lists` | Notification list definitions | No |
|
||||
@@ -215,6 +227,8 @@ Both `AlarmActor` and `NativeAlarmActor` tell the `InstanceActor` an `AlarmState
|
||||
|
||||
Config replication has no actor. `SiteReplicationActor` — which received `ReplicateConfigDeploy` / `ReplicateConfigRemove` / `ReplicateConfigSetEnabled` / `ReplicateArtifacts` / `ReplicateStoreAndForward` from the active node's `DeploymentManagerActor`, tracked the peer through cluster membership events, and forwarded each command to `/user/site-replication` via `ActorSelection` — was deleted in LocalDb Phase 2, together with its notify-and-fetch exchange (the standby was told a deploy had happened and then HTTP-fetched the config itself). The site's config tables are now replicated by LocalDb CDC, so a deploy on either node reaches the other as an ordinary row change. `SiteReconciliationActor` still fetches over HTTP at node startup when central reports gaps; that is a different path and it survives.
|
||||
|
||||
Since register row 67 that pass also heals the **runtime**, not only the file. It runs after `DeploymentManagerActor`'s boot-time SQLite load, so a config it stored used to start no instance actor — an enabled instance missing at boot needed a *second* restart to run. Each enabled instance it heals now gets a fire-and-forget `EnsureInstanceStartedCommand` sent through the deployment-manager singleton proxy; the handler creates the instance actor only if one is not already running, re-reads the stored row to honour `is_enabled`, and steps aside for any in-flight deploy. A start emits a Warning log line and a `deployment` site event so the heal is visible.
|
||||
|
||||
## Usage
|
||||
|
||||
### Lifecycle commands
|
||||
@@ -263,6 +277,7 @@ All options live in the `ScadaBridge:SiteRuntime` section, bound to `SiteRuntime
|
||||
| `MaxConcurrentRunsPerScript` | `4` | Runs in flight (queued or executing) per script/alarm on-trigger script before the newest is shed (WP3.1) |
|
||||
| `MirroredAlarmCapPerSource` | `1000` | Maximum mirrored conditions per `NativeAlarmActor` source binding before oldest is dropped and logged |
|
||||
| `NativeAlarmRetryIntervalMs` | `5000` | Milliseconds before retrying a failed native alarm subscription |
|
||||
| `InstanceSweepIntervalSeconds` | `60` | Interval of the singleton's instance reconcile sweep (register rows 78 + 72); `0` disables it — see Instance reconcile sweep |
|
||||
|
||||
The SQLite connection string is passed directly to `AddSiteRuntime(connectionString)` in the host composition root and is not part of `SiteRuntimeOptions`.
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ The retry interval is fixed — not exponential. There is no maximum buffer size
|
||||
|
||||
`StoreAndForwardMessage.MaxRetries` controls how many background-sweep attempts the engine makes before parking. `MaxRetries = 0` means **no limit** — the message retries on every sweep until delivered and is never parked for retry exhaustion. It is not a "never retry" value; callers that want unbounded retry pass `maxRetries: 0` explicitly. The `EnqueueAsync` `maxRetries` parameter defaults to `StoreAndForwardOptions.DefaultMaxRetries` (50).
|
||||
|
||||
**The source-entity `0` means the opposite, and the gateways convert.** On an `ExternalSystemDefinition` / `DatabaseConnectionDefinition`, `MaxRetries = 0` (and `RetryDelay = TimeSpan.Zero`) mean *unset*, not *unbounded* — so `ExternalSystemClient.CachedCallAsync` and `DatabaseGateway` pass `system.MaxRetries > 0 ? system.MaxRetries : null`, letting the bounded engine default apply. Forwarding the entity's `0` verbatim would make every unconfigured cached call retry forever. The external system's values only actually reach a site as of deferred-work row 27; before that the artifact did not carry them.
|
||||
|
||||
### Messages not cleared on instance deletion
|
||||
|
||||
When an instance is deleted, its buffered S&F messages are not removed. `StoreAndForwardMessage.OriginInstanceName` records the originating instance at enqueue time so the buffer can continue to deliver and so the central UI can attribute parked messages even after the instance is gone.
|
||||
|
||||
@@ -49,6 +49,14 @@ After `LoadAsync` validates and decrypts a bundle, the plaintext content is stor
|
||||
|
||||
Without this, an abandoned wizard or a plain CLI preview held one of the eight `MaxConcurrentImportSessions` slots — and the decrypted bundle content — for the full TTL; eight of them locked out every import on the active node (deferred register row 43, observed live).
|
||||
|
||||
**Session-cap observability (register row 53).** That leak was only ever inferable from rotating test failures, because nothing counted open sessions and nothing logged a rejection. Two signals now close the gap:
|
||||
|
||||
- **Gauge `scadabridge.central.transport.import_sessions.open`** — open (unexpired) sessions on this node, on the `ZB.MOM.WW.ScadaBridge` meter, which is already in `SiteServiceRegistration.ObservedMeters` (the `ZbTelemetryOptions.Meters` allowlist; an unlisted meter exports nothing, silently). The provider is registered by `BundleSessionEvictionService.StartAsync` and identity-checked-cleared on `StopAsync`, mirroring the StoreAndForward queue-depth gauge. `IBundleSessionStore.OpenSessionCount` deliberately excludes expired-but-unswept entries — counting the raw dictionary would report the node as pinned at the cap for up to a sweep interval after a burst had actually drained, which is the exact false positive this instrument exists to rule out. Transport is registered only in the host's Central branch, so the gauge reports 0 on a site node.
|
||||
- **Counter `scadabridge.central.transport.import_sessions.rejected`** (register row 66) — bundle-import sessions turned away because the cap was exhausted, on the same meter (so likewise no allowlist change). Row 53 shipped the gauge and a Warning log, but a log line cannot be alerted on; this is the series to alert on. It is incremented **only at the post-eviction recheck** in `BundleSessionStore.Open`: the cap is tested twice, and a store full of merely *expired* sessions reclaims and admits the caller, so counting the first test would fire the signal routinely on any node whose sweep has not yet run. Re-`Open`ing an already-open session id skips the cap block entirely and is likewise never counted.
|
||||
|
||||
Read the two instruments **together** — the pair is what discriminates the failure modes. Rejections climbing while the gauge oscillates *below* the cap is genuine concurrency: raise `MaxConcurrentImportSessions`. Rejections climbing while the gauge sits *pinned at* the cap is a session leak (the register row 43 signature); raising the cap only defers it, so find the leak instead.
|
||||
- **Warning log on rejection** — `BundleSessionStore.Open` logs the open count, the cap, and the TTL when it rejects. It fires only on a real cap hit: a store full of *expired* sessions is reclaimed first and logs nothing. A count that stays pinned at the cap across repeated warnings is a leak; an occasional warning is genuine concurrency, and the fix there is to raise `ScadaBridge:Transport:MaxConcurrentImportSessions` (each slot pins up to `MaxBundleSizeMb` of decrypted plaintext, so the cap is a memory budget).
|
||||
|
||||
### Conflict resolution and `BundleImportId` correlation
|
||||
|
||||
`PreviewAsync` compares each bundle artifact against the target environment using `ArtifactDiff`, classifying items as `Identical`, `Modified`, `New`, or `Blocker`. The operator assigns a `ResolutionAction` (`Add`, `Overwrite`, `Skip`, `Rename`) per item. `ApplyAsync` honours those resolutions in a single EF transaction and threads a new `BundleImportId` GUID through every per-entity audit row via the scoped `IAuditCorrelationContext`. This makes every configuration row written by a bundle import queryable as a group from the audit log.
|
||||
@@ -122,10 +130,18 @@ Passphrase lockout operates at two levels: per-bundle (3-strike counter in `Bund
|
||||
|
||||
The two levels interact in a specific order, and it is load-bearing (register row 44 — the limiter had been registered, injected and unit-tested but never called, so only the per-bundle lockout was real):
|
||||
|
||||
1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget.
|
||||
1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` (Commons — register row 51 moved it out of `Transport.Import` for the same reason the throttle exception lives there: `ManagementService` does not reference `Transport`, so a lockout used to reach the CLI as `MapFault`'s generic "An internal error occurred" plus a correlation id) without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget.
|
||||
2. **Per-IP throttle next, before the decrypt.** Over budget throws `BundleUnlockThrottledException` (Commons, so the management surface can catch it and return a curated message). Because it precedes the decrypt, a denial burns **no** per-bundle strike — otherwise the throttle would be a way to lock out a bundle someone is legitimately importing. Every unlock attempt is registered, successful ones included: the documented budget counts attempts, not failures.
|
||||
3. Only then the AES-GCM decrypt, whose failure increments the per-bundle counter as before.
|
||||
|
||||
**The per-IP window is fleet-wide (register row 52).** It used to be a process-local `ConcurrentDictionary` on each central node, which had two consequences worth naming: the effective fleet cap was N × `MaxUnlockAttemptsPerIpPerHour` per client (a client that failed over to the other node got a fresh budget), and a node restart cleared the window outright. The window now lives in the central MS SQL `BundleUnlockAttempt` table — the HA store both central nodes already share, so this needs no new infrastructure, no new port and no consensus protocol. `BundleUnlockRateLimiter` keeps the policy; `SqlBundleUnlockAttemptWindow` is the store, resolving a scoped `IBundleUnlockAttemptRepository` from its own scope per call (it is a singleton, like the limiter). Three properties are load-bearing:
|
||||
|
||||
- **It fails OPEN.** A store error logs a Warning naming the client and the cap, and *permits* the attempt. This is a best-effort rate cap layered over a surface that is already authenticated and Administrator-gated, so the availability of legitimate imports outranks the cap — a database blip must not make the import wizard unusable. Falling back to the old process-local window instead was considered and rejected: it would still block a legitimate operator's 11th import during an outage, which is precisely the outcome fail-open exists to prevent. The Warning is the operator's signal that the fleet cap is not currently being enforced.
|
||||
- **Accuracy is deliberately approximate.** The count-then-insert pair is not atomic across the two nodes, so genuinely simultaneous attempts can overshoot the cap by up to the number in flight. A conditional single-statement insert would close that at the cost of provider-specific raw SQL; the overshoot is bounded and self-corrects within the window.
|
||||
- **Rows are pruned by two drivers sharing one cadence claim (register row 65).** The prune is claimed by CAS at most once per 15 minutes per process, bounded to 1000 rows per pass, retaining `2 ×` the window so a prune racing a count can never delete a row the count still needs, and wrapped in a try/catch that logs and swallows — retention maintenance must never fail the operator's actual unlock. This is the same shape `DeploymentService` uses for terminal deployment records. It originally rode the **unlock path only**, on the reasoning that Transport owned no central scheduler; the consequence was that a cluster which stopped importing entirely left its last rows in place indefinitely, since only a node actively serving unlocks ever pruned. Transport has since gained exactly such a scheduler — `BundleSessionEvictionService`, the central-only 1-minute session sweep — so it now also ticks `IBundleUnlockAttemptWindow.PruneAsync`, and the table converges whether or not anyone is importing. Because both drivers take the **same** claim, ticking on the 1-minute sweep costs at most one bounded DELETE per quarter hour and the two can never prune concurrently. `PruneAsync` is contractually non-throwing (unlike `TryRegisterAttemptAsync`, which throws and lets the limiter own the fail-open decision) — a background sweep has no caller to report to and must survive a database blip.
|
||||
|
||||
Behaviour the operator sees is otherwise unchanged: same config key, same semantics ("attempts, not failures"), same ordering relative to the per-bundle lockout, same `<unattributed>` bucket.
|
||||
|
||||
The client key is the caller's remote IP: the Central UI wizard reads it from `CircuitClientAddressProvider` (a scoped service that snapshots `Connection.RemoteIpAddress` at circuit establishment, since `HttpContext` is gone by the time a circuit submits a passphrase — the same pattern as `CookieAuthenticationStateProvider`), and the management endpoint stamps it on `ManagementEnvelope.ClientIp` for the CLI `transport preview` / `transport import` paths. A caller with no attributable address is counted against a single **shared** bucket rather than skipping the throttle — "unknown client" degrades to a stricter budget, never to none. As with the login throttle (`Component-Security.md`), per-client isolation is only real when ForwardedHeaders is configured for the fronting proxy; behind an untrusted proxy every caller collapses into the proxy's bucket.
|
||||
|
||||
```csharp
|
||||
@@ -255,11 +271,11 @@ A template's scripts reference a `SharedScript` or `ExternalSystem` that exists
|
||||
|
||||
### Passphrase lockout
|
||||
|
||||
After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt.
|
||||
After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt. Both the Central UI and the CLI surface the lockout reason verbatim — the exception lives in `Commons/Types/Transport/` so `ManagementActor` can curate it into a `ManagementCommandException` (register row 51).
|
||||
|
||||
### Unlock throttled ("Too many bundle unlock attempts")
|
||||
|
||||
`BundleUnlockThrottledException` — this **client** (not this bundle) has used its `MaxUnlockAttemptsPerIpPerHour` budget within the trailing hour. Re-uploading does not help; the budget is keyed by IP and rolls forward on a sliding window, and the counters are process-local (a central node restart clears them, deliberately, so an outage doesn't strand legitimate operators). The denial burns no per-bundle strike, so the bundle itself is still importable once the window frees up. Raise `ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour` if a legitimate workflow (many encrypted bundles in one sitting) trips it routinely.
|
||||
`BundleUnlockThrottledException` — this **client** (not this bundle) has used its `MaxUnlockAttemptsPerIpPerHour` budget within the trailing hour. Re-uploading does not help; the budget is keyed by IP, rolls forward on a sliding window, and is **fleet-wide and restart-durable** (register row 52 — see below), so failing over to the other central node does not help either. The denial burns no per-bundle strike, so the bundle itself is still importable once the window frees up. Raise `ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour` if a legitimate workflow (many encrypted bundles in one sitting) trips it routinely.
|
||||
|
||||
### BundleImportAlarmScriptUnresolved / BundleImportCompositionUnresolved warnings
|
||||
|
||||
|
||||
@@ -223,19 +223,35 @@ one of the ten replicated tables, and its retention DELETE is captured by CDC ex
|
||||
ordinary write — by design, since LocalDb Phase 2 there is deliberately no purge-exemption path
|
||||
(the same property that makes a mass DELETE dangerous, which is why `ReplaceAllAsync` was deleted
|
||||
rather than reinstated). One oplog row is therefore queued per deleted event, and the backlog jumps
|
||||
by the size of the day's expired batch.
|
||||
by the size of the batch the tick deleted.
|
||||
|
||||
**The spike is bounded per tick** (register row 34, measured:
|
||||
`docs/plans/2026-08-15-site-events-purge-burst-measurement.md`). One purge tick deletes at most
|
||||
`ScadaBridge:SiteEventLog:MaxPurgedRowsPerTick` rows — default **250 000**, across the retention
|
||||
purge and the cap trim combined — so the height of the spike is a property of the *configuration*,
|
||||
not of how much history had expired. That matters because the burst used to be unbounded: a full
|
||||
1 GB event log holds ~2.73 million rows, and expiring all of them at once queued 2 731 947 oplog
|
||||
rows, **2.7x LocalDb's `MaxOplogRows` (default 1 000 000)** — enough for the maintenance tick to
|
||||
prune the oplog and flag a *full ten-table snapshot resync*. A healthy peer blunts that (the oplog
|
||||
drains while the purge runs — measured peak was 391 000, not 2.73 M), which is precisely the problem:
|
||||
the old safety margin was a function of how fast the peer happened to be, and vanished when the peer
|
||||
was down. Set `MaxPurgedRowsPerTick` to `0` to restore the old unbounded behaviour.
|
||||
|
||||
- **When.** Every `ScadaBridge:SiteEventLog:PurgeInterval` (default **24 h**), plus once at
|
||||
startup. The timer is anchored to the **active node's process start**, not to a wall-clock hour,
|
||||
so the burst lands at a different time of day after each failover or restart — do not expect it
|
||||
at a fixed hour. The storage-cap trim (default 1 GB) can produce the same shape off-schedule, and
|
||||
is usually the larger of the two.
|
||||
startup — **except** after a tick that hit the ceiling, which schedules the next tick after
|
||||
`ScadaBridge:SiteEventLog:PurgeCatchUpInterval` (default **5 minutes**) instead. So a large
|
||||
backlog appears as a *run* of bounded spikes minutes apart rather than one huge one; a
|
||||
worst-case 2.73 M-row backlog converges in ~11 ticks ≈ 55 minutes. The cadence is anchored to the
|
||||
**end of the previous purge**, not to a wall-clock hour, so the burst lands at a different time of
|
||||
day after each failover or restart — do not expect it at a fixed hour. The storage-cap trim
|
||||
(default 1 GB) can produce the same shape off-schedule, and draws from the same per-tick budget.
|
||||
- **Where it shows.** Only on a node with replication configured — the rig's site-a. site-b/site-c
|
||||
have no capture triggers at all and report no backlog for a purge.
|
||||
- **What healthy looks like.** `LocalDbReplicationConnected` stays **true** across the spike, and
|
||||
the backlog drains back to ~0 as the peer acks the batch — within seconds to a couple of minutes
|
||||
depending on batch size (delta messages are bounded by `LocalDb:Replication:MaxBatchBytes`,
|
||||
default 2 MB, and secondarily by `MaxBatchSize`). No dead letters, no schema-mismatch errors.
|
||||
the backlog drains back to ~0 as the peer acks the batch. Measured on a loopback pair at
|
||||
~10 800 tombstones/second, so a full 250 000-row tick drains in well under a minute (delta
|
||||
messages are bounded by `LocalDb:Replication:MaxBatchBytes`, default 2 MB, and secondarily by
|
||||
`MaxBatchSize`). No dead letters, no schema-mismatch errors.
|
||||
- **What is actually wrong.** `LocalDbReplicationConnected` **false** while the backlog climbs, a
|
||||
backlog that keeps rising across successive readings rather than draining, or a backlog that
|
||||
never returns near zero between bursts. Those point at the sync stream — an ApiKey mismatch
|
||||
@@ -248,13 +264,69 @@ logs an Information line next to the purge count:
|
||||
```
|
||||
Purged 41230 events older than 30 days
|
||||
Purged 41230 site_events rows on a replication-enabled node — a transient LocalDb oplog backlog
|
||||
is expected while the deletes replicate to the peer. It drains on its own;
|
||||
of up to that many rows is expected while the deletes replicate to the peer. It drains on its own;
|
||||
LocalDbReplicationConnected staying true with the backlog returning to ~0 is the healthy signature.
|
||||
A tick deletes at most 250000 rows, so the spike is bounded by that number rather than by how much
|
||||
history had expired.
|
||||
```
|
||||
|
||||
An unreplicated node logs only the first line. If a backlog spike has no such line near it in the
|
||||
active node's log, the purge is *not* the explanation and the spike is worth investigating.
|
||||
|
||||
The second line is emitted **once per purge tick**, and its count covers the retention purge and
|
||||
the storage-cap trim together. If you are comparing it against the `Purged N events older than…`
|
||||
line above it, expect the two numbers to differ whenever the cap trim also ran — the note reports
|
||||
the tick's whole contribution to the oplog, which is what a backlog spike is made of. (Through
|
||||
2026-08-16 the note was emitted per phase, so a tick that ran both produced two near-identical
|
||||
lines; register row 84.)
|
||||
|
||||
**A tick that hit the ceiling adds a Warning**, rate-limited to one line per 15 minutes so a
|
||||
converging backlog does not flood the log:
|
||||
|
||||
```
|
||||
Event log purge stopped at its per-tick ceiling of 250000 rows (250000 deleted); expired events
|
||||
remain and the next purge runs in 00:05:00 instead of 1.00:00:00.
|
||||
```
|
||||
|
||||
Every hit — throttled line or not — increments the counter
|
||||
`scadabridge_site_event_log_purge_ceiling_hits`. **Read the counter's shape, not a single hit:** a
|
||||
short run of hits that stops is a backlog converging (normal, e.g. the first purge after enabling
|
||||
replication on a long-running site); hits that never stop mean the site writes events faster than
|
||||
one paced tick can purge, i.e. `MaxPurgedRowsPerTick` or `PurgeCatchUpInterval` is mis-sized for
|
||||
that site's event rate.
|
||||
|
||||
**Two dashboard surfaces make this reviewable rather than a log-archaeology exercise** (register
|
||||
row 33):
|
||||
|
||||
- **The "Replication backlog" badge** on the site tile of the Health dashboard. It is deliberately
|
||||
a *sustained* signal, not a bare threshold: the badge appears only once the backlog has stayed
|
||||
above `ScadaBridge:HealthMonitoring:LocalDbBacklogThreshold` (default **1000**) continuously for
|
||||
`ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor` (default **15 minutes**). The daily
|
||||
purge burst described above drains in seconds to a couple of minutes, so it never reaches the
|
||||
dwell and never badges the site — which is the entire reason the dwell exists. **The per-tick
|
||||
ceiling reinforces that rather than threatening it:** pacing lowers the spike (250 000 rows
|
||||
maximum instead of an unbounded 2.7 M) at the cost of more ticks, and a 250 000-row tick was
|
||||
measured draining in well under a minute — even allowing 10x for a real inter-node link that is
|
||||
~4 minutes, against a 15-minute dwell and a 5-minute gap to the next tick. (The *unpaced* 2.73 M
|
||||
burst drained in 6.0 minutes on loopback: inside the dwell, but with only 2.5x of margin, so a
|
||||
materially slower link would have badged.) A bare threshold
|
||||
would fire once a day on every replicated site and be ignored within a week. The dwell is
|
||||
validated to be at least one `ReportInterval`, since a shorter window could be satisfied by a
|
||||
single sample and would collapse back into a bare threshold.
|
||||
- **The "LocalDb Replication Backlog" trend chart** in the dashboard's per-site Site Health Trends
|
||||
panel, backed by the KPI history store (sampled every minute, retained 90 days raw / 365 days
|
||||
hourly). This is what answers the question a point-in-time reading cannot: *did that spike
|
||||
drain?* A past purge burst appears as a spike that returns to ~0; a real fault appears as a
|
||||
level that never comes back down.
|
||||
|
||||
**Both surfaces are silent on an unreplicated site, by design.** `LocalDbOplogBacklog` and
|
||||
`LocalDbReplicationConnected` are nullable, and null means "no reading", never zero. The KPI
|
||||
sampler therefore writes **no sample at all** rather than a fabricated 0, so site-b/site-c show an
|
||||
empty chart rather than a flat zero line, and they never accumulate the dwell anchor the badge
|
||||
keys off. A gap in those series means "not replicated / not reporting" — which is the truth. Note
|
||||
that `false` and null differ for the connected flag: a configured link that is DOWN samples as 0,
|
||||
while no link at all samples as nothing.
|
||||
|
||||
### Site Pair Upgrades — stop and start BOTH nodes together
|
||||
|
||||
**A rolling upgrade of a site pair, one node at a time, is no longer supported.** It worked while
|
||||
@@ -265,6 +337,18 @@ will run, but they will not converge, and the divergence is silent.
|
||||
|
||||
Stop both nodes of a site pair, upgrade both, then start both.
|
||||
|
||||
**A release that adds a column to a replicated table is the same constraint, enforced louder.**
|
||||
`ReplicatedTable.ComputeDigest` hashes every column name and declared type, and the sync handshake
|
||||
compares those digests fail-closed — so a mixed-version pair whose builds disagree about a table's
|
||||
columns does not diverge silently, it refuses to sync with a `LocalDbSchemaMismatchException`.
|
||||
Stopping and starting both nodes together, which is already the rule above, is the whole fix; no
|
||||
manual trigger or oplog migration is needed, because `RegisterReplicated` re-reads
|
||||
`PRAGMA table_info` on every boot and rebuilds the capture triggers (DROP + CREATE) over the
|
||||
current column set. The site schema's own `AddColumnIfMissing` runs before registration on that
|
||||
same boot, so a node picks up the new columns and the matching triggers in one start.
|
||||
(Most recent instance: `external_systems` gained `max_retries` / `retry_delay_ms` for the
|
||||
per-system store-and-forward retry policy.)
|
||||
|
||||
**Related bound — do not leave one node of a pair offline for long.** A node absent for longer than
|
||||
`LocalDb:Replication:TombstoneRetention` (default **7 days**) can **resurrect deleted rows** when
|
||||
it rejoins: deletes replicate as HLC-ordered tombstones, and once a tombstone is pruned there is
|
||||
@@ -273,6 +357,103 @@ a rejoin is safe and self-correcting (verified live: a node stopped and restarte
|
||||
with both nodes byte-identical and zero duplicates). Beyond it, rebuild the returning node's
|
||||
database from its peer rather than letting it rejoin.
|
||||
|
||||
### Docker-rig data files on macOS/OrbStack — a virtiofs corruption + availability hazard
|
||||
|
||||
**Scope: the local `docker/` and `docker-env2/` development rigs on macOS only.** This is a
|
||||
host-level property of the filesystem the rig's data files sit on, **not a ScadaBridge defect** and
|
||||
not something a production Linux deployment is exposed to. It is documented here because it has
|
||||
already cost two databases and half a session, and because the recovery is non-obvious.
|
||||
|
||||
Every ScadaBridge data file on the rig rides a **relative bind mount** out of the repo working tree
|
||||
— `./site-a-node-a/data:/app/data` and its siblings, which is where `site-localdb.db`,
|
||||
`auditlog.db` and the secrets store all live. On macOS that path crosses **virtiofs** to reach the
|
||||
Linux VM, and **virtiofs does not give SQLite true `fsync` durability**. SQLite's crash-safety
|
||||
contract assumes a successful `fsync` means the bytes are on stable storage; when that is not true,
|
||||
an abrupt container kill mid-write can leave a torn file. `docker compose up --force-recreate`
|
||||
— which `deploy.sh` uses — is exactly such an abrupt kill.
|
||||
|
||||
Two distinct failure modes have been observed live (both 2026-08-15):
|
||||
|
||||
**1. Silent database corruption.** Both site-a LocalDb files were found malformed:
|
||||
|
||||
```
|
||||
SQLite Error 11: 'database disk image is malformed'
|
||||
```
|
||||
|
||||
Note how differently the two nodes presented, because this is the part that makes it hard to spot:
|
||||
|
||||
- **site-a-b** surfaced it as a *running* fault — every sync call threw, replication reported
|
||||
`connected: false` with a backlog of 892, and the faulted session retried about once a minute.
|
||||
The node stayed up and otherwise healthy-looking.
|
||||
- **site-a-a** was corrupt too, but **latently** — nothing on the running build ever read the
|
||||
damaged pages. It only became fatal on the *next upgrade*, when a new build's boot-time
|
||||
re-registration read pages the old build never touched: `SqliteLocalDb` construction threw and
|
||||
the container **SIGABRT crash-looped** (×12).
|
||||
|
||||
The lesson to carry: a clean-looking node proves nothing about its file. Corruption can sit latent
|
||||
until an unrelated code change happens to read further into the database.
|
||||
|
||||
**2. The OrbStack VM idle-sleeping mid-operation.** The VM can suspend under its idle policy while
|
||||
work is in flight. `~/.orbstack/log/vmgr.log` records it plainly:
|
||||
|
||||
```
|
||||
vmgr ... msg=sleep
|
||||
```
|
||||
|
||||
On wake, the virtiofs layer logs repeated errors of the form:
|
||||
|
||||
```
|
||||
refresh_nodeid: race with new lookup
|
||||
```
|
||||
|
||||
and `docker` hangs — commands stop returning, `docker ps` comes back empty — for as long as it
|
||||
takes to notice. **The fix is `orb restart docker`**; data survives and containers auto-restart.
|
||||
Observed cost: roughly 20 minutes of a session lost to diagnosing what looked like a cluster
|
||||
failure but was a host one. If the rig goes unresponsive as a whole, check `vmgr.log` **before**
|
||||
investigating ScadaBridge.
|
||||
|
||||
#### Recovery runbook — LocalDb corruption on a site pair (proven)
|
||||
|
||||
This is the procedure that recovered site-a. It relies on the pair being able to reseed itself,
|
||||
so it is a *replicated* pair's recovery; an unreplicated node has no peer to reseed from and must
|
||||
be restored from a backup instead.
|
||||
|
||||
1. **Stop BOTH nodes of the site pair, together.** Not one at a time — this is the same
|
||||
stop-and-start-together rule as *Site Pair Upgrades* above, for the same reason.
|
||||
2. **Quarantine the database and its sidecars — do not delete them.** Rename all three, keeping
|
||||
them for forensics:
|
||||
|
||||
```sh
|
||||
# per affected node's data directory
|
||||
mv site-localdb.db site-localdb.db.corrupt-$(date +%F)
|
||||
mv site-localdb.db-wal site-localdb.db-wal.corrupt-$(date +%F)
|
||||
mv site-localdb.db-shm site-localdb.db-shm.corrupt-$(date +%F)
|
||||
```
|
||||
|
||||
The `-wal` and `-shm` files **must** move with the main file. Leaving a stale WAL beside a fresh
|
||||
database is its own corruption.
|
||||
3. **Start both nodes together.** The fresh files reseed themselves from two independent paths:
|
||||
`SiteReconciliationActor`'s node-startup fetch pulls config back from central when central
|
||||
reports gaps, and LocalDb's **CDC snapshot resync** merges the peer's rows under LWW.
|
||||
4. **Verify convergence**, do not assume it: `LocalDbReplicationConnected` should read `true` and
|
||||
`LocalDbOplogBacklog` should settle to ~0 on the site health report. (See *Reading the
|
||||
replication backlog* above for why a transient spike is normal and not a fault.)
|
||||
|
||||
On the live recovery, both quarantined files were large (394 MB and 416 MB), the pair converged to
|
||||
`connected: true` / backlog 0, and a schema change in flight at the time additionally proved the
|
||||
digest handshake green.
|
||||
|
||||
#### Mitigation options, with their trade-offs
|
||||
|
||||
None of these is applied today; the rig deliberately still runs on bind mounts, and the accepted
|
||||
position is the runbook above. Recorded so the choice is a choice:
|
||||
|
||||
| Option | Gains | Costs |
|
||||
|--------|-------|-------|
|
||||
| Move rig data dirs to **named docker volumes** | Files live on native ext4 inside the VM, out of virtiofs entirely — removes the corruption mechanism at the source | Loses the host-side inspectability the debugging sessions lean on constantly: no more opening `site-localdb.db` from the Mac, no `ls` of a node's data dir from the repo tree. Requires a compose change. |
|
||||
| **Disable OrbStack's idle pause** | Removes failure mode 2 (the mid-operation sleep and the virtiofs `refresh_nodeid` storm) | A host-level setting outside this repo, so it cannot be version-controlled or applied by `deploy.sh` — it silently does not exist on a new machine. Does nothing about failure mode 1. |
|
||||
| **Accept, and use this runbook** (current) | No changes to compose or host config; full host-side inspectability retained | Corruption recurs on the next unlucky `--force-recreate`, and can sit latent until a later upgrade turns it into a crash loop. |
|
||||
|
||||
### Central-Site Communication
|
||||
|
||||
Three transports cross the boundary, not one — **all now gRPC or HTTP; Akka ClusterClient was removed
|
||||
@@ -339,6 +520,33 @@ hardening and needs no change to the key design.
|
||||
| Site node | 2 cores | 4 GB | 20 GB | SQLite databases grow with S&F |
|
||||
| SQL Server | 4 cores | 16 GB | 100 GB | Shared across central cluster |
|
||||
|
||||
#### Sizing a site's data volume — `MaxStorageMb` is not the whole story
|
||||
|
||||
`ScadaBridge:SiteEventLog:MaxStorageMb` (default **1 GB**) bounds **`site_events` and its four
|
||||
indexes**, and nothing else. It is deliberately *not* a cap on the consolidated LocalDb file
|
||||
(register row 81 — decision record
|
||||
`docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md`). Before that change it did
|
||||
measure the whole file, which on a replicated node made the cap unenforceable: the trim's own CDC
|
||||
oplog rows and tombstones grew the file as fast as the deletes shrank it, and the trim stopped
|
||||
2.5x over cap. Tying the event log's budget to the peer's liveness was the defect, not a feature
|
||||
worth preserving.
|
||||
|
||||
So budget the volume as the **sum** of:
|
||||
|
||||
- **`MaxStorageMb`** — the event log proper.
|
||||
- **LocalDb replication bookkeeping** — `__localdb_oplog` and `__localdb_row_version`, bounded
|
||||
separately by `LocalDb:Replication:MaxOplogRows` (default 1 000 000) and `TombstoneRetention`
|
||||
(default 7 days). This is transient: it drains as the peer acks and as tombstones expire, but it
|
||||
is not small during a burst. Measured worst case (register row 34): purging a full 1 GB event log
|
||||
left **707 MB** of bookkeeping in the file. Unreplicated sites carry none of this — they have no
|
||||
capture triggers at all.
|
||||
- **The other eight consolidated tables** plus the separate, unreplicated audit database at
|
||||
`AuditLog:SiteWriter:DatabasePath`.
|
||||
|
||||
The 20 GB figure above has ample headroom for all three at default settings. Raising
|
||||
`MaxStorageMb` on a replicated site should be paired with a look at the oplog settings, not done
|
||||
in isolation.
|
||||
|
||||
### Network Bandwidth
|
||||
|
||||
- Health reports: ~1 KB per site per 30 seconds = negligible
|
||||
|
||||
@@ -20,33 +20,76 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
|
||||
| 24 | M8 large-bundle performance hardening | `docs/plans/2026-06-15-stillpending-completion-design.md:106` — "Small follow-ups logged (not blocking): … large-bundle/perf hardening" | Logged as a non-blocking follow-up when M8 shipped and never given an artifact: **no plan, no task entry, no perf/load test exists** (`tests/…Transport.Tests/Import/BundleImporterLoadTests.cs` is a `LoadAsync` unit suite despite the name). No measured problem; the only sizing controls in place are the 5-minute CLI transport timeout, `LineDiffer`'s `MaxInputLines`=4000 summary-only cap, and `MaxConcurrentImportSessions`=8. | First real bundle that times out, exhausts memory, or makes the import wizard's diff step unusable |
|
||||
| 25 | ~~**Phase-8 WP-4 target-scale load test** (10 sites x 500 instances x 75 tags = 37,500 subscriptions/site, 375,000 total)~~ **RESOLVED 2026-08-15 — run, measured, evidenced.** The 107-byte stub is replaced by a real full-scale run: **10 x 500 x 75 = 375,000 live tag subscriptions**, 37,518 tag updates/s achieved against 37,500 nominal (100.0%), **45,021,375 updates offered** over a 20-minute steady-state window on an M4 Pro / 14-core / 48 GB host with the 8-node `docker/` rig still running. Headline numbers: tag update latency **P50 0.88 ms / P99 4.57 ms / max 37.41 ms** end-to-end (DCL boundary to stream subscriber, the emit instant carried verbatim through production code); **0 events dropped** at 100 live subscribers; health report collect+ingest **P99 0.31 ms** with 10/10 sites tracked; debug view snapshot under load **P99 2.19 ms, 0 timeouts**; 500 instances deployed to a site in **2.6 s**; CPU **2.9% of the box**; working-set slope **+8.83 MB/min**. **11 clean passes, 1 pass with a caveat (F1: 20 min with zero gen-2 collections cannot fully settle the leak question — the 1-hour run would), 0 failures.** Harness: `tests/ZB.MOM.WW.ScadaBridge.LoadHarness/` (standalone Exe — the `Category=Performance` trait enables a filter but does NOT exclude by default, so a 20-minute test could not live in `PerformanceTests`) plus a CI-scale smoke `[Fact]` (`PerformanceTests/TargetScale/TargetScaleHarnessSmokeTests.cs`). Real: per-site ActorSystem + LocalDb, the real DCL via the documented `DataConnectionFactory.RegisterAdapter` seam, real `InstanceActor`s, real `SiteStreamManager`, real `StreamRelayActor` + production-capacity bounded channel, real S&F, real health collector/aggregator; only the socket hops are stood in for. **Results: `docs/plans/2026-08-15-target-scale-load-test-results.md`; design + deviations + thresholds: `docs/plans/2026-08-15-target-scale-load-test-design.md`.** | as before | Closed. Residual scope: `[xc-3]` site-event-log retention volume, `[xc-4]` audit-vs-central degradation, `[xc-5]` large-template flattening and `[xc-8]` UI responsiveness are NOT covered by this harness (central-cluster/browser concerns) and remain open WP-4 scope — see results doc SS6. | Closed 2026-08-15. |
|
||||
| 26 | Ipsen MES MoveIn tail: leak-test (`-LT`) receivers + routing, PLC-output-flag writes, `Z28062` BTDB data completeness | `docs/plans/2026-06-16-ipsen-mes-movein.md:409` ("Out of scope (future)"); design `2026-06-16-ipsen-mes-movein-design.md:58-60, :196-198` | Customer-site scope, not a platform gap. `-LT` routing needs an MES-receiver child + Galaxy reference that do not exist on the reactor template (any `-LT`/unknown suffix returns `WasSuccessful=false` with an "unsupported side/target" message by decision); `MoveInComplete`/`Successful`/`ErrorText` are **PLC-owned** by locked decision, so ScadaBridge deliberately does not write them; `Z28062` completeness is an operational data fix, not code. Note the separate alarm-status path already handles the suffix — `_LT` is stripped before side-scoping (`2026-06-30-mes-alarm-status-api.md:158`). | Ipsen creates the leak-test receiver + Galaxy reference, or asks ScadaBridge to own the PLC-output flags — otherwise a **candidate won't-do** (`[PERM]`) at the next Ipsen scope review |
|
||||
| 27 | External-system per-system retry config (`MaxRetries`/`RetryDelay`) never reaches sites, and has no CLI/management surface | Found live 2026-08-01 (rig session, #11 gRPC live checks) | Two stacked gaps: (a) `ExternalSystemArtifact` (Commons) carries `TimeoutSeconds` but NOT `MaxRetries`/`RetryDelay`, and the site `external_systems` table has no such columns — so a centrally-configured retry policy is silently ignored on sites; every cached call buffers with the S&F default (`DefaultMaxRetries` 50 × `DefaultRetryInterval` 30s ≈ 25 min to park). (b) `Create/UpdateExternalSystemCommand` don't expose the fields either — the only way to set them today is a direct DB edit of `ExternalSystemDefinitions`. Transport bundles DO carry them (arch-review 05 "ES retry config"), which masks the gap in export/import round-trips. Fix is additive: extend the artifact + site schema + apply path, and add `--max-retries`/`--retry-delay` to the CLI. | First operator who tunes retry policy on an external system and expects site cached calls to honor it |
|
||||
| 28 | Health-dashboard "Trigger failover" confirm dialog's confirm button is labeled **"Delete"** | Found live 2026-08-01 (rig session, #11 TriggerSiteFailover check) | The DialogService confirmation host's default destructive-action label leaks through — the dialog copy is correct but the red confirm button says "Delete" for a failover. One-line fix: pass an explicit confirm label ("Fail over") at the Health-dashboard call site (and audit other confirm-dialog call sites for the same default). | Next Central UI session |
|
||||
| 29 | **1-hour sustained run to settle the memory-slope ambiguity (load-test finding F1)** | `docs/plans/2026-08-15-target-scale-load-test-results.md` F1 (Low) | The row-25 20-minute run PASSED the working-set threshold (+8.83 MB/min < 20) but recorded **zero gen-2 collections across 45M events**, so the +19.52 MB/min managed-heap drift on an uncompacted gen-2 is ambiguous — the heap demonstrably sawtooths, but 20 min cannot distinguish "gen-2 not yet collected" from a slow leak. Not a defect finding; an evidence gap. The harness exists (`tests/ZB.MOM.WW.ScadaBridge.LoadHarness/`) and the full 1-hour protocol is one documented command (results doc §7) — deferred only because the measured window was scaled for practicality. | Before production go-live at target scale, or first field report of unexplained site-node memory growth |
|
||||
| 27 | ~~External-system per-system retry config (`MaxRetries`/`RetryDelay`) never reaches sites, and has no CLI/management surface~~ **RESOLVED 2026-08-15 (`41a5ecf1`).** The gap was transport + persistence only — the consumption end already worked (`ExternalSystemClient.CachedCallAsync` already passed `system.MaxRetries`/`RetryDelay` to `EnqueueAsync` with 0-as-unset, and the Central UI form already collected both fields, inert until now with defaults 3×5s that will take effect on next deploy). Shipped: artifact fields, additive `site_command.proto` fields 7/8 (regen via `docker/regen-proto.sh`, csproj left clean), site `external_systems` columns `max_retries`/`retry_delay_ms` as REAL COLUMNS — not a JSON bag; verified safe against the AckTime precedent in the LocalDb source (capture triggers are DROP-then-CREATE from `PRAGMA table_info` each boot, and the fail-closed handshake digest turns a mixed-version pair into a loud sync refusal, no new constraint since both-nodes-together is already the standing pair rule; `timeout_seconds` set the additive-column precedent on this exact table) — apply path, and CLI `--max-retries`/`--retry-delay-seconds` (+`--retry-delay` alias). Bonus fix: `ArtifactDiff.CompareExternalSystem` never compared the retry fields, so a bundle import changing a retry budget previewed as "Identical". Fail-before verified on the site round-trip + apply tests; 12 suites green. | Found live 2026-08-01 (rig session, #11 gRPC live checks) | Closed 2026-08-15. | — |
|
||||
| 28 | ~~Health-dashboard "Trigger failover" confirm dialog's confirm button is labeled **"Delete"**~~ **RESOLVED 2026-08-15 (`0db253b5`).** Structural, not a missing argument: `IDialogService.ConfirmAsync` had NO label parameter — `DialogHost` derived the verb from `danger` alone (`danger ? "Delete" : "Confirm"`), so no call site could ever have passed one. New optional `confirmLabel`; `danger` now selects styling only. Full audit of all 34 `ConfirmAsync` call sites (the only confirmation path — no `window.confirm`, no per-page modals): NINE were wrongly labeled "Delete" — both failover buttons, secured-write **Approve** and **Reject**, instance Disable, and four Discard actions — all now name their action; 18 genuine deletes + 7 non-danger sites verified correct. The `danger→"Delete"` fallback deliberately kept: forcing an explicit label would churn ~20 correct sites, and a missed site would silently degrade to "Confirm", detectable only by the rig-dependent Playwright suite — contract pinned in the interface XML doc, Component-CentralUI.md, and a fallback test instead. Gotcha recorded in the tests: adding an optional parameter silently un-matches existing 3-arg NSubstitute stubs (returns null Task → NRE). Follow-ups: rows 75 (Approve's red styling), and the bUnit real-dialog deadlock hazard documented in `FailoverConfirmLabelTests`. | Found live 2026-08-01 (rig session, #11 TriggerSiteFailover check) | Closed 2026-08-15. | — |
|
||||
| 29 | ~~**1-hour sustained run to settle the memory-slope ambiguity (load-test finding F1)**~~ **RESOLVED 2026-08-15 — run, and F1 is settled: NOT a leak.** Full 1-hour protocol executed (same harness/scale, `--sustain-minutes 60`, 135,041,250 updates at 37,511/s = 100.0 % of nominal, concurrent with the live docker rig AND a central failover drill on the same host). Decisive: **working-set slope −28.89 MB/min** (2,722 → 1,757 MB over the 3,600 s window, peak 3,258 MB) with **one gen-2 collection observed** and threads flat at 59 — a working set that FALLS ~1 GB across a sustained hour is incompatible with a slow leak; the positive managed-heap least-squares slope was the sawtooth-segment artifact F1 hypothesised. All other metrics held or improved vs the 20-min run: P99 4.01 ms, 0 healthy-subscriber drops, 744/744 debug snapshots, S&F drain to depth 0. Full numbers appended to the results doc F1 section. | `docs/plans/2026-08-15-target-scale-load-test-results.md` F1 postscript | Closed 2026-08-15. | — |
|
||||
| 30 | **WP-4 residual scope: `[xc-3]` site-event-log retention volume, `[xc-4]` audit-load-vs-central degradation, `[xc-5]` large-template flattening time, `[xc-8]` UI responsiveness under load** | `docs/plans/2026-08-15-target-scale-load-test-results.md` §6 + row 25 closure note | The row-25 harness is a site-runtime rig; these four WP-4 sub-criteria are central-cluster/browser concerns it deliberately does not cover. Recorded so the row-25 closure never reads as "WP-4 fully done" — phase-8 WP-4 is closed for the site-runtime data path only. | Before declaring phase-8 WP-4 complete, or production go-live at target scale |
|
||||
| 31 | **Unpaced-burst absorption is bounded by the shared publish stage, not per-subscriber buffers (load-test finding F3 caveat)** | `docs/plans/2026-08-15-target-scale-load-test-results.md` F3 | Measurement-backed design note, no defect: slow-subscriber isolation is structural and total (a stalled reader loses events only in its own bounded `DropOldest` channel; healthy subscribers delivered 100.00%), but the publish `Source.ActorRef` **upstream** of the `BroadcastHub` is shared, so an unpaced burst degrades every subscriber equally — that stage, not per-subscriber buffering, is the burst ceiling. | First field report of event loss on *healthy* subscribers during a burst, or any redesign of the site stream publish stage |
|
||||
| 32 | **`QueueDepthGaugeTests` full-suite flake — shared static gauge (load-test finding F4)** | `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs`; surfaced by the row-25 verification runs (`docs/plans/2026-08-15-target-scale-load-test-results.md` F4) | Pre-existing, test-only: fails when the full suite runs concurrently, passes in isolation — the gauge under test is process-global static state shared across test classes. Zero `src/` involvement; needs test isolation (per-test meter/collector instance), not a product fix. | Next test-infra session, or the first time it fails a CI/full-suite run that matters |
|
||||
| 33 | **`LocalDbOplogBacklog` has no threshold, KPI history, or dashboard treatment** | Found by R7 (`docs/plans/2026-08-15-residual-remediation-plan.md`) while documenting the purge burst | `SiteAuditBacklog` drives a configurable threshold warning on the site tile, but the LocalDb backlog is reported and Prometheus-exported with nothing consuming it for alerting or trending — it is not among the metrics `SiteHealthKpiSampleSource` samples into KPI history, so the purge-burst spike R7 documents cannot be reviewed after the fact; an operator must be watching live. Candidate: add it to the KPI sample source and/or a sustained-backlog threshold badge. Pairs naturally with the R1 per-table-snapshot work, which changes snapshot/backlog behavior. | Next health-dashboard session, or first operator question about a backlog spike they didn't see live |
|
||||
| 34 | **`site_events` purge burst is unbounded in the oplog and unmeasured at scale** | Found by R7 | Retention deletes are sliced at 1000 rows/statement, but the whole expired set is deleted in one tick, so a replicated site's oplog gains one row per deleted event with no pacing — a first purge after enabling replication (or after a long low-purge period) queues the entire batch at once. The wire path is bounded (`MaxBatchBytes`), but the oplog write itself is not paced, and no measurement exists for batch size or drain time at target scale. Candidate: measure on the rig or in the load harness; consider a per-tick delete ceiling if drain time is material. | Before enabling replication on a site with a large existing `site_events` backlog, or the next load-harness session |
|
||||
| 35 | **`DeploymentRecords` index does not cover the deployment-list sort key** | Found by R3 (`35ce1413`) | The table has a single-column `IX DeployedAt`; the new list query orders `DeployedAt DESC, Id DESC` and joins `Instance`, so SQL Server sorts per page. A composite `(DeployedAt DESC, Id DESC)` index would serve the ordering directly — deliberately not done in R3 because it needs an EF migration and the terminal-record purge bounds the table. *(Low)* | Deployment list measurably slow at a real fleet's retention depth |
|
||||
| 36 | **Deployments free-text search is unindexable by construction** | Found by R3 | Four `LIKE '%term%'` predicates (instance name, deployment id, revision hash, user) — leading wildcards preclude index seeks, so a search is a scan of the join. Acceptable at the purge-bounded row count. Fix if hot: anchor the id/hash legs as prefix matches, or full-text. *(Low)* | Search latency complaints on the Deployments page |
|
||||
| 37 | **Two paged reads of `DeploymentRecords` with different contracts** | Found by R3 | `QueryDeploymentSummariesAsync` (CLI/ManagementActor: offset, no total, no instance join, instance-id-list scoping) and R3's `QueryDeploymentListPageAsync` (UI: total + instance join + site-id scoping) duplicate query logic. Consolidating on one contract — giving the CLI a total, both scoping by site — removes the duplication. Genuinely different contracts today, so not urgent. *(Low)* | Next time either query's shape changes |
|
||||
| 32 | ~~**`QueueDepthGaugeTests` full-suite flake — shared static gauge (load-test finding F4)**~~ **RESOLVED 2026-08-15 (`65b19516`).** Reproduced first (2 failures in 5 parallel runs — the gauge read a sibling class's provider). The register's suggested fix (per-test meter) was rejected on inspection: the slot is process-global by product design and 3 of the 6 facts assert the slot-ownership semantics themselves (compare-and-clear identity, stop/restart re-seed), so per-test instances would change what they prove. Correct isolation is mutual exclusion — the three `StartAsync`-calling classes now share xunit collection `QueueDepthGaugeCollection` (with a `[CallerFilePath]`-anchored source-scan drift guard that names an offending file), the other eight classes stay parallel, and `parallelizeTestCollections` was flipped back ON so the fix is exercised rather than masked. Negative control: dropping the attribute fails the guard AND resurfaces the original flake in the same run. 8/8 green parallel runs. | `tests/…StoreAndForward.Tests/QueueDepthGaugeTests.cs`; surfaced by the row-25 verification runs | Closed 2026-08-15. | — |
|
||||
| 33 | ~~**`LocalDbOplogBacklog` has no threshold, KPI history, or dashboard treatment**~~ **RESOLVED 2026-08-15 (`8da60f57`).** Both halves shipped: sampled into KPI history via `SiteHealthKpiSampleSource` (conditionally — 12 samples unreplicated / 14 replicated, so a series gap truthfully means "not replicated") plus a backlog trend chart on the site health panel, and a SUSTAINED threshold badge (`LocalDbBacklogThreshold` 1000 held for `LocalDbBacklogSustainedFor` 15 min, anchored by `LocalDbBacklogAboveSince` in the aggregator) — a bare instantaneous threshold would fire on every replicated site every day from the documented purge burst. Null backlog is a deliberate third branch: it leaves the anchor unchanged (neither evidence of a drain nor of a backlog), so unreplicated sites can never anchor and can never be badged. NOTE the row's premise was false — the `SiteAuditBacklog` "configurable threshold badge" it told us to mirror was described twice in `Component-HealthMonitoring.md` but NEVER BUILT; both stale doc claims corrected, and the new badge was modeled on the real Metrics-stale badge instead. | Found by R7 while documenting the purge burst | Closed 2026-08-15. | — |
|
||||
| 34 | ~~**`site_events` purge burst is unbounded in the oplog and unmeasured at scale**~~ **RESOLVED 2026-08-16 (`d9a35846`) — measured, and paced.** Measured with the REAL `EventLogPurgeService` over a real `RegisterReplicated` LocalDb file + a loopback sync pair at the rig's `MaxBatchSize: 64`: 393 B/row incl. indexes ⇒ a 1 GB event log holds **~2.73 M rows**, and expiring them in one tick took 285 s and queued **2 731 947 oplog rows = 2.7x LocalDb's `MaxOplogRows` default (1 M)** — enough to make the maintenance tick prune the oplog and flag a FULL ten-table snapshot resync, bought by a routine retention purge. The decisive nuance: the *resident* backlog is produced−minus−drained, so a healthy peer blunts it (391 k peak on loopback, drained 6.0 min) — the old safety margin was a function of peer throughput and vanished with the peer down. Fix: `MaxPurgedRowsPerTick` (default **250 000**, `0` = unlimited escape hatch), one budget SHARED across retention + cap trim; a ceiling-limited tick reschedules after `PurgeCatchUpInterval` (**5 min**, validated `> 0` and `<= PurgeInterval`) so worst-case retention converges in ~55 min instead of 11 days; purge loop is now a delay loop anchored to the END of the previous purge; rate-limited Warning + counter `scadabridge.site.event_log.purge_ceiling_hits`. No purge-exemption path added. Row-33 interplay verified empirically (500 k drained in 46 s vs the 15-min dwell). Fail-before: `PurgeBudget` neutralised → 6 of the new tests fail, all 19 pre-existing pass. Measurement + decision record: `docs/plans/2026-08-15-site-events-purge-burst-measurement.md`. Side-finding recorded as row 81 (storage cap unenforceable on a replicated node). | Found by R7 | Closed 2026-08-16. | — |
|
||||
| 35 | ~~**`DeploymentRecords` index does not cover the deployment-list sort key**~~ **RESOLVED 2026-08-15 (`be29bf47`).** Migration `20260815165452_AddDeploymentRecordListSortIndex` + hand-written `docs/plans/sql/AddDeploymentRecordListSortIndex.sql`: composite `IX_DeploymentRecords_DeployedAt_Id (DeployedAt DESC, Id DESC)`; the old single-column index DROPPED as strictly redundant (`Id` is the clustered PK and was already in the old index's leaf as the row locator — same leaf width, now a declared ordered key; keeping it would cost a second index on every insert/status transition for no read benefit). Serves the three reads sharing the key (`QueryDeploymentListPageAsync`, `QueryDeploymentSummariesAsync`, `GetCurrentDeploymentStatusAsync`). `Id` in the key is also a CORRECTNESS matter: a bulk site deploy stamps many rows in one `DeployedAt` instant, so without the tie-break keyset pages can repeat or drop rows. Recorded caveat: the optimizer may already have served the ordering via a backward scan of the old index, so the "sorts per page" premise was possibly optimizer-dependent — the composite makes it deterministic and self-documenting; an actual plan capture against the rig would quantify the before/after. The ConfigurationDatabase suite exercises the descending composite as real DDL via SQLite `EnsureCreated()`, proving provider portability. | Found by R3 (`35ce1413`) | Closed 2026-08-15. | — |
|
||||
| 36 | **Deployments free-text search is unindexable by construction** | Found by R3; re-verified 2026-08-17 (Low-tail sweep) | Four `LIKE '%term%'` predicates (instance name, deployment id, revision hash, user) — leading wildcards preclude index seeks, so a search is a scan of the join (`DeploymentManagerRepository.cs:100-104`; only searched column with any index is `DeploymentId`, unseekable under a leading wildcard). Acceptable at the purge-bounded row count. **2026-08-17 refresh: the row undercounted the cost by half** — `BuildListQuery` is shared with `GetDeploymentStatusCountsAsync`, so one search = TWO scans of the join (list + status tiles), mitigated by the UI's trailing-edge debounce (one pair per pause, not per keystroke). Anchoring the id/hash legs was evaluated and REJECTED as a micro-fix: `DeploymentListFilter.Search`'s doc comment promises substring matching and a 4-case `[Theory]` deliberately pins mid-string matches — changing it is a contract renegotiation, not an optimization. Fix if hot: renegotiate to prefix on id/hash legs, or full-text. *(Low)* | Search latency complaints on the Deployments page |
|
||||
| 37 | **Two paged reads of `DeploymentRecords` with different contracts** | Found by R3; re-verified 2026-08-17 (Low-tail sweep: both shapes intact, `DeploymentManagerRepository.cs:110-149` and `:399-449`) | `QueryDeploymentSummariesAsync` (CLI/ManagementActor: offset, no total, no instance join, instance-id-list scoping) and R3's `QueryDeploymentListPageAsync` (UI: total + instance join + site-id scoping) duplicate query logic. Consolidating on one contract — giving the CLI a total, both scoping by site — removes the duplication. **2026-08-17: the duplication has now DRIFTED into real behavioral divergences, recorded as rows 86–88 (site-scope authorization divergence, CLI unbounded read + non-transactional scope resolution, silent `--status` parse fallback) — a consolidation should resolve those rows with it.** *(Low structurally; the drift itself is row 86, Medium)* | Rows 86–88, or next time either query's shape changes |
|
||||
| 38 | **OtOpcUa: `RoslynVirtualTagEvaluatorTests` racing-clear test is load-fragile** (OtOpcUa repo, not this one) | Found by R4 during the full-suite bump verification | `Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed` fails under full-solution parallel test load, passes 3/3 isolated: its design assumes at most one `ClearCompiledScripts` lands inside one evaluation (so the product's one-shot retry suffices); under contention two can. Same timing-assumption family as the R5 sweep, but lives in OtOpcUa. Test-side fix: bound clear cadence relative to observed evaluation latency, or assert on retry-exhaustion instead of zero failures. | Next OtOpcUa test session, or when it fails a run that matters |
|
||||
| 39 | **OtOpcUa: late-enabled replication cannot baseline pre-existing rows** (OtOpcUa repo, not this one) | Found by R4; documented in OtOpcUa's CLAUDE.md | `LocalDbSetup.OnReady` registers all four tables unconditionally on every driver node, so it can never pass `baselineExistingRows: true` (only correct when gated on replication being configured). Turning replication ON for a long-running default-OFF node converges on subsequent writes only — existing rows are never baselined. ScadaBridge closed this class with conditional registration + boot-time `DeregisterReplicated` self-heal (WP1.3/WP3.3); porting it is a behavior change beyond a pin bump (registered-set pinned by tests, deregistration must be pair-symmetric). Low current impact: site-a was enabled from fresh. | Before enabling replication on an established OtOpcUa node |
|
||||
| 40 | **Wall-clock sleeps used as synchronization in tests (absence-assertion class)** | Found by the R5 sweep (`9fb52153`) — a different class from the one swept, deliberately left unfixed | Tests that sleep, then assert an *absence*: `ScriptDeadlineAtEnqueueTests.cs:105,128` (two `Thread.Sleep(2s)`, with `Assert.Equal(0, scheduler.DetachedThreadCount)` depending on the sleep out-racing the watchdog — worst offender), `DeploymentWarmThenGateTests.cs:117`, `SiteAlarmLiveCacheServiceTests.cs:195,216`, `DataConnectionActorTests.cs:435` (`Task.Delay(200)` to "settle" after an `AwaitCondition`). These fail toward false-pass rather than flake, so they don't trip CI — but each is a timing pin that weakens what the test proves. Fix pattern: replace the sleep with an explicit observable edge (or assert on the positive event with a bounded wait plus a negative control). | Next test-infra session, or the first time one of these areas regresses without its test catching it |
|
||||
| 40 | ~~**Wall-clock sleeps used as synchronization in tests (absence-assertion class)**~~ **RESOLVED 2026-08-15 (`7a7d345b`).** All six listed sites replaced with observable edges + positive controls, strengthening proven by stash-based negative controls: `ScriptDeadlineAtEnqueueTests:128` now pins the watchdog via a genuinely-wedged positive control (armed strictly later, self-releasing) + per-script absence assertion + detach counter pinned at 1 — **disabling the entire stuck-script watchdog passed the OLD test 2/2 and fails the new one**; `:105` now proves the run was still queued when its budget expired via `QueueDepth` edges (the actual design claim — the old sleep asserted nothing about it); `DeploymentWarmThenGateTests:117` (+2 sibling `Task.Delay(500)`s) — the empty-site zero is now the manager's own observation (phantom-startup control: old passed 2/2, new fails); `SiteAlarmLiveCacheServiceTests:195,216` poll a new subscription-cancellation observable across 3 linger windows, each closing with a positive control (control: linger cancellation disabled + viewer guard dropped — `Resubscribe_Within_Linger` old passed 2/2, new fails; honest caveat: the other case's old later assertion did already catch that break, so its gain is the observable + control, not a false-pass fix); `DataConnectionActorTests:435` waits for the Connected health publication so the subscribe provably runs against Connected (the sleep guarded no assertion — determinism gain only). Bonus: the de-padded schedule exposed a LATENT race in `InstanceActorChildAttributeRaceTests` (~1-in-6 under load, A/B'd 14/14 green on the unmodified tree so pre-existing) — fixed with a bounded-deadline resolution retry; pattern recorded as row 77. | Found by the R5 sweep (`9fb52153`) | Closed 2026-08-15. | — |
|
||||
| 41 | **LocalDb (scadaproj) sync-session tests flake under parallel load** (scadaproj repo, not this one) | Found by R1; confirmed pre-existing at base `f627cee` (1 failure in 3 full runs) | `SyncSessionTests.Handshake_DigestMismatch_FailsClosed` and `BatchBudgetTests.Pump_RowLargerThanTheBudget_IsSentAloneRatherThanStallingTheStream` intermittently time out. Root cause looks structural, not a timing pin: on a handshake fault, `RunAsync`'s catch cancels the writer loop, which can kill the session's OWN Handshake before it transmits — the healthy peer then waits out its full 15 s cap for a message that never comes. Product-adjacent (the same early-cancel could delay a real peer's fail-closed verdict), so worth a look beyond the tests. | Next LocalDb session |
|
||||
| 42 | **`build/push.sh`-class publish bug across the family repos** | Found by R1 the hard way (incident: 25 unrelated packages briefly published to the feed, deleted + verified same session; script fixed in scadaproj `0038d46`) | The bug shape: `dotnet pack -c Release -o ./artifacts` with no project argument and no `cd`, so the script packs the CALLER'S CWD. Fixed in LocalDb's script (cd to own repo root, name the solution, absolute output path). Peer repos' publish scripts (MxAccessGateway, HistorianGateway, OtOpcUa, others in scadaproj) should be checked for the same shape — the recovery only worked because the token happened to carry delete rights. | Before anyone runs a family publish script from outside its repo, or next umbrella-repo session |
|
||||
| 42 | **`build/push.sh`-class publish bug across the family repos** | Found by R1 the hard way (incident: 25 unrelated packages briefly published to the feed, deleted + verified same session; script fixed in scadaproj `0038d46`) | The bug shape: `dotnet pack -c Release -o ./artifacts` with no project argument and no `cd`, so the script packs the CALLER'S CWD. Fixed in LocalDb's script (cd to own repo root, name the solution, absolute output path). **AUDITED 2026-08-15 (read-only, this wave): SEVEN scadaproj scripts still carry the exact shape** — `ZB.MOM.WW.Secrets/build/push.sh`, `ZB.MOM.WW.Theme/build/{pack,push}.sh`, `ZB.MOM.WW.Audit/build/{pack,push}.sh`, `ZB.MOM.WW.Auth/build/{pack,push}.sh` (all `dotnet pack -c Release -o ./artifacts` with no project argument and no `cd`, plus a CWD-relative `nuget push "./artifacts/*.nupkg"` that would publish whatever sits in the caller's artifacts dir). MxAccessGateway's `scripts/pack-clients.ps1` is SAFE (`$PSScriptRoot`-anchored, explicit csproj paths); HistorianGateway and OtOpcUa have no pack scripts. Fix = copy LocalDb's `0038d46` pattern into the seven scripts; lives in the scadaproj repo, outside this wave's edit scope. | Before anyone runs a family publish script from outside its repo, or next umbrella-repo session — **now with the exact seven-script fix list ready** |
|
||||
| 43 | ~~**Central UI import wizard leaks a `BundleSession` slot on every abandoned import — imports lock out for 30 min**~~ **RESOLVED 2026-08-15 (`70838982`).** New `IBundleImporter.Abandon(sessionId)` does the same zero-then-remove release `ApplyAsync` already did (T-007 discipline), idempotent (double/unknown/expired ids are no-ops) and only ever given the caller's own random-GUID id, so it cannot evict a concurrent circuit's session. Wired into EVERY abandonment path: wizard new-upload, `TryLoadAsync` pre-overwrite, Back-to-Upload/`ResetSessionState`, 3-strike lockout reset, `Dispose` — plus two CLI leaks the original row didn't know about: `transport preview` released nothing at all (every preview burned a slot for 30 min) and `transport import` stranded the session on pre-apply aborts; both now release in a `finally`. `BundleSessionEvictionService` is documented as the TTL backstop, not an abandonment path. Fail-before-fix stash-verified (3 of 4 new bUnit tests fail pre-fix). 10 new tests. | Found live by R6 | Closed 2026-08-15. | — |
|
||||
| 44 | ~~**`BundleUnlockRateLimiter` is dead code — the documented per-IP unlock throttle is not enforced**~~ **RESOLVED 2026-08-15 (`5ab26a42`).** `BundleImporter.LoadAsync` now registers every unlock attempt against `MaxUnlockAttemptsPerIpPerHour` (10). Ordering is load-bearing: AFTER the per-bundle 3-strike check (hammering a locked bundle spends no hourly budget) and BEFORE the decrypt (a throttle denial burns no per-bundle strike, so the throttle can't be abused to lock out a legitimate import); successful unlocks count too. Denial = `BundleUnlockThrottledException` (placed in Commons so the management surface returns a curated message). Client key: Central UI via a scoped `CircuitClientAddressProvider` snapshotting the IP at circuit establishment; CLI via additive `ManagementEnvelope.ClientIp`. No attributable IP falls into one shared `<unattributed>` bucket — stricter, never a bypass; per-client isolation is only real behind a ForwardedHeaders-trusted proxy (same caveat as the login throttle, documented). Fail-before-fix verified (4 of 6 new tests fail with the limiter neutralised; 2 negative controls). | Found by R6 | Closed 2026-08-15. | — |
|
||||
| 45 | ~~**Notification Outbox dispatcher shares one `DbContext` across up to 4 parallel deliveries**~~ **RESOLVED 2026-08-15 (`9e769735`).** `_adaptersCache`/`_adaptersScope`/`ResolveAdapters()` deleted; the typed adapter is resolved inside the per-delivery DI scope `DeliverGatedAsync` already opens (static `ResolveAdapter` preserving DI last-wins + the null-adapter park path), so each concurrent delivery gets its own adapter and `DbContext`. Nothing shared was lost: the Email adapter builds a fresh SMTP client per attempt from an injected factory, the SMS adapter draws from the singleton `IHttpClientFactory`, and CentralUI's adapter-gated Type selector (`NotificationChannelCatalog`) is an independent scoped consumer, untouched. Fail-before-fix stash-verified (simulated DbContext throwing EF's exact second-operation exception under a rendezvous-guaranteed overlap: 1 of 2 rows persisted pre-fix; 1-vs-4 distinct adapter instances). Root cause of note: the bug was INTRODUCED by code-review finding NotificationOutbox-006's caching "fix" (2026-05-28), whose own regression test pinned the broken behavior and predates bounded parallel delivery — that finding is marked SUPERSEDED. NotificationOutbox 153, AuditLog 383 green. | Found by R6; observed live on rig central-b | Closed 2026-08-15. | — |
|
||||
| 46 | **CLI `audit-config query` returns `{}` for every filter combination** *(Low)* | Found by R6 against the rig; underlying `AuditLogEntries` rows verified present | Possibly a CLI output-shape/serialization issue; not investigated (out of R6 scope). | Next CLI session |
|
||||
| 46 | ~~**CLI `audit-config query` returns `{}` for every filter combination**~~ **RESOLVED 2026-08-15 (`09108018`).** Not a CLI bug: `ManagementActor.HandleQueryAuditLog` returned the repository's ValueTuple `(Entries, TotalCount)` straight to `SerializeResult`; System.Text.Json writes properties only and a ValueTuple's `Item1`/`Item2` are **fields**, so the whole payload serialized to the literal `{}` — every filter combination looked empty with no error anywhere in the stack. Fixed with a named Commons record `AuditLogQueryResult` (mirroring `SecuredWriteListResult`) plus a class-wide guard: `SerializeResult` now throws on any `ITuple` result, so the next tuple-returning handler fails loudly instead of silently emitting `{}`. Fail-before verified (test asserted exactly `"{}"` pre-fix); live re-verified on the rig (`totalCount 2725` unfiltered, `712` for `--entity-type Template --action Delete`). The only other tuple-returning repo method in Commons (`INotificationOutboxRepository.QueryAsync`) is never serialized. | Found by R6 against the rig; underlying `AuditLogEntries` rows verified present | Closed 2026-08-15. | — |
|
||||
| 47 | ~~**`SiteStreamGrpcServer` duplicate-stream replacement disposed another handler's `CancellationTokenSource`**~~ **RESOLVED 2026-08-15 (found and fixed in-flight by R2, `e6842c10`).** `SubscribeInstance`'s duplicate-prevention path called `Cancel()` AND `Dispose()` on the replaced stream's CTS, which belongs to that still-running handler's `using var` and is still read — escaping as an unhandled `ObjectDisposedException` under scheduling pressure (confirmed pre-existing on `2b74851f`; load-only, passed 10/10 isolated). Fixed to cancel-only (the owner's `using` disposes exactly once), `Cancel` wrapped for the converse race, mirroring `CancelAllStreams()`. Deterministic regression test via a gate inside the setup window, fail-before/pass-after verified by reinstating the `Dispose()` as a negative control. Recorded here (though closed) because it is a product-bug instance of the disposal-race class, useful precedent for future stream-lifecycle work. | Closed 2026-08-15. |
|
||||
| 48 | **Docker rig has no running site instances — live stream probes have no traffic** | Found during the R2 close-out live probe (2026-08-15) | All 7 seeded instances are `soak-*` rows whose deploy fails pre-deployment validation (34 errors: ScriptCompilation ×4, ConnectionBinding ×30 — their bound data connections no longer exist on the sites), and `debug snapshot` returns `instanceNotFound`; `scadabridge_site_connection_up` is 0 on all six site nodes. Consequence: the R2 batching histogram (`scadabridge.site.stream.batch_size`) could not be observed live — batching verification rests on its integration/wire-skew/latency tests, which are thorough. Fix: re-seed a small working instance + data connection set (OPC UA sim is still up) so future live probes have traffic. | Next rig session, or before any live probe that needs event traffic |
|
||||
| 49 | **Sweep code-review "Resolved" entries that cached or lifetime-widened a DI-scoped service** *(Low, process)* | Found while fixing row 45: NotificationOutbox-006, a Low perf nit, introduced the row-45 Medium correctness bug, and its regression test actively pinned the broken behavior — written before `MaxParallelDeliveries` existed and never revisited when parallel delivery landed | The failure shape generalizes: a review resolution that widens a scoped service's lifetime is correct at the time and silently invalidated by a later concurrency feature. Sweep `code-reviews/*/findings.md` resolved entries for caching/lifetime-widening of scoped services and re-check each against features added after its date. | Next code-review or test-infra session |
|
||||
| 50 | **No guard against capturing a scoped service on a singleton actor (captive dependency)** *(Low)* | Found while fixing row 45 | The actors take root `IServiceProvider` rather than typed services, which defeats .NET's built-in scope validation — the row-45 class of bug is invisible at build time. Cheap mitigations: `ValidateScopes`/`ValidateOnBuild` on the central host's provider in Development, and/or a test asserting no `ReceiveActor` field holds an `IServiceScope`. | Next DI/host-hardening session |
|
||||
| 51 | **`BundleLockedException` reaches the CLI as "An internal error occurred"** *(Low, pre-existing)* | Found while fixing row 44 | `ManagementService` doesn't reference `Transport`, so `MapFault` can't curate the 3-strike lockout — a CLI operator gets a correlation id, not the reason. Row 44 solved this shape for the throttle exception by placing it in Commons; `BundleLockedException` needs the same move (public type relocation, low risk, deliberately kept out of the row-44 diff). | Next Transport/CLI session |
|
||||
| 52 | **The unlock throttle's counters are per-node and per-process** *(Low, by design but recorded)* | Row 44 implementation note; documented in `docs/components/Transport.md` | Each central node has its own limiter, so the effective fleet cap is 10/hour/IP per node, and a restart clears the window. Acceptable for the threat model today; a shared store is the hardening if it ever matters. | A hardening pass that wants a fleet-wide cap |
|
||||
| 53 | **`MaxConcurrentImportSessions` has no observability** *(Low)* | Found while fixing row 43 — R6 could only infer the leak from rotating test failures | Nothing counts open sessions or logs when `Open` rejects at the cap. A gauge (mind the `ObservedMeters` allowlist) or a Warning log on rejection would make a recurrence diagnosable in seconds. | Next Transport or observability session |
|
||||
| 48 | ~~**Docker rig has no running site instances — live stream probes have no traffic**~~ **RESOLVED 2026-08-15 (`b5e6821c`).** Working set re-seeded on site-a entirely via the CLI (`soak-*` rows left alone): template `RigProbe` + instance `rig-probe-1`, bound to the OPC PLC sim's built-in moving tags (`SpikeData`, `RandomSignedInt32`) plus a 5s heartbeat script. Deploy Success; `debug snapshot` returns live Good-quality data; `health site` shows `OPC PLC Simulator: Connected`, `good:2 bad:0 uncertain:0`; a 60s `debug stream` produced 239 events; and **`scadabridge.site.stream.batch_size` recorded live for the first time** (count 70 / sum 238, batches to ~10 events) — the R2 batching histogram is now observable on the rig, which was this row's purpose. Reproducible via `docker/seed-live-probe.sh`. Two diagnostic corrections worth keeping: the custom tags in `infra/opcua/nodes.json` are declared but **not simulated** by opc-plc (a healthy-looking connection with zero traffic — bind to the built-ins: `SpikeData`, `RandomSignedInt32`, `StepUp`, `FastUInt1..5`, `SlowUInt1..5`); and `scadabridge_site_connection_up` counts open central→site gRPC subscriber streams, not data connections, so this row's original "0 on all six site nodes" was healthy steady state, not the fault (row 54). Close-out postscript: the seeding agent's live verification ran against worktree-homed data dirs (row 68), so after the orchestrator's redeploy from main the config was absent at boot and re-fetched by the startup reconcile — which exposed row 67 — and `rig-probe-1` was re-verified live on the main checkout's data dirs (snapshot Good ×3, heartbeat ticking, sim values moving). | Found during the R2 close-out live probe (2026-08-15) | Closed 2026-08-15. | — |
|
||||
| 49 | ~~**Sweep code-review "Resolved" entries that cached or lifetime-widened a DI-scoped service**~~ **RESOLVED 2026-08-15 — swept, no further bug found (`75520bee`).** All 576 resolved findings across the 25 component findings files were re-read for the row-45 generalization (a resolution that caches, hoists, or widens the lifetime of a DI-resolved service, later invalidated by a concurrency/lifetime feature). Nine matched or nearly matched the shape; **NotificationOutbox-006 is the only one that ever became a bug** and was already fixed as row 45. The other eight verify STILL-SAFE or SUPERSEDED-BENIGN — the two with real concurrency nearby each anticipated it explicitly (`DeploymentService`'s `dbGate` serialises the one repository touch inside the bounded-parallel Send phase; `ArtifactDeploymentService` builds all per-site commands serially before fanning out, commenting "DbContext is not thread-safe"). An independent product-code cross-check confirms the pattern is absent: no `IServiceScope` is stored on a field anywhere in `src/` (every long-lived holder takes `IServiceScopeFactory` and opens a scope per operation), every `Task.WhenAll`/`Parallel.*` site either touches no scoped service or opens a per-operation scope, and no scoped-registered service is captured by a singleton. One stale archive corrected: NotificationOutbox-004 was marked Resolved with an `_Unresolved._` body; the real resolution is now recorded. Near-miss shapes recorded as rows 58–60. | Found while fixing row 45 | Closed 2026-08-15. | — |
|
||||
| 50 | ~~**No guard against capturing a scoped service on a singleton actor (captive dependency)**~~ **RESOLVED 2026-08-15 (`00a804e6`).** Both mitigations shipped, and they are complementary. (1) `ValidateScopes`/`ValidateOnBuild` in Development on **both** role branches via `Host/ServiceProviderValidation.cs` — written out rather than left to `CreateBuilder`'s silent default; production stays unvalidated deliberately (fail-closed at boot). Switching it on surfaced **two real Site-role defects**: `ReconcileService` and `AuditLogKpiSampleSource` were registered on sites but need central-only repositories, so neither could ever be constructed there — one unconstructible descriptor fails `ValidateOnBuild` for the entire site graph, which is exactly why `SiteHealthEndpointTests` had quietly pinned `UseEnvironment("Production")` to dodge validation. Both moved to central-only registrations (`AddCentralCommunication()` / `AddAuditLogCentralKpi()`); the workaround is gone and the site graph now boots validated. (2) `CaptiveDependencyGuard` reflects over all 28 shipped actor classes and fails on any instance field (backing fields included) typed `IServiceScope`, `DbContext`, or Scoped-in-the-real-containers — the scoped set is derived from the built Central and Site graphs, so a lifetime change auto-re-scopes the check; `IServiceProvider`/`IServiceScopeFactory` fields are allowed (the correct pattern). Proven non-vacuous via permanent positive-control dummy actors. No live violation; the two test-only `_injectedRepository` ctor fields allowlisted with written safety arguments plus a staleness self-test (row 61). Doc: REQ-HOST-11 in `Component-Host.md`. | Found while fixing row 45 | Closed 2026-08-15. | — |
|
||||
| 51 | ~~**`BundleLockedException` reaches the CLI as "An internal error occurred"**~~ **RESOLVED 2026-08-15 (`b449fe20`).** Relocated to `Commons/Types/Transport/` beside `BundleUnlockThrottledException` (row 44's pattern); both consumers already carried the namespace, so no call-site edits. `ManagementActor.HandlePreviewBundle`/`HandleImportBundle` now rethrow it as `ManagementCommandException`, so CLI and UI print the lockout reason instead of a correlation id. Fault-mapping tests mirrored from row 44 on both handlers. Follow-up in the same diff (`73f518d6`): deleted the dead `BundleUnlockRateLimitedException` (superseded by row 44, zero references — three similarly-named unlock exceptions with one inert was a live trap). | Found while fixing row 44 | Closed 2026-08-15. | — |
|
||||
| 52 | ~~**The unlock throttle's counters are per-node and per-process**~~ **RESOLVED 2026-08-15 (`677263af`).** The trailing-hour window now lives in the central MS SQL `BundleUnlockAttempt` table — the HA store both central nodes already share, so no new infra, port or consensus. `MaxUnlockAttemptsPerIpPerHour` is a true fleet cap and survives a node restart; config keys, semantics and the rig's 500 override are unchanged, as are all row-44 behaviours (lockout checked first so locked bundles spend no budget; denials burn no strike and are not recorded; `<unattributed>` bucket; attempts-not-failures). Three recorded trades: **fails open** (store error → Warning naming client + cap, attempt permitted — availability of legitimate imports outranks a best-effort cap over an already-authenticated Administrator-gated surface; a process-local fallback was considered and rejected as it would still block an operator's legitimate 11th import); **approximate** (count-then-insert is not atomic across nodes; overshoot bounded by in-flight count, self-corrects within the window — row 64); **opportunistic prune on the unlock path** (CAS-claimed ≤ every 15 min per process, ≤1000 rows, retains 2× the window so a prune can't race a count — row 65) rather than a new background service. EF migration `20260815154803_AddBundleUnlockAttemptTable` + hand-written production script `docs/plans/sql/AddBundleUnlockAttemptTable.sql`. Integration test proves two limiter instances over one shared store enforce one combined window, restart durability, and store-failure fail-open. | Row 44 implementation note; documented in `docs/components/Transport.md` | Closed 2026-08-15. | — |
|
||||
| 53 | ~~**`MaxConcurrentImportSessions` has no observability**~~ **RESOLVED 2026-08-15 (`372ea4df`).** Gauge `scadabridge.central.transport.import_sessions.open` on the `ZB.MOM.WW.ScadaBridge` meter — already in `SiteServiceRegistration.ObservedMeters` (the single shared allowlist source for both roles; no appsettings pins one), so no allowlist change was needed and the series is not silently dropped. Provider-backed like the S&F queue-depth gauge: registered by `BundleSessionEvictionService.StartAsync`, identity-checked-cleared on `StopAsync`. `IBundleSessionStore.OpenSessionCount` counts only **unexpired** sessions, so the gauge doesn't read "pinned at the cap" for a sweep interval after a burst drains. `BundleSessionStore.Open` logs Warning with open count, cap and TTL on a genuine cap hit only (a store full of expired sessions is reclaimed silently). No alertable rejection counter yet — row 66. | Found while fixing row 43 — R6 could only infer the leak from rotating test failures | Closed 2026-08-15. | — |
|
||||
| 54 | ~~**`scadabridge_site_connection_up` is misleadingly named** *(Low, observability)*~~ **RESOLVED 2026-08-15 (`b5f120c3`).** Renamed to `scadabridge.site.stream.subscribers.active` (a test pins the new name AND asserts the old one is gone; doc/script references updated) and BOTH real data-connection gauges added: `scadabridge.site.data_connections.connected` / `.total` — the denominator ships with the numerator because neither alone separates "device down" from "nothing configured"; `connected < total` is the alert condition. Sourced from the DCL's four state transitions, deliberately NOT from the health report, so the metric survives a dead report pipeline (and stays clear of the concurrent row-55 fix in `SiteHealthCollector`). All on the already-allowlisted `ZB.MOM.WW.ScadaBridge` meter. Expected rig shape: connected==total on the active site node, 0/0 on the standby (healthy — connections open on the active node only). | Row-48 close-out (2026-08-15) — the row's original mis-diagnosis traced directly to this | Closed 2026-08-15. | — |
|
||||
| 55 | ~~**`tagResolutionCounts` reads `{totalSubscribed:0, successfullyResolved:0}` for a connection with 2 live Good-quality subscribed tags** *(Low)*~~ **RESOLVED 2026-08-15 (`5da27e05`).** All three suspected reporting-path hypotheses were WRONG — live debugging showed the zeros and the `good:2` on the SAME report from the SAME collector on the active node, ruling out standby shadowing, keying races, and the central merge in one stroke. The real bug is a DCL omission, not a regression: `DataConnectionActor.HandleSubscribeCompleted` — the first-time, everything-resolves-cleanly subscribe path — was the missing FOURTH `UpdateTagResolution` push site. `BecomeConnected`'s legitimate 0/0 push fires before the stashed subscribe unstashes (proven in live logs: "Entering Connected state" follows the subscribe line), `ScheduleTagResolutionRetry` cancels outright at zero unresolved, and any reconnect masks the bug via the batch path — so the diagnostic was blindest on a perfectly HEALTHY connection, exactly when it should read clean rather than empty. One mirrored push + regression test (stash-verified fail-before: exactly one `0/0` call received pre-fix while the actor's own `GetHealthReport` already said 2/2, pinning the actor-correct/collector-stale asymmetry). Also corrected `Component-DataConnectionLayer.md`, which claimed the counts were "derived at report time" — they are pushed, and a cached push is only as fresh as its last push site. | Row-48 live probe; root-caused live 2026-08-15 | Closed 2026-08-15. | — |
|
||||
| 56 | ~~**The rig's central nodes expose no Prometheus endpoint** *(Low, rig config)*~~ **RESOLVED 2026-08-15 (`103f2f19`).** Premise refined on inspection: central already served `/metrics` on its web port (anonymous, no fallback policy) — what was missing was the dedicated 8084 listener the documented docker-exec scrape recipe targets. Both rigs' central appsettings now set `MetricsPort`, and the Host binds it as a PRESENCE check, not a defaulted read — `NodeOptions` defaults to 8084, so a defaulted read would make every existing production central surprise-bind a new port on upgrade, and port-in-use is a hard boot failure. | Row-48 live probe | Closed 2026-08-15. | — |
|
||||
| 57 | ~~**`audit-config query` has no CLI table projection** *(Low, cosmetic)*~~ **RESOLVED 2026-08-15 (`b2cab59c`).** `AuditConfigTableProjection` mirrors `TemplateTableProjection`: unwraps the `{entries, totalCount}` envelope to rows (`id, timestamp, user, action, entityType, entityName, entityId`), drops the fat `afterStateJson`, adds `--detail` to skip projection (matching `template list`), and surfaces `totalCount` as a trailing note row (`showing N of M — use --page/--page-size`) emitted ONLY when the page is a strict subset, with no `id` cell so it can't be mistaken for an entry. The deprecated `audit-log` alias shares the command tree and gets the projector too. Tests serialize the REAL `AuditLogQueryResult`/`AuditLogEntry` types, so a server-side property rename fails the test instead of silently blanking a column. Stale-README follow-up recorded as row 76. | Row-46 fix (2026-08-15) | Closed 2026-08-15. | — |
|
||||
| 58 | ~~**`IDeploymentStatusNotifier` singleton retains delegates targeting circuit-scoped Blazor components** *(Low)*~~ **RESOLVED 2026-08-17 (`2807b5a3`).** Still exactly one subscriber (`Deployments.razor`), but the cited pin was BLIND: `Deployments_Dispose_UnsubscribesFromNotifier` was satisfied by the `_disposed` guard alone — mutation-verified (with the `-=` deleted, the old test stayed green while the leak was wide open). Closed by two real pins: `DeploymentsPushUpdateTests.Deployments_Dispose_ActuallyDetachesTheDelegate_NotJustGuardsIt` (structural — the invocation list must shrink to 0) and `EveryCentralUiSubscriberToTheNotifier_UnsubscribesAndIsDisposable` (a source scan pinning this row's own trigger: any new subscriber without a matching Dispose unsubscribe fails the suite). Both fail on the mutation. | Row-49 sweep (CentralUI-006 re-verification) | Closed 2026-08-17. | — |
|
||||
| 59 | **`SiteCallAuditActor` per-tick scope spans all sites × ≤50 upsert pages** *(Low)* | Row-49 sweep (SiteCallAudit-009 re-verification); re-verified 2026-08-17 (Low-tail sweep — loop still serial, line numbers drifted to `SiteCallAuditActor.cs:528-545`) | Safe only because site reconciliation is a serial `foreach … await`; one scope across a parallel fan-out would be row 45 again. 2026-08-17: the missing in-code constraint remark was added at the loop (`2807b5a3` — "serial by design … do not fan out"); a source-scan pin was judged too brittle, so the comment + this row remain the tripwire. | `ReconcileSiteAsync` is ever fanned out with `Task.WhenAll` |
|
||||
| 60 | ~~**`ArtifactDeploymentService.GlobalArtifactSnapshot` holds EF-materialised entities across the parallel per-site send** *(Low)*~~ **RESOLVED 2026-08-17 (`2807b5a3`) — with a premise correction so the row is never reopened on the wrong reasoning.** The row's premise was factually wrong: `GlobalArtifactSnapshot` holds pure Commons DTOs, not EF entities (`ArtifactDeploymentService.cs:170-212`); the EF state that actually crosses the fan-out is the tracked `Site` entities the per-site lambda closes over. The safety conclusion still holds — commands are built serially before the fan-out and lazy loading is confirmed absent — and the load-bearing half of the trigger is now permanently pinned by `ConfigurationDatabase.Tests.AddConfigurationDatabase_DoesNotEnableLazyLoading`. | Row-49 sweep (DeploymentManager-023 re-verification) | Closed 2026-08-17. | — |
|
||||
| 61 | ~~**`_injectedRepository` test-mode constructors are one call site from re-introducing row 45** *(Low)*~~ **RESOLVED 2026-08-15 (`92fe5acc`).** Preferred fix done: both actors now have exactly ONE constructor (`IServiceProvider`); 12 test call sites folded onto stub providers via two `TestSupport` helpers. Also deleted as newly-dead: `OnCachedTelemetryWithoutDualWriteAsync`, `SiteCallAuditActor`'s `_backgroundTimersEnabled` (a constant once the test ctors went), and the private `SerializedRepository` — moved into the test project, since production resolves a fresh repository per scope and the concurrency hazard existed only for fixtures sharing one instance. `CaptiveDependencyGuard` allowlist is now EMPTY; the staleness self-test replaced by two stronger pins — `Allowlist_IsEmpty` plus `FormerlyAllowlistedActors_NowScanClean`, which scans both actors with the allowlist DISABLED, so emptiness is evidence the fields are gone, not that the scan stopped looking. Control: re-adding a field fails both pins and the real scan. | Row-50 guard allowlist | Closed 2026-08-15. | — |
|
||||
| 62 | ~~**Five SiteRuntime actors resolve `ISiteEventLogger` off the root provider without a scope** *(Low)*~~ **RESOLVED 2026-08-16 (`9d668861`) — pinned, and the row undercounted: there are SIX sites.** `SiteEventLoggerLifetimePinTests` (Host tests, registration-only inspection, no provider built) pins TWO facts — the interface registration is a *factory* forwarding to concrete `SiteEventLogger`, so pinning the interface lifetime alone doesn't prove callers share an instance; the concrete registration is pinned too, and flipping only the concrete to Scoped is caught ONLY by the second pin (verified both directions, registration restored). The pin's failure message names all six root-resolve sites: the five in this row plus `SiteRuntime/Scripts/ScriptRunLauncher.cs:224` — the run body WP3.1 lifted verbatim out of the deleted execution actors, which inherited the pattern unnoticed. | Row-50 guard allowlist | Closed 2026-08-16. | — |
|
||||
| 63 | ~~**Rig site containers run without `ASPNETCORE_ENVIRONMENT`, so the row-50 Development validation never fires there** *(Low, rig config)*~~ **RESOLVED 2026-08-15 (`0d2d6de6`).** Set on all six `docker/` site containers and both `docker-env2` site containers, mirroring the centrals; both compose files re-parsed to prove well-formedness; the now-stale `Program.cs` comment corrected. A captive dependency in the site graph now fails the container at boot on the rig, not just in CI. | Row-50 implementation | Closed 2026-08-15. | — |
|
||||
| 64 | **The fleet-wide unlock throttle's count-then-insert is not atomic across central nodes** *(Low, accepted by design)* | Row-52 implementation trade, documented in both Transport docs (re-verified 2026-08-17: `Component-Transport.md:341`, `docs/components/Transport.md:140`, plus the code XML doc) | Simultaneous attempts can overshoot `MaxUnlockAttemptsPerIpPerHour` by the number in flight; bounded and self-correcting within the window. A conditional single-statement insert would close it at the cost of provider-specific raw SQL. | A threat model needing a hard cap rather than a rate damper, or evidence of a real brute-force exploiting the overshoot |
|
||||
| 65 | ~~**`BundleUnlockAttempt` rows are pruned only by nodes actively serving unlocks** *(Low)*~~ **RESOLVED 2026-08-17 (`2807b5a3`) — the revisit trigger had FIRED.** Transport gained exactly the central scheduler the trigger named: `BundleSessionEvictionService` (central-only `BackgroundService`; `AddTransport` is called only from the central branch of `Program.cs`). The prune tick now rides it, so an idle cluster's rows age out without an unlock ever occurring; the opportunistic unlock-path prune remains. Operational note: the service now has two maintenance duties, deliberately in separate `try` blocks so neither can mask the other. Tests: `FleetWideUnlockThrottleTests` additions + `BundleSessionObservabilityTests` extension, both green. | Row-52 implementation trade | Closed 2026-08-17. | — |
|
||||
| 66 | ~~**No counter for import-session cap rejections** *(Low)*~~ **RESOLVED 2026-08-15 (`90ce0999`).** `scadabridge.central.transport.import_sessions.rejected` on the existing allowlisted meter, emitted ONLY at the post-eviction recheck — the cap is tested twice and a store full of expired sessions reclaims and succeeds, so counting the first test would fire routinely on any node whose sweep hadn't run; duplicate-session-id re-`Open` is also excluded. New tests use delta-over-scoped-action with unique per-test keys (the pre-existing gauge tests' absolute asserts are row 74). | Row-53 implementation note | Closed 2026-08-15. | — |
|
||||
| 67 | ~~**Startup reconcile stores a fetched instance config but does not start the InstanceActor** *(Medium-Low, availability)*~~ **RESOLVED 2026-08-15 (`482165cb`).** Root cause: InstanceActors are created from exactly two paths — the boot-time SQLite load in `DeploymentManagerActor.PreStart` and an explicit deploy/enable command — and the reconcile pass fires ~5s AFTER the boot load with a pure file write (`StoreDeployedConfigIfNewerAsync`) as its only side effect. Fix: reconcile collects the enabled instances it stored and Tells a new `EnsureInstanceStartedCommand` to the DM **singleton proxy**, so the actor is created under the singleton no matter which node's pass found the gap. Handler is advisory and idempotent: no-op if already running; re-reads the durable row (authority on config body AND `is_enabled`, so a disable landing after the fetch wins); defers to `_deployWarms`/`_terminatingActorsByName`/`_inFlightDeployments` so deploy-gate ordering and all-or-nothing-per-instance are untouched (it starts an already-persisted config, never applies one); quiet no-op on an absent row (normal on an unreplicated site whose peer healed its own file). The heal is no longer silent — a Warning site event `Instance <X> started by startup reconcile self-heal` fires. Fail-before by behavioral revert: 3 of 8 new tests fail pre-fix, including the exact live symptom (`debug snapshot` → `instanceNotFound`); the 5 negative guards (disabled / failed-fetch / unknown / idempotence) green in both states. SiteRuntime 613/613, Host 504/504. Sibling gaps found during the fix recorded as rows 72/73. | Proven live during the rows-46–53 close-out (2026-08-15) | Closed 2026-08-15. | — |
|
||||
| 69 | ~~**Both site-a nodes' LocalDb files corrupt — replication down, one node crash-looping**~~ **RESOLVED 2026-08-15 (operational recovery, no code change).** Found live during this wave: site-a-b threw `SQLite Error 11: 'database disk image is malformed'` on every sync call (replication `connected: false`, backlog 892, faulted session retried ~every 60s) and, after the redeploy, site-a-a crash-looped (SIGABRT ×12) on the same error at `SqliteLocalDb` construction — the new boot-time re-registration reads pages the old build never touched, so site-a-a's corruption (present but latent) became fatal only on upgrade. Recovery: pair stopped together, both files quarantined (`*.corrupt-2026-08-15`, kept for forensics; 394 MB / 416 MB), fresh files reseeded — site-a-b via its startup reconcile (which live-proved the row-67 fix, see row 67) and CDC snapshot resync; converged to `connected: true`, backlog 0, with the row-27 schema change proving the digest handshake green. Suspected cause is host-level, not product: see row 79. | Found live by the row-55 debug + this wave's close-out redeploy | Closed 2026-08-15. | — |
|
||||
| 70 | **`auto-down-unreachable-after` is deprecated — Akka warns "keep-majority will be used instead" on every boot** *(Medium info — verified NOT currently true, but a live removal risk)* | Row-55 live session; investigated + drill-verified at this wave's close-out | The warning is real (fires at Cluster start on every node, Akka.NET 1.5.62 — same version as the 2026-07-21 decision, so nothing changed underneath; it fires on the presence of the `auto-down-unreachable-after` key). **Drill-proven that the substitution does NOT happen when `downing-provider-class = "Akka.Cluster.AutoDowning"` is EXPLICIT (as `BuildHocon` sets):** `DRILL_MODE=active` kills central-a (oldest AND lowest address) — under keep-majority a 2-node even split keeps the lowest-address side, so surviving central-b would have downed ITSELF (total outage); instead central-b observed OldestChanged → became Oldest, took over all singletons, and the victim rejoined ready 2s after restart (2026-08-15 drill, post-merge rig). The 2026-07-21 decision therefore still holds exactly as documented. The residual risk is the deprecation itself: a future Akka.NET bump can delete `AutoDowning`, silently or loudly breaking the availability-first strategy. | **Any Akka.NET version bump** — re-run `DRILL_MODE=active bash docker/failover-drill.sh` and re-read the release notes before accepting it |
|
||||
| 71 | ~~**Standby nodes log the active-node health check at ERR every 10s** *(Low, log noise)*~~ **RESOLVED 2026-08-16 (`8b761841`).** One narrowly-keyed Serilog `Filter.ByExcluding` in `LoggerConfigurationFactory`: `Level == Error` AND the `DefaultHealthCheckService` category AND `HealthCheckName == "active-node"` — check-NAME keying is what preserves the constraint (`localdb`/`akka-cluster`/`database`/`required-singletons` keep their Error lines, and active-node has no error path of its own on the active side, so the only suppressible event is a standby reporting it's a standby). Rejected on evidence: `MinimumLevel.Override` can only raise a floor (can't rewrite Error→Information), and reporting `Degraded` would change the HTTP status `/health/active` publishes, which is load-bearing for Traefik. Bonus reach: the CENTRAL standby was logging the same Error 2x as often (Traefik probes `/health/active` every 5 s) — covered by the same filter. Fail-before: filter disabled → `StandbyActiveNodeVerdict_IsNotLogged` fails; the 9 must-still-log constraint tests pass either way, proving narrowness. Side-finding: the observed ~10 s SITE-side cadence comes from an out-of-repo prober (nothing in this repo probes a site's `/health/active`), and CLAUDE.md's "site nodes map no `/health/*`" claim was stale — corrected. | Row-55 live session (2026-08-15) | Closed 2026-08-16. | — |
|
||||
| 72 | ~~**Reconcile refreshes a STALE config but does not restart the running InstanceActor** *(Medium-Low — same silent-divergence class as row 67, arguably wider blast radius)*~~ **RESOLVED 2026-08-16 (`e606b24a`, with row 78 — one fix, the DM instance sweep).** Design decision made and recorded (`docs/plans/2026-08-15-instance-sweep-self-heal-design.md`): **converge, don't merely warn** — the sweep re-applies the stored config through **`HandleDeploy`**, the exact path a central redeploy uses, so the compile gate, warm-then-gate ordering, redeploy-supersede, static-override/native-alarm reset and all-or-nothing-per-instance are the production ones (a stop still does not cancel an in-flight run). Rationale: the stored row IS what central successfully deployed; surface-only leaves the operator to hand-issue the very redeploy the sweep would perform. Ordering hazards checked: the sweep never acts on an instance a deploy pipeline owns; a gate-rejected restart is inert, not partial; the synthetic deploy replies to `Self`, never `NoSender` (dead letters are a health metric). "Nothing surfaces it" is now false in BOTH paths — even a failed restart yields two `deployment` site events naming both revisions. Safety property beyond the brief: **one repair attempt per (instance, revision) pair**, so the self-heal can never become a restart loop for an init-crashing actor or a config the gate can never accept. Fail-before: sweep unarmed → `Sweep_RestartsInstance_ServingAStaleRevision` fails showing rev-1's value served while the store holds rev-2. | Row-67 fix session (2026-08-15) | Closed 2026-08-16. | — |
|
||||
| 73 | ~~**`SiteReconciliationActor` is silently skipped when `IDeploymentConfigFetcher` is null** *(Low)*~~ **RESOLVED 2026-08-16 (`a2f0086f`, with row 80).** New tri-state health-report field **`StartupReconcilerActive`** (`bool?`, matching the `LocalDbReplicationConnected` nullable precedent; additive proto **field 29**, vendored C# regenerated via `docker/regen-proto.sh centralcontrol` with the csproj verified restored): null = not reported (central self-report or pre-row-73 site build), false = booted with no fetcher. Latched once at boot by the Host in both branches. The skip branch was raised **Warning → Error** (no configuration legitimately omits the fetcher on a site node) plus a best-effort `deployment`/`Error` site event so evidence outlives the boot log. Central treatment: a **"No self-heal" Warning badge**, deliberately not a failure — config still arrives via replication while the node is up; what's lost is recovery from a deploy landing while it's down. Only explicit `false` is badged (badging null would light the fleet the moment central upgrades ahead of the sites); offline sites skipped, matching the backlog badge. | Row-67 fix session | Closed 2026-08-16. | — |
|
||||
| 74 | ~~**`BundleSessionObservabilityTests` gauge tests assert absolute values — latent row-32-class race** *(Low, test-only)*~~ **RESOLVED 2026-08-16 (`98c94588`).** Both gauge facts converted to delta-over-scoped-action via a `ReadGaugeDelta` helper — and two assertions got STRONGER: the post-clear check (`Assert.Equal(0, …)` — satisfied by any unregistered slot) now asserts opens on the detached store move the gauge by nothing (the actual claim), and the compare-and-clear fact gained a negative control (the superseded store's opens must NOT reach the instrument), without which it passed by coincidence whenever both stores held equal counts. Fail-before: a temporary foreign-provider probe returning 7 pre-start — old body fails `Expected: 0 / Actual: 7`, new body passes the identical scenario (probe deleted, not committed). Documented residual: a registrant clobbering the slot BETWEEN the two reads still needs row-32's mutual-exclusion pattern; cannot arise today (sole constructor of `BundleSessionEvictionService` in the assembly). | Row-66 implementation (2026-08-15) | Closed 2026-08-16. | — |
|
||||
| 75 | ~~**SecuredWrites Approve renders as a red destructive button** *(Low, UI)*~~ **RESOLVED 2026-08-16 (`40944a0d`).** The real defect was deeper than "red means delete": Approve and Reject rendered as IDENTICAL red buttons, so at the moment of a two-person-control decision colour carried no discriminating information — because `danger` was a two-state flag for a three-state concept. Fix: `DialogSeverity` (`Normal→btn-primary`, `Caution→btn-warning`, `Destructive→btn-danger`); **Approve → Caution amber** (executes a real write to live plant equipment — consequential, not destroying), **Reject keeps Destructive red** (terminal for the request; and holding it red keeps the two opposite decisions distinct — dropping it to neutral would just invert the bias). Only `Destructive` inherits row-28's "Delete" label fallback, or row 28 returns through the new door. Implemented as an ADDITIVE overload deliberately: a 5th optional parameter would silently un-match every 4-arg NSubstitute stub (the row-28 gotcha, null Task → NRE); the overload's only breakage was 6 hand-written fakes failing to COMPILE — the loud mode. `DialogState.Danger` became `Severity` (one field can't disagree with itself). Playwright `.btn-danger` selectors stay valid (`Destructive` mapping unchanged). Fail-before: call site reverted to `danger: true` → both Approve pins fail; `Caution→btn-danger` remap → amber styling pin fails. Decision recorded in `Component-CentralUI.md`. | Row-28 audit (2026-08-15) | Closed 2026-08-16. | — |
|
||||
| 76 | ~~**CLI README's `audit-config query` JSON sample omits `afterStateJson`/`bundleImportId`** *(Low, docs)*~~ **RESOLVED 2026-08-16 (`4ae57bdb`).** Sample now shows both fields with their nullability semantics, and was grown to two entries mirroring the table-output sample below it row-for-row — which is what makes the "the table drops these two fields" sentence checkable. | Row-57 fix (2026-08-15) | Closed 2026-08-16. | — |
|
||||
| 77 | ~~**Test pattern: reflecting `ActorCell.Actor` right after `ResolveOne` is unordered vs child construction** *(Low, test-infra)*~~ **RESOLVED 2026-08-16 — swept clean, no commit needed.** Full `tests/` sweep: `ActorCell` (1 hit — the already-fixed file's comment), `ActorRefWithCell` (1 — fixed file :135), `.Underlying` (1 — same line), `BindingFlags` near actor/cell (2 — same file), `.Cell` (0), `ResolveOne` (3 files: the fixed one; `SiteAlarmLiveCacheServiceTests:432` resolves-then-`Sys.Stop`s, benign; `ReconcileStartsInstanceTests:363` uses the ref only for `IActorRef` equality, benign), `UnderlyingActor` (6 files — all Akka.TestKit `TestActorRef<T>` via `ActorOfAsTestActorRef`, an ORDERED mechanism, not this pattern). The unordered idiom exists in exactly one place and already carries the bounded-deadline retry. | Row-40 session (2026-08-15) | Closed 2026-08-16. | — |
|
||||
| 78 | ~~**Configs arriving via LocalDb replication start no InstanceActor — row 67's gap through a side door** *(Medium-Low, availability; proven live 2026-08-15)*~~ **RESOLVED 2026-08-16 (`e606b24a`, with row 72 — the DM instance sweep).** A periodic sweep on the Deployment Manager **singleton** (`ScadaBridge:SiteRuntime:InstanceSweepIntervalSeconds`, default 60, validated `>= 0`, `0` disables — default is live everywhere with no rig config change, which is intended): each tick re-reads the durable rows off-thread and, on the actor thread, starts any **enabled** stored config with no running actor. Arrival-path-agnostic by design — covers replication seeding, post-boot snapshot resyncs, and any future path. A LocalDb change hook was considered and rejected on evidence: it covers only the replication path and only the replicated half of the fleet (site-b/c aren't), AND `ILocalDb` 0.3.0 exposes no change-notification API at all, so a hook means a cross-repo upstream capability to close a site-local gap. Warning site event on a real heal names the sweep as the trigger; silent when there is nothing to do. Fail-before (sweep unarmed): 5 of 10 new tests fail including the exact live symptom ("A replication-seeded config for an enabled instance left no running Instance Actor"); 5 negative guards (disabled / in-flight-deploy deferral / matching-revision no-op / silence / interval-0) green in both states. Design note: `docs/plans/2026-08-15-instance-sweep-self-heal-design.md`. **LIVE-PROVEN at close-out (2026-08-16) in the exact original failure sequence:** site-a pair stopped, node-a's LocalDb file quarantined, pair restarted together — node-a's 08:45:55 reconcile reported "0 fetched" (replication had seeded the fresh store; central rightly saw no gap — the precise side door), and at 08:46:50, one sweep interval later, BOTH enabled instances started with the Warning `Instance rig-probe-1 started by deployment-manager sweep self-heal — its config is present in the deployed-config store but no Instance Actor was running until now`; `debug snapshot` live with no extra restart. The probe also exposed the DCL sibling gap — row 85. | This wave's close-out (2026-08-15) | Closed 2026-08-16. | — |
|
||||
| 79 | **OrbStack virtiofs is a live corruption + availability hazard for the rig's bind-mounted SQLite files** *(Medium, rig infra — host-level, not product)* — **mitigation chosen 2026-08-16: accept + document (`851b528c`).** The full hazard description + the proven quarantine-and-reseed recovery runbook now live in `docs/deployment/topology-guide.md` (new section), including the `orb restart docker` VM-sleep recovery and the named-volumes/idle-pause alternatives with their trade-offs. Row stays OPEN as the hazard tracker: a recurrence should trigger actually moving to named volumes or disabling idle-pause rather than re-running the runbook a third time. Original evidence: the OrbStack VM idle-SLEPT mid-session (`vmgr … msg=sleep`) leaving docker hung ~20 min until `orb restart docker`, with virtiofs `refresh_nodeid: race with new lookup` ERRORs on wake; both site-a LocalDb files (row 69) corrupted through this bind-mount layer — macOS virtiofs does not give SQLite true fsync durability, and abrupt container kills mid-write compound it. | This wave's close-out (2026-08-15); runbook shipped 2026-08-16 | Runbook shipped; hazard accepted for now | **Next corruption occurrence ⇒ stop accepting: move the rig data dirs to named volumes or disable OrbStack idle-pause** |
|
||||
| 80 | ~~**Unreplicated sites report NON-NULL `LocalDbReplicationConnected: false` / `LocalDbOplogBacklog: 0` — contradicting the documented nullable semantics** *(Low-Medium, observability correctness)*~~ **RESOLVED 2026-08-16 (`a2f0086f`, with row 73).** Root cause was type-level: `ISiteHealthCollector.SetLocalDbReplicationStatus(bool …)` made null INEXPRESSIBLE for the connected flag, and the Host wired the bridge unconditionally — the replication engine is registered on every site node and idles answering `false`/`0` with no peer. Fix mirrors the `SiteEventLogReplicationCheck` precedent exactly: the Host evaluates `SiteLocalDbSetup.ReplicationIsConfigured` once and passes it as a third provider to `AddLocalDbReplicationHealthBridge` (the SAME captured value now also feeds `SiteEventLogReplicationCheck`, so the two surfaces can't disagree); unconfigured short-circuits to `(null, null)`; the collector widens to `bool?`. The rest of the chain needed NO change — proto already `BoolValue`/`Int64Value` wrappers, `NextBacklogAboveSince` already leaves the anchor unchanged on null, KPI source already conditional: row 33's machinery was correct and had simply never received a null (its null branch was dead code in production until now). `false` now means exactly one thing: configured, peer session down = real fault. Trap that produced the bug, now pinned in tests: configured-ness is `PeerAddress` **OR** `ApiKey` — "no PeerAddress" is NOT "unreplicated" (a passive-half node carries the key alone); the pre-existing Host fixture asserting `false`/`0` was exactly such a node and its assertion is CORRECT — kept with an explaining comment, plus a new genuinely-unreplicated fixture. Fail-before: gate behaviourally reverted → `UnreplicatedSite_HealthReport_ReportsNullForBothLocalDbFields` fails `Expected: null / Actual: False`. | Found at the previous wave's close-out verifying row 33 | Closed 2026-08-16. | — |
|
||||
| 81 | ~~**The site-event-log storage cap cannot be enforced on a replicated node with an undrained oplog** *(Medium-Low, measured 2026-08-16 during the row-34 work)*~~ **RESOLVED 2026-08-17 (`ddb3c32e`) — but NOT via the row's proposed `dbstat` fix, which is impossible on this stack.** Probed BEFORE implementing: the bundled SQLite 3.53.3 has no `ENABLE_DBSTAT_VTAB` (`pragma_module_list` returns 10 modules, none of them `dbstat`/`sqlite_dbpage`); pinned by `SqliteCapabilityTests`, written to FAIL if a future bump enables it (row 94). Switching SQLite builds rejected — the provider is shared with `ZB.MOM.WW.LocalDb` fleet-wide. The cap instead measures `site_events` + its four indexes via a sampled page-layout model: row width sampled 500 rows from each end per tick (`octet_length`), whole-page table cost (linear models under-estimate near-half-page rows by ~40%), clamped by whole-file logical size (a true upper bound). Index pages count against the cap (~34% of footprint — the 1 GB default was sized against a figure that included them). Measured model error −0.8%/+8.4%/+12.8%, biased high on purpose; cross-checks row 34's independent 393 B/row at 397.2. Guard semantics re-based: (1) unsampleable row width ⇒ refuse to trim (checked BEFORE the cap comparison); (2) the old "size did not decrease" guard now runs on the table-scoped estimate, so replication bookkeeping can no longer trip it — it now means exactly one thing: events arriving as fast as the trim removes them. Bytes-per-row held fixed per tick (re-sampling per batch would jitter the estimate through guard 2 — the same self-defeating measurement as the row itself). LocalDb bookkeeping explicitly NOT managed here (`MaxOplogRows`/tombstone retention own it); topology guide gained a volume-sizing subsection stating the honest budget. Fail-before: reverting to whole-file semantics fails 5 new tests and reproduces the exact original signature (rows destroyed, cap still unmet). SiteEventLogging 104/104, Host 520/520. No config keys added or renamed. Decision memo: `docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md`. Model limitations recorded as rows 92/93. | Row-34 measurement harness; §5 of the measurement doc (postscript appended) | Closed 2026-08-17. | — |
|
||||
| 82 | ~~**`AuditLog.Tests` flakes under parallel full-solution runs — `MsSqlMigrationFixture` contention** *(Low, test-infra)*~~ **RESOLVED 2026-08-17 (`da23f2dc`) — three mechanisms, and the row's "per-run database name" prescription was already in place (the fixture has minted `ScadaBridgeAuditMigTest_<guid>` per class since it was written).** (1) The headline fingerprint was create+migrate SATURATION: nothing bounded how many fixtures ran `CREATE DATABASE` + the ~100-migration chain at once (measured peak: 45 concurrent test DBs / 19 blocked requests / 98 sessions on one container), pushing DDL past EF's default 30 s command timeout — every observed failure was `..ctor → MigrateAsync → SqlException 258 Execution Timeout Expired`, which xunit reports against every test in the class (hence "29 failures"). Fix: a machine-wide counting semaphore on `sp_getapplock` in `master` (`ScadaBridgeTestMigrate/0..N-1`, 4 slots, `SCADABRIDGE_MSSQL_TEST_MIGRATE_SLOTS` override) held across create+migrate — the arbiter must live on the shared SERVER because the contention is cross-process; gate connection is deliberately UNPOOLED (a pooled one defers the session-lock release until reuse, stranding a slot); crashed process ⇒ SQL releases the lock; a gate deadline miss proceeds UNGATED so the throttle can never itself be a red. Timeouts raised to 300 s migrate / 120 s create-drop (bounded queue = busy but progressing); once-per-process sweep of orphaned `ScadaBridgeAuditMigTest_*` >2 h; connectivity probe retries 3× (a busy server misread as absent flips every MSSQL test to a green-looking Skip — worse than a red). (2)+(3) Two more flakes behind it: `PartitionPurgeTests` and `NotifyDispatcherAuditTrailTests` LEAKED 100 ms-tick actors (purge / outbox dispatcher) whose only stop was timeout-bounded `TestKit.Dispose` — a leaked dispatcher even delivered the NEXT test's notification through its own exhausted adapter, stranding it `Retrying`. Fixed with `Watch`/`Stop`/`ExpectTerminated` in the creating test. Evidence: 10× concurrent ConfigurationDatabase.Tests went from 10/10 processes failing (17–77 failures each) to 3 consecutive 10/10-green runs; the AuditLog 3×+1× mix from failing to 3 clean runs; standalone AuditLog 383/383, ConfigurationDatabase 395/395 (no runtime regression), SiteCallAudit 66/66 (inherits the fixture); zero orphan DBs or stuck app locks left. Host.Tests/PlaywrightTests hit the PERSISTENT DB on an already-serialized axis — untouched. Systemic residue recorded as rows 95/96. Doc: `docs/test_infra/test_infra_db.md`. | Found independently by two agents in the 2026-08-16 wave | Closed 2026-08-17. | — |
|
||||
| 83 | ~~**Post-boot start-path tests must gate on the DM's first instance-count report — `ReconcileStartsInstanceTests` still uses a bare `Task.Delay(500)`** *(Low, test-infra)*~~ **RESOLVED 2026-08-17 (`524bdbbe`).** `ReconcileStartsInstanceTests` converted to the first-`SetInstanceCounts`-report gate; the pattern was extracted into a shared `TestSupport/DeploymentManagerStartupProbe` now used by both this suite and `InstanceSweepSelfHealTests`, so the convention has one implementation to copy instead of two to keep in sync. SiteRuntime 633/633 green. | Row-78 fix session | Closed 2026-08-17. | — |
|
||||
| 84 | ~~**`LogReplicationBacklogNote` can fire twice in one purge tick** *(Low, cosmetic)*~~ **RESOLVED 2026-08-17 (`ddb3c32e`).** The note is emitted at most once per tick. Fail-before: `RunPurge_WhenBothPhasesDeleteRows_LogsTheBacklogNoteOnceForTheWholeTick` fails pre-fix with `Assert.Single(): collection contained 2 matching items`. | Row-34 session | Closed 2026-08-17. | — |
|
||||
| 85 | ~~**DCL connection actors are not spawned for connection configs that arrive via LocalDb replication — row 78's gap, one layer down** *(Medium-Low, availability; proven live 2026-08-16 by the row-78 close-out probe)*~~ **RESOLVED 2026-08-17 (`8f2c2813`) — with a premise correction AND a widened blast radius.** The row's mechanism was wrong: `data_connection_definitions` has NO production reader (its getter is test-only) — connection actors are created solely from the `Connections` map in an instance's own flattened config via `EnsureDclConnections`, so there was never a store→actor path to repair. The real defect: that ensure was the CALLER'S job and only 2 of 4 instance-start paths did it (boot batch ✅, `ApplyDeployment` ✅, `StartStoredInstance` ❌ — both the row-67 ensure-started and the row-78 sweep — and `HandleEnableResult` ❌; the two misses are exactly the two paths added later). So the bug was NOT replication-specific: an ordinary unreplicated site that boots with an instance disabled and enables it later hit the same dead end, because the boot load reads only enabled configs. Fix is structural: `EnsureDclConnections` moved INTO `CreateInstanceActor` (the documented single creation funnel), before the already-exists guard so the two working paths keep exact semantics; reuses the per-name hash guard so steady state is silent; a fifth path cannot forget. Store-level sweep pass explicitly REJECTED (memo §7.4): it wouldn't have fixed this (the table is empty on deploy-only sites) and the unguarded form ping-pongs the hash slot against the deploy path, dropping live subscriptions every tick. Fail-before by behavioral revert: 4 of 7 new tests fail (sweep-started, reconcile-ensure-started, enable-after-disabled-boot, exactly-once-across-sweeps). SiteRuntime 633/633, DCL 320/320. Sibling gaps found during the fix recorded as rows 89/90. | Proven live 2026-08-16 by the row-78 close-out probe (sweep started `rig-probe-1`/`soak-notify-1` but every tag subscribe failed for 6+ min with `Unknown connection: OPC PLC Simulator — will retry`; a second pair restart healed it) | Closed 2026-08-17. **Live-proven on the rig same day** in the exact original failure sequence (pair stopped, node-a `site-localdb.db` quarantined as `.probe85-quarantine-2026-08-17`, pair restarted): reconcile "0 fetched" at 08:29:59, then at the 08:30:54 sweep tick `Created DCL connection OPC PLC Simulator` fired ALONGSIDE the two self-heal Warnings (pre-fix they appeared alone), zero `No connection actor`/`Unknown connection` lines on either node, all three `rig-probe-1` tags Good with live values ~2 min after restart — NO second restart; steady-state silence confirmed (0 Created/Updated lines across subsequent ticks); replication reseeded (`localDbReplicationConnected: true`, backlog 0), site-b/c still correctly omit the fields. | — |
|
||||
| 86 | **CLI and UI disagree on deployment site-scoping for Administrators** *(Medium, authorization)* | Found by the 2026-08-17 Low-tail sweep while re-verifying row 37 | `ManagementActor.cs:2586` grants an **Administrator role bypass** of site scoping on `deploy status`, while the UI's `SiteScopeService` is role-independent (system-wide iff the user carries zero `SiteId` claims). An Administrator carrying site claims therefore sees the whole fleet via the CLI but only their sites on the Deployments page — same user, same data, two answers, and one of them is wrong. This is row 37's duplication having drifted into behavior; decide which semantic is intended (role bypass vs claims-only) and enforce it on BOTH paths, ideally as part of the row-37 consolidation. | Next ManagementService/CLI session, or the row-37 consolidation — whichever comes first |
|
||||
| 87 | **CLI deployment scoping is non-transactional and unbounded** *(Low)* | Found by the 2026-08-17 Low-tail sweep | The CLI path resolves site grants to an instance-id list in a separate, non-transactional read (`ManagementActor.cs:2590-2596`), reloading EVERY instance per scoped query; and `PageSize: 0` on the wire maps to `take: null` = "no limit" (`DeploymentManagerRepository.cs:434`), materializing the entire retention window in one query. The UI path is structurally incapable of both. Bounded today by the purge window and low CLI traffic. | Row-37 consolidation, or first slow/OOM `deploy status` report |
|
||||
| 88 | **Unparseable CLI `--status` silently means "no filter"** *(Low, UX)* | Found by the 2026-08-17 Low-tail sweep | `ManagementActor.ParseDeploymentStatus` returns null on an unparseable status value, so a typo (`--status Sucess`) silently returns ALL statuses instead of an error — the caller cannot distinguish "filter applied, nothing matched" from "filter ignored". Fix: fail loudly on an unrecognized value (mirror the row-46 principle: loud failure over silent empty/full results). | Next CLI touch to the deploy command group |
|
||||
| 89 | **Connection-only artifact deploy does not reach the peer node's DCL** *(Medium-Low, availability)* | Found by the 2026-08-17 row-85 fix session | `data_connection_definitions` IS one of the ten replicated tables, but only the node that received the deploy runs `HandleApplyArtifactDataConnectionsToDcl` — the peer gets the row via CDC and keeps serving the OLD endpoint until a restart or an instance redeploy touches it. Not fixable by another sweep: applying a definition change to a live connection needs an arbitration decision first (does the instance's flattened-config `Connections` hash or the standalone definition hash win when they disagree?), because the row-85 fix deliberately made the flattened config the sole creation source. | Next artifact-deploy or DCL session — decide the arbitration, then apply-on-CDC becomes safe |
|
||||
| 90 | **No removal path for a connection deleted on the peer** *(Low, mirror image of row 89)* | Found by the 2026-08-17 row-85 fix session | `RemoveConnectionCommand` is sent only from the artifact-deploy reconcile on the node that processed the deploy; a connection deleted on the peer leaves a live connection actor here until restart. Bounded: the orphan actor serves a connection no instance references, wasting a device session but corrupting nothing. Same arbitration prerequisite as row 89 — fix them together. | With row 89 |
|
||||
| 91 | **`SiteScriptCompileCacheTests.GetOrAdd_SameCodeAndGlobals_ComputesOnce` is parallel-load fragile** *(Low, test-infra; row-82 family)* | Found by the 2026-08-17 row-85 fix session: failed once in 4 full-suite runs, green in isolation AND green on base — pre-existing, probability merely raised by the suite growing | `SiteScriptCompileCache` is a process-wide static and the test asserts an absolute `Hits == 1` after a global `Clear()`; xUnit runs other classes in parallel in the same process, so any concurrently-compiling test breaks the absolute count. Recommended fix: row-74's established delta-over-scoped-action assertion pattern (a collection fixture would serialize more than needed). | Next SiteRuntime test touch, or first flake in a run that matters |
|
||||
| 92 | **The event-log cap's size model is sample-based and blind to mid-history width shifts** *(Low, accuracy limitation — documented in memo §3.1)* | Row-81 fix session (2026-08-17) | Row width is sampled 500 rows from each END of `site_events`; a stream whose row width changes sharply mid-history is mis-sized until the trim reaches that region. Self-correcting per tick (each trim exposes new tail rows to the sample), and the model's deliberate +8–13% high bias absorbs modest shifts, but a pathological width profile could over- or under-trim for several ticks. | First field report of the cap trimming visibly wide of its target, or any rework of the size model (see rows 93/94) |
|
||||
| 93 | **The cap model's page-layout constants are empirical for the CURRENT `site_events` schema** *(Low, maintenance trap)* | Row-81 fix session (2026-08-17) | Fill factors (0.78/0.75), cell overheads (16/8) and especially `IndexedColumnCount = 4` are calibrated to today's table + four indexes. A schema change — new column, new index — silently invalidates them; a fifth index in particular would be silently under-counted. Any `site_events` schema migration must re-run the memo §4 calibration and update the constants together. | Any schema change to `site_events` or its indexes |
|
||||
| 94 | **`SqliteCapabilityTests` is a deliberate tripwire — do not "fix" it by weakening the assertion** *(Low, standing note)* | Row-81 fix session (2026-08-17) | The test FAILS BY DESIGN if a future SQLite bump ships `dbstat`/`sqlite_dbpage` support. The correct response at that point is to REPLACE the sampled page-layout model with exact `dbstat` measurement (retiring rows 92/93 with it), not to relax the test. The test's own comment says the same. | `SqliteCapabilityTests` fails after a SQLite/provider bump |
|
||||
| 95 | **The leaked 100 ms-tick test-actor pattern is systemic — three more classes carry it** *(Low, test-infra; row-82 residue)* | Row-82 fix session (2026-08-17): two classes fixed (`PartitionPurgeTests`, `NotifyDispatcherAuditTrailTests`), three more found carrying the same shape | `AuditLogPurgeActorTests` (benign today only because exactly ONE test there touches the fixture DB — adding a second DB-backed test reintroduces row 82), `OutageReconciliationTests`, and `SiteAuditReconciliationActorTests` all create 100 ms-tick actors against a shared fixture DB with no deterministic stop (only timeout-bounded `TestKit.Dispose`, which does not fail on expiry). None failed across ~15 parallel runs, so left untouched for proportionate blast radius. Fix pattern is already written: `Watch`/`Stop`/`ExpectTerminated` in the creating test. | Next touch to any of the three classes, or first flake matching the leaked-actor fingerprint (a prior test's rows mutated mid-test) |
|
||||
| 96 | **xunit parallelism is uncapped almost everywhere — `MaxParallelThreads` unset, only 3 of ~20 test projects have an `xunit.runner.json`** *(Low, test-infra policy)* | Row-82 fix session (2026-08-17) | Every suite runs at processor-count collection parallelism, and a solution-level `dotnet test` multiplies that across concurrent processes — the general multiplier behind the row-82/91 flake class. The row-82 fix bounds the DB-migration axis specifically; a project-wide parallelism default (or per-suite runner configs for the resource-heavy suites) is a policy decision worth making once, deliberately, rather than per-flake. Supporting evidence from the 2026-08-17 close-out run: `AuditLogPageTests.DrillInFromParentExecution_FiltersGridToSpawnerExecution` (Playwright, untouched by the wave) hit a 30 s navigation timeout under full-suite parallel load and passed isolated in 1 s — one more member of this class. | Next test-infra session, or CI adoption of parallel suite runs |
|
||||
| 68 | ~~**`deploy.sh` run from a git worktree silently rehomes every node's bind-mount data dir**~~ **RESOLVED 2026-08-15 (guard added in the same close-out).** The compose files' bind mounts are relative (`./site-a-node-a/data`), so a deploy from a worktree checkout mounts the worktree's copies — the rig keeps running and looks healthy, but its SQLite stores split from the main checkout's and everything written there is orphaned by the next deploy from main. Bit the rows-46–53 close-out live: the seeding agent's `rig-probe-1` deploy persisted into worktree-homed dirs and "vanished" after the orchestrator's redeploy (no tombstone, no product bug — LWW/tombstone forensics ruled out deletion; the row sat in `.claude/worktrees/…/docker/site-a-node-a/data/site-localdb.db` the whole time). Both `docker/deploy.sh` and `docker-env2/deploy.sh` now refuse to run from a `.claude/worktrees/` path (override: `SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1`). | Found during the rows-46–53 close-out | Closed 2026-08-15. | — |
|
||||
|
||||
## Resolved (verified against the code 2026-07-10)
|
||||
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
|
||||
|
||||
@@ -111,3 +111,21 @@ mis-explanation in the repo ("the alone-oldest is dead and cannot down itself").
|
||||
- Monitoring already surfaces dual-active if it ever happens: both nodes report
|
||||
`IsActive` in heartbeats / both `/health/active` = 200 — the Health dashboard shows
|
||||
two Primaries.
|
||||
|
||||
## Postscript 2026-08-15 — the deprecation warning is benign (drill-verified), but pins us for Akka bumps
|
||||
|
||||
Every node logs at Cluster start: *"The `auto-down-unreachable-after` feature has been
|
||||
deprecated as of Akka.NET v1.5.2 … The `keep-majority` split brain resolver will be used
|
||||
instead."* Investigated and drill-verified on 1.5.62 (the same version this decision was
|
||||
made and drilled on): the warning fires purely on the **presence of the key** and the
|
||||
substitution does **NOT** happen when `downing-provider-class = "Akka.Cluster.AutoDowning"`
|
||||
is set explicitly, as `BuildHocon` does. Proof by discriminating drill (`DRILL_MODE=active`,
|
||||
2026-08-15): killing central-a — oldest AND lowest-address — left central-b to take over
|
||||
(OldestChanged → all singletons moved, victim rejoined ready 2s after restart). Under
|
||||
keep-majority, a 2-node even split keeps the lowest-address side, so central-b would have
|
||||
downed ITSELF; it demonstrably did not. The decision stands exactly as written.
|
||||
|
||||
The real exposure is forward-looking: `AutoDowning` is deprecated and can be REMOVED by a
|
||||
future Akka.NET release. **Any Akka version bump must re-run `DRILL_MODE=active
|
||||
bash docker/failover-drill.sh` and re-read the release notes before being accepted**
|
||||
(deferred-work register row 70).
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Instance reconcile sweep — closing the config/runtime divergence gaps
|
||||
|
||||
**Date:** 2026-08-15 (§7 added 2026-08-17)
|
||||
**Scope:** Site Runtime (`DeploymentManagerActor`), deferred-work-register rows **78**, **72**
|
||||
and **85**
|
||||
**Status:** implemented
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
The site's `deployed_configurations` rows are the durable truth about what should be running.
|
||||
The set of live Instance Actors is what *is* running. Nothing kept the two in agreement after
|
||||
boot, and there was no signal when they disagreed.
|
||||
|
||||
Before this change, an Instance Actor could be created from exactly three places:
|
||||
|
||||
1. the boot-time SQLite load in `DeploymentManagerActor.PreStart`;
|
||||
2. an explicit deploy / enable command from central;
|
||||
3. (register row 67, commit `482165cb`) `SiteReconciliationActor`'s `EnsureInstanceStartedCommand`,
|
||||
raised for enabled instances **the reconcile pass itself fetched and stored**.
|
||||
|
||||
### Row 78 — configs that arrive by replication start nothing
|
||||
|
||||
*(Medium-Low, availability; proven live 2026-08-15.)*
|
||||
|
||||
`deployed_configurations` is one of LocalDb's ten replicated tables. On a replicated site pair,
|
||||
a config can therefore appear in a node's file with **no local actor involved at all**: the CDC
|
||||
sync applier writes the row directly.
|
||||
|
||||
The live sequence was:
|
||||
|
||||
- site-a-b booted solo; its startup reconcile fetched 4 configs from central and stored them
|
||||
(row 67's machinery started their actors on that node — correct).
|
||||
- The pair was then restarted with a **fresh** site-a-a file. CDC snapshot replication seeded
|
||||
site-a-a's `deployed_configurations` within ~5 s of boot.
|
||||
- That was **after** site-a-a's boot-time load had already read an empty table, and **invisible**
|
||||
to site-a-a's own reconcile pass: central rightly reported *no gap* ("0 fetched"), because
|
||||
central's view of the site was correct — the site really did have the configs.
|
||||
- Result: enabled instances sat dead with their configs present (`debug snapshot` →
|
||||
`instanceNotFound`) until yet another restart.
|
||||
|
||||
Row 67 covers configs the reconcile *fetches*. This is the same gap through a side door.
|
||||
|
||||
### Row 72 — a refreshed config does not reach the running actor
|
||||
|
||||
*(Medium-Low, same silent-divergence class.)*
|
||||
|
||||
`ReconcileGapItem` covers both *missing* and *stale* configs. For a **stale** item the reconcile
|
||||
writes the newer config via `StoreDeployedConfigIfNewerAsync` — a pure file write. The running
|
||||
Instance Actor keeps executing the **previously compiled** config until the next restart or
|
||||
redeploy, and nothing anywhere surfaced "stored revision ≠ running revision".
|
||||
|
||||
Row 72 was deliberately deferred out of row 67's scope because restarting a *live* instance
|
||||
carries redeploy semantics that needed their own decision. That decision is section 3.
|
||||
|
||||
---
|
||||
|
||||
## 2. The mechanism: one periodic sweep in the Deployment Manager singleton
|
||||
|
||||
A single periodic tick on the Deployment Manager singleton re-reads every durable
|
||||
`deployed_configurations` row and repairs both divergences.
|
||||
|
||||
```
|
||||
InstanceSweepTick (every InstanceSweepIntervalSeconds, default 60)
|
||||
└── off-thread read of all deployed configs
|
||||
└── InstanceSweepLoaded → actor thread
|
||||
for each ENABLED stored config, unless a deploy pipeline owns the instance:
|
||||
no running actor → start it (row 78)
|
||||
running, revision ≠ stored → re-apply stored (row 72)
|
||||
```
|
||||
|
||||
**Ownership.** The Deployment Manager is a cluster singleton scoped to the site-specific role, so
|
||||
the sweep runs on exactly one node — the active one — and Instance Actors stay under the
|
||||
singleton, which is the same invariant row 67's singleton-proxy routing preserved.
|
||||
|
||||
### 2.1 Why a sweep and not a LocalDb change hook
|
||||
|
||||
The register suggested either. A sweep was chosen:
|
||||
|
||||
- **Coverage.** A replication-apply hook covers exactly one arrival path, and only on the half of
|
||||
the fleet that runs with replication enabled (rig posture: site-a replicated, site-b/site-c
|
||||
deliberately not). The sweep is arrival-path-agnostic — replication seeding, a post-boot
|
||||
snapshot resync, an operator restoring a file by hand, and any future path all reduce to the
|
||||
same question: *does the store agree with the runtime?*
|
||||
- **Availability.** `ILocalDb` exposes **no** change-notification, applied-rows callback or
|
||||
observer API — verified against `ZB.MOM.WW.LocalDb` 0.3.0. A hook would require a new upstream
|
||||
capability in the `scadaproj` library plus an in-process bus from the sync applier into this
|
||||
singleton, i.e. a cross-repo change to close a site-local gap.
|
||||
- **Reuse.** The sweep inherits row 67's guards verbatim; the shared `StartStoredInstance` helper
|
||||
means the reconcile heal and the sweep heal produce identical bookkeeping and identical
|
||||
operator-facing events, differing only in the trigger they name.
|
||||
|
||||
Cost is one SQLite read per minute per site — the same read `HandleDeploymentStateQuery` already
|
||||
performs per central query.
|
||||
|
||||
### 2.2 Guards (all inherited or shared with row 67)
|
||||
|
||||
- **Disabled instances are never started.** A disabled instance legitimately has a config row and
|
||||
no actor; that is its correct steady state.
|
||||
- **The sweep defers to any deploy pipeline that owns the instance** — a compile warm in flight
|
||||
(`_deployWarms`), a predecessor still terminating (`_terminatingActorsByName`), or a deployment
|
||||
awaiting its persistence/init join (`_inFlightDeployments`). This is the same
|
||||
`InstanceIsOwnedByADeployPipeline` predicate row 67's `ShouldAttemptEnsureStart` is now built
|
||||
from. It matters: a redeploy passes through a window where the predecessor has been stopped and
|
||||
removed from routing but the replacement does not exist yet, which an unguarded sweep would read
|
||||
as "enabled config, no actor" and answer by creating a child whose actor name is still
|
||||
registered.
|
||||
- **Inert until boot-time creation finishes.** A tick landing between two staggered startup
|
||||
batches would "heal" instances the boot load is already about to create, announcing a fault on
|
||||
a node that is merely still starting.
|
||||
- **One repair attempt per (instance, revision) pair.** This is what keeps a *periodic* self-heal
|
||||
from becoming a restart loop: an Instance Actor that dies during init leaves its config row in
|
||||
place, and a stored config the site compile gate rejects can never apply. Without the claim,
|
||||
either would be re-attempted — and re-announced — every tick forever. A genuinely new revision
|
||||
is a different key and is always attempted.
|
||||
- **No overlapping reads.** A slow storage read cannot let ticks stack up into duplicate repairs.
|
||||
- **Silent when healthy.** A sweep that finds nothing emits no log line above Trace and no site
|
||||
event. This runs on every tick of every healthy site, forever.
|
||||
|
||||
---
|
||||
|
||||
## 3. The row-72 decision: converge, do not merely warn
|
||||
|
||||
**Decision.** When the sweep finds a running instance whose revision no longer matches the stored
|
||||
row, it **applies the stored config through the same per-instance apply/restart path a central
|
||||
redeploy uses** (`HandleDeploy` → compile gate → stop/watch/recreate → persist), and announces
|
||||
both revisions in a Warning `deployment` site event.
|
||||
|
||||
**Rationale.**
|
||||
|
||||
- The stored row **is** what central successfully deployed. The running actor is executing a
|
||||
config central believes was replaced. Leaving it running is the actual fault — a site quietly
|
||||
serving superseded logic is worse than a controlled restart, and the operator has no way to
|
||||
discover it.
|
||||
- The alternative (surface-only) leaves an operator with a Warning they can only act on by
|
||||
issuing a redeploy — the exact operation the sweep would have performed, minus the delay and
|
||||
minus the chance they see it.
|
||||
- Reusing `HandleDeploy` rather than writing a second apply path means the compile gate, the
|
||||
warm-then-gate ordering, the redeploy-supersede semantics, the static-override and
|
||||
native-alarm reset, and all-or-nothing-per-instance are the ones already in production. No new
|
||||
semantics were invented. In particular a stop still does **not** cancel an in-flight script
|
||||
run, exactly as for a central redeploy.
|
||||
|
||||
**Why this is safe against the ordering hazards the register worried about.**
|
||||
|
||||
- The sweep never acts on an instance a deploy pipeline owns, so it cannot interleave with a real
|
||||
redeploy; and if a deploy arrives *after* the sweep's own deploy is queued, it is ordinary
|
||||
last-write-wins on the same mailbox, which is a case the deploy path already handles.
|
||||
- A rejected restart is inert, not partial: `RunDeployGateAndProceed` returns **before** anything
|
||||
is stopped, so the running instance keeps serving its old config. It is announced once
|
||||
(Warning for the attempt, Error for the failure) and never retried for that revision.
|
||||
- The synthetic `DeployInstanceCommand` replies to `Self`, not to `NoSender` — dead letters are a
|
||||
monitored site health metric, and a self-heal must not manufacture them.
|
||||
|
||||
**Row 72's "nothing surfaces it" is false either way.** Even in the failure path the divergence
|
||||
produces two operator-visible `deployment` site events naming both revisions.
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
`ScadaBridge:SiteRuntime:InstanceSweepIntervalSeconds` — default **60**, validated `>= 0`
|
||||
(`SiteRuntimeOptionsValidator`). **`0` disables the sweep**, which reopens both gaps; the row-67
|
||||
`EnsureInstanceStartedCommand` path is unaffected either way. No config change is required on any
|
||||
node to get the fix — the default is live.
|
||||
|
||||
## 5. Observability
|
||||
|
||||
| Condition | Log | Site event |
|
||||
|---|---|---|
|
||||
| Nothing to repair | — | — |
|
||||
| Row 78 start | Warning naming the instance and trigger | `deployment` / **Warning** — "started by deployment-manager sweep self-heal" |
|
||||
| Row 72 restart attempt | Warning naming both revisions | `deployment` / **Warning** — "restarted by deployment-manager sweep self-heal — running revision X is stale, applying stored revision Y" |
|
||||
| Row 72 restart applied | Information | — (the deploy path's own Info event covers it) |
|
||||
| Row 72 restart rejected | Warning with the gate's errors | `deployment` / **Error** — "stale-revision deployment-manager sweep self-heal failed" |
|
||||
| Storage read failed | Warning | — |
|
||||
|
||||
The trigger is named in the message so a sweep heal is distinguishable from row 67's
|
||||
"startup reconcile self-heal".
|
||||
|
||||
## 6. Tests
|
||||
|
||||
`tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceSweepSelfHealTests.cs` — 10 tests.
|
||||
|
||||
Fail-before evidence (behavioral revert: the periodic timer left unarmed) — **5 fail, 5 pass**:
|
||||
|
||||
| Test | Pre-fix |
|
||||
|---|---|
|
||||
| `Sweep_StartsEnabledInstance_WhoseConfigArrivedWithoutADeployOrReconcile` | **FAIL** — the live row-78 symptom |
|
||||
| `SweepStartedInstance_EmitsAWarningSiteEvent_NamingTheSweepTrigger` | **FAIL** |
|
||||
| `Sweep_StartsTheInstanceExactlyOnce_AcrossManySweeps` | **FAIL** |
|
||||
| `Sweep_RestartsInstance_ServingAStaleRevision` | **FAIL** — the row-72 symptom |
|
||||
| `Sweep_StaleRestartRejectedByTheCompileGate_IsAnnouncedOnceAndNotRetried` | **FAIL** |
|
||||
| `Sweep_NeverStarts_ADisabledInstance` | pass (negative guard) |
|
||||
| `Sweep_DefersToAnInFlightRedeploy` | pass (negative guard) |
|
||||
| `Sweep_DoesNotRestart_WhenStoredAndRunningRevisionsMatch` | pass (negative guard) |
|
||||
| `Sweep_IsSilent_WhenThereIsNothingToHeal` | pass (negative guard) |
|
||||
| `Sweep_DoesNothing_WhenTheIntervalIsZero` | pass (negative guard) |
|
||||
|
||||
Every test gates on the Deployment Manager's first instance-count report before writing a config
|
||||
row, because a row written before the boot-time load has read storage is picked up by that load —
|
||||
which is precisely the path row 78 is about *not* having. Without the gate a test would silently
|
||||
prove the boot load works.
|
||||
|
||||
That gate is now a shared helper,
|
||||
`tests/…/TestSupport/DeploymentManagerStartupProbe.cs`, and **register row 83** applied it to
|
||||
row 67's suite (`ReconcileStartsInstanceTests`), which had the same trap behind a bare
|
||||
`Task.Delay(500)`. Those tests also pin `InstanceSweepIntervalSeconds = 0`: with the sweep armed
|
||||
it would independently start the same instances, and the suite would stop distinguishing the
|
||||
row-67 mechanism from the row-78 one. The remaining `Task.Delay`s in that file are deliberate —
|
||||
they precede assertions that *nothing happened*, which cannot be awaited on.
|
||||
|
||||
---
|
||||
|
||||
## 7. Row 85 — a self-healed instance started with no DCL connection actors
|
||||
|
||||
*(Medium-Low, availability; proven live 2026-08-16 by the row-78 close-out probe. Added
|
||||
2026-08-17.)*
|
||||
|
||||
Row 78 got the Instance Actor running. Row 85 is what was still missing underneath it: the
|
||||
instance came up with **no data connection actors**, so every tag it subscribed was answered
|
||||
`Unknown connection: OPC PLC Simulator` by the DCL manager — for 6+ minutes, until a second pair
|
||||
restart. Same class as row 78, one layer down.
|
||||
|
||||
### 7.1 Where connection actors actually come from
|
||||
|
||||
The register's suggested fix was "ensure a connection actor exists for every stored site data
|
||||
connection". Reading the code first changed the shape of the answer, because the premise is not
|
||||
how the runtime works:
|
||||
|
||||
- **`data_connection_definitions` has no production reader.**
|
||||
`SiteStorageService.GetAllDataConnectionDefinitionsAsync` is called from exactly one place in
|
||||
the repo, and it is a test. Nothing loads that table at boot.
|
||||
- A DCL connection actor is created **only as a side effect of starting an instance**, from the
|
||||
`Connections` map inside that instance's *own flattened config*, via `EnsureDclConnections`
|
||||
(plus the separate system-wide artifact-deploy path, `HandleApplyArtifactDataConnectionsToDcl`).
|
||||
|
||||
So "the connection row sat in the store with no actor" was right about the symptom and wrong
|
||||
about the mechanism: there is no store→actor path to repair. The real defect is that
|
||||
`EnsureDclConnections` was **the caller's job, and only two of the four callers did it**:
|
||||
|
||||
| Path | Creates the Instance Actor | Ensured its connections (pre-fix) |
|
||||
|---|---|---|
|
||||
| Boot-time staggered batch | `CreateInstanceActorBatch` | ✅ |
|
||||
| Central deploy / redeploy | `ApplyDeployment` | ✅ |
|
||||
| **Self-heal start** (row 67 ensure-started **and** row 78 sweep) | `StartStoredInstance` | ❌ |
|
||||
| **Enable** a disabled instance | `HandleEnableResult` | ❌ |
|
||||
|
||||
The two misses are exactly the two paths that were added *after* the original pair, which is the
|
||||
signature of a convention that lives at call sites instead of in the funnel.
|
||||
|
||||
**The enable path proves this was never replication-specific.** The boot-time load reads only
|
||||
ENABLED configs, so a node that boots with an instance disabled never creates that instance's
|
||||
connections; enabling it afterwards hit the identical dead end on an ordinary, unreplicated
|
||||
site. Row 85 was reachable with no LocalDb replication anywhere in the picture — the replication
|
||||
sequence is just how it was found.
|
||||
|
||||
### 7.2 The fix: move the ensure into the single creation funnel
|
||||
|
||||
`CreateInstanceActor` is already documented as *"the single creation funnel, so recording it here
|
||||
covers every path that can ever start an Instance Actor"* — it is where `_runningRevisions` is
|
||||
stamped for exactly that reason. `EnsureDclConnections(configJson)` now sits at the top of it,
|
||||
and the two caller-side calls are removed.
|
||||
|
||||
- **Placed BEFORE the already-exists guard**, so the two paths that already called it keep their
|
||||
exact previous semantics (both called it unconditionally, before `CreateInstanceActor`).
|
||||
- **No new message, no new option, no DCL change.** It reuses `EnsureDclConnection`'s existing
|
||||
`_createdConnections` hash guard, so an unchanged connection is a no-op — no message, no log,
|
||||
nothing on the wire. Steady state is silent, which matters because this now runs on every
|
||||
instance start on every node.
|
||||
- **Structural, not another guard.** A fifth start path cannot forget, because there is nothing
|
||||
left to remember.
|
||||
|
||||
### 7.3 Why the DM and not the DCL manager
|
||||
|
||||
The register offered either. The DCL manager was rejected: it has no storage handle and no notion
|
||||
of "the connections this site ought to have" — it is a router over actors it was told to create.
|
||||
Teaching it to read `data_connection_definitions` would give the DCL a **second, divergent config
|
||||
source** alongside the flattened instance configs the DM already pushes, with no arbiter when the
|
||||
two disagree. The DM owns `_createdConnections`, owns the flattened configs, and is already the
|
||||
only sender of `CreateConnectionCommand`/`RemoveConnectionCommand` — it is a single funnel
|
||||
already, and the fix keeps it that way.
|
||||
|
||||
### 7.4 Why NOT a store-level connection pass in the sweep
|
||||
|
||||
A sweep pass over `data_connection_definitions` was designed and **deliberately dropped**. It is
|
||||
recorded here because it looks obviously right and is not:
|
||||
|
||||
- **It would not have fixed row 85.** That table is written only by the artifact-deploy path, so
|
||||
on a site that has only ever received instance deploys it is empty — while the connections that
|
||||
matter live in the instances' flattened configs.
|
||||
- **The unguarded version is actively harmful.** `_createdConnections` holds one hash per name.
|
||||
The flattened-config hash and the artifact-definition hash for the same connection can
|
||||
legitimately differ (artifact newer than an instance's flattened config — the supported
|
||||
version-skew case). A sweep re-hashing from the definitions table would flip the slot every
|
||||
tick against the deploy path flipping it back, **recreating the connection actor and dropping
|
||||
its live subscriptions on a loop**.
|
||||
- **The guarded version is near-empty.** Restricting it to names absent from `_createdConnections`
|
||||
removes the ping-pong but then covers only "a definition row exists that no instance
|
||||
references" — where no connection actor is wanted anyway.
|
||||
|
||||
**Residual gap, accepted and recorded:** `data_connection_definitions` *is* one of LocalDb's ten
|
||||
replicated tables, so a **connection-only** artifact deploy replicates to the peer node, and only
|
||||
the node that received the deploy runs `HandleApplyArtifactDataConnectionsToDcl` — the peer keeps
|
||||
serving the old endpoint until something restarts it or an instance is redeployed. That is a real
|
||||
sibling of row 85, it is **not** closed here, and closing it needs the arbitration decision above
|
||||
(which source wins), not another sweep pass. Flagged for the deferred-work register.
|
||||
|
||||
**Removal side:** also unchanged and out of scope. `RemoveConnectionCommand` is sent only from the
|
||||
artifact-deploy reconcile; a connection deleted on the peer via replication leaves a live actor on
|
||||
this node. Same root cause, same arbitration question, same register entry.
|
||||
|
||||
### 7.5 Tests
|
||||
|
||||
`tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SelfHealCreatesDclConnectionTests.cs` —
|
||||
7 tests, asserting on the `CreateConnectionCommand` messages the DM sends to a recording stand-in
|
||||
for the DCL manager (the message whose absence *was* row 85).
|
||||
|
||||
Fail-before evidence (behavioral revert: `EnsureDclConnections` removed from the funnel and
|
||||
restored at the two original call sites — i.e. the exact pre-fix code) — **4 fail, 3 pass**:
|
||||
|
||||
| Test | Pre-fix |
|
||||
|---|---|
|
||||
| `SweepStartedInstance_AlsoCreatesItsDclConnection` | **FAIL** — the live row-85 symptom |
|
||||
| `ReconcileEnsureStartedInstance_AlsoCreatesItsDclConnection` | **FAIL** — row-67 path |
|
||||
| `EnabledInstance_DisabledAtBoot_AlsoCreatesItsDclConnection` | **FAIL** — no replication involved |
|
||||
| `SweepStartedInstance_CreatesTheConnection_ExactlyOnce_AcrossManySweeps` | **FAIL** (0, not 1) |
|
||||
| `Sweep_SendsNoConnectionCommands_WhenThereIsNothingToHeal` | pass (steady-state silence) |
|
||||
| `OrdinaryDeploy_StillCreatesItsDclConnection` | pass (refactor guard) |
|
||||
| `BootLoadedInstance_StillCreatesItsDclConnection` | pass (refactor guard) |
|
||||
|
||||
The two refactor guards are the point of passing both before and after: they pin that moving the
|
||||
call out of `CreateInstanceActorBatch` and `ApplyDeployment` did not regress the paths that
|
||||
already worked.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
# `site_events` purge burst — measurement + pacing decision (register row 34)
|
||||
|
||||
**Date:** 2026-08-15
|
||||
**Row:** deferred-work register #34 — *"`site_events` purge burst is unbounded in the oplog and unmeasured at scale."*
|
||||
**Outcome:** measured; **pacing implemented** (a per-tick delete ceiling with an accelerated
|
||||
catch-up tick). One separate defect found on the storage-cap path — see *New issue* at the end.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the row asked
|
||||
|
||||
`site_events` lives in the consolidated LocalDb file and is CDC-replicated on a replication-enabled
|
||||
site. The daily retention purge (30 days) and the storage-cap trim (1 GB) slice their deletes at
|
||||
1000 rows per DELETE statement, but the **whole** expired set went in one tick, so the oplog gained
|
||||
one row per deleted event with no pacing. The wire path is bounded (`MaxBatchBytes` 2 MB; the rig
|
||||
also pins `MaxBatchSize: 64` to bound the DB read page) but the oplog **write** was not, and nothing
|
||||
had been measured.
|
||||
|
||||
The design commitment that had to survive, and did: **there is no purge-exemption path.** Purge
|
||||
deletes are captured like any other write — the same property that made `ReplaceAllAsync` unsafe to
|
||||
reintroduce. Nothing below adds an exemption or an oplog bypass; the fix bounds how much one tick
|
||||
deletes, not what gets captured.
|
||||
|
||||
## 2. How it was measured
|
||||
|
||||
A throwaway console harness (`dotnet` console app, scratchpad — not committed) running the **real**
|
||||
`EventLogPurgeService` over a **real** `ZB.MOM.WW.LocalDb` file with `site_events` genuinely
|
||||
`RegisterReplicated`, plus a two-node loopback replication pair for the drain leg:
|
||||
|
||||
| Phase | What it does |
|
||||
| --- | --- |
|
||||
| `calibrate` | Seeds realistically-shaped rows, measures `page_count`/`freelist_count`/`page_size` delta ⇒ bytes per row ⇒ rows at the 1 GB cap. |
|
||||
| `burst` | Seeds N expired rows, registers CDC, parks the oplog at "everything acked", runs one real `RunPurge()`. Records purge wall time, oplog rows, oplog payload bytes, row-version tombstones, file size before/after. |
|
||||
| `cap` | Same but with rows *inside* retention and a small `MaxStorageMb`, so only the storage-cap trim runs. Run twice — replicated and not — to isolate the CDC contribution. |
|
||||
| `drain` | Two full replication stacks (initiator + passive `MapZbLocalDbSync`) over Kestrel h2c on loopback, both nodes registered for `site_events`. Seeds through the normal write path so the pair converges naturally, then purges on node A and polls the O(1) backlog expression until it returns to 0 and node B's table is empty. |
|
||||
|
||||
Row content mirrors the post-WP3.2 event stream: mostly short Info rows, 1-in-10 carrying a details
|
||||
payload, instance ids on 2-in-3. `docker/` was not touched.
|
||||
|
||||
Machine: macOS (darwin 25.5.0), .NET 10, Release build, server GC, local SSD. Loopback drain numbers
|
||||
are therefore an **upper bound on throughput** — a real inter-node link is slower, and the margins
|
||||
below are stated with that in mind.
|
||||
|
||||
## 3. Numbers
|
||||
|
||||
### 3.1 How big the worst case actually is
|
||||
|
||||
```
|
||||
calibrate: 200,000 rows
|
||||
bytes per row (incl. the four indexes) 393
|
||||
rows at the 1 GB storage cap 2,731,947
|
||||
```
|
||||
|
||||
The 1 GB cap is the real bound on a single retention burst: the worst realistic case is a log at the
|
||||
cap in which everything has expired (a site restarted after a long outage, a clock correction, or
|
||||
simply the first purge after `site_events` retention was ever satisfied).
|
||||
|
||||
### 3.2 The unpaced retention burst
|
||||
|
||||
| Seeded expired rows | Purge wall time | Oplog rows queued | Oplog payload | Row-version tombstones | File before → after |
|
||||
| ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 500,000 | 36.2 s | 500,000 | 52.0 MB | 500,000 | 254 MB → 129 MB |
|
||||
| **2,731,947** (1 GB cap) | **285.1 s** | **2,731,947** | **284.1 MB** | **2,731,947** | 1.39 GB → 707 MB |
|
||||
|
||||
**The headline finding:** the worst-case burst *queues* **2.7x LocalDb's `MaxOplogRows` default of
|
||||
1,000,000**. Crossing that ceiling is not cosmetic — `OplogStore.EnforceCapsAsync`, which the
|
||||
replication maintenance service runs every 60 s, prunes the oldest oplog rows **and sets
|
||||
`needs_snapshot = 1`**, the database-wide flag. The next handshake therefore serves a **full
|
||||
snapshot of all ten registered tables**. A routine retention purge could, on its own, force a full
|
||||
resync of the site's entire replicated state.
|
||||
|
||||
**How much of that 2.73 M is actually resident depends on the peer** — the oplog is drained
|
||||
concurrently, so the *peak backlog* is the burst minus what the peer acks while the purge runs. §3.3
|
||||
measures it: on a loopback pair the peak was 391 k, not 2.73 M, because the peer kept up. That does
|
||||
not make the risk theoretical, it makes it **conditional on peer speed**, which is exactly the
|
||||
property a design should not depend on:
|
||||
|
||||
- **peer down / disconnected** — nothing drains, the full 2.73 M is resident. Over the cap outright.
|
||||
- **peer slower than ~63 % of loopback** — the production deficit outruns the drain and the peak
|
||||
crosses 1 M. Arithmetic from §3.3: the purge produced 8,548 rows/s and the loopback peer consumed
|
||||
7,585 rows/s; halve the peer's rate and the peak lands ≈1.5 M.
|
||||
- **peer at loopback speed** — peak 391 k, under the cap, but still 391x the row-33 badge threshold.
|
||||
|
||||
Pacing removes the dependency entirely: a 250 k tick cannot exceed the cap at *any* peer speed,
|
||||
including a peer that is down.
|
||||
|
||||
Secondary costs at worst case: 285 s of purge (the write lock is taken and released 2,732 times, so
|
||||
recorder flushes interleave — but they contend for nearly five minutes), 284 MB of oplog payload,
|
||||
and 2.73 M row-version tombstones retained for `TombstoneRetention` (7 days). The file was still
|
||||
707 MB *after* deleting a 1 GB event log, because the replication bookkeeping the delete generated
|
||||
is itself in that file.
|
||||
|
||||
### 3.3 Drain
|
||||
|
||||
Two-node loopback pair, `MaxBatchSize: 64` (the rig's pin), `MaxBatchBytes` at its 2 MB default.
|
||||
Both nodes registered for `site_events`; rows are seeded through the normal write path so the pair
|
||||
converges naturally before the purge runs.
|
||||
|
||||
| Rows | Insert leg (seed + replicate) | Purge | Backlog → 0 | **Peak backlog** | Tombstones/s | Inside the 15-min dwell? |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 20,000 | 1.3 s (15.0 k/s) | 0.5 s | 1.0 s | 17,424 | 19,807 | yes |
|
||||
| 500,000 | 65.7 s (7.6 k/s) | 34.4 s | 46.3 s | 150,912 | 10,795 | yes |
|
||||
| **2,731,947** (1 GB cap) | 996.9 s (2.7 k/s) | 319.6 s | **360.2 s** | **391,424** | 7,585 | yes (6.0 min) |
|
||||
|
||||
**Drain is not the bottleneck, and a healthy peer blunts the burst.** At every size the pump
|
||||
consumed tombstones almost as fast as the purge produced them, so the resident backlog stayed far
|
||||
below the burst size — 391 k peak on a 2.73 M burst. The tombstone leg is roughly 4x cheaper per row
|
||||
than the insert leg, as expected: a delete oplog entry carries a null `row_json`.
|
||||
|
||||
**That is exactly why the unpaced burst was still unsafe**, though. The peak is
|
||||
`produced − drained`, so it is a function of the *peer's* throughput, and the margin evaporates as
|
||||
soon as the peer is slower than loopback or absent (see §3.2). A design whose oplog-cap safety
|
||||
depends on the peer keeping up is one bad link away from a full ten-table resync.
|
||||
|
||||
**Row-33 interplay (the sustained badge).** The badge needs the backlog above 1000 continuously for
|
||||
15 minutes.
|
||||
|
||||
- **Paced (shipped default).** A full 250 000-row tick drains in ~23 s at the measured rate; even
|
||||
assuming a real link is **10x slower** than loopback it clears in ~4 minutes — comfortably inside
|
||||
the dwell, with the next tick 5 minutes away. Pacing lowers each spike and lengthens *total*
|
||||
purge duration, but every individual spike still drains between ticks, so the badge assumption
|
||||
holds with a large margin. **Verified, not assumed:** the 500 k measurement is 2x a paced tick and
|
||||
drained in 46 s.
|
||||
- **Unpaced (previous behaviour).** 6.0 minutes on loopback — inside the dwell, but only 2.5x of
|
||||
margin, at a peak of 391 k (391x the badge threshold). At a 3x-slower link the same burst badges.
|
||||
So pacing does not merely preserve the row-33 assumption; it is what makes it robust.
|
||||
|
||||
### 3.4 The storage-cap trim
|
||||
|
||||
`MaxStorageMb = 64`, 400,000 rows seeded inside the retention window (so only the cap path runs):
|
||||
|
||||
| Replicated? | Events deleted | Remaining | Measured size before → after | Reached the cap? |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| no | 248,000 | 152,000 | 158.0 MB → 66.98 MB | **yes** (cap 67.11 MB) |
|
||||
| yes | 174,000 | 226,000 | 203.6 MB → 167.7 MB | **no — stopped 2.5x over cap** |
|
||||
|
||||
See *New issue* below.
|
||||
|
||||
## 4. Decision — pace it
|
||||
|
||||
Pacing is warranted, on the strength of §3.2: an unbounded tick can exceed `MaxOplogRows` and buy a
|
||||
full ten-table snapshot resync. Drain *speed* (§3.3) was not the reason — that is healthy — but the
|
||||
fact that the safety margin **depends on** drain speed is: with the peer down, the whole 2.73 M
|
||||
burst is resident and the cap is breached outright. Bounding the tick makes the outcome independent
|
||||
of the peer, which is the property the previous design lacked.
|
||||
|
||||
**Shipped:**
|
||||
|
||||
- `SiteEventLogOptions.MaxPurgedRowsPerTick`, default **250 000**, `0` = unlimited.
|
||||
- Ceiling on rows deleted by **one tick**, **shared** between the retention purge and the cap
|
||||
trim. Two independently-capped phases would let a tick emit twice the intended burst.
|
||||
- `0` (not a negative, which the validator rejects) is the documented escape hatch back to the
|
||||
pre-row-34 behaviour, byte-for-byte.
|
||||
- **Why 250 000:** a 4x margin under `MaxOplogRows` = 1 000 000, and that margin is shared with
|
||||
every *other* replicated table's ordinary traffic, which also occupies the same oplog. At the
|
||||
measured rates one such tick costs ~17 s of purge and ~23 s of drain.
|
||||
- `SiteEventLogOptions.PurgeCatchUpInterval`, default **5 minutes**.
|
||||
- A tick that stopped at the ceiling schedules the next tick after this instead of
|
||||
`PurgeInterval`. Without it, pacing would make retention converge in 11 **days** at the daily
|
||||
cadence — a policy that no longer enforces its own window. With it, the worst case converges in
|
||||
~11 ticks ≈ **55 minutes**, every burst bounded.
|
||||
- **Why 5 minutes rather than 1:** the interval must exceed the peer's drain time for one tick, or
|
||||
ticks outrun the drain and the backlog accumulates across them — reintroducing the very problem
|
||||
being fixed. 250 k drains in ~23 s on loopback; 5 minutes leaves ~13x headroom for a slower
|
||||
real link.
|
||||
- Validated `> 0` and `<= PurgeInterval` (a longer catch-up would *delay* convergence, inverting
|
||||
its purpose).
|
||||
- **The ceiling is not claimed when the work is finished.** An expired set that lands exactly on the
|
||||
budget is done; the retention phase runs a bounded `EXISTS` probe before flagging a catch-up tick,
|
||||
so a converged node never arms a 5-minute retry that deletes nothing.
|
||||
- **Visibility.** A ceiling-limited tick logs a Warning naming the ceiling, the rows deleted and the
|
||||
accelerated interval — **rate-limited to one line per 15 minutes**, because a large backlog
|
||||
produces a run of such ticks. Every hit increments
|
||||
`scadabridge.site.event_log.purge_ceiling_hits` (`ScadaBridgeTelemetry`, whose meter is already in
|
||||
the site `ObservedMeters` allowlist). The counter is the unthrottled signal, and its **shape** is
|
||||
the diagnosis: a run that stops = a backlog converging; hits that never stop = the site writes
|
||||
events faster than one paced tick purges, so the ceiling or the catch-up interval is mis-sized.
|
||||
- **Log wording updated.** The existing replication-backlog Information note now says the spike is
|
||||
bounded by the ceiling rather than by how much history expired.
|
||||
|
||||
**Consequence accepted:** the purge loop is now a `Task.Delay` loop rather than a `PeriodicTimer`,
|
||||
because the wait is no longer a constant. The cadence is therefore anchored to the **end** of the
|
||||
previous purge rather than to process start. At a daily interval and a measured worst-case paced
|
||||
tick of ~17 s the drift is immaterial; the topology guide and component doc say so.
|
||||
|
||||
**Explicitly not done:** no purge-exemption path, no oplog bypass, no "skip capture for deletes"
|
||||
flag. Those are ruled out by design and the measurement gives no reason to revisit that.
|
||||
|
||||
## 5. New issue found — the storage cap cannot be met on a replicated node with an undrained oplog
|
||||
|
||||
Not row 34, and not fixed here. `EventLogPurgeService.GetDatabaseSizeBytes()` measures
|
||||
`PRAGMA page_count - freelist_count` — i.e. the **whole consolidated LocalDb file**, which since
|
||||
Phase 1/2 holds ten tables *plus* `__localdb_oplog` and `__localdb_row_version`. On a replicated
|
||||
node the cap trim therefore fights itself: each 1000-row delete frees ~390 KB of `site_events` pages
|
||||
and adds ~1000 oplog rows plus ~1000 row-version tombstones to the same file. Measured (§3.4) the
|
||||
trim stopped **2.5x over a 64 MB cap** with 226 k rows still present, tripped by the existing
|
||||
"size did not decrease across an iteration" guard — which is doing its job (it prevents the table
|
||||
being wiped) but leaves the cap unenforced.
|
||||
|
||||
Severity is moderate, not acute: with a live peer the oplog drains on ack and successive ticks
|
||||
recover ground, and tombstones expire after 7 days. But two real consequences follow — a node whose
|
||||
peer is down cannot enforce its event-log storage cap at all, and the "1 GB event log" budget is in
|
||||
practice shared with replication bookkeeping that the event log does not own. A correct fix would
|
||||
measure `site_events`' own pages (e.g. `dbstat`) rather than the file, which is a change to the
|
||||
cap's semantics and deserves its own decision. Reported for the register; no row added here.
|
||||
|
||||
> **Postscript (2026-08-17) — fixed; register row 81 closed.** The cap now measures `site_events`
|
||||
> and its four indexes only. One correction to the suggestion above: **`dbstat` turned out to be
|
||||
> unavailable.** It is a compile-time option and the bundled `SQLitePCLRaw.lib.e_sqlite3`
|
||||
> (SQLite 3.53.3) does not enable it — `PRAGMA compile_options` lists no `ENABLE_DBSTAT_VTAB`, and
|
||||
> neither `dbstat` nor `sqlite_dbpage` appears in `pragma_module_list`. The table's footprint is
|
||||
> therefore *modelled* from sampled row width rather than measured, at −0.8 %…+12.8 % accuracy
|
||||
> against the numbers in §3.4, biased high on purpose. The "size did not decrease" guard survives,
|
||||
> re-based onto the table-scoped metric so replication bookkeeping can no longer trip it. Decision
|
||||
> record, including the probe evidence and why a different SQLite build was rejected:
|
||||
> `docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md`. §3.4's replicated row is left
|
||||
> as measured — it is the record of the defect, not of current behaviour.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- Fail-before: the new behavioural tests do not compile against pre-fix `EventLogPurgeService`
|
||||
(`RunPurge` was `void`); with the fix restored but `PurgeBudget` neutralised to always-unlimited,
|
||||
**6 of the new tests fail** and the 19 pre-existing ones pass — so the assertions are carried by
|
||||
the ceiling, not incidentally.
|
||||
- `dotnet build ZB.MOM.WW.ScadaBridge.slnx` clean.
|
||||
- `ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests`: 93 passed / 0 failed.
|
||||
@@ -162,6 +162,24 @@ flattens once gen-2 collections begin, or issue an explicit
|
||||
`GC.Collect(2, Forced, blocking: true)` at window start and end and compare.
|
||||
Deliberately *not* attempted here — the brief was to measure, not to tune.
|
||||
|
||||
**SETTLED 2026-08-15 — the 1-hour protocol was run (register row 29): NOT a leak.**
|
||||
Same harness, same scale, `--sustain-minutes 60` (3,911 s total, 135,041,250 updates
|
||||
offered at 37,511/s = 100.0 % of nominal, on the same host, concurrent with the docker
|
||||
rig and a central failover drill). The decisive numbers over the 3,600 s steady-state
|
||||
window (361 samples): **working set 2,722 → 1,757 MB, slope −28.89 MB/min** (peak
|
||||
3,258 MB), **one gen-2 collection observed**, threads flat at 59. A working set that
|
||||
*falls* by ~1 GB across a full sustained hour is incompatible with a slow leak; the
|
||||
managed-heap least-squares slope (+16.26 MB/min, end 3,352 MB, peak 3,726 MB) remains
|
||||
an artifact of measuring across sawtooth segments between rare gen-2 collects, exactly
|
||||
as F1 hypothesised. Every other metric held or improved on the 20-minute run: tag
|
||||
latency P50 0.92 ms / P99 4.01 ms / max 92.18 ms across 2.9 M samples, **0 events
|
||||
dropped** on live subscribers, health-report ingest P99 0.05 ms (1,240/1,240, 10 sites),
|
||||
debug snapshots 744/744 with 0 timeouts, S&F 20,000 msgs buffered at 16,837 msg/s and
|
||||
drained at 3,418 msg/s to depth 0 (same one-`DefaultRetryInterval` pre-drain wait, 29.0 s
|
||||
— F2 unchanged), slow-subscriber isolation structural and total (4 healthy peers at
|
||||
100.00 % while the stalled probe lost 98.81 % entirely in its own channel — F3
|
||||
unchanged). CPU 2.9 % of the box.
|
||||
|
||||
### F2 — Store-and-forward waits one full `DefaultRetryInterval` before draining a deferred backlog (informational, by design)
|
||||
|
||||
The drain measurement initially read as **33 msg/s**, which would have been alarming.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# `site_events` storage cap — what the cap measures (register row 81)
|
||||
|
||||
**Date:** 2026-08-17
|
||||
**Row:** deferred-work register #81 — *"the storage cap fights its own oplog."*
|
||||
**Outcome:** cap semantics changed. `ScadaBridge:SiteEventLog:MaxStorageMb` now bounds
|
||||
**`site_events` and its four indexes**, not the whole consolidated LocalDb file. `dbstat` — the
|
||||
exact measurement the register proposed — turned out to be **unavailable in the SQLite this
|
||||
solution ships**, so the table's footprint is *modelled* from sampled row width. This memo records
|
||||
the probe that ruled `dbstat` out, the model, its measured accuracy, and the new meaning of the
|
||||
wipe-prevention guard.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect
|
||||
|
||||
Since LocalDb Phase 1/2 the site keeps ten tables in one SQLite file at `LocalDb:Path`, alongside
|
||||
LocalDb's own `__localdb_oplog`, `__localdb_row_version` and `__localdb_snapshot_state`
|
||||
bookkeeping. `EventLogPurgeService.GetDatabaseSizeBytes()` measured
|
||||
`page_count − freelist_count` over **that whole file** and compared it to `MaxStorageMb`.
|
||||
|
||||
On a replication-enabled node the cap trim therefore fought itself. Every 1000-row batch freed
|
||||
~390 KB of `site_events` pages *and* pushed ~1000 rows into `__localdb_oplog` plus ~1000
|
||||
row-version tombstones into the same file. The measured size did not fall, the existing
|
||||
"size did not decrease across an iteration" guard fired, and the trim stopped — with the cap
|
||||
unmet.
|
||||
|
||||
Measured 2026-08-16 during the row-34 work
|
||||
(`docs/plans/2026-08-15-site-events-purge-burst-measurement.md` §3.4/§5), 64 MB cap, 400 000 rows:
|
||||
|
||||
| Replicated? | Events deleted | Remaining | Size before → after | Reached the cap? |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| no | 248 000 | 152 000 | 158.0 MB → 66.98 MB | **yes** (cap 67.11 MB) |
|
||||
| yes | 174 000 | 226 000 | 203.6 MB → 167.7 MB | **no — stopped 2.5x over cap** |
|
||||
|
||||
Two consequences, both real:
|
||||
|
||||
1. **A node whose peer is down cannot enforce its event-log cap at all.** Nothing drains the
|
||||
oplog, so the file never shrinks and the guard stops the trim every tick, forever.
|
||||
2. **The "1 GB event log" budget was in practice shared with replication bookkeeping the event
|
||||
log does not own.** Worse, the direction of the coupling is backwards: a *replication fault*
|
||||
(peer down, oplog piling up) would, under a naive "just keep deleting" fix, cause operational
|
||||
event history to be destroyed. The cap must not be a function of the peer's health.
|
||||
|
||||
The old guard was not the bug — it was doing its job, preventing the table being wiped in pursuit
|
||||
of an unreachable target. The bug is that the target was unreachable, because the metric was
|
||||
wrong.
|
||||
|
||||
## 2. `dbstat` is not available — the decision this memo turns on
|
||||
|
||||
The register proposed `SELECT sum(pgsize) FROM dbstat WHERE name='site_events'`. `dbstat` is a
|
||||
**compile-time option** (`SQLITE_ENABLE_DBSTAT_VTAB`), and it was verified before any
|
||||
implementation was written. It is **not enabled** in the native library this solution ships
|
||||
(`SQLitePCLRaw.lib.e_sqlite3`, the security-pinned 2.1.12 build referenced by both
|
||||
`ZB.MOM.WW.ScadaBridge.SiteEventLogging` and its test project).
|
||||
|
||||
Probed live against a real file, SQLite **3.53.3**:
|
||||
|
||||
```
|
||||
PRAGMA compile_options -> no ENABLE_DBSTAT_VTAB
|
||||
(ENABLE_FTS3/4/5, RTREE, GEOPOLY, SESSION, PREUPDATE_HOOK, … present)
|
||||
pragma_module_list -> 10 modules: fts3, fts3tokenize, fts4, fts4aux, fts5, fts5vocab,
|
||||
geopoly, pragma_module_list, rtree, rtree_i32
|
||||
SELECT sum(pgsize) FROM dbstat -> SQLite Error 1: 'no such table: dbstat'
|
||||
SELECT … FROM dbstat('main') WHERE name=… -> SQLite Error 1: 'no such table: dbstat'
|
||||
```
|
||||
|
||||
`sqlite_dbpage` — the other route to per-table page attribution — is absent for the same reason.
|
||||
The module list is non-empty, so this is a genuine absence and not an empty-result artefact.
|
||||
|
||||
**This is pinned by a committed test**, `SqliteCapabilityTests.DbStat_IsNotAvailable_…`, written to
|
||||
fail if `dbstat` ever appears. If it does, the right move is to delete the model in §3 and measure
|
||||
exactly; the test's own comment says so.
|
||||
|
||||
Alternatives considered and rejected:
|
||||
|
||||
- **Switch to a SQLite build that enables `dbstat`** (system `libsqlite3` via
|
||||
`SQLitePCLRaw.bundle_sqlite3`, or a vendored native build). Rejected: the provider is shared with
|
||||
`ZB.MOM.WW.LocalDb` and every other SQLite consumer in the product, so this trades a storage-cap
|
||||
accuracy problem for fleet-wide version skew across macOS dev boxes and the Debian-based runtime
|
||||
image — a disproportionate blast radius for a coarse safety valve.
|
||||
- **Give `site_events` its own file again.** Reverses LocalDb Phase 1/2 consolidation and removes
|
||||
the table from CDC replication. Out of the question.
|
||||
- **Maintain a running byte counter in the writer, or via triggers.** The writer is not the only
|
||||
thing that inserts into `site_events` (replication applies remote rows), a counter does not
|
||||
survive a restart, and adding our own triggers to a `RegisterReplicated` table is hazardous —
|
||||
LocalDb builds its capture triggers from the column list at registration time and extra triggers
|
||||
would also fire on replicated applies.
|
||||
- **Keep the whole-file metric and simply stop re-measuring mid-loop** (compute a delete plan once
|
||||
and execute it). This makes the trim terminate, but it makes the *event log* pay for bookkeeping
|
||||
bytes: an oplog inflated by a down peer would drive aggressive deletion of operational history.
|
||||
That is consequence 2 above, made worse rather than fixed.
|
||||
|
||||
## 3. What the cap measures now
|
||||
|
||||
`EventLogPurgeService.EstimateSiteEventsSize()` returns the live row count and modelled bytes per
|
||||
row. `MaxStorageMb` is compared against `rowCount × bytesPerRow`.
|
||||
|
||||
Row width is **sampled** — `SizeSampleRows` (500) rows from each end of the table, head and tail.
|
||||
The head is what the oldest-first trim will actually delete; the tail catches an event stream whose
|
||||
shape has drifted. Widths are taken with `octet_length()` (SQLite 3.43+), not `length()`, which
|
||||
counts characters and would under-size any multi-byte UTF-8 message; a second committed test pins
|
||||
that function's availability.
|
||||
|
||||
The table term is computed as **whole pages**, `ceil(rows / rowsPerPage) × pageSize`, rather than
|
||||
as a flat bytes-per-row rate:
|
||||
|
||||
```
|
||||
usablePerPage = (pageSize - 35) * 0.78 // max-local-payload rule × measured mean page fill
|
||||
avgCellBytes = avgPayloadBytes + 16 // payload-length varint, rowid varint, cell pointer
|
||||
rowsPerPage = max(1, floor(usablePerPage / avgCellBytes))
|
||||
tableBytes = ceil(rowCount / rowsPerPage) * pageSize
|
||||
indexBytes = rowCount * (avgIndexedBytes + 8 * 4) / 0.75
|
||||
estimate = min(tableBytes + indexBytes, wholeFileLogicalBytes)
|
||||
```
|
||||
|
||||
Whole pages rather than a rate because that term **dominates once rows approach half a page**: a
|
||||
2 KB row cannot be packed two-to-a-page, so each one wastes the remainder. A linear model
|
||||
underestimates that case by ~40 % (measured), and underestimating is the failure direction that
|
||||
matters for a cap.
|
||||
|
||||
**Index pages count against the cap — deliberately.** Measured, the four indexes are ~34 % of
|
||||
`site_events`' total footprint (397.2 vs 260.9 bytes/row with and without them, same 200 000 rows),
|
||||
and the 1 GB default was sized against the 393 bytes/row figure from row 34, which already included
|
||||
them. Excluding index pages would silently raise real disk consumption by about a third at an
|
||||
unchanged setting. The index term is driven by the *sampled* widths of the four indexed columns, so
|
||||
an event stream that leaves `instance_id` null is charged less than one that fills it.
|
||||
|
||||
**Clamped by the file.** The whole-file logical size is a true upper bound on any single table's
|
||||
pages, so the model is capped by it. This can only ever correct an over-estimate downward, and it
|
||||
cannot reintroduce the row-81 defect: on a replicated node the file is *larger* than
|
||||
`site_events`, never smaller, so the clamp does not bind there.
|
||||
|
||||
`GetDatabaseSizeBytes()` survives, renamed in meaning rather than in signature: it is the whole
|
||||
consolidated file's logical size, used for the clamp and as a diagnostic. It is no longer what the
|
||||
cap compares against.
|
||||
|
||||
### 3.1 Measured accuracy
|
||||
|
||||
Calibration seeded realistic rows into a real file and measured `page_count`/`freelist_count`/
|
||||
`page_size`. Two independent cross-checks against row 34's harness: the baseline workload came out
|
||||
at **397.2 bytes/row** against row 34's independently measured **393**, and the row-count-at-1 GB
|
||||
figure follows.
|
||||
|
||||
| Workload | rows | actual bytes/row | payload bytes/row |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 1-in-10 details @200 B (baseline) | 200 000 | 397.2 | 187.9 |
|
||||
| same, **no indexes** | 200 000 | 260.9 | 187.9 |
|
||||
| no details at all | 200 000 | 373.5 | 167.7 |
|
||||
| details on every row @200 B | 200 000 | 597.7 | 367.7 |
|
||||
| details on every row @2 KB | 50 000 | 4295.2 | 2215.7 |
|
||||
| 1-in-10 details @4 KB | 50 000 | 824.7 | 583.0 |
|
||||
| small table | 5 000 | 396.5 | 189.6 |
|
||||
|
||||
The shipped model, scored against a **differential** measurement of the table's real page cost
|
||||
(file measured with ballast only, events added, growth attributed to `site_events`) — this is
|
||||
`EstimateSiteEventsSize_TracksTheRealFootprint`, run on every build:
|
||||
|
||||
| Workload | estimated | actual | error |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| narrow rows, no details | 3 155 626 | 2 912 256 | **+8.4 %** |
|
||||
| typical rows, 200 B details | 3 992 096 | 3 538 944 | **+12.8 %** |
|
||||
| wide rows, 2 KB details | 12 748 048 | 12 845 056 | **−0.8 %** |
|
||||
|
||||
The test asserts the band **−5 % … +25 %**. The bias is intentionally to the high side: a cap that
|
||||
fires slightly early forfeits a slice of the budget, whereas one that reads low lets the log
|
||||
overrun the disk allowance the cap exists to defend. Against the previous behaviour's **+250 %**
|
||||
error on a replicated node this is not a close call.
|
||||
|
||||
**Known limitation.** The model samples 1000 rows out of a table that may hold millions. An event
|
||||
stream whose row width changes sharply *in the middle* of the history — neither at the head nor the
|
||||
tail — is mis-sized until the trim reaches it. Self-correcting in practice, since the cap is
|
||||
re-evaluated every tick and the head sample advances as the oldest rows go.
|
||||
|
||||
## 4. The wipe-prevention guard, restated
|
||||
|
||||
The register asked whether the "size did not decrease" guard should survive. It should, and it
|
||||
does, but its meaning has changed and it has gained a sibling. There are now **two** backstops:
|
||||
|
||||
1. **Unusable row width → refuse to trim.** Checked *before* the cap comparison, because a zero
|
||||
bytes-per-row makes "is `site_events` over cap" unanswerable rather than false. Logs a Warning
|
||||
naming the row count and deletes nothing. The old code had no analogue: its answer to an
|
||||
unanswerable cap was to keep deleting until the file shrank, which is exactly how a cap that can
|
||||
never be met empties a table.
|
||||
|
||||
2. **Estimated size did not decrease across a batch → stop.** The old guard, re-based onto the
|
||||
table-scoped metric. Crucially it **can no longer be tripped by replication bookkeeping** — the
|
||||
metric excludes it — so when it fires it now means precisely one thing: the table is not
|
||||
shrinking even though deletes are landing, i.e. **new events are arriving at least as fast as
|
||||
the trim removes them.** That is a real operational condition and the Warning says so.
|
||||
|
||||
For this to be observable rather than tautological, the loop re-reads the **row count** from the
|
||||
database after each batch instead of decrementing a local counter, so concurrent recorder writes
|
||||
are visible. Bytes-per-row, by contrast, is sampled **once per tick and held fixed** — re-sampling
|
||||
every batch would make the estimate jitter as the trim consumes the head of the table, and a
|
||||
sample that happened to widen could stall the loop through this very guard. That is the same
|
||||
class of self-defeating measurement as row 81 itself, and it is deliberately avoided.
|
||||
|
||||
Both backstops are covered by tests that pin a degenerate estimate through `internal virtual`
|
||||
seams (`EstimateSiteEventsSize`, `GetSiteEventsRowCount`).
|
||||
|
||||
## 5. Behaviour on an UNREPLICATED node
|
||||
|
||||
Materially unchanged, as intended — but not bit-identical, and the delta is worth stating.
|
||||
|
||||
On a node with no CDC triggers there is no oplog and no tombstone table, so whole-file logical size
|
||||
was already ≈ `site_events` + the nine other (largely small) tables. The cap therefore used to fire
|
||||
at roughly the right place, and the row-34 measurement confirms it: the unreplicated trim reached
|
||||
66.98 MB against a 67.11 MB cap.
|
||||
|
||||
What changes:
|
||||
|
||||
- **Slightly more headroom.** The other nine tables no longer count against the event log's budget.
|
||||
On a rig-shaped site those tables are small relative to a 1 GB event log, so the shift is a few
|
||||
percent at most.
|
||||
- **Model error replaces measurement error.** Where the old number was exact-but-wrong-scoped, the
|
||||
new one is right-scoped but carries the +8 %/+13 % conservative bias of §3.1. Net effect on an
|
||||
unreplicated node: the cap fires marginally earlier than before at the same setting.
|
||||
|
||||
Neither warrants a config change. Sites that were happy with `MaxStorageMb: 1024` remain so.
|
||||
|
||||
## 6. Explicitly out of scope: LocalDb's own bookkeeping
|
||||
|
||||
This change makes the cap ignore `__localdb_oplog`, `__localdb_row_version` and
|
||||
`__localdb_snapshot_state`. It does **not** attempt to manage them, and should not.
|
||||
|
||||
Their size is governed by LocalDb's own machinery — `LocalDb:Replication:MaxOplogRows` (default
|
||||
1 000 000) and `TombstoneRetention` (default 7 days) — and row 34's pacing
|
||||
(`MaxPurgedRowsPerTick`) is what keeps a purge burst from blowing through the oplog cap and forcing
|
||||
a snapshot resync. Those are the correct controls and they already exist. A second, competing
|
||||
bound applied from the event log would only obscure which subsystem is responsible for a full disk.
|
||||
|
||||
The honest statement of the budget is therefore: **`MaxStorageMb` bounds `site_events` and its
|
||||
indexes; LocalDb replication bookkeeping is separate overhead on the same volume, bounded
|
||||
separately.** Sizing a site's data volume means adding the two, and the topology guide now says so.
|
||||
Row 34's measurement gives the scale — a 2.73 M-row purge left 707 MB of bookkeeping in the file
|
||||
after deleting a 1 GB event log — and that overhead is transient, draining as the peer acks and as
|
||||
tombstones expire.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
- **`dbstat` unavailability probed before any implementation was written**, on a real file, with
|
||||
the result recorded in §2 and pinned by `SqliteCapabilityTests`.
|
||||
- **Fail-before, row 81.** With `EstimateSiteEventsSize` reverted to pre-row-81 semantics (whole
|
||||
file attributed to `site_events`) and nothing else changed, **5 of the new tests fail** and all
|
||||
99 others pass. `PurgeByStorageCap_WithLargeBallastInTheSameFile_StillEnforces…` reproduces the
|
||||
original signature exactly: `remaining=2000 estimate=21725184 cap=11534336` — 4000 of 6000 rows
|
||||
destroyed and the cap **still unmet**.
|
||||
- **Fail-before, row 84.** With per-phase emission restored,
|
||||
`RunPurge_WhenBothPhasesDeleteRows_LogsTheBacklogNoteOnceForTheWholeTick` fails with
|
||||
`Assert.Single() Failure: The collection contained 2 matching items`.
|
||||
- `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — clean, 0 warnings.
|
||||
- `ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests`: **104 passed / 0 failed** (93 before this change).
|
||||
- The docker rig was not touched.
|
||||
|
||||
## 8. Register row 84, folded in
|
||||
|
||||
`LogReplicationBacklogNote` was emitted by each purge phase, so a tick in which the retention purge
|
||||
*and* the cap trim both deleted rows produced two identical Information notes. It is now emitted
|
||||
once from `RunPurge`, driven by the tick's shared `PurgeBudget`.
|
||||
|
||||
The budget is the natural source: it already holds exactly "rows this tick deleted", which is also
|
||||
the more useful number to put next to an oplog spike — the tick's whole contribution rather than
|
||||
one phase's share. `PurgeByRetention` keeps its own "Purged N events older than D days" line; only
|
||||
the replication note moved.
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Register row 52: the fleet-wide bundle-unlock throttle window.
|
||||
--
|
||||
-- Creates the central [BundleUnlockAttempt] table that both central nodes share, so
|
||||
-- ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour is a fleet cap rather than a
|
||||
-- per-node, per-process one. Operational history, not audit: non-partitioned, standard
|
||||
-- [PRIMARY] filegroup, no DB-role restriction, and rows are pruned opportunistically by
|
||||
-- the application once they age past twice the trailing window.
|
||||
--
|
||||
-- Run with QUOTED_IDENTIFIER ON. Several tables in this database carry filtered indexes
|
||||
-- and persisted computed columns, and SQL Server refuses DDL/DML against them with error
|
||||
-- 1934 when the session has QUOTED_IDENTIFIER OFF -- which is exactly what `sqlcmd` gives
|
||||
-- you by default (pass -I, or keep this header). The SETs sit in their own batch so they
|
||||
-- are in force when the statements after the GO parse.
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260815154803_AddBundleUnlockAttemptTable'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'CREATE TABLE [BundleUnlockAttempt] (
|
||||
[Id] bigint NOT NULL IDENTITY,
|
||||
[ClientKey] varchar(64) NOT NULL,
|
||||
[AttemptedAtUtc] datetime2 NOT NULL,
|
||||
CONSTRAINT [PK_BundleUnlockAttempt] PRIMARY KEY ([Id])
|
||||
)');
|
||||
|
||||
EXEC(N'CREATE INDEX [IX_BundleUnlockAttempt_Client_Attempted]
|
||||
ON [BundleUnlockAttempt] ([ClientKey], [AttemptedAtUtc])');
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260815154803_AddBundleUnlockAttemptTable'
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||
VALUES (N'20260815154803_AddBundleUnlockAttemptTable', N'10.0.7');
|
||||
END;
|
||||
|
||||
COMMIT;
|
||||
GO
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Register row 35: the deployment-list sort key gets its own index.
|
||||
--
|
||||
-- Replaces the single-column [IX_DeploymentRecords_DeployedAt] with the composite
|
||||
-- [IX_DeploymentRecords_DeployedAt_Id] ([DeployedAt] DESC, [Id] DESC), which is exactly
|
||||
-- the order DeploymentManagerRepository.QueryDeploymentListPageAsync (and
|
||||
-- QueryDeploymentSummariesAsync) asks for. With both key columns descending, a forward
|
||||
-- scan delivers the requested order, so a paged Skip/Take reads a Top-N off the index
|
||||
-- instead of sorting the whole filtered set on every page request.
|
||||
--
|
||||
-- The DROP is safe rather than merely tolerable. [DeployedAt] leads both indexes, so any
|
||||
-- predicate the old one served the composite serves too (direction does not affect
|
||||
-- seekability -- SQL Server can scan either way), and the leaf rows are the same width:
|
||||
-- [Id] is the clustered primary key and was therefore already in the old nonclustered
|
||||
-- index's leaf as the row locator. All that changes is that [Id] becomes a declared,
|
||||
-- ordered key column. Keeping both would maintain a second index on every deployment
|
||||
-- insert and status transition for no read benefit.
|
||||
--
|
||||
-- Note [SystemArtifactDeploymentRecords] is a DIFFERENT table with its own
|
||||
-- [DeployedAt] index; it is deliberately untouched.
|
||||
--
|
||||
-- DeploymentRecords is small enough that a plain (offline) index rebuild is fine. On an
|
||||
-- Enterprise edition instance where the deployment history is large and the Central UI
|
||||
-- must stay responsive, add WITH (ONLINE = ON) to the CREATE INDEX below.
|
||||
--
|
||||
-- Run with QUOTED_IDENTIFIER ON. Several tables in this database carry filtered indexes
|
||||
-- and persisted computed columns, and SQL Server refuses DDL/DML against them with error
|
||||
-- 1934 when the session has QUOTED_IDENTIFIER OFF -- which is exactly what `sqlcmd` gives
|
||||
-- you by default (pass -I, or keep this header). The SETs sit in their own batch so they
|
||||
-- are in force when the statements after the GO parse.
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260815165452_AddDeploymentRecordListSortIndex'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'DROP INDEX [IX_DeploymentRecords_DeployedAt] ON [DeploymentRecords]');
|
||||
|
||||
EXEC(N'CREATE INDEX [IX_DeploymentRecords_DeployedAt_Id]
|
||||
ON [DeploymentRecords] ([DeployedAt] DESC, [Id] DESC)');
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260815165452_AddDeploymentRecordListSortIndex'
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||
VALUES (N'20260815165452_AddDeploymentRecordListSortIndex', N'10.0.7');
|
||||
END;
|
||||
|
||||
COMMIT;
|
||||
GO
|
||||
@@ -194,8 +194,8 @@ scadabridge data-connection certs remove --site <identifier> --thumbprint <hex>
|
||||
```
|
||||
scadabridge external-system list
|
||||
scadabridge external-system get --id <id>
|
||||
scadabridge external-system create --name <name> --endpoint-url <url> --auth-type <type> [--auth-config <json>]
|
||||
scadabridge external-system update --id <id> [--name <name>] [--endpoint-url <url>] [--auth-type <type>] [--auth-config <json>]
|
||||
scadabridge external-system create --name <name> --endpoint-url <url> --auth-type <type> [--auth-config <json>] [--timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
|
||||
scadabridge external-system update --id <id> [--name <name>] [--endpoint-url <url>] [--auth-type <type>] [--auth-config <json>] [--timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
|
||||
scadabridge external-system delete --id <id>
|
||||
scadabridge external-system method list --external-system-id <id>
|
||||
scadabridge external-system method get --id <id>
|
||||
@@ -238,9 +238,26 @@ record of admin edits). `audit-log` is a deprecated alias that still works but e
|
||||
warning; migrate scripts to `audit-config`.
|
||||
|
||||
```
|
||||
scadabridge audit-config query [--user <username>] [--entity-type <type>] [--action <action>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>]
|
||||
scadabridge audit-config query [--user <username>] [--entity-type <type>] [--action <action>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>] [--detail]
|
||||
```
|
||||
|
||||
The response is an `AuditLogQueryResult` — `{ "entries": [ … ], "totalCount": <n> }`, where
|
||||
`totalCount` is the unpaged match count. The result type is a named record specifically
|
||||
because the handler must not return the repository's `(Entries, TotalCount)` ValueTuple:
|
||||
`System.Text.Json` serializes properties only, so a tuple result renders as the literal
|
||||
`{}` and every query looks empty regardless of how many rows matched. `ManagementActor.SerializeResult`
|
||||
now rejects tuple results outright so the failure mode cannot recur silently.
|
||||
|
||||
JSON is the primary format here and is never altered by the table path. `--format table`
|
||||
applies a compact projection (`AuditConfigTableProjection`) that unwraps the envelope into
|
||||
one row per entry — `id`, `timestamp`, `user`, `action`, `entityType`, `entityName`,
|
||||
`entityId` — and drops `afterStateJson`, the full post-change entity snapshot, since a
|
||||
single one fills the terminal. Without a projector the renderer took its object branch and
|
||||
printed the whole `entries` array as raw JSON inside one `Property`/`Value` cell (register
|
||||
row 57). `totalCount` survives the unwrap as a trailing `note` row, emitted only when the
|
||||
page is a strict subset of the matches. `--detail` skips the projection and renders the
|
||||
raw payload, mirroring `template list`/`template get`.
|
||||
|
||||
The new centralized Audit Log component (#23) is exposed via the `scadabridge audit` group below.
|
||||
|
||||
### Centralized Audit Commands
|
||||
|
||||
@@ -13,6 +13,12 @@ Central cluster only. Sites have no user interface.
|
||||
- **Framework**: Blazor Server (ASP.NET Core). UI logic executes on the server, updates pushed to the browser via SignalR.
|
||||
- Keeps the entire stack in C#/.NET, consistent with the rest of the system (Akka.NET, EF Core).
|
||||
- SignalR provides built-in support for real-time UI updates.
|
||||
- **Dialog service confirm labels.** Every confirmation goes through `IDialogService.ConfirmAsync(title, message, danger, confirmLabel)`, rendered by the single `DialogHost` in `MainLayout`. `danger` selects **styling only** (`btn-danger`); `confirmLabel` names the action. When no label is supplied the button falls back to "Delete" for a danger confirm and "Confirm" otherwise — a convenience for the ~20 genuine delete call sites, and the reason the Health dashboard's *Trigger failover* dialog used to offer a red **Delete** button (deferred-work register row 28). **Any destructive action that is not literally a delete must pass `confirmLabel`**: failover → *Fail over*, secured-write approve/reject → *Approve* / *Reject*, instance disable → *Disable*, and every parked-message / notification / cached-call discard → *Discard*.
|
||||
- **Dialog confirm severity — three rungs, not two** (deferred-work register row 75). `DialogSeverity` drives the confirm button's colour: `Normal` → `btn-primary`, `Caution` → `btn-warning`, `Destructive` → `btn-danger`. Only `Destructive` inherits the "Delete" label fallback above; an unlabelled `Caution` reads *Confirm*, because a caution action is by definition not a delete.
|
||||
- **Why a middle rung exists.** The boolean was a two-state flag for a three-state concept, so a consequential-but-not-destructive action had to choose between looking like a delete and looking like the safe default. The **Secured Writes** page is where that bit: *Approve* and *Reject* both rendered as identical red buttons, so at the moment of a two-person-control decision the colour carried **no discriminating information at all** — and the affirmative half of the control wore the delete affordance.
|
||||
- **The decision.** *Approve* is `Caution` (amber): approving executes a real write to live plant equipment, so it is not a benign primary action, but it is not destroying anything either. *Reject* **keeps** `Destructive` (red): it is terminal and irreversible for the request — the Operator must resubmit — and holding it red is what keeps the two opposite decisions visually distinct. Dropping *Reject* to a neutral button was considered and rejected: it would simply invert the bias, making declining look like the safe default.
|
||||
- Colour is never the only signal — the button always carries its verb (see the label contract above), so the mapping is an accessibility affordance, not the meaning.
|
||||
- **`DialogSeverity` is an additive overload**, not a replacement: the boolean form remains the signature for the ~34 existing call sites, which are genuinely two-state. Adding a fifth optional parameter to the boolean form instead would have silently un-matched every existing four-argument NSubstitute stub — the row-28 gotcha, where a stub that stops matching returns a null `Task` and the caller NREs with no compile error. Reach for the overload only when `Caution` is what you mean.
|
||||
|
||||
## Failover Behavior
|
||||
|
||||
@@ -225,7 +231,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
|
||||
- A **Secured Writes** page (`/operations/secured-writes`) drives the **two-person** authorization workflow for writes through the MxAccess Gateway: an **Operator** initiates the write, a separate **Verifier** approves it, and only an approved write reaches the site.
|
||||
- The **page itself is gated by `RequireSecuredWriteAccess`** — satisfied by any of `Operator` / `Verifier` / `Administrator`. Although the pending/history lists are read-only, they expose process-sensitive tag values, so the page is not open to every authenticated user; this aligns with the ManagementActor `ListSecuredWritesCommand` any-of gate (arch-review UA1). The submit and approve/reject sub-actions are further gated by `RequireOperator` / `RequireVerifier` respectively (below).
|
||||
- **Operator (submit)** — a submit form gated by `RequireOperator`: pick the site → an **MxGateway** connection on that site → the tag path → a typed value → an optional comment. Submission inserts a `Pending` `PendingSecuredWrite` row centrally; it does **not** write anything yet.
|
||||
- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing, and surfaces the **submission age** (`Submitted <age> ago (<UTC timestamp>)`) so a verifier can catch a stale setpoint before it reaches the device. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason. The pending queue is **unpaged** — it is bounded by the submission **TTL**: a `Pending` row that is neither approved nor rejected before the TTL elapses expires to the terminal **`Expired`** status and drops out of the queue into History.
|
||||
- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing, and surfaces the **submission age** (`Submitted <age> ago (<UTC timestamp>)`) so a verifier can catch a stale setpoint before it reaches the device. The two confirmations are deliberately styled apart: **Approve is amber (`DialogSeverity.Caution`) and Reject stays red** — see the confirm-severity contract under Technology for why (register row 75). The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason. The pending queue is **unpaged** — it is bounded by the submission **TTL**: a `Pending` row that is neither approved nor rejected before the TTL elapses expires to the terminal **`Expired`** status and drops out of the queue into History.
|
||||
- **History** — terminal rows (Executed / Failed / Rejected / **Expired**, where `Expired` is a real TTL-driven terminal status) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error). Terminal rows accumulate without bound, so the History table is **offset-paged** (page-size 50, Prev/Next) driven by the server's unpaged `TotalCount` (`ListSecuredWritesCommand` `Skip`/`Take`); the pager buttons disable at the real bounds.
|
||||
- Every lifecycle event (submit / approve / reject / execute) is written to the central Audit Log; the rows share the `PendingSecuredWrite.Id` as `CorrelationId` so they join into one operation (see Component-ManagementService.md, Component-AuditLog.md).
|
||||
- **Dev caveat**: with `DisableLogin` on, the auto-login identity holds all roles, so the two-person flow cannot be exercised end-to-end by a single user via the dev UI — no-self-approval is covered by handler tests; real two-person use requires two real identities.
|
||||
@@ -243,6 +249,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.
|
||||
- **Trigger failover** (`CentralFailoverControl`, Administrator only) — on the central card and on each site card. Disabled with an explanatory tooltip when there is no standby to take over. It always confirms first, and the confirmation's red button reads **Fail over** (see the dialog-label contract under Technology); the central variant's copy states that this page is served by the node being restarted and will disconnect and reconnect, while the site variant deliberately does not, since a site is a different cluster.
|
||||
- **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)
|
||||
|
||||
@@ -108,7 +108,7 @@ The streaming protocol is defined in `sitestream.proto` (`src/ZB.MOM.WW.ScadaBri
|
||||
|
||||
- **Service**: `SiteStreamService` — hosted on each site node by `SiteStreamGrpcServer` — exposes six RPCs. Two are real-time **server-streaming** subscriptions (`SubscribeInstance`, `SubscribeSite`); the other four are **unary request/response** calls added by the Audit Log (#23) and Site Call Audit (#22) components. These are distinct from the command/control gRPC services (`CentralControlService` on central, `SiteCommandService` on the site) — `SiteStreamService` is the streaming + audit-pull surface, not the command/control channel:
|
||||
- `SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent)` — the per-instance real-time debug stream (§6).
|
||||
- `SubscribeSite(SiteStreamRequest) returns (stream SiteStreamEvent)` — the **site-wide, alarm-only** aggregated stream (§6.1) added for the operator Alarm Summary live cache. Additive (new RPC + new `SiteStreamRequest { correlation_id }` message; no field renumbering). The server handler mirrors `SubscribeInstance` — but with **its own, larger send channel** (`GrpcSiteAlarmStreamChannelCapacity`, default 20 000, `DropOldest`; WP2.3): sharing the Debug View's 1000-slot channel meant an alarm burst during a WAN stall silently evicted operator-visible transitions to make room for diagnostics traffic. Drops are tagged `stream=site-alarms` on `scadabridge.site.stream.events_dropped`. Otherwise identical — same `GrpcMaxConcurrentStreams`/`GrpcMaxStreamLifetime` limits, `SiteConnectionOpened/Closed` telemetry, and `StreamRelayActor` mapping — but subscribes via `SiteStreamManager.SubscribeSiteAlarms` (all instances, `AlarmStateChanged` only; attribute events dropped, no per-instance filter).
|
||||
- `SubscribeSite(SiteStreamRequest) returns (stream SiteStreamEvent)` — the **site-wide, alarm-only** aggregated stream (§6.1) added for the operator Alarm Summary live cache. Additive (new RPC + new `SiteStreamRequest { correlation_id }` message; no field renumbering). The server handler mirrors `SubscribeInstance` — but with **its own, larger send channel** (`GrpcSiteAlarmStreamChannelCapacity`, default 20 000, `DropOldest`; WP2.3): sharing the Debug View's 1000-slot channel meant an alarm burst during a WAN stall silently evicted operator-visible transitions to make room for diagnostics traffic. Drops are tagged `stream=site-alarms` on `scadabridge.site.stream.events_dropped`. Otherwise identical — same `GrpcMaxConcurrentStreams`/`GrpcMaxStreamLifetime` limits, `SiteStreamSubscriberOpened/Closed` telemetry, and `StreamRelayActor` mapping — but subscribes via `SiteStreamManager.SubscribeSiteAlarms` (all instances, `AlarmStateChanged` only; attribute events dropped, no per-instance filter).
|
||||
- `IngestAuditEvents(AuditEventBatch) returns (IngestAck)` — a legacy central-side ingest surface on the *site*-hosted service; it is **dead in the shipped topology** because no site ever dials a site for ingest. The production audit-telemetry *push* path is site→central gRPC to `CentralControlService`, which routes the batch to the central `AuditLogIngestActor` proxy and returns the accepted `EventId`s.
|
||||
- `IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck)` — ingest receiving surface for the combined cached-call telemetry packet (audit row + `SiteCalls` operational upsert written in one transaction).
|
||||
- `PullAuditEvents(PullAuditEventsRequest) returns (PullAuditEventsResponse)` — central→site **reconciliation pull** for the Audit Log self-heal feed; the site serves `Pending`/`Forwarded` rows from its `ISiteAuditQueue`. **At-least-once (WP2.3):** rows are NOT retired when served. The site retires (`Reconciled`) everything at or before the cursor of the **next** pull, because that cursor is central's only proof of receipt — a fault between the response leaving the site and central committing it re-serves the rows instead of losing them (central dedups on `EventId`). The request carries an additive composite-keyset `after_id` (field 3) mirroring `PullSiteCallsRequest`; with it the read is a strict `(OccurredAtUtc, EventId)` keyset and the retirement is exact, without it the legacy inclusive `>=` read applies and only rows strictly older than the cursor instant are provably received.
|
||||
|
||||
@@ -120,9 +120,12 @@ The configuration database stores all central system data, organized by domain a
|
||||
- **Site Scoping Rules**: Per-mapping site scope restrictions for Deployment role.
|
||||
|
||||
### Deployment
|
||||
- **Deployment Records**: Deployment history per instance (timestamp, user, status, deployed configuration snapshot).
|
||||
- **Deployment Records**: Deployment history per instance (timestamp, user, status, deployed configuration snapshot). Indexes: unique on `DeploymentId` (deployment identity / idempotency), `InstanceId` (FK-backing, and the per-instance history read), and `IX_DeploymentRecords_DeployedAt_Id (DeployedAt DESC, Id DESC)` — the deployment list's exact sort key, so a paged read takes a Top-N off the index instead of sorting the whole filtered set per page. `Id` is in the key as the deterministic tie-break: a bulk site deploy stamps many rows in the same `DeployedAt` instant, and without it a row can repeat or vanish across page boundaries. This index **replaced** an earlier single-column one on `DeployedAt` (migration `AddDeploymentRecordListSortIndex`, deferred-work register row 35) rather than joining it — `DeployedAt` leads both, and `Id` is the clustered PK so it was already in the old index's leaf as the row locator, making the old index strictly redundant at identical leaf width.
|
||||
- **System-Wide Artifact Deployment Records**: Deployment history for shared artifacts (timestamp, user, artifact type, status).
|
||||
|
||||
### Transport
|
||||
- **Bundle Unlock Attempts**: One row per permitted bundle-unlock passphrase attempt (`ClientKey`, `AttemptedAtUtc`), forming the fleet-wide sliding window behind Transport's `MaxUnlockAttemptsPerIpPerHour` throttle — see `Component-Transport.md` (register row 52). Operational history, not audit: non-partitioned, no DB-role restriction, and rows are pruned opportunistically by the application once they age past twice the window. **Denied** attempts are deliberately not recorded (a denial burns no budget), and no bundle identity is stored — the durable record of a failed unlock is the `BundleImportUnlockFailed` audit row. Central-only: written only by the Transport component, which the host registers for central roles.
|
||||
|
||||
### Audit Logging
|
||||
- **Audit Log Entries**: Append-only audit trail (timestamp, user, action, entity type, entity ID, entity name, state as JSON). Stores only the after-state — change history is reconstructed by comparing consecutive entries. Entries are never modified or deleted. No retention policy — retained indefinitely. Indexed on timestamp, user, entity type, entity ID, and action for efficient filtering.
|
||||
|
||||
|
||||
@@ -355,6 +355,10 @@ Each value update delivered to an Instance Actor includes:
|
||||
|
||||
## Connection Actor Model
|
||||
|
||||
> **Where connection actors come from — the Site Runtime is the only creator (register row 85)**: the DCL manager creates and removes connection actors **only** when the Deployment Manager tells it to, via `CreateConnectionCommand` / `RemoveConnectionCommand`. It never reads storage; the site's `data_connection_definitions` table has no production reader at all, and the DCL has no independent notion of "the connections this site ought to have". A connection actor's config therefore comes from the `Connections` map inside a deployed instance's **flattened config** (pushed as part of starting that instance) or from the system-wide artifact-deploy path. `HandleCreateConnection` is idempotent on the name — it warns and returns if it already holds that connection — but the Deployment Manager is the side that keeps the traffic quiet, hash-guarding on the last config pushed per name so an unchanged connection sends nothing. Row 85 was the consequence of that ownership split being incompletely honoured on the Site Runtime side: two of the four Instance-Actor start paths omitted the connection push, so instances came up whose every tag subscribe was answered `Unknown connection: <name>` here, forever. The fix is in the Site Runtime (the ensure moved into its single creation funnel) — **not** here; giving the DCL manager its own config source would create a second, divergent authority with no arbiter when the two disagree. See `Component-SiteRuntime.md` and `docs/plans/2026-08-15-instance-sweep-self-heal-design.md` §7.
|
||||
>
|
||||
> Two known residual gaps of the same family, **not** closed by row 85's fix: `data_connection_definitions` is one of LocalDb's ten replicated tables, so a **connection-only** artifact deploy replicates to the peer node — but only the node that received the deploy pushes it to its DCL, so the peer keeps serving the old endpoint until an instance is redeployed or the node restarts. The removal side is the mirror image: a connection deleted on the peer leaves a live connection actor on this node.
|
||||
|
||||
Each data connection is managed by a dedicated connection actor that uses the Akka.NET **Become/Stash** pattern to model its lifecycle as a state machine:
|
||||
|
||||
- **Connecting**: The actor attempts to establish the connection. Subscription requests and write commands received during this phase are **stashed** (buffered in the actor's stash).
|
||||
@@ -414,7 +418,9 @@ Note: Pre-deployment validation at central does **not** verify that tag paths re
|
||||
The DCL reports the following metrics to the Health Monitoring component via the existing periodic heartbeat:
|
||||
|
||||
- **Connection status**: `connected`, `disconnected`, or `reconnecting` per data connection.
|
||||
- **Tag resolution counts**: Per connection, the number of total subscribed tags vs. successfully resolved tags. This gives operators visibility into misconfigured templates without needing to open the debug view for individual instances. Both numbers are **derived at report time from the connection actor's authoritative per-tag state**, never accumulated in counters: the total is the number of distinct tag paths at least one instance currently subscribes to (the per-tag counted set that also drives value fan-out and the last-subscriber release decision), and the resolved count is the number of tags for which the adapter currently holds a subscription handle. A tag counts toward the total from the moment an instance registers it, whatever its resolution outcome — resolved, awaiting a resolution retry, or failed at connection level — so resolved can never exceed total. Deriving rather than accumulating is deliberate: increment/decrement counters drifted whenever an unsubscribe landed inside a reconnect window (the reconnect clears the per-tag maps the decrements keyed off), leaking a phantom tag into the reported total on every churn cycle.
|
||||
- **Tag resolution counts**: Per connection, the number of total subscribed tags vs. successfully resolved tags. This gives operators visibility into misconfigured templates without needing to open the debug view for individual instances. Both numbers are **derived from the connection actor's authoritative per-tag state**, never accumulated in counters: the total is the number of distinct tag paths at least one instance currently subscribes to (the per-tag counted set that also drives value fan-out and the last-subscriber release decision), and the resolved count is the number of tags for which the adapter currently holds a subscription handle. A tag counts toward the total from the moment an instance registers it, whatever its resolution outcome — resolved, awaiting a resolution retry, or failed at connection level — so resolved can never exceed total. Deriving rather than accumulating is deliberate: increment/decrement counters drifted whenever an unsubscribe landed inside a reconnect window (the reconnect clears the per-tag maps the decrements keyed off), leaking a phantom tag into the reported total on every churn cycle.
|
||||
|
||||
The pair is **pushed to the health collector, not pulled from it**, so it is a cached value that is only as fresh as its last push — and the collector holds it until the next one. Every site that mutates either underlying set must therefore push: entering the Connected state (a legitimate 0/0 — no instance has subscribed yet), the first-subscribe apply, the batch apply (reconnect re-subscribe and resolution probe), and unsubscribe. Missing the first-subscribe push is what made a *perfectly healthy* connection report 0/0 indefinitely — the connect-time 0/0 was never superseded, because a connection whose tags all resolve on the first attempt never reaches the batch path and has its resolution-retry timer cancelled outright for having nothing unresolved. That is the reverse of the usual staleness risk, and it blinded the diagnostic exactly when it should have read clean (deferred-work register row 55; the actor's own `GetHealthReport` reply, which really is derived on demand, was correct throughout).
|
||||
- **Tag quality counters** are pushed on a genuine quality **transition** only, coalesced onto a `QualityFlushInterval` (1s) single-shot timer. Counter arithmetic still runs per message; only the collector push is deferred, and a value whose quality is unchanged moves no counter at all. Health reports poll at 30s, so the coalescing loses nothing. Three paths flush **synchronously** because their correctness depends on it: the bad-quality push on disconnect, unsubscribe, and the reconnect counter reset.
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -42,7 +42,7 @@ Each external system definition includes:
|
||||
|
||||
**Legacy colon form (deprecated):** a value that does not start with `{` is parsed with the historical colon-split rules for backward compatibility — API Key as `HeaderName:KeyValue` (or a bare `KeyValue` using the default `X-API-Key` header), Basic Auth as `username:password`. This form cannot represent keys or passwords that contain `:` and is retained only so existing rows keep working; new definitions should use the JSON form. Malformed JSON (a value that starts with `{` but does not parse, or yields no usable `key`/`username`) is **not** an exception — the request is sent without the auth header and a warning is logged (the configuration value itself is never logged).
|
||||
- **Timeout**: Per-system timeout for all method calls (e.g., 30 seconds). Applies to the HTTP request round-trip. `0` (the default) means "unset" — the gateway's configured `DefaultHttpTimeout` applies instead. Set via the Central UI External System form's "Timeout (seconds)" field; ships to sites in the deployment artifact like the other definition fields.
|
||||
- **Retry Settings**: Max retry count, fixed time between retries (used by Store-and-Forward Engine for transient failures only).
|
||||
- **Retry Settings**: Max retry count, fixed time between retries (used by Store-and-Forward Engine for transient failures only). Both are **`0` = unset**, in which case the engine's `DefaultMaxRetries` (50) and `DefaultRetryInterval` (30 s) apply — note this is the inverse of the S&F engine's own `MaxRetries` contract, where a stored `0` means *unbounded* retry; the gateway translates an unset `0` to "no per-system override" rather than passing it through. Authored via the Central UI External System form ("Max Retries" / "Retry Delay (seconds)") or `external-system create|update --max-retries --retry-delay-seconds`, and **deployed to sites in the external-system artifact** alongside the timeout, so the site's cached-call buffering honours the configured policy. (Before the row-27 fix the artifact carried no retry fields and the site table had no columns for them, so a centrally-configured policy was silently ignored and every cached call used the engine default.)
|
||||
- **Method Definitions**: List of available API methods, each with:
|
||||
- Method name.
|
||||
- **HTTP method**: GET, POST, PUT, PATCH, or DELETE.
|
||||
|
||||
@@ -34,10 +34,11 @@ Site clusters (metric collection and reporting). Central cluster (aggregation an
|
||||
| Notification Outbox queue depth | Notification Outbox (central) | Count of `Pending` + `Retrying` notifications — central-computed, not site-reported |
|
||||
| Notification Outbox stuck count | Notification Outbox (central) | Count of `Pending` / `Retrying` notifications older than the configurable stuck-age threshold — central-computed, not site-reported |
|
||||
| Notification Outbox parked count | Notification Outbox (central) | Count of `Parked` notifications — central-computed, not site-reported |
|
||||
| `SiteAuditBacklog` | Audit Log (site) | Count of `Pending` rows in the site-local `AuditLog` plus oldest-pending-age plus on-disk bytes. A configurable threshold drives a Health dashboard warning on the affected site tile. |
|
||||
| `SiteAuditBacklog` | Audit Log (site) | Count of `Pending` rows in the site-local `AuditLog` plus oldest-pending-age plus on-disk bytes. Surfaced on the Audit KPI tiles (which border-highlight any non-zero backlog) and sampled into KPI history as `auditBacklogPending`. **No configurable per-site threshold badge exists for this metric** — earlier revisions of this document described one, but none was ever implemented and no such option exists. The only configurable sustained-threshold site badge is the LocalDb replication backlog (register row 33, below). |
|
||||
| `SiteAuditWriteFailures` | Audit Log (site) | Count of failed hot-path audit appends at the site since the last health report. |
|
||||
| `AuditRedactionFailure` | Audit Log (central) | Count of payload redactor errors (over-redacted payloads, safety-net hit) since the last interval. |
|
||||
| `LocalDbReplicationConnected` | Consolidated site LocalDb | Whether a peer sync session is currently running on this node. **Nullable — null means "no reading", not "disconnected".** Reported only by a node with replication configured. |
|
||||
| `LocalDbReplicationConnected` | Consolidated site LocalDb | Whether a peer sync session is currently running on this node. **Nullable — null means "no reading", not "disconnected".** Reported only by a node with replication configured; an unconfigured node reports **null**, never `false` (register row 80 — see *Unconfigured replication reports null* below). |
|
||||
| `StartupReconcilerActive` | Host (site) | Whether this node spawned its `SiteReconciliationActor` at boot. **Tri-state:** null = not reported (the central self-report, or a pre-row-73 site build), `true` = running, `false` = the node booted with no `IDeploymentConfigFetcher` and therefore has **no startup self-heal**. Latched once at boot by the Host — not polled. Drives the *No self-heal* dashboard badge (register row 73). |
|
||||
| `LocalDbOplogBacklog` | Consolidated site LocalDb | Unacked oplog depth (Prometheus `localdb_oplog_depth`). **Nullable — null means "no reading", NOT "connected with an empty backlog"** (a failed poll rendered as 0 would report a pair that cannot read its own oplog as perfectly healthy). **Expected transient spikes:** the daily `site_events` retention purge and the storage-cap trim are CDC-captured like any other write, so the backlog jumps by the batch size at purge time and drains as the peer acks it — see `Component-SiteEventLogging.md` → Storage and `docs/deployment/topology-guide.md` → *Reading the replication backlog* for the healthy-versus-faulty signature. |
|
||||
|
||||
## Reporting Protocol
|
||||
@@ -90,7 +91,7 @@ The Audit Log spans both sites (hot-path append + telemetry forward) and central
|
||||
|
||||
- **Audit volume** — events/min landing in the central `AuditLog` table, shown global plus per-site sparkline; sourced from the Audit Log component on the active central node.
|
||||
- **Audit error rate** — percent of central `AuditLog` rows with `Status` other than `Success` / `Delivered` / `Enqueued` over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, transient failures, parked deliveries, etc.) — NOT the audit writer's own health. Audit-writer issues surface separately via `AuditRedactionFailure`.
|
||||
- **Audit backlog** — global aggregate of `SiteAuditBacklog` across reporting sites (count of `Pending` site-local audit rows, oldest pending age, on-disk bytes); click drills into a per-site breakdown. The per-site tile surfaces a warning badge when its `SiteAuditBacklog` crosses the configurable threshold.
|
||||
- **Audit backlog** — global aggregate of `SiteAuditBacklog` across reporting sites (count of `Pending` site-local audit rows, oldest pending age, on-disk bytes); click drills into a per-site breakdown. The tile border-highlights a non-zero aggregate backlog; there is **no** configurable per-site `SiteAuditBacklog` threshold badge (see the note in Monitored Metrics — earlier revisions described one that was never built).
|
||||
|
||||
These tiles are **point-in-time** like the Notification Outbox and Site Call Audit KPI tiles — no time-series store; consistent with Health Monitoring's "current status only" philosophy. The site-scoped `SiteAuditBacklog` / `SiteAuditWriteFailures` metrics arrive in the existing site health report; the central-scoped `AuditRedactionFailure` metric is central-computed alongside the existing central KPIs.
|
||||
|
||||
@@ -101,6 +102,126 @@ These tiles are **point-in-time** like the Notification Outbox and Site Call Aud
|
||||
- Notification Outbox and Site Call Audit KPIs are not stored by Health Monitoring; they are computed point-in-time from the central `Notifications` and `SiteCalls` tables respectively each time the dashboard refreshes — consistent with the current-status-only philosophy.
|
||||
- Site connectivity history (online/offline transitions) may optionally be logged via the Audit Log or a separate mechanism if needed in the future.
|
||||
|
||||
## Sustained LocalDb Replication Backlog Badge
|
||||
|
||||
The per-site tile shows a **Replication backlog** warning badge when `LocalDbOplogBacklog` has
|
||||
stayed above `LocalDbBacklogThreshold` (default **1000**) continuously for
|
||||
`LocalDbBacklogSustainedFor` (default **15 minutes**). Both are configurable under
|
||||
`ScadaBridge:HealthMonitoring` and validated at startup.
|
||||
|
||||
- **Sustained, not instantaneous — and that is the design, not a refinement.** The daily
|
||||
`site_events` retention purge and the storage-cap trim are CDC-captured like any other write, so
|
||||
the backlog jumps by the deleted batch size and drains as the peer acks it. A bare
|
||||
`backlog > threshold` badge would therefore fire on every replicated site every day. Healthy is
|
||||
defined by the backlog *returning to ~0*, which only a dwell can express. `LocalDbBacklogSustainedFor`
|
||||
is validated to be at least one `ReportInterval`: a shorter dwell could be satisfied by a single
|
||||
sample and would collapse back into the bare threshold.
|
||||
- **Implementation.** `CentralHealthAggregator` holds `SiteHealthState.LocalDbBacklogAboveSince`,
|
||||
the instant the backlog first rose above the threshold. Reports at or below the threshold clear
|
||||
it; reports above it leave the original instant intact (a sustained window, not a sliding one).
|
||||
- **Null handling is load-bearing.** A report with a `null` backlog leaves the anchor *unchanged*
|
||||
rather than clearing it — null means "no reading" (unreplicated site, or a failed poll), which is
|
||||
evidence neither of a drain nor of a backlog. An unreplicated site (rig site-b/site-c) reports
|
||||
null forever, so it never accumulates an anchor and can never be badged.
|
||||
- Offline sites are not badged; the Offline badge is the signal that matters there.
|
||||
|
||||
## KPI History Sampling of Replication State
|
||||
|
||||
`SiteHealthKpiSampleSource` samples `localDbOplogBacklog` and `localDbReplicationConnected` (0/1)
|
||||
into the KPI history store, and the backlog is charted in the Health dashboard's per-site Site
|
||||
Health Trends panel as **LocalDb Replication Backlog**. This is the retrospective half of the
|
||||
signal: a point-in-time tile can never answer "did that spike drain?", which is the only thing
|
||||
distinguishing an expected purge burst from a stalled peer.
|
||||
|
||||
Both metrics are **conditionally emitted** — a site reporting `null` writes no sample rather than a
|
||||
coerced `0`, unlike the `auditBacklogPending` metric alongside them. A fabricated 0 would render a
|
||||
pair that cannot read its own oplog, or one deliberately not replicated, as perfectly healthy once
|
||||
a minute forever. Consequently a site's sample count varies: **12 metrics for an unreplicated site,
|
||||
14 for a replicated one**, and a gap in these two series truthfully means "not replicated / not
|
||||
reporting". Note `false` and `null` are different states for the connected flag — a configured link
|
||||
that is down samples as `0`; no link at all samples as nothing. Both fold as **Gauge** in the hourly
|
||||
rollup catalog (summing an oplog depth, or a 0/1 flag, per hour would be meaningless).
|
||||
|
||||
## Unconfigured Replication Reports Null
|
||||
|
||||
The two LocalDb fields are produced by `LocalDbReplicationStatusReporter`, a 30 s site-side poll
|
||||
over `ISyncStatus`. That poll is **gated on whether the node is configured to replicate at all**,
|
||||
and an unconfigured node publishes `null` for *both* fields.
|
||||
|
||||
The gate is load-bearing because the replication engine is registered **unconditionally** on every
|
||||
site node and sits inert without a peer — so `ISyncStatus` answers `Connected = false` with a real
|
||||
`0` backlog on a site that was never meant to replicate. Passing that pair through (the behaviour
|
||||
before register row 80) had two consequences:
|
||||
|
||||
- An operator reading a healthy, deliberately-unreplicated site saw `connected: false` and read a
|
||||
fault that did not exist.
|
||||
- Every null branch downstream was dead code on the rig. The conditional KPI emission above and the
|
||||
sustained-badge anchor exclusion below both exist to handle "no reading", but rig site-b/site-c
|
||||
never sent one — they emitted a permanently misleading `localDbReplicationConnected = 0` series
|
||||
instead.
|
||||
|
||||
`false` therefore has exactly one meaning on this field: **replication is configured on this node
|
||||
and the peer session is currently down** — a genuine fault. Beware the specific trap that produced
|
||||
the bug: configured-ness is `PeerAddress` **OR** `ApiKey` (only the dialling half sets a peer
|
||||
address; the passive half carries the key alone), so "no `PeerAddress`" does **not** mean
|
||||
"unreplicated".
|
||||
|
||||
The gate value is supplied by the **Host** from `SiteLocalDbSetup.ReplicationIsConfigured`, exactly
|
||||
as `SiteEventLogReplicationCheck` is supplied to Site Event Logging. Health Monitoring never reads
|
||||
LocalDb configuration itself, and the PeerAddress-OR-ApiKey rule stays in one place.
|
||||
|
||||
## Startup Reconciler Presence
|
||||
|
||||
`SiteReconciliationActor` is the per-node startup self-heal: a standby that was DOWN during a
|
||||
deploy reports its local inventory to central on its next boot, fetches the gap and writes it
|
||||
(and, since register row 67, restarts Instance Actors the boot-time load missed). The Host skips
|
||||
it when no `IDeploymentConfigFetcher` is resolvable.
|
||||
|
||||
That skip used to be a single boot Warning and nothing else, so a node that had silently lost its
|
||||
entire self-heal path went on reporting itself perfectly healthy (register row 73). It is now
|
||||
surfaced three ways:
|
||||
|
||||
- **`StartupReconcilerActive` on the health report**, latched at boot by the Host in both branches.
|
||||
- **A boot-time `Error` log line** (raised from Warning — no configuration makes the fetcher
|
||||
legitimately absent on a site node) plus a **site event** (`deployment` / `Error`), so the
|
||||
evidence survives after the boot log has scrolled away.
|
||||
- **A `No self-heal` warning badge** on the site's Health dashboard tile.
|
||||
|
||||
Central's treatment is deliberately **Warning, not failure**. Config still reaches the node through
|
||||
LocalDb replication while it is up; what it has lost is recovery from a deploy that landed while it
|
||||
was *down*. That is degraded, not offline. Only the explicit `false` is badged — `null` means "not
|
||||
reported", and badging it would light up the whole fleet the moment central is upgraded ahead of
|
||||
the sites. Offline sites are not badged, matching the replication-backlog badge.
|
||||
|
||||
## Prometheus Metrics (site node)
|
||||
|
||||
Distinct from the health *report* above: these are scraped from the site node's `/metrics`
|
||||
endpoint (`MetricsPort`, default 8084) rather than sent to central.
|
||||
|
||||
| Series | Meaning |
|
||||
| --- | --- |
|
||||
| `scadabridge_site_data_connections_connected` | Data connections on this node in the `Connected` state |
|
||||
| `scadabridge_site_data_connections_total` | Data connections known to this node, in any state |
|
||||
| `scadabridge_site_stream_subscribers_active` | Open central→site gRPC subscriber streams |
|
||||
|
||||
- The two `data_connections` gauges (register row 54) are the **only exported data-connection
|
||||
signal**; before them the health report's `DataConnectionStatuses` was visible in the UI and
|
||||
the CLI but never as a metric. `connected < total` is the alert condition — neither number
|
||||
alone distinguishes "a device is down" from "nothing is configured". They are fed directly by
|
||||
the Data Connection Layer at each connection state transition, deliberately **not** derived
|
||||
from the health-report path, so the metric survives a dead report pipeline (the exact failure
|
||||
the *metrics stale* flag exists to catch).
|
||||
- Both read **0 on the standby node** — data connections are opened by the deployment path on
|
||||
the active node only. A standby reporting 0 is healthy; alert on the active node, or on
|
||||
`max()` across the pair.
|
||||
- `scadabridge_site_stream_subscribers_active` was renamed from `scadabridge_site_connection_up`
|
||||
by the same row. It has only ever counted open subscriber streams (Debug View + the site-wide
|
||||
alarm feed), so it correctly reads 0 on a healthy site nobody is watching — the old name
|
||||
invited reading it as data-connection health, and did cause exactly that misdiagnosis once.
|
||||
- All three ride the `ZB.MOM.WW.ScadaBridge` meter, already in the `SiteServiceRegistration.ObservedMeters`
|
||||
allowlist. `ZbTelemetryOptions.Meters` is an **allowlist**: an unlisted meter exports nothing,
|
||||
with no error and no missing-metric signal.
|
||||
|
||||
## No Alerting
|
||||
|
||||
- Health monitoring is **display-only** for now — no automated notifications or alerts are triggered by health status changes.
|
||||
@@ -113,6 +234,7 @@ These tiles are **point-in-time** like the Notification Outbox and Site Call Aud
|
||||
- **Site Runtime (site)**: Provides script error rate and alarm evaluation error rate metrics.
|
||||
- **Store-and-Forward Engine (site)**: Provides buffer depth metrics, including the notification backlog awaiting forward to central.
|
||||
- **Cluster Infrastructure (site)**: Provides node role status.
|
||||
- **Host (site)**: Supplies the two boot-time facts Health Monitoring must not derive for itself — the LocalDb replication-configured gate behind `LocalDbReplicationConnected`/`LocalDbOplogBacklog` (from `SiteLocalDbSetup.ReplicationIsConfigured`), and `StartupReconcilerActive`, latched where the Host decides whether to spawn `SiteReconciliationActor`. Both follow the `SiteEventLogReplicationCheck` precedent: the Host owns the configuration reading, the component owns the reporting.
|
||||
- **Notification Outbox (central)**: Provides central-computed outbox KPIs — queue depth, stuck count, parked count — for the headline dashboard tiles.
|
||||
- **Site Call Audit (central)**: Provides central-computed cached-call KPIs — buffered count, parked count, failed/delivered (last interval), oldest pending age, stuck count — for the headline dashboard tiles.
|
||||
- **Site Event Logging (site)**: Provides `SiteEventLogWriteFailures`. Its daily retention purge and storage-cap trim are also the expected cause of transient `LocalDbOplogBacklog` spikes on a replicated node — see [Component-SiteEventLogging.md](Component-SiteEventLogging.md) → Storage.
|
||||
@@ -122,4 +244,4 @@ These tiles are **point-in-time** like the Notification Outbox and Site Call Aud
|
||||
|
||||
- **Central UI**: Health Monitoring Dashboard displays aggregated metrics.
|
||||
- **Communication Layer**: Health reports flow as periodic messages.
|
||||
- **KPI History (#26)**: emits `IKpiSampleSource` (`SiteHealthKpiSampleSource`, per-Site) consumed by the KpiHistory recorder (#26). It reads the in-memory `ICentralHealthAggregator.GetAllSiteStates()` (no DB), turning the per-site snapshot — previously sequence-numbered every 30s but discarded — into 12 sampled `KpiSample` metrics per site, persisted every minute: `connectionsUp`/`connectionsDown`, `scriptErrors`, `alarmEvalErrors`, `sfBufferDepth`, `deadLetters`, `parkedMessages`, `deployedInstances`/`enabledInstances`/`disabledInstances`, `auditBacklogPending`, `eventLogWriteFailures`. Of these, **only four are charted** in the dashboard's per-site `KpiTrendChart` panel — `connectionsDown`, `scriptErrors`, `sfBufferDepth`, and `deadLetters` (the metrics carried in the shared `KpiMetrics.SiteHealth` Commons catalog and fetched by `Health.razor`'s trend selector). The remaining eight are **sampled and persisted but not (yet) charted** — they are retained in the `KpiSample` store and available for future trend surfaces or ad-hoc query. The synthetic central self-report (`CentralHealthReportLoop.CentralSiteId = "$central"`, fed by the leader-only report loop via `CollectReport($central)`) appears in `GetAllSiteStates()` and is **intentionally sampled into the per-Site KPI store as a real `KpiScopes.Site` series** keyed `$central`; the Central UI deliberately **pins `$central` first** in the per-site trend selector (rendered as "Central Cluster"), so the central cluster's own trends sit alongside the real sites'. (`$central`'s zero-valued connection/instance/S&F metrics reflect the central self-report carrying no site-runtime data, not a defect.) See [Component-KpiHistory.md](Component-KpiHistory.md).
|
||||
- **KPI History (#26)**: emits `IKpiSampleSource` (`SiteHealthKpiSampleSource`, per-Site) consumed by the KpiHistory recorder (#26). It reads the in-memory `ICentralHealthAggregator.GetAllSiteStates()` (no DB), turning the per-site snapshot — previously sequence-numbered every 30s but discarded — into 12–14 sampled `KpiSample` metrics per site, persisted every minute: `connectionsUp`/`connectionsDown`, `scriptErrors`, `alarmEvalErrors`, `sfBufferDepth`, `deadLetters`, `parkedMessages`, `deployedInstances`/`enabledInstances`/`disabledInstances`, `auditBacklogPending`, `eventLogWriteFailures`, plus the two conditionally-emitted LocalDb replication metrics `localDbOplogBacklog` and `localDbReplicationConnected` (register row 33 — emitted only when the site actually reports a reading, hence 12 for an unreplicated site and 14 for a replicated one; see *KPI History Sampling of Replication State* above). Of these, **only five are charted** in the dashboard's per-site `KpiTrendChart` panel — `connectionsDown`, `scriptErrors`, `sfBufferDepth`, `deadLetters`, and `localDbOplogBacklog` (the metrics carried in the shared `KpiMetrics.SiteHealth` Commons catalog and fetched by `Health.razor`'s trend selector). The rest are **sampled and persisted but not (yet) charted** — they are retained in the `KpiSample` store and available for future trend surfaces or ad-hoc query. The synthetic central self-report (`CentralHealthReportLoop.CentralSiteId = "$central"`, fed by the leader-only report loop via `CollectReport($central)`) appears in `GetAllSiteStates()` and is **intentionally sampled into the per-Site KPI store as a real `KpiScopes.Site` series** keyed `$central`; the Central UI deliberately **pins `$central` first** in the per-site trend selector (rendered as "Central Cluster"), so the central cluster's own trends sit alongside the real sites'. (`$central`'s zero-valued connection/instance/S&F metrics reflect the central self-report carrying no site-runtime data, not a defect.) See [Component-KpiHistory.md](Component-KpiHistory.md).
|
||||
|
||||
@@ -181,6 +181,72 @@ Each component library must expose its services to the Host via a consistent set
|
||||
|
||||
The Host's `Program.cs` calls these extension methods; the component libraries own the registration logic. This keeps the Host thin and each component self-contained. (The ManagementService component no longer registers the ManagementActor with ClusterClientReceptionist — that registration was removed 2026-07-22, and the whole receptionist surface went in Phase 4.)
|
||||
|
||||
### REQ-HOST-11: Dependency-Injection Lifetime Discipline
|
||||
|
||||
Akka actors are constructed by the actor system, not by the DI container, so the container cannot enforce lifetimes on them. Three rules therefore apply, and each has a mechanical guard.
|
||||
|
||||
**1. An actor may hold the root `IServiceProvider` (or an `IServiceScopeFactory`) for its lifetime — never a scope or anything resolved from one.** Every operation that needs a scoped service opens its own scope (`CreateScope` / `CreateAsyncScope`, disposed in the same handler) and resolves inside it. Caching the resolved instance in a field is a **captive dependency**: the actor keeps one EF `DbContext` alive for its whole lifetime and any concurrency the actor allows then collides on it. This is not hypothetical — it shipped as register row 45, where `NotificationOutboxActor` cached delivery adapters from an actor-lifetime scope and `MaxParallelDeliveries` concurrent deliveries drove EF's "a second operation was started on this context instance" continuously on the rig.
|
||||
|
||||
*Guard:* `CaptiveDependencyGuard` (Host test suite) reflects over every actor class in the shipped assemblies and fails on any instance field — auto-property backing fields included — typed as `IServiceScope`, as a `DbContext`, or as a service the real composition roots register `Scoped`. The scoped set is derived from the actual registrations of the built Central and Site containers rather than hand-listed, so changing a registration's lifetime automatically re-scopes the check. `IServiceProvider` / `IServiceScopeFactory` fields are explicitly permitted. Exemptions live in a small allowlist inside the guard, each carrying its safety argument, and a self-test fails any allowlist entry that has stopped matching a real field.
|
||||
|
||||
**2. Scope validation is on in Development, for both roles.** `Program.cs` applies `ValidateScopes` + `ValidateOnBuild` via `ServiceProviderValidation` in both the Central and Site branches. This is the container-visible half of the same concern — it catches a singleton whose *constructor* takes a scoped service, which is the ordinary form of the bug in non-actor code. It cannot see rule 1's hand-rolled `GetRequiredService` calls, and rule 1's guard cannot see constructor injection; neither subsumes the other. Production runs unvalidated deliberately: a validation failure is fail-closed, and a defect must not take a production node down at boot when Development and CI give the same signal.
|
||||
|
||||
**3. A registration must be constructible on every role that registers it.** A service whose dependencies exist only on the other role belongs in a role-scoped extension method (`AddCentralCommunication`, `AddAuditLogCentralKpi`), not in a shared `AddXxx()` on the argument that "only the central actor ever resolves it". An unconstructible descriptor is not inert: it fails `ValidateOnBuild` for the *entire* graph, which is what previously kept rule 2 switched off on the Site branch and would have masked any genuine defect the validator found there.
|
||||
|
||||
---
|
||||
|
||||
### Boot-time facts the Host supplies to Health Monitoring
|
||||
|
||||
Health Monitoring reports two things the Host alone knows, and in both cases the Host reads the
|
||||
configuration and hands over a captured value rather than teaching the component to read it. This
|
||||
is the `SiteEventLogReplicationCheck` pattern generalised: configuration-reading rules live in one
|
||||
place, and a component that merely *reports* a fact never grows a dependency on where it comes
|
||||
from.
|
||||
|
||||
**LocalDb replication configured-ness** (`SiteServiceRegistration`, register row 80). The
|
||||
replication engine is registered unconditionally and idles without a peer, so `ISyncStatus` answers
|
||||
`Connected = false` with a real `0` backlog on a node that was never meant to replicate. Passing
|
||||
that through made every deliberately-unreplicated site publish "disconnected, empty backlog". The
|
||||
Host now evaluates `SiteLocalDbSetup.ReplicationIsConfigured(config)` **once** and passes it into
|
||||
`AddLocalDbReplicationHealthBridge` as a third provider; an unconfigured node publishes `null` for
|
||||
both LocalDb health fields. The same captured value feeds `SiteEventLogReplicationCheck`, so the
|
||||
two surfaces can never disagree about whether this node replicates. Note the rule is `PeerAddress`
|
||||
**OR** `ApiKey` — only the dialling half sets a peer address.
|
||||
|
||||
**Startup reconciler presence** (`AkkaHostedService`, register row 73). The Host decides whether to
|
||||
spawn `SiteReconciliationActor` — it needs an `IDeploymentConfigFetcher`, and skips the actor when
|
||||
none is resolvable. That skip was previously a lone boot Warning, so a node with no self-heal path
|
||||
at all reported itself perfectly healthy. Both branches now call
|
||||
`ISiteHealthCollector.SetStartupReconcilerActive(...)`, latching the fact onto every subsequent
|
||||
health report as tri-state `StartupReconcilerActive`; the skip branch additionally logs at **Error**
|
||||
(raised from Warning — no configuration makes the fetcher legitimately absent on a site node) and
|
||||
writes a best-effort `deployment`/`Error` site event so the evidence outlives the boot log. Central
|
||||
renders it as a *No self-heal* warning badge. See
|
||||
[Component-HealthMonitoring.md](Component-HealthMonitoring.md) → *Startup Reconciler Presence*.
|
||||
|
||||
### Standby health-check log noise
|
||||
|
||||
`LoggerConfigurationFactory` installs one Serilog exclusion filter, for the by-design standby
|
||||
verdict of the `active-node` probe (register row 71).
|
||||
|
||||
`DefaultHealthCheckService` logs one line per check per probe and picks the level from the verdict —
|
||||
Debug / Warning / **Error** for Healthy / Degraded / Unhealthy. Both active-node checks
|
||||
(`SitePairActiveNodeHealthCheck` on a site, the shared `ActiveNodeHealthCheck` on central) answer
|
||||
Unhealthy on the standby, because 503 is exactly the contract `/health/active` publishes to Traefik
|
||||
and the family dashboard. A perfectly healthy standby therefore logged ERR on every probe — every
|
||||
10 s on a site node, every 5 s on a central node behind Traefik's active-node probe — which floods
|
||||
the log and trains operators to ignore ERR.
|
||||
|
||||
Serilog's `MinimumLevel.Override` can only *raise* a per-source floor and a `LogEvent`'s level is
|
||||
immutable, so neither an override nor an enricher can rewrite the event; excluding it is the only
|
||||
surgical option. The predicate matches on `Level == Error` **and** the health-check logger category
|
||||
**and** `HealthCheckName == "active-node"`. Keying on the check name rather than the category is
|
||||
what preserves the constraint: a failing `localdb`, `akka-cluster`, `database` or
|
||||
`required-singletons` check still logs at Error, and the active-node check has no error path of its
|
||||
own (the active node always answers Healthy), so the only event this can ever suppress is a standby
|
||||
reporting that it is a standby. The verdict remains fully observable through the HTTP status of
|
||||
`/health/active` itself, the readiness probe's cluster view, and the health dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Component Registration Matrix
|
||||
|
||||
@@ -143,6 +143,17 @@ Reads `ICentralHealthAggregator.GetAllSiteStates()` (in-memory, no DB). Scope: *
|
||||
- `disabledInstances`
|
||||
- `auditBacklogPending`
|
||||
- `eventLogWriteFailures`
|
||||
- `localDbOplogBacklog` — **conditionally emitted** (register row 33)
|
||||
- `localDbReplicationConnected` — **conditionally emitted** (register row 33), 0/1
|
||||
|
||||
The last two are written **only when the site actually reports a reading**. Both underlying health-report
|
||||
fields are nullable and null means "no reading" (replication not configured, or a failed poll), never
|
||||
zero — so unlike `auditBacklogPending` above, they are not coerced with `?? 0`. A site therefore emits
|
||||
**12 metrics when unreplicated and 14 when replicated**, and a gap in these two series truthfully means
|
||||
"not replicated / not reporting" rather than a flat zero line that would render an unreplicated pair, or
|
||||
one that cannot read its own oplog, as perfectly healthy. Both fold as **Gauge** in
|
||||
`KpiMetricAggregationCatalog` (they are instantaneous readings; summing an oplog depth or a 0/1 flag per
|
||||
hour would be meaningless). `localDbOplogBacklog` is charted on the Health dashboard's per-site trend panel.
|
||||
|
||||
## Query + UI
|
||||
|
||||
|
||||
@@ -93,19 +93,88 @@ Each event entry contains:
|
||||
rig's site-b/site-c), the log stays node-local and a failover does start it fresh — the
|
||||
documented trade of not configuring a peer, unchanged by this policy.
|
||||
- **Retention**: 30 days. A **daily background job** runs on the active node and deletes all events older than 30 days. Hard delete — no archival. Deletes are sliced into bounded 1000-row batches per DELETE statement rather than one unbounded statement, so a large expired backlog does not hold the write lock (and every concurrent recorder flush) for the duration of the purge.
|
||||
- **Storage cap**: A configurable maximum database size (default: 1 GB) is enforced. If the storage cap is reached before the 30-day retention window, the oldest events are purged first. This prevents disk exhaustion from alarm storms, script failure loops, or connection flapping.
|
||||
- **Storage cap**: A configurable maximum size (default: 1 GB) is enforced. If the cap is reached
|
||||
before the 30-day retention window, the oldest events are purged first. This prevents disk
|
||||
exhaustion from alarm storms, script failure loops, or connection flapping.
|
||||
- **The cap measures `site_events` and its four indexes — NOT the database file** (register row 81;
|
||||
decision record `docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md`). This matters
|
||||
because the event log shares one SQLite file with nine other tables plus LocalDb's
|
||||
`__localdb_oplog` / `__localdb_row_version` / `__localdb_snapshot_state` bookkeeping. The cap used
|
||||
to compare `MaxStorageMb` against `page_count − freelist_count` over that whole file, which on a
|
||||
replication-enabled node made the trim **unwinnable**: each 1000-row delete freed ~390 KB of event
|
||||
pages while adding ~1000 oplog rows and ~1000 tombstones to the same file, the measured size did
|
||||
not fall, and the progress guard stopped the trim — measured 2.5x over a 64 MB cap with 226 k rows
|
||||
still present. A node whose peer was down could not enforce its cap at all. Two consequences of
|
||||
the new scoping are worth internalising:
|
||||
- **The cap is now independent of replication health.** It was previously a function of the
|
||||
*peer's* liveness, and in the wrong direction — an inflated oplog is a replication fault, and
|
||||
letting it drive event deletion would destroy operational history in response to one.
|
||||
- **`MaxStorageMb` no longer bounds the data volume on its own.** LocalDb bookkeeping is separate
|
||||
overhead on the same disk, bounded separately by `LocalDb:Replication:MaxOplogRows` and
|
||||
`TombstoneRetention`; sizing a site's volume means adding the two. Managing that bookkeeping is
|
||||
deliberately **not** this component's job — see the decision record §6.
|
||||
- **The measurement is modelled, not exact.** SQLite can report per-table page usage precisely via
|
||||
the `dbstat` virtual table, and that was the intended fix — but `dbstat` is a compile-time option
|
||||
and the bundled `SQLitePCLRaw.lib.e_sqlite3` native library (SQLite 3.53.3) does **not** enable it;
|
||||
neither `dbstat` nor `sqlite_dbpage` exists, verified by probe and pinned by
|
||||
`SqliteCapabilityTests`. The table's footprint is therefore estimated from sampled row width
|
||||
(500 rows from each end of the table, measured with `octet_length` so multi-byte UTF-8 is not
|
||||
under-counted) fed into a page-layout model; index pages count against the cap, deliberately,
|
||||
because they are ~34 % of the table's real footprint and the 1 GB default was sized against a
|
||||
bytes/row figure that included them. Measured error is **−0.8 % to +12.8 %** across representative
|
||||
workloads, biased high on purpose — a cap that fires slightly early forfeits budget, whereas one
|
||||
that reads low fails at the only job it has. The estimate is additionally clamped by the
|
||||
whole-file logical size, which is a true upper bound on any one table.
|
||||
- **Two wipe-prevention backstops** guard the cap trim, replacing the single "on-disk size did not
|
||||
decrease" check. (1) If row width cannot be sampled, the trim **refuses to run** and warns —
|
||||
checked before the cap comparison, because a zero row width makes "are we over cap" unanswerable
|
||||
rather than false, and the old loop's answer to an unanswerable cap was to keep deleting. (2) If
|
||||
the *estimated table size* fails to fall across a batch, the trim stops and warns. Guard (2) is the
|
||||
old check re-based onto the table-scoped metric, and its meaning is now unambiguous: replication
|
||||
bookkeeping can no longer trip it, so it fires only when new events are arriving at least as fast
|
||||
as the trim removes them. For that to be observable, the loop re-reads the row count from the
|
||||
database after every batch, while bytes-per-row is sampled **once per tick and held fixed** —
|
||||
re-sampling per batch would make the estimate jitter as the trim eats the head of the table and
|
||||
could stall the loop through guard (2), which is the same self-defeating-measurement failure as
|
||||
row 81 itself.
|
||||
- **Per-tick delete ceiling** (`MaxPurgedRowsPerTick`, default **250 000**; `0` = unlimited): one
|
||||
purge tick deletes at most this many rows, **shared** by the retention purge and the storage-cap
|
||||
trim — two independently-capped phases would let a tick emit twice the intended burst. A tick that
|
||||
stops at the ceiling schedules the next tick after `PurgeCatchUpInterval` (default **5 minutes**)
|
||||
instead of `PurgeInterval`, so retention still converges: a worst-case backlog drains over
|
||||
~11 accelerated ticks (≈55 minutes) rather than 11 days at the daily cadence, with every
|
||||
individual burst bounded. The ceiling exists because the purge is CDC-captured (next bullet) and
|
||||
an unbounded tick could exceed LocalDb's oplog budget; measured evidence and the chosen defaults
|
||||
are in `docs/plans/2026-08-15-site-events-purge-burst-measurement.md`. A ceiling-limited tick logs
|
||||
a Warning (rate-limited to one line per 15 minutes) and increments
|
||||
`scadabridge.site.event_log.purge_ceiling_hits` — the counter is the unthrottled series that
|
||||
distinguishes a backlog converging from a site whose event rate outruns one paced tick.
|
||||
Consequence of the accelerated retry: the purge loop is a delay loop, not a `PeriodicTimer`, so
|
||||
the cadence is anchored to the **end** of the previous purge rather than to process start.
|
||||
- **Purge deletes are CDC-captured on a replicated site — the resulting backlog spike is expected.**
|
||||
Retention and cap deletes are ordinary row changes on a replicated table; there is deliberately no
|
||||
purge-exemption path (LocalDb Phase 2 — CDC does all three jobs, and a table-wide exemption is the
|
||||
same mechanism that made `ReplaceAllAsync` unsafe). One oplog row is queued per deleted event, so
|
||||
the site health report's `LocalDbOplogBacklog` (Prometheus `localdb_oplog_depth`) jumps by the size
|
||||
of the batch at purge time and drains as the peer acks it. **Healthy signature:**
|
||||
of the batch at purge time and drains as the peer acks it. **The height of that jump is bounded by
|
||||
`MaxPurgedRowsPerTick`, not by how much history expired** — measured (register row 34): a full 1 GB
|
||||
event log holds ~2.73 million rows at 393 bytes/row, and purging it unbounded queued 2 731 947
|
||||
oplog rows, **2.7x LocalDb's `MaxOplogRows` default of 1 000 000**, which would make the
|
||||
maintenance tick prune the oplog and flag a full ten-table snapshot resync. How much of that is
|
||||
*resident* depends on the peer — the oplog drains concurrently, and a loopback peer held the same
|
||||
burst to a 391 000 peak — so the overrun is conditional on peer throughput and unconditional when
|
||||
the peer is down. Bounding the tick is what makes the outcome independent of the peer. Pacing the
|
||||
tick, not exempting the purge from capture, is the fix — the exemption remains ruled out.
|
||||
**Healthy signature:**
|
||||
`LocalDbReplicationConnected` stays true across the spike and the backlog returns to ~0; a backlog
|
||||
that keeps climbing, or climbs while `LocalDbReplicationConnected` is false, is a genuine
|
||||
replication fault and not the purge. The purge logs an Information line naming the row count and
|
||||
the expected transient backlog whenever it deletes rows on a replication-enabled node, so an
|
||||
operator can correlate a spike with the purge that caused it — a spike with no such line nearby is
|
||||
not the purge. Node-local wiring: the Host supplies the replication predicate
|
||||
not the purge. That line is emitted **once per tick**, carrying the retention purge's and the cap
|
||||
trim's deletes combined (register row 84 — it used to fire once per phase, so a tick in which both
|
||||
deleted produced two identical notes; the tick total is also the more useful number to put beside
|
||||
an oplog spike). Node-local wiring: the Host supplies the replication predicate
|
||||
(`SiteLocalDbSetup.ReplicationIsConfigured` — the `PeerAddress`-OR-`ApiKey` rule) as
|
||||
`SiteEventLogReplicationCheck`; this component never reads LocalDb configuration itself, and with
|
||||
no predicate registered the note is suppressed. Operator detail —
|
||||
|
||||
@@ -96,6 +96,20 @@ flowchart TD
|
||||
|
||||
> **Startup config load is retried (S5)**: step 1's deployed-config read is best-effort against local SQLite, which can transiently fail (locked/busy). A failed load must **not** leave the site as a silent zero-instance node — it is re-attempted on a fixed interval until it succeeds, then the retry self-cancels.
|
||||
|
||||
> **Startup reconcile heals the RUNTIME, not just the config file (register row 67)**: `SiteReconciliationActor` runs on **every** site node (not a singleton) and performs one best-effort pass shortly after boot — it reports the node's local deployed inventory to central and fetches + stores any config central reports as a gap. That pass fires **after** step 1's deployed-config read has already run, so storing a config started nothing: Instance Actors are created only by the boot-time load above and by an explicit deploy/enable. An instance whose config was missing at boot (lost volume, first boot after a restore, rehomed data dir) therefore stayed **dead until a second restart**, whose load finally picked it up — silently, with nothing surfacing "config stored, actor not running". The pass now raises a fire-and-forget `EnsureInstanceStartedCommand` for every **enabled** instance it healed, routed through the **deployment-manager singleton proxy** so Instance Actors are only ever created under the singleton regardless of which node's pass found the gap. The handler is deliberately advisory: it **no-ops when the actor is already running** (both nodes legitimately raise it for the same instance), **re-reads the durable row** — which is the authority on both the config body and enabled/disabled state, so a disable landing after the fetch still wins and the instance stays stopped — **defers to any in-flight deploy/redeploy** for that instance, preserving the deploy gate's ordering guarantees and the all-or-nothing-per-instance rule (it never applies a config, only starts one already fully persisted), and treats a missing row as a quiet no-op (the normal case on an **unreplicated** site whose peer node healed its own file). A successful reconcile start is **surfaced**, not silent: a Warning log line plus an operational `deployment` site event, because reaching it means the node booted without a config for an instance that should have been running.
|
||||
|
||||
> **Instance reconcile sweep — the store and the runtime are kept in agreement, and disagreement is surfaced (register rows 78 + 72)**: the reconcile self-heal above covers configs the reconcile pass *itself* fetched. Two sibling divergences were not covered, and neither surfaced anything.
|
||||
> - **Row 78 — a config that arrives by replication starts nothing.** `deployed_configurations` is one of LocalDb's ten replicated tables, so on a replicated pair a config can appear in a node's file with **no local actor involved at all** — the CDC sync applier writes the row directly. Proven live 2026-08-15: a fresh site-a-a was seeded from its peer by snapshot replication within ~5 s of boot, i.e. **after** its boot-time load had read an empty table and **invisibly** to its own reconcile (central rightly reported no gap, because central's view of the site was correct). Enabled instances sat dead with their configs present until another restart.
|
||||
> - **Row 72 — a refreshed config does not reach the running actor.** For a *stale* `ReconcileGapItem` the pass writes the newer config via `StoreDeployedConfigIfNewerAsync`, a pure file write; the running Instance Actor keeps executing the previously compiled config until the next restart or redeploy.
|
||||
>
|
||||
> Both are closed by one **periodic sweep owned by the Deployment Manager singleton** (`ScadaBridge:SiteRuntime:InstanceSweepIntervalSeconds`, default 60, validated `>= 0`; `0` disables it). Each tick re-reads the durable rows off-thread and, on the actor thread, for every **enabled** stored config: starts the Instance Actor if none is running (row 78), or — if one is running at a revision the store has since replaced — **re-applies the stored config through the exact per-instance apply/restart path a central redeploy uses** (row 72). A sweep is chosen over a LocalDb change hook deliberately: a hook would cover only the replication arrival path, only on replicated sites, and `ILocalDb` exposes no change-notification API at all, whereas the sweep is arrival-path-agnostic (replication seeding, post-boot snapshot resync, a hand-restored file, any future path). See `docs/plans/2026-08-15-instance-sweep-self-heal-design.md`.
|
||||
>
|
||||
> The **row-72 decision is to converge, not merely warn**: the stored row *is* what central successfully deployed, so an actor serving the config central believes was replaced is the actual fault. Reusing `HandleDeploy` means the compile gate, warm-then-gate ordering, redeploy-supersede, the static-override / native-alarm reset and all-or-nothing-per-instance are the production ones — and a stop still does **not** cancel an in-flight script run, exactly as for a central redeploy.
|
||||
>
|
||||
> Guards, all shared with the row-67 handler: a **disabled** instance is never started; the sweep **defers to any deploy pipeline that owns the instance** (compile warm in flight, predecessor still terminating, deployment awaiting its persistence/init join) so it can never interleave with a real redeploy; it is **inert until boot-time Instance Actor creation has finished**, so a tick between staggered batches does not "heal" instances the boot load is about to create; it makes **one repair attempt per (instance, revision) pair**, which is what stops a periodic self-heal becoming a restart loop for an actor that dies during init or a stored config the compile gate rejects (a rejection leaves the running instance untouched — the gate returns before anything is stopped); and reads never overlap. A sweep that finds nothing is **completely silent**. A repair emits the same Warning `deployment` site-event pattern as row 67, naming the trigger (`deployment-manager sweep self-heal`) so it is distinguishable from a startup-reconcile heal, and a stale-revision restart names **both** revisions; a rejected restart adds an Error event. The synthetic redeploy replies to the singleton itself rather than to `NoSender`, because dead letters are a monitored site health metric.
|
||||
|
||||
> **Starting an Instance Actor also creates its DCL connections — the ensure lives in the creation funnel, not at call sites (register row 85)**: a DCL connection actor is **never** created from the `data_connection_definitions` table (nothing in production reads it). It exists only as a side effect of starting an instance, built from the `Connections` map inside that instance's **own flattened config** via `EnsureDclConnections` — plus the separate system-wide artifact-deploy path. That ensure used to be each *caller's* responsibility, and only two of the four callers made it (the boot-time staggered batch and `ApplyDeployment`). The two that did not were **`StartStoredInstance`** — the path shared by the row-67 ensure-started handler and the row-78 sweep — and **`HandleEnableResult`**. Both therefore produced a running instance whose every tag subscribe was answered `Unknown connection: <name>` by the DCL manager, indefinitely, because nothing else on the node would ever create that connection actor. Proven live 2026-08-16: a replication-seeded node's instances were correctly started by the sweep and then failed every subscribe for 6+ minutes until a second pair restart let the boot load create the connections. **It was never replication-specific** — the boot-time load reads only *enabled* configs, so a node that boots with an instance disabled never creates its connections either, and enabling it afterwards hit the identical dead end on an ordinary unreplicated site. `EnsureDclConnections` now sits at the top of **`CreateInstanceActor`**, already the single Instance Actor creation funnel (it is where the running revision is stamped for the same reason), so all four paths get it and a fifth cannot forget. It is placed *before* the already-exists guard so the two paths that already called it keep their exact previous semantics, and it stays silent in steady state because `EnsureDclConnection` is hash-guarded on the last config pushed per connection name. Deliberately **not** done: a sweep pass over `data_connection_definitions` — it would not have fixed row 85 (that table is empty on a site that has only ever received instance deploys), and re-hashing from it would fight the deploy path over the same per-name hash slot, recreating connection actors and dropping live subscriptions on a loop. See `docs/plans/2026-08-15-instance-sweep-self-heal-design.md` §7.
|
||||
|
||||
> **Per-instance compilation during staggered startup — CLOSED (WP3.1)**: this was a deferred optimization (P6) affecting failover time-to-recover; each Instance Actor Roslyn-compiled its own scripts inside its own start, serialising a site's whole recovery behind compilation. Each staggered startup batch now runs a **pre-warm step**: before the batch's Instance Actors are created, the Deployment Manager compiles that batch's distinct script bodies and trigger expressions on a background task and pipes `BatchCompileWarmed` back, so every `PreStart` compile is a cache hit. Instance Actor start still calls the compilation service — it remains the correctness backstop — but in every warmed path those calls are memoised lookups. Cost: one extra message per batch.
|
||||
|
||||
> **The Roslyn `ScriptOptions` are process-static, and must stay that way**: the reference set backing every site-side compile is built once per process, never per compile. `ScriptOptions.WithReferences(Assembly[])` resolves each assembly through `MetadataReference.CreateFromFile`, which does **not** cache — each call mints a fresh `MetadataReference` owning an `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock`, an unmanaged copy of the assembly metadata that nothing disposes. Building the options per compile leaks native memory permanently: no GC reclaims it, and it is invisible to gcdump and to the managed allocation counters, so the node's working set grows without the GC heap growing. This was a live defect (a Site node at 2,885 MB working set with 150 MB of live GC heap, ~2,469 MB of it on the default process heap across ~6,700 undisposed `AssemblyMetadata` instances). Note this is orthogonal to the compile cache above — the cache dedupes *identical* script bodies, so it bounds nothing when the bodies differ. (It no longer clears wholesale on overflow: WP3.1 replaced that latency cliff with approximate-LRU batch eviction, see **Compile cache eviction** below.) Pinned by a reference-equality regression test rather than by watching memory.
|
||||
@@ -124,6 +138,8 @@ flowchart TD
|
||||
- **Disable**: Stops the Instance Actor and its children. Retains the deployed configuration in SQLite so the instance can be re-enabled without redeployment.
|
||||
- **Enable**: Creates a new Instance Actor from the stored configuration (same as startup).
|
||||
- **Delete**: Stops the Instance Actor and its children, removes the deployed configuration from local SQLite. Does **not** clear store-and-forward messages.
|
||||
- **Ensure Started** (`EnsureInstanceStartedCommand`, site-internal — never sent by central): advisory, idempotent request to make sure an already-persisted, enabled instance is actually running. Raised by the startup reconcile self-heal; see the note under **Startup Behavior**. Unlike the three commands above it has **no reply** and applies no configuration — it only starts an Instance Actor for a config that is already fully stored, and no-ops when the actor is running, the stored row says disabled, the row is absent, or a deploy for that instance is in flight.
|
||||
- **Instance reconcile sweep** (internal timer, not a command): the periodic singleton-owned check that the running Instance Actors still agree with the durable config rows — starting an enabled instance that has none, and re-applying a stored config a running instance has fallen behind. See the note under **Startup Behavior**; the start half shares its implementation (and therefore its bookkeeping and site events) with **Ensure Started**, and the restart half goes through the ordinary redeploy path.
|
||||
|
||||
### Debug Snapshot Routing
|
||||
- Receives `DebugSnapshotRequest` from the Communication Layer and forwards to the Instance Actor by unique name (same lookup as `SubscribeDebugViewRequest`).
|
||||
|
||||
@@ -63,6 +63,19 @@ For the external-system-call and cached-database-write categories, retry setting
|
||||
- **External systems**: Each external system definition includes max retry count and time between retries.
|
||||
- **Cached database writes**: Each database connection definition includes max retry count and time between retries.
|
||||
|
||||
Both are carried to the site in the deployment artifact and read back out of the site's own
|
||||
tables by the gateway, which passes them to `EnqueueAsync`. An **unset** source-entity value
|
||||
(`MaxRetries` `0` / `RetryDelay` `TimeSpan.Zero`) is passed as `null`, so the engine's
|
||||
`DefaultMaxRetries` (50) and `DefaultRetryInterval` (30 s) apply — roughly 25 minutes to park.
|
||||
The translation is deliberate and must not be removed: the engine reads a stored `MaxRetries`
|
||||
of `0` as **no limit**, so forwarding an unconfigured entity's `0` verbatim would turn every
|
||||
cached call into an unbounded retry loop.
|
||||
|
||||
> Until deferred-work row 27 was fixed, this was true of database connections only.
|
||||
> `ExternalSystemArtifact` carried no retry fields and the site `external_systems` table had no
|
||||
> columns for them, so an external system's centrally-configured policy never reached the site
|
||||
> and every external-system cached call silently used the 50 x 30 s default.
|
||||
|
||||
The **notification** category retries differently: it has no source-entity setting. The site→central forward uses a single fixed retry interval configured in the host `appsettings.json`. This interval is infrastructure config for reaching the central cluster, not a per-notification-list setting. It applies uniformly to every buffered notification regardless of its target list. A buffered notification is retried at that interval until central acks it; the engine's `DefaultMaxRetries` cap still applies (matching the cached-call categories) and a notification whose retries are exhausted under a sustained central outage parks like any other buffered message. The cap is sized so the normal central-recovery window stays well inside it — central, once reachable, owns delivery, retry, and parking from the ack point on.
|
||||
|
||||
The retry interval is **fixed** (not exponential backoff). Fixed interval is sufficient for the expected use cases.
|
||||
|
||||
@@ -134,7 +134,7 @@ flowchart TD
|
||||
class T muted
|
||||
```
|
||||
|
||||
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
|
||||
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory. **The cap is observable (register row 53):** open (unexpired) sessions are published as the `scadabridge.central.transport.import_sessions.open` gauge on the `ZB.MOM.WW.ScadaBridge` meter — already in the `ObservedMeters` allowlist, so no telemetry config change is needed — and a rejection at the cap logs at Warning with the current count, the cap and the TTL. Reclaiming a store full of *expired* sessions is not a cap hit and logs nothing. **A log line is not alertable, so register row 66 adds the counter `scadabridge.central.transport.import_sessions.rejected`** on the same meter, incremented at the same single genuine-rejection point (the post-eviction recheck — never on the reclaim-then-succeed path, and never when re-opening an already-open session id). Alert on the counter; interpret it against the gauge — rejections rising while the gauge stays below the cap means genuine concurrency (raise the cap), whereas rejections rising while the gauge is pinned at the cap means a leaked session (raising the cap only defers it).
|
||||
|
||||
**Session release is explicit, not TTL-only (register row 43).** `ApplyAsync` releases the session it applied (zero-then-remove, T-007), and `IBundleImporter.Abandon(sessionId)` does the same for a session that will never be applied. Every abandonment path calls it: the Central UI wizard on new upload / Back-to-Upload / `ResetSessionState` / lockout reset / `Dispose` (navigation away or circuit teardown), the CLI `transport preview` handler (a preview never applies), and the CLI `transport import` handler for the aborts that precede apply (blocker rows, unresolved mappings). `Abandon` is idempotent — double release, an unknown id, and an already-expired id are all silent no-ops — and since session ids are random GUIDs handed only to their opener, abandoning the id you hold can never evict another operator's in-progress import. Relying on the TTL alone was an availability *and* secret-lifetime defect: eight abandoned wizards exhausted `MaxConcurrentImportSessions` and locked out every import on the active node for 30 minutes, each still holding its decrypted bundle content.
|
||||
|
||||
@@ -336,7 +336,9 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a
|
||||
|
||||
- **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly.
|
||||
- **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line.
|
||||
- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.)
|
||||
- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Both typed exceptions live in `Commons/Types/Transport/` — `ManagementService` references Commons but not Transport, so a type declared beside the importer cannot be curated by `MapFault` and reaches the CLI as a generic internal-error message (register rows 44 and 51). Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.)
|
||||
|
||||
**The per-IP window is fleet-wide and restart-durable (register row 52).** It is persisted in the central MS SQL `BundleUnlockAttempt` table — the HA store both central nodes already share — rather than in each node's process memory, so the documented cap is a fleet cap and not N × the cap, and a node restart no longer clears it. `BundleUnlockRateLimiter` still owns the policy; `SqlBundleUnlockAttemptWindow` is the store behind the `IBundleUnlockAttemptWindow` seam, and `IBundleUnlockAttemptRepository` (Commons interface, ConfigurationDatabase implementation) is the persistence. Three deliberate trades: **the throttle fails OPEN** — a store error logs a Warning and permits the attempt, because this is a best-effort rate cap over an already-authenticated Administrator-gated surface and the availability of legitimate imports outranks the cap; **accuracy is approximate** — count-then-insert is not atomic across nodes, so simultaneous attempts can overshoot by the number in flight, which is bounded and self-corrects within the window; and **rows are pruned best-effort** (CAS-claimed at most every 15 minutes per process, bounded to 1000 rows, retaining twice the window). That prune originally rode the unlock path alone, since Transport owned no central scheduler — which meant a cluster that stopped importing entirely left its last rows in place indefinitely. **Register row 65 closed that**: Transport's central-only `BundleSessionEvictionService` sweep now also ticks `IBundleUnlockAttemptWindow.PruneAsync`, sharing the one 15-minute cadence claim with the unlock path, so the table converges with no import traffic at all. `PruneAsync` never throws (the fail-open policy stays with the limiter on the attempt path).
|
||||
- **Bundle size cap** on upload (default 100 MB, configurable) to bound memory.
|
||||
- **In-transit:** existing HTTPS to the Central UI; no new channel.
|
||||
- **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary.
|
||||
|
||||
@@ -26,6 +26,40 @@ The test database uses Microsoft SQL Server 2022 Developer Edition running in Do
|
||||
|
||||
Both databases are created by `infra/mssql/setup.sql`. EF Core migrations populate the `ScadaBridgeConfig` schema. The `ScadaBridgeMachineData` database is seeded with sample tables and stored procedures by `infra/mssql/machinedata_seed.sql`.
|
||||
|
||||
### Ephemeral per-test-class databases
|
||||
|
||||
`MsSqlMigrationFixture` (`tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/`) creates a
|
||||
throwaway `ScadaBridgeAuditMigTest_<guid>` database per test class, applies the full EF migration chain
|
||||
to it, and drops it on teardown. Both `ConfigurationDatabase.Tests` and `AuditLog.Tests` use it — the
|
||||
latter by project reference, so there is one fixture implementation, not a copy. Seeing a handful of
|
||||
these databases come and go while tests run is normal; seeing them survive a run is not (see below).
|
||||
|
||||
Three behaviours are worth knowing when you look at this server during a test run:
|
||||
|
||||
- **Concurrent create+migrate is throttled machine-wide.** Fixtures take a slot from a counting
|
||||
semaphore built on `sp_getapplock` in `master` (resources `ScadaBridgeTestMigrate/0..N-1`) and hold it
|
||||
across `CREATE DATABASE` + the migration chain. The default is 4 slots; override with
|
||||
`SCADABRIDGE_MSSQL_TEST_MIGRATE_SLOTS`. An xunit collection attribute cannot do this job — a
|
||||
solution-level `dotnet test` runs several MSSQL-backed test *processes* at once, so the throttle has
|
||||
to live on the shared server. Without it, dozens of simultaneous fresh-database migrations saturated
|
||||
the container and individual DDL statements blew past EF's default 30-second command timeout,
|
||||
failing whole test classes at fixture construction (deferred-work register row 82).
|
||||
- **Migration commands use a 300s timeout**, and CREATE/DROP DATABASE use 120s. Under a bounded queue
|
||||
the server is busy but progressing, so the correct response to a slow statement is to wait.
|
||||
- **Strays are swept.** Each test process drops, once at start and best-effort, any
|
||||
`ScadaBridgeAuditMigTest_*` database older than 2 hours, so a run killed before teardown does not
|
||||
leave databases accumulating. To clear them by hand:
|
||||
|
||||
```bash
|
||||
docker exec -i scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P 'ScadaBridge_Dev1#' -C \
|
||||
-Q "SELECT name, create_date FROM sys.databases WHERE name LIKE 'ScadaBridgeAuditMigTest_%'"
|
||||
```
|
||||
|
||||
Overriding the server the fixture targets (CI, a non-default port) is done with
|
||||
`SCADABRIDGE_MSSQL_TEST_CONN`. When no server answers, the fixture reports `Available = false` and the
|
||||
tests report as **Skipped**, not passed.
|
||||
|
||||
## Data Persistence
|
||||
|
||||
SQL data is stored in the named Docker volume `scadabridge-mssql-data`. Data survives container restarts and `docker compose down`. To reset the database completely:
|
||||
|
||||
@@ -42,13 +42,14 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
/// placeholder.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two constructors exist for a deliberate reason: the test ctor injects a
|
||||
/// concrete <see cref="IAuditLogRepository"/> against a per-test MSSQL fixture
|
||||
/// (the only way to verify the ingest-timestamp stamp + duplicate-key
|
||||
/// idempotency end to end), while the production host wiring registers the
|
||||
/// actor as a cluster singleton and must therefore resolve the repository —
|
||||
/// which is a scoped EF Core service — from a fresh DI scope per message.
|
||||
/// Mirroring the Notification Outbox actor's pattern.
|
||||
/// There is exactly ONE constructor, taking an <see cref="IServiceProvider"/>:
|
||||
/// the actor is a cluster singleton and the repository is a scoped EF Core
|
||||
/// service, so it must be resolved from a fresh DI scope per message and never
|
||||
/// held on a field. Mirroring the Notification Outbox actor's pattern. Tests
|
||||
/// that need a concrete repository (e.g. the MSSQL-backed fixture verifying the
|
||||
/// ingest-timestamp stamp and duplicate-key idempotency) supply a stub provider
|
||||
/// that hands one out, rather than a second constructor that would cache it —
|
||||
/// see register row 61.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class AuditLogIngestActor : ReceiveActor
|
||||
@@ -100,41 +101,25 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
/// </remarks>
|
||||
internal static readonly TimeSpan IngestFallbackBudget = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IServiceProvider? _serviceProvider;
|
||||
private readonly IAuditLogRepository? _injectedRepository;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AuditLogIngestActor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Test-mode constructor — injects a concrete repository instance whose
|
||||
/// lifetime exceeds the test, so the actor reuses the same instance across
|
||||
/// every message. Used by the MSSQL-backed TestKit fixture.
|
||||
/// </summary>
|
||||
/// <param name="repository">Audit log repository instance shared across all messages.</param>
|
||||
/// <param name="logger">Logger for ingest diagnostics.</param>
|
||||
public AuditLogIngestActor(
|
||||
IAuditLogRepository repository,
|
||||
ILogger<AuditLogIngestActor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_injectedRepository = repository;
|
||||
_logger = logger;
|
||||
|
||||
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
|
||||
// The single-repository test ctor cannot service the dual-write —
|
||||
// it has no SiteCalls repo and no DbContext. The handler still
|
||||
// registers (so callers don't dead-letter) but replies empty so the
|
||||
// test surface stays explicit about what this ctor supports.
|
||||
ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryWithoutDualWriteAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from
|
||||
/// The only constructor — resolves <see cref="IAuditLogRepository"/> from
|
||||
/// a fresh DI scope per message because the repository is a scoped EF Core
|
||||
/// service registered by <c>AddConfigurationDatabase</c>. The actor itself
|
||||
/// is a long-lived cluster singleton, so it cannot hold a scope across
|
||||
/// messages.
|
||||
///
|
||||
/// <para>
|
||||
/// Register row 61: a second, repository-taking "test-mode" constructor used
|
||||
/// to cache a scoped repository on a field for the actor's whole lifetime.
|
||||
/// Nothing in production selected it, but it left the captive-dependency bug
|
||||
/// one call site away and had to be allowlisted in the Host tests'
|
||||
/// <c>CaptiveDependencyGuard</c>. It is gone; test fixtures build a stub
|
||||
/// <see cref="IServiceProvider"/> instead, which keeps the actor's own
|
||||
/// contract "resolve per scope, hold nothing".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">Root service provider used to open a fresh scope per message.</param>
|
||||
/// <param name="logger">Logger for ingest diagnostics.</param>
|
||||
@@ -169,59 +154,51 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
var accepted = new List<Guid>(cmd.Events.Count);
|
||||
|
||||
// Resolve the repository for the whole batch — one DbContext per
|
||||
// message, mirroring NotificationOutboxActor. The injected-repository
|
||||
// mode (test ctor) skips the scope entirely.
|
||||
// message, mirroring NotificationOutboxActor.
|
||||
// The IAuditRedactor is also resolved from the per-message scope when
|
||||
// one is available so the row is truncated + redacted before
|
||||
// InsertIfNotExistsAsync. The single-repository test ctor has no
|
||||
// service provider — it falls through with no redactor, which preserves
|
||||
// the small-payload assumptions baked into the existing fixtures.
|
||||
// InsertIfNotExistsAsync. A composition root that registers no redactor
|
||||
// (test fixtures) falls back to the SafeDefault inside
|
||||
// IngestWithRepositoryAsync rather than to pass-through.
|
||||
// Use CreateAsyncScope + await using so scoped EF Core
|
||||
// services (IAsyncDisposable DbContexts) dispose asynchronously
|
||||
// without blocking on sync Dispose() of pending connection cleanup.
|
||||
if (_injectedRepository is not null)
|
||||
//
|
||||
// Guard scope-creation + repository resolution in a
|
||||
// try/catch, mirroring OnCachedTelemetryAsync. A transient DI /
|
||||
// DbContext-factory fault (pooled-context init, SQL-connection
|
||||
// exhaustion, a resolution race during host churn) would otherwise
|
||||
// propagate out of the ReceiveAsync handler, trip the parent's
|
||||
// supervision, and RESTART this central singleton over a transient
|
||||
// fault — dropping the captured reply so the site's Ask times out.
|
||||
// Best-effort audit must never wedge the singleton: log, optionally
|
||||
// bump the failure counter, and still reply with whatever was
|
||||
// accepted (empty on an up-front scope-resolution throw) so the
|
||||
// site keeps its rows Pending and retries on the next drain.
|
||||
try
|
||||
{
|
||||
await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||
// Central health counter is best-effort —
|
||||
// unregistered (test composition roots) means the per-row catch
|
||||
// simply logs without surfacing on the health dashboard.
|
||||
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
|
||||
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Guard scope-creation + repository resolution in a
|
||||
// try/catch, mirroring OnCachedTelemetryAsync. A transient DI /
|
||||
// DbContext-factory fault (pooled-context init, SQL-connection
|
||||
// exhaustion, a resolution race during host churn) would otherwise
|
||||
// propagate out of the ReceiveAsync handler, trip the parent's
|
||||
// supervision, and RESTART this central singleton over a transient
|
||||
// fault — dropping the captured reply so the site's Ask times out.
|
||||
// Best-effort audit must never wedge the singleton: log, optionally
|
||||
// bump the failure counter, and still reply with whatever was
|
||||
// accepted (empty on an up-front scope-resolution throw) so the
|
||||
// site keeps its rows Pending and retries on the next drain.
|
||||
try
|
||||
{
|
||||
await using var scope = _serviceProvider!.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||
// Central health counter is best-effort —
|
||||
// unregistered (test composition roots) means the per-row catch
|
||||
// simply logs without surfacing on the health dashboard.
|
||||
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
|
||||
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Scope creation or a required-service resolution threw before
|
||||
// (or while) processing the batch. Surface a sustained fault on
|
||||
// the dashboard if the counter is registered, but never let the
|
||||
// throw escape the handler and restart the singleton.
|
||||
try { _serviceProvider!.GetService<ICentralAuditWriteFailureCounter>()?.Increment(); }
|
||||
catch { /* counter must never throw — defence in depth */ }
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Audit event batch ingest failed before/while resolving the repository scope; replying with {Accepted} accepted row(s). The site keeps unaccepted rows Pending and retries on the next drain.",
|
||||
accepted.Count);
|
||||
}
|
||||
// Scope creation or a required-service resolution threw before
|
||||
// (or while) processing the batch. Surface a sustained fault on
|
||||
// the dashboard if the counter is registered, but never let the
|
||||
// throw escape the handler and restart the singleton.
|
||||
try { _serviceProvider.GetService<ICentralAuditWriteFailureCounter>()?.Increment(); }
|
||||
catch { /* counter must never throw — defence in depth */ }
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Audit event batch ingest failed before/while resolving the repository scope; replying with {Accepted} accepted row(s). The site keeps unaccepted rows Pending and retries on the next drain.",
|
||||
accepted.Count);
|
||||
}
|
||||
|
||||
replyTo.Tell(new IngestAuditEventsReply(accepted));
|
||||
@@ -357,7 +334,7 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _serviceProvider!.CreateAsyncScope();
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
@@ -544,18 +521,4 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback handler installed on the single-repository test ctor — that
|
||||
/// ctor has no DbContext and no <see cref="ISiteCallAuditRepository"/>, so
|
||||
/// it cannot service the dual-write. Logs a warning and replies with an
|
||||
/// empty ack so callers fall through to their retry path.
|
||||
/// </summary>
|
||||
private Task OnCachedTelemetryWithoutDualWriteAsync(IngestCachedTelemetryCommand cmd)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AuditLogIngestActor received IngestCachedTelemetryCommand on the single-repository ctor; dual-write requires the IServiceProvider ctor. Replying with empty ack ({Count} entries).",
|
||||
cmd.Entries.Count);
|
||||
Sender.Tell(new IngestCachedTelemetryReply(Array.Empty<Guid>()));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,15 +248,38 @@ public static class ServiceCollectionExtensions
|
||||
// provider as a singleton on both site and central paths.
|
||||
sp.GetRequiredService<INodeIdentityProvider>()));
|
||||
|
||||
// KPI History & Trends: the AuditLog source the central KPI
|
||||
// recorder enumerates each sampling pass to snapshot Global-scope Audit
|
||||
// Log KPIs (volume / error rate / backlog) into the KpiSample history
|
||||
// table. Scoped to match its IAuditLogRepository dependency — a SCOPED
|
||||
// EF Core service; the recorder opens a fresh scope per sampling pass.
|
||||
// TryAdd-Enumerable so multiple sources can register the same interface
|
||||
// (NotificationOutbox + SiteCallAudit + SiteHealth add their own) without
|
||||
// any one composition root clobbering another, and re-registration is a
|
||||
// no-op if AddAuditLog were ever called twice.
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Audit Log's KPI sample source. CENTRAL-ONLY — call from the Central
|
||||
/// composition root only, alongside <c>AddKpiHistory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// KPI History & Trends: the AuditLog source the central KPI recorder enumerates each
|
||||
/// sampling pass to snapshot Global-scope Audit Log KPIs (volume / error rate / backlog)
|
||||
/// into the <c>KpiSample</c> history table. Scoped to match its <c>IAuditLogRepository</c>
|
||||
/// dependency — a SCOPED EF Core service; the recorder opens a fresh scope per sampling
|
||||
/// pass. TryAdd-Enumerable so multiple sources can register the same interface
|
||||
/// (NotificationOutbox + SiteCallAudit + SiteHealth add their own) without any one
|
||||
/// composition root clobbering another, and re-registration is a no-op if this were ever
|
||||
/// called twice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Split out of <see cref="AddAuditLog"/> by deferred register row 50. Both the KPI
|
||||
/// recorder and <c>IAuditLogRepository</c> are central-only, so on a site node this
|
||||
/// registration was unconstructible — enough on its own to fail <c>ValidateOnBuild</c> for
|
||||
/// the entire site graph and so to keep scope validation switched off there. The site half
|
||||
/// of <see cref="AddAuditLog"/> (the SQLite writer chain) is unaffected and stays shared.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The DI service collection to register services into.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> to allow chaining.</returns>
|
||||
public static IServiceCollection AddAuditLogCentralKpi(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Scoped<IKpiSampleSource, AuditLogKpiSampleSource>());
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Compact table projection for <c>audit-config query</c> (deferred-work register row 57).
|
||||
///
|
||||
/// <para>
|
||||
/// The handler returns an <c>AuditLogQueryResult</c> — <c>{ "entries": [ … ],
|
||||
/// "totalCount": N }</c>. Handed to the generic renderer unprojected, that object takes
|
||||
/// the Property/Value branch and the whole <c>entries</c> array lands in a single cell as
|
||||
/// raw JSON, so <c>--format table</c> was unreadable. This unwraps the envelope to the
|
||||
/// row array the renderer expects and drops <c>afterStateJson</c>, the per-entry snapshot
|
||||
/// of the entire changed entity — the same "one fat field makes the table useless" problem
|
||||
/// <see cref="TemplateTableProjection"/> solves by dropping child collections.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>totalCount</c> would be lost by a plain unwrap, so when the page is a strict subset
|
||||
/// of the matches a single trailing row carries a <c>note</c> cell saying how many of how
|
||||
/// many are shown. Every real entry leaves that cell empty, and when the page IS the whole
|
||||
/// result set the column is not emitted at all — so the common small-query table stays
|
||||
/// exactly as narrow as its data.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// JSON output is never touched (this runs only on the table path), and the command's
|
||||
/// <c>--detail</c> flag skips the projection to render the raw payload as-is — that is
|
||||
/// the way to see <c>afterStateJson</c> in table form.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class AuditConfigTableProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects an <c>AuditLogQueryResult</c> envelope (or a bare entry array) to the
|
||||
/// compact row array the table renderer consumes. Returns the input unchanged when it
|
||||
/// is not JSON or not one of those shapes, so the renderer's own fallbacks still apply.
|
||||
/// </summary>
|
||||
/// <param name="json">The raw success JSON body from the management API.</param>
|
||||
/// <returns>Compact JSON suitable for table rendering.</returns>
|
||||
internal static string ProjectEntries(string json)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not JSON (e.g. a proxy error page) — let the renderer print it verbatim.
|
||||
return json;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
|
||||
// A bare array is accepted so the projection still works if the payload is
|
||||
// ever unwrapped upstream, and so the deprecated `audit-log` alias against an
|
||||
// older server does not render worse than it does today.
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return BuildRows(root, totalCount: null).ToJsonString();
|
||||
}
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& TryGetPropertyCI(root, "entries", out var entries)
|
||||
&& entries.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int? total = TryGetPropertyCI(root, "totalCount", out var tc)
|
||||
&& tc.ValueKind == JsonValueKind.Number
|
||||
&& tc.TryGetInt32(out var n)
|
||||
? n
|
||||
: null;
|
||||
return BuildRows(entries, total).ToJsonString();
|
||||
}
|
||||
|
||||
// Some other object (e.g. an error envelope) — leave it to the renderer.
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonArray BuildRows(JsonElement entries, int? totalCount)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
var shown = 0;
|
||||
foreach (var item in entries.EnumerateArray())
|
||||
{
|
||||
arr.Add(ProjectElement(item));
|
||||
shown++;
|
||||
}
|
||||
|
||||
// Only when the page is a strict subset — otherwise the count is just the row
|
||||
// count the operator can already see, and the extra column would be noise.
|
||||
if (totalCount is int total && total > shown)
|
||||
{
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["note"] = JsonValue.Create(
|
||||
$"showing {shown} of {total} — use --page / --page-size for the rest"),
|
||||
});
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/// <summary>Projects a single audit entry to its compact row node.</summary>
|
||||
private static JsonNode ProjectElement(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
return JsonValue.Create(element.ToString())!;
|
||||
|
||||
// JsonObject preserves insertion order, which fixes the table's column order.
|
||||
// afterStateJson (the full post-change entity snapshot) and bundleImportId are
|
||||
// deliberately omitted — `--detail`, or --format json, for those.
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = Int(element, "id"),
|
||||
["timestamp"] = Str(element, "timestamp"),
|
||||
["user"] = Str(element, "user"),
|
||||
["action"] = Str(element, "action"),
|
||||
["entityType"] = Str(element, "entityType"),
|
||||
["entityName"] = Str(element, "entityName"),
|
||||
["entityId"] = Str(element, "entityId"),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JsonNode? Str(JsonElement obj, string name)
|
||||
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? JsonValue.Create(v.GetString())
|
||||
: null;
|
||||
|
||||
private static JsonNode? Int(JsonElement obj, string name)
|
||||
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)
|
||||
? JsonValue.Create(n)
|
||||
: null;
|
||||
}
|
||||
@@ -75,6 +75,13 @@ public static class AuditLogCommands
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
var detailOption = new Option<bool>("--detail")
|
||||
{
|
||||
Description = "Render the raw query payload in table output instead of the compact column projection. "
|
||||
+ "This is how to see 'afterStateJson' (the full post-change entity snapshot) in table form — it is "
|
||||
+ "omitted from the compact columns because a single entry's snapshot fills the terminal. "
|
||||
+ "No effect on JSON output."
|
||||
};
|
||||
|
||||
var cmd = new Command("query") { Description = "Query audit log entries" };
|
||||
cmd.Add(userOption);
|
||||
@@ -84,6 +91,7 @@ public static class AuditLogCommands
|
||||
cmd.Add(toOption);
|
||||
cmd.Add(pageOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.Add(detailOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var user = result.GetValue(userOption);
|
||||
@@ -93,9 +101,11 @@ public static class AuditLogCommands
|
||||
var to = result.GetValue(toOption);
|
||||
var page = result.GetValue(pageOption);
|
||||
var pageSize = result.GetValue(pageSizeOption);
|
||||
var detail = result.GetValue(detailOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize));
|
||||
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize),
|
||||
tableProjector: detail ? null : AuditConfigTableProjection.ProjectEntries);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ public static class ExternalSystemCommands
|
||||
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type", Required = true };
|
||||
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
|
||||
var timeoutOption = new Option<int?>("--timeout-seconds") { Description = "Per-system HTTP timeout in seconds (0 = use gateway default); omit to preserve" };
|
||||
var maxRetriesOption = new Option<int?>("--max-retries") { Description = "Store-and-forward retry budget for cached calls (0 = use engine default); omit to preserve" };
|
||||
var retryDelayOption = new Option<int?>("--retry-delay-seconds") { Description = "Fixed interval between store-and-forward retries, in seconds (0 = use engine default); omit to preserve" };
|
||||
retryDelayOption.Aliases.Add("--retry-delay");
|
||||
|
||||
var cmd = new Command("update") { Description = "Update an external system" };
|
||||
cmd.Add(idOption);
|
||||
@@ -58,6 +61,8 @@ public static class ExternalSystemCommands
|
||||
cmd.Add(authTypeOption);
|
||||
cmd.Add(authConfigOption);
|
||||
cmd.Add(timeoutOption);
|
||||
cmd.Add(maxRetriesOption);
|
||||
cmd.Add(retryDelayOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
@@ -66,9 +71,13 @@ public static class ExternalSystemCommands
|
||||
var authType = result.GetValue(authTypeOption)!;
|
||||
var authConfig = result.GetValue(authConfigOption);
|
||||
var timeoutSeconds = result.GetValue(timeoutOption);
|
||||
var maxRetries = result.GetValue(maxRetriesOption);
|
||||
var retryDelaySeconds = result.GetValue(retryDelayOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateExternalSystemCommand(id, name, endpointUrl, authType, authConfig, timeoutSeconds));
|
||||
new UpdateExternalSystemCommand(id, name, endpointUrl, authType, authConfig, timeoutSeconds,
|
||||
maxRetries,
|
||||
retryDelaySeconds is { } rd ? TimeSpan.FromSeconds(rd) : null));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
@@ -91,6 +100,9 @@ public static class ExternalSystemCommands
|
||||
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type (ApiKey, BasicAuth)", Required = true };
|
||||
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
|
||||
var timeoutOption = new Option<int?>("--timeout-seconds") { Description = "Per-system HTTP timeout in seconds (0 = use gateway default)" };
|
||||
var maxRetriesOption = new Option<int?>("--max-retries") { Description = "Store-and-forward retry budget for cached calls (0 = use engine default)" };
|
||||
var retryDelayOption = new Option<int?>("--retry-delay-seconds") { Description = "Fixed interval between store-and-forward retries, in seconds (0 = use engine default)" };
|
||||
retryDelayOption.Aliases.Add("--retry-delay");
|
||||
|
||||
var cmd = new Command("create") { Description = "Create an external system" };
|
||||
cmd.Add(nameOption);
|
||||
@@ -98,6 +110,8 @@ public static class ExternalSystemCommands
|
||||
cmd.Add(authTypeOption);
|
||||
cmd.Add(authConfigOption);
|
||||
cmd.Add(timeoutOption);
|
||||
cmd.Add(maxRetriesOption);
|
||||
cmd.Add(retryDelayOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
@@ -105,9 +119,13 @@ public static class ExternalSystemCommands
|
||||
var authType = result.GetValue(authTypeOption)!;
|
||||
var authConfig = result.GetValue(authConfigOption);
|
||||
var timeoutSeconds = result.GetValue(timeoutOption);
|
||||
var maxRetries = result.GetValue(maxRetriesOption);
|
||||
var retryDelaySeconds = result.GetValue(retryDelayOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateExternalSystemCommand(name, endpointUrl, authType, authConfig, timeoutSeconds));
|
||||
new CreateExternalSystemCommand(name, endpointUrl, authType, authConfig, timeoutSeconds,
|
||||
maxRetries,
|
||||
retryDelaySeconds is { } rd ? TimeSpan.FromSeconds(rd) : null));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -1206,7 +1206,7 @@ scadabridge --url <url> external-system list
|
||||
Register an external HTTP system that scripts can call.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> external-system create --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>]
|
||||
scadabridge --url <url> external-system create --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
@@ -1215,6 +1215,13 @@ scadabridge --url <url> external-system create --name <string> --endpoint-url <u
|
||||
| `--endpoint-url` | yes | Base URL of the external system |
|
||||
| `--auth-type` | yes | Authentication type: `ApiKey` or `BasicAuth` |
|
||||
| `--auth-config` | no | Auth credentials as a JSON string |
|
||||
| `--timeout-seconds` | no | Per-system HTTP round-trip timeout. `0` (default) = use the gateway's `DefaultHttpTimeout` |
|
||||
| `--max-retries` | no | Store-and-forward retry budget for this system's `CachedCall`s. `0` (default) = use the engine's `DefaultMaxRetries` (50) |
|
||||
| `--retry-delay-seconds` | no | Fixed interval between store-and-forward retries. `0` (default) = use the engine's `DefaultRetryInterval` (30 s). Alias: `--retry-delay` |
|
||||
|
||||
The retry options are deployed to sites with the rest of the definition, so they take
|
||||
effect on the next deployment — they govern how long a transient outage of this system is
|
||||
buffered before a cached call parks (`--max-retries` x `--retry-delay-seconds`).
|
||||
|
||||
#### `external-system update`
|
||||
|
||||
@@ -1222,7 +1229,7 @@ Update an external system definition. An update **replaces** the whole entity
|
||||
required field below must be supplied, even if unchanged.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> external-system update --id <int> --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>]
|
||||
scadabridge --url <url> external-system update --id <int> --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
@@ -1231,7 +1238,14 @@ scadabridge --url <url> external-system update --id <int> --name <string> --endp
|
||||
| `--name` | yes | Display name |
|
||||
| `--endpoint-url` | yes | Base URL |
|
||||
| `--auth-type` | yes | Authentication type |
|
||||
| `--auth-config` | no | Updated auth credentials as a JSON string |
|
||||
| `--auth-config` | no | Updated auth credentials as a JSON string; omit to preserve the stored value |
|
||||
| `--timeout-seconds` | no | Per-system HTTP round-trip timeout; omit to preserve |
|
||||
| `--max-retries` | no | Store-and-forward retry budget for this system's `CachedCall`s; omit to preserve |
|
||||
| `--retry-delay-seconds` | no | Fixed interval between store-and-forward retries; omit to preserve. Alias: `--retry-delay` |
|
||||
|
||||
Note the asymmetry with the required fields above: the four optional settings follow an
|
||||
**omit-to-preserve** rule, so a rename does not silently reset a system's auth config,
|
||||
timeout, or retry policy.
|
||||
|
||||
#### `external-system delete`
|
||||
|
||||
@@ -1801,6 +1815,66 @@ scadabridge --url <url> audit-config query [options]
|
||||
| `--to` | no | — | End timestamp in ISO 8601 format |
|
||||
| `--page` | no | `1` | Page number |
|
||||
| `--page-size` | no | `50` | Results per page |
|
||||
| `--detail` | no | `false` | Table output only: render the raw payload instead of the compact column projection (see below). No effect on JSON output. |
|
||||
|
||||
Output shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": 412,
|
||||
"user": "multi-role",
|
||||
"action": "Created",
|
||||
"entityType": "Template",
|
||||
"entityId": "7",
|
||||
"entityName": "PumpTemplate",
|
||||
"afterStateJson": "{\"Id\":7,\"Name\":\"PumpTemplate\",\"FolderId\":3}",
|
||||
"timestamp": "2026-08-15T12:00:00+00:00",
|
||||
"bundleImportId": null
|
||||
},
|
||||
{
|
||||
"id": 413,
|
||||
"user": "designer",
|
||||
"action": "Deleted",
|
||||
"entityType": "ExternalSystem",
|
||||
"entityId": "3",
|
||||
"entityName": "MES",
|
||||
"afterStateJson": null,
|
||||
"timestamp": "2026-08-15T12:05:00+00:00",
|
||||
"bundleImportId": "9f2c1e40-5b31-4a7e-8d66-0c1a4b93ff21"
|
||||
}
|
||||
],
|
||||
"totalCount": 137
|
||||
}
|
||||
```
|
||||
|
||||
Both nullable fields are always present on the wire. `afterStateJson` is a JSON snapshot
|
||||
of the entity's state *after* the change, embedded as a string — it is `null` for deletes,
|
||||
since there is no after-state. `bundleImportId` is the import session id when the change
|
||||
was made by a bundle import (`transport import`), and `null` for a change made directly;
|
||||
it is the join key for "what did this bundle actually change".
|
||||
|
||||
`totalCount` is the unpaged number of rows matching the filters, so a client can page
|
||||
without a second call. JSON is the primary format for this command and is what scripts
|
||||
should consume — it is never altered by the table projection below.
|
||||
|
||||
**Table output.** `--format table` renders a compact projection with one row per entry:
|
||||
|
||||
```
|
||||
id timestamp user action entityType entityName entityId
|
||||
412 2026-08-15T12:00:00+00:00 multi-role Created Template PumpTemplate 7
|
||||
413 2026-08-15T12:05:00+00:00 designer Deleted ExternalSystem MES 3
|
||||
```
|
||||
|
||||
`afterStateJson` (the full post-change entity snapshot) and `bundleImportId` are omitted
|
||||
— one snapshot per row fills a terminal. Pass `--detail` to render the raw payload
|
||||
instead, or use `--format json`. When the page is a strict subset of the matches, a final
|
||||
row carries a `note` cell (`showing 50 of 137 — use --page / --page-size for the rest`);
|
||||
when the page is the whole result set that column is not emitted at all.
|
||||
|
||||
Before this projection existed the whole `entries` array was printed as raw JSON inside a
|
||||
single `Property`/`Value` cell (deferred-work register row 57).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -70,14 +70,16 @@
|
||||
+ "standby takes over and its Deployment Manager singleton hands over gracefully. "
|
||||
+ "In-flight work on that site node is interrupted, and the site is briefly "
|
||||
+ "unavailable while the handover completes.",
|
||||
danger: true)
|
||||
danger: true,
|
||||
confirmLabel: "Fail over")
|
||||
: await Dialog.ConfirmAsync(
|
||||
"Trigger central failover?",
|
||||
"The active central node will leave the cluster and restart; the standby takes over "
|
||||
+ "and becomes active. Cluster singletons hand over gracefully, but in-flight work on "
|
||||
+ "the active node is interrupted. This page is served by the active node, so it will "
|
||||
+ "briefly disconnect and reconnect against the new active node.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Fail over");
|
||||
|
||||
if (!confirmed)
|
||||
{
|
||||
|
||||
@@ -868,7 +868,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Disable Instance",
|
||||
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Disable");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
@@ -170,6 +170,17 @@
|
||||
IsAvailable="@_sfBufferDepthAvailable"
|
||||
ErrorMessage="@_sfBufferDepthError" />
|
||||
</div>
|
||||
@* Register row 33. The point of charting the backlog is retrospective:
|
||||
the daily purge burst is expected, so what an operator needs is to see
|
||||
whether a past spike DRAINED — which a point-in-time tile can never
|
||||
answer. An unreplicated site emits no samples, so this chart is
|
||||
legitimately empty there rather than a flat, misleading zero line. *@
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<KpiTrendChart Title="LocalDb Replication Backlog"
|
||||
Points="@_localDbBacklogSeries"
|
||||
IsAvailable="@_localDbBacklogAvailable"
|
||||
ErrorMessage="@_localDbBacklogError" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -248,6 +259,22 @@
|
||||
Metrics stale
|
||||
</span>
|
||||
}
|
||||
@if (IsLocalDbBacklogSustained(state))
|
||||
{
|
||||
<span class="badge bg-warning text-dark"
|
||||
data-test="localdb-backlog-badge"
|
||||
title="LocalDb replication oplog backlog has stayed above @HealthOptions.Value.LocalDbBacklogThreshold for over @BacklogSustainedDisplay. A short daily spike at the site_events purge is expected and is NOT this — the peer has stopped draining.">
|
||||
Replication backlog
|
||||
</span>
|
||||
}
|
||||
@if (IsStartupReconcilerMissing(state))
|
||||
{
|
||||
<span class="badge bg-warning text-dark"
|
||||
data-test="startup-reconciler-badge"
|
||||
title="This site reported that a node booted without its SiteReconciliationActor (no IDeploymentConfigFetcher), so the startup self-heal is disabled: a deploy that lands while the node is DOWN will not be recovered on restart. Config still reaches the node via LocalDb replication while it is up. Check the node's configuration.">
|
||||
No self-heal
|
||||
</span>
|
||||
}
|
||||
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
|
||||
{
|
||||
<small class="text-muted">offline since @changedAt.ToString("u")</small>
|
||||
@@ -526,6 +553,43 @@
|
||||
private string StaleTimeoutDisplay =>
|
||||
FormatDuration(HealthOptions.Value.MetricsStaleTimeout);
|
||||
|
||||
// Human-friendly rendering of the configured sustained-backlog dwell for the
|
||||
// "Replication backlog" badge tooltip (e.g. "15 minutes").
|
||||
private string BacklogSustainedDisplay =>
|
||||
FormatDuration(HealthOptions.Value.LocalDbBacklogSustainedFor);
|
||||
|
||||
// Register row 33: the sustained LocalDb replication backlog badge.
|
||||
//
|
||||
// Reads the anchor the aggregator maintains (LocalDbBacklogAboveSince) rather than the
|
||||
// instantaneous backlog on the report, because a bare "backlog > threshold" badge would
|
||||
// fire on EVERY replicated site EVERY day: the site_events retention purge is CDC-captured
|
||||
// like any other write, so the backlog jumps by the deleted batch size and then drains.
|
||||
// Healthy is "the backlog comes back down"; only a dwell can express that.
|
||||
//
|
||||
// A site that reports no backlog at all (unreplicated — rig site-b/site-c — or a failed
|
||||
// poll) never gets an anchor, so it can never be badged. That is the whole null-semantics
|
||||
// requirement: null means "no reading", not zero.
|
||||
private bool IsLocalDbBacklogSustained(SiteHealthState state) =>
|
||||
state.IsOnline
|
||||
&& state.LocalDbBacklogAboveSince is { } since
|
||||
&& DateTimeOffset.UtcNow - since >= HealthOptions.Value.LocalDbBacklogSustainedFor;
|
||||
|
||||
// Register row 73: the "No self-heal" badge.
|
||||
//
|
||||
// Warning-level treatment, deliberately not an error and never an offline-style state:
|
||||
// the node still receives config through LocalDb replication while it is up, so what it
|
||||
// has lost is recovery from a deploy that landed while it was DOWN. That is degraded,
|
||||
// not down — which is exactly what a warning badge says.
|
||||
//
|
||||
// Tri-state, and only the explicit false is badged. Null means "not reported": the
|
||||
// central self-report row (where a site reconciler is meaningless) and any site node on
|
||||
// a pre-row-73 build both send null, and badging those would fire on every site the
|
||||
// moment central is upgraded ahead of the sites. Offline sites are skipped for the same
|
||||
// reason the backlog badge skips them — the Offline badge is the signal that matters.
|
||||
private bool IsStartupReconcilerMissing(SiteHealthState state) =>
|
||||
state.IsOnline
|
||||
&& state.LatestReport?.StartupReconcilerActive == false;
|
||||
|
||||
// Online central nodes, from the same ClusterNodes list the Nodes column renders.
|
||||
// Fewer than two means no standby, which disables the manual-failover control. This is
|
||||
// a display-side guard only — AkkaManualFailoverService re-checks against live cluster
|
||||
@@ -616,6 +680,9 @@
|
||||
private IReadOnlyList<KpiSeriesPoint>? _sfBufferDepthSeries;
|
||||
private bool _sfBufferDepthAvailable = true;
|
||||
private string? _sfBufferDepthError;
|
||||
private IReadOnlyList<KpiSeriesPoint>? _localDbBacklogSeries;
|
||||
private bool _localDbBacklogAvailable = true;
|
||||
private string? _localDbBacklogError;
|
||||
|
||||
private static bool SiteHasActiveErrors(SiteHealthState state)
|
||||
{
|
||||
@@ -739,6 +806,8 @@
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.ScriptErrors, siteId, fromUtc, toUtc);
|
||||
(_sfBufferDepthSeries, _sfBufferDepthAvailable, _sfBufferDepthError) =
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.SfBufferDepth, siteId, fromUtc, toUtc);
|
||||
(_localDbBacklogSeries, _localDbBacklogAvailable, _localDbBacklogError) =
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.LocalDbOplogBacklog, siteId, fromUtc, toUtc);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
+4
-2
@@ -668,7 +668,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard parked messages",
|
||||
$"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return;
|
||||
|
||||
_bulkInProgress = true;
|
||||
@@ -720,7 +721,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard parked message",
|
||||
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return false;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
+2
-1
@@ -566,7 +566,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard notification",
|
||||
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
+16
-2
@@ -424,7 +424,14 @@
|
||||
$"Value: {row.ValueJson} ({row.ValueType})\n" +
|
||||
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
|
||||
$"Operator: {row.OperatorUser}",
|
||||
danger: true);
|
||||
// Caution, NOT danger (deferred-work register row 75). Approving executes a
|
||||
// real write to live plant equipment, so this is not a benign primary action
|
||||
// — but it is not destroying anything either, and red made it look like a
|
||||
// delete while rendering it identical to the Reject button right beside it.
|
||||
// Amber keeps the beat of hesitation the write deserves and restores the
|
||||
// colour contrast between the two opposite decisions.
|
||||
DialogSeverity.Caution,
|
||||
confirmLabel: "Approve");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
@@ -460,7 +467,14 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Reject secured write",
|
||||
$"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?",
|
||||
danger: true);
|
||||
// Reject KEEPS danger styling (register row 75). It is terminal and
|
||||
// irreversible for the request — the Operator has to resubmit — and holding
|
||||
// it red is what makes the pair readable: with Approve amber, the two
|
||||
// opposite decisions no longer look alike. Dropping Reject to a neutral
|
||||
// button would just invert the bias, making declining look like the safe
|
||||
// default.
|
||||
danger: true,
|
||||
confirmLabel: "Reject");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
+2
-1
@@ -343,7 +343,8 @@ public partial class SiteCallsReport
|
||||
"Discard cached call",
|
||||
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||||
<button type="button"
|
||||
class="btn @(state.Danger ? "btn-danger" : "btn-primary")"
|
||||
class="btn @ConfirmButtonClass(state)"
|
||||
@onclick="Confirm">
|
||||
@ConfirmLabel(state)
|
||||
</button>
|
||||
@@ -185,9 +185,32 @@
|
||||
svc.Resolve(resolveValue);
|
||||
}
|
||||
|
||||
private static string ConfirmLabel(DialogState state) => state.Kind switch
|
||||
// Bootstrap class for the confirm button, by severity (deferred-work register row 75).
|
||||
// Caution is the middle rung: amber, for an action that is consequential but is not
|
||||
// destroying anything — the Secured Writes "Approve" is the case it was added for,
|
||||
// where red made the affirmative half of a two-person control look like a delete and
|
||||
// made Approve and Reject visually indistinguishable. Colour is never the only signal:
|
||||
// the button is always labelled with its verb.
|
||||
private static string ConfirmButtonClass(DialogState state) => state.Severity switch
|
||||
{
|
||||
DialogKind.Prompt => "Save",
|
||||
_ => state.Danger ? "Delete" : "Confirm",
|
||||
DialogSeverity.Destructive => "btn-danger",
|
||||
DialogSeverity.Caution => "btn-warning",
|
||||
_ => "btn-primary",
|
||||
};
|
||||
|
||||
// An explicit label from the caller always wins. The kind/severity fallback below
|
||||
// is a convenience for the common cases only: a bare destructive confirm is assumed
|
||||
// to be a delete, which is exactly the assumption that put "Delete" on the Trigger
|
||||
// failover button (deferred-work register row 28). Callers whose destructive verb
|
||||
// is not "Delete" must pass ConfirmLabel — see IDialogService.ConfirmAsync.
|
||||
// Note Caution deliberately does NOT inherit that fallback: an unlabelled caution
|
||||
// dialog reads "Confirm", because a caution action is by definition not a delete.
|
||||
private static string ConfirmLabel(DialogState state) =>
|
||||
!string.IsNullOrWhiteSpace(state.ConfirmLabel)
|
||||
? state.ConfirmLabel
|
||||
: state.Kind switch
|
||||
{
|
||||
DialogKind.Prompt => "Save",
|
||||
_ => state.Severity == DialogSeverity.Destructive ? "Delete" : "Confirm",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,12 +35,27 @@ public class DialogService : IDialogService
|
||||
private TaskCompletionSource<object?>? _tcs;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) =>
|
||||
ConfirmAsync(
|
||||
title,
|
||||
message,
|
||||
danger ? DialogSeverity.Destructive : DialogSeverity.Normal,
|
||||
confirmLabel);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
|
||||
{
|
||||
EnsureNoActiveDialog();
|
||||
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_tcs = tcs;
|
||||
Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null);
|
||||
Current = new DialogState(
|
||||
title,
|
||||
DialogKind.Confirm,
|
||||
message,
|
||||
severity,
|
||||
PromptInitial: string.Empty,
|
||||
Placeholder: null,
|
||||
ConfirmLabel: confirmLabel);
|
||||
OnChange?.Invoke();
|
||||
return Project(tcs.Task, static r => r is bool b && b);
|
||||
}
|
||||
@@ -51,7 +66,7 @@ public class DialogService : IDialogService
|
||||
EnsureNoActiveDialog();
|
||||
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_tcs = tcs;
|
||||
Current = new DialogState(title, DialogKind.Prompt, label, Danger: false, PromptInitial: initialValue, Placeholder: placeholder);
|
||||
Current = new DialogState(title, DialogKind.Prompt, label, DialogSeverity.Normal, PromptInitial: initialValue, Placeholder: placeholder);
|
||||
OnChange?.Invoke();
|
||||
return Project(tcs.Task, static r => r as string);
|
||||
}
|
||||
@@ -78,7 +93,7 @@ public class DialogService : IDialogService
|
||||
title,
|
||||
DialogKind.Custom,
|
||||
Body: string.Empty,
|
||||
Danger: false,
|
||||
DialogSeverity.Normal,
|
||||
PromptInitial: string.Empty,
|
||||
Placeholder: null,
|
||||
Content: content,
|
||||
@@ -139,22 +154,28 @@ public class DialogService : IDialogService
|
||||
/// <param name="Title">Modal title text.</param>
|
||||
/// <param name="Kind">Discriminates between confirm, prompt, and custom rendering.</param>
|
||||
/// <param name="Body">For confirm: the message; for prompt: the input label; ignored for custom.</param>
|
||||
/// <param name="Danger">When true, the confirm button uses danger styling.</param>
|
||||
/// <param name="Severity">Drives the confirm button's styling, and the verb the host
|
||||
/// falls back to when <paramref name="ConfirmLabel"/> is blank. Single source of truth
|
||||
/// for both — there is deliberately no separate boolean, so the two cannot disagree.</param>
|
||||
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</param>
|
||||
/// <param name="Placeholder">Placeholder shown when the prompt input is empty.</param>
|
||||
/// <param name="Content">For <see cref="DialogKind.Custom"/>: the body fragment to
|
||||
/// render inside <c>.modal-body</c>; <c>null</c> for confirm/prompt.</param>
|
||||
/// <param name="Size">Optional Bootstrap modal-dialog size modifier class applied
|
||||
/// to the <c>modal-dialog</c> element (e.g. <c>modal-lg</c>); <c>null</c> = default.</param>
|
||||
/// <param name="ConfirmLabel">Explicit verb for the confirm button. <c>null</c> or
|
||||
/// blank falls back to the kind/danger default ("Save" for a prompt, "Delete" for a
|
||||
/// danger confirm, "Confirm" otherwise).</param>
|
||||
public record DialogState(
|
||||
string Title,
|
||||
DialogKind Kind,
|
||||
string Body,
|
||||
bool Danger,
|
||||
DialogSeverity Severity,
|
||||
string PromptInitial,
|
||||
string? Placeholder,
|
||||
RenderFragment? Content = null,
|
||||
string? Size = null);
|
||||
string? Size = null,
|
||||
string? ConfirmLabel = null);
|
||||
|
||||
public enum DialogKind
|
||||
{
|
||||
@@ -162,3 +183,39 @@ public enum DialogKind
|
||||
Prompt,
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How consequential the confirmed action is — drives the confirm button's colour.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deferred-work register row 75. The dialog surface used to carry a two-state
|
||||
/// <c>bool danger</c>, which forced every consequential-but-not-destructive action to
|
||||
/// choose between looking like a delete and looking like the safe default. The Secured
|
||||
/// Writes page is where that bit them: <em>Approve</em> and <em>Reject</em> both rendered
|
||||
/// as identical red buttons, so at the moment of a two-person-control decision the colour
|
||||
/// carried no discriminating information at all — and the affirmative half of the control
|
||||
/// was painted with the delete affordance.
|
||||
/// <para>
|
||||
/// <see cref="Caution"/> is the middle rung that was missing. It is for actions that are
|
||||
/// consequential and worth a beat of hesitation, but are not destroying anything: the
|
||||
/// canonical case is a write to live plant equipment.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum DialogSeverity
|
||||
{
|
||||
/// <summary>Ordinary confirmation — renders <c>btn-primary</c>.</summary>
|
||||
Normal,
|
||||
|
||||
/// <summary>
|
||||
/// Consequential but not destructive — renders <c>btn-warning</c>. Does NOT inherit
|
||||
/// the "Delete" label fallback; a caution dialog with no explicit label reads
|
||||
/// "Confirm".
|
||||
/// </summary>
|
||||
Caution,
|
||||
|
||||
/// <summary>
|
||||
/// Destructive or irreversible — renders <c>btn-danger</c>, and is the only severity
|
||||
/// that falls back to the "Delete" verb (see <c>DialogHost.ConfirmLabel</c>).
|
||||
/// </summary>
|
||||
Destructive
|
||||
}
|
||||
|
||||
@@ -19,10 +19,49 @@ public interface IDialogService
|
||||
/// <param name="title">Modal title text.</param>
|
||||
/// <param name="message">Body text shown to the user.</param>
|
||||
/// <param name="danger">When <c>true</c>, the confirm button renders in
|
||||
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
|
||||
/// "Confirm" button is shown.</param>
|
||||
/// <c>btn-danger</c> styling; otherwise a primary button is shown. This
|
||||
/// controls <em>styling only</em> — it does not name the action. For an action that
|
||||
/// is consequential but NOT destructive, use the <see cref="DialogSeverity"/>
|
||||
/// overload and pass <see cref="DialogSeverity.Caution"/> rather than reaching for
|
||||
/// red (deferred-work register row 75).</param>
|
||||
/// <param name="confirmLabel">Verb rendered on the confirm button. When
|
||||
/// <c>null</c> or blank the button falls back to "Delete" for a danger
|
||||
/// dialog and "Confirm" otherwise. <strong>Any destructive action that is
|
||||
/// not literally a delete must pass this</strong> — otherwise the red button
|
||||
/// on, say, a failover or a discard reads "Delete", which is simply wrong
|
||||
/// (deferred-work register row 28). See the label table in
|
||||
/// <c>Component-CentralUI.md</c> → Dialog Service.</param>
|
||||
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, bool danger = false);
|
||||
Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a confirmation dialog with an explicit <see cref="DialogSeverity"/>, for
|
||||
/// actions the <c>danger</c> boolean cannot describe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deferred-work register row 75. This is an ADDITIVE overload, deliberately: the
|
||||
/// boolean form stays the signature for the ~34 existing call sites, all of which are
|
||||
/// genuinely two-state. Changing the boolean's type instead would have rippled into
|
||||
/// pages this change has no business touching, and adding a fifth optional parameter
|
||||
/// to the boolean form would have silently un-matched every existing four-argument
|
||||
/// NSubstitute stub — the exact gotcha register row 28 recorded (a stub that stops
|
||||
/// matching returns a null Task and the caller NREs, with no compile error). A
|
||||
/// separate overload cannot do either.
|
||||
/// <para>
|
||||
/// Reach for this only when <see cref="DialogSeverity.Caution"/> is what you mean.
|
||||
/// For Normal / Destructive the boolean form says the same thing more briefly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="title">Modal title text.</param>
|
||||
/// <param name="message">Body text shown to the user.</param>
|
||||
/// <param name="severity">How consequential the action is; selects the confirm
|
||||
/// button's styling.</param>
|
||||
/// <param name="confirmLabel">Verb rendered on the confirm button. Only
|
||||
/// <see cref="DialogSeverity.Destructive"/> falls back to "Delete" when this is
|
||||
/// blank; the other severities fall back to "Confirm". The row-28 rule still
|
||||
/// applies — name the action.</param>
|
||||
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a single-line text prompt and resolves to the entered value, or
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// One recorded bundle-unlock passphrase attempt, written to the central
|
||||
/// <c>BundleUnlockAttempt</c> table. Rows form the trailing-hour sliding window the
|
||||
/// Transport component's per-client unlock throttle
|
||||
/// (<c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>) is evaluated against.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Register row 52: the throttle's counters used to be per-node and per-process, so the
|
||||
/// effective fleet cap was N x limit per client and a node restart cleared the window.
|
||||
/// Central MS SQL is the store both central nodes already share, so putting the window
|
||||
/// there makes the cap fleet-wide and restart-durable without new infrastructure.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Operational history, NOT audit — the table is non-partitioned, lives on the standard
|
||||
/// <c>[PRIMARY]</c> filegroup and carries no DB-role restriction. Rows are pruned
|
||||
/// opportunistically once they age past the window; they are not a durable record of
|
||||
/// who tried to open which bundle (that is the <c>BundleImportUnlockFailed</c> audit row's
|
||||
/// job, and note this table deliberately records no bundle identity at all).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Persistence-ignorant POCO; the EF Core mapping lives in the Configuration Database
|
||||
/// component. <see cref="AttemptedAtUtc"/> is UTC, like every timestamp in the system.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class BundleUnlockAttempt
|
||||
{
|
||||
/// <summary>Surrogate identity key assigned by the store.</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The throttle bucket the attempt was counted against — the caller's remote IP, or
|
||||
/// the shared <c><unattributed></c> bucket when no address could be attributed.
|
||||
/// Sized for an IPv6 literal with a scope id.
|
||||
/// </summary>
|
||||
public required string ClientKey { get; set; }
|
||||
|
||||
/// <summary>The UTC instant at which the unlock attempt was registered.</summary>
|
||||
public DateTime AttemptedAtUtc { get; set; }
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 52: read/write access to the central <c>BundleUnlockAttempt</c> table,
|
||||
/// the fleet-wide sliding window behind the Transport component's per-client bundle-unlock
|
||||
/// throttle. Both central nodes share this store, so the documented
|
||||
/// <c>MaxUnlockAttemptsPerIpPerHour</c> cap is a fleet cap rather than a per-process one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The throttle is a best-effort rate cap layered over an already-authenticated,
|
||||
/// role-gated surface. Callers are expected to treat a store failure as fail-open (the
|
||||
/// availability of legitimate imports outranks the cap), so no method here is required to
|
||||
/// be transactional with respect to the caller's own work.
|
||||
/// </remarks>
|
||||
public interface IBundleUnlockAttemptRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts the attempts recorded for <paramref name="clientKey"/> at or after
|
||||
/// <paramref name="sinceUtc"/> — the trailing-window depth the cap is compared against.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">Throttle bucket (client IP, or the shared unattributed bucket).</param>
|
||||
/// <param name="sinceUtc">Inclusive UTC lower bound of the trailing window.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The number of attempts inside the window.</returns>
|
||||
Task<int> CountAttemptsSinceAsync(string clientKey, DateTime sinceUtc, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Appends one attempt row for <paramref name="clientKey"/>.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">Throttle bucket (client IP, or the shared unattributed bucket).</param>
|
||||
/// <param name="attemptedAtUtc">UTC instant of the attempt.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that completes when the row is committed.</returns>
|
||||
Task RecordAttemptAsync(string clientKey, DateTime attemptedAtUtc, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes attempt rows older than <paramref name="beforeUtc"/>, up to
|
||||
/// <paramref name="maxRows"/> in one bounded pass. Bounded so an opportunistic prune on
|
||||
/// a request path can never turn into an unbounded delete.
|
||||
/// </summary>
|
||||
/// <param name="beforeUtc">Exclusive UTC upper bound — rows strictly older than this are eligible.</param>
|
||||
/// <param name="maxRows">Maximum rows to delete in this pass; must be positive.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The number of rows deleted.</returns>
|
||||
Task<int> PruneAttemptsOlderThanAsync(DateTime beforeUtc, int maxRows, CancellationToken ct = default);
|
||||
}
|
||||
@@ -18,6 +18,15 @@ public interface IBundleSessionStore
|
||||
/// <summary>Removes all sessions whose expiry has passed.</summary>
|
||||
void EvictExpired();
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: the number of sessions currently held that have not yet expired.
|
||||
/// Backs the <c>scadabridge.central.transport.import_sessions.open</c> gauge, so it
|
||||
/// must be cheap and side-effect-free (it is read from the metrics collection thread)
|
||||
/// and must NOT count expired-but-unswept entries — otherwise the gauge would read
|
||||
/// as "at the cap" for up to a sweep interval after a burst drained.
|
||||
/// </summary>
|
||||
int OpenSessionCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// T-003: returns the current unlock-failure count for a bundle keyed by its
|
||||
/// content hash. The counter is server-owned so a second tab / CLI caller
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
|
||||
/// <summary>
|
||||
/// A single external HTTP system, flattened for deployment to a site.
|
||||
/// </summary>
|
||||
/// <param name="Name">Unique system name — the key scripts call it by.</param>
|
||||
/// <param name="EndpointUrl">Base URL of the system's HTTP endpoint.</param>
|
||||
/// <param name="AuthType">Authentication type identifier (e.g. <c>ApiKey</c>, <c>BasicAuth</c>).</param>
|
||||
/// <param name="AuthConfiguration">JSON-serialized authentication configuration for <paramref name="AuthType"/>.</param>
|
||||
/// <param name="MethodDefinitionsJson">JSON-serialized method list, or <c>null</c> when the system defines none.</param>
|
||||
/// <param name="TimeoutSeconds">
|
||||
/// Per-system HTTP round-trip timeout in seconds. <c>0</c> = unset — the gateway's configured
|
||||
/// <c>DefaultHttpTimeout</c> applies.
|
||||
/// </param>
|
||||
/// <param name="MaxRetries">
|
||||
/// Store-and-Forward retry budget for this system's cached calls. <c>0</c> = unset — the engine's
|
||||
/// <c>DefaultMaxRetries</c> applies.
|
||||
/// <para>
|
||||
/// <b>0 means "unset", not "no limit", at THIS layer</b> — the inverse of
|
||||
/// <c>StoreAndForwardMessage.MaxRetries</c>, where a stored 0 means unbounded retry. The two
|
||||
/// meanings are reconciled in <c>ExternalSystemClient.CachedCallAsync</c>, which passes a 0
|
||||
/// through as <c>null</c> so the bounded engine default applies rather than an unbounded retry
|
||||
/// loop. Keeping "unset" as 0 here (rather than a nullable) matches
|
||||
/// <see cref="DatabaseConnectionArtifact"/>, the non-nullable
|
||||
/// <c>ExternalSystemDefinition.MaxRetries</c> entity property this is projected from, and the
|
||||
/// proto3 scalar it is carried on.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="RetryDelay">
|
||||
/// Fixed interval between Store-and-Forward retry sweeps for this system's cached calls.
|
||||
/// <see cref="TimeSpan.Zero"/> = unset — the engine's <c>DefaultRetryInterval</c> applies.
|
||||
/// </param>
|
||||
public record ExternalSystemArtifact(
|
||||
string Name,
|
||||
string EndpointUrl,
|
||||
string AuthType,
|
||||
string? AuthConfiguration,
|
||||
string? MethodDefinitionsJson,
|
||||
int TimeoutSeconds = 0);
|
||||
int TimeoutSeconds = 0,
|
||||
int MaxRetries = 0,
|
||||
TimeSpan RetryDelay = default);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Site-internal request to make sure an already-persisted instance is actually RUNNING.
|
||||
/// Routes via the deployment-manager singleton proxy so the Instance Actor is only ever
|
||||
/// created under the singleton, whichever node raised the request.
|
||||
/// <para>
|
||||
/// Emitted by <c>SiteReconciliationActor</c> after its node-STARTUP pass fetches and stores
|
||||
/// a config the node was missing. The reconcile heal path writes the FILE; without this the
|
||||
/// runtime was left untouched, so an instance whose config was absent at boot (lost volume,
|
||||
/// first boot after a restore, rehomed data dir) stayed dead until the NEXT restart, whose
|
||||
/// SQLite load finally started it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fire-and-forget and idempotent.</b> There is no reply: the handler no-ops when the
|
||||
/// instance is already running, when an in-flight deploy will create it anyway, or when the
|
||||
/// durable row says the instance is disabled. The stored row — not this message — is the
|
||||
/// authority on both the config body and enabled/disabled state, so a disable that landed
|
||||
/// after the reconcile fetch still wins.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="InstanceUniqueName">Unique name of the instance that should be running.</param>
|
||||
/// <param name="Reason">
|
||||
/// Short human-readable provenance for the request (e.g. <c>"startup reconcile"</c>), carried
|
||||
/// verbatim into the log line and the operational site event so the heal is not silent.
|
||||
/// </param>
|
||||
public record EnsureInstanceStartedCommand(
|
||||
string InstanceUniqueName,
|
||||
string Reason);
|
||||
@@ -103,12 +103,23 @@ public record SiteHealthReport(
|
||||
/// or <see langword="null"/> when replication is not wired on this node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Nullable on purpose, and <see langword="false"/> is NOT the same as null.
|
||||
/// Replication ships default-OFF, so a site node with no peer configured reports
|
||||
/// <see langword="false"/> — that is a healthy, expected state, not an outage. Only
|
||||
/// a node that was configured with a peer and is reporting <see langword="false"/>
|
||||
/// is degraded, and distinguishing those two cases is the operator's job, not this
|
||||
/// field's.
|
||||
/// <b>Null means "no reading"</b> — replication is not configured on this node
|
||||
/// (the default-OFF posture), or the reporter has not run yet.
|
||||
/// <b><see langword="false"/> means "configured, and the peer session is down"</b>,
|
||||
/// which is a genuine fault worth investigating.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The site side must never publish <see langword="false"/> for an unconfigured node
|
||||
/// (deferred-work register row 80): the replication engine is registered
|
||||
/// unconditionally and answers <c>Connected=false</c> while inert, so passing that
|
||||
/// through made every deliberately-unreplicated site look like a broken replicated
|
||||
/// one, and starved central's null branches (conditional KPI emission,
|
||||
/// sustained-badge anchor exclusion) of the null they were built for.
|
||||
/// <c>LocalDbReplicationStatusReporter</c> gates on the Host-supplied
|
||||
/// <c>SiteLocalDbSetup.ReplicationIsConfigured</c> to keep that distinction honest.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool? LocalDbReplicationConnected { get; init; }
|
||||
|
||||
@@ -134,6 +145,34 @@ public record SiteHealthReport(
|
||||
/// 0 so existing producers/tests that don't populate it stay valid.
|
||||
/// </summary>
|
||||
public long SiteStreamAlarmDropCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this node spawned its <c>SiteReconciliationActor</c> at boot — the per-node
|
||||
/// startup self-heal that lets a standby which was DOWN during a deploy recover its
|
||||
/// deployed inventory (and, since register row 67, restart the Instance Actors a
|
||||
/// boot-time load missed).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Tri-state, and the distinction is the whole point (deferred-work register row 73).
|
||||
/// <b>Null = not reported</b>: a central self-report, where the notion does not apply,
|
||||
/// or a site node running a pre-row-73 build. <b><see langword="true"/></b> = the
|
||||
/// reconciler is running. <b><see langword="false"/></b> = the node booted WITHOUT it,
|
||||
/// because no <c>IDeploymentConfigFetcher</c> was resolvable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That false case used to be invisible: <c>AkkaHostedService</c> logged a single boot
|
||||
/// Warning and spawned nothing, so a misconfigured site reported itself perfectly
|
||||
/// healthy forever while being unable to self-heal. Central badges it rather than
|
||||
/// failing the site — replication remains the primary path, so the node is degraded,
|
||||
/// not down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Stamped once at boot by the Host (which owns the spawn decision) via
|
||||
/// <c>ISiteHealthCollector.SetStartupReconcilerActive</c>; it is not polled.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool? StartupReconcilerActive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
public record QueryAuditLogCommand(
|
||||
string? User = null, string? EntityType = null, string? Action = null,
|
||||
DateTimeOffset? From = null, DateTimeOffset? To = null,
|
||||
int Page = 1, int PageSize = 50);
|
||||
|
||||
/// <summary>
|
||||
/// Result wrapper for <see cref="QueryAuditLogCommand"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This record exists specifically so the handler does not return the repository's
|
||||
/// <c>(IReadOnlyList<AuditLogEntry> Entries, int TotalCount)</c> ValueTuple directly.
|
||||
/// A ValueTuple exposes <c>Item1</c>/<c>Item2</c> as public <em>fields</em>, and
|
||||
/// <see cref="System.Text.Json"/> serializes only properties unless
|
||||
/// <c>JsonSerializerOptions.IncludeFields</c> is set — so a tuple result serialized to
|
||||
/// the literal string <c>{}</c> and every <c>audit-config query</c> looked empty no
|
||||
/// matter how many rows the query matched (deferred-work register row 46). Mirrors the
|
||||
/// <c>SecuredWriteListResult</c> shape used by the other paged management query.
|
||||
/// </remarks>
|
||||
/// <param name="Entries">The page of configuration-change audit entries.</param>
|
||||
/// <param name="TotalCount">Total number of rows matching the filters (unpaged).</param>
|
||||
public record AuditLogQueryResult(IReadOnlyList<AuditLogEntry> Entries, int TotalCount);
|
||||
|
||||
@@ -2,8 +2,23 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
public record ListExternalSystemsCommand;
|
||||
public record GetExternalSystemCommand(int ExternalSystemId);
|
||||
public record CreateExternalSystemCommand(string Name, string EndpointUrl, string AuthType, string? AuthConfiguration, int? TimeoutSeconds = null);
|
||||
public record UpdateExternalSystemCommand(int ExternalSystemId, string Name, string EndpointUrl, string AuthType, string? AuthConfiguration, int? TimeoutSeconds = null);
|
||||
/// <summary>
|
||||
/// Creates an external HTTP system.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="MaxRetries"/> and <paramref name="RetryDelay"/> are the Store-and-Forward
|
||||
/// retry policy applied to this system's <c>ExternalSystem.CachedCall</c> buffering. Both are
|
||||
/// nullable on the wire so "omit" is distinguishable from "set to 0"; a null (or an explicit 0)
|
||||
/// stores the entity's unset value, which the site gateway translates into the engine's
|
||||
/// <c>DefaultMaxRetries</c>/<c>DefaultRetryInterval</c>.
|
||||
/// </remarks>
|
||||
public record CreateExternalSystemCommand(string Name, string EndpointUrl, string AuthType, string? AuthConfiguration, int? TimeoutSeconds = null, int? MaxRetries = null, TimeSpan? RetryDelay = null);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an external HTTP system. Nullable members follow the "omit to preserve" convention —
|
||||
/// a null leaves the stored value untouched.
|
||||
/// </summary>
|
||||
public record UpdateExternalSystemCommand(int ExternalSystemId, string Name, string EndpointUrl, string AuthType, string? AuthConfiguration, int? TimeoutSeconds = null, int? MaxRetries = null, TimeSpan? RetryDelay = null);
|
||||
public record DeleteExternalSystemCommand(int ExternalSystemId);
|
||||
|
||||
// External System Methods
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
|
||||
@@ -93,6 +94,38 @@ public static class ScadaBridgeTelemetry
|
||||
Meter.CreateCounter<long>("scadabridge.site.stream.events_dropped", unit: "1",
|
||||
description: "Events evicted from a site gRPC stream's bounded send channel, tagged by stream kind.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 66: incremented each time <c>BundleSessionStore.Open</c> refuses a
|
||||
/// bundle-import session because <c>TransportOptions.MaxConcurrentImportSessions</c>
|
||||
/// (default 8) is genuinely exhausted — i.e. the store was still at the cap AFTER
|
||||
/// expired sessions were reclaimed. Reclaim-then-succeed and re-opening an already
|
||||
/// open session id are deliberately NOT counted: neither turns an operator away.
|
||||
///
|
||||
/// Row 53 shipped the companion open-sessions gauge and a Warning log, but a log line
|
||||
/// is not alertable. The pair is what distinguishes the two failure modes: rejections
|
||||
/// climbing while the gauge oscillates below the cap is genuine concurrency (raise the
|
||||
/// cap), whereas rejections climbing while the gauge sits pinned at the cap is a
|
||||
/// session leak like register row 43's (find the leak — raising the cap only defers it).
|
||||
/// </summary>
|
||||
private static readonly Counter<long> _transportImportSessionsRejected =
|
||||
Meter.CreateCounter<long>("scadabridge.central.transport.import_sessions.rejected", unit: "1",
|
||||
description: "Bundle-import sessions rejected because the concurrent-import-session cap was exhausted.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 34: incremented once per site-event-log purge tick that stopped at
|
||||
/// <c>SiteEventLogOptions.MaxPurgedRowsPerTick</c> with expired rows still to delete.
|
||||
///
|
||||
/// A single hit is routine (a backlog is being worked through on accelerated catch-up
|
||||
/// ticks) — what the series is for is the SHAPE. A short burst of hits that stops is a
|
||||
/// backlog converging; hits that never stop mean the event log is being written faster
|
||||
/// than one paced tick can purge, i.e. the ceiling or the catch-up interval is now
|
||||
/// mis-sized for this site's event rate. The paired log line is rate-limited and so
|
||||
/// cannot answer that question on its own.
|
||||
/// </summary>
|
||||
private static readonly Counter<long> _siteEventLogPurgeCeilingHits =
|
||||
Meter.CreateCounter<long>("scadabridge.site.event_log.purge_ceiling_hits", unit: "1",
|
||||
description: "Site event log purge ticks that stopped at the per-tick delete ceiling with work remaining.");
|
||||
|
||||
// ---------------- Histograms ----------------
|
||||
|
||||
/// <summary>
|
||||
@@ -109,8 +142,27 @@ public static class ScadaBridgeTelemetry
|
||||
|
||||
// ---------------- Observable gauges ----------------
|
||||
|
||||
/// <summary>Current count of open site connections, mutated via <see cref="Interlocked"/>.</summary>
|
||||
private static long _siteConnectionsUp;
|
||||
/// <summary>
|
||||
/// Current count of open central→site gRPC subscriber streams, mutated via
|
||||
/// <see cref="Interlocked"/>. See <see cref="_siteStreamSubscribersActive"/>'s gauge for
|
||||
/// why this is NOT a data-connection count.
|
||||
/// </summary>
|
||||
private static long _siteStreamSubscriberCount;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 54: last-known state of every data connection on this site node, keyed by
|
||||
/// connection name. Written by the DCL at its four state-transition points and read by the
|
||||
/// data-connection gauges below.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A static registry, matching <see cref="_siteStreamSubscriberCount"/>'s precedent, rather
|
||||
/// than a DI-injected holder: the DCL actors already receive <c>ISiteHealthCollector</c>
|
||||
/// through their constructors, and threading a second dependency through both actor
|
||||
/// constructors would ripple into every test that builds them. A site node hosts exactly
|
||||
/// one DCL, so a process-global registry and a per-node one are the same thing here.
|
||||
/// </remarks>
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ConnectionHealth>
|
||||
_dataConnectionStates = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Current count of running per-site live-alarm aggregators, mutated via <see cref="Interlocked"/>.</summary>
|
||||
private static long _liveAlarmAggregatorsActive;
|
||||
@@ -118,12 +170,61 @@ public static class ScadaBridgeTelemetry
|
||||
/// <summary>Provider that yields the live StoreAndForward queue depth; set by a later task.</summary>
|
||||
private static Func<long>? _queueDepthProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: provider that yields the number of currently open (unexpired)
|
||||
/// Transport bundle-import sessions, registered by the Transport component's
|
||||
/// session-eviction hosted service. Null on a site node (Transport is central-only),
|
||||
/// where the gauge simply reports 0.
|
||||
/// </summary>
|
||||
private static Func<long>? _importSessionCountProvider;
|
||||
|
||||
#pragma warning disable IDE0052 // Held to keep the observable gauges alive for the meter's lifetime.
|
||||
/// <summary>Gauge reporting the number of currently open site connections.</summary>
|
||||
private static readonly ObservableGauge<long> _siteConnectionUp =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.connection.up",
|
||||
() => Interlocked.Read(ref _siteConnectionsUp),
|
||||
description: "Number of currently open site connections.");
|
||||
/// <summary>
|
||||
/// Gauge reporting the number of open central→site gRPC subscriber streams.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register row 54 RENAMED this from <c>scadabridge.site.connection.up</c>
|
||||
/// (<c>scadabridge_site_connection_up</c> in Prometheus). The old name read as a
|
||||
/// data-connection health signal, but it has only ever counted open subscriber streams
|
||||
/// on <c>SiteStreamGrpcServer</c> — Debug View and the site-wide alarm feed. It therefore
|
||||
/// sits at 0 on a perfectly healthy site that nobody is watching, and an alert written
|
||||
/// against the old name fired on an idle operator console rather than a broken device.
|
||||
/// For real data-connection health use the <c>scadabridge.site.data_connections.*</c>
|
||||
/// gauges below. Counts both stream kinds together (no <c>stream</c> tag).
|
||||
/// </remarks>
|
||||
private static readonly ObservableGauge<long> _siteStreamSubscribersActive =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.stream.subscribers.active",
|
||||
() => Interlocked.Read(ref _siteStreamSubscriberCount),
|
||||
unit: "1",
|
||||
description: "Open central→site gRPC subscriber streams (Debug View + site-wide alarm feed). NOT data connections.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 54: gauge reporting how many of this site node's data connections are
|
||||
/// currently in <see cref="ConnectionHealth.Connected"/> — the real data-connection
|
||||
/// signal the misnamed stream-subscriber gauge was being mistaken for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read together with <see cref="_dataConnectionsTotal"/>: <c>connected < total</c> is the
|
||||
/// alertable condition, and neither number alone distinguishes "one device down" from
|
||||
/// "nothing configured". Expect **0 on the standby node** — connections are opened by the
|
||||
/// deployment path on the active node only, so a standby reporting 0 is healthy, not a
|
||||
/// fault. Alert on the active node, or on <c>max()</c> across the pair.
|
||||
/// </remarks>
|
||||
private static readonly ObservableGauge<long> _dataConnectionsConnected =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.data_connections.connected",
|
||||
CountDataConnections,
|
||||
unit: "1",
|
||||
description: "Data connections on this site node currently in the Connected state.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 54: gauge reporting how many data connections this site node knows about,
|
||||
/// in any state. The denominator for <see cref="_dataConnectionsConnected"/>.
|
||||
/// </summary>
|
||||
private static readonly ObservableGauge<long> _dataConnectionsTotal =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.data_connections.total",
|
||||
() => (long)_dataConnectionStates.Count,
|
||||
unit: "1",
|
||||
description: "Data connections known to this site node, in any state.");
|
||||
|
||||
/// <summary>Gauge reporting the current StoreAndForward queue depth via the registered provider.</summary>
|
||||
private static readonly ObservableGauge<long> _storeAndForwardQueueDepth =
|
||||
@@ -138,6 +239,20 @@ public static class ScadaBridgeTelemetry
|
||||
() => Interlocked.Read(ref _liveAlarmAggregatorsActive),
|
||||
unit: "1",
|
||||
description: "Number of per-site live-alarm aggregators currently running on the active central node.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: gauge reporting the number of open Transport bundle-import
|
||||
/// sessions on this central node. Each open session pins a fully decrypted bundle
|
||||
/// in memory and consumes one of <c>TransportOptions.MaxConcurrentImportSessions</c>
|
||||
/// (default 8), so a series that plateaus at the cap is the signature of leaked
|
||||
/// sessions — the failure mode register row 43 could only be inferred from rotating
|
||||
/// test failures because nothing counted them.
|
||||
/// </summary>
|
||||
private static readonly ObservableGauge<long> _transportImportSessionsOpen =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.central.transport.import_sessions.open",
|
||||
() => Volatile.Read(ref _importSessionCountProvider) is { } p ? p() : 0L,
|
||||
unit: "1",
|
||||
description: "Number of currently open bundle-import sessions on this central node.");
|
||||
#pragma warning restore IDE0052
|
||||
|
||||
// ---------------- Emit helpers ----------------
|
||||
@@ -164,11 +279,83 @@ public static class ScadaBridgeTelemetry
|
||||
/// <summary>Records one resync whose ack window expired.</summary>
|
||||
public static void RecordSfResyncAckMissing() => _sfResyncAckMissing.Add(1);
|
||||
|
||||
/// <summary>Records that a site connection opened (increments the up-count gauge).</summary>
|
||||
public static void SiteConnectionOpened() => Interlocked.Increment(ref _siteConnectionsUp);
|
||||
/// <summary>
|
||||
/// Records one bundle-import session rejected on a genuine concurrent-session cap hit
|
||||
/// (register row 66). Call ONLY from the post-eviction recheck in
|
||||
/// <c>BundleSessionStore.Open</c> — counting the pre-eviction check would inflate the
|
||||
/// series with reclaim-then-succeed cases that never turned an operator away.
|
||||
/// </summary>
|
||||
public static void RecordImportSessionRejected() => _transportImportSessionsRejected.Add(1);
|
||||
|
||||
/// <summary>Records that a site connection closed (decrements the up-count gauge).</summary>
|
||||
public static void SiteConnectionClosed() => Interlocked.Decrement(ref _siteConnectionsUp);
|
||||
/// <summary>
|
||||
/// Records one site-event-log purge tick that stopped at its per-tick delete ceiling
|
||||
/// (register row 34). Call ONLY when rows genuinely remain to be purged — a tick that
|
||||
/// finished the work and happened to land exactly on the ceiling has nothing to report.
|
||||
/// </summary>
|
||||
public static void RecordSiteEventLogPurgeCeilingHit() => _siteEventLogPurgeCeilingHits.Add(1);
|
||||
|
||||
/// <summary>
|
||||
/// Records that a central→site gRPC subscriber stream opened (increments
|
||||
/// <c>scadabridge.site.stream.subscribers.active</c>). Renamed from
|
||||
/// <c>SiteConnectionOpened</c> by register row 54 — it never counted data connections.
|
||||
/// </summary>
|
||||
public static void SiteStreamSubscriberOpened() => Interlocked.Increment(ref _siteStreamSubscriberCount);
|
||||
|
||||
/// <summary>
|
||||
/// Records that a central→site gRPC subscriber stream closed (decrements
|
||||
/// <c>scadabridge.site.stream.subscribers.active</c>). Renamed from
|
||||
/// <c>SiteConnectionClosed</c> by register row 54.
|
||||
/// </summary>
|
||||
public static void SiteStreamSubscriberClosed() => Interlocked.Decrement(ref _siteStreamSubscriberCount);
|
||||
|
||||
/// <summary>
|
||||
/// Records the current state of one data connection, feeding the
|
||||
/// <c>scadabridge.site.data_connections.*</c> gauges (register row 54). Called by the DCL
|
||||
/// at each connection state transition; safe to call repeatedly with the same value.
|
||||
/// </summary>
|
||||
/// <param name="connectionName">The data connection's name (unique per site node).</param>
|
||||
/// <param name="state">The connection's new state.</param>
|
||||
public static void RecordDataConnectionState(string connectionName, ConnectionHealth state)
|
||||
{
|
||||
if (string.IsNullOrEmpty(connectionName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dataConnectionStates[connectionName] = state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops a data connection from the gauges (register row 54). Called when the DCL removes
|
||||
/// a connection, so an undeployed connection stops counting toward the total rather than
|
||||
/// lingering as a permanently-not-connected row that would alert forever.
|
||||
/// </summary>
|
||||
/// <param name="connectionName">The data connection's name.</param>
|
||||
public static void RemoveDataConnection(string connectionName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(connectionName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dataConnectionStates.TryRemove(connectionName, out _);
|
||||
}
|
||||
|
||||
/// <summary>Counts data connections currently in <see cref="ConnectionHealth.Connected"/>.</summary>
|
||||
/// <returns>The connected count.</returns>
|
||||
private static long CountDataConnections()
|
||||
{
|
||||
long connected = 0;
|
||||
foreach (var state in _dataConnectionStates.Values)
|
||||
{
|
||||
if (state == ConnectionHealth.Connected)
|
||||
{
|
||||
connected++;
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
/// <summary>Records that a per-site live-alarm aggregator started (increments the active-aggregator gauge).</summary>
|
||||
public static void LiveAlarmAggregatorStarted() => Interlocked.Increment(ref _liveAlarmAggregatorsActive);
|
||||
@@ -242,4 +429,42 @@ public static class ScadaBridgeTelemetry
|
||||
// delegate, so a stale stop cannot clobber a successor's provider.
|
||||
Interlocked.CompareExchange(ref _queueDepthProvider, null, provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: registers the provider the open-import-sessions gauge reads on
|
||||
/// each observation. Called by the Transport component's session-eviction hosted
|
||||
/// service on start. A null provider is ignored so the gauge falls back to 0.
|
||||
/// The callback is invoked synchronously on the metrics collection thread, so it
|
||||
/// must stay cheap — the Transport implementation reads a
|
||||
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/> it
|
||||
/// already owns.
|
||||
/// </summary>
|
||||
/// <param name="provider">A callback returning the current open-session count.</param>
|
||||
public static void SetImportSessionCountProvider(Func<long> provider)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _importSessionCountProvider, provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the open-import-sessions provider, but only if the currently registered
|
||||
/// provider is the exact <paramref name="provider"/> delegate passed in
|
||||
/// (reference-equal compare-and-clear), so a late stop of a superseded instance
|
||||
/// cannot stomp a newer registrant. Mirrors <see cref="ClearQueueDepthProvider"/>.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider delegate to remove; ignored unless it is the
|
||||
/// one currently registered.</param>
|
||||
public static void ClearImportSessionCountProvider(Func<long> provider)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.CompareExchange(ref _importSessionCountProvider, null, provider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,5 +120,22 @@ public static class KpiMetrics
|
||||
|
||||
/// <summary>Summed store-and-forward buffer depth across all buffers.</summary>
|
||||
public const string SfBufferDepth = "sfBufferDepth";
|
||||
|
||||
/// <summary>
|
||||
/// Unacked LocalDb replication oplog depth (register row 33). Emitted ONLY by sites
|
||||
/// that actually report a backlog — an unreplicated site reports null, and null means
|
||||
/// "no reading", never zero, so no sample is written at all rather than a fabricated 0.
|
||||
/// A gap in this series therefore means "not replicated / not reporting", which is
|
||||
/// exactly what it should mean.
|
||||
/// </summary>
|
||||
public const string LocalDbOplogBacklog = "localDbOplogBacklog";
|
||||
|
||||
/// <summary>
|
||||
/// LocalDb replication link state as 1/0 (register row 33). Emitted ONLY when the site
|
||||
/// reports a value; null (replication not configured) writes no sample. Note that
|
||||
/// <c>false</c> and null are different states here — false is a configured link that is
|
||||
/// down, null is no link configured — which is why this cannot be coerced to 0.
|
||||
/// </summary>
|
||||
public const string LocalDbReplicationConnected = "localDbReplicationConnected";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// T-003: thrown by <c>IBundleImporter.LoadAsync</c> when an encrypted bundle has
|
||||
/// exceeded the configured failed-unlock attempt limit
|
||||
/// (<c>TransportOptions.MaxUnlockAttemptsPerSession</c>, default 3). The lockout is tracked
|
||||
/// server-side keyed by <see cref="BundleManifest.ContentHash"/>, so a second tab / CLI caller
|
||||
/// re-uploading the same bytes hits the same counter and cannot side-step the limit.
|
||||
/// <para>
|
||||
/// Distinct from <see cref="BundleUnlockThrottledException"/> on purpose: this one means
|
||||
/// "this BUNDLE has burned its strikes" and is keyed by the bundle's content hash; that one
|
||||
/// means "this CLIENT has attempted too many unlocks, across any bundles" and is keyed by
|
||||
/// the caller's IP.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Register row 51: lives in Commons rather than beside the importer for the same reason
|
||||
/// <see cref="BundleUnlockThrottledException"/> does — it is part of the
|
||||
/// <c>IBundleImporter.LoadAsync</c> contract, which is declared here, and the management
|
||||
/// surface (which references Commons but not Transport) must be able to catch it to return
|
||||
/// a clear, curated error instead of a generic internal-failure message plus a correlation id.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleLockedException : Exception
|
||||
{
|
||||
/// <summary>Number of recorded unlock failures for this bundle.</summary>
|
||||
public int FailedAttempts { get; }
|
||||
|
||||
/// <summary>SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key.</summary>
|
||||
public string BundleContentHash { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleLockedException"/>.
|
||||
/// </summary>
|
||||
/// <param name="bundleContentHash">SHA-256 hex from <see cref="BundleManifest.ContentHash"/>.</param>
|
||||
/// <param name="failedAttempts">Number of failures recorded against this bundle.</param>
|
||||
public BundleLockedException(string bundleContentHash, int failedAttempts)
|
||||
: base(
|
||||
$"Bundle is locked after {failedAttempts} failed unlock attempts. "
|
||||
+ "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.")
|
||||
{
|
||||
BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash));
|
||||
FailedAttempts = failedAttempts;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
"ZXkYASABKAkSQQoFdmFsdWUYAiABKAsyMi5zY2FkYWJyaWRnZS5jZW50cmFs",
|
||||
"Y29udHJvbC52MS5UYWdRdWFsaXR5Q291bnRzRHRvOgI4ASJQChFOb2RlU3Rh",
|
||||
"dHVzTGlzdER0bxI7CgVub2RlcxgBIAMoCzIsLnNjYWRhYnJpZGdlLmNlbnRy",
|
||||
"YWxjb250cm9sLnYxLk5vZGVTdGF0dXNEdG8iig4KE1NpdGVIZWFsdGhSZXBv",
|
||||
"YWxjb250cm9sLnYxLk5vZGVTdGF0dXNEdG8iyQ4KE1NpdGVIZWFsdGhSZXBv",
|
||||
"cnREdG8SDwoHc2l0ZV9pZBgBIAEoCRIXCg9zZXF1ZW5jZV9udW1iZXIYAiAB",
|
||||
"KAMSNAoQcmVwb3J0X3RpbWVzdGFtcBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1",
|
||||
"Zi5UaW1lc3RhbXAScAoYZGF0YV9jb25uZWN0aW9uX3N0YXR1c2VzGAQgAygL",
|
||||
@@ -106,42 +106,43 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
"YmxlVmFsdWUSQgoebG9jYWxfZGJfcmVwbGljYXRpb25fY29ubmVjdGVkGBsg",
|
||||
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLkJvb2xWYWx1ZRI7ChZsb2NhbF9kYl9v",
|
||||
"cGxvZ19iYWNrbG9nGBwgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDY0VmFs",
|
||||
"dWUacgobRGF0YUNvbm5lY3Rpb25TdGF0dXNlc0VudHJ5EgsKA2tleRgBIAEo",
|
||||
"CRJCCgV2YWx1ZRgCIAEoDjIzLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9s",
|
||||
"LnYxLkNvbm5lY3Rpb25IZWFsdGhFbnVtOgI4ARpxChhUYWdSZXNvbHV0aW9u",
|
||||
"Q291bnRzRW50cnkSCwoDa2V5GAEgASgJEkQKBXZhbHVlGAIgASgLMjUuc2Nh",
|
||||
"ZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuVGFnUmVzb2x1dGlvblN0YXR1",
|
||||
"c0R0bzoCOAEaQgogU3RvcmVBbmRGb3J3YXJkQnVmZmVyRGVwdGhzRW50cnkS",
|
||||
"CwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgFOgI4ASJjChZTaXRlSGVhbHRo",
|
||||
"UmVwb3J0QWNrRHRvEg8KB3NpdGVfaWQYASABKAkSFwoPc2VxdWVuY2VfbnVt",
|
||||
"YmVyGAIgASgDEhAKCGFjY2VwdGVkGAMgASgIEg0KBWVycm9yGAQgASgJIosB",
|
||||
"CgxIZWFydGJlYXREdG8SDwoHc2l0ZV9pZBgBIAEoCRIVCg1ub2RlX2hvc3Ru",
|
||||
"YW1lGAIgASgJEhEKCWlzX2FjdGl2ZRgDIAEoCBItCgl0aW1lc3RhbXAYBCAB",
|
||||
"KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhEKCXN5bnRoZXRpYxgF",
|
||||
"IAEoCCq9AQoUQ29ubmVjdGlvbkhlYWx0aEVudW0SIQodQ09OTkVDVElPTl9I",
|
||||
"RUFMVEhfVU5TUEVDSUZJRUQQABIfChtDT05ORUNUSU9OX0hFQUxUSF9DT05O",
|
||||
"RUNURUQQARIiCh5DT05ORUNUSU9OX0hFQUxUSF9ESVNDT05ORUNURUQQAhIg",
|
||||
"ChxDT05ORUNUSU9OX0hFQUxUSF9DT05ORUNUSU5HEAMSGwoXQ09OTkVDVElP",
|
||||
"Tl9IRUFMVEhfRVJST1IQBDKhBgoVQ2VudHJhbENvbnRyb2xTZXJ2aWNlEoMB",
|
||||
"ChJTdWJtaXROb3RpZmljYXRpb24SNC5zY2FkYWJyaWRnZS5jZW50cmFsY29u",
|
||||
"dHJvbC52MS5Ob3RpZmljYXRpb25TdWJtaXREdG8aNy5zY2FkYWJyaWRnZS5j",
|
||||
"ZW50cmFsY29udHJvbC52MS5Ob3RpZmljYXRpb25TdWJtaXRBY2tEdG8SkgEK",
|
||||
"F1F1ZXJ5Tm90aWZpY2F0aW9uU3RhdHVzEjkuc2NhZGFicmlkZ2UuY2VudHJh",
|
||||
"bGNvbnRyb2wudjEuTm90aWZpY2F0aW9uU3RhdHVzUXVlcnlEdG8aPC5zY2Fk",
|
||||
"YWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5Ob3RpZmljYXRpb25TdGF0dXNS",
|
||||
"ZXNwb25zZUR0bxJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVzdHJlYW0u",
|
||||
"QXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sSUAoVSW5n",
|
||||
"ZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRUZWxlbWV0",
|
||||
"cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEoABCg1SZWNvbmNpbGVT",
|
||||
"aXRlEjYuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuUmVjb25jaWxl",
|
||||
"U2l0ZVJlcXVlc3REdG8aNy5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52",
|
||||
"MS5SZWNvbmNpbGVTaXRlUmVzcG9uc2VEdG8SfQoQUmVwb3J0U2l0ZUhlYWx0",
|
||||
"aBIyLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlNpdGVIZWFsdGhS",
|
||||
"ZXBvcnREdG8aNS5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5TaXRl",
|
||||
"SGVhbHRoUmVwb3J0QWNrRHRvElAKCUhlYXJ0YmVhdBIrLnNjYWRhYnJpZGdl",
|
||||
"LmNlbnRyYWxjb250cm9sLnYxLkhlYXJ0YmVhdER0bxoWLmdvb2dsZS5wcm90",
|
||||
"b2J1Zi5FbXB0eUIrqgIoWkIuTU9NLldXLlNjYWRhQnJpZGdlLkNvbW11bmlj",
|
||||
"YXRpb24uR3JwY2IGcHJvdG8z"));
|
||||
"dWUSPQoZc3RhcnR1cF9yZWNvbmNpbGVyX2FjdGl2ZRgdIAEoCzIaLmdvb2ds",
|
||||
"ZS5wcm90b2J1Zi5Cb29sVmFsdWUacgobRGF0YUNvbm5lY3Rpb25TdGF0dXNl",
|
||||
"c0VudHJ5EgsKA2tleRgBIAEoCRJCCgV2YWx1ZRgCIAEoDjIzLnNjYWRhYnJp",
|
||||
"ZGdlLmNlbnRyYWxjb250cm9sLnYxLkNvbm5lY3Rpb25IZWFsdGhFbnVtOgI4",
|
||||
"ARpxChhUYWdSZXNvbHV0aW9uQ291bnRzRW50cnkSCwoDa2V5GAEgASgJEkQK",
|
||||
"BXZhbHVlGAIgASgLMjUuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEu",
|
||||
"VGFnUmVzb2x1dGlvblN0YXR1c0R0bzoCOAEaQgogU3RvcmVBbmRGb3J3YXJk",
|
||||
"QnVmZmVyRGVwdGhzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgF",
|
||||
"OgI4ASJjChZTaXRlSGVhbHRoUmVwb3J0QWNrRHRvEg8KB3NpdGVfaWQYASAB",
|
||||
"KAkSFwoPc2VxdWVuY2VfbnVtYmVyGAIgASgDEhAKCGFjY2VwdGVkGAMgASgI",
|
||||
"Eg0KBWVycm9yGAQgASgJIosBCgxIZWFydGJlYXREdG8SDwoHc2l0ZV9pZBgB",
|
||||
"IAEoCRIVCg1ub2RlX2hvc3RuYW1lGAIgASgJEhEKCWlzX2FjdGl2ZRgDIAEo",
|
||||
"CBItCgl0aW1lc3RhbXAYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0",
|
||||
"YW1wEhEKCXN5bnRoZXRpYxgFIAEoCCq9AQoUQ29ubmVjdGlvbkhlYWx0aEVu",
|
||||
"dW0SIQodQ09OTkVDVElPTl9IRUFMVEhfVU5TUEVDSUZJRUQQABIfChtDT05O",
|
||||
"RUNUSU9OX0hFQUxUSF9DT05ORUNURUQQARIiCh5DT05ORUNUSU9OX0hFQUxU",
|
||||
"SF9ESVNDT05ORUNURUQQAhIgChxDT05ORUNUSU9OX0hFQUxUSF9DT05ORUNU",
|
||||
"SU5HEAMSGwoXQ09OTkVDVElPTl9IRUFMVEhfRVJST1IQBDKhBgoVQ2VudHJh",
|
||||
"bENvbnRyb2xTZXJ2aWNlEoMBChJTdWJtaXROb3RpZmljYXRpb24SNC5zY2Fk",
|
||||
"YWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5Ob3RpZmljYXRpb25TdWJtaXRE",
|
||||
"dG8aNy5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5Ob3RpZmljYXRp",
|
||||
"b25TdWJtaXRBY2tEdG8SkgEKF1F1ZXJ5Tm90aWZpY2F0aW9uU3RhdHVzEjku",
|
||||
"c2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuTm90aWZpY2F0aW9uU3Rh",
|
||||
"dHVzUXVlcnlEdG8aPC5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5O",
|
||||
"b3RpZmljYXRpb25TdGF0dXNSZXNwb25zZUR0bxJHChFJbmdlc3RBdWRpdEV2",
|
||||
"ZW50cxIbLnNpdGVzdHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVh",
|
||||
"bS5Jbmdlc3RBY2sSUAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0",
|
||||
"cmVhbS5DYWNoZWRUZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0",
|
||||
"QWNrEoABCg1SZWNvbmNpbGVTaXRlEjYuc2NhZGFicmlkZ2UuY2VudHJhbGNv",
|
||||
"bnRyb2wudjEuUmVjb25jaWxlU2l0ZVJlcXVlc3REdG8aNy5zY2FkYWJyaWRn",
|
||||
"ZS5jZW50cmFsY29udHJvbC52MS5SZWNvbmNpbGVTaXRlUmVzcG9uc2VEdG8S",
|
||||
"fQoQUmVwb3J0U2l0ZUhlYWx0aBIyLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250",
|
||||
"cm9sLnYxLlNpdGVIZWFsdGhSZXBvcnREdG8aNS5zY2FkYWJyaWRnZS5jZW50",
|
||||
"cmFsY29udHJvbC52MS5TaXRlSGVhbHRoUmVwb3J0QWNrRHRvElAKCUhlYXJ0",
|
||||
"YmVhdBIrLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLkhlYXJ0YmVh",
|
||||
"dER0bxoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eUIrqgIoWkIuTU9NLldXLlNj",
|
||||
"YWRhQnJpZGdlLkNvbW11bmljYXRpb24uR3JwY2IGcHJvdG8z"));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.EmptyReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor, },
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionHealthEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
@@ -159,7 +160,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto.Parser, new[]{ "Entries" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, }),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto.Parser, new[]{ "Entries" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, }),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto.Parser, new[]{ "Nodes" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto.Parser, new[]{ "SiteId", "SequenceNumber", "ReportTimestamp", "DataConnectionStatuses", "TagResolutionCounts", "ScriptErrorCount", "AlarmEvaluationErrorCount", "StoreAndForwardBufferDepths", "DeadLetterCount", "DeployedInstanceCount", "EnabledInstanceCount", "DisabledInstanceCount", "NodeRole", "NodeHostname", "DataConnectionEndpoints", "DataConnectionTagQuality", "ParkedMessageCount", "ClusterNodes", "SiteAuditWriteFailures", "AuditRedactionFailure", "SiteAuditBacklog", "SiteEventLogWriteFailures", "OldestParkedMessageAgeSeconds", "ScriptQueueDepth", "ScriptBusyThreads", "ScriptOldestBusyAgeSeconds", "LocalDbReplicationConnected", "LocalDbOplogBacklog" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, null, null, }),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto.Parser, new[]{ "SiteId", "SequenceNumber", "ReportTimestamp", "DataConnectionStatuses", "TagResolutionCounts", "ScriptErrorCount", "AlarmEvaluationErrorCount", "StoreAndForwardBufferDepths", "DeadLetterCount", "DeployedInstanceCount", "EnabledInstanceCount", "DisabledInstanceCount", "NodeRole", "NodeHostname", "DataConnectionEndpoints", "DataConnectionTagQuality", "ParkedMessageCount", "ClusterNodes", "SiteAuditWriteFailures", "AuditRedactionFailure", "SiteAuditBacklog", "SiteEventLogWriteFailures", "OldestParkedMessageAgeSeconds", "ScriptQueueDepth", "ScriptBusyThreads", "ScriptOldestBusyAgeSeconds", "LocalDbReplicationConnected", "LocalDbOplogBacklog", "StartupReconcilerActive" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, null, null, }),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto.Parser, new[]{ "SiteId", "SequenceNumber", "Accepted", "Error" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto.Parser, new[]{ "SiteId", "NodeHostname", "IsActive", "Timestamp", "Synthetic" }, null, null, null, null)
|
||||
}));
|
||||
@@ -4266,6 +4267,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
ScriptOldestBusyAgeSeconds = other.ScriptOldestBusyAgeSeconds;
|
||||
LocalDbReplicationConnected = other.LocalDbReplicationConnected;
|
||||
LocalDbOplogBacklog = other.LocalDbOplogBacklog;
|
||||
StartupReconcilerActive = other.StartupReconcilerActive;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -4642,6 +4644,27 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Field number for the "startup_reconciler_active" field.</summary>
|
||||
public const int StartupReconcilerActiveFieldNumber = 29;
|
||||
private static readonly pb::FieldCodec<bool?> _single_startupReconcilerActive_codec = pb::FieldCodec.ForStructWrapper<bool>(234);
|
||||
private bool? startupReconcilerActive_;
|
||||
/// <summary>
|
||||
/// Whether this node spawned its SiteReconciliationActor at boot. Absent means
|
||||
/// "not reported" — a central self-report (where it is meaningless) or a site
|
||||
/// node on a pre-row-73 build. false means the node booted WITHOUT the startup
|
||||
/// self-heal path, which is a misconfiguration a healthy-looking site would
|
||||
/// otherwise hide entirely.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool? StartupReconcilerActive {
|
||||
get { return startupReconcilerActive_; }
|
||||
set {
|
||||
startupReconcilerActive_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -4685,6 +4708,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (!pbc::ProtobufEqualityComparers.BitwiseNullableDoubleEqualityComparer.Equals(ScriptOldestBusyAgeSeconds, other.ScriptOldestBusyAgeSeconds)) return false;
|
||||
if (LocalDbReplicationConnected != other.LocalDbReplicationConnected) return false;
|
||||
if (LocalDbOplogBacklog != other.LocalDbOplogBacklog) return false;
|
||||
if (StartupReconcilerActive != other.StartupReconcilerActive) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -4720,6 +4744,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (scriptOldestBusyAgeSeconds_ != null) hash ^= pbc::ProtobufEqualityComparers.BitwiseNullableDoubleEqualityComparer.GetHashCode(ScriptOldestBusyAgeSeconds);
|
||||
if (localDbReplicationConnected_ != null) hash ^= LocalDbReplicationConnected.GetHashCode();
|
||||
if (localDbOplogBacklog_ != null) hash ^= LocalDbOplogBacklog.GetHashCode();
|
||||
if (startupReconcilerActive_ != null) hash ^= StartupReconcilerActive.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -4837,6 +4862,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (localDbOplogBacklog_ != null) {
|
||||
_single_localDbOplogBacklog_codec.WriteTagAndValue(output, LocalDbOplogBacklog);
|
||||
}
|
||||
if (startupReconcilerActive_ != null) {
|
||||
_single_startupReconcilerActive_codec.WriteTagAndValue(output, StartupReconcilerActive);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -4946,6 +4974,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (localDbOplogBacklog_ != null) {
|
||||
_single_localDbOplogBacklog_codec.WriteTagAndValue(ref output, LocalDbOplogBacklog);
|
||||
}
|
||||
if (startupReconcilerActive_ != null) {
|
||||
_single_startupReconcilerActive_codec.WriteTagAndValue(ref output, StartupReconcilerActive);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -5034,6 +5065,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (localDbOplogBacklog_ != null) {
|
||||
size += _single_localDbOplogBacklog_codec.CalculateSizeWithTag(LocalDbOplogBacklog);
|
||||
}
|
||||
if (startupReconcilerActive_ != null) {
|
||||
size += _single_startupReconcilerActive_codec.CalculateSizeWithTag(StartupReconcilerActive);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -5147,6 +5181,11 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
LocalDbOplogBacklog = other.LocalDbOplogBacklog;
|
||||
}
|
||||
}
|
||||
if (other.startupReconcilerActive_ != null) {
|
||||
if (startupReconcilerActive_ == null || other.StartupReconcilerActive != false) {
|
||||
StartupReconcilerActive = other.StartupReconcilerActive;
|
||||
}
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -5305,6 +5344,13 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 234: {
|
||||
bool? value = _single_startupReconcilerActive_codec.Read(input);
|
||||
if (startupReconcilerActive_ == null || value != false) {
|
||||
StartupReconcilerActive = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5463,6 +5509,13 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 234: {
|
||||
bool? value = _single_startupReconcilerActive_codec.Read(ref input);
|
||||
if (startupReconcilerActive_ == null || value != false) {
|
||||
StartupReconcilerActive = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ public static class CentralControlDtoMapper
|
||||
ScriptOldestBusyAgeSeconds = msg.ScriptOldestBusyAgeSeconds,
|
||||
LocalDbReplicationConnected = msg.LocalDbReplicationConnected,
|
||||
LocalDbOplogBacklog = msg.LocalDbOplogBacklog,
|
||||
StartupReconcilerActive = msg.StartupReconcilerActive,
|
||||
};
|
||||
|
||||
foreach (var (name, health) in msg.DataConnectionStatuses)
|
||||
@@ -649,6 +650,7 @@ public static class CentralControlDtoMapper
|
||||
ScriptOldestBusyAgeSeconds = dto.ScriptOldestBusyAgeSeconds,
|
||||
LocalDbReplicationConnected = dto.LocalDbReplicationConnected,
|
||||
LocalDbOplogBacklog = dto.LocalDbOplogBacklog,
|
||||
StartupReconcilerActive = dto.StartupReconcilerActive,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -506,7 +506,9 @@ public static class SiteCommandDtoMapper
|
||||
AuthType = artifact.AuthType,
|
||||
AuthConfiguration = OrEmpty(artifact.AuthConfiguration),
|
||||
MethodDefinitionsJson = OrEmpty(artifact.MethodDefinitionsJson),
|
||||
TimeoutSeconds = artifact.TimeoutSeconds
|
||||
TimeoutSeconds = artifact.TimeoutSeconds,
|
||||
MaxRetries = artifact.MaxRetries,
|
||||
RetryDelay = Duration.FromTimeSpan(artifact.RetryDelay)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -523,7 +525,9 @@ public static class SiteCommandDtoMapper
|
||||
dto.AuthType,
|
||||
NullIfEmpty(dto.AuthConfiguration),
|
||||
NullIfEmpty(dto.MethodDefinitionsJson),
|
||||
dto.TimeoutSeconds);
|
||||
dto.TimeoutSeconds,
|
||||
dto.MaxRetries,
|
||||
dto.RetryDelay?.ToTimeSpan() ?? TimeSpan.Zero);
|
||||
}
|
||||
|
||||
/// <summary>Projects a <see cref="DatabaseConnectionArtifact"/> onto the wire.</summary>
|
||||
|
||||
@@ -467,12 +467,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
|
||||
// Telemetry follow-on: the connection is now fully established (Subscribe
|
||||
// succeeded, so no leak via the catch above). Count it up here and balance
|
||||
// it in the finally below so the scadabridge.site.connection.up gauge is
|
||||
// decremented on EVERY exit path — normal completion, client-cancel /
|
||||
// it in the finally below so the scadabridge.site.stream.subscribers.active
|
||||
// gauge is decremented on EVERY exit path — normal completion, client-cancel /
|
||||
// duplicate-replacement (OperationCanceledException), server shutdown
|
||||
// (CancelAllStreams -> Cts.Cancel), and any other exception — guaranteeing
|
||||
// exactly one Closed per Opened and a gauge that never drifts up.
|
||||
ScadaBridgeTelemetry.SiteConnectionOpened();
|
||||
ScadaBridgeTelemetry.SiteStreamSubscriberOpened();
|
||||
try
|
||||
{
|
||||
// R2 — event batching. The pump replaces the old per-event
|
||||
@@ -500,7 +500,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
ScadaBridgeTelemetry.SiteConnectionClosed();
|
||||
ScadaBridgeTelemetry.SiteStreamSubscriberClosed();
|
||||
_streamSubscriber.RemoveSubscriber(relayActor);
|
||||
_actorSystem!.Stop(relayActor);
|
||||
channel.Writer.TryComplete();
|
||||
|
||||
@@ -228,6 +228,12 @@ message SiteHealthReportDto {
|
||||
// Absent means UNKNOWN, never zero — a failed backlog read rendered as 0
|
||||
// would report a broken replication pair as perfectly healthy.
|
||||
google.protobuf.Int64Value local_db_oplog_backlog = 28;
|
||||
// Whether this node spawned its SiteReconciliationActor at boot. Absent means
|
||||
// "not reported" — a central self-report (where it is meaningless) or a site
|
||||
// node on a pre-row-73 build. false means the node booted WITHOUT the startup
|
||||
// self-heal path, which is a misconfiguration a healthy-looking site would
|
||||
// otherwise hide entirely.
|
||||
google.protobuf.BoolValue startup_reconciler_active = 29;
|
||||
}
|
||||
|
||||
// Central -> Site: health report ack, so a lost report is observable.
|
||||
|
||||
@@ -224,6 +224,11 @@ message ExternalSystemArtifactDto {
|
||||
string auth_configuration = 4; // empty string represents null
|
||||
string method_definitions_json = 5; // empty string represents null
|
||||
int32 timeout_seconds = 6;
|
||||
// Store-and-Forward retry policy for this system's cached calls. Additive (fields 7-8);
|
||||
// a pre-existing site decodes them as 0/absent, which is exactly the "unset" value that
|
||||
// makes the engine defaults apply — i.e. the old behaviour.
|
||||
int32 max_retries = 7;
|
||||
google.protobuf.Duration retry_delay = 8;
|
||||
}
|
||||
|
||||
message DatabaseConnectionArtifactDto {
|
||||
|
||||
@@ -42,9 +42,33 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<SiteAlarmLiveCacheService>();
|
||||
services.AddSingleton<ISiteAlarmLiveCache>(sp => sp.GetRequiredService<SiteAlarmLiveCacheService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the communication services that only a CENTRAL node can construct.
|
||||
/// Call alongside <see cref="AddCommunication"/> from the Central composition root only.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="ReconcileService"/> is scoped and takes <c>IDeploymentManagerRepository</c> —
|
||||
/// an EF repository registered by <c>AddConfigurationDatabase</c>, which only central calls.
|
||||
/// It used to live in the shared <see cref="AddCommunication"/> on the argument that it was
|
||||
/// "harmless on site hosts: only the central actor ever resolves it". That is true of the
|
||||
/// runtime behaviour but not of the container: the registration was UNCONSTRUCTIBLE on a
|
||||
/// site node, so <c>ValidateOnBuild</c> failed the whole site graph and the site branch
|
||||
/// could not have scope validation turned on at all (deferred register row 50). A
|
||||
/// registration nothing can ever build is not harmless — it is the thing that stops the
|
||||
/// validator from being usable, and it hides whatever real defect the validator would
|
||||
/// otherwise have found.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The DI service collection to register services into.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> to allow chaining.</returns>
|
||||
public static IServiceCollection AddCentralCommunication(this IServiceCollection services)
|
||||
{
|
||||
// Startup reconciliation handler — scoped (holds scoped repositories), resolved
|
||||
// per-request by CentralCommunicationActor inside a DI scope. Harmless on site
|
||||
// hosts: only the central actor ever resolves it.
|
||||
// per-request by CentralCommunicationActor inside a DI scope.
|
||||
services.AddScoped<ReconcileService>();
|
||||
|
||||
return services;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+46
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="BundleUnlockAttempt"/> POCO to the central
|
||||
/// <c>BundleUnlockAttempt</c> table — the fleet-wide sliding window behind the Transport
|
||||
/// component's per-client bundle-unlock throttle (register row 52). Operational history,
|
||||
/// NOT audit, so the table is non-partitioned, standard <c>[PRIMARY]</c> filegroup, no
|
||||
/// DB-role restriction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One named index, <c>IX_BundleUnlockAttempt_Client_Attempted</c>, serves both access
|
||||
/// paths: the window count seeks on <c>ClientKey</c> and ranges on
|
||||
/// <c>AttemptedAtUtc</c>, and the opportunistic prune scans the same index by time. A
|
||||
/// second time-only index was deliberately not added — the table is kept small by the
|
||||
/// prune, so the leading-column seek is cheap enough for the prune to share.
|
||||
/// </remarks>
|
||||
public class BundleUnlockAttemptEntityTypeConfiguration : IEntityTypeConfiguration<BundleUnlockAttempt>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the EF Core entity type mapping for <see cref="BundleUnlockAttempt"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<BundleUnlockAttempt> builder)
|
||||
{
|
||||
builder.ToTable("BundleUnlockAttempt");
|
||||
|
||||
// Surrogate long identity assigned by the store.
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
// Bucket key: an IP literal or the "<unattributed>" sentinel. 64 chars covers an
|
||||
// IPv6 address with a scope id; ASCII, so non-unicode (varchar) like every other
|
||||
// catalog-bounded column in the schema.
|
||||
builder.Property(a => a.ClientKey).HasMaxLength(64).IsUnicode(false).IsRequired();
|
||||
|
||||
builder.Property(a => a.AttemptedAtUtc).IsRequired();
|
||||
|
||||
// Window index — seeks the bucket, ranges over the trailing hour, and is
|
||||
// reused by the prune's time scan. Name locked for migration discoverability.
|
||||
builder.HasIndex(a => new { a.ClientKey, a.AttemptedAtUtc })
|
||||
.HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted");
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -39,7 +39,26 @@ public class DeploymentRecordConfiguration : IEntityTypeConfiguration<Deployment
|
||||
|
||||
builder.HasIndex(d => d.DeploymentId).IsUnique();
|
||||
builder.HasIndex(d => d.InstanceId);
|
||||
builder.HasIndex(d => d.DeployedAt);
|
||||
|
||||
// Deployment-list sort key. Every list read orders DeployedAt DESC, Id DESC
|
||||
// (DeploymentManagerRepository.QueryDeploymentListPageAsync and
|
||||
// QueryDeploymentSummariesAsync) — Id is the deterministic tie-break, because
|
||||
// a bulk site deploy stamps many rows in the same DeployedAt instant and a
|
||||
// non-deterministic order would let a row repeat or vanish across pages.
|
||||
// Declaring both key columns descending lets a forward scan feed the paged
|
||||
// Skip/Take directly instead of the server sorting the whole filtered set per
|
||||
// page (deferred-work register row 35).
|
||||
//
|
||||
// This REPLACES the old single-column HasIndex(d => d.DeployedAt): that index
|
||||
// is strictly redundant here, not merely overlapping. DeployedAt leads both, so
|
||||
// any predicate the old one served the composite serves too (direction does not
|
||||
// affect seekability — SQL Server scans either way), and the leaf is the same
|
||||
// width, since Id is the clustered PK and was therefore already sitting in the
|
||||
// old index's leaf as the row locator. Keeping it would buy nothing and cost a
|
||||
// second index maintained on every insert and status transition.
|
||||
builder.HasIndex(d => new { d.DeployedAt, d.Id })
|
||||
.IsDescending(true, true)
|
||||
.HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2121
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBundleUnlockAttemptTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BundleUnlockAttempt",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ClientKey = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
|
||||
AttemptedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BundleUnlockAttempt", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BundleUnlockAttempt_Client_Attempted",
|
||||
table: "BundleUnlockAttempt",
|
||||
columns: new[] { "ClientKey", "AttemptedAtUtc" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BundleUnlockAttempt");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2123
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces the single-column <c>IX_DeploymentRecords_DeployedAt</c> with the
|
||||
/// composite <c>IX_DeploymentRecords_DeployedAt_Id (DeployedAt DESC, Id DESC)</c>,
|
||||
/// which is the deployment list's exact sort key (deferred-work register row 35).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list read (<c>DeploymentManagerRepository.QueryDeploymentListPageAsync</c>)
|
||||
/// joins <c>Instance</c> to make <c>SiteId</c> filterable, then orders
|
||||
/// <c>DeployedAt DESC, Id DESC</c> and pages with Skip/Take. With both key columns
|
||||
/// declared descending, a forward scan of this index already delivers rows in the
|
||||
/// requested order, so the plan can feed the Top-N page directly rather than sorting
|
||||
/// the whole filtered set on every page request.
|
||||
///
|
||||
/// The drop is safe rather than merely tolerable: <c>DeployedAt</c> leads both
|
||||
/// indexes, so every predicate the old one served the composite serves as well
|
||||
/// (index direction does not affect seekability — SQL Server scans either
|
||||
/// direction), and the leaf rows are the same width, because <c>Id</c> is the
|
||||
/// clustered primary key and was therefore already present in the old nonclustered
|
||||
/// index's leaf as the row locator. What changes is only that <c>Id</c> is now a
|
||||
/// declared, ordered key column instead of an implicit locator. Keeping both would
|
||||
/// add a second index to maintain on every insert and status transition for no read
|
||||
/// benefit. <c>SystemArtifactDeploymentRecords</c> has its own separate
|
||||
/// <c>DeployedAt</c> index and is deliberately untouched.
|
||||
/// </remarks>
|
||||
public partial class AddDeploymentRecordListSortIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt",
|
||||
table: "DeploymentRecords");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt_Id",
|
||||
table: "DeploymentRecords",
|
||||
columns: new[] { "DeployedAt", "Id" },
|
||||
descending: new bool[0]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt_Id",
|
||||
table: "DeploymentRecords");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt",
|
||||
table: "DeploymentRecords",
|
||||
column: "DeployedAt");
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -267,13 +267,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeployedAt");
|
||||
|
||||
b.HasIndex("DeploymentId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("InstanceId");
|
||||
|
||||
b.HasIndex("DeployedAt", "Id")
|
||||
.IsDescending()
|
||||
.HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id");
|
||||
|
||||
b.ToTable("DeploymentRecords");
|
||||
});
|
||||
|
||||
@@ -1716,6 +1718,31 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.ToTable("TemplateScripts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport.BundleUnlockAttempt", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("AttemptedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ClientKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientKey", "AttemptedAtUtc")
|
||||
.HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted");
|
||||
|
||||
b.ToTable("BundleUnlockAttempt", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
|
||||
{
|
||||
b.Property<Guid>("EventId")
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IBundleUnlockAttemptRepository"/> over the central
|
||||
/// <c>BundleUnlockAttempt</c> table (register row 52). Plain reads/writes against the
|
||||
/// shared <see cref="ScadaBridgeDbContext"/>; the prune uses <c>ExecuteDeleteAsync</c> so
|
||||
/// no rows are materialised into the change tracker.
|
||||
/// </summary>
|
||||
public class BundleUnlockAttemptRepository : IBundleUnlockAttemptRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BundleUnlockAttemptRepository"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The EF Core database context.</param>
|
||||
public BundleUnlockAttemptRepository(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountAttemptsSinceAsync(
|
||||
string clientKey, DateTime sinceUtc, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
||||
|
||||
return await _context.BundleUnlockAttempts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.ClientKey == clientKey && a.AttemptedAtUtc >= sinceUtc)
|
||||
.CountAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RecordAttemptAsync(
|
||||
string clientKey, DateTime attemptedAtUtc, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
||||
|
||||
_context.BundleUnlockAttempts.Add(new BundleUnlockAttempt
|
||||
{
|
||||
ClientKey = clientKey,
|
||||
AttemptedAtUtc = attemptedAtUtc,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PruneAttemptsOlderThanAsync(
|
||||
DateTime beforeUtc, int maxRows, CancellationToken ct = default)
|
||||
{
|
||||
if (maxRows < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxRows), "Prune batch size must be at least 1.");
|
||||
}
|
||||
|
||||
// Bounded by construction: OrderBy + Take caps the delete regardless of how much
|
||||
// history accumulated while the prune was failing or the node was down, so an
|
||||
// opportunistic sweep on a request path can never become an unbounded delete.
|
||||
return await _context.BundleUnlockAttempts
|
||||
.Where(a => a.AttemptedAtUtc < beforeUtc)
|
||||
.OrderBy(a => a.AttemptedAtUtc)
|
||||
.Take(maxRows)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
@@ -152,6 +153,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
/// <summary>Gets the set of hourly KPI rollups (pre-aggregated long-range trend backbone).</summary>
|
||||
public DbSet<KpiRollupHourly> KpiRollupHourly => Set<KpiRollupHourly>();
|
||||
|
||||
// Transport (bundle export/import)
|
||||
/// <summary>Gets the set of recorded bundle-unlock attempts — the fleet-wide sliding window behind the per-client unlock throttle (register row 52).</summary>
|
||||
public DbSet<BundleUnlockAttempt> BundleUnlockAttempts => Set<BundleUnlockAttempt>();
|
||||
|
||||
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
||||
/// <summary>Gets the set of data protection keys.</summary>
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
@@ -87,6 +87,10 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ISecuredWriteRepository, SecuredWriteRepository>();
|
||||
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
||||
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
|
||||
// Register row 52: the fleet-wide bundle-unlock throttle window. Central-only in
|
||||
// practice (Transport is registered in the host's Central branch), but registered
|
||||
// here with every other repository so the ConfigurationDatabase surface stays uniform.
|
||||
services.AddScoped<IBundleUnlockAttemptRepository, BundleUnlockAttemptRepository>();
|
||||
// Auth re-arch: inbound API keys are no longer persisted in SQL Server —
|
||||
// the repository now exposes only API-method access, so a plain scoped
|
||||
// registration suffices (no peppered-hasher accessor to wire).
|
||||
|
||||
@@ -3,6 +3,7 @@ using Akka.Event;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
@@ -312,6 +313,10 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_log.Info("[{0}] Entering Connecting state", _connectionName);
|
||||
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connecting);
|
||||
_healthCollector.UpdateConnectionEndpoint(_connectionName, "Connecting");
|
||||
// Register row 54 — mirror the state into the data-connection gauges. Kept alongside
|
||||
// the health-collector write (rather than derived from it) so the metric never
|
||||
// depends on the health-report path being alive.
|
||||
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Connecting);
|
||||
Become(Connecting);
|
||||
Self.Tell(new AttemptConnect());
|
||||
}
|
||||
@@ -394,6 +399,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_healthCollector.UpdateTagResolution(_connectionName, TotalSubscribedTagCount, ResolvedTagCount);
|
||||
var endpointLabel = _backupConfig == null ? "Connected" : $"Connected to {_activeEndpoint.ToString().ToLower()}";
|
||||
_healthCollector.UpdateConnectionEndpoint(_connectionName, endpointLabel);
|
||||
// Register row 54 — the only transition that raises the connected gauge.
|
||||
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Connected);
|
||||
Become(Connected);
|
||||
Stash.UnstashAll();
|
||||
}
|
||||
@@ -475,6 +482,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_log.Warning("[{0}] Entering Reconnecting state", _connectionName);
|
||||
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Disconnected);
|
||||
_healthCollector.UpdateConnectionEndpoint(_connectionName, "Disconnected");
|
||||
// Register row 54 — drops this connection out of the connected gauge while the
|
||||
// total holds, which is exactly the connected < total alert condition.
|
||||
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Disconnected);
|
||||
|
||||
// Track unstable connections toward failover.
|
||||
// If we were connected for less than the stability threshold, this counts
|
||||
@@ -1275,6 +1285,20 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// Fire-and-forget release of every handle this request created redundantly.
|
||||
_ = UnsubscribeIdsAsync(_adapter, redundantIds);
|
||||
|
||||
// Publish the resolution counts this request just changed. Without this push a
|
||||
// connection whose tags ALL resolve on the first attempt reported 0/0 forever:
|
||||
// the only other pushes are BecomeConnected's (which fires before any instance
|
||||
// has subscribed, so its 0/0 is legitimate but is never superseded),
|
||||
// HandleBatchSubscribeCompleted's (reconnect re-subscribe / resolution probe) and
|
||||
// HandleUnsubscribe's (teardown) — and ScheduleTagResolutionRetry below cancels
|
||||
// the retry timer outright when nothing is unresolved, so no later tick pushed
|
||||
// either. The blind spot was therefore worst on a perfectly healthy connection,
|
||||
// which is precisely when the "device still booting / tag path unresolved"
|
||||
// diagnostic is supposed to read clean rather than empty. Mirrors the push site
|
||||
// in HandleBatchSubscribeCompleted, and stays on the actor thread where both
|
||||
// derived counts are stable.
|
||||
_healthCollector.UpdateTagResolution(_connectionName, TotalSubscribedTagCount, ResolvedTagCount);
|
||||
|
||||
// Now that every tag is registered in
|
||||
// _subscriptionsByInstance, deliver the values captured by the initial read.
|
||||
// Re-entering via Self reuses HandleTagValueReceived's generation guard, fan-out
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
@@ -307,6 +308,10 @@ public class DataConnectionManagerActor : ReceiveActor
|
||||
Context.Stop(actor);
|
||||
_connectionActors.Remove(command.ConnectionName);
|
||||
_healthCollector.RemoveConnection(command.ConnectionName);
|
||||
// Register row 54 — drop it from the gauges too, so an undeployed connection
|
||||
// stops counting toward the total instead of lingering as a permanently
|
||||
// not-connected row that would hold the connected < total alert on forever.
|
||||
ScadaBridgeTelemetry.RemoveDataConnection(command.ConnectionName);
|
||||
_log.Info("Removed DataConnectionActor for {0}", command.ConnectionName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,8 +192,13 @@ public class ArtifactDeploymentService
|
||||
m.ReturnDefinition
|
||||
}))
|
||||
: null;
|
||||
// MaxRetries/RetryDelay ride along so the site's Store-and-Forward buffering honours
|
||||
// the centrally-configured per-system retry policy. Before they were carried, a site
|
||||
// silently fell back to the engine defaults (50 x 30 s) for every cached call, whatever
|
||||
// central had configured — the DatabaseConnectionArtifact below always carried them.
|
||||
externalSystemArtifacts.Add(new ExternalSystemArtifact(
|
||||
es.Name, es.EndpointUrl, es.AuthType, es.AuthConfiguration, methodsJson, es.TimeoutSeconds));
|
||||
es.Name, es.EndpointUrl, es.AuthType, es.AuthConfiguration, methodsJson, es.TimeoutSeconds,
|
||||
es.MaxRetries, es.RetryDelay));
|
||||
}
|
||||
|
||||
// Map database connections
|
||||
@@ -286,7 +291,22 @@ public class ArtifactDeploymentService
|
||||
site.Id, globals, cancellationToken, deploymentId);
|
||||
}
|
||||
|
||||
// Deploy to each site in parallel with per-site timeout
|
||||
// Deploy to each site in parallel with per-site timeout.
|
||||
//
|
||||
// Deferred-work register row 60 — what makes this fan-out safe, stated precisely
|
||||
// because the invariant is easy to break from a distance:
|
||||
// * `globals` is NOT EF state. FetchGlobalArtifactsAsync projects the entities
|
||||
// into pure Commons message DTOs and drops the entities, so the snapshot the
|
||||
// lambda closes over cannot touch a DbContext at all.
|
||||
// * `site` IS a tracked EF entity, and it is the one piece of EF state that
|
||||
// genuinely crosses into the parallel phase. Only already-materialised scalar
|
||||
// properties are read below (Id, SiteIdentifier, Name) and NO navigation
|
||||
// property is traversed — with lazy loading off (pinned by
|
||||
// ConfigurationDatabase.Tests.AddConfigurationDatabase_DoesNotEnableLazyLoading)
|
||||
// those are plain field reads that never re-enter the shared context.
|
||||
// So: do not traverse a navigation property on `site` in here, and do not move
|
||||
// per-site command building (which DOES query) inside this lambda — either one
|
||||
// puts concurrent load on one non-thread-safe DbContext.
|
||||
var tasks = sites.Select(async site =>
|
||||
{
|
||||
try
|
||||
|
||||
@@ -50,7 +50,8 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
FirstSeenAt = now,
|
||||
LastSequenceNumber = report.SequenceNumber,
|
||||
IsOnline = true,
|
||||
IsMetricsStale = false
|
||||
IsMetricsStale = false,
|
||||
LocalDbBacklogAboveSince = NextBacklogAboveSince(null, report, now)
|
||||
// LastStatusChangeAt stays null: first observation is not a flip.
|
||||
};
|
||||
|
||||
@@ -84,7 +85,9 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
IsMetricsStale = false,
|
||||
// Stamp a flip only when this report brings the site back online;
|
||||
// a report from an already-online site leaves the timestamp be.
|
||||
LastStatusChangeAt = existing.IsOnline ? existing.LastStatusChangeAt : now
|
||||
LastStatusChangeAt = existing.IsOnline ? existing.LastStatusChangeAt : now,
|
||||
LocalDbBacklogAboveSince =
|
||||
NextBacklogAboveSince(existing.LocalDbBacklogAboveSince, report, now)
|
||||
};
|
||||
|
||||
if (_siteStates.TryUpdate(report.SiteId, updated, existing))
|
||||
@@ -320,4 +323,43 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the sustained-backlog anchor for one incoming report (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three cases, and the null one is the subtle one:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Backlog <c>null</c> — no reading. An unreplicated site (rig site-b/site-c) reports this
|
||||
/// forever, and a replicated site reports it when a poll fails. Carry the previous anchor
|
||||
/// through UNCHANGED: null is not zero, so it is neither evidence of a drain (which would
|
||||
/// justify clearing) nor of a backlog (which would justify starting one). An unreplicated
|
||||
/// site therefore never accumulates an anchor and can never be badged.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Backlog above the threshold — start the clock if it is not already running. Keeping the
|
||||
/// ORIGINAL start instant is what makes the window sustained rather than sliding.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Backlog at or below the threshold — clear. This is the daily purge burst's exit path:
|
||||
/// the spike drains within a few reports and the anchor is discarded long before the dwell
|
||||
/// elapses, so the expected burst never badges the site.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="current">The anchor currently held for this site, if any.</param>
|
||||
/// <param name="report">The incoming health report.</param>
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <returns>The anchor to store.</returns>
|
||||
private DateTimeOffset? NextBacklogAboveSince(
|
||||
DateTimeOffset? current, SiteHealthReport report, DateTimeOffset now)
|
||||
{
|
||||
if (report.LocalDbOplogBacklog is not { } backlog)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
return backlog > _options.LocalDbBacklogThreshold ? current ?? now : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,32 @@ public class HealthMonitoringOptions
|
||||
/// (~4 missed 30s reports).
|
||||
/// </summary>
|
||||
public TimeSpan MetricsStaleTimeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Unacked LocalDb replication oplog depth above which a site is considered backlogged
|
||||
/// (register row 33). Crossing this alone shows nothing — the backlog must also stay
|
||||
/// above it for <see cref="LocalDbBacklogSustainedFor"/> before the Health dashboard
|
||||
/// badges the site. Must be positive. Default: <c>1000</c> entries.
|
||||
/// </summary>
|
||||
public long LocalDbBacklogThreshold { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// How long the oplog backlog must stay continuously above
|
||||
/// <see cref="LocalDbBacklogThreshold"/> before the Health dashboard badges the site
|
||||
/// (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This dwell time is the whole point of the signal, not a refinement of it. A bare
|
||||
/// threshold would fire every single day on a healthy replicated site: the `site_events`
|
||||
/// retention purge and the storage-cap trim are CDC-captured like any other write, so the
|
||||
/// backlog jumps by the deleted batch size and then drains as the peer acks it. That spike
|
||||
/// is expected and documented (see <c>docs/deployment/topology-guide.md</c> → *Reading the
|
||||
/// replication backlog*). Healthy means the backlog RETURNS TO ~0; unhealthy means it
|
||||
/// stays up. Only a sustained window can tell those apart, so the default is set well
|
||||
/// clear of a purge drain. Must be positive and no shorter than
|
||||
/// <see cref="ReportInterval"/> — a window shorter than one report interval could be
|
||||
/// satisfied by a single sample, which is exactly the bare threshold this exists to avoid.
|
||||
/// Default: 15 minutes.
|
||||
/// </remarks>
|
||||
public TimeSpan LocalDbBacklogSustainedFor { get; set; } = TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
@@ -64,5 +64,29 @@ public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase<Heal
|
||||
$"must be >= ReportInterval ({options.ReportInterval}): a stale window shorter than one " +
|
||||
"report interval would flag every healthy site, since reports cannot arrive faster than " +
|
||||
"they are emitted.");
|
||||
|
||||
builder.RequireThat(options.LocalDbBacklogThreshold > 0,
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogThreshold must be positive " +
|
||||
$"(was {options.LocalDbBacklogThreshold}); a zero/negative threshold would badge every " +
|
||||
"replicated site permanently, since an idle backlog of 0 already sits at or above it.");
|
||||
|
||||
builder.RequireThat(options.LocalDbBacklogSustainedFor > TimeSpan.Zero,
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor must be a positive duration " +
|
||||
$"(was {options.LocalDbBacklogSustainedFor}); it is the dwell time a backlog must stay " +
|
||||
"above the threshold before the site is badged.");
|
||||
|
||||
// A dwell shorter than one report interval can be satisfied by a single sample,
|
||||
// degrading the sustained check back into the bare threshold it exists to replace —
|
||||
// which would fire on every daily purge burst. Same De Morgan guard shape as the
|
||||
// pairs above: silent unless BOTH fields are positive and the dwell is too short,
|
||||
// so the dedicated positive-value checks own that failure.
|
||||
builder.RequireThat(
|
||||
!(options.ReportInterval > TimeSpan.Zero
|
||||
&& options.LocalDbBacklogSustainedFor > TimeSpan.Zero
|
||||
&& options.LocalDbBacklogSustainedFor < options.ReportInterval),
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor ({options.LocalDbBacklogSustainedFor}) " +
|
||||
$"must be >= ReportInterval ({options.ReportInterval}): a dwell shorter than one report " +
|
||||
"interval can be satisfied by a single sample, which turns the sustained check back into " +
|
||||
"a bare threshold and re-introduces the daily purge-burst false positive it exists to avoid.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,14 +162,38 @@ public interface ISiteHealthCollector
|
||||
/// service. Point-in-time: values are NOT reset on <see cref="CollectReport"/>.
|
||||
/// </summary>
|
||||
/// <param name="connected">
|
||||
/// Whether a sync session is currently running. <see langword="false"/> is the
|
||||
/// normal state on a node with no peer configured — replication ships default-OFF.
|
||||
/// Whether a sync session is currently running, or <see langword="null"/> when there
|
||||
/// is no reading to give — which is the state of a node with replication UNCONFIGURED
|
||||
/// (register row 80). <see langword="false"/> means "replication is configured on this
|
||||
/// node and the peer session is currently down", i.e. a genuine fault worth a badge;
|
||||
/// reporting that on a deliberately-unreplicated site reads as a fault that isn't one.
|
||||
/// </param>
|
||||
/// <param name="oplogBacklog">
|
||||
/// Unacked oplog entries, or <see langword="null"/> when unknown. Pass null through
|
||||
/// unchanged: a failed poll rendered as 0 would report a broken pair as healthy.
|
||||
/// Unacked oplog entries, or <see langword="null"/> when unknown (failed poll) or not
|
||||
/// applicable (replication unconfigured). Pass null through unchanged: a failed poll
|
||||
/// rendered as 0 would report a broken pair as healthy.
|
||||
/// </param>
|
||||
void SetLocalDbReplicationStatus(bool connected, long? oplogBacklog)
|
||||
void SetLocalDbReplicationStatus(bool? connected, long? oplogBacklog)
|
||||
{
|
||||
// Default no-op so test fakes do not need to be updated.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record whether this node spawned its <c>SiteReconciliationActor</c> at boot, for the
|
||||
/// <c>StartupReconcilerActive</c> field of every subsequent <see cref="CollectReport"/>
|
||||
/// (deferred-work register row 73).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called ONCE at boot by the Host, which owns the spawn decision — this is a latched
|
||||
/// boot fact, not a polled gauge, so there is no reporter hosted service behind it.
|
||||
/// A collector that is never told (central's self-report, or any test host) leaves the
|
||||
/// field null, which reads as "not reported" rather than "reconciler absent".
|
||||
/// </remarks>
|
||||
/// <param name="active">
|
||||
/// <see langword="true"/> when the reconciler was created; <see langword="false"/> when
|
||||
/// it was skipped because no <c>IDeploymentConfigFetcher</c> was available.
|
||||
/// </param>
|
||||
void SetStartupReconcilerActive(bool active)
|
||||
{
|
||||
// Default no-op so test fakes do not need to be updated.
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource
|
||||
private const string MetricDisabledInstances = "disabledInstances";
|
||||
private const string MetricAuditBacklogPending = "auditBacklogPending";
|
||||
private const string MetricEventLogWriteFailures = KpiMetrics.SiteHealth.EventLogWriteFailures;
|
||||
private const string MetricLocalDbOplogBacklog = KpiMetrics.SiteHealth.LocalDbOplogBacklog;
|
||||
private const string MetricLocalDbReplicationConnected = KpiMetrics.SiteHealth.LocalDbReplicationConnected;
|
||||
|
||||
private readonly ICentralHealthAggregator _aggregator;
|
||||
|
||||
@@ -136,6 +138,32 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource
|
||||
report.SiteAuditBacklog?.PendingCount ?? 0));
|
||||
samples.Add(Sample(
|
||||
capturedAtUtc, MetricEventLogWriteFailures, siteId, report.SiteEventLogWriteFailures));
|
||||
|
||||
// Register row 33: LocalDb replication. Both fields are NULLABLE and null is
|
||||
// load-bearing — it means "no reading" (replication not configured, as on rig
|
||||
// site-b/site-c, or a failed poll), NOT zero. So unlike the audit-backlog line
|
||||
// above, these are CONDITIONALLY emitted rather than coerced with `?? 0`:
|
||||
//
|
||||
// - A fabricated 0 backlog would render a pair that cannot read its own oplog,
|
||||
// or one that simply isn't replicated, as perfectly healthy — and would do it
|
||||
// once a minute, forever, burying the real series for the sites that are.
|
||||
// - For the connected flag, `false` and null are genuinely different states: a
|
||||
// configured link that is DOWN versus no link at all. Collapsing null to 0
|
||||
// would alarm on every unreplicated site permanently.
|
||||
//
|
||||
// The cost is that a site's sample count varies (12 unreplicated, 14 replicated).
|
||||
// That is the intended shape: a GAP in these two series means "not replicated / not
|
||||
// reporting", which is a truthful reading, whereas a flat 0 line is a lie.
|
||||
if (report.LocalDbOplogBacklog is { } backlog)
|
||||
{
|
||||
samples.Add(Sample(capturedAtUtc, MetricLocalDbOplogBacklog, siteId, backlog));
|
||||
}
|
||||
|
||||
if (report.LocalDbReplicationConnected is { } connected)
|
||||
{
|
||||
samples.Add(Sample(
|
||||
capturedAtUtc, MetricLocalDbReplicationConnected, siteId, connected ? 1 : 0));
|
||||
}
|
||||
}
|
||||
|
||||
private static KpiSample Sample(
|
||||
|
||||
@@ -22,6 +22,23 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
/// report a replication pair that cannot read its own oplog as perfectly healthy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An UNCONFIGURED node reports null for both fields</b> (register row 80). The
|
||||
/// replication engine is registered unconditionally and sits inert without a peer, so
|
||||
/// <c>ISyncStatus</c> answers <c>Connected=false</c> / <c>OplogBacklog=0</c> on a site
|
||||
/// that was never meant to replicate at all. Publishing that pair is actively misleading:
|
||||
/// a dashboard reader sees "connected: false" on a healthy unreplicated site and reads a
|
||||
/// fault, and the central null-branch machinery (conditional KPI emission, sustained-badge
|
||||
/// anchor exclusion) never receives the null it was built for. The
|
||||
/// <c>replicationConfiguredProvider</c> gate therefore short-circuits the probe and pushes
|
||||
/// <c>(null, null)</c> — "no reading", per the documented nullable semantics.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The gate is supplied by the Host, not read from configuration here</b>, mirroring
|
||||
/// <c>SiteEventLogReplicationCheck</c>: the Host owns
|
||||
/// <c>SiteLocalDbSetup.ReplicationIsConfigured</c> (the PeerAddress-OR-ApiKey rule) and
|
||||
/// HealthMonitoring must not learn to read LocalDb configuration itself.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cadence.</b> 30 s, matching <see cref="SiteEventLogFailureCountReporter"/> and
|
||||
/// <c>SiteAuditBacklogReporter</c>. Any exception during a probe is logged and swallowed;
|
||||
/// the next tick retries.
|
||||
@@ -34,6 +51,7 @@ public sealed class LocalDbReplicationStatusReporter : IHostedService, IDisposab
|
||||
|
||||
private readonly Func<bool> _connectedProvider;
|
||||
private readonly Func<long?> _oplogBacklogProvider;
|
||||
private readonly Func<bool> _replicationConfiguredProvider;
|
||||
private readonly ISiteHealthCollector _collector;
|
||||
private readonly ILogger<LocalDbReplicationStatusReporter> _logger;
|
||||
private readonly TimeSpan _refreshInterval;
|
||||
@@ -43,18 +61,26 @@ public sealed class LocalDbReplicationStatusReporter : IHostedService, IDisposab
|
||||
/// <summary>Initializes a new instance of <see cref="LocalDbReplicationStatusReporter"/>.</summary>
|
||||
/// <param name="connectedProvider">Reads whether a peer sync session is currently running.</param>
|
||||
/// <param name="oplogBacklogProvider">Reads the unacked oplog backlog, or null when unknown.</param>
|
||||
/// <param name="replicationConfiguredProvider">
|
||||
/// Reads whether replication is configured on this node at all (the Host's
|
||||
/// <c>SiteLocalDbSetup.ReplicationIsConfigured</c>). When it answers false, both fields
|
||||
/// are published as null rather than as the engine's inert false/0 defaults.
|
||||
/// </param>
|
||||
/// <param name="collector">The site health collector receiving the snapshot.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="refreshInterval">Poll interval override; defaults to 30 s.</param>
|
||||
public LocalDbReplicationStatusReporter(
|
||||
Func<bool> connectedProvider,
|
||||
Func<long?> oplogBacklogProvider,
|
||||
Func<bool> replicationConfiguredProvider,
|
||||
ISiteHealthCollector collector,
|
||||
ILogger<LocalDbReplicationStatusReporter> logger,
|
||||
TimeSpan? refreshInterval = null)
|
||||
{
|
||||
_connectedProvider = connectedProvider ?? throw new ArgumentNullException(nameof(connectedProvider));
|
||||
_oplogBacklogProvider = oplogBacklogProvider ?? throw new ArgumentNullException(nameof(oplogBacklogProvider));
|
||||
_replicationConfiguredProvider = replicationConfiguredProvider
|
||||
?? throw new ArgumentNullException(nameof(replicationConfiguredProvider));
|
||||
_collector = collector ?? throw new ArgumentNullException(nameof(collector));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_refreshInterval = refreshInterval ?? DefaultRefreshInterval;
|
||||
@@ -114,6 +140,14 @@ public sealed class LocalDbReplicationStatusReporter : IHostedService, IDisposab
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_replicationConfiguredProvider())
|
||||
{
|
||||
// Replication unconfigured: "no reading", NOT "disconnected with an empty
|
||||
// backlog". Publishing the inert engine's false/0 here is register row 80.
|
||||
_collector.SetLocalDbReplicationStatus(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
_collector.SetLocalDbReplicationStatus(
|
||||
_connectedProvider(), _oplogBacklogProvider());
|
||||
}
|
||||
|
||||
@@ -170,19 +170,33 @@ public static class ServiceCollectionExtensions
|
||||
/// a reference on the replication library, matching
|
||||
/// <see cref="AddSiteEventLogHealthMetricsBridge"/>. Pass the backlog through as
|
||||
/// nullable — coercing null to 0 would report an unreadable oplog as a healthy one.
|
||||
/// <para>
|
||||
/// <paramref name="replicationConfiguredProvider"/> is the register-row-80 gate: the
|
||||
/// replication engine is registered unconditionally and answers <c>false</c>/<c>0</c>
|
||||
/// while inert, so without it a deliberately-unreplicated site publishes
|
||||
/// "disconnected, empty backlog" instead of the documented "no reading" null. The
|
||||
/// caller supplies it — the Host owns <c>SiteLocalDbSetup.ReplicationIsConfigured</c>,
|
||||
/// exactly as it supplies <c>SiteEventLogReplicationCheck</c> to SiteEventLogging.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
/// <param name="connectedProvider">Given the root provider, returns a reader for "a sync session is running".</param>
|
||||
/// <param name="oplogBacklogProvider">Given the root provider, returns a reader for the unacked backlog (null = unknown).</param>
|
||||
/// <param name="replicationConfiguredProvider">
|
||||
/// Given the root provider, returns a reader for "replication is configured on this node".
|
||||
/// False makes the reporter publish null for BOTH fields.
|
||||
/// </param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddLocalDbReplicationHealthBridge(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, Func<bool>> connectedProvider,
|
||||
Func<IServiceProvider, Func<long?>> oplogBacklogProvider)
|
||||
Func<IServiceProvider, Func<long?>> oplogBacklogProvider,
|
||||
Func<IServiceProvider, Func<bool>> replicationConfiguredProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(connectedProvider);
|
||||
ArgumentNullException.ThrowIfNull(oplogBacklogProvider);
|
||||
ArgumentNullException.ThrowIfNull(replicationConfiguredProvider);
|
||||
|
||||
if (services.Any(d => d.ServiceType == typeof(LocalDbReplicationHealthBridgeMarker)))
|
||||
{
|
||||
@@ -193,6 +207,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddHostedService(sp => new LocalDbReplicationStatusReporter(
|
||||
connectedProvider(sp),
|
||||
oplogBacklogProvider(sp),
|
||||
replicationConfiguredProvider(sp),
|
||||
sp.GetRequiredService<ISiteHealthCollector>(),
|
||||
sp.GetRequiredService<ILogger<LocalDbReplicationStatusReporter>>()));
|
||||
|
||||
|
||||
@@ -20,8 +20,18 @@ public class SiteHealthCollector : ISiteHealthCollector
|
||||
private long _siteEventLogWriteFailures;
|
||||
// One volatile tuple rather than two independent fields: a torn read that paired a
|
||||
// fresh Connected with a stale backlog would be indistinguishable from a real state.
|
||||
// Null = the reporter has not yet run (or replication is not wired).
|
||||
private volatile Tuple<bool, long?>? _localDbReplicationStatus;
|
||||
// Null = the reporter has not yet run. Once it HAS run, an unreplicated node latches
|
||||
// (null, null) — "no reading" — rather than the inert engine's (false, 0); see
|
||||
// LocalDbReplicationStatusReporter and register row 80.
|
||||
private volatile Tuple<bool?, long?>? _localDbReplicationStatus;
|
||||
// Latched once at boot by the Host (register row 73). A tri-state int rather than a
|
||||
// bool? because `volatile` cannot be applied to Nullable<bool> (a struct) and this is
|
||||
// written on the startup thread but read on the report-sender thread:
|
||||
// 0 = never told -> report null ("not reported": central self-report, or a test host)
|
||||
// 1 = reconciler spawned
|
||||
// -1 = reconciler SKIPPED (no IDeploymentConfigFetcher)
|
||||
// Null must stay distinct from -1: "we don't know" is not "self-heal is missing".
|
||||
private volatile int _startupReconcilerActive;
|
||||
private readonly ConcurrentDictionary<string, ConnectionHealth> _connectionStatuses = new();
|
||||
private readonly ConcurrentDictionary<string, TagResolutionStatus> _tagResolutionCounts = new();
|
||||
private readonly ConcurrentDictionary<string, string> _connectionEndpoints = new();
|
||||
@@ -109,11 +119,17 @@ public class SiteHealthCollector : ISiteHealthCollector
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetLocalDbReplicationStatus(bool connected, long? oplogBacklog)
|
||||
public void SetLocalDbReplicationStatus(bool? connected, long? oplogBacklog)
|
||||
{
|
||||
_localDbReplicationStatus = Tuple.Create(connected, oplogBacklog);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetStartupReconcilerActive(bool active)
|
||||
{
|
||||
_startupReconcilerActive = active ? 1 : -1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health)
|
||||
{
|
||||
@@ -256,6 +272,7 @@ public class SiteHealthCollector : ISiteHealthCollector
|
||||
// Single read of the volatile tuple — two reads could straddle a reporter tick and
|
||||
// pair a fresh Connected with a stale backlog.
|
||||
var localDbReplication = _localDbReplicationStatus;
|
||||
var startupReconciler = _startupReconcilerActive;
|
||||
|
||||
// Snapshot current connection and tag resolution state
|
||||
var connectionStatuses = new Dictionary<string, ConnectionHealth>(_connectionStatuses);
|
||||
@@ -307,7 +324,10 @@ public class SiteHealthCollector : ISiteHealthCollector
|
||||
// with an empty backlog".
|
||||
LocalDbReplicationConnected = localDbReplication?.Item1,
|
||||
LocalDbOplogBacklog = localDbReplication?.Item2,
|
||||
SiteStreamAlarmDropCount = Interlocked.Read(ref _siteStreamAlarmDropCount)
|
||||
SiteStreamAlarmDropCount = Interlocked.Read(ref _siteStreamAlarmDropCount),
|
||||
// Tri-state: 0 stays null ("not reported"), which must never collapse into
|
||||
// false ("self-heal is missing"). Register row 73.
|
||||
StartupReconcilerActive = startupReconciler == 0 ? null : startupReconciler > 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,28 @@ public sealed record SiteHealthState
|
||||
/// </summary>
|
||||
public bool IsMetricsStale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instant this site's LocalDb replication oplog backlog first rose above
|
||||
/// <see cref="HealthMonitoringOptions.LocalDbBacklogThreshold"/> and stayed there, or
|
||||
/// <c>null</c> if the backlog is not currently above it (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The anchor for the *sustained* backlog badge: the badge shows only once
|
||||
/// <see cref="HealthMonitoringOptions.LocalDbBacklogSustainedFor"/> has elapsed since this
|
||||
/// instant. Holding a start time rather than a boolean is what makes the signal survive the
|
||||
/// expected daily purge burst — that spike raises the backlog for a drain's worth of
|
||||
/// reports and then clears this back to null, never reaching the dwell.
|
||||
/// <para>
|
||||
/// Null is set two different ways and they mean different things. A report whose backlog is
|
||||
/// at or below the threshold CLEARS it (the backlog demonstrably drained). A report whose
|
||||
/// backlog is <c>null</c> — an unreplicated site, or a failed poll — LEAVES IT UNCHANGED:
|
||||
/// null means "no reading", never zero, so a node that stops reporting mid-climb must not
|
||||
/// silently reset the clock, and an unreplicated site (which reports null forever) never
|
||||
/// starts one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public DateTimeOffset? LocalDbBacklogAboveSince { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instant of the last online↔offline flip, or <c>null</c> if the
|
||||
/// site has never changed status since it was first observed. Answers "when
|
||||
|
||||
@@ -1024,6 +1024,19 @@ akka {{
|
||||
// guarded-writes it (orphans are logged, never deleted). Requires the HTTP
|
||||
// config fetcher; if it is somehow absent the self-heal is skipped (best-effort —
|
||||
// replication remains the primary path and the next restart retries).
|
||||
// The dmProxy is passed so the pass heals the RUNTIME too (register row 67): each
|
||||
// enabled instance it restores gets an EnsureInstanceStartedCommand routed to the
|
||||
// singleton, which starts the Instance Actor if the boot-time load missed it. Going
|
||||
// through the PROXY (not a local ActorOf) keeps Instance Actors under the singleton
|
||||
// regardless of which node's reconcile pass found the gap.
|
||||
//
|
||||
// Whichever branch is taken is LATCHED onto the site health report as
|
||||
// StartupReconcilerActive (deferred-work register row 73). Before that, the skip
|
||||
// was a single boot Warning and nothing else: a node that silently lost its entire
|
||||
// self-heal path went on reporting itself perfectly healthy forever, and the only
|
||||
// evidence was one line scrolled off the top of a log. Central badges the false
|
||||
// case at Warning rather than failing the site — replication is still the primary
|
||||
// path, so the node is degraded, not down.
|
||||
if (deploymentConfigFetcher != null)
|
||||
{
|
||||
var reconcileLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
@@ -1037,17 +1050,53 @@ akka {{
|
||||
_nodeOptions.NodeName,
|
||||
reconcileLogger,
|
||||
null,
|
||||
null)),
|
||||
null,
|
||||
dmProxy)),
|
||||
"site-reconciliation");
|
||||
siteHealthCollector?.SetStartupReconcilerActive(true);
|
||||
_logger.LogInformation(
|
||||
"SiteReconciliationActor created (per-node startup self-heal) for site {SiteId} node {Node}",
|
||||
_nodeOptions.SiteId, _nodeOptions.NodeName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No IDeploymentConfigFetcher available; SiteReconciliationActor not created — "
|
||||
+ "startup self-heal disabled (replication remains the primary path)");
|
||||
siteHealthCollector?.SetStartupReconcilerActive(false);
|
||||
|
||||
// Raised from Warning to Error (register row 73). This is a misconfiguration,
|
||||
// not a degraded-but-expected state: no configuration makes the fetcher
|
||||
// legitimately absent on a site node, and the consequence is that neither the
|
||||
// file heal nor the row-67 runtime heal will ever run on this node.
|
||||
_logger.LogError(
|
||||
"No IDeploymentConfigFetcher available; SiteReconciliationActor not created for "
|
||||
+ "site {SiteId} node {Node} — startup self-heal is DISABLED (replication "
|
||||
+ "remains the primary path). Surfaced centrally as StartupReconcilerActive=false.",
|
||||
_nodeOptions.SiteId, _nodeOptions.NodeName);
|
||||
|
||||
// Also record it where an operator reviewing the site after the fact will find
|
||||
// it — the boot log line is gone by then, but the site event log is queryable
|
||||
// from the Central UI. Best-effort: a failed event write must never keep the
|
||||
// node from finishing startup, which is why this is fire-and-forget with an
|
||||
// observed continuation rather than an await.
|
||||
var bootEventLogger = _serviceProvider
|
||||
.GetService<ZB.MOM.WW.ScadaBridge.SiteEventLogging.ISiteEventLogger>();
|
||||
_ = bootEventLogger?.LogEventAsync(
|
||||
"deployment",
|
||||
"Error",
|
||||
null,
|
||||
"AkkaHostedService",
|
||||
"Startup self-heal disabled: SiteReconciliationActor was not created "
|
||||
+ "because no IDeploymentConfigFetcher is registered.",
|
||||
$"Node {_nodeOptions.NodeName} of site {_nodeOptions.SiteId} cannot "
|
||||
+ "reconcile its deployed inventory at boot. Config deploys still reach "
|
||||
+ "this node via LocalDb replication when it is up; a deploy that lands "
|
||||
+ "while it is DOWN will not be recovered on restart.")
|
||||
.ContinueWith(
|
||||
t => _logger.LogWarning(
|
||||
t.Exception,
|
||||
"Failed to write the startup-self-heal-disabled site event."),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Audit Log — site-side telemetry actor that drains the SQLite
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user