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.
18 KiB
DCL connection arbitration — which config source owns a live connection
Date: 2026-08-17
Scope: Site Runtime (DeploymentManagerActor), deferred-work-register rows 89 and 90
Status: implemented
Predecessor: docs/plans/2026-08-15-instance-sweep-self-heal-design.md §7.4, which designed a
store-level connection sweep, rejected it, and recorded rows 89/90 as the residual — explicitly
blocked on "the arbitration decision above (which source wins), not another sweep pass".
1. Verified topology — and two premise corrections
Both register rows are worded in terms of "the peer node's DCL". That framing is wrong, and the correction changes what the fix has to do, so it comes first.
1.1 What actually runs where
| Actor | Placement | Verified at |
|---|---|---|
DeploymentManagerActor |
Cluster singleton, role-scoped to site-{SiteId} (SingletonRegistrar.Start(…, "deployment-manager", …, role: siteRole)) |
Host/Actors/AkkaHostedService.cs |
DataConnectionManagerActor (/user/dcl-manager) |
Every site node, unconditional ActorOf — not a singleton |
Host/Actors/AkkaHostedService.cs |
CertStoreActor |
Every site node (documented; re-confirmed) | same file |
CreateConnectionCommand and RemoveConnectionCommand have exactly one sender in the whole
repo — DeploymentManagerActor — and it sends them to the _dclManager ref it was constructed
with, which is its own node's local dcl-manager (the Props are built per node, so whichever
node materialises the singleton uses that node's manager).
1.2 Premise correction 1 — the standby's DCL is EMPTY, not stale
Because the singleton is the only sender, the standby node's dcl-manager holds no connection
actors at all. It is not "serving the old endpoint"; it is serving nothing, and nothing subscribes
through it. So there is no per-node divergence to reconcile with a cross-node message.
The real failure mode is one layer over:
_createdConnections— the per-name hash cache that records what the DM last pushed — is in-memory and per singleton incarnation. When the singleton restarts (failover, or an ordinary node restart), the new incarnation starts with an empty cache, loadsdeployed_configurations, and rebuilds every connection from theConnectionssnapshot frozen into each instance's flattened config at that instance's deploy time. Nothing has ever readdata_connection_definitions— that table had no production reader — so a newer, connection-only artifact deploy is silently discarded and the site goes back to the old endpoint.
This also means row 89 is not replication-specific. Replication only puts the definition row on both nodes, which is if anything helpful. The identical loss happens on an unreplicated single-node restart. The row-89 fix is therefore a create-time arbitration fix, not a message-delivery fix.
Row 90 is the same shape: the delete is durable in the store, but the flattened configs still name the connection, so the next instance start re-creates it.
1.3 Premise correction 2 — a changed config never reached a LIVE connection either
EnsureDclConnection's contract, as documented in the code before this change, was that a changed
endpoint/credential "re-issues a CreateConnectionCommand so the DCL adopts the new configuration
instead of keeping a stale connection". That is false.
DataConnectionManagerActor.HandleCreateConnection is create-only:
if (_connectionActors.ContainsKey(command.ConnectionName))
{
_log.Warning("Connection {0} already exists", command.ConnectionName);
return;
}
So the system-wide artifact deploy's "make it live immediately" push has never worked on the deploy-handling node, and the DM's hash cache recorded it as applied regardless. This is a separate, pre-existing defect discovered while validating rows 89/90; see §6.
2. The arbitration rule
The definitions store wins wherever it has an opinion. The flattened config is authoritative only for names the store has never owned.
- A name with a row in
data_connection_definitions— that row's config configures the live DCL connection actor. The identically-named entry in an instance's flattenedConnectionsmap is a deploy-time snapshot and never overrides it. - A name with no definition row — the flattened config is the source, exactly as before. This is not a fallback; it is the correct answer for a deploy-only site, where the definitions table is legitimately empty because nothing has ever sent that site a system-wide artifact deploy while every connection that matters lives inline in the instance configs. §7.4 of the predecessor memo identified this as the reason a blanket store-level sweep is wrong, and it remains the reason rule 2 exists.
- Removal semantics on the deploy-handling node are unchanged: the actor is stopped even if deployed instances still reference it, and their attributes go bad quality until redeploy. What is added is a tombstone (§4).
2.1 The overlay is an arbiter, not a creation source
_connectionDefinitions decides which configuration a connection the runtime already wants is
built from. It never creates a connection on its own: a definition row naming a connection no
deployed instance references produces no actor, because none is wanted. This is what makes it safe
to consult on every sweep tick, and it is why re-adding a centrally deleted connection brings the
actor back at the next instance start rather than instantly (pinned by
ReAddedDefinition_ClearsTheTombstone_SoTheNextInstanceStartRecreatesIt).
3. The ping-pong, and why keying the hash on the winner makes it unrepresentable
The predecessor memo rejected a store-level sweep partly because of this:
_createdConnectionsholds one hash per name. The flattened-config hash and the artifact-definition hash for the same connection can legitimately differ (the supported version-skew case). A sweep re-hashing from the definitions table would flip the slot every tick against the deploy path flipping it back, recreating the connection actor and dropping its live subscriptions on a loop.
That analysis is correct, and it is a property of where the hash is computed, not of the sweep. The fix is one line of ordering discipline:
Every ensure path resolves the winning configuration BEFORE hashing or pushing.
EnsureDclConnections(configJson) no longer hands the flattened ConnectionConfig straight to
EnsureDclConnection. It first asks the overlay whether the store owns that name; if it does, the
definition's protocol/config/backup/failover fields are what get hashed and pushed. The
artifact-deploy path pushes those same fields. Therefore, for any given name, only one
configuration can ever be hashed, no matter which path calls — the two sources cannot occupy the
slot alternately because there is only ever one candidate. The flip is not guarded against; it is
unrepresentable.
The guard test DisagreeingSources_DoNotPingPongTheConnection_AcrossManySweeps pins exactly this
with the two sources deliberately disagreeing: one create, zero removes, across many ticks. It
passes before and after the fix — before, because nothing read the definitions at all — which is
the point: it is a guard against the rejected naive design, not a fail-before test.
3.1 A second flip source, closed in the same way
HandleApplyArtifactDataConnectionsToDcl now writes the artifact's rows into the overlay in the
same pass it pushes them. Without that there is a window between an artifact deploy and the next
sweep read in which an instance start would resolve the name to its flattened snapshot while the
artifact path had already pushed the definition — the same flip, arriving through a different door.
4. Removal and the tombstone
_centrallyRemovedConnections is a HashSet<string> of names this singleton incarnation has seen
removed centrally. It is populated by both removal paths, which share one helper
(RemoveConnectionCentrally) so they cannot drift:
- the artifact reconcile's
RemovedConnectionNames(the existing path); and - the sweep's disappearance detection (new — §5).
EnsureDclConnections skips a tombstoned name. Without the tombstone the removal does not survive
one tick: the removal evicts the hash slot, and the next instance start whose flattened config still
names the connection re-creates it. That is not hypothetical — the sweep starts instances — and it
is a pre-existing bug this change also closes
(ArtifactReconcileRemoval_IsNotResurrectedByALaterInstanceStart fails on the old code).
Tombstones are deliberately in-memory only. Persisting them was rejected: a durable "this name is gone" marker is a second divergent authority over the same names, and the flattened configs already resurrect a removed connection across a restart today. That pre-existing semantic is accepted and unchanged — see the residual in §6.
A tombstone is cleared the moment a definition row for the name reappears (a definition row is the store re-asserting ownership), so a centrally re-added connection is creatable again.
5. The mechanism: the definitions table as an overlay on the existing reads
No new message, no new actor, no new cross-node path. The two durable tables the DM arbitrates over
are read in one off-thread task, ReadStoreSnapshotAsync, used by both existing reads:
PreStart → LoadDeployedConfigs ─┐
├─ ReadStoreSnapshotAsync (off-thread, one task)
InstanceSweepTick ──────────────┘ deployed_configurations
data_connection_definitions
│
▼ piped back
StartupConfigsLoaded / InstanceSweepLoaded (actor thread)
│
ApplyConnectionDefinitionOverlay
│
┌───────────────────┴───────────────────┐
row present row disappeared
adopt + push if live tombstone + stop actor
Nothing blocks the actor thread on SQLite; the overlay mutation, the hash cache and the tombstone
set are all actor-thread-confined, matching the existing StartupConfigsLoaded /
ApplyArtifactDataConnectionsToDcl patterns.
Ordering is load-bearing in both handlers. The overlay is applied before the boot batch creates any Instance Actor and before the sweep's repair loop, so a connection is created from the winning config on its first push rather than created stale and corrected a tick later. On failover that is the difference between a clean takeover and a needless recreate on every one.
5.1 The two reads fail differently, on purpose
A failed config read propagates and re-arms the whole load (the existing S5 path, unchanged). A
failed definitions read is swallowed into a null result, and null means keep the overlay
exactly as it is — deliberately distinct from an empty list, which is a real answer (a deploy-only
site, or a site after central deletes every data connection). Clearing the overlay on a transient
read failure would hand arbitration back to the flattened snapshots for one pass and take it away
on the next successful read: the §3 ping-pong, reintroduced through the error path.
5.2 Disappearance detection, and precisely how far it reaches
A name that was in the overlay and now has no row is a removal this incarnation can prove: the store owned it a moment ago and does not now. It is tombstoned and its connection actor stopped.
A name that was never in the overlay is indistinguishable from one the store has never owned — a fresh incarnation cannot tell "central deleted this" from "this site keeps its connections inline". It is left to the flattened config. That asymmetry is deliberate; the alternative (infer deletion from "the definitions table is non-empty, therefore it is the complete set") would stop live connections on an inference, and is recorded as a candidate follow-up in §6.
5.3 Cost
One extra SELECT over data_connection_definitions per sweep tick (default 60 s) and once at
boot, on the same task as the existing deployed-configs read. Steady state is otherwise silent: the
hash guard means an unchanged connection produces no message and no log.
6. Residuals — what this does NOT close
These are precise, and each is a candidate register row.
- A live connection actor is still not reconfigured in place (premise correction 1.3). The DCL
manager's
HandleCreateConnectionis create-only, so a changed configuration for a name whose actor already exists takes effect only when the connection is next created from scratch — a node restart, a singleton failover, or the connection being removed and re-added. This change makes that honest rather than silent:EnsureDclConnectionnow logs a Warning naming exactly that, once per changed configuration (the hash guard suppresses repeats, so a healthy site stays silent). It is not fixed here because the obvious fix is destructive: stopping and re-creating the connection actor drops every Instance Actor's tag subscription, andInstanceActorcancels its per-connection subscribe-retry timer on the first success, so nothing re-subscribes — trading "stale endpoint, data flowing" for "correct endpoint, no data until redeploy". The non-destructive fix is aReconfigureConnectionCommandhandled insideDataConnectionActor, reusing the adapter-swap machinery the primary→backup failover path already has (CountFailureAndMaybeFailover: dispose adapter,_factory.Createthe new one, bump_adapterGeneration, re-enter the connect cycle, which re-issues everything from_subscriptionsByInstance). That is a Data Connection Layer change and is out of rows 89/90's scope. - A removal is still not durable across a restart. Tombstones are in-memory, so a connection deleted centrally and then resurrected by an instance's flattened config on the next node restart behaves as it always has. Closing this needs a durable marker or — better — flattened configs that stop carrying connection bodies at all.
- A delete that arrives while this node has never adopted the row is undetectable (§5.2). The narrow case that remains open: the singleton moves to a node whose file already has the row deleted, and some instance's flattened config still names the connection. That node re-creates it. A stronger rule — "a non-empty definitions table is the complete set, so absence means deleted" — would close it, at the cost of stopping live connections on an inference about what central shipped; deliberately not taken.
- Dual-active (a real partition under
auto-down) is the only routine way a definition change reaches a singleton that did not process the deploy itself. It is handled correctly by §5, but it is worth naming that this is not the common path — the common path is failover/restart (§1.2).
7. Tests
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DclConnectionArbitrationTests.cs — 8 tests,
asserting on the CreateConnectionCommand / RemoveConnectionCommand messages the DM sends to a
recording stand-in for the DCL manager, and on the endpoint inside those commands (which is what
distinguishes the two config sources).
Every test gates on DeploymentManagerStartupProbe for the same reason the row-78/85 suites do: a
row written before the boot load reads storage is consumed by the boot load, and an ungated test
would silently prove the boot load works instead of the path it names.
Fail-before evidence (behavioral revert: ApplyConnectionDefinitionOverlay made inert and the
tombstone check disabled — i.e. the exact pre-fix arbitration) — 5 fail, 3 pass:
| Test | Pre-fix |
|---|---|
DefinitionRow_WinsOverAnOlderFlattenedSnapshot_WhenTheSingletonBoots |
FAIL — the row-89 symptom in its failover shape (create carries the old endpoint) |
DefinitionArrivingAfterBoot_IsAdoptedByTheNextSweepTick |
FAIL — the CDC-delivered change is never read |
DefinitionRowDisappearing_StopsTheConnection_AndItIsNotResurrected |
FAIL — the row-90 symptom (orphan actor stays live) |
ArtifactReconcileRemoval_IsNotResurrectedByALaterInstanceStart |
FAIL — removal undone by the next instance start |
ReAddedDefinition_ClearsTheTombstone_SoTheNextInstanceStartRecreatesIt |
FAIL |
DisagreeingSources_DoNotPingPongTheConnection_AcrossManySweeps |
pass (guard against the rejected naive design) |
NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_AtBoot |
pass (deploy-only regression guard) |
NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_OnTheSweepPath |
pass (deploy-only regression guard) |
The three passers are the point of passing both before and after: rule 2 must be byte-for-byte unchanged for a deploy-only site, and the ping-pong must never appear.
8. Configuration
None added. The overlay refresh rides ScadaBridge:SiteRuntime:InstanceSweepIntervalSeconds
(default 60; 0 disables the sweep and therefore the post-boot half of this convergence — the
boot-time arbitration still applies, because it hangs off the startup load, not the sweep timer).