Compare commits

...

67 Commits

Author SHA1 Message Date
Joseph Doherty c7a0c92244 docs(archreview): sync PLAN-03 tasks.json — Tasks 25-26 completed (Batch 9, plan complete 26/26) 2026-07-09 02:13:13 -04:00
Joseph Doherty 1db0c32115 docs(requirements): sync SiteRuntime + DCL specs with hardening changes (C3/C4/C5 + S1-S6, P2/P4/P6, UA1/UA4 behavior notes) 2026-07-09 02:13:13 -04:00
Joseph Doherty c9ac075e39 fix(site-runtime,dcl): low-severity cleanups — observed adapter dispose (S9), alarm-filter overwrite warning (S10), bounded-channel comment (C2), invariant-culture condition fallback (C6) 2026-07-09 02:09:06 -04:00
Joseph Doherty 30886c9426 docs(archreview): sync PLAN-03 tasks.json — Tasks 22-24 completed (Batch 8) 2026-07-09 01:27:21 -04:00
Joseph Doherty 29ee9176a7 feat(site-runtime): wire ConfigFetchRetryCount into the standby replicated-config fetch (UA2) 2026-07-09 01:26:58 -04:00
Joseph Doherty c457e8f464 feat(site-runtime): cert-trust reconcile-on-join — singleton pushes trusted certs to (re)joining site nodes (UA1) 2026-07-09 01:22:13 -04:00
Joseph Doherty 39976772d9 feat(site-runtime): CertStoreActor exports trusted certs (thumbprint+DER) for node reconciliation (UA1 groundwork) 2026-07-09 01:15:51 -04:00
Joseph Doherty 667d863e58 docs(archreview): mark PLAN-03 Tasks 19-21 (Batch 7) completed in tasks.json 2026-07-09 01:10:18 -04:00
Joseph Doherty 2efe1a4202 feat(site-runtime): persist native-alarm display metadata so rehydrated conditions render fully (UA4) 2026-07-09 01:09:13 -04:00
Joseph Doherty 326d945d85 perf(site-runtime): coalesce native-alarm mirror persistence into batched flushes (P4) 2026-07-09 01:05:20 -04:00
Joseph Doherty d2feb92bfd fix(site-runtime): site SQLite runs WAL with an explicit busy-timeout floor (S8) — concurrent writers stop serializing on the rollback journal 2026-07-09 01:00:12 -04:00
Joseph Doherty dd16cd52b2 docs(archreview): mark PLAN-03 Tasks 16-18 (Batch 6) completed in tasks.json 2026-07-09 00:55:43 -04:00
Joseph Doherty d124025cc7 perf(site-runtime): per-attribute child routing in InstanceActor — attribute changes reach only interested children (P2) 2026-07-09 00:55:26 -04:00
Joseph Doherty 1673268aee perf(dcl): tag->instances reverse index replaces per-update all-instances scan (P3) 2026-07-09 00:48:22 -04:00
Joseph Doherty f294b46263 fix(dcl): capture adapter into locals before background tasks — no off-thread reads of the mutable _adapter field (S7) 2026-07-09 00:44:09 -04:00
Joseph Doherty ef69fab6f6 docs(archreview): register PLAN-03 Task 15 readiness-handshake deviation as a follow-up (owner: Task 26 doc capture) 2026-07-09 00:36:20 -04:00
Joseph Doherty faa9d34cd1 docs(archreview): PLAN-03 tasks.json — mark T13-T15 complete (Batch 5) 2026-07-09 00:33:53 -04:00
Joseph Doherty 957db62df1 fix(site-runtime): deployment whose Instance Actor dies during init reports Failed, exactly-once reply (S6)
Success now requires BOTH persistence commit AND an InstanceActorInitialized
readiness signal from the actor's PreStart — persistence can commit before the
actor's async init has run or failed, so persistence alone must not report
Success. An actor that dies during init never signals readiness; the Terminated
fallback fails that deployment and rolls back the optimistic state. The
persisted-row rollback is deferred until the store commits so it cannot race the
optimistic write.

Deviation from plan Task 15: the plan's swallow-only guard did not handle the
persistence-first ordering (empirically the store commits before the Terminated
signal, so Success was reported for a dead actor). Added the readiness handshake
to make the join deterministic.
2026-07-09 00:33:40 -04:00
Joseph Doherty db5ad02cc9 test(site-runtime): harden scheduler OldestBusyAge gauge assertion — assert non-null, not strictly-positive (TickCount64 ~15ms granularity flakes under load) 2026-07-09 00:33:40 -04:00
Joseph Doherty 9343b4f30f fix(site-runtime): watch every Instance Actor; unexpected termination evicts the dead ref from routing (S6) 2026-07-09 00:20:12 -04:00
Joseph Doherty 2dabbbdde0 fix(site-runtime): retry the startup deployed-config load on SQLite failure (S5) — no more silent zero-instance node 2026-07-09 00:17:36 -04:00
Joseph Doherty b126fda22e chore(archreview): PLAN-03 tasks.json — mark T10-T12 complete 2026-07-09 00:05:24 -04:00
Joseph Doherty fa556b4f8d fix(site-runtime): NativeAlarmActor retries a lost subscribe response, not only a failed one (S4) 2026-07-09 00:05:09 -04:00
Joseph Doherty feb6f34e90 fix(site-runtime): InstanceActor retries failed/lost tag subscriptions per connection (S4/UA6) — closes the unknown-connection ordering race 2026-07-09 00:03:44 -04:00
Joseph Doherty bd89c1474e perf(site-runtime): AlarmActor trigger expressions evaluate on the script scheduler (P1) 2026-07-08 23:59:54 -04:00
Joseph Doherty 6baf5a1d45 chore(archreview): PLAN-03 tasks.json — mark T7-T9 complete 2026-07-08 23:50:35 -04:00
Joseph Doherty 5aee90d155 perf(site-runtime): ScriptActor trigger expressions evaluate on the script scheduler, coalesced, edge state actor-confined (P1) 2026-07-08 23:50:21 -04:00
Joseph Doherty 467edd74ac feat(site-runtime): stuck-script watchdog names the script holding a scheduler thread past timeout (S2) 2026-07-08 23:46:48 -04:00
Joseph Doherty dddafc025e feat(site-runtime): periodic scheduler-stats reporter feeding site health (S2/UA5) 2026-07-08 23:43:09 -04:00
Joseph Doherty d44b6a0e52 chore(archreview): PLAN-03 tasks.json — mark T4-T6 complete 2026-07-08 23:38:07 -04:00
Joseph Doherty 7d50555fd3 feat(health): additive script-scheduler stats on ISiteHealthCollector + SiteHealthReport (S2/UA5) 2026-07-08 23:37:50 -04:00
Joseph Doherty 474ec8a221 feat(site-runtime): script scheduler gauges — queue depth, busy threads, oldest-busy age (S2/UA5) 2026-07-08 23:35:09 -04:00
Joseph Doherty b83d1ed19c fix(site-runtime): site-side compile failure now rejects the deployment per spec (S3) — synchronous validation gate before actor creation
Validation runs on the actor thread (not off-thread as the plan sketched): piping
the verdict back to self reorders concurrent deploys relative to each other and to
delete/disable, breaking the redeploy-supersede FIFO ordering (SR020/SR029). A deploy
is an infrequent admin command, so a brief synchronous Roslyn compile is acceptable.
2026-07-08 23:33:48 -04:00
Joseph Doherty 2caab79c0a chore(archreview): PLAN-03 tasks.json — mark T1-T3 complete 2026-07-08 23:22:06 -04:00
Joseph Doherty e20c329cad feat(site-runtime): pure deploy-time compile validator for flattened configs (S3 groundwork) 2026-07-08 23:21:42 -04:00
Joseph Doherty a8a01f026b fix(dcl): cancel old event loop + dispose old MxGateway client on reconnect (S1) 2026-07-08 23:19:11 -04:00
Joseph Doherty 11eb5157e6 fix(dcl): dispose+detach previous OPC UA client on reconnect (S1) — stops session leak and stale ConnectionLost flapping 2026-07-08 23:16:27 -04:00
Joseph Doherty 5d5c747517 docs(archreview): mark PLAN-02 complete (24/24, merged) in master tracker 2026-07-08 23:11:52 -04:00
Joseph Doherty ce239f6f8d docs(archreview): PLAN-02 tasks.json — mark T21,T22,T23,T24 complete (final batch) 2026-07-08 21:59:13 -04:00
Joseph Doherty ae94b22d7d test(reconciliation): pin inclusive (>=) cursor reads at the site (audit + site-calls); EventId/upsert dedup absorbs boundary duplicates 2026-07-08 21:58:57 -04:00
Joseph Doherty 846279f6a8 chore(communication): low-severity cleanup — eviction counting, shared ingest timeout, split app heartbeat interval, doc notes 2026-07-08 21:54:42 -04:00
Joseph Doherty 11c72ecf0f feat(health): oldest-parked-message age on the site health report (parked-retention aging signal) 2026-07-08 21:49:29 -04:00
Joseph Doherty 0321ad0c20 feat(store-and-forward): peer-join full-buffer resync — standby anti-entropy for the S&F buffer 2026-07-08 21:43:11 -04:00
Joseph Doherty e8996f859b docs(archreview): PLAN-02 tasks.json — mark T18,T19,T20 complete (batch 6) 2026-07-08 21:22:38 -04:00
Joseph Doherty 3eef9d1077 feat(store-and-forward): GetAllMessagesAsync/ReplaceAllAsync storage primitives for standby buffer resync 2026-07-08 21:22:25 -04:00
Joseph Doherty c6ea332c94 perf(store-and-forward): post audit-observer notifications to an ordered single-reader channel, latency-isolating the sweep 2026-07-08 21:19:59 -04:00
Joseph Doherty 5fe1b9747b perf(store-and-forward): epoch-ms due-check with additive column, backfill, and (status, due) index 2026-07-08 21:14:26 -04:00
Joseph Doherty 992f343f11 docs(archreview): PLAN-02 tasks.json — mark T13,T15,T17 complete (batch 5) 2026-07-08 21:02:15 -04:00
Joseph Doherty 499a37ac78 perf(store-and-forward): enable WAL + per-connection busy_timeout on the S&F SQLite store 2026-07-08 21:01:53 -04:00
Joseph Doherty 7626d5ba34 test(communication): pin wire round-trip + type identity of cross-cluster message contracts 2026-07-08 21:00:02 -04:00
Joseph Doherty 4bdd5f0e4a fix(notifications): Notify.Send enqueues unbounded (maxRetries 0) and defers to sweep — no 30s script-thread block, no stranding 2026-07-08 20:56:09 -04:00
Joseph Doherty e2944d201c docs(archreview): PLAN-02 tasks.json — mark T11,T12,T14 complete (batch 4) 2026-07-08 20:48:57 -04:00
Joseph Doherty c3a8576863 fix(notifications): park corrupt buffered notification payloads instead of silently discarding as delivered (supersedes StoreAndForward-018) 2026-07-08 20:48:16 -04:00
Joseph Doherty 05a71a0260 feat(store-and-forward): deferToSweep enqueue mode with immediate sweep kick for latency-sensitive callers 2026-07-08 20:46:08 -04:00
Joseph Doherty 283ce0647c fix(store-and-forward): standby applies Add/Park/Requeue as upserts so a lost Add self-heals 2026-07-08 20:42:39 -04:00
Joseph Doherty 9fe3452790 docs(archreview): PLAN-02 tasks.json — mark T8,T9,T16 complete (batch 3) 2026-07-08 20:12:03 -04:00
Joseph Doherty 66cb0febad fix(communication): replicate heartbeat marks to the peer central node (closes post-failover offline blind window) 2026-07-08 20:11:54 -04:00
Joseph Doherty 59d9cba621 perf(store-and-forward): parallel per-target sweep lanes (cap 4), sequential within lane 2026-07-08 20:07:30 -04:00
Joseph Doherty 1459f9407f perf(store-and-forward): short-circuit a (category,target) lane after its first transient failure per sweep 2026-07-08 20:06:06 -04:00
Joseph Doherty 03dcd55958 docs(archreview): PLAN-02 tasks.json — mark T4,T6,T7 complete (batch 2) 2026-07-08 17:48:39 -04:00
Joseph Doherty 41ed57421d fix(host): wire S&F active-node delivery gate on site nodes (SelfIsPrimary); align S&F doc standby-passive wording 2026-07-08 17:48:31 -04:00
Joseph Doherty 1a53b8082a perf(store-and-forward): bound retry sweep with SweepBatchLimit (default 500) on the due-rows query 2026-07-08 17:45:11 -04:00
Joseph Doherty e75cd6f3d8 fix(communication): debug-stream teardown/failover unsubscribes via TryGet, never creates or disposes shared channels 2026-07-08 17:43:40 -04:00
Joseph Doherty 80013639ef docs(archreview): PLAN-02 tasks.json — mark T3,T5,T10 complete (batch 1) 2026-07-08 17:35:39 -04:00
Joseph Doherty 70e7dfd7b9 fix(store-and-forward): inline ordered replication dispatch; Warning + counter on replication failures 2026-07-08 17:35:29 -04:00
Joseph Doherty ca5e79e922 fix(communication): key gRPC client cache by (site, endpoint) so per-session failover cannot dispose shared channels 2026-07-08 17:33:03 -04:00
Joseph Doherty 76c10c5de6 fix(store-and-forward): gate the retry sweep behind an active-node delivery gate (standby must be passive) 2026-07-08 17:30:21 -04:00
85 changed files with 5465 additions and 486 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
- Notification Service: SMTP with OAuth2 Client Credentials (Microsoft 365) or Basic Auth. BCC delivery, plain text.
- Notification delivery is central-only: sites store-and-forward notifications to the central cluster (target = central, not SMTP); sites never talk to SMTP. Notification lists and SMTP config are no longer deployed to sites; recipient resolution happens at central, at delivery time.
- Notification lists carry a `Type` discriminator (`Email` and `Sms`). `Notify.To("list")` is type-agnostic; delivery is via per-type `INotificationDeliveryAdapter` (Email via SMTP; Sms via Twilio REST — `SmsNotificationDeliveryAdapter`, no SDK, one POST per recipient, per-recipient rollup). List Type is fixed after creation.
- `Notify.Send` is async — returns a `NotificationId` (GUID, idempotency key) status handle immediately. `Notify.Status(notificationId)` returns a status record (status, retry count, last error, key timestamps); answered site-locally as `Forwarding` while still in the site S&F buffer, otherwise round-trips to central.
- `Notify.Send` is async and **enqueue-only** — it buffers the notification into the local SQLite S&F store and returns a `NotificationId` (GUID, idempotency key) status handle immediately; it never runs the forwarder's central Ask inline on the script thread (`deferToSweep: true` buffers due-immediately + kicks a background sweep), so its worst-case latency is the local insert whether central is up or down. It enqueues with `maxRetries: 0` (the "no limit" escape hatch), so notifications retry until central acks and are **never parked for retry exhaustion** — only a corrupt payload parks them (arch-review 02, Tasks 13/14). `Notify.Status(notificationId)` returns a status record (status, retry count, last error, key timestamps); answered site-locally as `Forwarding` while still in the site S&F buffer, otherwise round-trips to central.
- SMS delivery adapter (T9/T10, 2026-06-19): `SmsNotificationDeliveryAdapter` — Twilio REST, no SDK, HTTP Basic auth (`AccountSid:AuthToken`), one `POST .../Messages.json` per recipient. `SmsConfiguration` entity (`AccountSid`, `AuthToken` encrypted via Data Protection, `FromNumber`/`MessagingServiceSid`, `ApiBaseUrl`, timeout/retry) stored centrally; managed Admin-only via CLI `notification sms list|update` + Central UI `/notifications/sms`. Central-only (never deployed to sites). Per-recipient rollup: all-accepted → Delivered; any-transient → retry/park; mix → delivered-to-good + note; all-permanent → Park. `SmsConfiguration` (Auth Token in SecretsBlock) travels in Transport bundles via `--sms-configs`.
- Inbound API: `POST /api/{methodName}`, `X-API-Key` header, flat JSON, extended type system (Object, List).
+8 -2
View File
@@ -11,8 +11,8 @@ Every plan follows the TDD bite-sized-task format and ships a co-located `.tasks
| Plan | Domain | Tasks | Done | Status | Findings coverage |
|------|--------|------:|-----:|--------|-------------------|
| [PLAN-01](PLAN-01-cluster-host-failover.md) | Cluster, Host & Failover | 23 | 23 | ✅ Complete (all 23 tasks; branch not yet merged) | 28 findings: 20 fixed, 3 → other plans, 3 accepted, 2 roadmap-deferred |
| [PLAN-02](PLAN-02-communication-store-and-forward.md) | Communication & S&F | 24 | 2 | 🟨 In progress (P0: T1, T2) | 28 findings: 25 fixed, 1 → plan 01, 1 deferred (serializer swap), 1 won't-fix |
| [PLAN-03](PLAN-03-site-runtime-dcl.md) | Site Runtime & DCL | 26 | 0 | ⬜ Not started | 28 actionable: 25 fixed, 3 deferred with rationale |
| [PLAN-02](PLAN-02-communication-store-and-forward.md) | Communication & S&F | 24 | 24 | ✅ Complete (all 24 tasks; merged to `main`) | 28 findings: 25 fixed, 1 → plan 01, 1 deferred (serializer swap), 1 won't-fix |
| [PLAN-03](PLAN-03-site-runtime-dcl.md) | Site Runtime & DCL | 26 | 0 | 🟨 In progress (Wave 1) | 28 actionable: 25 fixed, 3 deferred with rationale |
| [PLAN-04](PLAN-04-data-audit-backbone.md) | Data & Audit Backbone | 26 | 0 | ⬜ Not started | 27 items: 22 fixed, 4 deferred, 1 won't-fix (incl. NodeB reconcile from report 08) |
| [PLAN-05](PLAN-05-templates-deployment-transport.md) | Templates, Deployment & Transport | 25 | 1 | 🟨 In progress (P0: T1) | 33 line-items: 27 fixed, 6 deferred with rationale |
| [PLAN-06](PLAN-06-edge-integrations.md) | Edge Integrations | 24 | 0 | ⬜ Not started | 24 findings: 21 fixed, 3 deferred/won't-fix |
@@ -23,6 +23,8 @@ Status values: ⬜ Not started · 🟨 In progress · ✅ Complete · ⏸ Blocke
**Progress:** P0 (Wave 0) complete — **10 of 192 tasks done** (2026-07-08): PLAN-01 T1/T3/T4, PLAN-02 T1/T2, PLAN-05 T1, PLAN-07 T1/T2/T3, PLAN-08 T1. Merged to `main` (`3f8e18c6`). One follow-up surfaced during execution — see the [active-node-crash SBR gap](#follow-up-discovered-during-p0-execution-2026-07-08) in the deferred registry.
**Wave 1 — PLAN-02 COMPLETE (2026-07-08):** all 24 tasks delivered and **merged to `main`** (`ce239f6f`). Store-and-Forward: active-node delivery gate (`Func<bool>` seam), bounded/short-circuiting retry sweep, upsert-based standby applies + anti-entropy peer resync, WAL+busy_timeout, epoch-ms due-check, ordered single-reader audit-observer channel; `Notify.Send` enqueue-only (`deferToSweep`, `maxRetries:0`, corrupt-payload parks not discards); gRPC channel keying + DropOldest debug-stream eviction counting + 5s application heartbeat; oldest-parked-age health field. Docker cluster rebuilt on merged image (Healthy, 5 singletons, corrected active-node semantics) and 3 sites seeded. Initiative total: **52 of 192** (sum of the Done column above).
**Wave 1 — PLAN-01 COMPLETE (2026-07-08):** all 23 tasks delivered on worktree branch `worktree-archreview-wave1-plan01` (**NOT yet merged/pushed**). Beyond the P0/early-Wave-1 spine (SBR downing + two-node rig T1/T3/T4; oldest-member active-node unification via `ClusterActivityEvaluator` T5–T8; `CentralSingletonRegistrar` drains T2/T9/T10; health-report acking T11–T12), Wave 1 also landed: metrics-staleness + status-transition state machine and its dashboard surface (T13/T14), deleted-site eviction from the aggregator (T18), seed-vs-metrics-port validator + `AllowSingleNodeCluster` + docker `stop_grace_period` (T15–T17), rate-limited dead-letter warnings (T19), the down-if-alone process-exit recovery loop (T20), the `failover-drill.sh` SIGKILL drill (T21), and the REQ-HOST-6 + Traefik/Host doc reconciliation with unused-Akka.Hosting-package removal (T22/T23). PLAN-01 now **23 / 23**. Initiative total: **30 of 192**. Each task TDD + pathspec-committed; full solution builds clean (0 warnings). Three deploy-artifact sub-edits (`deploy/wonder-app-vd03/` appsettings + install.ps1, Tasks 16/20/23) were deferred because that production artifact is not tracked in this repo — logged in the deferred register below.
## P0 — Do These First (regardless of plan order)
@@ -94,3 +96,7 @@ Consolidated in **PLAN-08 Task 11** → `docs/plans/2026-07-08-deferred-work-reg
### Deferred during Wave 1 PLAN-01 execution (2026-07-08)
- **`deploy/wonder-app-vd03/` overlay edits not applied — that production deploy artifact is not tracked in this repo.** PLAN-01 Tasks 16, 20, and 23 each specified a small edit to the on-host deployment overlay (`appsettings.Central.json`: add `AllowSingleNodeCluster: true` + drop the phantom seed, add `NodeName: central-a`; `install.ps1`: add `sc.exe failure` service-recovery actions for the down-if-alone restart contract). Only the *deployment-record* doc (`docs/deployment/deployment-records/2026-06-27-wonder-app-vd03.md`) is version-controlled; the `deploy/wonder-app-vd03/` artifact directory itself is not present in the repo (production config kept out of source control). The **code/validator/doc** halves of all three tasks shipped and are what make the features usable (`AllowSingleNodeCluster` flag + validator, the `IHostApplicationLifetime` process-exit watchdog, the oldest-member Traefik/Host doc reconciliation + TLS roadmap note). **Owner: whoever maintains the on-host artifact** — apply the three overlay edits out-of-band on `wonder-app-vd03` (see the referenced task steps); without `NodeName` every audit row from that deployment stamps a NULL `SourceNode`, and without the service-recovery actions a self-downed node will not auto-restart.
### Plan deviations during Wave 1 PLAN-03 execution (2026-07-09)
- **Task 15 (S6) — deploy-success reporting redesigned: readiness handshake, not swallow-only.** The plan's Task 15 relied on the `Terminated` signal beating the persistence result, so a stale persistence `Success` could simply be swallowed once the init-failure path had already replied `Failed`. **In practice the store commits FIRST** (the optimistic `StoreDeployedConfigAsync` completes before the child's async `PreStart` throws), so the swallow-only guard still reported `Success` for an actor that then died — the exact S6 bug. Fix as shipped makes the join deterministic: `InstanceActor.PreStart` now sends a new `InstanceActorInitialized` readiness message to the DeploymentManager as its final init step, and deployment `Success` requires **both** the persistence commit **and** that signal (`InFlightDeploy.Persisted && Initialized`); an actor that dies during init never signals readiness, so the `Terminated` fallback fails the deployment and rolls back the optimistic state. A second, related bug was fixed at the same time: the optimistic `Store…` and the rollback `Remove…` were independent `Task.Run`s with no ordering guarantee, so the persisted-row rollback is now **deferred until the store commits** (`_initFailedPendingRowRemoval`) and can never race ahead of it. New public surface: `Messages/InstanceActorInitialized.cs` (additive). **Follow-up owner: PLAN-03 Task 26** — the design-doc updates must describe the two-signal deploy-success join + the readiness message (alongside Task 4's already-flagged synchronous-compile-gate deviation). No further code work outstanding; this is a doc-capture follow-up.
@@ -1,30 +1,186 @@
{
"planPath": "archreview/plans/PLAN-02-communication-store-and-forward.md",
"tasks": [
{ "id": 1, "subject": "Task 1: Generation-suffixed, sanitized ClusterClient actor names", "status": "completed", "blockedBy": [] },
{ "id": 2, "subject": "Task 2: Guard client creation in the refresh loop + real-factory address-edit lifecycle test", "status": "completed", "blockedBy": [1] },
{ "id": 3, "subject": "Task 3: Active-node delivery gate on the S&F retry sweep", "status": "pending", "blockedBy": [] },
{ "id": 4, "subject": "Task 4: Wire the delivery gate in the Host + align the S&F design doc", "status": "pending", "blockedBy": [3] },
{ "id": 5, "subject": "Task 5: Key the gRPC client cache by (site, endpoint)", "status": "pending", "blockedBy": [] },
{ "id": 6, "subject": "Task 6: Endpoint-safe unsubscribe in DebugStreamBridgeActor + concurrent-session regression test", "status": "pending", "blockedBy": [5] },
{ "id": 7, "subject": "Task 7: Bound the retry sweep with a batch LIMIT", "status": "pending", "blockedBy": [3] },
{ "id": 8, "subject": "Task 8: Per-target short-circuit in the retry sweep", "status": "pending", "blockedBy": [7] },
{ "id": 9, "subject": "Task 9: Parallel per-target lanes with a small concurrency cap", "status": "pending", "blockedBy": [8] },
{ "id": 10, "subject": "Task 10: Ordered, observable replication dispatch", "status": "pending", "blockedBy": [] },
{ "id": 11, "subject": "Task 11: Upsert-based standby applies for Add/Park/Requeue", "status": "pending", "blockedBy": [7, 10] },
{ "id": 12, "subject": "Task 12: deferToSweep enqueue mode + immediate sweep kick", "status": "pending", "blockedBy": [9] },
{ "id": 13, "subject": "Task 13: Unstrand and unblock Notify.Send (maxRetries 0 + deferToSweep) + docs", "status": "pending", "blockedBy": [12] },
{ "id": 14, "subject": "Task 14: Park (don't silently drop) corrupt buffered notifications", "status": "pending", "blockedBy": [] },
{ "id": 15, "subject": "Task 15: Wire-format pin tests for cross-cluster contracts", "status": "pending", "blockedBy": [] },
{ "id": 16, "subject": "Task 16: Replicate heartbeat marks to the peer central node", "status": "pending", "blockedBy": [2] },
{ "id": 17, "subject": "Task 17: S&F SQLite hygiene — WAL + busy_timeout", "status": "pending", "blockedBy": [11] },
{ "id": 18, "subject": "Task 18: Epoch-ms due-check (replace per-row julianday parsing)", "status": "pending", "blockedBy": [17] },
{ "id": 19, "subject": "Task 19: Decouple audit-observer notification from the sweep (ordered channel)", "status": "pending", "blockedBy": [12] },
{ "id": 20, "subject": "Task 20: Buffer resync storage primitives (GetAllMessagesAsync / ReplaceAllAsync)", "status": "pending", "blockedBy": [18] },
{ "id": 21, "subject": "Task 21: Peer-join S&F buffer resync (anti-entropy)", "status": "pending", "blockedBy": [10, 20] },
{ "id": 22, "subject": "Task 22: Conventions & observability cleanup batch (Lows)", "status": "pending", "blockedBy": [16] },
{ "id": 23, "subject": "Task 23: Oldest-parked-age health metric (parked-retention aging signal)", "status": "pending", "blockedBy": [20] },
{ "id": 24, "subject": "Task 24: Pin the reconciliation cursor boundary contract (>= at the site read)", "status": "pending", "blockedBy": [] }
{
"id": 1,
"subject": "Task 1: Generation-suffixed, sanitized ClusterClient actor names",
"status": "completed",
"blockedBy": []
},
{
"id": 2,
"subject": "Task 2: Guard client creation in the refresh loop + real-factory address-edit lifecycle test",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: Active-node delivery gate on the S&F retry sweep",
"status": "completed",
"blockedBy": []
},
{
"id": 4,
"subject": "Task 4: Wire the delivery gate in the Host + align the S&F design doc",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 5,
"subject": "Task 5: Key the gRPC client cache by (site, endpoint)",
"status": "completed",
"blockedBy": []
},
{
"id": 6,
"subject": "Task 6: Endpoint-safe unsubscribe in DebugStreamBridgeActor + concurrent-session regression test",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 7,
"subject": "Task 7: Bound the retry sweep with a batch LIMIT",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 8,
"subject": "Task 8: Per-target short-circuit in the retry sweep",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 9,
"subject": "Task 9: Parallel per-target lanes with a small concurrency cap",
"status": "completed",
"blockedBy": [
8
]
},
{
"id": 10,
"subject": "Task 10: Ordered, observable replication dispatch",
"status": "completed",
"blockedBy": []
},
{
"id": 11,
"subject": "Task 11: Upsert-based standby applies for Add/Park/Requeue",
"status": "completed",
"blockedBy": [
7,
10
]
},
{
"id": 12,
"subject": "Task 12: deferToSweep enqueue mode + immediate sweep kick",
"status": "completed",
"blockedBy": [
9
]
},
{
"id": 13,
"subject": "Task 13: Unstrand and unblock Notify.Send (maxRetries 0 + deferToSweep) + docs",
"status": "completed",
"blockedBy": [
12
]
},
{
"id": 14,
"subject": "Task 14: Park (don't silently drop) corrupt buffered notifications",
"status": "completed",
"blockedBy": []
},
{
"id": 15,
"subject": "Task 15: Wire-format pin tests for cross-cluster contracts",
"status": "completed",
"blockedBy": []
},
{
"id": 16,
"subject": "Task 16: Replicate heartbeat marks to the peer central node",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 17,
"subject": "Task 17: S&F SQLite hygiene \u2014 WAL + busy_timeout",
"status": "completed",
"blockedBy": [
11
]
},
{
"id": 18,
"subject": "Task 18: Epoch-ms due-check (replace per-row julianday parsing)",
"status": "completed",
"blockedBy": [
17
]
},
{
"id": 19,
"subject": "Task 19: Decouple audit-observer notification from the sweep (ordered channel)",
"status": "completed",
"blockedBy": [
12
]
},
{
"id": 20,
"subject": "Task 20: Buffer resync storage primitives (GetAllMessagesAsync / ReplaceAllAsync)",
"status": "completed",
"blockedBy": [
18
]
},
{
"id": 21,
"subject": "Task 21: Peer-join S&F buffer resync (anti-entropy)",
"status": "completed",
"blockedBy": [
10,
20
]
},
{
"id": 22,
"subject": "Task 22: Conventions & observability cleanup batch (Lows)",
"status": "completed",
"blockedBy": [
16
]
},
{
"id": 23,
"subject": "Task 23: Oldest-parked-age health metric (parked-retention aging signal)",
"status": "completed",
"blockedBy": [
20
]
},
{
"id": 24,
"subject": "Task 24: Pin the reconciliation cursor boundary contract (>= at the site read)",
"status": "completed",
"blockedBy": []
}
],
"lastUpdated": "2026-07-08"
}
+1 -1
View File
@@ -1264,7 +1264,7 @@ public void SecondAlarmSubscriber_WithDifferentFilter_LogsOverwriteWarning()
- **S3** (Script Compilation Errors section ~474–477): add "Enforced at the site by a pre-compile validation gate in the Deployment Manager: all scripts, alarm on-trigger scripts, and trigger expressions are compiled off-thread before the Instance Actor is created or the config persisted; any failure rejects the deployment with the compile errors."
- **S4/UA6** (Instance Actor / DCL interaction): add "A failed or unanswered tag-subscription handshake is retried on a fixed interval per connection (default 5 s), mirroring the native-alarm subscribe retry."
- **S5**: note the fixed-interval retry of the startup deployed-config load.
- **S6**: note that all Instance Actors are watched and an unexpected termination evicts the ref and fails any in-flight deployment.
- **S6**: note that all Instance Actors are watched and an unexpected termination evicts the ref and fails any in-flight deployment. **Deviation from the plan (2026-07-09, see master-tracker "Plan deviations during Wave 1 PLAN-03 execution"):** deploy-success is NOT reported on persistence alone — the store can commit before the actor's async `PreStart` throws, so the design uses a **two-signal join**: the Instance Actor sends a new `InstanceActorInitialized` readiness message at the end of a successful `PreStart`, and the Deployment Manager reports `Success` only once BOTH persistence has committed AND that signal has arrived. An actor that dies during init never signals readiness → the `Terminated` fallback replies `Failed` and rolls the optimistic state back (the persisted-row rollback is deferred until the store commits so it cannot race the write). Document the readiness message + the join.
- **UA1** (cert trust section ~125): add "On a site node (re)joining the cluster, the Deployment Manager singleton pushes its trusted-cert set to the joining node's CertStore (additive union). Removal reconciliation and central persistence of trust decisions remain the documented follow-up."
- **UA4** (native alarm mirror section): "Persisted mirror rows include display metadata (type/category/message), so rehydrated conditions render fully before the first source snapshot."
- **P2** note (attribute change fan-out ~191): "The Instance Actor routes attribute changes per monitored attribute (Expression-trigger children receive all changes); child actors keep their own trigger gates as defense in depth."
@@ -1,32 +1,32 @@
{
"planPath": "archreview/plans/PLAN-03-site-runtime-dcl.md",
"tasks": [
{ "id": 1, "subject": "Task 1: OpcUaDataConnection — dispose + detach the previous client on reconnect (S1)", "status": "pending", "blockedBy": [] },
{ "id": 2, "subject": "Task 2: MxGatewayDataConnection — dispose previous client + cancel previous event loop on reconnect (S1)", "status": "pending", "blockedBy": [] },
{ "id": 3, "subject": "Task 3: Pure deploy-time compile validator for a flattened config (S3 groundwork)", "status": "pending", "blockedBy": [] },
{ "id": 4, "subject": "Task 4: Reject the deployment on site-side compile failure per spec (S3)", "status": "pending", "blockedBy": [3] },
{ "id": 5, "subject": "Task 5: ScriptExecutionScheduler gauges — queue depth, busy threads, oldest-busy age (S2/UA5)", "status": "pending", "blockedBy": [] },
{ "id": 6, "subject": "Task 6: Surface scheduler stats through ISiteHealthCollector + SiteHealthReport (S2/UA5)", "status": "pending", "blockedBy": [5] },
{ "id": 7, "subject": "Task 7: Periodic scheduler-stats reporter hosted service (S2/UA5)", "status": "pending", "blockedBy": [5, 6] },
{ "id": 8, "subject": "Task 8: Stuck-script watchdog — name the script whose thread never came back (S2)", "status": "pending", "blockedBy": [] },
{ "id": 9, "subject": "Task 9: ScriptActor — trigger-expression evaluation off the dispatcher, coalesced (P1)", "status": "pending", "blockedBy": [] },
{ "id": 10, "subject": "Task 10: AlarmActor — async trigger-expression evaluation on the script scheduler (P1)", "status": "pending", "blockedBy": [] },
{ "id": 11, "subject": "Task 11: InstanceActor — retry failed/lost tag subscriptions per connection (S4/UA6)", "status": "pending", "blockedBy": [4, 8] },
{ "id": 12, "subject": "Task 12: NativeAlarmActor — retry on lost subscribe response (S4)", "status": "pending", "blockedBy": [] },
{ "id": 13, "subject": "Task 13: DeploymentManager — retry the startup config load (S5)", "status": "pending", "blockedBy": [4] },
{ "id": 14, "subject": "Task 14: Watch all Instance Actors; clean the map on unexpected termination (S6)", "status": "pending", "blockedBy": [13] },
{ "id": 15, "subject": "Task 15: Fail the in-flight deployment when its Instance Actor dies during init (S6)", "status": "pending", "blockedBy": [14] },
{ "id": 16, "subject": "Task 16: DataConnectionActor — capture _adapter into locals before background tasks (S7)", "status": "pending", "blockedBy": [] },
{ "id": 17, "subject": "Task 17: DCL — tag→instances reverse index for the value fan-out hot path (P3)", "status": "pending", "blockedBy": [16] },
{ "id": 18, "subject": "Task 18: InstanceActor — per-attribute child-subscription routing (P2)", "status": "pending", "blockedBy": [9, 11] },
{ "id": 19, "subject": "Task 19: SiteStorageService — WAL journal mode + busy timeout (S8)", "status": "pending", "blockedBy": [] },
{ "id": 20, "subject": "Task 20: NativeAlarmActor — coalesce per-transition SQLite upserts (P4)", "status": "pending", "blockedBy": [12, 19] },
{ "id": 21, "subject": "Task 21: Persist native-alarm display metadata for rehydration (UA4)", "status": "pending", "blockedBy": [20] },
{ "id": 22, "subject": "Task 22: CertStoreActor — export trusted certs (thumbprint + DER) for reconciliation (UA1 groundwork)", "status": "pending", "blockedBy": [] },
{ "id": 23, "subject": "Task 23: Cert-trust reconcile-on-join — singleton pushes trusted set to (re)joining site node (UA1)", "status": "pending", "blockedBy": [15, 22] },
{ "id": 24, "subject": "Task 24: Wire ConfigFetchRetryCount into the standby replication fetch (UA2)", "status": "pending", "blockedBy": [11] },
{ "id": 25, "subject": "Task 25: Low-severity code cleanups — S9, S10, C2, C6", "status": "pending", "blockedBy": [9, 17] },
{ "id": 26, "subject": "Task 26: Design-doc updates — C3, C4, C5 + behavior notes for landed changes", "status": "pending", "blockedBy": [1, 2, 4, 7, 8, 11, 13, 15, 18, 21, 23, 24, 25] }
{ "id": 1, "subject": "Task 1: OpcUaDataConnection — dispose + detach the previous client on reconnect (S1)", "status": "completed", "blockedBy": [] },
{ "id": 2, "subject": "Task 2: MxGatewayDataConnection — dispose previous client + cancel previous event loop on reconnect (S1)", "status": "completed", "blockedBy": [] },
{ "id": 3, "subject": "Task 3: Pure deploy-time compile validator for a flattened config (S3 groundwork)", "status": "completed", "blockedBy": [] },
{ "id": 4, "subject": "Task 4: Reject the deployment on site-side compile failure per spec (S3)", "status": "completed", "blockedBy": [3] },
{ "id": 5, "subject": "Task 5: ScriptExecutionScheduler gauges — queue depth, busy threads, oldest-busy age (S2/UA5)", "status": "completed", "blockedBy": [] },
{ "id": 6, "subject": "Task 6: Surface scheduler stats through ISiteHealthCollector + SiteHealthReport (S2/UA5)", "status": "completed", "blockedBy": [5] },
{ "id": 7, "subject": "Task 7: Periodic scheduler-stats reporter hosted service (S2/UA5)", "status": "completed", "blockedBy": [5, 6] },
{ "id": 8, "subject": "Task 8: Stuck-script watchdog — name the script whose thread never came back (S2)", "status": "completed", "blockedBy": [] },
{ "id": 9, "subject": "Task 9: ScriptActor — trigger-expression evaluation off the dispatcher, coalesced (P1)", "status": "completed", "blockedBy": [] },
{ "id": 10, "subject": "Task 10: AlarmActor — async trigger-expression evaluation on the script scheduler (P1)", "status": "completed", "blockedBy": [] },
{ "id": 11, "subject": "Task 11: InstanceActor — retry failed/lost tag subscriptions per connection (S4/UA6)", "status": "completed", "blockedBy": [4, 8] },
{ "id": 12, "subject": "Task 12: NativeAlarmActor — retry on lost subscribe response (S4)", "status": "completed", "blockedBy": [] },
{ "id": 13, "subject": "Task 13: DeploymentManager — retry the startup config load (S5)", "status": "completed", "blockedBy": [4] },
{ "id": 14, "subject": "Task 14: Watch all Instance Actors; clean the map on unexpected termination (S6)", "status": "completed", "blockedBy": [13] },
{ "id": 15, "subject": "Task 15: Fail the in-flight deployment when its Instance Actor dies during init (S6)", "status": "completed", "blockedBy": [14] },
{ "id": 16, "subject": "Task 16: DataConnectionActor — capture _adapter into locals before background tasks (S7)", "status": "completed", "blockedBy": [] },
{ "id": 17, "subject": "Task 17: DCL — tag→instances reverse index for the value fan-out hot path (P3)", "status": "completed", "blockedBy": [16] },
{ "id": 18, "subject": "Task 18: InstanceActor — per-attribute child-subscription routing (P2)", "status": "completed", "blockedBy": [9, 11] },
{ "id": 19, "subject": "Task 19: SiteStorageService — WAL journal mode + busy timeout (S8)", "status": "completed", "blockedBy": [] },
{ "id": 20, "subject": "Task 20: NativeAlarmActor — coalesce per-transition SQLite upserts (P4)", "status": "completed", "blockedBy": [12, 19] },
{ "id": 21, "subject": "Task 21: Persist native-alarm display metadata for rehydration (UA4)", "status": "completed", "blockedBy": [20] },
{ "id": 22, "subject": "Task 22: CertStoreActor — export trusted certs (thumbprint + DER) for reconciliation (UA1 groundwork)", "status": "completed", "blockedBy": [] },
{ "id": 23, "subject": "Task 23: Cert-trust reconcile-on-join — singleton pushes trusted set to (re)joining site node (UA1)", "status": "completed", "blockedBy": [15, 22] },
{ "id": 24, "subject": "Task 24: Wire ConfigFetchRetryCount into the standby replication fetch (UA2)", "status": "completed", "blockedBy": [11] },
{ "id": 25, "subject": "Task 25: Low-severity code cleanups — S9, S10, C2, C6", "status": "completed", "blockedBy": [9, 17] },
{ "id": 26, "subject": "Task 26: Design-doc updates — C3, C4, C5 + behavior notes for landed changes", "status": "completed", "blockedBy": [1, 2, 4, 7, 8, 11, 13, 15, 18, 21, 23, 24, 25] }
],
"lastUpdated": "2026-07-08"
"lastUpdated": "2026-07-09"
}
@@ -64,6 +64,8 @@ Both central and site clusters. Each side has communication actors that handle m
- Alarm state stream messages: `[InstanceUniqueName].[AlarmName]`, state (active/normal), priority, timestamp.
- Central sends an unsubscribe request via ClusterClient when the debug session ends. The gRPC stream is cancelled. The site's `StreamRelayActor` is stopped and the SiteStreamManager subscription is removed.
- The stream is session-based and temporary.
- **Accepted limitation (central-side session locality):** debug sessions are process-local state on the central node hosting the `DebugStreamBridgeActor`. A central **restart or failover drops all active sessions** with no `OnStreamTerminated` signal to the operator — the engineer simply re-establishes the debug session from the UI. Debug streaming is an interactive, transient diagnostic aid, so this is an accepted trade-off rather than a durability gap (arch review 02, U7).
- **Backpressure is lossy-by-design and now observable:** the per-session `Channel<SiteStreamEvent>` (bounded 1000, `DropOldest`) silently evicts the oldest event when a slow consumer falls behind. The eviction is counted via the channel's `itemDropped` callback and logged at Warning (first eviction, then every 500th) so real event loss on a debug stream is visible instead of silent.
#### Site-Side gRPC Streaming Components
@@ -255,6 +255,7 @@ The `DataConnectionActor` opens **one alarm feed per connection** (not per subsc
- **State gating**: `SubscribeAlarmsRequest` is handled only in the **Connected** state; requests arriving while **Connecting**/**Reconnecting** are stashed (standard Become/Stash) and processed on entering Connected.
- **Capability check**: if `_adapter is not IAlarmSubscribableConnection`, the actor replies `SubscribeAlarmsResponse(Success = false, ...)`.
- **Reconnect handling**: on entering **Reconnecting**, the actor pushes a `NativeAlarmSourceUnavailable` to every alarm subscriber (consumers mark mirrored alarms uncertain rather than clearing them). On successful reconnection it re-subscribes the feed; the adapter re-emits a snapshot, reconciling state.
- **Shared condition filter (last-subscriber-wins)**: because the feed is opened once per source, it carries a single condition filter. A second subscriber that registers a *different* filter for the same source overwrites it (last writer wins) and the actor logs a warning — co-subscribers to one source are expected to agree on the filter.
### Protocol-Neutral Types & Messages
@@ -299,10 +300,11 @@ This pattern ensures no messages are lost during connection transitions and is t
The DCL manages connection lifecycle automatically:
1. **Connection drop detection**: When a connection to a data source is lost, the DCL immediately pushes a value update with quality `bad` for **every tag subscribed on that connection**. Instance Actors and their downstream consumers (alarms, scripts checking quality) see the staleness immediately.
2. **Auto-reconnect with fixed interval**: The DCL retries the connection at a configurable fixed interval (e.g., every 5 seconds). The retry interval is defined **per data connection**. This is consistent with the fixed-interval retry philosophy used throughout the system. Individual gRPC/OPC UA operations (reads, writes) fail immediately to the caller on error; there is no operation-level retry within the adapter.
1. **Connection drop detection**: When a connection to a data source is lost, the DCL immediately pushes a value update with quality `bad` for **every tag subscribed on that connection**. Instance Actors see the staleness immediately and publish it to the site stream / debug view; **script and alarm actors are deliberately NOT re-notified on a quality-only change** (no value changed — re-evaluating triggers would cause spurious firings). Scripts observe quality on their next attribute read; a computed alarm holds its last-known state until fresh values arrive.
2. **Auto-reconnect with fixed interval**: The DCL retries the connection at a configurable fixed interval (e.g., every 5 seconds). The retry interval is a **shared setting for all data connections** (`ReconnectInterval` in the Shared Settings table). This is consistent with the fixed-interval retry philosophy used throughout the system. Individual gRPC/OPC UA operations (reads, writes) fail immediately to the caller on error; there is no operation-level retry within the adapter.
3. **Connection state transitions**: The DCL tracks each connection's state as `connected`, `disconnected`, or `reconnecting`. All transitions are logged to Site Event Logging.
4. **Transparent re-subscribe**: On successful reconnection, the DCL automatically re-establishes all previously active subscriptions for that connection. Instance Actors require no action — they simply see quality return to `good` as fresh values arrive from restored subscriptions.
5. **Clean client replacement on (re)connect**: On every (re)connect the adapter detaches and disposes the previous protocol client (session, keep-alive timers, event loop) before creating a new one — a stale client can neither leak nor signal a disconnect against the new session.
### Disconnect Detection Pattern
+17 -4
View File
@@ -94,6 +94,10 @@ flowchart TD
4. Create Instance Actors for all deployed, **enabled** instances as child actors. Instance Actors are created in **staggered batches** (e.g., 20 at a time with a short delay between batches) to prevent a reconnection storm — 500 Instance Actors all registering data subscriptions simultaneously would overwhelm OPC UA servers and network capacity.
5. Make compiled shared script code available to all Script Actors.
> **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.
> **Per-instance compilation during staggered startup (P6, follow-up)**: the Roslyn compile of each instance's scripts still runs inside Instance Actor start during staggered startup; moving it off-thread is a deferred optimization (it affects failover time-to-recover only, not correctness).
### Deployment Handling
- Receives flattened instance configurations from central via the Communication Layer.
- Stores the new configuration in local SQLite.
@@ -102,6 +106,8 @@ flowchart TD
- For redeployments: the existing Instance Actor and all its children are stopped, then a new Instance Actor is created with the updated configuration. Subscriptions are re-established.
- Reports deployment result (success/failure) back to central.
> **Dead-child bookkeeping + two-signal deploy join (S6).** All Instance Actors are `Watch`ed; an unexpected `Terminated` evicts the actor ref from the singleton's map. **Deviation from the naive design (2026-07-09, see master-tracker "Plan deviations during Wave 1 PLAN-03 execution"):** deploy-success is **NOT** reported on persistence alone — the config store can commit before the Instance Actor's asynchronous `PreStart` throws. The design therefore uses a **two-signal join**: the Instance Actor sends a new `InstanceActorInitialized` readiness message at the end of a successful `PreStart`, and the Deployment Manager reports `Success` only once **both** persistence has committed **and** that readiness signal has arrived. An actor that dies during init never signals readiness → the `Terminated` fallback replies `Failed` and rolls the optimistic in-memory state back (the persisted-row rollback is deferred until the store commits so it cannot race the write).
### System-Wide Artifact Handling
- Receives updated shared scripts, external system definitions, database connection definitions, and data connection definitions from central. (Notification lists and SMTP configuration are central-only and are not deployed to sites — see Component-NotificationService.md.)
- Stores all artifacts in local SQLite. After artifact deployment, the site is fully self-contained — all runtime configuration is read from local SQLite with no access to the central configuration database.
@@ -123,6 +129,7 @@ flowchart TD
- **`RemoveServerCertCommand`** — delete a certificate (by thumbprint) from the node's stores.
- **`ListServerCertsCommand`** — enumerate the node's trusted-peer + rejected store contents (Subject / Issuer / Thumbprint / validity / status).
- The Deployment Manager singleton receives the central cert-trust verbs (relayed via the Communication Layer) and **broadcasts** `TrustServerCertCommand` / `RemoveServerCertCommand` to the `CertStoreActor` on **both** site nodes (via `ActorSelection`), so node-a and node-b PKI stores stay in sync. List reads from the local node. Trust is **site-local** — there is no central persistence of trusted certs (logged as a follow-up). The captured-but-untrusted server cert that seeds this flow comes from the DCL verify-endpoint probe (see Component-DataConnectionLayer.md), which never trusts on its own; the Central UI surfaces Trust / Remove (Admin-gated — see Component-CentralUI.md).
- **Reconcile-on-join (UA1)**: a broadcast only reaches nodes that are Up at the time, so a node that was down when a cert was trusted would stay divergent forever. To close that gap, the Deployment Manager singleton subscribes to cluster `MemberUp` events; when a site node (re)joins, it reads its own trusted-peer store (`ExportLocalTrustedCerts` → `CertStoreActor`) and pushes each certificate to the joining node's `CertStoreActor` (additive union; each `WriteCertToLocalStore` is idempotent by thumbprint). **Removal** reconciliation and central persistence of trust decisions remain the documented follow-up — only additions converge automatically.
---
@@ -196,6 +203,8 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
- **WhileTrue**: On the `false → true` edge the script fires once, then re-fires on a periodic timer while the condition stays true; on the `true → false` edge the timer stops. The re-fire cadence is the script's **minimum time between runs**; with none configured the trigger degrades to the single edge fire and logs a warning.
- **Minimum time between runs**: If configured, the Script Actor tracks the last execution time and skips trigger invocations that fire before the minimum interval has elapsed. For a WhileTrue trigger it doubles as the re-fire cadence.
> **Per-attribute change routing (P2).** Rather than broadcasting every attribute change to all child Script/Alarm Actors, the Instance Actor routes each change only to the children that monitor that attribute (built from their trigger configs at spawn time); **Expression-trigger children, which read an attribute snapshot, receive all changes**. Each child still keeps its own trigger gate as defense-in-depth — the routing is an optimization, not the authority.
### Concurrent Execution
- Each invocation spawns a **new Script Execution Actor** as a child.
- Multiple Script Execution Actors can run concurrently (e.g., a trigger fires while a previous `Instance.CallScript` invocation is still running).
@@ -300,8 +309,10 @@ The Instance Actor owns native-alarm setup alongside its computed Script and Ala
`SiteStorageService` gains a `native_alarm_state` table backing the native mirror:
- **Primary key**: `(instance_unique_name, source_canonical_name, source_reference)` — one row per mirrored condition.
- **Columns**: `condition_json` (the serialized `AlarmConditionState`) and `last_transition_at` (the accepted `TransitionTime`).
- **Columns**: `condition_json` (the serialized `AlarmConditionState`), `metadata_json` (display metadata — type/category/message/current+limit, UA4), and `last_transition_at` (the accepted `TransitionTime`).
- **Operations**: `Upsert` (on accepted transition), `Delete` (on condition drop-out), `Get` (PreStart rehydrate, scoped to instance + source), and `ClearForInstance` (redeploy/undeploy reset).
- **Display metadata persistence (UA4)**: persisted rows include the condition's display metadata, so **rehydrated conditions render fully** (type / category / message / current + limit values) before the first fresh source snapshot arrives; a pre-UA4 row with no metadata rehydrates with empty display fields (unchanged behavior).
- **Coalesced writes (P4)**: per-transition upserts are coalesced (latest-per-source-reference) and flushed on a short single-shot timer rather than one SQLite write per transition, so an alarm storm collapses to one batched write; a pending upsert is dropped if the condition is deleted before the flush (no resurrection). Durability window is at most one flush interval on crash, reconciled by the next re-subscribe snapshot.
- This is a **peer SQLite store** to the existing deployed-configuration, store-and-forward, operation-tracking, and `AuditLog` stores. Unlike computed alarm state, native mirror state is intentionally persisted so it survives failover.
---
@@ -330,6 +341,7 @@ The enriched message flows Instance Actor → site-wide Akka stream → `SiteStr
- When a Script Execution Actor calls `Scripts.CallShared("scriptName", params)`, the shared script code executes **inline** in the Script Execution Actor's context — it is a direct method invocation, not an actor message.
- This avoids serialization bottlenecks since there is no shared script actor to contend for.
- Shared scripts have access to the same runtime API as instance scripts (GetAttribute, SetAttribute, external systems, notifications, databases).
- **Shared scripts always execute with Root scope (C5)**: `Attributes[...]` inside a shared script addresses **root** attributes even when the shared script is invoked from a composed-module script. Callers needing module-scoped access must pass the values in as parameters.
---
@@ -408,7 +420,7 @@ Scripts execute **in-process** with constrained access. The following restrictio
- **Allowed**: Access to the Script Runtime API (GetAttribute, SetAttribute, CallScript, CallShared, ExternalSystem, Notify, Database, Tracking), standard C# language features, basic .NET types (collections, string manipulation, math, date/time). `System.Diagnostics.Stopwatch`, `Debug`, and `Activity` are permitted.
- **Forbidden**: File system access (`System.IO`), process spawning (`System.Diagnostics.Process`), threading (`System.Threading` — except `Tasks`, `CancellationToken`, and `CancellationTokenSource`), reflection (`System.Reflection`), all raw network access (`System.Net` — must use `ExternalSystem.Call`), native interop (`System.Runtime.InteropServices`, `Microsoft.Win32`), assembly loading, unsafe code, `dynamic`, `Activator`.
- **Execution timeout**: Configurable per-script maximum execution time. Exceeding the timeout cancels the script and logs an error.
- **Execution timeout**: Configurable per-script maximum execution time. Exceeding the timeout cancels the script **cooperatively** (S2/UA5): a script blocked in synchronous I/O or a tight CPU loop does not observe cancellation and continues to occupy its dedicated script-execution thread. A **watchdog logs the script by name** once its thread has not returned within a grace period after the timeout, and the script-execution scheduler's **queue depth / busy-thread / oldest-busy-age gauges** are reported through Health Monitoring so a saturated or stuck scheduler is visible.
- **Memory**: Scripts share the host process memory. No per-script memory limit, but the execution timeout prevents runaway allocations.
The forbidden-API policy is defined authoritatively in `ScriptTrustPolicy` (Script Analysis component, #25). `ScriptCompilationService.ValidateTrustModel` delegates to `ScriptTrustValidator.FindViolations` for the trust verdict; the Site Runtime also performs a `CSharpScript.Compile` against the real `ScriptGlobals` for execution. This is defence-in-depth static enforcement, not a true runtime sandbox.
@@ -474,14 +486,15 @@ Per Akka.NET best practices, internal actor communication uses **Tell** (fire-an
### Script Compilation Errors
- If script compilation fails when a deployment is received, the entire deployment for that instance is **rejected**. No partial state is applied.
- The failure is reported back to central as a failed deployment.
- Note: Pre-deployment validation at central should catch compilation errors before they reach the site. Site-side compilation failures indicate an unexpected issue.
- **Enforced by a site-side pre-compile validation gate (S3)**: before the Instance Actor is created or the config persisted, the Deployment Manager compiles **all** of the instance's scripts, alarm on-trigger scripts, and trigger expressions **off-thread**; any compile failure rejects the deployment with the collected compile errors. This makes the deployment result honest even if central's pre-deployment validation was bypassed or skew let a bad artifact through.
- Note: Pre-deployment validation at central should still catch compilation errors before they reach the site; the site gate is the authoritative backstop.
---
## Dependencies
- **Script Analysis (#25)**: `ScriptCompilationService.ValidateTrustModel` delegates the forbidden-API verdict to `ScriptTrustValidator.FindViolations`. The Site Runtime retains its own `CSharpScript.Compile` against the real `ScriptGlobals` for execution.
- **Data Connection Layer**: Provides tag value updates to Instance Actors. Receives write requests from Instance Actors. Also feeds Native Alarm Actors: connections implementing `IAlarmSubscribableConnection` (OPC UA A&C servers, MxAccess Gateway) deliver `NativeAlarmTransitionUpdate` events in response to a `SubscribeAlarmsRequest`, and signal `NativeAlarmSourceUnavailable` on connection loss.
- **Data Connection Layer**: Provides tag value updates to Instance Actors. Receives write requests from Instance Actors. Also feeds Native Alarm Actors: connections implementing `IAlarmSubscribableConnection` (OPC UA A&C servers, MxAccess Gateway) deliver `NativeAlarmTransitionUpdate` events in response to a `SubscribeAlarmsRequest`, and signal `NativeAlarmSourceUnavailable` on connection loss. **Subscription-handshake retry (S4/UA6)**: a tag-subscription handshake that either failed or whose response was lost is re-sent on a fixed interval per connection (default 5 s) — the retry is armed on every send and cancelled on the first success reply, closing both the failed-response and lost-response gaps; the `NativeAlarmActor` subscribe uses the same retry pattern.
- **Store-and-Forward Engine**: Handles reliable delivery for external system calls, cached database writes, and notifications submitted by scripts. For the notification category specifically, it forwards to the central cluster for delivery (not directly to SMTP). Owns the site-local operation tracking table that backs `Tracking.Status(id)`.
- **External System Gateway**: Provides external system method invocations for scripts.
- **Communication Layer**: Receives deployments and lifecycle commands from central. Handles debug view requests. Reports deployment results.
@@ -49,7 +49,9 @@ flowchart TD
class I bad
```
For notifications, "delivery" means forwarding the message to the central cluster via Central–Site Communication; "success" is central's ack, on which the message is cleared. Notifications are retried at the fixed forward interval until central acks, but — like every other category — they are bounded by the engine's `DefaultMaxRetries` cap: a sustained central outage that exceeds `DefaultMaxRetries × forward-interval` will park the buffered notification, after which an operator can Retry/Discard it via the parked-message UI. Operationally, the cap is sized so the normal central-recovery window stays well inside it; "do not park" is the design's operational intent on the happy path, not an absolute invariant. Callers that genuinely require unbounded retry pass `maxRetries: 0` on `EnqueueAsync` (the documented "no limit" escape hatch — see `StoreAndForward-015`).
For notifications, "delivery" means forwarding the message to the central cluster via Central–Site Communication; "success" is central's ack, on which the message is cleared. `Notify.Send` is **enqueue-only**: it buffers the notification into the local SQLite S&F store and returns the `NotificationId` immediately — it never runs the forwarder's central Ask inline on the script thread (it passes `deferToSweep: true`, which buffers the row due-immediately and kicks a background sweep). Its worst-case latency is therefore the local SQLite insert, whether central is up or down; the actual forward happens on the sweep. `Notify.Send` also enqueues with `maxRetries: 0` — the documented "no limit" escape hatch (`StoreAndForward-015`) — so notifications are retried at the fixed forward interval until central acks and are **never parked for retry exhaustion**: a sustained central outage can no longer strand a notification behind per-message operator unparking. ("do not park" is thus the real behaviour for retry-exhaustion, not merely a happy-path aspiration — but see the two remaining parking causes below.)
There are now exactly two ways a notification parks. First, a **corrupt buffered payload** (a row whose stored JSON no longer deserialises to a `NotificationSubmit`, or deserialises to null): the forwarder returns the delivery-handler contract's permanent-failure signal (`false`) so the engine parks the row, preserving the payload for operator forensics. This **supersedes `StoreAndForward-018`**, which discarded such rows by reporting them as delivered — a silent data loss with no parked row, no central `Notifications` row, and no audit trail. Retrying a corrupt payload is pointless (it can never deserialise), so parking, not retrying, is the correct home for it. Second, retry exhaustion under the `DefaultMaxRetries` cap — but because `Notify.Send` enqueues with `maxRetries: 0` (its unbounded-retry contract, above), that second cause is **off for script-produced notifications**, leaving corrupt payload as their only parking cause. (Retry-exhaustion parking remains reachable only for a hypothetical notification enqueued with a positive `maxRetries`.)
For the cached-call categories (`ExternalCall` and `DatabaseWrite`), the operation tracking table is the status record and the S&F buffer is purely the retry mechanism. A cached call that succeeds on its first immediate attempt is written directly as a terminal `Delivered` tracking row and never enters the S&F buffer. When immediate delivery fails transiently, the message is buffered and its tracking row moves to `Pending`/`Retrying`; the buffered message carries its `TrackedOperationId` so the tracking row and the retry record stay linked. When immediate delivery fails **permanently** (e.g. HTTP 4xx), the message is not buffered — the error is returned synchronously to the calling script as before — but the tracking row is written directly as a terminal `Failed` row capturing the error. On every tracking-table status transition the site emits `CachedCallTelemetry` to central.
@@ -75,9 +77,10 @@ There is **no maximum buffer size**. Messages accumulate in the buffer until del
- Buffered messages are persisted to a **local SQLite database** on each site node.
- The active node persists locally and forwards each buffer operation (add, remove, park) to the standby node **asynchronously** via Akka.NET remoting. The active node does not wait for standby acknowledgment — this avoids adding latency to every script that buffers a message.
- The standby node applies the same operations to its own local SQLite database.
- On failover, the new active node has a near-complete copy of the buffer. In rare cases, the most recent operations may not have been replicated (e.g., a message added or removed just before failover). This can result in a few **duplicate deliveries** (message delivered but remove not replicated) or a few **missed retries** (message added but not replicated). Both are acceptable trade-offs for the latency benefit.
- On failover, the new active node resumes delivery from its local copy.
- The standby node applies the same operations to its own local SQLite database but is **passive**: it never runs the delivery sweep. The retry sweep is **gated to the active node** (the oldest Up member / singleton host, re-evaluated every sweep tick), so only one node delivers at a time. The standby applies replicated operations purely to keep its copy warm for a future failover.
- On failover, the new active node has a near-complete copy of the buffer. In rare cases, the most recent operations may not have been replicated (e.g., a message added or removed just before failover). This can result in a few **duplicate deliveries** (message delivered but its `Remove` not yet replicated) or a few **missed retries** (message added but not replicated). Duplicate deliveries are therefore confined to the **failover window** — an in-flight delivery whose `Remove` had not yet replicated — and never occur in steady-state operation (the standby's gate keeps it from delivering the same rows). Both are acceptable trade-offs for the latency benefit.
- On failover, the new active node's gate flips to active within one sweep interval and it resumes delivery from its local copy.
- **Peer-join anti-entropy resync.** Asynchronous, no-ack replication keeps the standby warm in steady state, but a standby that was **down for an extended period** (a crash, a long maintenance window) misses every operation replicated while it was gone and would otherwise diverge from the active node's buffer forever. To close that gap, whenever a node **(re)tracks its peer**, a **standby** requests a full-buffer snapshot (`RequestSfBufferResync`); the **active** node answers with up to `MaxResyncRows` (10 000) of its oldest rows (`SfBufferSnapshot`), and the standby **replaces its entire local buffer** with that snapshot (`ReplaceAllAsync`, one transaction). Only the active node answers; only a standby applies (each side checks the repo-standard leader+Up active-node predicate, safe-by-default to standby). Because replicated applies are **upserts** (see the replication apply path), any Add/Remove/Park that lands after the resync merges cleanly onto the resynced state — no primary-key conflict, no lost delta. If the buffer exceeds the 10 000-row cap the snapshot is flagged `Truncated` and the standby logs a Warning; the residual divergence beyond the cap drains naturally as the active node delivers.
### Operation Tracking Table (lives in Site Runtime, not here)
@@ -47,7 +47,33 @@ public record SiteHealthReport(
// hosted service. Point-in-time (not reset on collect) — mirrors the
// SiteAuditBacklog pattern. Defaults to 0 so existing producers / tests that
// don't wire the poller stay valid.
long SiteEventLogWriteFailures = 0);
long SiteEventLogWriteFailures = 0,
// Age (seconds) of the oldest parked
// store-and-forward message, computed at report time on the site; null when no
// rows are parked. Parked rows persist until operator action, so this aging
// signal surfaces a forgotten site's stale backlog (count alone hides age).
// Additive-only trailing member — defaults to null for producers / tests that
// don't populate it.
double? OldestParkedMessageAgeSeconds = null)
{
// Script-execution-scheduler gauges (S2/UA5), surfaced additively as init
// properties (rather than positional parameters) so the constructor signature
// is untouched. Point-in-time, refreshed on the site by ScriptSchedulerStatsReporter;
// safe defaults for producers / tests that don't populate them.
/// <summary>Script tasks waiting to run on the dedicated script-execution scheduler.</summary>
public int ScriptQueueDepth { get; init; }
/// <summary>Script-execution worker threads currently running a script body.</summary>
public int ScriptBusyThreads { get; init; }
/// <summary>
/// Age (seconds) of the oldest in-flight script across the scheduler's worker
/// threads, or null when the pool is idle. A large value with all threads busy
/// indicates a stuck/blocked script holding a dedicated thread.
/// </summary>
public double? ScriptOldestBusyAgeSeconds { get; init; }
}
/// <summary>
/// Broadcast wrapper used between central nodes to keep per-node
@@ -82,6 +82,30 @@ public record RemoveCertFromLocalStore(string Thumbprint);
/// <summary>Per-node: enumerate the local trusted-peer and rejected stores.</summary>
public record ListLocalCerts();
/// <summary>
/// Per-node: export the trusted-peer store contents (thumbprint + raw DER bytes)
/// so the Deployment Manager singleton can reconcile the trusted set onto a
/// (re)joining site node. Trusted store only — rejected certs are never pushed.
/// </summary>
public record ExportLocalTrustedCerts();
/// <summary>
/// One exported trusted certificate: its thumbprint (store filename key) and the
/// raw DER bytes needed to re-write it into another node's trusted-peer store.
/// </summary>
/// <param name="Thumbprint">The certificate thumbprint.</param>
/// <param name="DerBytes">The certificate's raw DER encoding.</param>
public record ExportedCert(string Thumbprint, byte[] DerBytes);
/// <summary>
/// Per-node reply to <see cref="ExportLocalTrustedCerts"/>: the local trusted-peer
/// store's certificates, or a failure with the IO error.
/// </summary>
/// <param name="Success">True if the trusted store was enumerated successfully.</param>
/// <param name="Error">The error message on failure, otherwise null.</param>
/// <param name="Certs">Exported trusted certificates, empty on failure.</param>
public record LocalCertExport(bool Success, string? Error, IReadOnlyList<ExportedCert> Certs);
/// <summary>
/// Per-node ack for a <see cref="WriteCertToLocalStore"/>, <see cref="RemoveCertFromLocalStore"/>
/// or <see cref="ListLocalCerts"/> operation.
@@ -37,6 +37,11 @@ public static class ScadaBridgeTelemetry
Meter.CreateCounter<long>("scadabridge.inbound_api.requests", unit: "1",
description: "Inbound API requests, tagged by method.");
/// <summary>Incremented each time an S&amp;F buffer replication op fails to dispatch/deliver to the peer.</summary>
private static readonly Counter<long> _replicationFailures =
Meter.CreateCounter<long>("scadabridge.store_and_forward.replication.failures", unit: "1",
description: "S&F buffer replication operations that failed to dispatch/deliver to the peer node");
// ---------------- Observable gauges ----------------
/// <summary>Current count of open site connections, mutated via <see cref="Interlocked"/>.</summary>
@@ -70,6 +75,9 @@ public static class ScadaBridgeTelemetry
public static void RecordInboundApiRequest(string method) =>
_inboundApiRequests.Add(1, new KeyValuePair<string, object?>("method", method));
/// <summary>Records one failed S&amp;F replication dispatch.</summary>
public static void RecordReplicationFailure() => _replicationFailures.Add(1);
/// <summary>Records that a site connection opened (increments the up-count gauge).</summary>
public static void SiteConnectionOpened() => Interlocked.Increment(ref _siteConnectionsUp);
@@ -137,20 +137,18 @@ public class CentralCommunicationActor : ReceiveActor
/// <summary>
/// Default Ask timeout for routing audit ingest commands to the
/// AuditLogIngestActor proxy — 30 s, matching the value of
/// <c>SiteStreamGrpcServer.AuditIngestAskTimeout</c> (that constant is private
/// to the gRPC server and not reachable here, so it is declared locally). A
/// generous window absorbs a slow MS SQL connection without the round-trip
/// surfacing as a failure on a healthy site. When the window is exceeded the
/// Ask faults and that fault is piped back to the caller as a
/// <see cref="Status.Failure"/> (see <see cref="HandleIngestAuditEvents"/>).
/// </summary>
private static readonly TimeSpan DefaultAuditIngestAskTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Effective Ask timeout for audit ingest routing. Defaults to
/// <see cref="DefaultAuditIngestAskTimeout"/>; overridable via the constructor
/// so tests can exercise the timeout/fault path without waiting 30 s.
/// <see cref="Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout"/> (30 s) — the two
/// audit-ingest transports (gRPC vs ClusterClient) now share one source of truth
/// for the timeout. Overridable via the constructor so tests can exercise the
/// timeout/fault path without waiting 30 s. When the window is exceeded the Ask
/// faults and that fault is piped back to the caller as a
/// <see cref="Status.Failure"/> (see <see cref="HandleIngestAuditEvents"/>).
///
/// The gRPC and ClusterClient ingest transports differ in fault semantics (the
/// gRPC handler surfaces the fault as an RpcException; here it becomes a piped
/// Status.Failure). Consolidating the two ingest transports is a scheduled
/// follow-up — see arch review 02, Underdeveloped #1.
/// </summary>
private readonly TimeSpan _auditIngestAskTimeout;
@@ -166,7 +164,7 @@ public class CentralCommunicationActor : ReceiveActor
/// <param name="siteClientFactory">Factory used to create per-site ClusterClient actors.</param>
/// <param name="auditIngestAskTimeout">
/// Optional override for the audit-ingest Ask timeout; defaults to
/// <see cref="DefaultAuditIngestAskTimeout"/> (30 s). Exists only so tests can
/// <see cref="Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout"/> (30 s). Exists only so tests can
/// exercise the timeout/fault path quickly — production always uses the default.
/// </param>
public CentralCommunicationActor(
@@ -176,7 +174,7 @@ public class CentralCommunicationActor : ReceiveActor
{
_serviceProvider = serviceProvider;
_siteClientFactory = siteClientFactory;
_auditIngestAskTimeout = auditIngestAskTimeout ?? DefaultAuditIngestAskTimeout;
_auditIngestAskTimeout = auditIngestAskTimeout ?? Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout;
// Site address cache loaded from database
Receive<SiteAddressCacheLoaded>(HandleSiteAddressCacheLoaded);
@@ -197,6 +195,7 @@ public class CentralCommunicationActor : ReceiveActor
Receive<HeartbeatMessage>(HandleHeartbeat);
Receive<SiteHealthReport>(HandleSiteHealthReport);
Receive<SiteHealthReportReplica>(r => ProcessLocally(r.Report));
Receive<SiteHeartbeatReplica>(r => MarkHeartbeatLocally(r.Heartbeat));
Receive<SubscribeAck>(_ => { /* DistributedPubSub subscribe confirmation */ });
// Route enveloped messages to sites
@@ -371,6 +370,31 @@ public class CentralCommunicationActor : ReceiveActor
}
private void HandleHeartbeat(HeartbeatMessage heartbeat)
{
MarkHeartbeatLocally(heartbeat);
// Fan the heartbeat out to the peer central node so BOTH aggregators mark
// it, regardless of which central node the site's ClusterClient delivered
// to. Without this, a heartbeat that only ever reaches one node leaves the
// other node's aggregator blind to that site's liveness after a failover
// (arch review 02, Low). MarkHeartbeat is idempotent (timestamp overwrite),
// so self-delivery via pub-sub is harmless.
try
{
DistributedPubSub.Get(Context.System).Mediator.Tell(
new Publish(HealthReportTopic, new SiteHeartbeatReplica(heartbeat)));
}
catch
{
// No-op in non-clustered hosts (TestKit).
}
}
/// <summary>
/// Marks a site heartbeat on the local aggregator without re-broadcasting.
/// Used for both site-originated heartbeats and peer-replicated ones.
/// </summary>
private void MarkHeartbeatLocally(HeartbeatMessage heartbeat)
{
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();
aggregator?.MarkHeartbeat(heartbeat.SiteId, heartbeat.Timestamp);
@@ -665,6 +689,15 @@ internal record SiteAddressCacheLoaded(
IReadOnlyDictionary<string, IReadOnlyList<string>> SiteContacts,
IReadOnlyCollection<string> KnownSiteIds);
/// <summary>
/// Peer-replication envelope for a site heartbeat, fanned out over the same
/// DistributedPubSub topic as <see cref="SiteHealthReportReplica"/> so both
/// central aggregators mark heartbeats regardless of which node the site's
/// ClusterClient delivered to. Only ever travels central↔central (same assembly
/// version, rolled together), so the default reflective serializer is safe.
/// </summary>
public sealed record SiteHeartbeatReplica(HeartbeatMessage Heartbeat);
/// <summary>
/// Notification sent to debug view subscribers when the stream is terminated
/// due to site disconnection.
@@ -466,8 +466,11 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
// stops the StreamRelayActor for this correlation ID, rather than leaving a
// zombie relay actor until TCP RST / keepalive eventually detects the loss.
var previousEndpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
var previousClient = _grpcFactory.GetOrCreate(_siteIdentifier, previousEndpoint);
previousClient.Unsubscribe(_correlationId);
// TryGet, not GetOrCreate: unsubscribing a failed stream must never open a
// fresh channel (and, with (site,endpoint) keying, must never touch another
// session's healthy channel). Absent client => the channel is already gone
// and the site-side relay will be reaped by keepalive/session-lifetime.
_grpcFactory.TryGet(_siteIdentifier, previousEndpoint)?.Unsubscribe(_correlationId);
// Flip to the other node
_useNodeA = !_useNodeA;
@@ -489,9 +492,10 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
_grpcCts?.Dispose();
_grpcCts = null;
var client = _grpcFactory.GetOrCreate(_siteIdentifier,
_useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress);
client.Unsubscribe(_correlationId);
// TryGet, not GetOrCreate: teardown must never open a fresh channel just to
// unsubscribe. Absent client => nothing to cancel.
var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
_grpcFactory.TryGet(_siteIdentifier, endpoint)?.Unsubscribe(_correlationId);
}
private void SendUnsubscribe()
@@ -434,12 +434,14 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
{
_log.Info("SiteCommunicationActor started for site {0}", _siteId);
// Schedule periodic heartbeat to central
// Schedule periodic heartbeat to central. Uses the application heartbeat
// cadence — distinct from the Akka.Remote transport failure-detector
// interval — so the two can be tuned independently.
Timers.StartPeriodicTimer(
"heartbeat",
new SendHeartbeat(),
TimeSpan.FromSeconds(1), // initial delay
_options.TransportHeartbeatInterval);
_options.ApplicationHeartbeatInterval);
}
private void HandleRegisterLocalHandler(RegisterLocalHandler msg)
@@ -104,9 +104,13 @@ public class StreamRelayActor : ReceiveActor
private void WriteToChannel(SiteStreamEvent protoEvent)
{
// TryWrite on a DropOldest bounded channel never returns false for a "full"
// channel — eviction of the oldest item is what happens instead, and it is
// counted by the channel's itemDropped callback (SiteStreamGrpcServer). This
// branch therefore guards only a completed/closed channel, not backpressure.
if (!_channelWriter.TryWrite(protoEvent))
{
_log.Warning("Channel full, dropping event for correlation {0}", _correlationId);
_log.Debug("Channel closed, dropping event for correlation {0}", _correlationId);
}
}
@@ -56,6 +56,14 @@ public class CommunicationOptions
/// <summary>Akka.Remote transport heartbeat interval.</summary>
public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Application-level site→central heartbeat cadence. Distinct from
/// <see cref="TransportHeartbeatInterval"/> (the Akka.Remote failure-detector
/// setting) — tuning the transport FD must not silently retune the health
/// heartbeat. Default equals the old effective value (5s) — no behavior change.
/// </summary>
public TimeSpan ApplicationHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Akka.Remote transport failure detection threshold.</summary>
public TimeSpan TransportFailureThreshold { get; set; } = TimeSpan.FromSeconds(15);
@@ -5,13 +5,25 @@ using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <summary>
/// Caches one <see cref="SiteStreamGrpcClient"/> per site identifier.
/// Caches one <see cref="SiteStreamGrpcClient"/> per <c>(site, endpoint)</c> pair.
/// The DebugStreamBridgeActor uses this factory to obtain (or create) a
/// gRPC client for a given site before opening a streaming subscription.
/// gRPC client for a given site+node before opening a streaming subscription.
/// <para>
/// Both of a site's node channels (NodeA and NodeB) coexist in the cache, so a
/// per-session NodeA↔NodeB failover flip never disposes a channel another debug
/// session is still using (arch review 02, High). <see cref="GetOrCreate"/> no
/// longer disposes on endpoint mismatch; site *removal* (<see cref="RemoveSiteAsync"/>)
/// is the only shared-disposal path — it disposes every endpoint for that site.
/// </para>
/// <para>
/// Trade-off: an edited gRPC address leaves the old endpoint's now-idle channel
/// cached until site removal or process shutdown — bounded at a handful of entries
/// per site (2 nodes + rare edits), each an idle HTTP/2 channel.
/// </para>
/// </summary>
public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
{
private readonly ConcurrentDictionary<string, SiteStreamGrpcClient> _clients = new();
private readonly ConcurrentDictionary<(string Site, string Endpoint), SiteStreamGrpcClient> _clients = new();
private readonly ILoggerFactory _loggerFactory;
private readonly CommunicationOptions _options;
@@ -38,44 +50,27 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
}
/// <summary>
/// Returns the cached client for the site, or creates a new one. If a client is
/// already cached but bound to a *different* <paramref name="grpcEndpoint"/> — the
/// NodeA→NodeB failover flip, or a site whose gRPC address was edited — the stale
/// client is disposed and replaced with one bound to the requested endpoint.
/// Keying purely by site identifier and ignoring the
/// endpoint on a cache hit defeated debug-stream node failover and meant a
/// corrected gRPC address never took effect without a central restart.
/// Returns the cached client for <c>(site, endpoint)</c>, or creates one. Both node
/// channels for a site coexist under distinct keys, so requesting a different
/// endpoint for the same site adds a second entry rather than disposing the first —
/// per-session failover can never dispose a channel a concurrent session still holds.
/// </summary>
/// <param name="siteIdentifier">Unique site identifier used as the cache key.</param>
/// <param name="grpcEndpoint">gRPC endpoint the returned client must be bound to.</param>
/// <param name="siteIdentifier">Unique site identifier (first half of the cache key).</param>
/// <param name="grpcEndpoint">gRPC endpoint (second half of the cache key) the client is bound to.</param>
/// <returns>The cached or newly-created client bound to <paramref name="grpcEndpoint"/>.</returns>
public virtual SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
{
// Fast path: a client is cached and already bound to the requested endpoint.
if (_clients.TryGetValue(siteIdentifier, out var existing) &&
string.Equals(existing.Endpoint, grpcEndpoint, StringComparison.Ordinal))
{
return existing;
}
public virtual SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) =>
_clients.GetOrAdd((siteIdentifier, grpcEndpoint), _ => CreateClient(grpcEndpoint));
// Either no client is cached, or the cached one is bound to a different
// endpoint. AddOrUpdate atomically installs a client for the requested
// endpoint; the prior (stale) client, if any, is disposed afterwards.
SiteStreamGrpcClient? stale = null;
var client = _clients.AddOrUpdate(
siteIdentifier,
_ => CreateClient(grpcEndpoint),
(_, current) =>
{
if (string.Equals(current.Endpoint, grpcEndpoint, StringComparison.Ordinal))
return current;
stale = current;
return CreateClient(grpcEndpoint);
});
stale?.Dispose();
return client;
}
/// <summary>
/// Returns the cached client for <c>(site, endpoint)</c>, or <c>null</c> — never creates.
/// Teardown paths (DebugStreamBridgeActor.CleanupGrpc / failed-stream unsubscribe)
/// use this so cleanup can never open a fresh channel or touch another endpoint's client.
/// </summary>
/// <param name="siteIdentifier">Unique site identifier (first half of the cache key).</param>
/// <param name="grpcEndpoint">gRPC endpoint (second half of the cache key).</param>
/// <returns>The cached client, or <c>null</c> when no client is cached for the pair.</returns>
public virtual SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) =>
_clients.TryGetValue((siteIdentifier, grpcEndpoint), out var client) ? client : null;
/// <summary>
/// Creates a single <see cref="SiteStreamGrpcClient"/>. Overridable so tests
@@ -91,19 +86,18 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
}
/// <summary>
/// Removes and disposes the client for the given site. Site *address changes* are
/// now handled transparently by <see cref="GetOrCreate"/> (it disposes and recreates
/// a client whose endpoint no longer matches). This method remains the disposal
/// path for full site *removal* — call it when a site record is deleted so its
/// cached gRPC client does not linger for the life of the process.
/// Removes and disposes every cached client for the given site — all of its node
/// endpoints. This is the only shared-disposal path: call it when a site record is
/// deleted so the site's gRPC channels do not linger for the life of the process.
/// </summary>
/// <param name="siteIdentifier">Unique site identifier whose client should be removed.</param>
/// <returns>A task that completes when the cached client has been removed and disposed.</returns>
/// <param name="siteIdentifier">Unique site identifier whose clients should be removed.</param>
/// <returns>A task that completes when all of the site's cached clients have been disposed.</returns>
public async Task RemoveSiteAsync(string siteIdentifier)
{
if (_clients.TryRemove(siteIdentifier, out var client))
foreach (var key in _clients.Keys.Where(k => k.Site == siteIdentifier).ToList())
{
await client.DisposeAsync();
if (_clients.TryRemove(key, out var client))
await client.DisposeAsync();
}
}
@@ -42,7 +42,9 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// Ask timeout is 30 s. The ingest actor's repo
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow
// MSSQL connection without surfacing as a gRPC failure on a healthy site.
private static readonly TimeSpan AuditIngestAskTimeout = TimeSpan.FromSeconds(30);
// Shared with CentralCommunicationActor (same assembly) so the two audit-ingest
// transports use one source of truth for the timeout.
internal static readonly TimeSpan AuditIngestAskTimeout = TimeSpan.FromSeconds(30);
// Audit Log: site-local queue handed in by AkkaHostedService on
// site roles so the central reconciliation puller's PullAuditEvents RPC
// can read Pending/Forwarded rows. Null when not wired (e.g. central-only
@@ -240,7 +242,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
existingEntry.Cts.Dispose();
}
// Check max concurrent streams after duplicate removal
// Check max concurrent streams after duplicate removal.
// Deliberately check-then-act: two concurrent subscribes at the boundary can
// both pass, overshooting the cap by at most (concurrent subscribers - 1).
// Bounded and benign — not worth a lock on this path.
if (_activeStreams.Count >= _maxConcurrentStreams)
throw new RpcException(new GrpcStatus(StatusCode.ResourceExhausted, "Max concurrent streams reached"));
@@ -253,8 +258,20 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
var entry = new StreamEntry(streamCts);
_activeStreams[request.CorrelationId] = entry;
long dropped = 0;
var correlationId = request.CorrelationId;
var channel = Channel.CreateBounded<SiteStreamEvent>(
new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest });
new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest },
_ =>
{
// Lossy-under-backpressure is the debug-view spec; make the real
// loss visible: first eviction + every 500th thereafter.
var n = Interlocked.Increment(ref dropped);
if (n == 1 || n % 500 == 0)
_logger.LogWarning(
"Debug stream {CorrelationId} backpressure: {Dropped} oldest event(s) evicted so far",
correlationId, n);
});
var actorSeq = Interlocked.Increment(ref _actorCounter);
var relayActor = _actorSystem!.ActorOf(
@@ -61,6 +61,18 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
/// </summary>
private readonly Dictionary<string, int> _tagSubscriberCount = new();
/// <summary>
/// Reverse index of which instances subscribe to each tag path: tagPath → set of
/// instanceUniqueName. Mirrors <see cref="_subscriptionsByInstance"/> inverted so the
/// <see cref="HandleTagValueReceived"/> hot path fans a value out to exactly the
/// interested instances in O(subscribers) instead of scanning every instance's tag set
/// on every tag update. Maintained via <see cref="IndexTag"/>/<see cref="UnindexTag"/>
/// at every <see cref="_subscriptionsByInstance"/> tag-set mutation; a tag key is dropped
/// when its instance set empties. Derived purely from <see cref="_subscriptionsByInstance"/>,
/// so it is preserved across reconnect exactly as that map is (see <see cref="ReSubscribeAll"/>).
/// </summary>
private readonly Dictionary<string, HashSet<string>> _instancesByTag = new();
/// <summary>
/// Tags whose path resolution failed and are awaiting retry.
/// </summary>
@@ -217,7 +229,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{
_log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName);
_adapter.Disconnected -= OnAdapterDisconnected;
_ = _adapter.DisposeAsync().AsTask();
// S9: fire-and-forget, but observe a faulted dispose so a failing adapter
// teardown is logged rather than swallowed into an unobserved task exception.
_adapter.DisposeAsync().AsTask().ContinueWith(
t => _log.Warning("[{0}] Adapter dispose faulted on stop: {1}",
_connectionName, t.Exception?.GetBaseException().Message),
TaskContinuationOptions.OnlyOnFaulted);
}
/// <inheritdoc />
@@ -678,6 +695,13 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Capture the current adapter generation so callbacks
// from this adapter can be distinguished from a later (post-failover) adapter.
var generation = _adapterGeneration;
// Capture the adapter reference on the actor thread too (S7). The background task
// below iterates multiple tags with an await between each SubscribeAsync — a failover
// that swaps the mutable _adapter field mid-loop would otherwise make later iterations
// (and the SeedTagsAsync read) run against the NEW adapter while carrying the OLD
// generation. The in-flight subscribe must finish against the adapter that started it;
// the generation guard drops any value the swapped-out adapter later produces.
var adapter = _adapter;
// Partition tags on the actor thread into "this
// request will issue _adapter.SubscribeAsync" vs. "already subscribed (by us
@@ -718,7 +742,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{
try
{
var subId = await _adapter.SubscribeAsync(tagPath, (path, value) =>
var subId = await adapter.SubscribeAsync(tagPath, (path, value) =>
{
self.Tell(new TagValueReceived(path, value, generation));
});
@@ -752,7 +776,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// SeedTagsAsync retries the still-empty reads so a
// seed that races the just-created advise (returns VT_EMPTY) is not silently
// dropped, and logs any tag that never yields a value.
var seedValues = await SeedTagsAsync(_adapter, tagsToSeed);
var seedValues = await SeedTagsAsync(adapter, tagsToSeed);
return new SubscribeCompleted(request, sender, results, seedValues);
}).PipeTo(self);
@@ -900,8 +924,11 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// increments the reference count, so the count stays an accurate "number
// of distinct instances subscribed to this tag".
if (instanceTags.Add(result.TagPath))
{
_tagSubscriberCount[result.TagPath] =
_tagSubscriberCount.GetValueOrDefault(result.TagPath) + 1;
IndexTag(result.TagPath, instanceName);
}
// Re-check against current state: another subscribe may have resolved the
// same tag while this request's I/O was in flight.
@@ -1046,6 +1073,10 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Cleanup on Instance Actor stop
foreach (var tagPath in tags)
{
// Drop this instance from the fan-out reverse index (mirrors the
// _subscriptionsByInstance removal at the end of this method).
UnindexTag(tagPath, request.InstanceUniqueName);
// Drop this instance's reference; the tag is only
// released at the adapter when no other instance still subscribes to it.
// The reference count makes this O(1) instead of an O(instances) scan.
@@ -1119,8 +1150,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// failed WriteTagResponse so the failure is returned synchronously.
var cts = new CancellationTokenSource(_options.WriteTimeout);
// Capture the adapter on the actor thread (S7) so the write completes against the
// adapter that started it even if a failover swaps the _adapter field mid-flight.
var adapter = _adapter;
// Write through DCL to device, failure returned synchronously
_adapter.WriteAsync(request.TagPath, request.Value, cts.Token).ContinueWith(t =>
adapter.WriteAsync(request.TagPath, request.Value, cts.Token).ContinueWith(t =>
{
cts.Dispose();
if (t.IsCompletedSuccessfully)
@@ -1158,11 +1193,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
var sender = Sender;
var cts = new CancellationTokenSource(_options.WriteTimeout);
// Capture the adapter on the actor thread (S7). The trigger write below runs AFTER
// the batch await, so re-reading the _adapter field there could hit a post-failover
// adapter; both legs must use the adapter that started this batch.
var adapter = _adapter;
async Task<WriteTagBatchResponse> RunAsync()
{
try
{
var results = await _adapter.WriteBatchAsync(
var results = await adapter.WriteBatchAsync(
new Dictionary<string, object?>(request.Values), cts.Token);
var failed = results.Values.Where(r => !r.Success).Select(r => r.ErrorMessage).ToList();
if (failed.Count > 0)
@@ -1172,7 +1212,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
if (!string.IsNullOrEmpty(request.TriggerTagPath))
{
var tr = await _adapter.WriteAsync(request.TriggerTagPath, request.TriggerValue, cts.Token);
var tr = await adapter.WriteAsync(request.TriggerTagPath, request.TriggerValue, cts.Token);
if (!tr.Success)
return new WriteTagBatchResponse(
request.CorrelationId, false,
@@ -1569,6 +1609,10 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_log.Info("[{0}] Re-subscribing {1} tags after reconnect", _connectionName, allTags.Count);
var self = Self;
// NOTE: do NOT clear _instancesByTag here. It is the inverse of
// _subscriptionsByInstance, which this method deliberately preserves as the durable
// source of truth across reconnect — clearing the index would silently stop the
// post-reconnect value fan-out for every already-subscribed tag.
_subscriptionIds.Clear();
_unresolvedTags.Clear();
_resolutionInFlight.Clear();
@@ -1591,9 +1635,13 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_healthCollector.UpdateTagQuality(_connectionName, _tagsGoodQuality, _tagsBadQuality, _tagsUncertainQuality);
var generation = _adapterGeneration;
// Capture the adapter up front (S7), symmetric with reseedAdapter below. This loop
// runs on the actor thread so a mid-loop swap can't happen, but reading the local
// keeps every adapter access in this method tied to one generation.
var subscribeAdapter = _adapter;
foreach (var tagPath in allTags)
{
_adapter.SubscribeAsync(tagPath, (path, value) =>
subscribeAdapter.SubscribeAsync(tagPath, (path, value) =>
{
self.Tell(new TagValueReceived(path, value, generation));
}).ContinueWith(t =>
@@ -1690,6 +1738,31 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}
}
/// <summary>
/// Adds <paramref name="instance"/> to the reverse index for <paramref name="tag"/>.
/// Call at every site that adds a tag to an instance's <see cref="_subscriptionsByInstance"/> set.
/// </summary>
private void IndexTag(string tag, string instance)
{
if (!_instancesByTag.TryGetValue(tag, out var insts))
{
insts = new HashSet<string>();
_instancesByTag[tag] = insts;
}
insts.Add(instance);
}
/// <summary>
/// Removes <paramref name="instance"/> from the reverse index for <paramref name="tag"/>,
/// dropping the tag key entirely when its instance set empties. Call at every site that
/// removes a tag from an instance's <see cref="_subscriptionsByInstance"/> set.
/// </summary>
private void UnindexTag(string tag, string instance)
{
if (_instancesByTag.TryGetValue(tag, out var insts) && insts.Remove(instance) && insts.Count == 0)
_instancesByTag.Remove(tag);
}
private void HandleTagValueReceived(TagValueReceived msg)
{
// Drop values delivered by a disposed adapter. After a
@@ -1702,16 +1775,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
return;
}
// Fan out to all subscribed instances
foreach (var (instanceName, tags) in _subscriptionsByInstance)
// Fan out to exactly the instances subscribed to this tag via the reverse index —
// O(subscribers of this tag) instead of scanning every instance's tag set per update.
if (_instancesByTag.TryGetValue(msg.TagPath, out var interestedInstances))
{
if (!tags.Contains(msg.TagPath))
continue;
if (_subscribers.TryGetValue(instanceName, out var subscriber))
foreach (var instanceName in interestedInstances)
{
subscriber.Tell(new TagValueUpdate(
_connectionName, msg.TagPath, msg.Value.Value, msg.Value.Quality, msg.Value.Timestamp));
if (_subscribers.TryGetValue(instanceName, out var subscriber))
{
subscriber.Tell(new TagValueUpdate(
_connectionName, msg.TagPath, msg.Value.Value, msg.Value.Quality, msg.Value.Timestamp));
}
}
}
@@ -1760,6 +1834,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_alarmSourceSubscribers[request.SourceReference] = subs;
}
subs.Add(subscriber);
// S10: the adapter feed carries a single shared condition filter per source.
// A second subscriber with a different filter silently overwrites the first
// (last-writer-wins). Surface it so a co-subscriber mismatch is diagnosable —
// co-subscribers to one source are expected to agree on the filter.
if (_alarmSourceFilter.TryGetValue(request.SourceReference, out var existingFilter)
&& !string.Equals(existingFilter, request.ConditionFilter, StringComparison.Ordinal))
{
_log.Warning(
"[{0}] Alarm condition filter for source {1} overwritten: '{2}' -> '{3}' (last subscriber wins; co-subscribers must agree)",
_connectionName, request.SourceReference, existingFilter, request.ConditionFilter);
}
_alarmSourceFilter[request.SourceReference] = request.ConditionFilter;
// Parse the type-name filter once; this is the authoritative client-side
// gate consulted on every routed transition.
@@ -67,6 +67,27 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
var cfg = MxGatewayEndpointConfigSerializer.FromFlatDict(connectionDetails);
// Reconnect reuses this adapter: cancel the previous event loop and dispose
// the previous client so a flapping gateway cannot accumulate orphaned
// streams/sockets, and the old loop's fault path cannot signal Disconnected
// against the new session after the guard resets below. Keep this BEFORE the
// _disconnectFired reset so a fault raised by the dying loop during teardown
// is absorbed by the old guard cycle. Dispose is best-effort fire-and-forget.
if (_eventLoopCts is { } previousLoopCts)
{
previousLoopCts.Cancel();
previousLoopCts.Dispose();
_eventLoopCts = null;
}
if (_client is { } previousClient)
{
_ = previousClient.DisposeAsync().AsTask().ContinueWith(
t => _logger.LogWarning(t.Exception?.GetBaseException(),
"Disposing previous MxGateway client failed"),
TaskContinuationOptions.OnlyOnFaulted);
}
Interlocked.Exchange(ref _disconnectFired, 0); // reset guard on (re)connect, like OPC UA
_client = _clientFactory.Create();
@@ -96,6 +96,24 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
_status = ConnectionHealth.Connecting;
// Reconnect reuses this adapter instance (DataConnectionActor re-calls
// ConnectAsync on auto-reconnect): detach + dispose the previous client so
// (a) its session/keep-alive/socket do not leak per flap and (b) its late
// ConnectionLost cannot flap the NEW healthy session after _disconnectFired
// is reset below. Also stop the previous heartbeat monitor so the old
// StaleTagMonitor cannot fire against the new session. Dispose is best-effort
// fire-and-forget (CloseAsync against a dead server can block for the
// operation timeout); faults are logged, never thrown into the connect path.
StopHeartbeatMonitor();
if (_client is { } previousClient)
{
previousClient.ConnectionLost -= OnClientConnectionLost;
_ = previousClient.DisposeAsync().AsTask().ContinueWith(
t => _logger.LogWarning(t.Exception?.GetBaseException(),
"Disposing previous OPC UA client failed for {Endpoint}", _endpointUrl),
TaskContinuationOptions.OnlyOnFaulted);
}
_client = _clientFactory.Create();
_client.ConnectionLost += OnClientConnectionLost;
await _client.ConnectAsync(_endpointUrl, options, cancellationToken);
@@ -107,6 +107,15 @@ public class HealthReportSender : BackgroundService
{
var parkedCount = await _sfStorage.GetParkedMessageCountAsync();
_collector.SetParkedMessageCount(parkedCount);
// Parked-retention aging signal: age of the oldest parked row
// (null when none are parked), so a forgotten site's stale
// backlog is visible by age, not just count.
var oldestParked = await _sfStorage.GetOldestParkedCreatedAtAsync();
_collector.SetOldestParkedMessageAgeSeconds(
oldestParked.HasValue
? (DateTimeOffset.UtcNow - oldestParked.Value).TotalSeconds
: null);
}
catch (Exception ex)
{
@@ -111,6 +111,19 @@ public interface ISiteHealthCollector
/// <param name="count">The number of parked messages.</param>
void SetParkedMessageCount(int count);
/// <summary>
/// Sets the age (seconds) of the oldest parked store-and-forward message for the
/// next <see cref="CollectReport"/> call; <c>null</c> when no rows are parked.
/// Point-in-time (not reset on collect), refreshed by the parked-count poller in
/// <c>HealthReportSender</c>. Default interface implementation is a no-op so
/// existing test fakes continue to compile without per-fake updates.
/// </summary>
/// <param name="ageSeconds">Age in seconds of the oldest parked row, or <c>null</c> if none are parked.</param>
void SetOldestParkedMessageAgeSeconds(double? ageSeconds)
{
// Default no-op so test fakes do not need to be updated.
}
/// <summary>
/// Replace the latest cumulative
/// site-event-log write-failure count (SQLite error, disk full,
@@ -128,6 +141,24 @@ public interface ISiteHealthCollector
// SiteHealthCollector overrides this with the Interlocked.Exchange store.
}
/// <summary>
/// Replace the latest script-execution-scheduler gauges (queue depth, busy
/// thread count, and age in seconds of the oldest in-flight script) used by
/// the next <see cref="CollectReport"/> call. Refreshed periodically by the
/// <c>ScriptSchedulerStatsReporter</c> hosted service. Point-in-time: the
/// values are NOT reset on <see cref="CollectReport"/>. S2/UA5 — surfaces a
/// saturated or stuck script-execution pool on the site health report.
/// Default interface implementation is a no-op so existing test fakes continue
/// to compile without per-fake updates.
/// </summary>
/// <param name="queueDepth">Script tasks waiting to run.</param>
/// <param name="busyThreads">Worker threads currently executing a script.</param>
/// <param name="oldestBusyAgeSeconds">Age (seconds) of the oldest in-flight script, or <c>null</c> when idle.</param>
void SetScriptSchedulerStats(int queueDepth, int busyThreads, double? oldestBusyAgeSeconds)
{
// Default no-op so test fakes do not need to be updated.
}
/// <summary>
/// Sets the hostname of this node.
/// </summary>
@@ -25,6 +25,16 @@ public class SiteHealthCollector : ISiteHealthCollector
private IReadOnlyDictionary<string, int> _sfBufferDepths = new Dictionary<string, int>();
private int _deployedInstanceCount, _enabledInstanceCount, _disabledInstanceCount;
private int _parkedMessageCount;
// Age (seconds) of the oldest parked message, stored as raw double bits so it can
// be read/written atomically via Interlocked (double? is neither atomic nor
// volatile-able). NaN is the "no parked rows" sentinel (ages are always >= 0).
private long _oldestParkedAgeBits = BitConverter.DoubleToInt64Bits(double.NaN);
// Script-execution-scheduler gauges (S2/UA5), point-in-time, refreshed by the
// ScriptSchedulerStatsReporter. Oldest-busy age stored as double bits like the
// parked-age above; NaN = idle (no in-flight script).
private int _scriptQueueDepth;
private int _scriptBusyThreads;
private long _scriptOldestBusyAgeBits = BitConverter.DoubleToInt64Bits(double.NaN);
private volatile string _nodeHostname = "";
private volatile IReadOnlyList<Commons.Messages.Health.NodeStatus>? _clusterNodes;
private volatile bool _isActiveNode;
@@ -123,6 +133,36 @@ public class SiteHealthCollector : ISiteHealthCollector
Interlocked.Exchange(ref _parkedMessageCount, count);
}
/// <inheritdoc />
public void SetOldestParkedMessageAgeSeconds(double? ageSeconds)
{
Interlocked.Exchange(ref _oldestParkedAgeBits,
BitConverter.DoubleToInt64Bits(ageSeconds ?? double.NaN));
}
/// <summary>Reads the atomically-stored oldest-parked age, mapping the NaN sentinel back to null.</summary>
private double? ReadOldestParkedAgeSeconds()
{
var value = BitConverter.Int64BitsToDouble(Interlocked.Read(ref _oldestParkedAgeBits));
return double.IsNaN(value) ? null : value;
}
/// <inheritdoc />
public void SetScriptSchedulerStats(int queueDepth, int busyThreads, double? oldestBusyAgeSeconds)
{
Interlocked.Exchange(ref _scriptQueueDepth, queueDepth);
Interlocked.Exchange(ref _scriptBusyThreads, busyThreads);
Interlocked.Exchange(ref _scriptOldestBusyAgeBits,
BitConverter.DoubleToInt64Bits(oldestBusyAgeSeconds ?? double.NaN));
}
/// <summary>Reads the atomically-stored oldest-busy script age, mapping the NaN sentinel back to null.</summary>
private double? ReadScriptOldestBusyAgeSeconds()
{
var value = BitConverter.Int64BitsToDouble(Interlocked.Read(ref _scriptOldestBusyAgeBits));
return double.IsNaN(value) ? null : value;
}
/// <inheritdoc />
public void SetNodeHostname(string hostname) => _nodeHostname = hostname;
@@ -214,6 +254,12 @@ public class SiteHealthCollector : ISiteHealthCollector
SiteAuditWriteFailures: siteAuditWriteFailures,
AuditRedactionFailure: auditRedactionFailures,
SiteAuditBacklog: _siteAuditBacklog,
SiteEventLogWriteFailures: Interlocked.Read(ref _siteEventLogWriteFailures));
SiteEventLogWriteFailures: Interlocked.Read(ref _siteEventLogWriteFailures),
OldestParkedMessageAgeSeconds: ReadOldestParkedAgeSeconds())
{
ScriptQueueDepth = Interlocked.CompareExchange(ref _scriptQueueDepth, 0, 0),
ScriptBusyThreads = Interlocked.CompareExchange(ref _scriptBusyThreads, 0, 0),
ScriptOldestBusyAgeSeconds = ReadScriptOldestBusyAgeSeconds()
};
}
}
@@ -769,7 +769,7 @@ akka {{
var replicationActor = _actorSystem!.ActorOf(
Props.Create(() => new SiteReplicationActor(
storage, sfStorage, replicationService, siteRole, replicationLogger,
deploymentConfigFetcher)),
deploymentConfigFetcher, null, siteRuntimeOptionsValue, null)),
"site-replication");
// Wire S&F replication handler to forward operations via the replication actor
@@ -859,6 +859,22 @@ akka {{
cancellationToken.ThrowIfCancellationRequested();
await storeAndForwardService.StartAsync();
// Standby must be passive: only the active site node runs the S&F
// delivery sweep. Without this gate BOTH nodes deliver the same
// replicated Pending rows — systematic duplicate external calls and
// DB writes (arch review 02, Stability #2). Re-evaluated per sweep
// tick so failover resumes delivery within one RetryTimerInterval.
// IClusterNodeProvider.SelfIsPrimary is the canonical "this node is the
// oldest Up member (singleton host)" check from the cluster-infrastructure
// fix plan — the shared helper this seam was designed to accept. In a
// non-clustered test host the provider is unregistered, so the gate stays
// unset and the sweep is ungated (legacy behaviour, preserved).
var clusterNodeProvider = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.HealthMonitoring.IClusterNodeProvider>();
if (clusterNodeProvider != null)
{
storeAndForwardService.SetDeliveryGate(() => clusterNodeProvider.SelfIsPrimary);
}
// Register the store-and-forward delivery handlers so buffered
// ExternalSystem calls, cached DB writes and notifications are actually
// delivered by the retry sweep. Without this, every buffered message is
@@ -212,11 +212,11 @@ public class SiteEventLogger : ISiteEventLogger, IDisposable
// on disk I/O or on contention for the write lock.
if (!_writeQueue.Writer.TryWrite(pending))
{
// The channel is unbounded, so the only way TryWrite fails is that the
// writer has been completed (logger disposed). The event cannot be
// persisted — fault the Task rather than
// reporting false success, so a caller that awaits a critical audit
// event can tell it was dropped.
// The channel is bounded with DropOldest, so TryWrite only returns false
// once the channel is completed (logger disposed) — full-buffer pressure
// evicts the oldest pending event instead of failing the write. The event
// cannot be persisted — fault the Task rather than reporting false success,
// so a caller that awaits a critical audit event can tell it was dropped.
pending.Completion.TrySetException(
new ObjectDisposedException(nameof(SiteEventLogger),
"Event could not be recorded: the event logger has been disposed."));
@@ -86,6 +86,12 @@ public class AlarmActor : ReceiveActor
// expression on the hot path.
private readonly Script<object?>? _compiledTriggerExpression;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
// Coalescing guard for off-dispatcher expression evaluation (P1): at most one
// evaluation runs on the script scheduler at a time; a change arriving while
// one is in flight sets _evalPending so exactly one more runs afterwards
// against the latest snapshot. Both flags are touched only on the actor thread.
private bool _evalInFlight;
private bool _evalPending;
/// <summary>
/// The exact dictionary instance this actor was seeded from
@@ -177,6 +183,9 @@ public class AlarmActor : ReceiveActor
// Handle alarm execution completion
Receive<AlarmExecutionCompleted>(_ =>
_logger.LogDebug("Alarm {Alarm} execution completed on {Instance}", _alarmName, _instanceName));
// Handle the off-dispatcher trigger-expression evaluation result (P1).
Receive<ExpressionEvalResult>(HandleExpressionEvalResult);
}
/// <inheritdoc />
@@ -230,52 +239,25 @@ public class AlarmActor : ReceiveActor
return;
}
// Expression triggers evaluate off the dispatcher on the script
// scheduler (P1) — the result comes back as ExpressionEvalResult and
// drives ApplyTriggeredState there. All other trigger types are cheap
// synchronous comparisons and keep the inline path.
if (_triggerType == AlarmTriggerType.Expression)
{
StartExpressionEvaluation();
return;
}
var isTriggered = _triggerType switch
{
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
AlarmTriggerType.Expression => EvaluateExpression(),
_ => false
};
if (isTriggered && _currentState == AlarmState.Normal)
{
// Transition: Normal → Active
_currentState = AlarmState.Active;
_logger.LogInformation(
"Alarm {Alarm} ACTIVATED on instance {Instance}",
_alarmName, _instanceName);
// Notify Instance Actor of alarm state change
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// Operational `alarm` event — raise. Severity by priority.
LogAlarmEvent(RaiseSeverity(_priority), $"Alarm {_alarmName} activated (priority {_priority})");
// Spawn AlarmExecutionActor if on-trigger script defined
if (_onTriggerCompiledScript != null)
{
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty);
}
}
else if (!isTriggered && _currentState == AlarmState.Active)
{
// Transition: Active → Normal (no script on clear)
_currentState = AlarmState.Normal;
_logger.LogInformation(
"Alarm {Alarm} CLEARED on instance {Instance}",
_alarmName, _instanceName);
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// Operational `alarm` event — return to normal.
LogAlarmEvent("Info", $"Alarm {_alarmName} cleared");
}
ApplyTriggeredState(isTriggered);
}
catch (Exception ex)
{
@@ -287,6 +269,53 @@ public class AlarmActor : ReceiveActor
}
}
/// <summary>
/// Applies the binary Normal↔Active transition for the current triggered
/// value. Shared by the synchronous trigger types and the asynchronous
/// Expression path (via <see cref="HandleExpressionEvalResult"/>). Edge state
/// (<see cref="_currentState"/>) is touched only on the actor thread.
/// </summary>
private void ApplyTriggeredState(bool isTriggered)
{
if (isTriggered && _currentState == AlarmState.Normal)
{
// Transition: Normal → Active
_currentState = AlarmState.Active;
_logger.LogInformation(
"Alarm {Alarm} ACTIVATED on instance {Instance}",
_alarmName, _instanceName);
// Notify Instance Actor of alarm state change
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// Operational `alarm` event — raise. Severity by priority.
LogAlarmEvent(RaiseSeverity(_priority), $"Alarm {_alarmName} activated (priority {_priority})");
// Spawn AlarmExecutionActor if on-trigger script defined
if (_onTriggerCompiledScript != null)
{
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty);
}
}
else if (!isTriggered && _currentState == AlarmState.Active)
{
// Transition: Active → Normal (no script on clear)
_currentState = AlarmState.Normal;
_logger.LogInformation(
"Alarm {Alarm} CLEARED on instance {Instance}",
_alarmName, _instanceName);
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// Operational `alarm` event — return to normal.
LogAlarmEvent("Info", $"Alarm {_alarmName} cleared");
}
}
/// <summary>
/// HiLo state machine: emit an AlarmStateChanged whenever the evaluated
/// level changes. Spawns the on-trigger script only on the Normal→Active
@@ -488,34 +517,63 @@ public class AlarmActor : ReceiveActor
/// as an alarm error) so that the state machine still runs — an Active
/// alarm correctly clears if the expression starts throwing.
/// </summary>
private bool EvaluateExpression()
private void StartExpressionEvaluation()
{
if (_compiledTriggerExpression == null) return false;
if (_compiledTriggerExpression == null) return;
if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending
_evalInFlight = true;
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
var expression = _compiledTriggerExpression;
var self = Self;
Task.Factory.StartNew(async () =>
{
try
{
// Bound evaluation with a short timeout. The CancellationToken covers
// cooperative/async cases; a pathological CPU-bound expression is not
// fully interruptible — acceptable because trigger expressions are
// authored by trusted Design-role users and compile-checked pre-deploy.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = await expression.RunAsync(new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
return state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too and is
// treated as false. _healthCollector (Interlocked) and _logger are
// thread-safe, so this catch is safe off the actor thread.
_healthCollector?.IncrementAlarmError();
_logger.LogError(ex,
"Alarm {Alarm} trigger expression evaluation failed on {Instance}; treated as false",
_alarmName, _instanceName);
return false;
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
success: r => new ExpressionEvalResult(r));
}
/// <summary>
/// Applies an off-dispatcher expression result on the actor thread: runs the
/// Normal↔Active transition, clears the in-flight flag, and drains a coalesced
/// pending evaluation (against the now-current snapshot) if one was requested.
/// </summary>
private void HandleExpressionEvalResult(ExpressionEvalResult msg)
{
_evalInFlight = false;
try
{
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
// Bound evaluation with a short timeout. The CancellationToken
// covers cooperative/async cases; a pathological CPU-bound
// expression is not fully interruptible. Acceptable because
// trigger expressions are authored by trusted Design-role users
// and are compile-checked pre-deployment.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = _compiledTriggerExpression
.RunAsync(globals, cancellationToken: cts.Token)
.GetAwaiter().GetResult();
return state.ReturnValue is bool b && b;
ApplyTriggeredState(msg.Result);
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too,
// and is correctly treated as false.
_healthCollector?.IncrementAlarmError();
_logger.LogError(ex,
"Alarm {Alarm} trigger expression evaluation failed on {Instance}; treated as false",
"Alarm {Alarm} state transition error on {Instance}",
_alarmName, _instanceName);
return false;
}
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
}
/// <summary>
@@ -730,6 +788,13 @@ public class AlarmActor : ReceiveActor
// ── Internal messages ──
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
/// <summary>
/// Piped back to self from an off-dispatcher trigger-expression evaluation (P1);
/// carries the boolean truth value so the Normal↔Active transition runs on the
/// actor thread.
/// </summary>
private sealed record ExpressionEvalResult(bool Result);
}
internal enum RateOfChangeDirection { Either, Rising, Falling }
@@ -51,6 +51,7 @@ public class CertStoreActor : ReceiveActor
Receive<WriteCertToLocalStore>(HandleWrite);
Receive<RemoveCertFromLocalStore>(HandleRemove);
Receive<ListLocalCerts>(_ => HandleList());
Receive<ExportLocalTrustedCerts>(_ => HandleExport());
}
/// <summary>
@@ -133,6 +134,47 @@ public class CertStoreActor : ReceiveActor
}
}
/// <summary>
/// Exports the trusted-peer store (thumbprint + raw DER) so the Deployment
/// Manager singleton can reconcile the trusted set onto a (re)joining node.
/// Trusted store only — rejected certs are never pushed to peers.
/// </summary>
private void HandleExport()
{
try
{
var certs = new List<ExportedCert>();
if (Directory.Exists(_trustedStoreDir))
{
foreach (var file in Directory.EnumerateFiles(_trustedStoreDir)
.Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)))
{
try
{
var der = File.ReadAllBytes(file);
// Load only to derive the canonical thumbprint (the store
// filename); the DER bytes shipped are the raw file bytes.
using var cert = X509CertificateLoader.LoadCertificate(der);
certs.Add(new ExportedCert(cert.Thumbprint, der));
}
catch (Exception ex)
{
// A malformed file must not abort the whole export.
_log.Warning(ex, "Skipping unreadable certificate file {File} during export", file);
}
}
}
Sender.Tell(new LocalCertExport(true, null, certs));
}
catch (Exception ex)
{
_log.Warning(ex, "Failed to export trusted certificates from PKI store");
Sender.Tell(new LocalCertExport(false, ex.Message, Array.Empty<ExportedCert>()));
}
}
private IEnumerable<TrustedCertInfo> EnumerateStore(string storeDir, bool rejected)
{
if (!Directory.Exists(storeDir))
@@ -35,6 +35,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
// Pure, off-thread deploy-time compile gate. A site-side compile failure
// must reject the deployment (spec: no partial state applied), so every
// DeployInstanceCommand is validated before any actor/persistence work.
private readonly DeployCompileValidator _deployCompileValidator;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteStreamManager? _streamManager;
private readonly SiteRuntimeOptions _options;
@@ -57,6 +61,23 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// nodes/tests that never receive a refresh; the active site path supplies it via DI.
/// </summary>
private readonly IDeploymentConfigFetcher? _configFetcher;
/// <summary>
/// Delay before re-attempting the startup deployed-config load after a
/// transient failure (S5). Fixed-interval, unlimited retries — consistent with
/// the system's fixed-interval retry philosophy. Overridable via the ctor for tests.
/// </summary>
private readonly TimeSpan _startupLoadRetryInterval;
/// <summary>
/// Loads all deployed configs at startup. Defaults to
/// <see cref="SiteStorageService.GetAllDeployedConfigsAsync"/>; overridable via the
/// ctor so a test can drive a transient load failure (S5).
/// </summary>
private readonly Func<Task<List<DeployedInstance>>> _configLoader;
/// <summary>
/// Set once the startup deployed-config load has succeeded. Guards against a
/// stale <see cref="RetryStartupLoad"/> re-running the load after success.
/// </summary>
private bool _startupLoadCompleted;
private readonly Dictionary<string, IActorRef> _instanceActors = new();
/// <summary>
/// Tracks Instance Actors that are terminating as part of a redeployment, keyed by
@@ -89,6 +110,30 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// </summary>
private readonly HashSet<string> _deployedInstanceNames = new();
/// <summary>
/// Deployments awaiting their persistence result, keyed by instance name (S6).
/// The entry is added when the Instance Actor is created optimistically and removed
/// by whichever of two paths fires first:
/// <list type="bullet">
/// <item><see cref="HandleDeployPersistenceResult"/> on a normal persistence outcome,
/// which then replies Success/Failed;</item>
/// <item>the <see cref="HandleTerminated"/> S6 fallback when the actor dies during
/// init, which replies Failed and rolls back the optimistic state.</item>
/// </list>
/// Removing the entry in exactly one of those paths guarantees the deployer receives
/// exactly one <see cref="DeploymentStatusResponse"/> regardless of ordering.
/// </summary>
private readonly Dictionary<string, InFlightDeploy> _inFlightDeployments = new();
/// <summary>
/// Instances whose deployment was failed by the S6 Terminated fallback WHILE their
/// optimistic <c>StoreDeployedConfigAsync</c> was still in flight. The persisted-row
/// rollback must run AFTER the store commits (the two are independent background
/// tasks with no ordering guarantee), so the removal is deferred here and executed by
/// <see cref="HandleDeployPersistenceResult"/> when the store's result finally lands.
/// </summary>
private readonly HashSet<string> _initFailedPendingRowRemoval = new();
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!;
@@ -121,10 +166,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
ILoggerFactory? loggerFactory = null,
IDeploymentConfigFetcher? configFetcher = null)
IDeploymentConfigFetcher? configFetcher = null,
TimeSpan? startupLoadRetryInterval = null,
Func<Task<List<DeployedInstance>>>? configLoader = null)
{
_storage = storage;
_compilationService = compilationService;
_deployCompileValidator = new DeployCompileValidator(compilationService);
_sharedScriptLibrary = sharedScriptLibrary;
_streamManager = streamManager;
_options = options;
@@ -133,6 +181,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_configFetcher = configFetcher;
_startupLoadRetryInterval = startupLoadRetryInterval ?? TimeSpan.FromSeconds(5);
_configLoader = configLoader ?? (() => _storage.GetAllDeployedConfigsAsync());
_logger = logger;
// Reuse a single logger factory for all Instance Actors.
// Prefer an explicitly injected factory, fall back to one resolved from
@@ -220,8 +270,25 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<RemoveServerCertCommand>(HandleRemoveServerCert);
Receive<ListServerCertsCommand>(HandleListServerCerts);
// UA1: cert-trust reconcile-on-join. A (re)joining site node may have
// missed a trust broadcast while it was down, leaving its PKI store
// divergent forever. The singleton reads its own trusted store and pushes
// each cert to the joining node so the two per-node stores reconverge.
Receive<ClusterEvent.MemberUp>(HandleMemberUp);
Receive<SiteNodeJoined>(HandleSiteNodeJoined);
Receive<LocalCertExport>(HandleLocalCertExport);
// Internal startup messages
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
// S5: re-attempt the startup deployed-config load after a transient
// failure. Ignored once the load has already completed so a stale timer
// (e.g. a failure retry that raced a subsequent success) cannot re-run it.
Receive<RetryStartupLoad>(_ =>
{
if (_startupLoadCompleted)
return;
LoadDeployedConfigs();
});
Receive<SharedScriptsLoaded>(HandleSharedScriptsLoaded);
Receive<StartNextBatch>(HandleStartNextBatch);
@@ -231,6 +298,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Internal deploy persistence result
Receive<DeployPersistenceResult>(HandleDeployPersistenceResult);
// Instance Actor init confirmation — the second half of the deploy-success
// join (S6). Success is only reported once both this and persistence land.
Receive<InstanceActorInitialized>(HandleInstanceActorInitialized);
// Terminated signal — drains a buffered redeployment once the previous
// Instance Actor has fully stopped.
Receive<Terminated>(HandleTerminated);
@@ -243,8 +314,38 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_healthCollector?.SetActiveNode(true);
_logger.LogInformation("DeploymentManagerActor starting — loading deployed configs from SQLite...");
// Load all configs asynchronously and pipe to self
_storage.GetAllDeployedConfigsAsync().ContinueWith(t =>
// UA1: watch for site nodes coming Up so we can reconcile cert trust onto
// them. Cluster-only concern — a no-op in the unit-test ActorSystem which
// has no cluster provider.
if (ClusterAvailable(Context.System))
{
Cluster.Get(Context.System).Subscribe(Self,
ClusterEvent.SubscriptionInitialStateMode.InitialStateAsEvents,
typeof(ClusterEvent.MemberUp));
}
LoadDeployedConfigs();
}
/// <summary>
/// True when this <see cref="ActorSystem"/> is running the cluster ref-provider.
/// The unit-test harness uses the local provider, where <see cref="Cluster.Get"/>
/// would throw — the cert reconcile-on-join subscription is skipped there.
/// </summary>
private static bool ClusterAvailable(ActorSystem system) =>
system is ExtendedActorSystem ext &&
ext.Provider.GetType().Name.Contains("Cluster", StringComparison.Ordinal);
/// <summary>
/// Loads all deployed configs off the actor thread and pipes the result back as a
/// <see cref="StartupConfigsLoaded"/>. Called from <see cref="PreStart"/> and re-invoked
/// by a <see cref="RetryStartupLoad"/> timer when a load fails (S5) — a transient SQLite
/// failure must NOT leave the site as a silent zero-instance node, so the load is retried
/// at a fixed interval until it succeeds.
/// </summary>
private void LoadDeployedConfigs()
{
_configLoader().ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return new StartupConfigsLoaded(t.Result, null);
@@ -256,6 +357,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
protected override void PostStop()
{
_healthCollector?.SetActiveNode(false);
if (ClusterAvailable(Context.System))
{
Cluster.Get(Context.System).Unsubscribe(Self);
}
base.PostStop();
}
@@ -292,10 +397,20 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
if (msg.Error != null)
{
_logger.LogError("Failed to load deployed configs: {Error}", msg.Error);
// S5: a transient SQLite failure must not strand the site as a silent
// zero-instance node. Log and re-arm the load at a fixed interval; the
// retry is unlimited and self-cancels once a load succeeds.
_logger.LogError(
"Failed to load deployed configs: {Error} — retrying in {Interval}",
msg.Error, _startupLoadRetryInterval);
Timers.StartSingleTimer(
"startup-load-retry", RetryStartupLoad.Instance, _startupLoadRetryInterval);
return;
}
// Mark the startup load complete so a stale retry timer is ignored.
_startupLoadCompleted = true;
var enabledConfigs = msg.Configs.Where(c => c.IsEnabled).ToList();
_deployedInstanceNames.Clear();
foreach (var c in msg.Configs)
@@ -387,6 +502,41 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// apply still replies to the right actor.
/// </param>
private void HandleDeploy(DeployInstanceCommand command, IActorRef replyTo)
{
// Site-side compile gate (S3): a compile failure must reject the
// deployment with NO partial state applied (no Instance Actor, no
// persisted config) — the design spec's contract. Validation runs
// synchronously on the actor thread: it is a pure prefix step of the
// deploy handler, so the existing redeploy-supersede / delete-during-
// redeploy ordering (which depends on strict mailbox FIFO) is preserved
// exactly. It is NOT run off-thread — piping the verdict back to self
// reorders concurrent deploys relative to each other and to
// delete/disable commands, breaking that ordering. A deploy is an
// infrequent admin command, so briefly holding the singleton for a pure
// Roslyn compile is acceptable; the central deployer already Asks and
// waits for the DeploymentStatusResponse.
var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson);
if (errors.Count > 0)
{
var instanceName = command.InstanceUniqueName;
var joined = string.Join(" | ", errors);
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} deploy rejected — site compile validation failed (deploymentId={command.DeploymentId})",
joined);
replyTo.Tell(new DeploymentStatusResponse(
command.DeploymentId, instanceName, DeploymentStatus.Failed,
"Script compilation failed on site: " + joined, DateTimeOffset.UtcNow));
return;
}
ProceedWithDeploy(command, replyTo);
}
/// <summary>
/// Core deploy application (redeploy-supersede + fresh apply), reached only
/// after the synchronous compile gate in <see cref="HandleDeploy"/> passes.
/// </summary>
private void ProceedWithDeploy(DeployInstanceCommand command, IActorRef replyTo)
{
var instanceName = command.InstanceUniqueName;
_logger.LogInformation(
@@ -435,22 +585,75 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Recreates an Instance Actor once its predecessor has fully terminated during a
/// redeployment, draining the buffered <see cref="DeployInstanceCommand"/>.
/// Handles an Instance Actor's <see cref="Terminated"/> signal. Two cases:
/// <list type="number">
/// <item>The child was stopped as part of a redeployment — recreate it from the
/// buffered <see cref="DeployInstanceCommand"/>.</item>
/// <item>The child terminated OUTSIDE the redeploy flow — an init failure or crash.
/// Evict the now-dead ref from the routing map so the singleton never routes to a
/// stopped actor (S6).</item>
/// </list>
/// </summary>
private void HandleTerminated(Terminated terminated)
{
if (!_pendingRedeploys.Remove(terminated.ActorRef, out var pending))
// Case 1 — redeployment drain: the predecessor of a buffered redeploy has
// fully stopped and freed its actor name.
if (_pendingRedeploys.Remove(terminated.ActorRef, out var pending))
{
// Drop the name-keyed shadow now that the predecessor has
// fully terminated and its actor name is free. Any deploy arriving after
// this point sees neither _instanceActors[name] (cleared when we stopped
// the predecessor) nor _terminatingActorsByName[name] (cleared here), so
// ApplyDeployment + Context.ActorOf below safely reuses the name.
_terminatingActorsByName.Remove(pending.Command.InstanceUniqueName);
ApplyDeployment(pending.Command, pending.OriginalSender, isRedeploy: true);
return;
}
// Drop the name-keyed shadow now that the predecessor has
// fully terminated and its actor name is free. Any deploy arriving after
// this point sees neither _instanceActors[name] (cleared when we stopped
// the predecessor) nor _terminatingActorsByName[name] (cleared here), so
// ApplyDeployment + Context.ActorOf below safely reuses the name.
_terminatingActorsByName.Remove(pending.Command.InstanceUniqueName);
// Case 2 — S6 fallback: an Instance Actor terminated unexpectedly (init
// failure or crash). Expected stops (disable/delete/redeploy/persistence
// rollback) all remove or replace the map entry BEFORE the child stops, so
// the equality guard is false for them and this branch is inert. Only a dead
// ref still mapped to this exact actor reaches here.
var name = terminated.ActorRef.Path.Name;
if (_instanceActors.TryGetValue(name, out var mapped) && mapped.Equals(terminated.ActorRef))
{
_instanceActors.Remove(name);
UpdateInstanceCounts();
_logger.LogError(
"Instance Actor {Instance} terminated unexpectedly (init failure or crash) — removed from routing",
name);
LogDeploymentEvent("Error", name,
$"Instance {name} terminated unexpectedly (init failure or crash) — removed from routing");
ApplyDeployment(pending.Command, pending.OriginalSender, isRedeploy: true);
// S6: if the death happened while a deployment was still in flight, the
// actor died during init — fail that deployment (the persistence path must
// NOT later report Success) and roll back the optimistically-applied state
// so the site never advertises an instance it cannot durably recover.
if (_inFlightDeployments.Remove(name, out var inflight))
{
inflight.ReplyTo.Tell(new DeploymentStatusResponse(
inflight.DeploymentId, name, DeploymentStatus.Failed,
"Instance actor failed during initialization — see site event log",
DateTimeOffset.UtcNow));
if (!inflight.IsRedeploy)
{
_deployedInstanceNames.Remove(name);
UpdateInstanceCounts();
// Remove the optimistically-persisted config row so a restart does
// not re-create the failing actor. If the store already committed
// (Persisted), remove it now; otherwise defer the removal until the
// in-flight store completes so the remove cannot race ahead of it.
if (inflight.Persisted)
RollbackPersistedConfig(name);
else
_initFailedPendingRowRemoval.Add(name);
}
}
}
}
/// <summary>
@@ -548,6 +751,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_deployedInstanceNames.Add(instanceName);
UpdateInstanceCounts();
// Record the in-flight deployment so the deployer is answered exactly once:
// Success only once BOTH persistence commits AND the actor confirms init, or
// Failed via the S6 Terminated fallback if the Instance Actor dies during init.
_inFlightDeployments[instanceName] = new InFlightDeploy
{
DeploymentId = command.DeploymentId,
ReplyTo = sender,
IsRedeploy = isRedeploy
};
// Persist to SQLite and clear static overrides asynchronously
Task.Run(async () =>
{
@@ -587,29 +800,44 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Reports the deployment outcome to central only after the
/// persistence result is known. On a persistence failure the Instance Actor that was
/// created optimistically is stopped and the deployed-instance counter rolled back,
/// so the in-memory state stays consistent with durable storage, and central is told
/// the deployment <see cref="DeploymentStatus.Failed"/>.
/// Records the persistence half of the deploy-success join. On a persistence
/// failure the optimistically-created Instance Actor is stopped and the counter
/// rolled back and the deployer is told <see cref="DeploymentStatus.Failed"/>. On
/// success the in-flight entry is marked persisted and success is reported only once
/// the actor has ALSO confirmed init (<see cref="TryCompleteDeploy"/>) — persistence
/// can commit before the actor's async init has run or failed (S6).
/// </summary>
private void HandleDeployPersistenceResult(DeployPersistenceResult result)
{
if (result.Success)
// If the S6 Terminated fallback already failed this deployment (the Instance
// Actor died during init), the in-flight entry is gone — swallow the now-stale
// persistence result so the deployer is answered exactly once.
if (!_inFlightDeployments.TryGetValue(result.InstanceName, out var inflight))
{
// Operational `deployment` event — deploy succeeded.
LogDeploymentEvent("Info", result.InstanceName,
$"Instance {result.InstanceName} deployed (deploymentId={result.DeploymentId})");
// The fallback deferred the persisted-row rollback because the store was
// still in flight then. The store has now committed (Success), so remove
// the orphan row — guaranteed to run after the store, never racing it.
if (_initFailedPendingRowRemoval.Remove(result.InstanceName) && result.Success)
RollbackPersistedConfig(result.InstanceName);
result.OriginalSender.Tell(new DeploymentStatusResponse(
result.DeploymentId,
result.InstanceName,
DeploymentStatus.Success,
null,
DateTimeOffset.UtcNow));
_logger.LogDebug(
"Persistence result for {Instance} arrived after the deployment was already " +
"resolved (init-time termination) — ignoring (deploymentId={DeploymentId}, success={Success})",
result.InstanceName, result.DeploymentId, result.Success);
return;
}
if (result.Success)
{
// Persistence committed — record it and complete only if init also confirmed.
inflight.Persisted = true;
TryCompleteDeploy(result.InstanceName, inflight);
return;
}
// Persistence genuinely failed — fail now regardless of init state.
_inFlightDeployments.Remove(result.InstanceName);
_logger.LogError(
"Failed to persist deployment {DeploymentId} for {Instance}: {Error}",
result.DeploymentId, result.InstanceName, result.Error);
@@ -619,15 +847,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
$"Instance {result.InstanceName} deploy failed (deploymentId={result.DeploymentId})",
result.Error);
// Persistence failed — undo the optimistic actor creation and counter bump so
// the site does not advertise an instance it cannot durably recover.
// Undo the optimistic actor creation and counter bump so the site does not
// advertise an instance it cannot durably recover.
if (_instanceActors.Remove(result.InstanceName, out var orphan))
Context.Stop(orphan);
if (!result.IsRedeploy)
_deployedInstanceNames.Remove(result.InstanceName);
UpdateInstanceCounts();
result.OriginalSender.Tell(new DeploymentStatusResponse(
inflight.ReplyTo.Tell(new DeploymentStatusResponse(
result.DeploymentId,
result.InstanceName,
DeploymentStatus.Failed,
@@ -635,6 +863,61 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
DateTimeOffset.UtcNow));
}
/// <summary>
/// Records the init half of the deploy-success join (S6): the Instance Actor
/// finished PreStart and sent <see cref="InstanceActorInitialized"/>. Success is
/// reported only once persistence has ALSO committed. Actors created outside a
/// deployment (startup, enable) have no in-flight entry and are ignored here.
/// </summary>
private void HandleInstanceActorInitialized(InstanceActorInitialized msg)
{
if (_inFlightDeployments.TryGetValue(msg.InstanceUniqueName, out var inflight))
{
inflight.Initialized = true;
TryCompleteDeploy(msg.InstanceUniqueName, inflight);
}
}
/// <summary>
/// Completes a deployment with <see cref="DeploymentStatus.Success"/> once BOTH the
/// persistence result and the Instance Actor init confirmation have landed (S6).
/// A no-op until both signals are present.
/// </summary>
private void TryCompleteDeploy(string instanceName, InFlightDeploy inflight)
{
if (!inflight.Persisted || !inflight.Initialized)
return;
_inFlightDeployments.Remove(instanceName);
// Operational `deployment` event — deploy succeeded.
LogDeploymentEvent("Info", instanceName,
$"Instance {instanceName} deployed (deploymentId={inflight.DeploymentId})");
inflight.ReplyTo.Tell(new DeploymentStatusResponse(
inflight.DeploymentId,
instanceName,
DeploymentStatus.Success,
null,
DateTimeOffset.UtcNow));
}
/// <summary>
/// Fire-and-forget removal of an optimistically-persisted deployed-config row after
/// an init failure (S6). Only ever called once the row's write has committed, so it
/// cannot race the store. A fault is logged, never thrown.
/// </summary>
private void RollbackPersistedConfig(string instanceName)
{
Task.Run(() => _storage.RemoveDeployedConfigAsync(instanceName)).ContinueWith(t =>
{
if (!t.IsCompletedSuccessfully)
_logger.LogError(t.Exception?.GetBaseException(),
"Failed to roll back the persisted config for {Instance} after an init failure",
instanceName);
});
}
/// <summary>
/// Disables an instance: stops the actor and marks as disabled in SQLite.
/// </summary>
@@ -1006,6 +1289,77 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}).PipeTo(sender);
}
/// <summary>
/// The site node most recently observed coming Up, awaiting its trusted-cert
/// reconcile push. A site cluster is two nodes, so at most one peer reconciles
/// at a time; a single pending slot is sufficient (a second join before the
/// first export returns simply reconciles the newer node — both then converge).
/// </summary>
private Address? _pendingCertReconcileTarget;
/// <summary>
/// UA1: a site node came Up. On the singleton this is the trigger to reconcile
/// cert trust onto that node. Self and non-site members are ignored — self is
/// already the source of truth, and only site nodes host a CertStoreActor.
/// </summary>
private void HandleMemberUp(ClusterEvent.MemberUp evt)
{
var member = evt.Member;
if (!member.HasRole(SiteClusterRole))
return;
if (member.Address.Equals(Cluster.Get(Context.System).SelfAddress))
return;
Self.Tell(new SiteNodeJoined(member.Address));
}
/// <summary>
/// Reads this node's own trusted-peer store (via the LOCAL CertStoreActor) so
/// its contents can be pushed to the joined node. The export reply is handled
/// by <see cref="HandleLocalCertExport"/>, which uses the target captured here.
/// </summary>
private void HandleSiteNodeJoined(SiteNodeJoined msg)
{
_pendingCertReconcileTarget = msg.Address;
Context.ActorSelection($"/user/{CertStoreActor.WellKnownName}")
.Tell(new ExportLocalTrustedCerts());
}
/// <summary>
/// Pushes each locally-trusted certificate to the joined node's CertStoreActor.
/// Additive union only: removals are NOT reconciled here (there is no central
/// persistence of cert trust yet — a logged follow-up), and each write is
/// idempotent (overwrites by thumbprint), so re-pushing an already-trusted cert
/// is harmless. Fire-and-forget: a failed push heals on the next MemberUp or a
/// manual re-broadcast.
/// </summary>
private void HandleLocalCertExport(LocalCertExport export)
{
var target = _pendingCertReconcileTarget;
_pendingCertReconcileTarget = null;
if (target is null)
return;
if (!export.Success)
{
_logger.LogWarning(
"Cert-trust reconcile: could not read local trusted store for joining node {Address}: {Error}",
target, export.Error);
return;
}
var basePath = new RootActorPath(target) / "user" / CertStoreActor.WellKnownName;
foreach (var cert in export.Certs)
{
Context.ActorSelection(basePath).Tell(
new WriteCertToLocalStore(Convert.ToBase64String(cert.DerBytes), cert.Thumbprint));
}
_logger.LogInformation(
"Cert-trust reconcile: pushed {Count} trusted cert(s) to joining node {Address}",
export.Certs.Count, target);
}
// ── DCL connection management ──
/// <summary>
@@ -1627,6 +1981,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var actorRef = Context.ActorOf(props, instanceName);
_instanceActors[instanceName] = actorRef;
// Watch every Instance Actor so an unexpected termination (init failure or
// crash) delivers a Terminated signal that evicts the dead ref from routing
// (S6). Redeploy already double-watches; a double Watch is idempotent in
// Akka.NET (a single Terminated is delivered).
Context.Watch(actorRef);
_logger.LogDebug("Created Instance Actor for {Instance}", instanceName);
}
@@ -1671,8 +2030,27 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// ── Internal messages ──
/// <summary>
/// UA1: a site cluster node has come Up and should receive a cert-trust
/// reconcile push. Derived from <see cref="ClusterEvent.MemberUp"/> (self and
/// non-site members already filtered out) so the reconcile flow is unit-testable
/// without constructing a real cluster membership event.
/// </summary>
internal sealed record SiteNodeJoined(Address Address);
internal record StartupConfigsLoaded(List<DeployedInstance> Configs, string? Error);
/// <summary>
/// Internal singleton signal that re-attempts the startup deployed-config load
/// after a transient SQLite failure (S5). Delivered by a single-shot Akka timer.
/// </summary>
internal sealed record RetryStartupLoad
{
/// <summary>The shared singleton instance.</summary>
public static readonly RetryStartupLoad Instance = new();
private RetryStartupLoad() { }
}
/// <summary>
/// Internal message piped back once shared scripts have been compiled off-thread
/// Carries the enabled configs so staggered Instance Actor
@@ -1694,6 +2072,31 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// </summary>
internal record PendingRedeploy(DeployInstanceCommand Command, IActorRef OriginalSender);
/// <summary>
/// Join state for a deployment awaiting confirmation. Deployment <c>Success</c> is
/// reported only once BOTH signals land: <see cref="Persisted"/> (the config committed
/// to SQLite) AND <see cref="Initialized"/> (the Instance Actor finished PreStart and
/// sent <see cref="Messages.InstanceActorInitialized"/>). Persistence can commit before
/// the actor's asynchronous init has run or failed, so persistence alone is not enough
/// to declare success (S6). An actor that dies during init never sets
/// <see cref="Initialized"/>; the S6 Terminated fallback fails that deployment instead.
/// <see cref="IsRedeploy"/> gates rollback so a failed init rolls back the
/// deployed-instance set and the persisted row only for a genuinely new deployment.
/// </summary>
internal sealed class InFlightDeploy
{
/// <summary>The deployment id echoed back in the status response.</summary>
public required string DeploymentId { get; init; }
/// <summary>The deployer to answer exactly once.</summary>
public required IActorRef ReplyTo { get; init; }
/// <summary>Whether this is a redeploy (an update) rather than a new deployment.</summary>
public required bool IsRedeploy { get; init; }
/// <summary>Set when the config has committed to SQLite.</summary>
public bool Persisted { get; set; }
/// <summary>Set when the Instance Actor has finished PreStart successfully.</summary>
public bool Initialized { get; set; }
}
/// <summary>
/// Notify-and-fetch: piped back to self when the deployment's flattened
/// config has been fetched from central. Carries the original central sender so the
@@ -53,6 +53,25 @@ public class InstanceActor : ReceiveActor
private readonly Dictionary<string, IActorRef> _scriptActors = new();
private readonly Dictionary<string, IActorRef> _alarmActors = new();
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
/// <summary>
/// Per-attribute child routing (P2): monitoredAttributeName → the script/alarm actors
/// that react only to that attribute (value-change / conditional scripts;
/// HiLo/ValueMatch/RangeViolation/RateOfChange alarms). Built once in
/// <see cref="CreateChildActors"/> from each child's trigger config and consulted by
/// <see cref="PublishAndNotifyChildren"/> so an attribute change reaches only the
/// children interested in it instead of every child. The child's own trigger gate
/// remains authoritative — this routing is a fan-out optimization, not the correctness
/// check.
/// </summary>
private readonly Dictionary<string, List<IActorRef>> _childrenByMonitoredAttribute = new();
/// <summary>
/// Children that must hear EVERY attribute change (P2): Expression-triggered scripts and
/// alarms (they keep a full attribute snapshot), plus any child whose trigger config was
/// unparseable (fail-open). Complements <see cref="_childrenByMonitoredAttribute"/>.
/// </summary>
private readonly List<IActorRef> _allChangeChildren = new();
/// <summary>
/// Native alarm kind per source-binding canonical name (the same value handed
/// to each <see cref="NativeAlarmActor"/>'s <c>_nativeKind</c>). Used to stamp
@@ -96,6 +115,15 @@ public class InstanceActor : ReceiveActor
// fan out to all of them — not just the last one registered.
private readonly Dictionary<string, List<string>> _tagPathToAttributes = new();
// Tag-subscription retry state (S4/UA6): tag paths grouped by connection name
// (built once in SubscribeToDcl), the in-flight correlation-id → connection
// map, and a per-connection retry timer. Arming the timer on *send* closes both
// the failed-response and the lost-response gaps with one mechanism; the timer
// is cancelled when a Success reply arrives.
private readonly Dictionary<string, List<string>> _tagsByConnection = new();
private readonly Dictionary<string, string> _pendingTagSubscribes = new();
private readonly Dictionary<string, ICancelable> _tagSubscribeRetryTimers = new();
/// <summary>
/// Initializes the instance actor with its configuration and dependencies.
/// </summary>
@@ -204,7 +232,8 @@ public class InstanceActor : ReceiveActor
// Handle tag value updates from DCL — convert to AttributeValueChanged
Receive<TagValueUpdate>(HandleTagValueUpdate);
Receive<SubscribeTagsResponse>(_ => { }); // Ack from DCL subscribe — no action needed
Receive<SubscribeTagsResponse>(HandleSubscribeTagsResponse); // S4/UA6: real retry-aware handler
Receive<RetryTagSubscribe>(msg => SendTagSubscribe(msg.ConnectionName));
Receive<ConnectionQualityChanged>(HandleConnectionQualityChanged);
// Handle alarm state changes from Alarm Actors (Tell pattern)
@@ -252,11 +281,22 @@ public class InstanceActor : ReceiveActor
// Subscribe to DCL for data-sourced attributes
SubscribeToDcl();
// Init succeeded — tell the Deployment Manager so it can confirm the
// deployment (S6). If any step above throws, PreStart aborts before this
// line and the actor is stopped with ActorInitializationException instead,
// so the DM never sees a readiness signal for an instance that failed to init.
Context.Parent.Tell(new InstanceActorInitialized(_instanceUniqueName));
}
/// <inheritdoc />
protected override void PostStop()
{
// Cancel any armed tag-subscribe retry timers so they cannot fire after stop.
foreach (var timer in _tagSubscribeRetryTimers.Values)
timer.Cancel();
_tagSubscribeRetryTimers.Clear();
// Operational `instance_lifecycle` event — instance stopped. An
// instance stops on disable, delete, redeployment, and graceful shutdown;
// this single point covers them all.
@@ -963,8 +1003,9 @@ public class InstanceActor : ReceiveActor
{
if (_dclManager == null || _configuration == null) return;
// Group attributes by their bound connection name
var byConnection = new Dictionary<string, List<string>>();
// Group attributes by their bound connection name into the persistent
// _tagsByConnection field so a retry can re-send the exact same request.
_tagsByConnection.Clear();
foreach (var attr in _configuration.Attributes)
{
if (string.IsNullOrEmpty(attr.DataSourceReference) ||
@@ -980,10 +1021,10 @@ public class InstanceActor : ReceiveActor
}
attrs.Add(attr.CanonicalName);
if (!byConnection.TryGetValue(attr.BoundDataConnectionName, out var connTags))
if (!_tagsByConnection.TryGetValue(attr.BoundDataConnectionName, out var connTags))
{
connTags = new List<string>();
byConnection[attr.BoundDataConnectionName] = connTags;
_tagsByConnection[attr.BoundDataConnectionName] = connTags;
}
// Subscribe each distinct tag path once per connection — a tag shared
// by several attributes still needs only one DCL subscription.
@@ -991,19 +1032,69 @@ public class InstanceActor : ReceiveActor
connTags.Add(attr.DataSourceReference);
}
// Send subscription requests to DCL for each connection
foreach (var (connectionName, tagPaths) in byConnection)
// Send subscription requests to DCL for each connection (retry-armed).
foreach (var connectionName in _tagsByConnection.Keys)
SendTagSubscribe(connectionName);
}
/// <summary>
/// Sends (or re-sends) the tag-subscribe request for one connection and arms a
/// per-connection retry timer (S4/UA6). Arming on send means the retry fires
/// whether the response is a failure OR is lost entirely; a Success reply
/// cancels it. A superseded in-flight correlation id is overwritten so only the
/// latest attempt's reply is honoured.
/// </summary>
private void SendTagSubscribe(string connectionName)
{
if (_dclManager == null) return;
if (!_tagsByConnection.TryGetValue(connectionName, out var tagPaths) || tagPaths.Count == 0)
return;
var correlationId = Guid.NewGuid().ToString("N");
_pendingTagSubscribes[correlationId] = connectionName;
var request = new SubscribeTagsRequest(
correlationId, _instanceUniqueName, connectionName, tagPaths, DateTimeOffset.UtcNow);
_dclManager.Tell(request, Self);
_logger.LogInformation(
"Instance {Instance} subscribing to {Count} tags on connection {Connection} (corr={Corr})",
_instanceUniqueName, tagPaths.Count, connectionName, correlationId);
if (_tagSubscribeRetryTimers.Remove(connectionName, out var existing))
existing.Cancel();
_tagSubscribeRetryTimers[connectionName] = Context.System.Scheduler.ScheduleTellOnceCancelable(
TimeSpan.FromMilliseconds(_options.TagSubscribeRetryIntervalMs), Self,
new RetryTagSubscribe(connectionName), Self);
}
/// <summary>
/// Handles the DCL's tag-subscribe response: on Success cancels the connection's
/// retry timer; on failure logs + emits a site event and leaves the armed timer
/// to re-send. A reply for an unknown/superseded correlation id is ignored.
/// </summary>
private void HandleSubscribeTagsResponse(SubscribeTagsResponse response)
{
if (!_pendingTagSubscribes.Remove(response.CorrelationId, out var connectionName))
return; // stale/superseded reply — ignore
if (response.Success)
{
var request = new SubscribeTagsRequest(
Guid.NewGuid().ToString("N"),
_instanceUniqueName,
connectionName,
tagPaths,
DateTimeOffset.UtcNow);
_dclManager.Tell(request, Self);
_logger.LogInformation(
"Instance {Instance} subscribed to {Count} tags on connection {Connection}",
_instanceUniqueName, tagPaths.Count, connectionName);
if (_tagSubscribeRetryTimers.Remove(connectionName, out var timer))
timer.Cancel();
_logger.LogDebug(
"Instance {Instance} tag subscription confirmed on connection {Connection}",
_instanceUniqueName, connectionName);
}
else
{
_logger.LogWarning(
"Instance {Instance} tag subscribe FAILED on connection {Connection}: {Error} — will retry",
_instanceUniqueName, connectionName, response.ErrorMessage);
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
"instance_lifecycle", "Warning", _instanceUniqueName,
$"InstanceActor:{_instanceUniqueName}",
$"Tag subscribe failed on connection '{connectionName}': {response.ErrorMessage} — will retry");
// Leave the armed retry timer to re-send.
}
}
@@ -1181,16 +1272,20 @@ public class InstanceActor : ReceiveActor
// Publish to site-wide stream
_streamManager?.PublishAttributeValueChanged(changed);
// Notify Script Actors (for value-change and conditional triggers)
foreach (var scriptActor in _scriptActors.Values)
// Route the change to exactly the children interested in it (P2): the all-changes
// bucket (Expression scripts/alarms + fail-open children) plus the children monitoring
// this specific attribute. Children still gate on their own trigger config — routing
// is a fan-out optimization, not the correctness check.
foreach (var child in _allChangeChildren)
{
scriptActor.Tell(changed);
child.Tell(changed);
}
// Notify Alarm Actors (for alarm evaluation)
foreach (var alarmActor in _alarmActors.Values)
if (_childrenByMonitoredAttribute.TryGetValue(changed.AttributeName, out var interested))
{
alarmActor.Tell(changed);
foreach (var child in interested)
{
child.Tell(changed);
}
}
// WaitForAttribute (spec §4.2): re-evaluate any waiters on THIS attribute —
@@ -1336,7 +1431,9 @@ public class InstanceActor : ReceiveActor
/// Creates child Script Actors and Alarm Actors from the flattened configuration.
/// Script Actors spawned per script definition.
/// Alarm Actors spawned per alarm definition, as peers to Script Actors.
/// Compilation errors reject entire instance deployment (logged but actor still starts).
/// Compile failures are rejected at deploy time by the Deployment Manager's
/// validation gate; a failure reaching this point (startup load of a legacy
/// config) is logged and the offending script/alarm skipped.
///
/// Each child is seeded from a private point-in-time snapshot
/// of <c>_attributes</c>, NOT the live dictionary. The snapshot is taken here on
@@ -1345,6 +1442,33 @@ public class InstanceActor : ReceiveActor
/// would let a child constructor enumerate it on the child's mailbox thread while
/// this actor mutates it in <c>HandleAttributeValueChanged</c>.
/// </summary>
/// <summary>
/// Files a freshly-created child actor into the attribute-change routing maps (P2)
/// according to its trigger classification. Native alarm actors are NOT routed here —
/// they subscribe to their own native feed through the DCL and never react to computed
/// attribute changes.
/// </summary>
private void RouteChild(IActorRef child, TriggerRoutingDecision decision)
{
switch (decision.Kind)
{
case TriggerRoutingKind.MonitoredAttribute:
if (!_childrenByMonitoredAttribute.TryGetValue(decision.MonitoredAttribute!, out var list))
{
list = new List<IActorRef>();
_childrenByMonitoredAttribute[decision.MonitoredAttribute!] = list;
}
list.Add(child);
break;
case TriggerRoutingKind.AllChanges:
_allChangeChildren.Add(child);
break;
case TriggerRoutingKind.None:
// Interval / call-only triggers never react to attribute changes.
break;
}
}
private void CreateChildActors()
{
if (_configuration == null) return;
@@ -1389,6 +1513,7 @@ public class InstanceActor : ReceiveActor
var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
_scriptActors[script.CanonicalName] = actorRef;
RouteChild(actorRef, TriggerRouting.ForScript(script.TriggerType, script.TriggerConfiguration));
}
// Create Alarm Actors
@@ -1448,6 +1573,7 @@ public class InstanceActor : ReceiveActor
_alarmActors[alarm.CanonicalName] = actorRef;
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration));
}
// Create Native Alarm Actors — read-only mirror of each bound source's
@@ -1546,6 +1672,16 @@ public class InstanceActor : ReceiveActor
/// </summary>
public int AlarmActorCount => _alarmActors.Count;
/// <summary>
/// Read-only view of the per-attribute child routing map (P2, for testing/diagnostics).
/// </summary>
internal IReadOnlyDictionary<string, List<IActorRef>> ChildrenByMonitoredAttribute => _childrenByMonitoredAttribute;
/// <summary>
/// Read-only view of the all-changes child routing bucket (P2, for testing/diagnostics).
/// </summary>
internal IReadOnlyList<IActorRef> AllChangeChildren => _allChangeChildren;
/// <summary>
/// Internal message for async override loading result.
/// </summary>
@@ -1569,4 +1705,10 @@ public class InstanceActor : ReceiveActor
IActorRef Replyer,
ICancelable Timeout,
bool RequireGoodQuality);
/// <summary>
/// Self-scheduled tick (S4/UA6): re-send the tag-subscribe request for one
/// connection whose previous attempt failed or whose response was lost.
/// </summary>
private sealed record RetryTagSubscribe(string ConnectionName);
}
@@ -53,7 +53,21 @@ public class NativeAlarmActor : ReceiveActor
/// <summary>Buffer accumulating a snapshot replay until <see cref="AlarmTransitionKind.SnapshotComplete"/>.</summary>
private readonly Dictionary<string, NativeAlarmTransition> _snapshotBuffer = new();
/// <summary>
/// Pending mirror upserts coalesced by source reference (P4): under an alarm storm each
/// source reference keeps only its LATEST transition, flushed as one batched SQLite write on
/// a short single-shot timer rather than a write per transition. A crash loses at most one
/// flush interval of mirror writes; the (re)subscribe snapshot swap reconciles the mirror, so
/// the durability trade is safe. A <see cref="PersistDelete"/> removes the ref here so a
/// deleted condition is not resurrected by a still-pending upsert.
/// </summary>
private readonly Dictionary<string, NativeAlarmTransition> _dirtyUpserts = new();
private ICancelable? _retryTimer;
private ICancelable? _flushTimer;
/// <summary>How long transitions coalesce before a batched persist flush (P4).</summary>
private readonly TimeSpan _persistFlushInterval;
/// <summary>Initializes a new <see cref="NativeAlarmActor"/> for a single resolved source binding.</summary>
/// <param name="source">The resolved native alarm source (connection + source reference + filter).</param>
@@ -68,6 +82,8 @@ public class NativeAlarmActor : ReceiveActor
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
/// <see cref="ISiteEventLogger"/> for <c>alarm</c> operational events. Fire-and-forget;
/// a logging failure never affects the mirror.</param>
/// <param name="persistFlushInterval">How long native-alarm transitions coalesce before a
/// batched SQLite persist flush (P4). Defaults to 100 ms.</param>
public NativeAlarmActor(
ResolvedNativeAlarmSource source,
string instanceName,
@@ -77,7 +93,8 @@ public class NativeAlarmActor : ReceiveActor
SiteRuntimeOptions options,
ILogger logger,
AlarmKind nativeKind = AlarmKind.NativeOpcUa,
IServiceProvider? serviceProvider = null)
IServiceProvider? serviceProvider = null,
TimeSpan? persistFlushInterval = null)
{
_source = source;
_instanceName = instanceName;
@@ -88,12 +105,14 @@ public class NativeAlarmActor : ReceiveActor
_logger = logger;
_nativeKind = nativeKind;
_serviceProvider = serviceProvider;
_persistFlushInterval = persistFlushInterval ?? TimeSpan.FromMilliseconds(100);
Receive<RehydrationCompleted>(HandleRehydration);
Receive<NativeAlarmTransitionUpdate>(HandleTransition);
Receive<NativeAlarmSourceUnavailable>(HandleSourceUnavailable);
Receive<SubscribeAlarmsResponse>(HandleSubscribeResponse);
Receive<RetrySubscribe>(_ => SendSubscribe());
Receive<FlushPersistence>(_ => FlushDirtyUpserts());
}
/// <inheritdoc />
@@ -117,6 +136,11 @@ public class NativeAlarmActor : ReceiveActor
protected override void PostStop()
{
_retryTimer?.Cancel();
_flushTimer?.Cancel();
// Best-effort final flush of any coalesced upserts so a graceful stop does not lose the
// last ≤ flush-interval of mirror writes (P4). Fire-and-forget — a failed final write is
// reconciled by the next (re)subscribe snapshot swap.
FlushDirtyUpserts();
base.PostStop();
}
@@ -129,6 +153,15 @@ public class NativeAlarmActor : ReceiveActor
SourceReference: _source.SourceReference,
ConditionFilter: _source.ConditionFilter,
Timestamp: DateTimeOffset.UtcNow));
// Arm a response-timeout retry so a LOST subscribe response is re-sent, not
// only a failure reply (S4). Cancelled on a Success reply; a failure reply
// re-arms it at 1× (overriding this 2× response-timeout arm). 2× interval so
// a slow-but-arriving response is not pre-empted by a redundant re-send.
_retryTimer?.Cancel();
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs * 2),
Self, RetrySubscribe.Instance, Self);
}
private void HandleRehydration(RehydrationCompleted msg)
@@ -158,13 +191,26 @@ public class NativeAlarmActor : ReceiveActor
continue;
}
// Metadata (type/category/message) is not persisted — rehydrate the
// condition + key so the DebugView reflects last-known state until the
// first source snapshot replaces it.
// Restore the persisted display metadata (UA4) so the rehydrated condition renders
// fully (type/category/message/values) until the first source snapshot replaces it.
// Null/malformed metadata falls back to empty strings (pre-UA4 rows behave as before).
NativeAlarmMetadata meta = EmptyMetadata;
if (!string.IsNullOrEmpty(row.MetadataJson))
{
try
{
meta = JsonSerializer.Deserialize<NativeAlarmMetadata>(row.MetadataJson) ?? EmptyMetadata;
}
catch (JsonException)
{
meta = EmptyMetadata;
}
}
var t = new NativeAlarmTransition(
row.SourceReference, string.Empty, string.Empty, AlarmTransitionKind.Snapshot,
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
null, row.LastTransitionAt, string.Empty, string.Empty);
row.SourceReference, string.Empty, meta.AlarmTypeName, AlarmTransitionKind.Snapshot,
condition, meta.Category, string.Empty, meta.Message, string.Empty, string.Empty,
null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue);
_alarms[row.SourceReference] = t;
// Rehydration replays last-known state on (re)start — surface it
// upward for the DebugView but do NOT re-log it as a fresh operational
@@ -216,7 +262,7 @@ public class NativeAlarmActor : ReceiveActor
foreach (var (sourceRef, t) in _snapshotBuffer)
{
_alarms[sourceRef] = t;
PersistUpsert(t);
MarkDirtyUpsert(t);
// A snapshot replay is a re-sync of the source's current
// active set on (re)subscribe, NOT a live transition — surface it
// upward for the DebugView but do NOT re-log an `alarm` operational
@@ -240,7 +286,7 @@ public class NativeAlarmActor : ReceiveActor
}
_alarms[t.SourceReference] = t;
PersistUpsert(t);
MarkDirtyUpsert(t);
Emit(t, t.Condition);
// Retention: a resolved condition (inactive AND acknowledged) drops out of
@@ -270,6 +316,8 @@ public class NativeAlarmActor : ReceiveActor
{
if (resp.Success)
{
// Success — cancel the response-timeout retry armed in SendSubscribe.
_retryTimer?.Cancel();
_logger.LogInformation("Native alarm subscription established for {Source} on {Instance}",
_source.CanonicalName, _instanceName);
return;
@@ -417,18 +465,55 @@ public class NativeAlarmActor : ReceiveActor
"alarm", severity, _instanceName, $"NativeAlarmActor:{_source.CanonicalName}", message);
}
private void PersistUpsert(NativeAlarmTransition t)
/// <summary>
/// Records the latest transition per source reference and arms the coalesced flush timer (P4).
/// The actual SQLite write is deferred to <see cref="FlushDirtyUpserts"/> so an alarm storm
/// collapses into one batched write instead of a write per transition.
/// </summary>
private void MarkDirtyUpsert(NativeAlarmTransition t)
{
var json = JsonSerializer.Serialize(t.Condition);
_storage.UpsertNativeAlarmAsync(_instanceName, _source.CanonicalName, t.SourceReference, json, t.TransitionTime)
_dirtyUpserts[t.SourceReference] = t;
if (_flushTimer == null)
{
_flushTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
_persistFlushInterval, Self, FlushPersistence.Instance, Self);
}
}
/// <summary>
/// Persists all coalesced upserts in one batched transaction (P4), then disarms the flush
/// timer. Fire-and-forget with OnlyOnFaulted logging — a failed write never blocks the actor;
/// the mirror reconciles on the next (re)subscribe snapshot.
/// </summary>
private void FlushDirtyUpserts()
{
_flushTimer = null;
if (_dirtyUpserts.Count == 0)
return;
var batch = _dirtyUpserts.Values
.Select(t => (
t.SourceReference,
JsonSerializer.Serialize(t.Condition),
(string?)JsonSerializer.Serialize(new NativeAlarmMetadata(
t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue)),
t.TransitionTime))
.ToList();
_dirtyUpserts.Clear();
_storage.UpsertNativeAlarmsAsync(_instanceName, _source.CanonicalName, batch)
.ContinueWith(
task => _logger.LogWarning(task.Exception,
"Failed to persist native alarm {Ref} on {Instance}", t.SourceReference, _instanceName),
"Failed to persist {Count} native alarms on {Instance}", batch.Count, _instanceName),
TaskContinuationOptions.OnlyOnFaulted);
}
private void PersistDelete(string sourceReference)
{
// Drop any pending coalesced upsert for this ref so the delete is not resurrected by a
// still-buffered upsert on the next flush (P4).
_dirtyUpserts.Remove(sourceReference);
_storage.DeleteNativeAlarmAsync(_instanceName, _source.CanonicalName, sourceReference)
.ContinueWith(
task => _logger.LogWarning(task.Exception,
@@ -445,4 +530,23 @@ public class NativeAlarmActor : ReceiveActor
public static readonly RetrySubscribe Instance = new();
private RetrySubscribe() { }
}
/// <summary>Self-tell that fires the coalesced native-alarm persist flush (P4).</summary>
private sealed class FlushPersistence
{
public static readonly FlushPersistence Instance = new();
private FlushPersistence() { }
}
/// <summary>Empty metadata fallback for pre-UA4 rows or malformed <c>metadata_json</c>.</summary>
private static readonly NativeAlarmMetadata EmptyMetadata =
new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
/// <summary>
/// Persisted display metadata for a native alarm condition (UA4). Serialized into the
/// <c>metadata_json</c> column so a rehydrated condition renders fully (type/category/message/
/// current+limit values) before the first source snapshot re-supplies it.
/// </summary>
private sealed record NativeAlarmMetadata(
string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue);
}
@@ -59,6 +59,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
private readonly Script<object?>? _compiledTriggerExpression;
private bool _lastExpressionResult;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
// Coalescing guard for off-dispatcher expression evaluation (P1): at most one
// evaluation runs on the script scheduler at a time; a change arriving while
// one is in flight sets _evalPending so exactly one more runs afterwards
// against the latest snapshot. Both flags are touched only on the actor thread.
private bool _evalInFlight;
private bool _evalPending;
// WhileTrue trigger state: the most recent truth value of a Conditional
// trigger's comparison, used to detect false->true / true->false edges.
@@ -150,6 +156,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
// Handle execution completion (for logging/metrics)
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
// Handle the off-dispatcher trigger-expression evaluation result (P1).
Receive<ExpressionEvalResult>(HandleExpressionEvalResult);
}
/// <inheritdoc />
@@ -254,56 +263,74 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
else if (_triggerConfig is ExpressionTriggerConfig)
{
EvaluateExpressionTrigger();
StartExpressionEvaluation();
}
}
/// <summary>
/// Evaluates the compiled trigger expression against the current attribute
/// snapshot. In <see cref="TriggerMode.OnTrue"/> mode the script runs once
/// per false→true transition; in <see cref="TriggerMode.WhileTrue"/> mode it
/// fires on the edge and the re-fire timer is started/stopped with the
/// expression's truth value. A throwing or non-bool expression is treated as
/// false and logged as a script error; the actor never crashes.
/// Starts an off-dispatcher evaluation of the compiled trigger expression (P1).
/// The expression previously ran synchronously on the actor's dispatcher thread
/// via <c>RunAsync(...).GetAwaiter().GetResult()</c>, blocking the dispatcher on
/// every attribute change. It now runs on the dedicated script-execution
/// scheduler against a point-in-time snapshot; the boolean result is piped back
/// to this actor as an <see cref="ExpressionEvalResult"/> so all edge state is
/// applied on the actor thread. Bursts coalesce: at most one evaluation is in
/// flight, at most one pending, so a storm of changes collapses to the latest
/// snapshot without unbounded task fan-out.
/// </summary>
private void EvaluateExpressionTrigger()
private void StartExpressionEvaluation()
{
if (_compiledTriggerExpression == null) return;
if (_triggerConfig is not ExpressionTriggerConfig exprConfig) return;
if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return;
if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending
_evalInFlight = true;
bool result;
try
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
var expression = _compiledTriggerExpression;
var self = Self;
Task.Factory.StartNew(async () =>
{
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
// Bound evaluation with a short timeout. The CancellationToken
// covers cooperative/async cases; a pathological CPU-bound
// expression is not fully interruptible. Acceptable because
// trigger expressions are authored by trusted Design-role users
// and are compile-checked pre-deployment.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = _compiledTriggerExpression
.RunAsync(globals, cancellationToken: cts.Token)
.GetAwaiter().GetResult();
result = state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too,
// and is correctly treated as false.
LogExpressionError(ex);
result = false;
}
try
{
// Bound evaluation with a short timeout. The CancellationToken covers
// cooperative/async cases; a pathological CPU-bound expression is not
// fully interruptible — acceptable because trigger expressions are
// authored by trusted Design-role users and compile-checked pre-deploy.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = await expression.RunAsync(new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
return state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too and is
// treated as false. LogExpressionError touches only thread-safe
// members (_healthCollector Interlocked, _logger, DI-resolved
// singleton logger) so it is safe off the actor thread.
LogExpressionError(ex);
return false;
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
success: r => new ExpressionEvalResult(r));
}
if (exprConfig.Mode == TriggerMode.WhileTrue)
/// <summary>
/// Applies an off-dispatcher trigger-expression result on the actor thread:
/// runs the OnTrue / WhileTrue edge logic against <see cref="_lastExpressionResult"/>,
/// clears the in-flight flag, and drains a coalesced pending evaluation (against
/// the now-current snapshot) if one was requested while this one ran.
/// </summary>
private void HandleExpressionEvalResult(ExpressionEvalResult msg)
{
_evalInFlight = false;
if (_triggerConfig is ExpressionTriggerConfig exprConfig)
{
HandleWhileTrueTransition(result, _lastExpressionResult);
if (exprConfig.Mode == TriggerMode.WhileTrue)
HandleWhileTrueTransition(msg.Result, _lastExpressionResult);
else if (msg.Result && !_lastExpressionResult)
TrySpawnExecution(null);
_lastExpressionResult = msg.Result;
}
else if (result && !_lastExpressionResult)
{
TrySpawnExecution(null);
}
_lastExpressionResult = result;
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
}
/// <summary>
@@ -448,7 +475,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
_scriptName, _instanceName, msg.Success);
}
private static bool EvaluateCondition(ConditionalTriggerConfig config, object? value)
// internal (not private) so the culture-invariance of the non-numeric fallback
// can be unit-tested directly on the test thread — the live path evaluates on a
// dispatcher thread whose CurrentCulture the test cannot deterministically set.
internal static bool EvaluateCondition(ConditionalTriggerConfig config, object? value)
{
if (value == null) return false;
@@ -473,7 +503,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
catch
{
return string.Equals(value.ToString(), config.Threshold.ToString(), StringComparison.Ordinal);
// Render the threshold with InvariantCulture to match the invariant numeric
// parse two lines up — otherwise a de-DE host renders 1.5 as "1,5" and the
// string fallback fires spuriously on locale-formatted values (C6).
return string.Equals(value.ToString(), config.Threshold.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
}
}
@@ -536,23 +569,21 @@ public class ScriptActor : ReceiveActor, IWithTimers
private static ValueChangeTriggerConfig? ParseValueChangeTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
try
{
var doc = JsonDocument.Parse(json);
var attr = doc.RootElement.GetProperty("attributeName").GetString()!;
return new ValueChangeTriggerConfig(attr);
}
catch { return null; }
// Share the monitored-attribute parser with InstanceActor's routing map (P2) so both
// agree on which attribute this trigger reacts to.
return TriggerRouting.TryReadAttributeName(json, out var attr)
? new ValueChangeTriggerConfig(attr)
: null;
}
private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
// Attribute name comes from the shared parser (P2); operator/threshold/mode stay local.
if (!TriggerRouting.TryReadAttributeName(json, out var attr)) return null;
try
{
var doc = JsonDocument.Parse(json);
var attr = doc.RootElement.GetProperty("attributeName").GetString()!;
var op = doc.RootElement.GetProperty("operator").GetString()!;
var threshold = doc.RootElement.GetProperty("threshold").GetDouble();
return new ConditionalTriggerConfig(
@@ -576,6 +607,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error);
/// <summary>
/// Piped back to self from an off-dispatcher trigger-expression evaluation (P1);
/// carries the boolean truth value so the OnTrue/WhileTrue edge logic runs on
/// the actor thread.
/// </summary>
private sealed record ExpressionEvalResult(bool Result);
}
// ── Trigger config types ──
@@ -127,6 +127,32 @@ public class ScriptExecutionActor : ReceiveActor
// it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
using var cts = new CancellationTokenSource(timeout);
// Stuck-script watchdog (S2). The CTS firing only REQUESTS cooperative
// cancellation; it does NOT free a thread blocked in synchronous I/O.
// When the timeout elapses, wait a grace period on the thread pool and,
// if the body still hasn't returned, name the script loudly — this is
// the only signal an operator gets that one of the bounded
// script-execution threads is gone. `completed` is flipped in the
// finally below; Register fires on cancellation only (normal completion
// disposes the CTS with no callback). The Task.Run/Task.Delay run on the
// thread pool, not the (possibly saturated) script scheduler — deliberate.
var completed = 0;
var graceMs = options.StuckScriptGraceMs;
cts.Token.Register(() => _ = Task.Run(async () =>
{
await Task.Delay(graceMs);
if (Volatile.Read(ref completed) == 0)
{
var stuckMsg = $"Script '{scriptName}' on instance '{instanceName}' exceeded its " +
$"{timeout.TotalSeconds:F0}s timeout and is STILL EXECUTING — its dedicated " +
"script-execution thread is blocked (cooperative cancellation not observed).";
logger.LogError(stuckMsg);
_ = siteEventLogger?.LogEventAsync("script", "Error", instanceName,
$"ScriptActor:{scriptName}", stuckMsg);
}
}));
try
{
// Resolve integration services from DI (scoped lifetime)
@@ -295,6 +321,9 @@ public class ScriptExecutionActor : ReceiveActor
}
finally
{
// Mark the body finished so the stuck-script watchdog (registered on
// cts.Token above) treats a timely cancellation as NOT stuck.
Interlocked.Exchange(ref completed, 1);
// Dispose the DI scope (and scoped services) after script execution completes
serviceScope?.Dispose();
// Stop self after execution completes
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Cluster;
using Akka.Event;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
@@ -26,8 +27,18 @@ public class SiteReplicationActor : ReceiveActor
private readonly string _siteRole;
private readonly ILogger<SiteReplicationActor> _logger;
private readonly Cluster _cluster;
private readonly Func<bool> _isActive;
private readonly int _configFetchRetryCount;
private readonly TimeSpan _configFetchRetryDelay;
private Address? _peerAddress;
/// <summary>
/// Maximum rows an active node returns in a single anti-entropy resync snapshot.
/// A standby whose buffer exceeded this (Truncated snapshot) resyncs the oldest
/// 10 000 rows; further divergence drains naturally as the active node delivers.
/// </summary>
private const int MaxResyncRows = 10_000;
/// <summary>
/// Initializes a new <see cref="SiteReplicationActor"/> and registers Akka message handlers.
/// </summary>
@@ -42,13 +53,22 @@ public class SiteReplicationActor : ReceiveActor
/// replicates only the deployment id, and the standby fetches the config itself so a large
/// config never crosses the intra-site Akka hop. Null on nodes/tests without a fetcher.
/// </param>
/// <param name="isActiveOverride">
/// Test seam for the active-node check that gates the buffer-resync roles (a
/// standby requests a resync, the active node answers). Null (production) uses
/// the repo-standard leader+Up check via <see cref="Cluster"/> — swap point for
/// plan 01's shared active-node helper.
/// </param>
public SiteReplicationActor(
SiteStorageService storage,
StoreAndForwardStorage sfStorage,
ReplicationService replicationService,
string siteRole,
ILogger<SiteReplicationActor> logger,
IDeploymentConfigFetcher? configFetcher = null)
IDeploymentConfigFetcher? configFetcher = null,
Func<bool>? isActiveOverride = null,
SiteRuntimeOptions? options = null,
TimeSpan? configFetchRetryDelay = null)
{
_storage = storage;
_sfStorage = sfStorage;
@@ -57,6 +77,12 @@ public class SiteReplicationActor : ReceiveActor
_siteRole = siteRole;
_logger = logger;
_cluster = Cluster.Get(Context.System);
_isActive = isActiveOverride ?? DefaultIsActive;
// UA2: bound the standby's replicated-config fetch retries. At least one
// attempt always runs; the fixed inter-attempt delay is a test seam
// (production default 2 s).
_configFetchRetryCount = Math.Max(1, options?.ConfigFetchRetryCount ?? 1);
_configFetchRetryDelay = configFetchRetryDelay ?? TimeSpan.FromSeconds(2);
// Cluster member events
Receive<ClusterEvent.MemberUp>(HandleMemberUp);
@@ -79,6 +105,10 @@ public class SiteReplicationActor : ReceiveActor
Receive<ApplyConfigSetEnabled>(HandleApplyConfigSetEnabled);
Receive<ApplyArtifacts>(HandleApplyArtifacts);
Receive<ApplyStoreAndForward>(HandleApplyStoreAndForward);
// Anti-entropy — full S&F buffer resync on peer (re)join
Receive<RequestSfBufferResync>(HandleRequestSfBufferResync);
Receive<SfBufferSnapshot>(HandleSfBufferSnapshot);
}
/// <inheritdoc />
@@ -128,6 +158,57 @@ public class SiteReplicationActor : ReceiveActor
{
_peerAddress = member.Address;
_logger.LogInformation("Peer node tracked: {Address}", _peerAddress);
OnPeerTracked();
}
}
/// <summary>
/// Side-effect run whenever a peer is (re)tracked. A <b>standby</b> requests a
/// full S&amp;F buffer snapshot for anti-entropy resync — this closes the "a
/// standby down for an hour rejoins and diverges forever" gap: it may have missed
/// replicated Add/Remove/Park ops while it was gone. The active node never
/// requests. <see langword="protected virtual"/> so tests can drive it without a
/// real two-node cluster.
/// </summary>
protected virtual void OnPeerTracked()
{
if (!SafeIsActive())
{
SendToPeer(new RequestSfBufferResync());
}
}
/// <summary>
/// Repo-standard active-node check: this node is active when it is the current
/// cluster leader AND its own <see cref="MemberStatus"/> is
/// <see cref="MemberStatus.Up"/>. Mirrors <c>SiteCommunicationActor.DefaultIsActiveCheck</c>
/// (swap point for plan 01's shared helper). Any other state reports standby —
/// safe-by-default.
/// </summary>
private bool DefaultIsActive()
{
var self = _cluster.SelfMember;
if (self.Status != MemberStatus.Up)
return false;
var leader = _cluster.State.Leader;
return leader != null && leader == self.Address;
}
/// <summary>
/// Evaluates the active-node check, treating a throwing check as standby
/// (safe-by-default: a standby never delivers or answers resyncs).
/// </summary>
private bool SafeIsActive()
{
try
{
return _isActive();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Active-node check threw; treating node as standby");
return false;
}
}
@@ -140,7 +221,12 @@ public class SiteReplicationActor : ReceiveActor
{
if (_peerAddress == null)
{
_logger.LogDebug("No peer available, dropping replication message {Type}", message.GetType().Name);
// A dropped op is a lost delta — surface it at Warning + a metric so a
// never-tracked peer (a dead standby) is visible, not silent (arch
// review 02). In single-node dev the peer is legitimately absent; the
// per-op warning is rate-tolerable (accepted per review).
ScadaBridgeTelemetry.RecordReplicationFailure();
_logger.LogWarning("No peer available, dropping replication message {Type}", message.GetType().Name);
return;
}
@@ -177,43 +263,53 @@ public class SiteReplicationActor : ReceiveActor
// Notify-and-fetch: the peer sent only the id, so the standby fetches the config
// itself (off-thread; best-effort fire-and-forget, matching the no-ack replication
// model). The guarded write only overwrites a strictly-older local row. A single
// fetch attempt — reconciliation is the durable backstop for a lost fetch.
_configFetcher.FetchAsync(msg.CentralFetchBaseUrl, msg.DeploymentId, msg.FetchToken, CancellationToken.None)
.ContinueWith(async t =>
// model). The guarded write only overwrites a strictly-older local row. The fetch
// is retried up to ConfigFetchRetryCount times with a fixed delay (UA2) — a transient
// central hiccup no longer defers to the slower reconciliation backstop, which still
// covers a total failure after the last attempt.
_ = FetchWithRetryAsync();
return;
async Task FetchWithRetryAsync()
{
for (var attempt = 1; attempt <= _configFetchRetryCount; attempt++)
{
try
{
if (t.IsCompletedSuccessfully)
{
await _storage.StoreDeployedConfigIfNewerAsync(
msg.InstanceName, t.Result, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled);
return;
}
var ex = t.Exception?.GetBaseException();
if (ex is DeploymentConfigFetchException { IsSuperseded: true })
_logger.LogInformation(
"Skip replicated config for {Instance}: superseded/expired (a newer deploy will replicate)",
msg.InstanceName);
else if (t.IsCanceled)
_logger.LogWarning(
"Replicated config fetch cancelled for {Instance} (deployment {DeploymentId})",
msg.InstanceName, msg.DeploymentId);
else
_logger.LogError(ex,
"Replicated config fetch failed for {Instance} (deployment {DeploymentId})",
msg.InstanceName, msg.DeploymentId);
// Non-null: the outer method returns early when _configFetcher is null.
var json = await _configFetcher!.FetchAsync(
msg.CentralFetchBaseUrl, msg.DeploymentId, msg.FetchToken, CancellationToken.None);
await _storage.StoreDeployedConfigIfNewerAsync(
msg.InstanceName, json, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled);
return;
}
catch (Exception writeEx)
catch (DeploymentConfigFetchException fex) when (fex.IsSuperseded)
{
// Guarded-write failure is best-effort; observe + log so nothing faults silently.
_logger.LogError(writeEx,
"Failed to write replicated config for {Instance} (deployment {DeploymentId})",
msg.InstanceName, msg.DeploymentId);
// A superseded/expired fetch never heals by retrying — a newer deploy
// will replicate its own id.
_logger.LogInformation(
"Skip replicated config for {Instance}: superseded/expired (a newer deploy will replicate)",
msg.InstanceName);
return;
}
})
.Unwrap();
catch (Exception ex)
{
if (attempt < _configFetchRetryCount)
{
_logger.LogWarning(ex,
"Replicated config fetch attempt {Attempt}/{Max} failed for {Instance} (deployment {DeploymentId}) — retrying in {Delay}",
attempt, _configFetchRetryCount, msg.InstanceName, msg.DeploymentId, _configFetchRetryDelay);
await Task.Delay(_configFetchRetryDelay);
}
else
{
_logger.LogError(ex,
"Replicated config fetch failed after {Attempts} attempt(s) for {Instance} (deployment {DeploymentId}) — reconciliation will backstop",
_configFetchRetryCount, msg.InstanceName, msg.DeploymentId);
}
}
}
}
}
private void HandleApplyConfigRemove(ApplyConfigRemove msg)
@@ -289,4 +385,73 @@ public class SiteReplicationActor : ReceiveActor
_logger.LogError(t.Exception, "Failed to apply replicated S&F operation {Id}", msg.Operation.MessageId);
});
}
/// <summary>
/// Active-node side of the anti-entropy resync: answers a standby's
/// <see cref="RequestSfBufferResync"/> with a full-buffer <see cref="SfBufferSnapshot"/>
/// (up to <see cref="MaxResyncRows"/> oldest rows). A non-active node ignores the
/// request — only the authoritative node may answer.
/// </summary>
private void HandleRequestSfBufferResync(RequestSfBufferResync msg)
{
if (!SafeIsActive())
{
_logger.LogDebug("Ignoring S&F buffer resync request — this node is not active");
return;
}
var replyTo = Sender;
_sfStorage.GetAllMessagesAsync(MaxResyncRows).PipeTo(
replyTo,
Self,
success: result => new SfBufferSnapshot(result.Messages, result.Truncated));
}
/// <summary>
/// Standby-node side of the anti-entropy resync: replaces the local buffer
/// wholesale with the active node's snapshot. Combined with the upsert-based
/// replicated applies (arch review 02, Task 11), any replicated op that lands
/// after this resync merges cleanly onto the resynced state. An active node
/// ignores a snapshot — it is the source of truth, never a resync target.
/// </summary>
private void HandleSfBufferSnapshot(SfBufferSnapshot msg)
{
if (SafeIsActive())
{
_logger.LogDebug("Ignoring S&F buffer snapshot — this node is active");
return;
}
if (msg.Truncated)
{
_logger.LogWarning(
"S&F buffer resync snapshot truncated at {Cap} rows; divergence beyond the cap drains naturally",
MaxResyncRows);
}
_logger.LogInformation(
"Applying S&F buffer resync snapshot ({Count} rows), replacing local buffer", msg.Messages.Count);
_sfStorage.ReplaceAllAsync(msg.Messages)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply S&F buffer resync snapshot");
});
}
}
/// <summary>
/// Standby→active: request a full S&amp;F buffer snapshot for anti-entropy resync
/// (sent when a standby (re)tracks a peer). Crosses Akka remoting between the two
/// site nodes; the POCO rides the default serializer.
/// </summary>
public sealed record RequestSfBufferResync;
/// <summary>
/// Active→standby: full-buffer snapshot. <paramref name="Truncated"/> is true when
/// the active node's buffer exceeded <c>MaxResyncRows</c> (the standby logs a Warning —
/// divergence beyond the cap drains naturally as the active node delivers). Crosses
/// Akka remoting; the message list rides the default serializer.
/// </summary>
public sealed record SfBufferSnapshot(List<StoreAndForwardMessage> Messages, bool Truncated);
@@ -0,0 +1,117 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
/// <summary>
/// Pure, thread-safe deploy-time gate: given a flattened-configuration JSON, it
/// compiles every script, every alarm on-trigger script, and every Expression
/// trigger just as <see cref="Actors.InstanceActor.CreateChildActors"/> does at
/// runtime — but WITHOUT touching any actor state. It returns the collected
/// compile errors so the Deployment Manager can reject a deployment whose scripts
/// do not compile on the site (design spec: site-side compile failure rejects the
/// deployment; no partial state is applied).
///
/// Because it holds no actor state and only calls the (stateless)
/// <see cref="ScriptCompilationService"/>, it is safe to run off the singleton
/// mailbox on a <c>Task.Run</c> thread — the deploy path never blocks on Roslyn.
/// </summary>
public sealed class DeployCompileValidator
{
private readonly ScriptCompilationService _compilationService;
/// <summary>Initializes a new instance of the <see cref="DeployCompileValidator"/>.</summary>
/// <param name="compilationService">The same compilation service the Instance Actor uses at runtime.</param>
public DeployCompileValidator(ScriptCompilationService compilationService)
=> _compilationService = compilationService;
/// <summary>
/// Validates every compilable artifact in the flattened configuration.
/// Returns an empty list when the whole configuration compiles; otherwise
/// one human-readable error per failing artifact. Malformed JSON is itself a
/// single error (this also fronts the poison-config case for startup load).
/// </summary>
/// <param name="configJson">The flattened-configuration JSON to validate.</param>
/// <returns>A list of compile-error messages; empty if the configuration is clean.</returns>
public IReadOnlyList<string> Validate(string configJson)
{
FlattenedConfiguration? config;
try
{
config = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
}
catch (JsonException ex)
{
return [$"Configuration JSON failed to deserialize: {ex.Message}"];
}
if (config == null)
return ["Configuration JSON failed to deserialize: result was null."];
var errors = new List<string>();
// Scripts: full execution-path compile against ScriptGlobals.
foreach (var script in config.Scripts)
{
var result = _compilationService.Compile(script.CanonicalName, script.Code);
if (!result.IsSuccess)
errors.Add($"Script '{script.CanonicalName}': {string.Join("; ", result.Errors)}");
// Expression-triggered scripts also compile their trigger expression.
AddTriggerExpressionError(errors, script.TriggerType, script.TriggerConfiguration,
$"script-trigger-{script.CanonicalName}");
}
// Alarms: on-trigger script reference must resolve + compile, and any
// Expression trigger must compile — mirrors InstanceActor.CreateChildActors.
foreach (var alarm in config.Alarms)
{
if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
{
var triggerScriptDef = config.Scripts
.FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName);
if (triggerScriptDef == null)
{
errors.Add($"Alarm '{alarm.CanonicalName}': on-trigger script " +
$"'{alarm.OnTriggerScriptCanonicalName}' is not defined.");
}
else
{
var result = _compilationService.Compile(
$"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code);
if (!result.IsSuccess)
errors.Add($"Alarm '{alarm.CanonicalName}' on-trigger script " +
$"'{alarm.OnTriggerScriptCanonicalName}': {string.Join("; ", result.Errors)}");
}
}
AddTriggerExpressionError(errors, alarm.TriggerType, alarm.TriggerConfiguration,
$"alarm-trigger-expr-{alarm.CanonicalName}");
}
return errors;
}
/// <summary>
/// Compiles the boolean trigger expression for an Expression-triggered script
/// or alarm and records any failure. No-op for non-Expression triggers or a
/// blank/malformed expression config — mirroring
/// <c>InstanceActor.CompileTriggerExpression</c>, where those cases yield an
/// inert trigger rather than a deployment error.
/// </summary>
private void AddTriggerExpressionError(
List<string> errors, string? triggerType, string? triggerConfigJson, string compileName)
{
if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
return;
var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson);
if (expression == null)
return;
var result = _compilationService.CompileTriggerExpression(compileName, expression);
if (!result.IsSuccess)
errors.Add($"Trigger expression '{compileName}': {string.Join("; ", result.Errors)}");
}
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
/// <summary>
/// Readiness signal an <c>InstanceActor</c> sends to its parent
/// <c>DeploymentManagerActor</c> at the very end of a successful <c>PreStart</c> —
/// after its child Script/Alarm/NativeAlarm actors have been created and its DCL
/// subscriptions issued. If <c>PreStart</c> throws before this point (e.g. a child
/// actor cannot be created), the message is never sent and the actor is stopped
/// with an <c>ActorInitializationException</c> instead.
///
/// The Deployment Manager uses it to confirm a deployment only once the Instance
/// Actor has actually initialized — SQLite persistence completing is necessary but
/// NOT sufficient, because persistence can commit before the actor's asynchronous
/// init has run (or failed). Reporting deployment <c>Success</c> is therefore gated
/// on BOTH the persistence result AND this signal (S6); an actor that dies during
/// init never sends it, so its deployment is failed by the Terminated fallback.
/// </summary>
/// <param name="InstanceUniqueName">The unique name of the initialized instance.</param>
public sealed record InstanceActorInitialized(string InstanceUniqueName);
@@ -20,15 +20,32 @@ public class SiteStorageService
/// <param name="logger">Logger instance for diagnostic messages.</param>
public SiteStorageService(string connectionString, ILogger<SiteStorageService> logger)
{
_connectionString = connectionString;
// Normalize the connection string and pin a busy-timeout floor (S8). WAL lets a reader
// and a writer proceed concurrently, but two writers still contend; Microsoft.Data.Sqlite
// drives its busy handler off the command timeout, so a floor of 5 s means a briefly
// busy database is waited-on rather than failing fast with SQLITE_BUSY. Only raise a
// caller-supplied value that is lower than the floor (the library default of 30 s stays).
var builder = new SqliteConnectionStringBuilder(connectionString);
if (builder.DefaultTimeout < BusyTimeoutFloorSeconds)
builder.DefaultTimeout = BusyTimeoutFloorSeconds;
_connectionString = builder.ToString();
_logger = logger;
}
/// <summary>Busy-timeout floor in seconds applied to the site connection string (S8).</summary>
private const int BusyTimeoutFloorSeconds = 5;
/// <summary>
/// Creates a new (unopened) SQLite connection against the site database.
/// Exposed so site-local repositories can open their own connections without
/// reaching into private state via reflection. The caller owns
/// the connection and is responsible for opening and disposing it.
/// <para>
/// The database runs in WAL journal mode (set once in <see cref="InitializeAsync"/>) with a
/// busy-timeout floor (see the constructor), so connections handed out here inherit
/// concurrent-reader/writer behavior and wait out a briefly-busy database instead of
/// failing with SQLITE_BUSY.
/// </para>
/// </summary>
/// <returns>A new, unopened <see cref="SqliteConnection"/> against the site database.</returns>
public SqliteConnection CreateConnection() => new(_connectionString);
@@ -43,6 +60,21 @@ public class SiteStorageService
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
// Switch to WAL journal mode (S8). WAL is persistent (database-level, survives across
// connections) so this one-time set is enough. It lets readers and a writer proceed
// concurrently instead of every access serializing on the rollback journal. A
// :memory: database or a filesystem that cannot support WAL falls back to its prior
// mode — we log the result rather than throwing.
await using (var walCommand = connection.CreateCommand())
{
walCommand.CommandText = "PRAGMA journal_mode=WAL;";
var resultingMode = (await walCommand.ExecuteScalarAsync()) as string ?? "unknown";
if (!string.Equals(resultingMode, "wal", StringComparison.OrdinalIgnoreCase))
_logger.LogWarning(
"Site SQLite could not enable WAL journal mode (got '{Mode}') — concurrent access may serialize",
resultingMode);
}
await using var command = connection.CreateCommand();
command.CommandText = @"
CREATE TABLE IF NOT EXISTS deployed_configurations (
@@ -120,6 +152,7 @@ public class SiteStorageService
source_reference TEXT NOT NULL,
condition_json TEXT NOT NULL,
last_transition_at TEXT NOT NULL,
metadata_json TEXT,
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
);
";
@@ -137,6 +170,10 @@ public class SiteStorageService
// (added in primary/backup data connections feature)
await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT");
await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3");
// Native-alarm display metadata (UA4) — restored on rehydration so a persisted condition
// renders fully (type/category/message/values) before the first source snapshot arrives.
await TryAddColumnAsync(connection, "native_alarm_state", "metadata_json", "TEXT");
}
private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type)
@@ -451,7 +488,7 @@ public class SiteStorageService
/// <returns>A task that completes when the alarm condition has been inserted or updated.</returns>
public async Task UpsertNativeAlarmAsync(
string instanceName, string sourceCanonicalName, string sourceReference,
string conditionJson, DateTimeOffset lastTransitionAt)
string conditionJson, DateTimeOffset lastTransitionAt, string? metadataJson = null)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
@@ -459,21 +496,76 @@ public class SiteStorageService
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO native_alarm_state
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at)
VALUES (@name, @source, @ref, @json, @at)
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
VALUES (@name, @source, @ref, @json, @at, @meta)
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
condition_json = excluded.condition_json,
last_transition_at = excluded.last_transition_at";
last_transition_at = excluded.last_transition_at,
metadata_json = excluded.metadata_json";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@source", sourceCanonicalName);
command.Parameters.AddWithValue("@ref", sourceReference);
command.Parameters.AddWithValue("@json", conditionJson);
command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O"));
command.Parameters.AddWithValue("@meta", (object?)metadataJson ?? DBNull.Value);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Batch counterpart of <see cref="UpsertNativeAlarmAsync"/> (P4): upserts a set of mirrored
/// native alarm conditions for one instance/source in a SINGLE connection + transaction with
/// one prepared command re-bound per row. Used by the NativeAlarmActor's coalesced flush so an
/// alarm storm collapses into one batched write instead of a write per transition.
/// </summary>
/// <param name="instanceName">Unique name of the instance owning the alarms.</param>
/// <param name="sourceCanonicalName">Canonical name of the source binding.</param>
/// <param name="rows">The conditions to upsert (source reference, serialized condition, transition time).</param>
/// <returns>A task that completes when all rows have been committed.</returns>
public async Task UpsertNativeAlarmsAsync(
string instanceName, string sourceCanonicalName,
IReadOnlyList<(string SourceReference, string ConditionJson, string? MetadataJson, DateTimeOffset LastTransitionAt)> rows)
{
if (rows.Count == 0)
return;
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = @"
INSERT INTO native_alarm_state
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
VALUES (@name, @source, @ref, @json, @at, @meta)
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
condition_json = excluded.condition_json,
last_transition_at = excluded.last_transition_at,
metadata_json = excluded.metadata_json";
var pName = command.Parameters.Add("@name", SqliteType.Text);
var pSource = command.Parameters.Add("@source", SqliteType.Text);
var pRef = command.Parameters.Add("@ref", SqliteType.Text);
var pJson = command.Parameters.Add("@json", SqliteType.Text);
var pAt = command.Parameters.Add("@at", SqliteType.Text);
var pMeta = command.Parameters.Add("@meta", SqliteType.Text);
pName.Value = instanceName;
pSource.Value = sourceCanonicalName;
foreach (var row in rows)
{
pRef.Value = row.SourceReference;
pJson.Value = row.ConditionJson;
pAt.Value = row.LastTransitionAt.ToString("O");
pMeta.Value = (object?)row.MetadataJson ?? DBNull.Value;
await command.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
/// <summary>
/// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention).
/// </summary>
@@ -513,7 +605,7 @@ public class SiteStorageService
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT source_reference, condition_json, last_transition_at
SELECT source_reference, condition_json, last_transition_at, metadata_json
FROM native_alarm_state
WHERE instance_unique_name = @name AND source_canonical_name = @source";
command.Parameters.AddWithValue("@name", instanceName);
@@ -526,7 +618,8 @@ public class SiteStorageService
results.Add(new NativeAlarmRow(
reader.GetString(0),
reader.GetString(1),
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind)));
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind),
reader.IsDBNull(3) ? null : reader.GetString(3)));
}
return results;
@@ -938,5 +1031,8 @@ public class StoredDataConnectionDefinition
/// <summary>
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table.
/// <paramref name="MetadataJson"/> carries the serialized display metadata (type/category/message/
/// values) restored on rehydration (UA4); null on rows written before the column existed.
/// </summary>
public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt);
public record NativeAlarmRow(
string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null);
@@ -22,6 +22,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
private readonly List<Thread> _threads;
private int _disposed;
// Per-worker "busy since" timestamp (Environment.TickCount64 ms) while a task
// is executing on that worker, 0 when idle. Written by the worker thread,
// read (lock-free) by the observability gauges below. S2/UA5: makes a
// saturated or stuck script-execution pool visible on the site health report.
private readonly long[] _busySinceTicks;
private static volatile ScriptExecutionScheduler? _shared;
private static readonly object SharedLock = new();
@@ -52,10 +58,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
if (threadCount < 1)
threadCount = 1;
_busySinceTicks = new long[threadCount];
_threads = new List<Thread>(threadCount);
for (var i = 0; i < threadCount; i++)
{
var thread = new Thread(WorkerLoop)
var index = i; // capture per-worker slot index
var thread = new Thread(() => WorkerLoop(index))
{
IsBackground = true,
Name = $"script-execution-{i}"
@@ -68,13 +76,57 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
/// <inheritdoc />
public override int MaximumConcurrencyLevel => _threads.Count;
private void WorkerLoop()
/// <summary>Number of tasks waiting in the queue (not counting those currently executing).</summary>
public int QueueDepth => _queue.Count;
/// <summary>Number of worker threads currently executing a task.</summary>
public int BusyThreadCount
{
get
{
var count = 0;
for (var i = 0; i < _busySinceTicks.Length; i++)
if (Volatile.Read(ref _busySinceTicks[i]) != 0) count++;
return count;
}
}
/// <summary>
/// Age of the longest-running in-flight task across all workers, or null when
/// the pool is idle. A large value with a full <see cref="BusyThreadCount"/>
/// indicates a stuck/blocked script holding a dedicated thread.
/// </summary>
public TimeSpan? OldestBusyAge
{
get
{
var now = Environment.TickCount64;
long oldestSince = 0;
for (var i = 0; i < _busySinceTicks.Length; i++)
{
var since = Volatile.Read(ref _busySinceTicks[i]);
if (since != 0 && (oldestSince == 0 || since < oldestSince))
oldestSince = since;
}
return oldestSince == 0 ? null : TimeSpan.FromMilliseconds(Math.Max(0, now - oldestSince));
}
}
private void WorkerLoop(int index)
{
try
{
foreach (var task in _queue.GetConsumingEnumerable())
{
TryExecuteTask(task);
Volatile.Write(ref _busySinceTicks[index], Environment.TickCount64);
try
{
TryExecuteTask(task);
}
finally
{
Volatile.Write(ref _busySinceTicks[index], 0);
}
}
}
catch (ObjectDisposedException)
@@ -2199,6 +2199,16 @@ public class ScriptRuntimeContext
target: _listName,
payloadJson: payloadJson,
originInstanceName: _instanceName,
// 0 = the documented "no limit" escape hatch (StoreAndForward-015):
// notifications are retried until central acks and are never parked
// for retry exhaustion — a long central outage must not strand them
// behind per-message operator unparking. (Corrupt payloads still
// park — Task 14.)
maxRetries: 0,
// Never run the forwarder's 30s central Ask inline on the script
// thread: buffer due-immediately and kick the sweep. Send returns
// in milliseconds whether central is up or down.
deferToSweep: true,
messageId: notificationId);
_logger.LogDebug(
@@ -0,0 +1,83 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Site-side hosted service (S2/UA5) that periodically reads the process-wide
/// <see cref="ScriptExecutionScheduler"/> gauges (queue depth, busy thread count,
/// oldest in-flight script age) and pushes them into
/// <see cref="ISiteHealthCollector.SetScriptSchedulerStats"/> so the next
/// <see cref="ISiteHealthCollector.CollectReport"/> carries a fresh snapshot on
/// the site health report. Mirrors <c>SiteEventLogFailureCountReporter</c>:
/// immediate first probe, fixed cadence, exceptions logged and swallowed so the
/// loop survives every probe failure.
/// </summary>
public sealed class ScriptSchedulerStatsReporter : BackgroundService
{
/// <summary>Default poll cadence (10 s) — coarse enough to amortise across health reports.</summary>
internal static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(10);
private readonly ISiteHealthCollector _collector;
private readonly SiteRuntimeOptions _options;
private readonly ILogger<ScriptSchedulerStatsReporter> _logger;
private readonly TimeSpan _pollInterval;
/// <summary>Initializes a new instance of <see cref="ScriptSchedulerStatsReporter"/>.</summary>
/// <param name="collector">The site health collector that receives the scheduler gauges.</param>
/// <param name="options">Site runtime options supplying the shared scheduler's thread count.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="pollInterval">Poll interval override; defaults to <see cref="DefaultPollInterval"/> (10 s).</param>
public ScriptSchedulerStatsReporter(
ISiteHealthCollector collector,
SiteRuntimeOptions options,
ILogger<ScriptSchedulerStatsReporter> logger,
TimeSpan? pollInterval = null)
{
_collector = collector ?? throw new ArgumentNullException(nameof(collector));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_pollInterval = pollInterval ?? DefaultPollInterval;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Immediate first probe so the first health report after start carries a
// real snapshot instead of zeros.
Probe();
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_pollInterval, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
Probe();
}
}
private void Probe()
{
try
{
var scheduler = ScriptExecutionScheduler.Shared(_options);
_collector.SetScriptSchedulerStats(
scheduler.QueueDepth,
scheduler.BusyThreadCount,
scheduler.OldestBusyAge?.TotalSeconds);
}
catch (Exception ex)
{
// Catch-all is deliberate: the hosted service must survive every class
// of probe failure so the next tick gets a chance.
_logger.LogWarning(ex, "ScriptSchedulerStatsReporter probe failed; next tick will retry.");
}
}
}
@@ -0,0 +1,122 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>How an attribute change should be routed to a child (script/alarm) actor.</summary>
internal enum TriggerRoutingKind
{
/// <summary>Reacts only to changes of a single named attribute.</summary>
MonitoredAttribute,
/// <summary>Reacts to ANY attribute change (Expression triggers; or fail-open when the
/// trigger config is unparseable — the child's own gate then discards what it ignores).</summary>
AllChanges,
/// <summary>Never reacts to attribute changes (interval / call-only scripts).</summary>
None
}
/// <summary>
/// Result of classifying a child's trigger for attribute-change routing.
/// </summary>
internal readonly record struct TriggerRoutingDecision(TriggerRoutingKind Kind, string? MonitoredAttribute)
{
public static readonly TriggerRoutingDecision AllChanges = new(TriggerRoutingKind.AllChanges, null);
public static readonly TriggerRoutingDecision None = new(TriggerRoutingKind.None, null);
public static TriggerRoutingDecision Monitored(string attribute) => new(TriggerRoutingKind.MonitoredAttribute, attribute);
}
/// <summary>
/// Single source of truth for the monitored attribute a script/alarm trigger reacts to (P2).
/// Shared by <see cref="ScriptActor"/> (attribute-name extraction) and
/// <c>InstanceActor.CreateChildActors</c> (routing-map construction) so both agree on the
/// attribute for a given trigger config.
///
/// NOTE: routing has three outcomes (monitored / all-changes / none), so this returns a
/// <see cref="TriggerRoutingDecision"/> rather than the plan's original bool-out signature —
/// a bool cannot distinguish "hears everything" from "hears nothing".
/// </summary>
internal static class TriggerRouting
{
/// <summary>
/// Reads the monitored attribute name from a trigger config, honoring both the
/// <c>"attributeName"</c> (scripts + alarms) and <c>"attribute"</c> (alarm alias) keys.
/// Returns false when the JSON is missing, malformed, or carries no attribute name.
/// </summary>
public static bool TryReadAttributeName(string? triggerConfigJson, out string attribute)
{
attribute = "";
if (string.IsNullOrEmpty(triggerConfigJson)) return false;
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
var root = doc.RootElement;
if (root.TryGetProperty("attributeName", out var a) && a.GetString() is { Length: > 0 } n)
{
attribute = n;
return true;
}
if (root.TryGetProperty("attribute", out var a2) && a2.GetString() is { Length: > 0 } n2)
{
attribute = n2;
return true;
}
return false;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// Classifies a script trigger. valuechange/conditional → the named attribute (fail open to
/// all-changes if unparseable); expression → all-changes; interval/call → none; anything
/// unknown → all-changes (fail open — a misconfigured script still reacts, its own gate
/// remains the correctness check).
/// </summary>
public static TriggerRoutingDecision ForScript(string? triggerType, string? triggerConfigJson)
{
switch (triggerType?.ToLowerInvariant())
{
case "valuechange":
case "conditional":
return TryReadAttributeName(triggerConfigJson, out var attr)
? TriggerRoutingDecision.Monitored(attr)
: TriggerRoutingDecision.AllChanges;
case "expression":
return TriggerRoutingDecision.AllChanges;
case "interval":
case "call":
return TriggerRoutingDecision.None;
default:
return TriggerRoutingDecision.AllChanges;
}
}
/// <summary>
/// Classifies an alarm trigger. Expression → all-changes; HiLo/ValueMatch/RangeViolation/
/// RateOfChange → the named attribute (fail open to all-changes if unparseable); an
/// unknown/unparseable trigger type → all-changes (fail open).
/// </summary>
public static TriggerRoutingDecision ForAlarm(string? triggerType, string? triggerConfigJson)
{
if (Enum.TryParse<AlarmTriggerType>(triggerType, ignoreCase: true, out var tt))
{
switch (tt)
{
case AlarmTriggerType.Expression:
return TriggerRoutingDecision.AllChanges;
case AlarmTriggerType.HiLo:
case AlarmTriggerType.ValueMatch:
case AlarmTriggerType.RangeViolation:
case AlarmTriggerType.RateOfChange:
return TryReadAttributeName(triggerConfigJson, out var attr)
? TriggerRoutingDecision.Monitored(attr)
: TriggerRoutingDecision.AllChanges;
}
}
return TriggerRoutingDecision.AllChanges;
}
}
@@ -70,6 +70,15 @@ public static class ServiceCollectionExtensions
c.Timeout = TimeSpan.FromSeconds(
sp.GetRequiredService<IOptions<SiteRuntimeOptions>>().Value.ConfigFetchTimeoutSeconds));
// S2/UA5: periodically lift the shared script-execution scheduler gauges
// (queue depth, busy threads, oldest-busy age) onto the site health report.
// Registered via factory-lambda so ISiteHealthCollector (registered by the
// Host's AddSiteHealthMonitoring) is resolved lazily at host start.
services.AddHostedService(sp => new ScriptSchedulerStatsReporter(
sp.GetRequiredService<HealthMonitoring.ISiteHealthCollector>(),
sp.GetRequiredService<IOptions<SiteRuntimeOptions>>().Value,
sp.GetRequiredService<ILogger<ScriptSchedulerStatsReporter>>()));
return services;
}
@@ -61,8 +61,27 @@ public class SiteRuntimeOptions
public int ConfigFetchTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Bounded retry count for the standby's best-effort replicated-config fetch.
/// Reserved — consumed by the standby replication fetch in a later task; not yet wired.
/// Bounded attempt count (including the first) for the standby's replicated-config
/// fetch; a 2 s fixed delay separates attempts and superseded fetches never retry.
/// Consumed by <c>SiteReplicationActor.HandleApplyConfigDeploy</c> (UA2). Default: 3.
/// </summary>
public int ConfigFetchRetryCount { get; set; } = 3;
/// <summary>
/// Fixed interval (ms) at which an Instance Actor re-sends a tag-subscribe
/// request that either failed or whose response was lost (S4/UA6). The retry is
/// armed on every send and cancelled on the first Success reply, so it closes
/// both the failed-response and the lost-response gaps. Default: 5000ms.
/// </summary>
public int TagSubscribeRetryIntervalMs { get; set; } = 5000;
/// <summary>
/// Grace period (ms) after a script's execution timeout elapses before the
/// stuck-script watchdog declares the script's dedicated thread blocked and
/// emits a loud site event naming it (S2). The CTS timeout only requests
/// cooperative cancellation; a script blocked in synchronous I/O never
/// observes it, so this watchdog is the only operator signal that one of the
/// bounded script-execution threads is gone. Default: 30000ms.
/// </summary>
public int StuckScriptGraceMs { get; set; } = 30000;
}
@@ -20,6 +20,10 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward;
/// <item><description>ack not <c>Accepted</c>, or the Ask times out / fails →
/// <see cref="DeliverAsync"/> throws; the S&amp;F engine treats any thrown
/// exception as transient and retries the forward at the fixed interval.</description></item>
/// <item><description>the buffered payload is corrupt (undeserialisable / null) →
/// <see cref="DeliverAsync"/> returns <c>false</c> (the handler contract's
/// permanent-failure signal); the engine parks the row so the payload is preserved
/// for operator forensics. Supersedes StoreAndForward-018's silent discard.</description></item>
/// </list>
///
/// The forward travels over the ClusterClient command/control transport: the handler
@@ -51,8 +55,8 @@ public sealed class NotificationForwarder
/// </param>
/// <param name="logger">
/// Optional logger. A corrupt buffered payload is logged at
/// Warning before being discarded so an operator has a forensic trail of the row
/// that vanished from the buffer.
/// Warning before the row is parked so an operator has a forensic trail pointing at
/// the preserved parked row.
/// </param>
public NotificationForwarder(
IActorRef siteCommunicationActor,
@@ -69,21 +73,26 @@ public sealed class NotificationForwarder
/// <summary>
/// Store-and-Forward delivery handler entry point — matches the
/// <c>Func&lt;StoreAndForwardMessage, Task&lt;bool&gt;&gt;</c> handler contract.
/// Returns <c>true</c> when central accepts the notification; throws on a
/// non-accepted ack or an Ask timeout/failure so the engine retries.
/// Returns <c>true</c> when central accepts the notification; returns <c>false</c>
/// when the buffered payload is corrupt (permanent failure → engine parks the row);
/// throws on a non-accepted ack or an Ask timeout/failure so the engine retries.
/// </summary>
/// <param name="message">The buffered store-and-forward message to deliver to central.</param>
/// <returns>A task that resolves to <c>true</c> when central accepts (or the payload is corrupt and discarded); throws on a transient forward failure so the engine retries.</returns>
/// <returns>A task that resolves to <c>true</c> when central accepts; <c>false</c> when the payload is corrupt (the engine parks the row for forensics); throws on a transient forward failure so the engine retries.</returns>
public async Task<bool> DeliverAsync(StoreAndForwardMessage message)
{
if (!TryBuildSubmit(message, out var submit))
{
_logger.LogWarning(
"Discarding corrupt buffered notification {NotificationId} (payload is not deserialisable as NotificationSubmit). " +
"Payload preview: {PayloadPreview}",
"Parking corrupt buffered notification {NotificationId} (payload is not deserialisable as NotificationSubmit); " +
"the row is preserved for operator forensics. Payload preview: {PayloadPreview}",
message.Id,
PreviewPayload(message.PayloadJson));
return true;
// false = permanent failure by the delivery-handler contract → the
// engine parks the row (payload preserved, operator can inspect or
// discard). Supersedes StoreAndForward-018's silent discard, which
// reported the notification as delivered while losing it entirely.
return false;
}
// The reply may legitimately be a non-accepted ack, so it is not requested as
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward;
@@ -100,6 +101,13 @@ public class ReplicationService
/// <summary>
/// Applies a replicated operation received from the active node.
/// Used by the standby node to keep its SQLite in sync.
///
/// Add/Park/Requeue are applied as <b>upserts</b> (<see cref="StoreAndForwardStorage.UpsertMessageAsync"/>),
/// not blind INSERT/UPDATE: the full message rides in every one of those operations,
/// so a Park/Requeue whose original Add was lost (fire-and-forget replication is
/// best-effort) self-heals by materialising the row, and a duplicate Add (e.g.
/// re-issued by an anti-entropy resync) applies newest-wins instead of throwing a
/// primary-key violation. Remove is a plain delete.
/// </summary>
/// <param name="operation">The replication operation to apply.</param>
/// <param name="storage">The standby node's store-and-forward storage to update.</param>
@@ -111,7 +119,7 @@ public class ReplicationService
switch (operation.OperationType)
{
case ReplicationOperationType.Add when operation.Message != null:
await storage.EnqueueAsync(operation.Message);
await storage.UpsertMessageAsync(operation.Message);
break;
case ReplicationOperationType.Remove:
@@ -120,32 +128,45 @@ public class ReplicationService
case ReplicationOperationType.Park when operation.Message != null:
operation.Message.Status = StoreAndForwardMessageStatus.Parked;
await storage.UpdateMessageAsync(operation.Message);
await storage.UpsertMessageAsync(operation.Message);
break;
case ReplicationOperationType.Requeue when operation.Message != null:
operation.Message.Status = StoreAndForwardMessageStatus.Pending;
operation.Message.RetryCount = 0;
await storage.UpdateMessageAsync(operation.Message);
await storage.UpsertMessageAsync(operation.Message);
break;
}
}
private void FireAndForget(ReplicationOperation operation)
{
Task.Run(async () =>
// Invoked inline, NOT via Task.Run: the handler is a non-blocking Akka
// Tell, and thread-pool hand-off destroyed Add/Remove ordering for the
// same message id (arch review 02). Inline invocation preserves issue
// order; Akka's per-sender/receiver guarantee preserves it on the wire.
try
{
try
var task = _replicationHandler!.Invoke(operation);
if (!task.IsCompletedSuccessfully)
{
await _replicationHandler!.Invoke(operation);
task.ContinueWith(t =>
{
ScadaBridgeTelemetry.RecordReplicationFailure();
_logger.LogWarning(t.Exception,
"Replication of {OpType} for message {MessageId} failed (best-effort); standby buffer may be diverging",
operation.OperationType, operation.MessageId);
},
TaskContinuationOptions.OnlyOnFaulted);
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Replication of {OpType} for message {MessageId} failed (best-effort)",
operation.OperationType, operation.MessageId);
}
});
}
catch (Exception ex)
{
ScadaBridgeTelemetry.RecordReplicationFailure();
_logger.LogWarning(ex,
"Replication of {OpType} for message {MessageId} failed (best-effort); standby buffer may be diverging",
operation.OperationType, operation.MessageId);
}
}
}
@@ -22,4 +22,17 @@ public class StoreAndForwardOptions
/// <summary>Interval for the background retry timer sweep.</summary>
public TimeSpan RetryTimerInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Maximum due rows loaded per retry sweep. Bounds sweep memory and duration
/// under backlog; the remainder is picked up on the next tick (10 s).
/// 0 = unlimited (legacy).
/// </summary>
public int SweepBatchLimit { get; set; } = 500;
/// <summary>
/// Max <c>(category, target)</c> lanes delivered concurrently per sweep.
/// 1 = legacy serial. Within a lane delivery stays sequential (per-target FIFO).
/// </summary>
public int SweepTargetParallelism { get; set; } = 4;
}
@@ -1,3 +1,4 @@
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
@@ -85,6 +86,21 @@ public class StoreAndForwardService
private Timer? _retryTimer;
private int _retryInProgress;
/// <summary>
/// Active-node delivery gate. When set, the retry sweep runs ONLY when the
/// gate reports true — the standby node applies replicated buffer operations
/// but must never deliver (Component-StoreAndForward.md "standby-passive"
/// model). Re-evaluated on every sweep tick so a failover resumes delivery
/// within one RetryTimerInterval without re-wiring. Null (tests, central
/// hosts) preserves the ungated legacy behaviour. A throwing gate is treated
/// as standby — safe-by-default, mirroring SiteCommunicationActor's
/// DefaultIsActiveCheck fallback.
/// </summary>
private Func<bool>? _deliveryGate;
/// <summary>Installs the active-node delivery gate (see <see cref="_deliveryGate"/>).</summary>
public void SetDeliveryGate(Func<bool> gate) => _deliveryGate = gate;
/// <summary>
/// The in-flight retry sweep <see cref="Task"/>, or
/// <c>null</c> when no sweep is currently running. Captured when the timer
@@ -97,6 +113,26 @@ public class StoreAndForwardService
/// </summary>
private Task? _sweepTask;
/// <summary>
/// Ordered, single-reader queue of cached-call audit-observer notifications.
/// The retry sweep <b>posts</b> to this channel instead of awaiting the
/// observer inline, so a slow observer (a SQLite audit write) cannot stretch
/// the sweep; the single reader (<see cref="_observerPump"/>) preserves
/// per-operation event order. Re-created in <see cref="StartAsync"/> because
/// <see cref="StopAsync"/> completes it (a restarted instance needs a fresh
/// channel). Before <see cref="StartAsync"/> starts the pump, posts fall back
/// to inline processing (see <see cref="PostObserverNotification"/>).
/// </summary>
private Channel<Func<Task>> _observerQueue =
Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>
/// The single-reader pump draining <see cref="_observerQueue"/>, or
/// <c>null</c> when the service is not started. Started in
/// <see cref="StartAsync"/>, completed + awaited in <see cref="StopAsync"/>.
/// </summary>
private Task? _observerPump;
/// <summary>
/// How long <see cref="StopAsync"/> waits for an
/// in-flight retry sweep to finish before returning. The default — 10 s —
@@ -334,6 +370,28 @@ public class StoreAndForwardService
ScadaBridgeTelemetry.SetQueueDepthProvider(provider);
}
// (Re)create the observer queue + start its single-reader pump. A prior
// StopAsync completes the channel, so a restarted instance needs a fresh
// one. The pump is best-effort: an observer that throws is logged and
// swallowed so a failing audit pipeline never corrupts retry bookkeeping.
_observerQueue = Channel.CreateUnbounded<Func<Task>>(
new UnboundedChannelOptions { SingleReader = true });
_observerPump = Task.Run(async () =>
{
await foreach (var work in _observerQueue.Reader.ReadAllAsync())
{
try
{
await work();
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Cached-call audit observer threw; ignored (best-effort per alog.md §7)");
}
}
});
_retryTimer = new Timer(
_ => Volatile.Write(ref _sweepTask, RetryPendingMessagesAsync()),
null,
@@ -389,6 +447,25 @@ public class StoreAndForwardService
}
}
// Complete the observer queue and wait (bounded) for the pump to drain any
// posted-but-not-yet-processed notifications, so shutdown does not silently
// drop audit events that the sweep already handed off.
_observerQueue.Writer.TryComplete();
var pump = _observerPump;
if (pump is not null)
{
try
{
await pump.WaitAsync(SweepShutdownWaitTimeout).ConfigureAwait(false);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Audit-observer pump did not drain within {Timeout}", SweepShutdownWaitTimeout);
}
_observerPump = null;
}
var provider = _queueDepthProvider;
if (provider is not null)
{
@@ -436,6 +513,14 @@ public class StoreAndForwardService
/// When <c>false</c>, the caller has already made its own delivery attempt and the
/// message is buffered directly for the retry sweep (the handler is not invoked here).
/// </param>
/// <param name="deferToSweep">
/// When <c>true</c>, the handler is never invoked inline — the message is buffered
/// due-immediately (<see cref="StoreAndForwardMessage.LastAttemptAt"/> stays null)
/// and a background sweep is kicked. Use for callers on latency-sensitive threads
/// (e.g. script dispatchers) whose delivery involves a long Ask timeout. Unlike
/// <paramref name="attemptImmediateDelivery"/><c>: false</c>, no delivery attempt is
/// presumed to have already been made, so the row is due on the very next sweep.
/// </param>
/// <param name="messageId">
/// An explicit, caller-supplied message id. <c>null</c> (the default) makes the
/// service mint a fresh GUID. The Notification Outbox enqueue path supplies its own
@@ -472,6 +557,7 @@ public class StoreAndForwardService
int? maxRetries = null,
TimeSpan? retryInterval = null,
bool attemptImmediateDelivery = true,
bool deferToSweep = false,
string? messageId = null,
Guid? executionId = null,
string? sourceScript = null,
@@ -494,6 +580,18 @@ public class StoreAndForwardService
ParentExecutionId = parentExecutionId
};
// Latency-sensitive caller: never run the handler inline. Buffer
// due-immediately (LastAttemptAt intentionally left null so the row is due
// on the very next sweep, not held back by RetryInterval) and kick a
// background sweep so the healthy-path latency is milliseconds.
if (deferToSweep)
{
await BufferAsync(message);
RaiseActivity("Queued", category, $"Deferred to sweep: {target}");
TriggerSweep();
return new StoreAndForwardResult(true, message.Id, true);
}
// Attempt immediate delivery — unless the caller has already made a
// delivery attempt of its own (attemptImmediateDelivery: false). In that
// case re-invoking the handler here would dispatch the request twice.
@@ -556,6 +654,18 @@ public class StoreAndForwardService
Interlocked.Increment(ref _bufferedCount);
}
/// <summary>
/// Kicks a background retry sweep now (fire-and-forget). No-op before
/// <see cref="StartAsync"/> (storage may be uninitialized and the timer, whose
/// presence gates this, is not yet set). Overlap-safe: the sweep's
/// <see cref="_retryInProgress"/> CAS makes a concurrent kick a cheap no-op.
/// </summary>
public void TriggerSweep()
{
if (_retryTimer == null) return;
Volatile.Write(ref _sweepTask, RetryPendingMessagesAsync());
}
/// <summary>
/// Background retry sweep. Processes all pending messages that are due for retry.
/// </summary>
@@ -568,15 +678,59 @@ public class StoreAndForwardService
try
{
var messages = await _storage.GetMessagesForRetryAsync();
var gate = _deliveryGate;
if (gate != null)
{
bool isActive;
try { isActive = gate(); }
catch (Exception ex)
{
_logger.LogWarning(ex,
"S&F delivery gate threw; treating this node as standby for this sweep");
isActive = false;
}
if (!isActive)
{
_logger.LogDebug("S&F retry sweep skipped: this node is not the active site node");
return;
}
}
var messages = await _storage.GetMessagesForRetryAsync(_options.SweepBatchLimit);
if (messages.Count == 0) return;
_logger.LogDebug("Retry sweep: {Count} messages due for retry", messages.Count);
foreach (var message in messages)
// Group due rows into per-(category,target) lanes. GroupBy is stable, so
// each lane preserves created_at-ASC (oldest-first) order. Lanes run
// concurrently up to SweepTargetParallelism, so one slow/dead target no
// longer blocks delivery to healthy ones (arch review 02, Performance #2);
// within a lane delivery stays strictly sequential (per-target FIFO).
var lanes = messages
.GroupBy(m => (m.Category, m.Target))
.Select(g => g.ToList())
.ToList();
using var laneCap = new SemaphoreSlim(Math.Max(1, _options.SweepTargetParallelism));
var laneTasks = lanes.Select(async lane =>
{
await RetryMessageAsync(message);
}
await laneCap.WaitAsync();
try
{
foreach (var message in lane)
{
// Task 8 short-circuit, per lane: one transient failure means
// the target is down — skip the rest of this lane this sweep
// rather than burning a full timeout per remaining message.
// Skipped rows keep their RetryCount/LastAttemptAt untouched.
var outcome = await RetryMessageAsync(message);
if (outcome == RetryOutcome.TransientFailure)
return;
}
}
finally { laneCap.Release(); }
}).ToList();
await Task.WhenAll(laneTasks);
}
catch (Exception ex)
{
@@ -588,12 +742,20 @@ public class StoreAndForwardService
}
}
private async Task RetryMessageAsync(StoreAndForwardMessage message)
/// <summary>
/// Outcome of a single message's retry attempt, used by the sweep to
/// short-circuit a <c>(category, target)</c> lane after its first transient
/// failure. <see cref="Skipped"/> = no attempt was made (no handler, or the
/// row's status changed under a concurrent apply); its RetryCount is untouched.
/// </summary>
internal enum RetryOutcome { Delivered, Parked, TransientFailure, Skipped }
private async Task<RetryOutcome> RetryMessageAsync(StoreAndForwardMessage message)
{
if (!_deliveryHandlers.TryGetValue(message.Category, out var handler))
{
_logger.LogWarning("No delivery handler for category {Category}", message.Category);
return;
return RetryOutcome.Skipped;
}
// Measure per-attempt
@@ -617,14 +779,14 @@ public class StoreAndForwardService
// Terminal Delivered observer notification — the audit
// bridge maps this to Attempted + CachedResolve(Delivered).
await NotifyCachedCallObserverAsync(
await PostObserverNotification(
message,
CachedCallAttemptOutcome.Delivered,
lastError: null,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
return;
return RetryOutcome.Delivered;
}
// Permanent failure on retry — park immediately.
@@ -638,7 +800,7 @@ public class StoreAndForwardService
_logger.LogDebug(
"Message {MessageId} changed status during delivery; sweep park skipped",
message.Id);
return;
return RetryOutcome.Skipped;
}
Interlocked.Decrement(ref _bufferedCount);
_replication?.ReplicatePark(message);
@@ -647,13 +809,14 @@ public class StoreAndForwardService
// Terminal PermanentFailure observer notification — the
// audit bridge maps this to Attempted(Failed) + CachedResolve(Parked).
await NotifyCachedCallObserverAsync(
await PostObserverNotification(
message,
CachedCallAttemptOutcome.PermanentFailure,
lastError: message.LastError,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
return RetryOutcome.Parked;
}
catch (Exception ex)
{
@@ -673,7 +836,7 @@ public class StoreAndForwardService
_logger.LogDebug(
"Message {MessageId} changed status during delivery; sweep park skipped",
message.Id);
return;
return RetryOutcome.Skipped;
}
Interlocked.Decrement(ref _bufferedCount);
_replication?.ReplicatePark(message);
@@ -685,13 +848,14 @@ public class StoreAndForwardService
// Terminal ParkedMaxRetries observer notification — the
// audit bridge maps this to Attempted(Failed) + CachedResolve(Parked).
await NotifyCachedCallObserverAsync(
await PostObserverNotification(
message,
CachedCallAttemptOutcome.ParkedMaxRetries,
lastError: ex.Message,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
return RetryOutcome.Parked;
}
else
{
@@ -701,28 +865,67 @@ public class StoreAndForwardService
_logger.LogDebug(
"Message {MessageId} changed status during delivery; sweep retry-count update skipped",
message.Id);
return;
return RetryOutcome.Skipped;
}
RaiseActivity("Retried", message.Category,
$"Retry {message.RetryCount}/{message.MaxRetries} for {message.Target}: {ex.Message}");
// Per-attempt TransientFailure observer notification —
// the audit bridge maps this to Attempted(Failed).
await NotifyCachedCallObserverAsync(
await PostObserverNotification(
message,
CachedCallAttemptOutcome.TransientFailure,
lastError: ex.Message,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
return RetryOutcome.TransientFailure;
}
}
}
/// <summary>
/// Hands a cached-call observer notification to the ordered single-reader pump
/// (see <see cref="_observerQueue"/>) and returns immediately, so a slow
/// observer cannot stretch the sweep. When the service is not started (no pump —
/// e.g. a unit test driving <see cref="RetryPendingMessagesAsync"/> directly),
/// the notification is processed inline so it is still delivered synchronously.
/// The buffered <paramref name="message"/> is not mutated again after this call
/// within a sweep (the next tick loads fresh row instances), so the deferred
/// read of its fields by the pump is safe.
/// </summary>
private ValueTask PostObserverNotification(
StoreAndForwardMessage message,
CachedCallAttemptOutcome outcome,
string? lastError,
int? httpStatus,
DateTime occurredAtUtc,
int? durationMs)
{
if (_cachedCallObserver == null)
{
return ValueTask.CompletedTask;
}
Func<Task> work = () => NotifyCachedCallObserverAsync(
message, outcome, lastError, httpStatus, occurredAtUtc, durationMs);
if (_observerPump is null)
{
// Not started: process inline (preserves pre-Task-19 test behavior).
return new ValueTask(work());
}
_observerQueue.Writer.TryWrite(work);
return ValueTask.CompletedTask;
}
/// <summary>
/// Notify the registered
/// <see cref="ICachedCallLifecycleObserver"/> of the just-completed
/// attempt. Only fires for cached-call categories
/// attempt. Invoked from the single-reader observer pump (latency-isolated
/// from the sweep; order preserved per posting sequence) — or inline when the
/// service is not started. Only fires for cached-call categories
/// (<see cref="StoreAndForwardCategory.ExternalSystem"/> and
/// <see cref="StoreAndForwardCategory.CachedDbWrite"/>); the
/// <see cref="StoreAndForwardCategory.Notification"/> category has its
@@ -33,8 +33,18 @@ public class StoreAndForwardStorage
{
EnsureDatabaseDirectoryExists();
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
// Enable WAL so the concurrent writers this store has by design (script
// enqueues, the retry sweep's per-target lanes, standby replication applies,
// central pull queries) read/write without "database is locked". WAL is
// persistent + file-scoped; in-memory DBs report "memory" instead — harmless,
// so it is not asserted here.
await using (var walCmd = connection.CreateCommand())
{
walCmd.CommandText = "PRAGMA journal_mode=WAL";
await walCmd.ExecuteNonQueryAsync();
}
await using var command = connection.CreateCommand();
command.CommandText = @"
@@ -75,6 +85,34 @@ public class StoreAndForwardStorage
// reads back ParentExecutionId = null (back-compat).
await AddColumnIfMissingAsync(connection, "parent_execution_id", "TEXT");
// Additively add the epoch-ms sibling of last_attempt_at (Task 18). The
// ISO-8601 text column stays authoritative for reads / back-compat; this
// INTEGER column drives the due predicate so the sweep no longer parses
// julianday() per row.
await AddColumnIfMissingAsync(connection, "last_attempt_at_ms", "INTEGER");
// One-time backfill for rows persisted before the ms column existed: derive
// epoch-ms from the text timestamp. The "... IS NULL" guard makes this run
// once per legacy DB and never match again — this is the only remaining
// julianday() use in the store.
await using (var backfill = connection.CreateCommand())
{
backfill.CommandText = @"
UPDATE sf_messages
SET last_attempt_at_ms = CAST((julianday(last_attempt_at) - 2440587.5) * 86400000 AS INTEGER)
WHERE last_attempt_at IS NOT NULL AND last_attempt_at_ms IS NULL";
await backfill.ExecuteNonQueryAsync();
}
// Covering index for the due query (status filter + ms ordering column).
// Created after the ALTER above so the column exists.
await using (var dueIndex = connection.CreateCommand())
{
dueIndex.CommandText =
"CREATE INDEX IF NOT EXISTS idx_sf_messages_status_due ON sf_messages(status, last_attempt_at_ms)";
await dueIndex.ExecuteNonQueryAsync();
}
_logger.LogInformation("Store-and-forward SQLite storage initialized");
}
@@ -129,6 +167,67 @@ public class StoreAndForwardStorage
}
}
/// <summary>
/// Opens a connection with a 5s busy_timeout. Concurrent writers exist by
/// design (script enqueues, the sweep's lanes, standby replication applies,
/// central pull queries); with connection-per-operation the pragma must be
/// set per open (it is per-connection, and Microsoft.Data.Sqlite pooling
/// makes the extra statement cheap on a pooled physical connection).
/// </summary>
private async Task<SqliteConnection> OpenConnectionAsync()
{
var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var pragma = connection.CreateCommand();
pragma.CommandText = "PRAGMA busy_timeout = 5000";
await pragma.ExecuteNonQueryAsync();
return connection;
}
/// <summary>
/// INSERT statement for a full message row. Shared by <see cref="EnqueueAsync"/>
/// and <see cref="ReplaceAllAsync"/>; bind with <see cref="BindMessageParameters"/>
/// so the column list and the parameters never drift apart.
/// </summary>
private const string InsertMessageSql = @"
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, last_attempt_at_ms, status, last_error,
origin_instance, execution_id, source_script, parent_execution_id)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @lastAttemptMs, @status, @lastError,
@origin, @executionId, @sourceScript, @parentExecutionId)";
/// <summary>
/// Binds every column parameter for a full message row (used by
/// <see cref="InsertMessageSql"/>). GUID ids are stored in canonical "D" string
/// form and the epoch-ms sibling of last_attempt_at is derived here so a single
/// place owns the mapping.
/// </summary>
private static void BindMessageParameters(SqliteCommand cmd, StoreAndForwardMessage message)
{
cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category);
cmd.Parameters.AddWithValue("@target", message.Target);
cmd.Parameters.AddWithValue("@payload", message.PayloadJson);
cmd.Parameters.AddWithValue("@retryCount", message.RetryCount);
cmd.Parameters.AddWithValue("@maxRetries", message.MaxRetries);
cmd.Parameters.AddWithValue("@retryIntervalMs", message.RetryIntervalMs);
cmd.Parameters.AddWithValue("@createdAt", message.CreatedAt.ToString("O"));
cmd.Parameters.AddWithValue("@lastAttempt", message.LastAttemptAt.HasValue
? message.LastAttemptAt.Value.ToString("O") : DBNull.Value);
cmd.Parameters.AddWithValue("@lastAttemptMs",
(object?)message.LastAttemptAt?.ToUnixTimeMilliseconds() ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value);
// GUID ids stored as canonical "D" strings; null when not threaded.
cmd.Parameters.AddWithValue("@executionId",
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
cmd.Parameters.AddWithValue("@parentExecutionId",
message.ParentExecutionId.HasValue ? message.ParentExecutionId.Value.ToString("D") : DBNull.Value);
}
/// <summary>
/// Enqueues a new message with Pending status.
/// </summary>
@@ -136,17 +235,114 @@ public class StoreAndForwardStorage
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EnqueueAsync(StoreAndForwardMessage message)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = InsertMessageSql;
BindMessageParameters(cmd, message);
await cmd.ExecuteNonQueryAsync();
}
/// <summary>
/// Returns every buffered message regardless of status, oldest-first, up to
/// <paramref name="limit"/> rows, plus a flag indicating the buffer holds more
/// than that. Anti-entropy groundwork: the active node snapshots its buffer for
/// a standby resync. One extra row is fetched (<c>limit + 1</c>) purely to
/// compute the truncation flag without a second COUNT round-trip.
/// </summary>
/// <param name="limit">Maximum rows to return; must be positive.</param>
/// <returns>The oldest-first page (at most <paramref name="limit"/> rows) and whether more rows exist beyond it.</returns>
public async Task<(List<StoreAndForwardMessage> Messages, bool Truncated)> GetAllMessagesAsync(int limit)
{
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script, parent_execution_id
FROM sf_messages
ORDER BY created_at ASC
LIMIT @limitPlusOne";
cmd.Parameters.AddWithValue("@limitPlusOne", limit + 1);
var rows = await ReadMessagesAsync(cmd);
var truncated = rows.Count > limit;
return (rows.Take(limit).ToList(), truncated);
}
/// <summary>
/// Atomically replaces the entire buffer with <paramref name="messages"/> in a
/// single transaction (delete-all then insert-all). Standby-side anti-entropy
/// apply: a peer-join resync overwrites the standby's divergent copy with the
/// active node's authoritative snapshot. <b>Never call on an active node</b> —
/// it discards every in-flight row.
/// </summary>
/// <param name="messages">The full buffer snapshot to install.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ReplaceAllAsync(IReadOnlyList<StoreAndForwardMessage> messages)
{
await using var connection = await OpenConnectionAsync();
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
await using (var deleteCmd = connection.CreateCommand())
{
deleteCmd.Transaction = transaction;
deleteCmd.CommandText = "DELETE FROM sf_messages";
await deleteCmd.ExecuteNonQueryAsync();
}
foreach (var message in messages)
{
await using var insertCmd = connection.CreateCommand();
insertCmd.Transaction = transaction;
insertCmd.CommandText = InsertMessageSql;
BindMessageParameters(insertCmd, message);
await insertCmd.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
/// <summary>
/// Inserts a message, or updates every mutable column in place if a row with the
/// same id already exists (<c>ON CONFLICT(id) DO UPDATE</c>). Used by the standby
/// node when applying a replicated Add/Park/Requeue: a Park/Requeue whose original
/// Add was lost self-heals (the full message rides in the operation), and a
/// duplicate Add (e.g. after an anti-entropy resync) applies newest-wins instead of
/// violating the primary key. The original <c>created_at</c> is preserved — it
/// orders the retry sweep and must never be overwritten on update.
/// </summary>
/// <param name="message">The message to insert or update.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UpsertMessageAsync(StoreAndForwardMessage message)
{
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error,
retry_interval_ms, created_at, last_attempt_at, last_attempt_at_ms, status, last_error,
origin_instance, execution_id, source_script, parent_execution_id)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
@origin, @executionId, @sourceScript, @parentExecutionId)";
@retryIntervalMs, @createdAt, @lastAttempt, @lastAttemptMs, @status, @lastError,
@origin, @executionId, @sourceScript, @parentExecutionId)
ON CONFLICT(id) DO UPDATE SET
category = excluded.category,
target = excluded.target,
payload_json = excluded.payload_json,
retry_count = excluded.retry_count,
max_retries = excluded.max_retries,
retry_interval_ms = excluded.retry_interval_ms,
last_attempt_at = excluded.last_attempt_at,
last_attempt_at_ms = excluded.last_attempt_at_ms,
status = excluded.status,
last_error = excluded.last_error,
origin_instance = excluded.origin_instance,
execution_id = excluded.execution_id,
source_script = excluded.source_script,
parent_execution_id = excluded.parent_execution_id";
cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -158,18 +354,14 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@createdAt", message.CreatedAt.ToString("O"));
cmd.Parameters.AddWithValue("@lastAttempt", message.LastAttemptAt.HasValue
? message.LastAttemptAt.Value.ToString("O") : DBNull.Value);
cmd.Parameters.AddWithValue("@lastAttemptMs",
(object?)message.LastAttemptAt?.ToUnixTimeMilliseconds() ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value);
// The execution id is stored as its
// canonical string form ("D") so it round-trips cleanly through the
// TEXT column; null when not a cached call / not threaded.
cmd.Parameters.AddWithValue("@executionId",
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
// The parent execution id is
// stored as its canonical string form ("D") so it round-trips cleanly
// through the TEXT column; null when not a routed cached call.
cmd.Parameters.AddWithValue("@parentExecutionId",
message.ParentExecutionId.HasValue ? message.ParentExecutionId.Value.ToString("D") : DBNull.Value);
@@ -180,10 +372,9 @@ public class StoreAndForwardStorage
/// Gets all messages that are due for retry (Pending status, last attempt older than retry interval).
/// </summary>
/// <returns>A task that resolves to the list of messages due for retry, ordered by creation time ascending.</returns>
public async Task<List<StoreAndForwardMessage>> GetMessagesForRetryAsync()
public async Task<List<StoreAndForwardMessage>> GetMessagesForRetryAsync(int limit = 0)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
@@ -192,12 +383,20 @@ public class StoreAndForwardStorage
execution_id, source_script, parent_execution_id
FROM sf_messages
WHERE status = @pending
AND (last_attempt_at IS NULL
AND (last_attempt_at_ms IS NULL
OR retry_interval_ms = 0
OR (julianday('now') - julianday(last_attempt_at)) * 86400000 >= retry_interval_ms)
OR (@nowMs - last_attempt_at_ms) >= retry_interval_ms)
ORDER BY created_at ASC";
// Bound the sweep batch (oldest-first). 0 = unlimited (legacy).
if (limit > 0)
{
cmd.CommandText += "\n LIMIT @limit";
cmd.Parameters.AddWithValue("@limit", limit);
}
cmd.Parameters.AddWithValue("@pending", (int)StoreAndForwardMessageStatus.Pending);
cmd.Parameters.AddWithValue("@nowMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
return await ReadMessagesAsync(cmd);
}
@@ -209,14 +408,14 @@ public class StoreAndForwardStorage
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UpdateMessageAsync(StoreAndForwardMessage message)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
UPDATE sf_messages
SET retry_count = @retryCount,
last_attempt_at = @lastAttempt,
last_attempt_at_ms = @lastAttemptMs,
status = @status,
last_error = @lastError
WHERE id = @id";
@@ -225,6 +424,8 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@retryCount", message.RetryCount);
cmd.Parameters.AddWithValue("@lastAttempt", message.LastAttemptAt.HasValue
? message.LastAttemptAt.Value.ToString("O") : DBNull.Value);
cmd.Parameters.AddWithValue("@lastAttemptMs",
(object?)message.LastAttemptAt?.ToUnixTimeMilliseconds() ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
@@ -244,14 +445,14 @@ public class StoreAndForwardStorage
StoreAndForwardMessage message,
StoreAndForwardMessageStatus expectedStatus)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
UPDATE sf_messages
SET retry_count = @retryCount,
last_attempt_at = @lastAttempt,
last_attempt_at_ms = @lastAttemptMs,
status = @status,
last_error = @lastError
WHERE id = @id AND status = @expectedStatus";
@@ -260,6 +461,8 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@retryCount", message.RetryCount);
cmd.Parameters.AddWithValue("@lastAttempt", message.LastAttemptAt.HasValue
? message.LastAttemptAt.Value.ToString("O") : DBNull.Value);
cmd.Parameters.AddWithValue("@lastAttemptMs",
(object?)message.LastAttemptAt?.ToUnixTimeMilliseconds() ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@expectedStatus", (int)expectedStatus);
@@ -275,8 +478,7 @@ public class StoreAndForwardStorage
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RemoveMessageAsync(string messageId)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = "DELETE FROM sf_messages WHERE id = @id";
@@ -297,8 +499,7 @@ public class StoreAndForwardStorage
int pageNumber = 1,
int pageSize = 50)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
@@ -343,13 +544,13 @@ public class StoreAndForwardStorage
/// <returns>A task that resolves to <c>true</c> if the message was found and reset to Pending; <c>false</c> if not found or not in Parked status.</returns>
public async Task<bool> RetryParkedMessageAsync(string messageId)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
UPDATE sf_messages
SET status = @pending, retry_count = 0, last_error = NULL, last_attempt_at = NULL
SET status = @pending, retry_count = 0, last_error = NULL,
last_attempt_at = NULL, last_attempt_at_ms = NULL
WHERE id = @id AND status = @parked";
cmd.Parameters.AddWithValue("@id", messageId);
@@ -367,8 +568,7 @@ public class StoreAndForwardStorage
/// <returns>A task that resolves to <c>true</c> if the message was found and deleted; <c>false</c> if not found or not in Parked status.</returns>
public async Task<bool> DiscardParkedMessageAsync(string messageId)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = "DELETE FROM sf_messages WHERE id = @id AND status = @parked";
@@ -385,8 +585,7 @@ public class StoreAndForwardStorage
/// <returns>A task that resolves to a dictionary mapping each category to its pending message count.</returns>
public async Task<Dictionary<StoreAndForwardCategory, int>> GetBufferDepthByCategoryAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
@@ -416,8 +615,7 @@ public class StoreAndForwardStorage
/// <returns>A task that resolves to the number of messages whose origin instance matches <paramref name="instanceName"/>.</returns>
public async Task<int> GetMessageCountByOriginInstanceAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
@@ -436,8 +634,7 @@ public class StoreAndForwardStorage
/// <returns>A task that resolves to the matching message, or <c>null</c> if not found.</returns>
public async Task<StoreAndForwardMessage?> GetMessageByIdAsync(string messageId)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
@@ -458,8 +655,7 @@ public class StoreAndForwardStorage
/// <returns>A task that resolves to the number of messages currently in Parked status.</returns>
public async Task<int> GetParkedMessageCountAsync()
{
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync();
await using var conn = await OpenConnectionAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM sf_messages WHERE status = @parked";
cmd.Parameters.AddWithValue("@parked", (int)StoreAndForwardMessageStatus.Parked);
@@ -467,6 +663,26 @@ public class StoreAndForwardStorage
return Convert.ToInt32(result);
}
/// <summary>
/// Gets the <c>created_at</c> of the oldest parked message, or <c>null</c> when
/// no rows are parked. Backs the parked-retention aging signal on the site health
/// report: parked rows persist until an operator acts, so their <i>age</i> — not
/// just their count — is what tells an operator a forgotten site's backlog is
/// growing stale.
/// </summary>
/// <returns>A task that resolves to the oldest parked row's creation time, or <c>null</c> if none are parked.</returns>
public async Task<DateTimeOffset?> GetOldestParkedCreatedAtAsync()
{
await using var conn = await OpenConnectionAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT MIN(created_at) FROM sf_messages WHERE status = @parked";
cmd.Parameters.AddWithValue("@parked", (int)StoreAndForwardMessageStatus.Parked);
var result = await cmd.ExecuteScalarAsync();
return result is null or DBNull
? null
: DateTimeOffset.Parse((string)result);
}
/// <summary>
/// Gets total message count by status.
/// </summary>
@@ -474,8 +690,7 @@ public class StoreAndForwardStorage
/// <returns>A task that resolves to the count of messages with the specified status.</returns>
public async Task<int> GetMessageCountByStatusAsync(StoreAndForwardMessageStatus status)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = await OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM sf_messages WHERE status = @status";
@@ -712,4 +712,26 @@ public class SqliteAuditWriterWriteTests
var row = Assert.Single(rows);
Assert.Equal(AuditOutcome.Success, row.Outcome);
}
// ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ──
[Fact]
public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates()
{
// Two rows share one OccurredAtUtc; a pull whose sinceUtc equals that
// timestamp MUST return both (inclusive >= read). Central reconciliation
// dedups on EventId (insert-if-not-exists), so re-reading the boundary row
// is a safe no-op — but an exclusive (>) read would silently and
// permanently lose the same-timestamp row. This test pins the >= contract.
var (writer, _) = CreateWriter(nameof(ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates));
await using var _w = writer;
var boundary = DateTime.UtcNow;
await writer.WriteAsync(NewEvent(id: Guid.NewGuid(), occurredAtUtc: boundary));
await writer.WriteAsync(NewEvent(id: Guid.NewGuid(), occurredAtUtc: boundary));
var rows = await writer.ReadPendingSinceAsync(boundary, batchSize: 10);
Assert.Equal(2, rows.Count);
}
}
@@ -148,6 +148,33 @@ public class CentralCommunicationActorTests : TestKit
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site1", timestamp));
}
[Fact]
public void HeartbeatReplica_MarksLocalAggregator_WithoutRebroadcast()
{
// Task 16 (arch review 02): a heartbeat replicated from the peer central
// node marks the local aggregator (so post-failover both nodes know the
// site's liveness) but is NOT re-published (it arrives via pub-sub already).
var mockRepo = Substitute.For<ISiteRepository>();
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site>());
var aggregator = Substitute.For<ICentralHealthAggregator>();
var services = new ServiceCollection();
services.AddScoped(_ => mockRepo);
services.AddSingleton(aggregator);
var sp = services.BuildServiceProvider();
var siteClientFactory = Substitute.For<ISiteClientFactory>();
var centralActor = Sys.ActorOf(
Props.Create(() => new CentralCommunicationActor(sp, siteClientFactory)));
var ts = DateTimeOffset.UtcNow;
centralActor.Tell(new SiteHeartbeatReplica(new HeartbeatMessage("site-1", "host-a", true, ts)));
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", ts));
}
[Fact]
public void RefreshSiteAddresses_UpdatesCache()
{
@@ -58,4 +58,15 @@ public class CommunicationOptionsTests
Assert.Equal(TimeSpan.FromMinutes(5), options.DeploymentTimeout);
Assert.Equal(TimeSpan.FromSeconds(2), options.TransportHeartbeatInterval);
}
[Fact]
public void ApplicationHeartbeatInterval_DefaultsTo5s_IndependentOfTransport()
{
var o = new CommunicationOptions();
Assert.Equal(TimeSpan.FromSeconds(5), o.ApplicationHeartbeatInterval);
// Retuning the Akka.Remote failure-detector heartbeat must NOT silently
// retune the application-level site→central health heartbeat.
o.TransportHeartbeatInterval = TimeSpan.FromSeconds(30);
Assert.Equal(TimeSpan.FromSeconds(5), o.ApplicationHeartbeatInterval);
}
}
@@ -400,6 +400,60 @@ public class DebugStreamBridgeActorTests : TestKit
Assert.Equal("corr-1", factory.ClientFor(GrpcNodeB).SubscribeCalls[0].CorrelationId);
}
// ── Task 6 (arch review 02, High): teardown/failover unsubscribe is endpoint-safe ──
// Both paths use TryGet, never GetOrCreate, so cleanup can never open a fresh
// channel or (with (site,endpoint) keying) touch another session's channel.
private (IActorRef Actor, EndpointTrackingGrpcClientFactory Factory) CreateBridgeWithTrackingFactory()
{
var commProbe = CreateTestProbe();
var factory = new EndpointTrackingGrpcClientFactory();
var props = Props.Create(typeof(DebugStreamBridgeActor),
SiteId, InstanceName, "corr-1", commProbe.Ref,
(Action<object>)(_ => { }), (Action)(() => { }),
factory, GrpcNodeA, GrpcNodeB);
var actor = Sys.ActorOf(props);
commProbe.ExpectMsg<SiteEnvelope>(); // PreStart subscribe handshake
return (actor, factory);
}
[Fact]
public void CleanupGrpc_UnsubscribesViaTryGet_WithoutOpeningAChannel()
{
var (actor, factory) = CreateBridgeWithTrackingFactory();
AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
TimeSpan.FromSeconds(3));
var createdBefore = factory.CreatedCount; // node-a only
actor.Tell(new StopDebugStream());
AwaitCondition(() => factory.ClientFor(GrpcNodeA).UnsubscribedCorrelationIds.Contains("corr-1"),
TimeSpan.FromSeconds(3));
// Pre-fix CleanupGrpc called GetOrCreate → could open a fresh channel here.
Assert.Equal(createdBefore, factory.CreatedCount);
}
[Fact]
public void SessionFailover_UnsubscribesFailedEndpointViaTryGet_OpensOnlyTheReconnectChannel()
{
var (_, factory) = CreateBridgeWithTrackingFactory();
AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
TimeSpan.FromSeconds(3));
// Fail NodeA: the bridge unsubscribes the failed NodeA stream via TryGet
// (if that path had used GetOrCreate on the post-flip cache it could have
// disposed/created the wrong client), then reconnects to NodeB.
factory.ClientFor(GrpcNodeA).SubscribeCalls[0].OnError(new Exception("NodeA down"));
AwaitCondition(() => factory.ClientFor(GrpcNodeA).UnsubscribedCorrelationIds.Contains("corr-1"),
TimeSpan.FromSeconds(5));
AwaitCondition(() => factory.ClientFor(GrpcNodeB).SubscribeCalls.Count == 1,
TimeSpan.FromSeconds(5));
// Only the two real node endpoints were ever opened — the unsubscribe
// itself created nothing.
Assert.Equal(2, factory.CreatedCount);
}
// ---------------------------------------------------------------------
// M2.18 (#26) — stream-first + replay/dedup
// ---------------------------------------------------------------------
@@ -883,6 +937,12 @@ internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory
RequestedEndpoints.Add(grpcEndpoint);
return _mockClient;
}
// Mirrors the real factory's TryGet: returns the client only for an endpoint
// already opened via GetOrCreate, never creating one. Lets teardown/failover
// unsubscribe paths (now TryGet-based, Task 6) resolve the mock client.
public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint)
=> RequestedEndpoints.Contains(grpcEndpoint) ? _mockClient : null;
}
/// <summary>
@@ -902,6 +962,13 @@ internal class EndpointTrackingGrpcClientFactory : SiteStreamGrpcClientFactory
public MockSiteStreamGrpcClient ClientFor(string endpoint) =>
_byEndpoint.GetOrAdd(endpoint, _ => new MockSiteStreamGrpcClient());
/// <summary>Number of distinct endpoint clients created so far (channels opened).</summary>
public int CreatedCount => _byEndpoint.Count;
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
=> ClientFor(grpcEndpoint);
// TryGet never creates: returns the client only for an already-opened endpoint.
public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint)
=> _byEndpoint.TryGetValue(grpcEndpoint, out var client) ? client : null;
}
@@ -81,19 +81,47 @@ public class SiteStreamGrpcClientFactoryTests
}
[Fact]
public void GetOrCreate_EndpointChanged_DisposesPriorClient()
public void GetOrCreate_DifferentEndpointSameSite_KeepsBothClientsAlive()
{
// Communication-013 regression: a later edit to a site's gRPC address must
// invalidate (and dispose) the stale cached client, so the corrected
// endpoint takes effect without a central restart.
// Arch review 02 (High): the cache is keyed by (site, endpoint). Both node
// channels for a site coexist, so a per-session NodeA→NodeB failover flip
// must NOT dispose the other endpoint's channel — another debug session may
// be using it. (Supersedes Communication-013's dispose-on-endpoint-change.)
using var factory = new TrackingEndpointFactory();
var first = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
var second = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200");
var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
var b = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200");
Assert.NotSame(first, second);
Assert.True(first.Disposed, "stale client for the old endpoint should be disposed");
Assert.False(second.Disposed, "fresh client for the new endpoint should still be live");
Assert.NotSame(a, b);
Assert.False(a.Disposed, "the other endpoint's channel must stay alive");
Assert.Same(a, factory.GetOrCreate("site-a", "http://localhost:5100")); // still cached
}
[Fact]
public void TryGet_ReturnsCachedClientOrNull_WithoutCreating()
{
using var factory = new TrackingEndpointFactory();
Assert.Null(factory.TryGet("site-a", "http://localhost:5100"));
var a = factory.GetOrCreate("site-a", "http://localhost:5100");
Assert.Same(a, factory.TryGet("site-a", "http://localhost:5100"));
Assert.Null(factory.TryGet("site-a", "http://localhost:5200")); // other endpoint not cached
Assert.Equal(1, factory.CreatedCount); // TryGet never creates
}
[Fact]
public async Task RemoveSiteAsync_DisposesAllEndpointsForTheSite_OnlyThatSite()
{
using var factory = new TrackingEndpointFactory();
var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
var b = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200");
var other = (TrackingEndpointClient)factory.GetOrCreate("site-b", "http://localhost:5100");
await factory.RemoveSiteAsync("site-a");
Assert.True(a.Disposed);
Assert.True(b.Disposed);
Assert.False(other.Disposed);
}
[Fact]
@@ -126,7 +154,11 @@ public class SiteStreamGrpcClientFactoryTests
private sealed class TrackingEndpointFactory : SiteStreamGrpcClientFactory
{
public TrackingEndpointFactory() : base(NullLoggerFactory.Instance) { }
public int CreatedCount { get; private set; }
protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint)
=> new TrackingEndpointClient(grpcEndpoint);
{
CreatedCount++;
return new TrackingEndpointClient(grpcEndpoint);
}
}
}
@@ -0,0 +1,191 @@
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Characterization pins for the cross-cluster wire contracts. The Akka default
/// reflective-JSON wire format couples compatibility to <b>type and namespace
/// identity</b> plus round-trip fidelity, and nothing else tested that
/// (<c>MessageContractTests</c> asserts correlation-ids only). These tests pin
/// (a) round-trip fidelity and (b) the fully-qualified type identity of the
/// highest-traffic central↔site contracts, so a rename/move that would break a
/// rolling central↔site upgrade fails a test here instead of in production.
///
/// The serializer swap (proto / explicit bindings) is deliberately deferred — it
/// changes the wire format and needs a coordinated rolling-upgrade plan; until
/// then these pins are the guardrail.
/// </summary>
public class WireSerializationPinTests : TestKit
{
/// <summary>
/// Serializes with the same serializer Akka would pick on the wire, then
/// deserializes back through the type manifest — exactly the path a peer node
/// takes when it receives the bytes.
/// </summary>
private T RoundTrip<T>(T message)
{
var serialization = Sys.Serialization;
var serializer = serialization.FindSerializerFor(message);
var bytes = serializer.ToBinary(message);
return (T)serialization.Deserialize(bytes, serializer.Identifier, message!.GetType());
}
[Fact]
public void HeartbeatMessage_RoundTripsOnTheWire()
{
var original = new HeartbeatMessage("site-1", "host-a", true, DateTimeOffset.UtcNow);
var back = RoundTrip(original);
Assert.Equal(original, back); // all-scalar record → structural equality holds
}
[Fact]
public void NotificationSubmit_RoundTripsOnTheWire()
{
var original = new NotificationSubmit(
NotificationId: Guid.NewGuid().ToString("N"),
ListName: "Operators",
Subject: "Pump alarm",
Body: "Pump 3 tripped",
SourceSiteId: "site-1",
SourceInstanceId: "Plant.Pump3",
SourceScript: "ScriptActor:MonitorSpeed",
SiteEnqueuedAt: DateTimeOffset.UtcNow,
OriginExecutionId: Guid.NewGuid(),
OriginParentExecutionId: Guid.NewGuid(),
SourceNode: "node-a");
var back = RoundTrip(original);
// All-scalar record; assert every field so a dropped ctor arg on either
// side (a wire-fidelity bug) is caught field-by-field, not just by ==.
Assert.Equal(original.NotificationId, back.NotificationId);
Assert.Equal(original.ListName, back.ListName);
Assert.Equal(original.Subject, back.Subject);
Assert.Equal(original.Body, back.Body);
Assert.Equal(original.SourceSiteId, back.SourceSiteId);
Assert.Equal(original.SourceInstanceId, back.SourceInstanceId);
Assert.Equal(original.SourceScript, back.SourceScript);
Assert.Equal(original.SiteEnqueuedAt, back.SiteEnqueuedAt);
Assert.Equal(original.OriginExecutionId, back.OriginExecutionId);
Assert.Equal(original.OriginParentExecutionId, back.OriginParentExecutionId);
Assert.Equal(original.SourceNode, back.SourceNode);
}
[Fact]
public void IngestAuditEventsCommand_RoundTripsOnTheWire()
{
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCallCached,
status: AuditStatus.Forwarded,
eventId: Guid.NewGuid(),
occurredAtUtc: occurredAt,
actor: "design-key",
target: "weather-api",
sourceNode: "node-a",
correlationId: Guid.NewGuid(),
executionId: Guid.NewGuid(),
parentExecutionId: Guid.NewGuid(),
sourceSiteId: "site-1",
sourceInstanceId: "Pump01",
sourceScript: "OnDemand",
httpStatus: 200,
durationMs: 42,
errorMessage: "transient timeout",
errorDetail: "stack-trace",
requestSummary: "GET /weather",
responseSummary: "{ \"ok\": true }",
payloadTruncated: true,
extra: "{ \"retryCount\": 1 }");
var original = new IngestAuditEventsCommand(new[] { evt });
var back = RoundTrip(original);
Assert.Single(back.Events);
var o = original.Events[0].AsRow();
var rt = back.Events[0].AsRow();
Assert.Equal(o.EventId, rt.EventId);
Assert.Equal(o.OccurredAtUtc, rt.OccurredAtUtc);
Assert.Equal(o.Channel, rt.Channel);
Assert.Equal(o.Kind, rt.Kind);
Assert.Equal(o.Status, rt.Status);
Assert.Equal(o.CorrelationId, rt.CorrelationId);
Assert.Equal(o.ExecutionId, rt.ExecutionId);
Assert.Equal(o.ParentExecutionId, rt.ParentExecutionId);
Assert.Equal(o.SourceSiteId, rt.SourceSiteId);
Assert.Equal(o.SourceNode, rt.SourceNode);
Assert.Equal(o.SourceInstanceId, rt.SourceInstanceId);
Assert.Equal(o.SourceScript, rt.SourceScript);
Assert.Equal(o.Actor, rt.Actor);
Assert.Equal(o.Target, rt.Target);
Assert.Equal(o.HttpStatus, rt.HttpStatus);
Assert.Equal(o.DurationMs, rt.DurationMs);
Assert.Equal(o.ErrorMessage, rt.ErrorMessage);
}
[Fact]
public void SiteHealthReport_RoundTripsOnTheWire()
{
var original = new SiteHealthReport(
SiteId: "site-1",
SequenceNumber: 42,
ReportTimestamp: DateTimeOffset.UtcNow,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
{
["opc-1"] = ConnectionHealth.Connected,
},
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>
{
["opc-1"] = new TagResolutionStatus(10, 9),
},
ScriptErrorCount: 1,
AlarmEvaluationErrorCount: 2,
StoreAndForwardBufferDepths: new Dictionary<string, int>
{
["Notification"] = 3,
},
DeadLetterCount: 4,
DeployedInstanceCount: 5,
EnabledInstanceCount: 6,
DisabledInstanceCount: 7,
NodeRole: "Active",
NodeHostname: "host-a",
ParkedMessageCount: 8);
var back = RoundTrip(original);
// Scalars + one entry per primary collection (record equality does NOT
// hold — dictionaries compare by reference — so assert field/entry-wise).
Assert.Equal(original.SiteId, back.SiteId);
Assert.Equal(original.SequenceNumber, back.SequenceNumber);
Assert.Equal(original.ReportTimestamp, back.ReportTimestamp);
Assert.Equal(ConnectionHealth.Connected, back.DataConnectionStatuses["opc-1"]);
Assert.Equal(new TagResolutionStatus(10, 9), back.TagResolutionCounts["opc-1"]);
Assert.Equal(3, back.StoreAndForwardBufferDepths["Notification"]);
Assert.Equal(original.DeadLetterCount, back.DeadLetterCount);
Assert.Equal(original.DeployedInstanceCount, back.DeployedInstanceCount);
Assert.Equal(original.NodeRole, back.NodeRole);
Assert.Equal(original.NodeHostname, back.NodeHostname);
Assert.Equal(original.ParkedMessageCount, back.ParkedMessageCount);
}
// Type-identity pins: the reflective-JSON wire embeds CLR type manifests, so a
// rename/move of any of these types silently breaks rolling cross-version
// central↔site communication. If one of these assertions fails, you are making
// a wire-breaking change — coordinate a full-fleet upgrade or add an explicit
// serialization binding before renaming/moving the type.
[Theory]
[InlineData(typeof(HeartbeatMessage), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.HeartbeatMessage")]
[InlineData(typeof(NotificationSubmit), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationSubmit")]
[InlineData(typeof(IngestAuditEventsCommand), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit.IngestAuditEventsCommand")]
[InlineData(typeof(SiteHealthReport), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport")]
public void CrossClusterContract_TypeIdentity_IsPinned(Type type, string expectedFullName) =>
Assert.Equal(expectedFullName, type.FullName);
}
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// DataConnectionLayer-S1 (MxGateway half): the adapter instance is reused across
/// reconnects. Before the fix, <c>ConnectAsync</c> overwrote <c>_client</c> and
/// <c>_eventLoopCts</c> without cancelling the previous event loop or disposing the
/// previous client — every gateway flap accumulated an orphaned stream/socket, and
/// the dying old loop's fault path could raise <c>Disconnected</c> against the new
/// session after the guard was reset.
/// </summary>
[Collection("DataConnectionManagerActor")]
public class MxGatewayDataConnectionReconnectTests
{
[Fact]
public async Task Reconnect_DisposesPreviousClient_AndCancelsPreviousEventLoop()
{
var clients = new List<IMxGatewayClient>();
var loopTokens = new List<CancellationToken>();
var factory = Substitute.For<IMxGatewayClientFactory>();
factory.Create().Returns(_ =>
{
var c = Substitute.For<IMxGatewayClient>();
c.RunEventLoopAsync(Arg.Any<Action<MxValueUpdate>>(), Arg.Do<CancellationToken>(t => loopTokens.Add(t)))
.Returns(Task.Delay(Timeout.Infinite));
clients.Add(c);
return c;
});
var adapter = new MxGatewayDataConnection(factory, NullLogger<MxGatewayDataConnection>.Instance);
var details = new Dictionary<string, string> { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };
// The event loop is pumped fire-and-forget (ConnectAsync → Task.Run →
// RunEventLoopAsync), so the token is recorded on a thread-pool continuation.
// Barrier on each recorded token before proceeding.
await adapter.ConnectAsync(details);
await WaitUntilAsync(() => loopTokens.Count == 1);
await adapter.ConnectAsync(details); // reconnect
await WaitUntilAsync(() => loopTokens.Count == 2);
Assert.Equal(2, clients.Count);
await clients[0].Received(1).DisposeAsync();
Assert.True(loopTokens[0].IsCancellationRequested); // old loop cancelled
Assert.False(loopTokens[1].IsCancellationRequested); // new loop live
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
for (var i = 0; i < 100 && !condition(); i++)
await Task.Delay(50);
Assert.True(condition(), "condition not met within timeout");
}
}
@@ -81,6 +81,29 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<SubscribeAlarmsResponse>(m => !m.Success && m.ErrorMessage != null);
}
[Fact]
public void SecondAlarmSubscriber_WithDifferentFilter_LogsOverwriteWarning()
{
// The adapter alarm feed carries a single shared condition filter per source;
// a second subscriber with a different filter overwrites it (last-writer-wins).
// That silent overwrite must be surfaced as a warning so a co-subscriber
// mismatch is diagnosable (S10).
var (adapter, _) = BuildAlarmAdapter();
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
"conn", adapter, _options, _health, _factory, "OpcUa")));
actor.Tell(new SubscribeAlarmsRequest(
"c1", "instA", "conn", "Tank01", "AnalogLimit.Hi", DateTimeOffset.UtcNow));
ExpectMsg<SubscribeAlarmsResponse>(m => m.Success);
EventFilter.Warning(contains: "condition filter").ExpectOne(() =>
{
actor.Tell(new SubscribeAlarmsRequest(
"c2", "instB", "conn", "Tank01", "AnalogLimit.Lo", DateTimeOffset.UtcNow));
ExpectMsg<SubscribeAlarmsResponse>(m => m.Success);
});
}
// ── M2.4 (#8): conditionFilter is now applied client-side in the actor ──
[Fact]
@@ -463,6 +463,146 @@ public class DataConnectionActorTests : TestKit
"sensor/temp", Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>());
}
// ── PLAN-03 Task 16 (S7): background tasks must use the adapter that started them ──
[Fact]
public async Task Task16_InFlightSubscribe_ContinuesOnOriginalAdapter_AfterFailover()
{
// Regression for S7. HandleSubscribe launches a background Task.Run that iterates
// the tags and awaits _adapter.SubscribeAsync per tag. If a failover swaps the
// mutable _adapter field mid-loop, later iterations must still hit the adapter that
// STARTED the subscribe — not the freshly-swapped-in backup. Pre-fix the loop re-read
// the _adapter field after the first await, so a tag after the first was subscribed
// against the wrong (backup) adapter generation.
var primaryConfig = new Dictionary<string, string> { ["Endpoint"] = "opc.tcp://primary:4840" };
var backupConfig = new Dictionary<string, string> { ["Endpoint"] = "opc.tcp://backup:4840" };
var primaryAdapter = Substitute.For<IDataConnection>();
var backupAdapter = Substitute.For<IDataConnection>();
// Primary connects once, then every reconnect fails so the disconnect drives failover.
var primaryConnectCount = 0;
primaryAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(_ => Interlocked.Increment(ref primaryConnectCount) == 1
? Task.CompletedTask
: Task.FromException(new Exception("Primary down")));
// The first tag's subscribe blocks on the gate (in-flight across the failover);
// the second tag resolves immediately once the loop resumes.
var subscribeGate = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
primaryAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns(_ => subscribeGate.Task, _ => Task.FromResult("sub-primary-2"));
primaryAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ReadResult(false, null, null));
_mockFactory.Create("OpcUa", Arg.Is<IDictionary<string, string>>(d => d["Endpoint"] == "opc.tcp://backup:4840"))
.Returns(backupAdapter);
backupAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
backupAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns("sub-backup");
backupAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ReadResult(false, null, null));
var actor = CreateFailoverActor(primaryAdapter, "task16-inflight", primaryConfig, backupConfig, failoverRetryCount: 1);
// Reach Connected on primary.
AwaitCondition(() => primaryConnectCount >= 1, TimeSpan.FromSeconds(2));
await Task.Delay(200);
// Kick off a two-tag subscribe; the first tag blocks in-flight on the gate.
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "task16-inflight", ["t1", "t2"], DateTimeOffset.UtcNow));
// The background loop must have called the primary's SubscribeAsync (t1) and parked.
AwaitCondition(() =>
primaryAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") >= 1,
TimeSpan.FromSeconds(3));
// Fail over while the subscribe is in flight: swaps _adapter to backup, bumps generation.
RaiseDisconnected(primaryAdapter);
AwaitCondition(() =>
backupAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
TimeSpan.FromSeconds(5));
// Release the in-flight subscribe; the loop resumes and subscribes t2.
subscribeGate.SetResult("sub-primary-1");
// t2 must go to the ORIGINAL (primary) adapter, never the swapped-in backup.
AwaitCondition(() =>
primaryAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") >= 2,
TimeSpan.FromSeconds(5));
Assert.DoesNotContain(backupAdapter.ReceivedCalls(),
c => c.GetMethodInfo().Name == "SubscribeAsync");
}
// ── PLAN-03 Task 17 (P3): tag→instances reverse index for the value fan-out hot path ──
private IActorRef CreateInstantSubscribeActor(string name)
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.Status.Returns(ConnectionHealth.Connected);
_mockAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns(callInfo => Task.FromResult("sub-" + callInfo.ArgAt<string>(0)));
// Failed reads → no seed values are delivered, so the probes see only the ack plus
// whatever TagValueReceived we explicitly deliver.
_mockAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ReadResult(false, null, null));
return CreateConnectionActor(name);
}
[Fact]
public async Task Task17_TagFanOut_AfterOneInstanceUnsubscribes_OnlyRemainingSubscriberReceives()
{
// Pins the fan-out contract that the reverse index must preserve: after one of two
// instances sharing a tag unsubscribes, a subsequent value for that tag reaches only
// the remaining subscriber. Passes with the O(N) scan AND the index — the index's
// bookkeeping on unsubscribe is exactly what this guards.
var actor = CreateInstantSubscribeActor("task17-unsub");
await Task.Delay(300); // reach Connected
var probeA = CreateTestProbe();
var probeB = CreateTestProbe();
actor.Tell(new SubscribeTagsRequest("cA", "inst-A", "task17-unsub", ["shared-tag"], DateTimeOffset.UtcNow), probeA.Ref);
actor.Tell(new SubscribeTagsRequest("cB", "inst-B", "task17-unsub", ["shared-tag"], DateTimeOffset.UtcNow), probeB.Ref);
probeA.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
probeB.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
// inst-A leaves; the shared tag now has exactly one remaining subscriber.
actor.Tell(new UnsubscribeTagsRequest("cA2", "inst-A", "task17-unsub", DateTimeOffset.UtcNow), probeA.Ref);
await Task.Delay(200); // let the unsubscribe be processed before delivering the value
actor.Tell(new DataConnectionActor.TagValueReceived(
"shared-tag", new TagValue(42.0, QualityCode.Good, DateTimeOffset.UtcNow), 0));
var update = probeB.ExpectMsg<TagValueUpdate>(TimeSpan.FromSeconds(5));
Assert.Equal("shared-tag", update.TagPath);
Assert.Equal(42.0, update.Value);
probeA.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task Task17_TagFanOut_TwoInstancesDisjointTags_EachReceivesOnlyItsOwn()
{
// Pins that the index keys values to the right instances: two instances on disjoint
// tags each receive only their own tag's value, never the other's.
var actor = CreateInstantSubscribeActor("task17-disjoint");
await Task.Delay(300);
var probeA = CreateTestProbe();
var probeB = CreateTestProbe();
actor.Tell(new SubscribeTagsRequest("cA", "inst-A", "task17-disjoint", ["tag-A"], DateTimeOffset.UtcNow), probeA.Ref);
actor.Tell(new SubscribeTagsRequest("cB", "inst-B", "task17-disjoint", ["tag-B"], DateTimeOffset.UtcNow), probeB.Ref);
probeA.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
probeB.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
actor.Tell(new DataConnectionActor.TagValueReceived(
"tag-A", new TagValue(1.0, QualityCode.Good, DateTimeOffset.UtcNow), 0));
var toA = probeA.ExpectMsg<TagValueUpdate>(TimeSpan.FromSeconds(5));
Assert.Equal("tag-A", toA.TagPath);
probeB.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
// ── DataConnectionLayer-001: subscribe must not mutate actor state off-thread ──
private static async Task<string> DelayedSubscribeAsync()
@@ -559,4 +559,56 @@ public class OpcUaDataConnectionTests
Arg.Is<OpcUaConnectionOptions?>(o => o != null && o.AutoAcceptUntrustedCerts == false),
Arg.Any<CancellationToken>());
}
// DataConnectionLayer-S1: the adapter instance is reused across reconnects
// (DataConnectionActor calls ConnectAsync again on auto-reconnect). Before the
// fix, ConnectAsync overwrote _client without disposing/detaching the previous
// one, leaking its session/keep-alive/socket per flap AND leaving its stale
// ConnectionLost handler wired — so a late keep-alive death on the OLD client
// flapped the NEW healthy session (after _disconnectFired was reset).
[Fact]
public async Task Reconnect_DisposesAndDetachesPreviousClient()
{
var clients = new List<IOpcUaClient>();
_mockFactory.Create().Returns(_ =>
{
var c = Substitute.For<IOpcUaClient>();
clients.Add(c);
return c;
});
var details = new Dictionary<string, string> { ["EndpointUrl"] = "opc.tcp://x:4840" };
await _adapter.ConnectAsync(details); // client[0]
await _adapter.ConnectAsync(details); // reconnect -> client[1]
Assert.Equal(2, clients.Count);
await clients[0].Received(1).DisposeAsync();
await clients[1].DidNotReceive().DisposeAsync();
}
[Fact]
public async Task StaleClientConnectionLost_DoesNotRaiseDisconnected_AfterReconnect()
{
var clients = new List<IOpcUaClient>();
_mockFactory.Create().Returns(_ =>
{
var c = Substitute.For<IOpcUaClient>();
clients.Add(c);
return c;
});
var details = new Dictionary<string, string> { ["EndpointUrl"] = "opc.tcp://x:4840" };
var disconnects = 0;
_adapter.Disconnected += () => Interlocked.Increment(ref disconnects);
await _adapter.ConnectAsync(details);
await _adapter.ConnectAsync(details); // reconnect resets _disconnectFired
// The OLD client's keep-alive dies later — must be a no-op now.
clients[0].ConnectionLost += Raise.Event<Action>();
Assert.Equal(0, disconnects);
// The CURRENT client's loss must still signal.
clients[1].ConnectionLost += Raise.Event<Action>();
Assert.Equal(1, disconnects);
}
}
@@ -38,6 +38,25 @@ public class SiteHealthCollectorTests
Assert.Equal(2, report.AlarmEvaluationErrorCount);
}
[Fact]
public void CollectReport_CarriesScriptSchedulerStats()
{
_collector.SetScriptSchedulerStats(queueDepth: 7, busyThreads: 3, oldestBusyAgeSeconds: 42.5);
var report = _collector.CollectReport("site-1");
Assert.Equal(7, report.ScriptQueueDepth);
Assert.Equal(3, report.ScriptBusyThreads);
Assert.Equal(42.5, report.ScriptOldestBusyAgeSeconds);
}
[Fact]
public void CollectReport_ScriptSchedulerStats_DefaultToZeroAndNull()
{
var report = _collector.CollectReport("site-1");
Assert.Equal(0, report.ScriptQueueDepth);
Assert.Equal(0, report.ScriptBusyThreads);
Assert.Null(report.ScriptOldestBusyAgeSeconds);
}
[Fact]
public void IncrementDeadLetter_AccumulatesBetweenReports()
{
@@ -1,6 +1,8 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -1008,4 +1010,48 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
// ── P1: Expression-alarm evaluation runs off the dispatcher ──
/// <summary>
/// Compiles a trigger expression WITHOUT the trust validator so a test can use
/// <c>System.Threading.Thread</c> to observe which thread the evaluation runs on.
/// </summary>
private static Script<object?> CompileRawTriggerExpression(string expression)
{
var opts = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var s = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
s.Compile();
return s;
}
[Fact]
public void ExpressionAlarm_EvaluatesOnSchedulerThread_AndActivates()
{
// TRUE only when evaluated on a script-execution thread. Before P1 the
// expression ran on the actor dispatcher (name is NOT "script-execution-*")
// → false → alarm never activates. After P1 it runs on the script scheduler.
var expr = CompileRawTriggerExpression(
"System.Threading.Thread.CurrentThread.Name != null && " +
"System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "ExprAlarm",
TriggerType = "Expression",
TriggerConfiguration = "{\"expression\":\"true\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"ExprAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance, expr)));
alarm.Tell(new AttributeValueChanged("Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow));
var change = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
Assert.Equal(AlarmState.Active, change.State);
}
}
@@ -106,6 +106,27 @@ public class CertStoreActorTests : TestKit, IDisposable
emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint);
}
[Fact]
public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes()
{
var (derBase64, thumbprint) = BuildSelfSignedCert();
var expectedDer = Convert.FromBase64String(derBase64);
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
// Seed one trusted cert via the normal write path.
actor.Tell(new WriteCertToLocalStore(derBase64, thumbprint));
ExpectMsg<LocalCertOpAck>().Success.Should().BeTrue();
// Export — thumbprint + raw DER bytes come back for reconciliation.
actor.Tell(new ExportLocalTrustedCerts());
var export = ExpectMsg<LocalCertExport>();
export.Success.Should().BeTrue();
export.Error.Should().BeNull();
var entry = export.Certs.Should().ContainSingle().Subject;
entry.Thumbprint.Should().Be(thumbprint);
entry.DerBytes.Should().Equal(expectedDer);
}
[Fact]
public void Write_InvalidBase64_ReturnsFailureAck()
{
@@ -15,6 +15,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using System.Text.Json;
using System.Threading;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
@@ -232,6 +233,80 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("NewPump", response.InstanceUniqueName);
}
// ── S3: site-side compile failure rejects the deployment (no partial state) ──
private static string MakeConfigWithBrokenScriptJson(string instanceName) =>
JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes = [new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }],
Scripts = [new ResolvedScript { CanonicalName = "S1", Code = "this is not C# ~~~" }]
});
[Fact]
public async Task Deploy_WithNonCompilingScript_ReportsFailed_AndCreatesNoInstanceActor()
{
var actor = CreateDeploymentManager();
await Task.Delay(500); // wait for empty startup
actor.Tell(new DeployInstanceCommand(
"dep-bad", "bad-instance", "r1",
MakeConfigWithBrokenScriptJson("bad-instance"), "admin", DateTimeOffset.UtcNow));
var resp = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal(DeploymentStatus.Failed, resp.Status);
Assert.NotNull(resp.ErrorMessage);
Assert.Contains("compil", resp.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
// No partial state applied: instance actor must not exist …
Sys.ActorSelection(actor.Path / "bad-instance").Tell(new Identify(1));
Assert.Null(ExpectMsg<ActorIdentity>().Subject);
// … and no config must have been persisted.
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "bad-instance");
}
// ── S5: startup deployed-config load is retried on transient failure ──
[Fact]
public void StartupLoadFailure_IsRetried_UntilSuccess()
{
// The first startup load throws (transient SQLite failure); the second
// succeeds and returns one enabled config. The DM must retry rather than
// stall as a silent zero-instance node — so the Instance Actor eventually
// exists.
var calls = 0;
Func<Task<List<DeployedInstance>>> loader = () =>
Interlocked.Increment(ref calls) == 1
? Task.FromException<List<DeployedInstance>>(new IOException("db locked"))
: Task.FromResult(new List<DeployedInstance>
{
new()
{
InstanceUniqueName = "inst-1",
ConfigJson = MakeConfigJson("inst-1"),
DeploymentId = "d1",
RevisionHash = "h1",
IsEnabled = true
}
});
var dm = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, _compilationService, _sharedScriptLibrary, null,
new SiteRuntimeOptions(), NullLogger<DeploymentManagerActor>.Instance,
null, null, null, null, null, null,
TimeSpan.FromMilliseconds(200), loader)));
AwaitAssert(() =>
{
Assert.True(Volatile.Read(ref calls) >= 2,
$"expected the load to be retried; calls={Volatile.Read(ref calls)}");
Sys.ActorSelection(dm.Path / "inst-1").Tell(new Identify(1));
Assert.NotNull(ExpectMsg<ActorIdentity>(TimeSpan.FromSeconds(1)).Subject);
}, TimeSpan.FromSeconds(10));
}
// ── M1.6: site event log `deployment` category ─────────────────────────
[Fact]
@@ -0,0 +1,105 @@
using Akka.Actor;
using Akka.Cluster;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// UA1: cert-trust reconcile-on-join. When a site node (re)joins the cluster, the
/// Deployment Manager singleton reads its own trusted-peer store and pushes each
/// trusted certificate to the joining node's <see cref="CertStoreActor"/> so the
/// two per-node PKI stores converge — without this, a node that missed a trust
/// broadcast while it was down stays permanently divergent.
///
/// Needs a cluster provider (the DM subscribes to <c>MemberUp</c> in PreStart and
/// addresses the joined node via a remote actor path), so this lives in its own
/// cluster-enabled ActorSystem rather than the non-cluster DeploymentManagerActorTests.
/// </summary>
public class DeploymentManagerCertReconcileTests : TestKit, IDisposable
{
// In-memory TestTransport (not dot-netty): the cluster extension loads and
// SelfAddress is available, but no socket is bound and the node never has to
// form a real two-node cluster. Role must be "Site" (SiteClusterRole).
private const string ClusterConfig = @"
akka {
actor { provider = cluster }
remote {
enabled-transports = [""akka.remote.test""]
test {
transport-class = ""Akka.Remote.Transport.TestTransport, Akka.Remote""
applied-adapters = []
registry-key = dm-cert-reconcile-test
local-address = ""test://dm-cert@localhost:1""
maximum-payload-bytes = 128000b
scheme-identifier = test
}
}
cluster { roles = [""Site""] }
loglevel = WARNING
}";
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly string _dbFile;
public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile")
{
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-cert-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}", NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
/// <summary>Forwards every message it receives to a probe, preserving the original sender.</summary>
private sealed class ForwardingActor : ReceiveActor
{
public ForwardingActor(IActorRef target) => ReceiveAny(msg => target.Forward(msg));
}
private IActorRef CreateDeploymentManager() =>
ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, _compilationService, _sharedScriptLibrary,
null, new SiteRuntimeOptions(), NullLogger<DeploymentManagerActor>.Instance,
null, null, null, null, null, null, null, null)));
[Fact]
public void SiteNodeJoined_PushesLocalTrustedCertsToJoinedNode()
{
// Stand a forwarder at the well-known cert-store name so both the local
// export Ask and the remote-path write land on our probe (self address in
// this single-node test resolves the remote path back to the local actor).
var certStoreProbe = CreateTestProbe();
Sys.ActorOf(Props.Create(() => new ForwardingActor(certStoreProbe.Ref)), CertStoreActor.WellKnownName);
var dm = CreateDeploymentManager();
dm.Tell(new DeploymentManagerActor.SiteNodeJoined(Cluster.Get(Sys).SelfAddress));
// 1) The singleton reads its own trusted store.
certStoreProbe.ExpectMsg<ExportLocalTrustedCerts>();
// 2) Feed the export back; the singleton pushes each cert to the joined node.
dm.Tell(new LocalCertExport(true, null,
new[] { new ExportedCert("ABC123", new byte[] { 1, 2, 3 }) }));
var write = certStoreProbe.ExpectMsg<WriteCertToLocalStore>(TimeSpan.FromSeconds(5));
Assert.Equal("ABC123", write.Thumbprint);
Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), write.DerBase64);
}
}
@@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
@@ -45,7 +46,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private IActorRef CreateDeploymentManager(ISiteHealthCollector? healthCollector = null)
private IActorRef CreateDeploymentManager(
ISiteHealthCollector? healthCollector = null, IServiceProvider? serviceProvider = null)
{
return ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
@@ -57,9 +59,31 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
null,
null,
healthCollector,
null)));
serviceProvider)));
}
/// <summary>
/// Builds a config that deserializes and compiles cleanly (so it passes the deploy
/// compile gate) but whose script's canonical name contains a space — illegal as an
/// Akka actor name, so <c>Context.ActorOf(props, "script-dies on init")</c> throws
/// <c>InvalidActorNameException</c> during the Instance Actor's PreStart. The failure
/// surfaces as an <c>ActorInitializationException</c> the DM decider stops — the exact
/// "Instance Actor dies during init" S6 scenario.
/// </summary>
private static string CtorThrowingConfig(string instanceName) =>
JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
],
Scripts =
[
new ResolvedScript { CanonicalName = "dies on init", Code = "return 1;" }
]
});
/// <summary>
/// Minimal fake that records the most recent deployed-instance count.
/// </summary>
@@ -311,4 +335,63 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
// not a new instance, so the in-memory counter must not drift upward.
Assert.Equal(1, health.LastDeployedCount);
}
// ── S6: an Instance Actor that dies during init must not linger as a dead ref ──
[Fact]
public async Task InstanceActorInitFailure_OnStartup_EvictsDeadRefAndLogsSiteEvent()
{
// A stored config that compiles cleanly but whose Instance Actor cannot
// initialize (illegal child actor name → ActorInitializationException → Stop).
// The DM watches every Instance Actor, so on the unexpected Termination it must
// evict the dead ref from the routing map and log a `deployment` Error event
// rather than leaving a routing entry that points at a stopped actor (S6).
await _storage.StoreDeployedConfigAsync(
"poison", CtorThrowingConfig("poison"), "d1", "h1", true);
var siteLog = new FakeSiteEventLogger();
CreateDeploymentManager(serviceProvider: new SingleServiceProvider(siteLog));
AwaitAssert(() =>
{
Assert.Contains(siteLog.OfType("deployment"), r =>
r.Severity == "Error" &&
r.InstanceId == "poison" &&
r.Message.Contains("terminated unexpectedly", StringComparison.OrdinalIgnoreCase));
}, TimeSpan.FromSeconds(10));
}
[Fact]
public async Task Deploy_WhoseActorDiesDuringInit_ReportsFailed_NotSuccess()
{
// A deploy whose config compiles (passing the compile gate) but whose Instance
// Actor dies during init must report Failed — NOT the Success it used to report
// once SQLite persisted, leaving the deployer believing a dead instance is live
// (S6). The reply must be exactly-once.
var actor = CreateDeploymentManager();
await Task.Delay(500); // empty startup
actor.Tell(new DeployInstanceCommand(
"dep-dies", "dies-on-init", "r1",
CtorThrowingConfig("dies-on-init"), "admin", DateTimeOffset.UtcNow));
var resp = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal(DeploymentStatus.Failed, resp.Status);
// Exactly one reply — no late Success from the persistence path.
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// The optimistically-persisted config row is rolled back so a restart does not
// re-create the failing actor. The rollback is fire-and-forget once the store
// commits, so poll for it rather than reading once.
List<DeployedInstance> configs = [];
for (var i = 0; i < 50; i++)
{
configs = await _storage.GetAllDeployedConfigsAsync();
if (configs.All(c => c.InstanceUniqueName != "dies-on-init"))
break;
await Task.Delay(100);
}
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "dies-on-init");
}
}
@@ -371,4 +371,70 @@ public class ExecutionActorTests : TestKit, IDisposable
// Non-positive per-script timeout must be ignored; global (1s) must fire.
ExpectTerminated(exec, TimeSpan.FromSeconds(10));
}
// ── S2: stuck-script watchdog names the script holding a scheduler thread ──
/// <summary>
/// Compiles a raw script that can reach the test-assembly <see cref="StuckTestHooks"/>
/// (so a script body can block on a gate). Bypasses the trust validator, like
/// <see cref="CompileScript"/>.
/// </summary>
private static Script<object?> CompileRaw(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(StuckTestHooks).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
[Fact]
public void TimedOutScript_WithBlockedThread_EmitsStuckThreadSiteEvent()
{
// A script that blocks synchronously on a gate NEVER observes cooperative
// cancellation, so the CTS firing at the 1s timeout does not free its
// scheduler thread. After the 200ms grace the watchdog must name it loudly.
StuckTestHooks.Gate = new SemaphoreSlim(0);
var compiled = CompileRaw(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.StuckTestHooks.Gate.Wait(); return null;");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var siteLog = new FakeSiteEventLogger();
var options = new SiteRuntimeOptions { ScriptExecutionTimeoutSeconds = 1, StuckScriptGraceMs = 200 };
ActorOf(Props.Create(() => new ScriptExecutionActor(
"StuckScript", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, options,
replyTo.Ref, "corr-stuck", NullLogger.Instance,
ScriptScope.Root, null, new SingleServiceProvider(siteLog))));
try
{
AwaitAssert(() =>
{
var rows = siteLog.OfType("script");
Assert.Contains(rows, r =>
r.Severity == "Error" &&
r.Message.Contains("still executing", StringComparison.OrdinalIgnoreCase) &&
r.Message.Contains("StuckScript"));
}, TimeSpan.FromSeconds(10));
}
finally
{
// Free the blocked scheduler thread so the test run stays clean.
StuckTestHooks.Gate.Release();
}
}
}
/// <summary>
/// Test hook the stuck-script watchdog test uses to block a script-execution
/// thread deterministically: the compiled body waits on <see cref="Gate"/>, which
/// the test releases once it has observed the stuck-thread site event.
/// </summary>
public static class StuckTestHooks
{
/// <summary>Gate a blocking test script waits on; reset per test.</summary>
public static SemaphoreSlim Gate = new(0);
}
@@ -0,0 +1,145 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// PLAN-03 Task 18 (P2): the Instance Actor routes each attribute change only to the
/// children interested in it. A value-change / conditional script (and a monitored-attribute
/// alarm) subscribes to a single named attribute; an Expression-triggered script/alarm hears
/// every change (it keeps a full attribute snapshot). These tests pin the routing-map
/// construction directly — the behavioral gate inside each child stays as defense in depth.
/// </summary>
public class InstanceActorChildRoutingTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly string _dbFile;
public InstanceActorChildRoutingTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-routing-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions
{
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = 30
};
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private static FlattenedConfiguration ScriptsAB_and_Expression(string instanceName)
=> new()
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Double" },
new ResolvedAttribute { CanonicalName = "B", Value = "2", DataType = "Double" }
],
Scripts =
[
new ResolvedScript
{
CanonicalName = "S_A", Code = "return 1;",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"attributeName\":\"A\"}"
},
new ResolvedScript
{
CanonicalName = "S_B", Code = "return 2;",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"attributeName\":\"B\"}"
},
new ResolvedScript
{
CanonicalName = "S_expr", Code = "return 3;",
TriggerType = "Expression",
TriggerConfiguration = "{\"expression\":\"true\"}"
}
]
};
private InstanceActor BuildInstance(FlattenedConfiguration config)
{
var testRef = ActorOfAsTestActorRef<InstanceActor>(
Props.Create(() => new InstanceActor(
config.InstanceUniqueName,
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance)),
"instance-" + Guid.NewGuid().ToString("N"));
return testRef.UnderlyingActor;
}
[Fact]
public void RoutingMaps_AreBuiltFromTriggerConfigs()
{
var actor = BuildInstance(ScriptsAB_and_Expression("RoutePump"));
// Two value-change scripts route to their own attribute; the expression script
// routes to the all-changes bucket.
Assert.Single(actor.ChildrenByMonitoredAttribute["A"]);
Assert.Single(actor.ChildrenByMonitoredAttribute["B"]);
Assert.Single(actor.AllChangeChildren);
}
[Fact]
public void MonitoredAttributeAlarm_RoutesToItsAttribute_ExpressionAlarm_RoutesToAllChanges()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "AlarmRoutePump",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", Value = "10", DataType = "Double" }
],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "HiTemp",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"min\":0,\"max\":100}",
PriorityLevel = 1
},
new ResolvedAlarm
{
CanonicalName = "ExprAlarm",
TriggerType = "Expression",
TriggerConfiguration = "{\"expression\":\"true\"}",
PriorityLevel = 1
}
]
};
var actor = BuildInstance(config);
Assert.Single(actor.ChildrenByMonitoredAttribute["Temp"]);
Assert.Single(actor.AllChangeChildren); // the expression alarm
}
}
@@ -1171,4 +1171,69 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Single(overrides);
Assert.Equal("[\"a\"", overrides["Counts"]);
}
// ── S4/UA6: tag-subscription retry (failed AND lost responses) ──
/// <summary>
/// Builds an Instance Actor with a single data-sourced attribute and the given
/// options (so the retry interval is short), returning it plus the DCL probe.
/// The initial SubscribeTagsRequest is left in the probe's queue for the caller.
/// </summary>
private (IActorRef Instance, TestProbe Dcl) CreateInstanceWithDataSourcedAttribute(SiteRuntimeOptions options)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Sub-1",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Motor.Temp", Value = "0", DataType = "Int",
DataSourceReference = "ns=2;s=Motor.Temp", BoundDataConnectionName = "conn-1"
}
]
};
var dcl = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Sub-1",
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
options,
NullLogger<InstanceActor>.Instance,
dcl.Ref)));
return (actor, dcl);
}
[Fact]
public void FailedSubscribeResponse_IsRetried()
{
var options = new SiteRuntimeOptions { TagSubscribeRetryIntervalMs = 200 };
var (instance, dclProbe) = CreateInstanceWithDataSourcedAttribute(options);
var first = dclProbe.ExpectMsg<SubscribeTagsRequest>();
instance.Tell(new SubscribeTagsResponse(first.CorrelationId, first.InstanceUniqueName,
false, "Unknown connection: conn-1", DateTimeOffset.UtcNow), dclProbe.Ref);
var retry = dclProbe.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5)); // S4 core
Assert.Equal(first.ConnectionName, retry.ConnectionName);
Assert.Equal(first.TagPaths, retry.TagPaths);
}
[Fact]
public void LostSubscribeResponse_IsRetried_AndSuccessStopsRetrying()
{
var options = new SiteRuntimeOptions { TagSubscribeRetryIntervalMs = 200 };
var (instance, dclProbe) = CreateInstanceWithDataSourcedAttribute(options);
dclProbe.ExpectMsg<SubscribeTagsRequest>();
// No reply at all (dead-lettered response) -> must re-send.
var retry = dclProbe.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
instance.Tell(new SubscribeTagsResponse(retry.CorrelationId, retry.InstanceUniqueName,
true, null, DateTimeOffset.UtcNow), dclProbe.Ref);
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // retry timer cancelled
}
}
@@ -150,6 +150,76 @@ public class NativeAlarmActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
// ── PLAN-03 Task 20 (P4): coalesced batched persistence ─────────────────
private IActorRef SpawnWithFlush(IActorRef instanceActor, IActorRef dclManager, TimeSpan flushInterval) =>
ActorOf(Props.Create(() => new NativeAlarmActor(
Source(), "inst", instanceActor, dclManager, _storage, _options,
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, null, flushInterval)));
private static void DeliverLiveTransition(IActorRef actor, string sourceRef, int severity, DateTimeOffset time) =>
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
sourceRef, AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, severity), time)));
[Fact]
public async Task AlarmStorm_CoalescesPersistence_LatestPerSourceRefWins()
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = SpawnWithFlush(instance.Ref, dcl.Ref, TimeSpan.FromMilliseconds(100));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// 50 live transitions across 5 source refs (strictly increasing time per ref so none
// is dropped as stale) — the coalesced flush must collapse them to one row per ref.
var baseTime = DateTimeOffset.UtcNow;
for (var i = 0; i < 50; i++)
DeliverLiveTransition(actor, $"ref-{i % 5}", severity: i, baseTime.AddMilliseconds(i));
await Task.Delay(500); // > flush interval
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Equal(5, rows.Count);
// The LAST write for ref-4 carries severity 49 (i = 49, 49 % 5 == 4).
Assert.Contains(rows, r => r.ConditionJson.Contains("49"));
}
// ── PLAN-03 Task 21 (UA4): rehydration restores display metadata ────────
[Fact]
public async Task Rehydration_RestoresDisplayMetadata()
{
var instance1 = CreateTestProbe();
var dcl1 = CreateTestProbe();
var actor1 = SpawnWithFlush(instance1.Ref, dcl1.Ref, TimeSpan.FromMilliseconds(100));
dcl1.ExpectMsg<SubscribeAlarmsRequest>();
// A live raise carrying full display metadata.
actor1.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition(
"ref-1", "T01", "HighLevelAlarm", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800),
"Process", "desc", "Tank overflow", "", "", null, DateTimeOffset.UtcNow, "92", "90")));
instance1.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Wait for the coalesced flush to land the metadata in SQLite.
await AwaitAssertAsync(async () =>
{
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Contains(rows, r => r.MetadataJson != null && r.MetadataJson.Contains("HighLevelAlarm"));
}, TimeSpan.FromSeconds(3));
// A fresh actor on the SAME storage rehydrates in PreStart and re-emits the condition
// with the restored metadata instead of empty strings.
var instance2 = CreateTestProbe();
var dcl2 = CreateTestProbe();
SpawnWithFlush(instance2.Ref, dcl2.Ref, TimeSpan.FromMilliseconds(100));
var emitted = instance2.FishForMessage<AlarmStateChanged>(m => m.SourceReference == "ref-1", TimeSpan.FromSeconds(5));
Assert.Equal("HighLevelAlarm", emitted.AlarmTypeName);
Assert.Equal("Process", emitted.Category);
Assert.Equal("Tank overflow", emitted.Message);
}
// ── M1.5: site event log `alarm` category ──────────────────────────────
[Fact]
@@ -378,4 +448,18 @@ public class NativeAlarmActorTests : TestKit, IDisposable
File.Delete(_dbFile);
}
}
[Fact]
public void LostSubscribeResponse_ResendsSubscribe()
{
// S4: a LOST subscribe response (no reply at all) must be re-sent, not only
// a failure reply. The response-timeout arm fires at 2× the retry interval.
var options = new SiteRuntimeOptions { NativeAlarmRetryIntervalMs = 200 };
var dcl = CreateTestProbe();
Spawn(CreateTestProbe().Ref, dcl.Ref, options);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// Swallow the response entirely — the actor must still re-subscribe.
dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(5));
}
}
@@ -1,3 +1,4 @@
using System.Globalization;
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
@@ -300,9 +301,76 @@ public class ScriptActorTests : TestKit, IDisposable
private AttributeValueChanged Change(string attribute, object? value) =>
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
[Fact]
public void ConditionalTrigger_NonNumericFallback_IsCultureInvariant()
{
// EvaluateCondition runs on a dispatcher thread in the live path, whose
// CurrentCulture a test cannot deterministically set — so the culture-
// invariance of the string fallback is verified by calling the pure helper
// directly under a forced de-DE culture.
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE"); // 1234.5 -> "1.234,5"
// "1.234,5" is the de-DE rendering of 1234.5: it FAILS the invariant
// numeric parse (decimal point then group separator) and falls through to
// the string comparison. The old fallback compared against the culture-
// sensitive Threshold.ToString() ("1.234,5" under de-DE) and matched (fired);
// the invariant fallback compares against "1234.5" and must NOT match.
var config = new ConditionalTriggerConfig("Temp", "==", 1234.5, TriggerMode.OnTrue);
Assert.False(ScriptActor.EvaluateCondition(config, "1.234,5"));
// Sanity: a value that genuinely equals the invariant rendering still matches.
Assert.True(ScriptActor.EvaluateCondition(
new ConditionalTriggerConfig("Temp", "==", 1234.5, TriggerMode.OnTrue), "1234.5"));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
private Script<object?> CompileTriggerExpression(string expression) =>
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
/// <summary>
/// Compiles a trigger expression WITHOUT the trust validator so a test can use
/// <c>System.Threading.Thread</c> (forbidden in real scripts) to observe which
/// thread the evaluation runs on. Mirrors the real compile against
/// <see cref="TriggerExpressionGlobals"/>.
/// </summary>
private static Script<object?> CompileRawTriggerExpression(string expression)
{
var opts = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var s = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
s.Compile();
return s;
}
[Fact]
public void ExpressionTrigger_EvaluatesOnScriptSchedulerThread_AndStillFires()
{
// The expression is TRUE only when evaluated on a script-execution thread.
// Before P1 it ran synchronously on the actor's dispatcher thread (name is
// NOT "script-execution-*") → false → no fire. After P1 it runs on the
// script scheduler → true → fire.
var expr = CompileRawTriggerExpression(
"System.Threading.Thread.CurrentThread.Name != null && " +
"System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
var (actor, instance) = CreateTriggeredActor(
"ExprThread",
"Expression",
"{\"expression\":\"true\",\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null,
expr);
actor.Tell(Change("Any", "1"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off-dispatcher
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
{
@@ -3,11 +3,13 @@ using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
@@ -78,6 +80,39 @@ akka {
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, fetcher)));
private IActorRef CreateReplicationActor(
IDeploymentConfigFetcher fetcher, SiteRuntimeOptions options, TimeSpan retryDelay) =>
ActorOf(Props.Create(() => new SiteReplicationActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, fetcher, null, options, retryDelay)));
[Fact]
public async Task ReplicatedFetch_RetriesUpToConfigFetchRetryCount()
{
// The first two fetches fail transiently; the third succeeds. With
// ConfigFetchRetryCount = 3 the standby must retry to the third attempt and
// then guarded-write the fetched config (a short retry delay keeps the test fast).
var attempts = 0;
var fetcher = new FakeConfigFetcher(_ =>
Interlocked.Increment(ref attempts) < 3
? Task.FromException<string>(new InvalidOperationException("central hiccup"))
: Task.FromResult("{\"instanceUniqueName\":\"RetryPump\"}"));
var actor = CreateReplicationActor(
fetcher, new SiteRuntimeOptions { ConfigFetchRetryCount = 3 },
TimeSpan.FromMilliseconds(50));
actor.Tell(new ApplyConfigDeploy(
"RetryPump", "dep-r1", "sha256:r1", true,
"http://central:9000", "tok-r1"));
await AwaitAssertAsync(async () =>
{
Assert.Equal(3, Volatile.Read(ref attempts));
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs, c => c.InstanceUniqueName == "RetryPump");
}, TimeSpan.FromSeconds(10));
}
[Fact]
public async Task ApplyConfigDeploy_StandbyFetchesConfigAndGuardedWrites()
{
@@ -184,6 +219,107 @@ akka {
Assert.Equal("tok-abc", applied.FetchToken);
}
// ── Task 21: peer-join S&F buffer resync (anti-entropy) ──
[Fact]
public void StandbyTrackingPeer_SendsResyncRequest()
{
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => false)));
actor.Tell(new TriggerPeerTracked()); // stands in for TryTrackPeer's MemberUp path
probe.ExpectMsg<RequestSfBufferResync>(TimeSpan.FromSeconds(3));
}
[Fact]
public void ActiveTrackingPeer_DoesNotRequestResync()
{
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => true)));
actor.Tell(new TriggerPeerTracked());
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // active node never requests a resync
}
[Fact]
public async Task ActiveNode_AnswersResyncRequest_WithFullBufferSnapshot()
{
await _sfStorage.EnqueueAsync(NewSfMessage("m1"));
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => true)));
actor.Tell(new RequestSfBufferResync(), TestActor);
var snapshot = ExpectMsg<SfBufferSnapshot>(TimeSpan.FromSeconds(3));
Assert.Single(snapshot.Messages);
Assert.False(snapshot.Truncated);
}
[Fact]
public async Task StandbyNode_AppliesSnapshot_ReplacingItsBuffer()
{
await _sfStorage.EnqueueAsync(NewSfMessage("stale"));
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => false)));
actor.Tell(new SfBufferSnapshot(new List<StoreAndForwardMessage> { NewSfMessage("fresh") }, false));
await AwaitAssertAsync(async () =>
{
Assert.Null(await _sfStorage.GetMessageByIdAsync("stale"));
Assert.NotNull(await _sfStorage.GetMessageByIdAsync("fresh"));
}, TimeSpan.FromSeconds(5));
}
private static StoreAndForwardMessage NewSfMessage(string id) => new()
{
Id = id,
Category = StoreAndForwardCategory.ExternalSystem,
Target = "t",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 30000,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
};
/// <summary>Test message: drives <see cref="SiteReplicationActor.OnPeerTracked"/> directly,
/// standing in for the MemberUp→TryTrackPeer path (a single-node TestKit cannot form a real peer).</summary>
private sealed record TriggerPeerTracked;
/// <summary>
/// Test subclass for the resync tests: captures peer sends to a probe, injects the
/// active-node check, and exposes <see cref="OnPeerTracked"/> via a test message.
/// </summary>
private sealed class ResyncTestActor : SiteReplicationActor
{
private readonly IActorRef _peerProbe;
public ResyncTestActor(
SiteStorageService storage, StoreAndForwardStorage sfStorage,
ReplicationService replicationService, string siteRole,
ILogger<SiteReplicationActor> logger, IActorRef peerProbe, Func<bool> isActive)
: base(storage, sfStorage, replicationService, siteRole, logger,
configFetcher: null, isActiveOverride: isActive)
{
_peerProbe = peerProbe;
Receive<TriggerPeerTracked>(_ => OnPeerTracked());
}
protected override void SendToPeer(object message) => _peerProbe.Tell(message, Self);
}
/// <summary>
/// Test subclass exposing the peer send: <see cref="SiteReplicationActor.SendToPeer"/> is
/// overridden to forward to a probe so the outbound mapping can be asserted without a real
@@ -0,0 +1,102 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment;
/// <summary>
/// Site Runtime S3: the pure deploy-time compile validator. It mirrors the
/// compile work <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.InstanceActor"/>
/// does at runtime (scripts, on-trigger scripts, Expression triggers) so the
/// Deployment Manager can reject a non-compiling deployment before any actor is
/// created — WITHOUT touching actor state (safe on a Task.Run thread).
/// </summary>
public class DeployCompileValidatorTests
{
private static DeployCompileValidator NewValidator() =>
new(new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance));
private static string ConfigWith(string scriptCode) => JsonSerializer.Serialize(new FlattenedConfiguration
{
Scripts = new List<ResolvedScript>
{
new() { CanonicalName = "S1", Code = scriptCode, TriggerType = "Call" }
},
Attributes = new List<ResolvedAttribute>(),
Alarms = new List<ResolvedAlarm>(),
NativeAlarmSources = new List<ResolvedNativeAlarmSource>()
});
[Fact]
public void BrokenScript_ReturnsErrors()
{
var errors = NewValidator().Validate(ConfigWith("this is not C# ~~~"));
Assert.NotEmpty(errors);
Assert.Contains(errors, e => e.Contains("S1"));
}
[Fact]
public void ValidScript_ReturnsNoErrors()
{
Assert.Empty(NewValidator().Validate(ConfigWith("return 1 + 1;")));
}
[Fact]
public void BrokenTriggerExpression_ReturnsErrors()
{
// One Expression-triggered script whose trigger expression "1 +" does not compile.
var cfg = JsonSerializer.Serialize(new FlattenedConfiguration
{
Scripts = new List<ResolvedScript>
{
new()
{
CanonicalName = "S1",
Code = "return 1;",
TriggerType = "Expression",
TriggerConfiguration = JsonSerializer.Serialize(new { expression = "1 +" })
}
},
Attributes = new List<ResolvedAttribute>(),
Alarms = new List<ResolvedAlarm>(),
NativeAlarmSources = new List<ResolvedNativeAlarmSource>()
});
var errors = NewValidator().Validate(cfg);
Assert.NotEmpty(errors);
Assert.Contains(errors, e => e.Contains("script-trigger-S1"));
}
[Fact]
public void MalformedJson_ReturnsSingleDeserializeError()
{
var errors = NewValidator().Validate("{ this is not json");
Assert.Single(errors);
Assert.Contains("deserialize", errors[0], StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MissingOnTriggerScript_ReturnsError()
{
var cfg = JsonSerializer.Serialize(new FlattenedConfiguration
{
Scripts = new List<ResolvedScript>(),
Attributes = new List<ResolvedAttribute>(),
Alarms = new List<ResolvedAlarm>
{
new()
{
CanonicalName = "A1",
TriggerType = "HiLo",
OnTriggerScriptCanonicalName = "DoesNotExist"
}
},
NativeAlarmSources = new List<ResolvedNativeAlarmSource>()
});
var errors = NewValidator().Validate(cfg);
Assert.Contains(errors, e => e.Contains("A1") && e.Contains("DoesNotExist"));
}
}
@@ -42,6 +42,19 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
await _storage.InitializeAsync();
}
[Fact]
public async Task Initialize_EnablesWalJournalMode()
{
// WAL is set once at InitializeAsync (persistent, database-level). A file-backed DB
// is required — WAL is not available for :memory: databases.
await using var conn = _storage.CreateConnection();
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode;";
var mode = (string)(await cmd.ExecuteScalarAsync())!;
Assert.Equal("wal", mode.ToLowerInvariant());
}
[Fact]
public async Task StoreAndRetrieve_DeployedConfig_RoundTrips()
{
@@ -317,4 +317,27 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
// Not at central, not in the site buffer → genuinely unknown, NOT Forwarding.
Assert.Equal("Unknown", status.Status);
}
[Fact]
public async Task NotifySend_BuffersUnbounded_AndNeverInvokesForwarderInline()
{
// Task 13: Notify.Send must buffer with maxRetries: 0 (never park for retry
// exhaustion) and deferToSweep: true (never run the forwarder's central Ask
// inline on the scarce script thread). A central outage must return in
// milliseconds, and the row must not strand behind operator unparking.
var inlineInvoked = false;
_saf.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => { inlineInvoked = true; throw new TimeoutException("central down"); });
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var notificationId = await notify.To("ops-list").Send("subject", "hello");
Assert.False(inlineInvoked); // pre-fix: Send ran the forwarder Ask inline
var row = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(row);
Assert.Equal(0, row!.MaxRetries); // pre-fix: DefaultMaxRetries → parked after a bounded outage
Assert.Equal(StoreAndForwardMessageStatus.Pending, row.Status);
}
}
@@ -44,4 +44,37 @@ public class ScriptExecutionSchedulerTests
var b = ScriptExecutionScheduler.Shared(options);
Assert.Same(a, b);
}
// SiteRuntime-S2/UA5: the scheduler exposes observability gauges so a stuck /
// saturated script-execution pool is visible to operators via site health.
[Fact]
public async Task Gauges_ReflectBusyThreadAndQueueDepth()
{
using var scheduler = new ScriptExecutionScheduler(1);
using var gate = new ManualResetEventSlim(false);
var blocking = Task.Factory.StartNew(() => gate.Wait(),
CancellationToken.None, TaskCreationOptions.None, scheduler);
var queued = Task.Factory.StartNew(() => { },
CancellationToken.None, TaskCreationOptions.None, scheduler);
await WaitUntilAsync(() => scheduler.BusyThreadCount == 1);
Assert.Equal(1, scheduler.QueueDepth); // second task waiting
// A busy thread reports a non-null age. Not asserting a strictly-positive
// value: Environment.TickCount64 has ~15 ms granularity, so under load the
// busy-timestamp and this read can fall in the same tick (age == Zero).
Assert.NotNull(scheduler.OldestBusyAge);
gate.Set();
await Task.WhenAll(blocking, queued);
await WaitUntilAsync(() => scheduler.BusyThreadCount == 0);
Assert.Null(scheduler.OldestBusyAge);
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
for (var i = 0; i < 100 && !condition(); i++)
await Task.Delay(50);
Assert.True(condition(), "condition not met within timeout");
}
}
@@ -0,0 +1,55 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// SiteRuntime-S2/UA5: the hosted reporter must lift the process-wide
/// <see cref="ScriptExecutionScheduler"/> gauges onto the site health report via
/// <see cref="ISiteHealthCollector"/>. Uses the real collector (NSubstitute is not
/// referenced by this test project) and drives the shared scheduler busy so the
/// pushed value is distinguishable from the idle default.
/// </summary>
public class ScriptSchedulerStatsReporterTests
{
[Fact]
public async Task Reporter_PushesSchedulerStatsToCollector()
{
var options = new SiteRuntimeOptions { ScriptExecutionThreadCount = 1 };
var collector = new SiteHealthCollector();
var scheduler = ScriptExecutionScheduler.Shared(options);
using var gate = new ManualResetEventSlim(false);
// Occupy a worker thread so BusyThreadCount is a non-zero, distinguishable value.
var blocking = Task.Factory.StartNew(() => gate.Wait(),
CancellationToken.None, TaskCreationOptions.None, scheduler);
await WaitUntilAsync(() => scheduler.BusyThreadCount >= 1);
using var reporter = new ScriptSchedulerStatsReporter(
collector, options, NullLogger<ScriptSchedulerStatsReporter>.Instance,
pollInterval: TimeSpan.FromMilliseconds(50));
await reporter.StartAsync(CancellationToken.None);
try
{
await WaitUntilAsync(() => collector.CollectReport("site-1").ScriptBusyThreads >= 1);
var report = collector.CollectReport("site-1");
Assert.True(report.ScriptBusyThreads >= 1);
Assert.NotNull(report.ScriptOldestBusyAgeSeconds); // a script is in flight
}
finally
{
gate.Set();
await blocking;
await reporter.StopAsync(CancellationToken.None);
}
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
for (var i = 0; i < 100 && !condition(); i++)
await Task.Delay(50);
Assert.True(condition(), "condition not met within timeout");
}
}
@@ -681,4 +681,43 @@ public class OperationTrackingStoreTests
// And a third async — also a no-op.
await store.DisposeAsync();
}
// ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ──
[Fact]
public async Task ReadChangedSince_BoundaryTimestamp_IsInclusive_UpsertOnNewerAbsorbsDuplicates()
{
// Two rows share one UpdatedAtUtc; a pull whose sinceUtc equals that instant
// MUST return both (inclusive >= read). Central ingest is insert-if-not-exists
// + upsert-on-newer, so re-reading the boundary row is a safe no-op — but an
// exclusive (>) read would silently and permanently drop the same-timestamp
// row. This test pins the >= contract.
var (store, dataSource) = CreateStore(
nameof(ReadChangedSince_BoundaryTimestamp_IsInclusive_UpsertOnNewerAbsorbsDuplicates));
await using var _store = store;
await store.RecordEnqueueAsync(TrackedOperationId.New(),
kind: nameof(AuditKind.ApiCallCached), targetSummary: "ERP.A",
sourceInstanceId: "I", sourceScript: "S", sourceNode: "node-a");
await store.RecordEnqueueAsync(TrackedOperationId.New(),
kind: nameof(AuditKind.ApiCallCached), targetSummary: "ERP.B",
sourceInstanceId: "I", sourceScript: "S", sourceNode: "node-a");
// Force both rows to share one exact UpdatedAtUtc instant, formatted the same
// way the read normalises the cursor ('Z'-suffixed round-trip "o").
var boundary = DateTime.UtcNow;
var boundaryText = DateTime.SpecifyKind(boundary, DateTimeKind.Utc)
.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
using (var conn = OpenVerifierConnection(dataSource))
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "UPDATE OperationTracking SET UpdatedAtUtc = $ts";
cmd.Parameters.AddWithValue("$ts", boundaryText);
cmd.ExecuteNonQuery();
}
var rows = await store.ReadChangedSinceAsync(boundary, batchSize: 10);
Assert.Equal(2, rows.Count);
}
}
@@ -452,4 +452,69 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
var msg = await _storage.GetMessageByIdAsync(trackedId.ToString());
Assert.Null(msg);
}
// ── Task 19: audit-observer decoupled from the sweep via an ordered channel ──
private sealed class DelegatingObserver : ICachedCallLifecycleObserver
{
private readonly Func<CachedCallAttemptContext, Task> _onNotify;
public DelegatingObserver(Func<CachedCallAttemptContext, Task> onNotify) => _onNotify = onNotify;
public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
=> _onNotify(context);
}
[Fact]
public async Task SlowObserver_DoesNotBlockTheSweep_AndEventsArriveInOrder()
{
var gate = new TaskCompletionSource();
var observed = new List<CachedCallAttemptOutcome>();
var observer = new DelegatingObserver(async ctx =>
{
await gate.Task; // first call blocks until released
lock (observed) observed.Add(ctx.Outcome);
});
// Fresh service over its own storage so StartAsync's pump/timer is isolated
// from the shared _service.
var connStr = $"Data Source=SlowObs_{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
using var keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var service = new StoreAndForwardService(
storage,
new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromHours(1), // timer never fires in-test
},
NullLogger<StoreAndForwardService>.Instance,
replication: null,
cachedCallObserver: observer,
siteId: "site-77");
await service.StartAsync();
try
{
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero,
messageId: TrackedOperationId.New().ToString());
var sweep = service.RetryPendingMessagesAsync();
await sweep.WaitAsync(TimeSpan.FromSeconds(5)); // pre-fix: blocks on gate → times out
gate.SetResult();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline)
{
lock (observed) if (observed.Count == 1) break;
await Task.Delay(10);
}
lock (observed) Assert.Single(observed);
}
finally { await service.StopAsync(); }
}
}
@@ -199,16 +199,13 @@ public class NotificationForwarderTests : TestKit
}
[Fact]
public async Task Deliver_CorruptJsonPayload_ReturnsTrue_AndDoesNotForwardAnything()
public async Task Deliver_CorruptJsonPayload_ReturnsFalse_ParksForForensics_AndDoesNotForward()
{
// Regression test for StoreAndForward-018. The design doc forbids parking
// notifications ("notifications do not park — they are retried at the fixed
// forward interval until central acks"; Component-StoreAndForward.md). The
// previous implementation returned false on a corrupt payload, which the S&F
// engine interprets as a permanent failure and parks the row — contradicting
// the invariant. The fix: discard a corrupt buffered notification by
// returning true (engine clears the buffer via its normal success path),
// with a Warning log line carrying the row id and a payload preview.
// Supersedes StoreAndForward-018 (discard-on-corrupt). Returning false is the
// handler contract's permanent-failure signal: the engine parks the row, which
// preserves the payload for operator forensics. Retrying a corrupt payload is
// pointless; deleting it silently (and reporting Delivered) loses data with no
// parked row, no central Notifications row, and no audit trail.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
@@ -222,7 +219,7 @@ public class NotificationForwarderTests : TestKit
OriginInstanceName = "Plant.Pump3",
};
Assert.True(await forwarder.DeliverAsync(corrupt));
Assert.False(await forwarder.DeliverAsync(corrupt));
// The corrupt-payload path must NOT round-trip to central — no
// NotificationSubmit / no Ask. ExpectNoMsg confirms nothing was forwarded.
@@ -230,11 +227,11 @@ public class NotificationForwarderTests : TestKit
}
[Fact]
public async Task Deliver_NullDeserializedPayload_ReturnsTrue_AndDoesNotForwardAnything()
public async Task Deliver_NullDeserializedPayload_ReturnsFalse_ParksForForensics_AndDoesNotForward()
{
// The companion case to corrupt JSON: the payload is valid JSON but
// deserialises to null (e.g. "null"). Same treatment per StoreAndForward-018
// — discard rather than park.
// deserialises to null (e.g. "null"). Same treatment — park (return false)
// rather than silently discard-as-delivered. Supersedes StoreAndForward-018.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
@@ -248,7 +245,7 @@ public class NotificationForwarderTests : TestKit
OriginInstanceName = "Plant.Pump3",
};
Assert.True(await forwarder.DeliverAsync(nullPayload));
Assert.False(await forwarder.DeliverAsync(nullPayload));
centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
}
@@ -1,4 +1,5 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -163,6 +164,47 @@ public class ReplicationServiceTests : IAsyncLifetime, IDisposable
// No exception -- fire-and-forget, best-effort
}
// ── Task 10 (arch review 02): ordered, observable replication dispatch ──
[Fact]
public void ReplicationOperations_AreDispatchedInIssueOrder()
{
var seen = new List<(ReplicationOperationType, string)>();
_replicationService.SetReplicationHandler(op =>
{
seen.Add((op.OperationType, op.MessageId));
return Task.CompletedTask;
});
for (var i = 0; i < 200; i++)
{
_replicationService.ReplicateEnqueue(CreateMessage($"m{i}"));
_replicationService.ReplicateRemove($"m{i}");
}
// Inline dispatch: by the time the calls return, every op was handed to the
// handler, Add strictly before Remove per id. Pre-fix (Task.Run) this was
// racy in both count and order.
Assert.Equal(400, seen.Count);
for (var i = 0; i < 200; i++)
{
Assert.Equal((ReplicationOperationType.Add, $"m{i}"), seen[2 * i]);
Assert.Equal((ReplicationOperationType.Remove, $"m{i}"), seen[2 * i + 1]);
}
}
[Fact]
public void ReplicationHandlerThrow_IsSwallowed_AndLoggedAtWarning()
{
var logger = new CapturingLogger<ReplicationService>();
var service = new ReplicationService(new StoreAndForwardOptions(), logger);
service.SetReplicationHandler(_ => throw new InvalidOperationException("peer gone"));
service.ReplicateRemove("m1"); // must not throw
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
}
private static StoreAndForwardMessage CreateMessage(string id)
{
return new StoreAndForwardMessage
@@ -178,4 +220,15 @@ public class ReplicationServiceTests : IAsyncLifetime, IDisposable
Status = StoreAndForwardMessageStatus.Pending
};
}
/// <summary>Minimal in-memory logger that records level + rendered message.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
}
}
@@ -0,0 +1,82 @@
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// Characterization pin for the intra-cluster S&amp;F replication contract. A
/// <see cref="ReplicationOperation"/> (carrying a full <see cref="StoreAndForwardMessage"/>)
/// is Told from the active node to the standby node's replication actor and rides
/// the Akka default reflective-JSON wire format. Nothing tested that it round-trips
/// or that its type identity is stable — a rename/move, a dropped setter, or a
/// non-default-constructible message would silently break standby buffer sync and
/// only surface as a divergent buffer after a failover.
///
/// The serializer swap (proto / explicit bindings) is deferred; until then this pin
/// is the guardrail.
/// </summary>
public class ReplicationWireSerializationPinTests : TestKit
{
private T RoundTrip<T>(T message)
{
var serialization = Sys.Serialization;
var serializer = serialization.FindSerializerFor(message);
var bytes = serializer.ToBinary(message);
return (T)serialization.Deserialize(bytes, serializer.Identifier, message!.GetType());
}
[Fact]
public void ReplicationOperation_WithFullMessage_RoundTripsOnTheWire()
{
var message = new StoreAndForwardMessage
{
Id = Guid.NewGuid().ToString("N"),
Category = StoreAndForwardCategory.Notification,
Target = "Operators",
PayloadJson = "{\"notificationId\":\"abc\"}",
RetryCount = 4,
MaxRetries = 0,
RetryIntervalMs = 30000,
CreatedAt = DateTimeOffset.UtcNow,
LastAttemptAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Parked,
LastError = "central rejected",
OriginInstanceName = "Plant.Pump3",
ExecutionId = Guid.NewGuid(),
SourceScript = "ScriptActor:MonitorSpeed",
ParentExecutionId = Guid.NewGuid(),
};
var original = new ReplicationOperation(ReplicationOperationType.Park, message.Id, message);
var back = RoundTrip(original);
Assert.Equal(original.OperationType, back.OperationType);
Assert.Equal(original.MessageId, back.MessageId);
Assert.NotNull(back.Message);
var m = back.Message!;
Assert.Equal(message.Id, m.Id);
Assert.Equal(message.Category, m.Category);
Assert.Equal(message.Target, m.Target);
Assert.Equal(message.PayloadJson, m.PayloadJson);
Assert.Equal(message.RetryCount, m.RetryCount);
Assert.Equal(message.MaxRetries, m.MaxRetries);
Assert.Equal(message.RetryIntervalMs, m.RetryIntervalMs);
Assert.Equal(message.CreatedAt, m.CreatedAt);
Assert.Equal(message.LastAttemptAt, m.LastAttemptAt);
Assert.Equal(message.Status, m.Status);
Assert.Equal(message.LastError, m.LastError);
Assert.Equal(message.OriginInstanceName, m.OriginInstanceName);
Assert.Equal(message.ExecutionId, m.ExecutionId);
Assert.Equal(message.SourceScript, m.SourceScript);
Assert.Equal(message.ParentExecutionId, m.ParentExecutionId);
}
// Type-identity pins: the reflective-JSON wire embeds CLR type manifests, so a
// rename/move of either type silently breaks standby replication across a
// rolling upgrade. If one fails, you are making a wire-breaking change.
[Theory]
[InlineData(typeof(ReplicationOperation), "ZB.MOM.WW.ScadaBridge.StoreAndForward.ReplicationOperation")]
[InlineData(typeof(StoreAndForwardMessage), "ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardMessage")]
public void ReplicationContract_TypeIdentity_IsPinned(Type type, string expectedFullName) =>
Assert.Equal(expectedFullName, type.FullName);
}
@@ -34,4 +34,8 @@ public class StoreAndForwardOptionsTests
Assert.Equal(TimeSpan.FromMinutes(5), options.DefaultRetryInterval);
Assert.Equal(100, options.DefaultMaxRetries);
}
[Fact]
public void SweepBatchLimit_Defaults_To_500() =>
Assert.Equal(500, new StoreAndForwardOptions().SweepBatchLimit);
}
@@ -203,4 +203,61 @@ public class StoreAndForwardReplicationTests : IAsyncLifetime, IDisposable
Assert.Equal(StoreAndForwardMessageStatus.Pending, row!.Status);
Assert.Equal(0, row.RetryCount);
}
private static ReplicationService NewReplicationService() =>
new(new StoreAndForwardOptions { ReplicationEnabled = true },
NullLogger<ReplicationService>.Instance);
private static StoreAndForwardMessage NewMessage(string id) => new()
{
Id = id,
Category = StoreAndForwardCategory.ExternalSystem,
Target = "api",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 1,
RetryIntervalMs = 0,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
};
/// <summary>
/// A Park (or Requeue) whose original Add was lost — e.g. the Add's
/// fire-and-forget replication dropped — must still materialise the row on the
/// standby. The full message rides in the operation, so an upsert self-heals;
/// a blind UPDATE would affect 0 rows and the row would be gone forever.
/// </summary>
[Fact]
public async Task ApplyReplicatedPark_WhenAddWasLost_MaterializesTheParkedRow()
{
var service = NewReplicationService();
var msg = NewMessage("lost-add-1"); // never Added on this (standby) storage
msg.Status = StoreAndForwardMessageStatus.Parked;
await service.ApplyReplicatedOperationAsync(
new ReplicationOperation(ReplicationOperationType.Park, msg.Id, msg), _storage);
var row = await _storage.GetMessageByIdAsync("lost-add-1");
Assert.NotNull(row); // pre-fix: blind UPDATE affected 0 rows, row is gone forever
Assert.Equal(StoreAndForwardMessageStatus.Parked, row!.Status);
}
/// <summary>
/// A duplicate Add (e.g. after Task 21's anti-entropy resync re-issues an Add
/// the standby already holds) must not violate the PK — the upsert applies
/// newest-wins instead of throwing.
/// </summary>
[Fact]
public async Task ApplyReplicatedAdd_Twice_IsIdempotent_NewestWins()
{
var service = NewReplicationService();
var msg = NewMessage("dup-add");
await service.ApplyReplicatedOperationAsync(
new ReplicationOperation(ReplicationOperationType.Add, msg.Id, msg), _storage);
msg.RetryCount = 3;
await service.ApplyReplicatedOperationAsync( // pre-fix: SqliteException PK violation
new ReplicationOperation(ReplicationOperationType.Add, msg.Id, msg), _storage);
Assert.Equal(3, (await _storage.GetMessageByIdAsync("dup-add"))!.RetryCount);
}
}
@@ -13,6 +13,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
private readonly StoreAndForwardOptions _options;
private readonly List<SqliteConnection> _extraKeepAlives = new();
public StoreAndForwardServiceTests()
{
@@ -38,7 +39,35 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose() => _keepAlive.Dispose();
public void Dispose()
{
_keepAlive.Dispose();
foreach (var c in _extraKeepAlives) c.Dispose();
}
/// <summary>
/// Builds a fresh service over its own in-memory (shared-cache) SQLite so a test
/// can call StartAsync without racing the shared <c>_service</c>'s timer. The
/// keep-alive connection is tracked for disposal.
/// </summary>
private StoreAndForwardService CreateService(TimeSpan? retryTimerInterval = null)
{
var connStr = $"Data Source=DeferTests_{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
var keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
_extraKeepAlives.Add(keepAlive);
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
storage.InitializeAsync().GetAwaiter().GetResult();
var options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = retryTimerInterval ?? TimeSpan.FromMinutes(10),
};
return new StoreAndForwardService(storage, options, NullLogger<StoreAndForwardService>.Instance);
}
// ── WP-10: Immediate delivery ──
@@ -582,4 +611,214 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.True(handlerCompleted,
"Sweep handler must have finished before StopAsync returned.");
}
// ── Task 3 (arch review 02, Stability #2): active-node delivery gate ──
// The standby site node applies replicated buffer operations but must never
// deliver; the sweep runs only when the gate reports the node is active.
[Fact]
public async Task RetrySweep_SkipsDelivery_WhenDeliveryGateReportsStandby()
{
var delivered = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => { delivered++; return Task.FromResult(true); });
_service.SetDeliveryGate(() => false); // standby
var result = await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "target-x", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
await _service.RetryPendingMessagesAsync();
Assert.Equal(0, delivered);
var row = await _service.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(row);
Assert.Equal(StoreAndForwardMessageStatus.Pending, row!.Status); // row untouched
}
[Fact]
public async Task RetrySweep_ResumesDelivery_WhenGateFlipsActive()
{
var delivered = 0;
var active = false;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => { delivered++; return Task.FromResult(true); });
_service.SetDeliveryGate(() => active);
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "target-x", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
await _service.RetryPendingMessagesAsync();
Assert.Equal(0, delivered);
active = true; // failover: this node became active
await _service.RetryPendingMessagesAsync();
Assert.Equal(1, delivered);
}
[Fact]
public async Task RetrySweep_TreatsThrowingGateAsStandby()
{
var delivered = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => { delivered++; return Task.FromResult(true); });
_service.SetDeliveryGate(() => throw new InvalidOperationException("cluster not ready"));
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
await _service.RetryPendingMessagesAsync(); // must not throw
Assert.Equal(0, delivered);
}
// ── Task 8 (arch review 02, Performance): per-target short-circuit ──
// After the first transient failure to a (category, target) the remaining
// messages for that pair are skipped this sweep — N dead-target messages
// cost one timeout, not N. Skipped rows keep their RetryCount.
[Fact]
public async Task RetrySweep_ShortCircuitsTarget_AfterFirstTransientFailure()
{
var attemptsByTarget = new Dictionary<string, int>();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, msg =>
{
attemptsByTarget[msg.Target] = attemptsByTarget.GetValueOrDefault(msg.Target) + 1;
if (msg.Target == "dead-target") throw new TimeoutException("down");
return Task.FromResult(true);
});
for (var i = 0; i < 3; i++)
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "dead-target", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "healthy-target", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
await _service.RetryPendingMessagesAsync();
Assert.Equal(1, attemptsByTarget["dead-target"]); // pre-fix: 3
Assert.Equal(1, attemptsByTarget["healthy-target"]); // healthy lane unaffected
}
[Fact]
public async Task RetrySweep_SkippedMessages_DoNotAccrueRetryCount()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new TimeoutException("down"));
var id1 = (await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "dead", "{}",
maxRetries: 5, attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero)).MessageId;
var id2 = (await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "dead", "{}",
maxRetries: 5, attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero)).MessageId;
await _service.RetryPendingMessagesAsync();
var r1 = (await _service.GetMessageByIdAsync(id1))!.RetryCount;
var r2 = (await _service.GetMessageByIdAsync(id2))!.RetryCount;
// Exactly one was attempted (RetryCount 1); the other was skipped by the
// short-circuit, its RetryCount untouched (0). Order-independent so the
// assertion doesn't depend on created_at tie-breaking.
Assert.Equal(new[] { 0, 1 }, new[] { r1, r2 }.OrderBy(x => x).ToArray());
}
// ── Task 9 (arch review 02, Performance): parallel per-target lanes ──
// A slow/dead target no longer serializes the whole sweep; delivery within a
// single (category, target) lane stays sequential (per-target FIFO).
[Fact]
public async Task RetrySweep_SlowTarget_DoesNotBlockOtherTargets()
{
var slowGate = new TaskCompletionSource();
var healthyDelivered = new TaskCompletionSource();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, async msg =>
{
if (msg.Target == "slow") { await slowGate.Task; return true; }
healthyDelivered.TrySetResult();
return true;
});
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "slow", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "healthy", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
var sweep = _service.RetryPendingMessagesAsync();
// Healthy lane completes while the slow lane is still blocked — pre-fix this
// times out because delivery is strictly serial across targets.
await healthyDelivered.Task.WaitAsync(TimeSpan.FromSeconds(5));
slowGate.SetResult();
await sweep;
}
[Fact]
public async Task RetrySweep_WithinTargetLane_StaysSequential()
{
var concurrent = 0;
var maxConcurrent = 0;
var delivered = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, async _ =>
{
var c = Interlocked.Increment(ref concurrent);
InterlockedMax(ref maxConcurrent, c);
await Task.Delay(20);
Interlocked.Decrement(ref concurrent);
Interlocked.Increment(ref delivered);
return true;
});
for (var i = 0; i < 4; i++)
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "same-target", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero, messageId: $"m{i}");
await _service.RetryPendingMessagesAsync();
Assert.Equal(4, delivered);
Assert.Equal(1, maxConcurrent); // one (category,target) lane: strictly sequential
}
private static void InterlockedMax(ref int target, int value)
{
int current;
do { current = Volatile.Read(ref target); if (value <= current) return; }
while (Interlocked.CompareExchange(ref target, value, current) != current);
}
// ── Task 12: deferToSweep enqueue mode ──
/// <summary>
/// <c>deferToSweep: true</c> must NOT invoke the delivery handler inline (the
/// caller is on a latency-sensitive thread), and the buffered row must be due on
/// the very next sweep — <c>LastAttemptAt</c> stays null so it is not held back by
/// RetryInterval.
/// </summary>
[Fact]
public async Task Enqueue_DeferToSweep_DoesNotInvokeHandlerInline_AndRowIsDueImmediately()
{
var service = CreateService();
var inlineInvoked = false;
service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => { inlineInvoked = true; return Task.FromResult(true); });
var result = await service.EnqueueAsync(StoreAndForwardCategory.Notification, "ops", "{}",
deferToSweep: true, messageId: "n1");
Assert.False(inlineInvoked);
Assert.True(result.WasBuffered);
var row = await service.GetMessageByIdAsync("n1");
Assert.Null(row!.LastAttemptAt); // due on the very next sweep, not after RetryInterval
}
/// <summary>
/// After StartAsync, <c>deferToSweep</c> must kick an immediate background sweep so
/// healthy-path latency is milliseconds, not one timer interval — proven with a
/// timer set to an hour so only the kicked sweep can deliver.
/// </summary>
[Fact]
public async Task Enqueue_DeferToSweep_AfterStart_KicksAnImmediateSweep()
{
var service = CreateService(retryTimerInterval: TimeSpan.FromHours(1)); // timer will never fire in-test
var delivered = new TaskCompletionSource();
service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => { delivered.TrySetResult(); return Task.FromResult(true); });
await service.StartAsync();
try
{
await service.EnqueueAsync(StoreAndForwardCategory.Notification, "ops", "{}", deferToSweep: true);
await delivered.Task.WaitAsync(TimeSpan.FromSeconds(5)); // pre-fix: times out
}
finally { await service.StopAsync(); }
}
}
@@ -587,6 +587,33 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved!.ParentExecutionId);
}
// ── Task 7 (arch review 02, Performance): bounded due-rows query ──
[Fact]
public async Task GetMessagesForRetry_HonorsLimit_OldestFirst()
{
var baseTime = DateTimeOffset.UtcNow.AddMinutes(-10);
for (var i = 0; i < 5; i++)
{
await _storage.EnqueueAsync(new StoreAndForwardMessage
{
Id = $"m{i}",
Category = StoreAndForwardCategory.ExternalSystem,
Target = "t",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 0, // always due
CreatedAt = baseTime.AddSeconds(i),
Status = StoreAndForwardMessageStatus.Pending
});
}
var page = await _storage.GetMessagesForRetryAsync(limit: 3);
Assert.Equal(3, page.Count);
Assert.Equal(new[] { "m0", "m1", "m2" }, page.Select(m => m.Id));
}
private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category)
{
return new StoreAndForwardMessage
@@ -629,4 +656,156 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task Initialize_EnablesWalJournalMode_OnFileDatabase()
{
// WAL lets the retry-sweep lanes, script enqueues, and standby replication
// applies read/write concurrently without "database is locked". journal_mode
// is persistent + file-scoped, so a fresh connection observes it.
var directory = Path.Combine(Path.GetTempPath(), "sf-wal-test-" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(directory, "wal-test.db");
try
{
var storage = new StoreAndForwardStorage(
$"Data Source={path}", NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
await using var conn = new SqliteConnection($"Data Source={path}");
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode";
Assert.Equal("wal", (string)(await cmd.ExecuteScalarAsync())!);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
// ── Task 18: epoch-ms due-check ──
[Fact]
public async Task GetMessagesForRetry_UsesEpochMs_RespectsInterval()
{
var msg = new StoreAndForwardMessage
{
Id = "due-check",
Category = StoreAndForwardCategory.ExternalSystem,
Target = "t",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 60_000,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
LastAttemptAt = DateTimeOffset.UtcNow.AddSeconds(-30), // not yet due
};
await _storage.EnqueueAsync(msg);
Assert.Empty(await _storage.GetMessagesForRetryAsync());
msg.LastAttemptAt = DateTimeOffset.UtcNow.AddSeconds(-90); // due
await _storage.UpdateMessageAsync(msg);
Assert.Single(await _storage.GetMessagesForRetryAsync());
}
[Fact]
public async Task Initialize_BackfillsEpochMs_ForLegacyRows()
{
// Simulate a legacy row: text timestamp present, ms column NULL.
await ExecRawAsync(
"INSERT INTO sf_messages (id, category, target, payload_json, created_at, last_attempt_at, status) " +
"VALUES ('legacy', 0, 't', '{}', @c, @l, 0)",
("@c", DateTimeOffset.UtcNow.ToString("O")),
("@l", DateTimeOffset.UtcNow.AddHours(-1).ToString("O")));
await _storage.InitializeAsync(); // second init runs the one-time backfill
var ms = await ScalarRawAsync("SELECT last_attempt_at_ms FROM sf_messages WHERE id='legacy'");
Assert.NotNull(ms);
Assert.InRange(Convert.ToInt64(ms),
DateTimeOffset.UtcNow.AddHours(-1).AddMinutes(-1).ToUnixTimeMilliseconds(),
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}
private async Task ExecRawAsync(string sql, params (string, object)[] parameters)
{
await using var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared");
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
await cmd.ExecuteNonQueryAsync();
}
private async Task<object?> ScalarRawAsync(string sql)
{
await using var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared");
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var result = await cmd.ExecuteScalarAsync();
return result is DBNull ? null : result;
}
// ── Task 20: buffer-resync storage primitives ──
private static StoreAndForwardMessage NewMsg(
string id,
StoreAndForwardMessageStatus status = StoreAndForwardMessageStatus.Pending,
DateTimeOffset? createdAt = null) => new()
{
Id = id,
Category = StoreAndForwardCategory.ExternalSystem,
Target = "t",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 30000,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
Status = status,
};
[Fact]
public async Task GetAllMessages_ReturnsEveryStatus_OldestFirst_UpToLimit()
{
var @base = DateTimeOffset.UtcNow;
await _storage.EnqueueAsync(NewMsg("p1", StoreAndForwardMessageStatus.Pending, @base));
await _storage.EnqueueAsync(NewMsg("k1", StoreAndForwardMessageStatus.Parked, @base.AddSeconds(1)));
await _storage.EnqueueAsync(NewMsg("p2", StoreAndForwardMessageStatus.Pending, @base.AddSeconds(2)));
var (all, truncated) = await _storage.GetAllMessagesAsync(limit: 2);
Assert.Equal(new[] { "p1", "k1" }, all.Select(m => m.Id)); // every status, oldest-first
Assert.True(truncated); // a third row exists beyond the limit
}
[Fact]
public async Task ReplaceAll_SwapsTheEntireBuffer_Atomically()
{
await _storage.EnqueueAsync(NewMsg("stale"));
await _storage.ReplaceAllAsync(new[] { NewMsg("fresh-1"), NewMsg("fresh-2") });
Assert.Null(await _storage.GetMessageByIdAsync("stale"));
Assert.NotNull(await _storage.GetMessageByIdAsync("fresh-1"));
Assert.NotNull(await _storage.GetMessageByIdAsync("fresh-2"));
}
// ── Task 23: oldest-parked-age health signal ──
[Fact]
public async Task GetOldestParkedCreatedAt_ReturnsOldestParkedRow_OrNull()
{
Assert.Null(await _storage.GetOldestParkedCreatedAtAsync());
var old = NewMsg("old-parked", StoreAndForwardMessageStatus.Parked, DateTimeOffset.UtcNow.AddDays(-3));
var recent = NewMsg("new-parked", StoreAndForwardMessageStatus.Parked, DateTimeOffset.UtcNow.AddHours(-1));
var pending = NewMsg("pending", StoreAndForwardMessageStatus.Pending, DateTimeOffset.UtcNow.AddDays(-9)); // must not count
await _storage.EnqueueAsync(old);
await _storage.EnqueueAsync(recent);
await _storage.EnqueueAsync(pending);
var oldest = await _storage.GetOldestParkedCreatedAtAsync();
Assert.Equal(old.CreatedAt, oldest!.Value, TimeSpan.FromSeconds(1));
}
}