fix(siteruntime): arbitrate DCL connection config — definitions store wins (rows 89 + 90)

Closes deferred-work-register rows 89 and 90, and corrects their premise twice.

THE RULE
The `data_connection_definitions` store wins wherever it has an opinion; an
instance's flattened `Connections` entry is a deploy-time snapshot and is
authoritative only for names the store has never owned (the deploy-only site,
where that table is legitimately empty).

PREMISE CORRECTION 1 — the peer's DCL is EMPTY, not stale
`DeploymentManagerActor` is a `site-{SiteId}`-scoped singleton and the only
sender of Create/RemoveConnectionCommand, to its OWN node's `dcl-manager`
(which exists on every node but is populated only where the singleton runs).
So the standby serves nothing. The real defect is that `_createdConnections`
is in-memory per singleton incarnation: a restart or failover rebuilds every
connection from the flattened snapshots and silently discards a newer
connection-only artifact deploy. Row 89 is therefore create-time, not
message-delivery, and NOT replication-specific — the same loss happens on an
unreplicated single-node restart.

PREMISE CORRECTION 2 — a changed config never reached a LIVE connection either
`DataConnectionManagerActor.HandleCreateConnection` is create-only (warns and
returns on a name it holds), so `EnsureDclConnection`'s documented "re-issues
the create so the DCL adopts the new configuration" has never been true on any
node. Now surfaced with a Warning saying exactly that, once per changed config.
Not fixed here: stop-and-recreate drops every tag subscription and InstanceActor
cancels its subscribe-retry timer on first success, so nothing re-subscribes.

MECHANISM
`data_connection_definitions` gains its first production reader. It is read on
the SAME off-thread task as the deployed configs (`ReadStoreSnapshotAsync`), by
the boot load and every sweep tick, into an actor-thread-confined arbitration
overlay applied BEFORE the boot batch and BEFORE the sweep's repair loop. A
CDC-delivered definition change converges within one tick on whichever node runs
the singleton — no new cross-node message. A failed definitions read yields null
and leaves the overlay untouched (distinct from an empty list, which is a real
answer); clearing it would reintroduce the ping-pong through the error path.

THE PING-PONG, KILLED BY CONSTRUCTION
§7.4 of the sweep memo rejected a store-level pass because two sources would
flip the single per-name hash slot, recreating the actor every tick. That is a
property of WHERE the hash is computed: every ensure path now resolves the
winner BEFORE hashing, so only one configuration can ever occupy the slot. The
artifact-deploy path writes its rows into the overlay in the same pass, closing
the same flip arriving through the other door.

REMOVAL (row 90)
Both removal paths share `RemoveConnectionCentrally` and record an in-memory
tombstone, so a centrally removed connection is not resurrected by the next
instance start — a pre-existing bug. Tombstones are deliberately not persisted;
a definition row reappearing clears one.

TESTS
tests/.../Actors/DclConnectionArbitrationTests.cs — 8 tests. Fail-before
(behavioral revert: overlay inert + tombstone check disabled): 5 fail, 3 pass;
the 3 passers are the two deploy-only regression guards and the ping-pong guard.

DOCS
New decision memo docs/plans/2026-08-17-dcl-connection-arbitration-decision.md
(topology, rule, ping-pong mechanism, tombstones, residuals); Component-SiteRuntime,
Component-DataConnectionLayer + their docs/components mirrors; the §7.4 residual in
the sweep memo marked closed with its premise corrected.
This commit is contained in:
Joseph Doherty
2026-08-17 05:22:42 -04:00
parent 15e5887b52
commit c57e74e660
8 changed files with 1133 additions and 32 deletions
@@ -229,7 +229,8 @@ The register's suggested fix was "ensure a connection actor exists for every sto
connection". Reading the code first changed the shape of the answer, because the premise is not
how the runtime works:
- **`data_connection_definitions` has no production reader.**
- **`data_connection_definitions` has no production reader** *(true when this was written; rows
89/90 gave it one on 2026-08-17 — see the residual note at the end of this section)*.
`SiteStorageService.GetAllDataConnectionDefinitionsAsync` is called from exactly one place in
the repo, and it is a test. Nothing loads that table at boot.
- A DCL connection actor is created **only as a side effect of starting an instance**, from the
@@ -311,6 +312,19 @@ sibling of row 85, it is **not** closed here, and closing it needs the arbitrati
artifact-deploy reconcile; a connection deleted on the peer via replication leaves a live actor on
this node. Same root cause, same arbitration question, same register entry.
> **CLOSED 2026-08-17 (rows 89 + 90) — and the residual above is worded on a wrong premise.**
> The arbitration decision was made and implemented: **the definitions store wins wherever it has
> an opinion; the flattened config is authoritative only for names the store has never owned.** The
> guarded-version-is-near-empty objection above does not apply to that rule, because the guard is
> not "skip names already in `_createdConnections`" but "resolve the WINNER before hashing", which
> leaves exactly one candidate per name and makes the flip unrepresentable rather than merely
> avoided. Two premise corrections came out of validating it: the peer's DCL is **empty**, not
> stale (only the singleton's node ever gets a `CreateConnectionCommand`), so the defect is
> create-time on the next singleton incarnation and is **not replication-specific**; and
> `HandleCreateConnection` is create-only, so a changed config never reached a *live* connection
> actor on any node — that one is still open and is this file's successor's §6.1. See
> `docs/plans/2026-08-17-dcl-connection-arbitration-decision.md`.
### 7.5 Tests
`tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SelfHealCreatesDclConnectionTests.cs` —