Secrets: Akka replicator deadlocks on DI actor creation in a hosted process (blocks OtOpcUa clustered-secrets adoption) #1
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
ZB.MOM.WW.Secrets.Replicator.AkkaDotNetdeadlocks when its replication actor is created through DI in a real hosted process. ResolvingISecretReplicatornever returns.This blocks OtOpcUa from adopting the Akka clustered-secrets topology (
Secrets:Replication:Enabled). ScadaBridge is unaffected — it uses the SQL-Server hub replicator, which passed its live gate 6/6.Impact
If
Secrets:Replication:Enabled=truewere set in OtOpcUa, the host would hang at startup: the wiring registers a startup hook that resolvesISecretStore, which constructsReplicatingSecretStore→ISecretReplicator→ the actor.It is harmless today only because the flag defaults to false in
SecretsRegistration.cs.Reproduction
Reproduced twice with a standalone 2-node harness (two
ActorSystems, real dot-netty remoting, one process), both members confirmedUpbefore the hang:What has been ruled out
ISecretStore; isolated by resolvingISecretReplicatordirectly instead. Hangs identically, so it is not contention on the container's singleton lock forISecretStore.Upfirst; the check that asserts this passes.TwoNodeClusterReplicationTestscreates the same actor on a real 2-node cluster and passes (33 tests green). The distinguishing factor is creation through DI inside a hosted process, not directsystem.ActorOffrom a test.Native stacks (
sample) showMonitor_Wait; managed frames were not resolvable.Candidate causes (unverified — do not fix on this basis without confirming)
SecretReplicationActor.PreStartcallsDistributedPubSub.Get(Context.System).Mediator, and the extension's lazy initialisation interacts badly with theLazy<IActorRef>(LazyThreadSafetyMode.ExecutionAndPublication) lock inSecretReplicationActorProvider.Akka.Cluster.Toolsreference config /akka.extensionsentry that the app hosts declare explicitly, soDistributedPubSub.Getblocks rather than failing.Note both OtOpcUa and ScadaBridge list the DistributedPubSub extension explicitly in their own HOCON — the harness (and possibly
AkkaSecretsReplication.SerializationConfig) does not.Definition of done
IHostedService+ActorSystemfrom the container), not directly viasystem.ActorOffrom a test. Every existing test passes over this.AkkaSecretsReplicationshould supply what it needs rather than relying on the host.Context
ZB.MOM.WW.Secrets/in this repo; affected packageZB.MOM.WW.Secrets.Replicator.AkkaDotNet0.2.1 (on the Gitea feed).components/secrets/GAPS.md.docs/plans/2026-07-18-secrets-0.2.0-upgrade-and-clustering.md(Task 9).Consumer-side tracker filed:
lmxopcua#482— OtOpcUa keepsSecrets:Replication:Enabled=falseuntil this is fixed, then bumps the package and re-runs the Akka live gate.Root cause identified and fixed —
main@a1df7ef(lib0.2.2, not yet published to the feed)The deadlock was never Akka. Managed stacks from a
dotnet-dumpof the hung harness (the nativeMonitor_Waitfinally resolved) show a circular singleton dependency hidden entirely in DI factory lambdas:MS.DI cannot detect cycles through opaque
sp => ...factories. At runtime the resolution recurses around the loop (same-threadMonitorre-entry keeps it alive), until MS.DI'sStackGuardmoves the recursion onto a fresh thread-pool thread to avoid a stack overflow — and that thread then blocks forever inMonitor.Enteron a singleton call-site lock the first thread still holds (which is itself parked inWaitOnewaiting for the offloaded task). A silent, permanent, cross-thread deadlock with no exception.Both candidate causes are disproven: the actor's constructor is never reached, so
DistributedPubSub.Getand the composed HOCON are irrelevant — and the new regression test reproduces the hang on a single non-clustered node.Confirming experiment: replacing only the eager
sp.GetService<ISecretCacheInvalidator>()withnulleliminated the hang and the 2-node gate ran — at 5/6, with deleted secrets still resolving on the peer. So the invalidator edge is functionally required; it must be deferred, not severed.Fix:
DeferredSecretCacheInvalidator— the provider now receives a wrapper that resolves the real invalidator on first eviction, which can only happen when a replicated row is applied, strictly after graph resolution completes. (Deferring merely to actor-creation time is NOT sufficient — that still happens inside the same resolution and deadlocks identically.) The SQL-Server package never had the cycle (its replicator chain never touches the invalidator), which is exactly why the hub gate passed 6/6 while Akka hung.Definition of done, item by item:
HostedProcessResolutionTests(container-registeredActorSystem, hosted services started, watchdogged resolves ofISecretStore/ISecretReplicator/ISecretResolver). Verified red on 0.2.1 (fails at 20s watchdog), green on 0.2.2. ✅Also pinned:
DeferredSecretCacheInvalidatorTests(no eager resolve, evictions forward, lookup runs once) — this library's fourth defect visible only with the DI graph built in a real host, so the pass-through itself gets guarded against going silently inert.Remaining before
lmxopcua#482can proceed: publish0.2.2to the Gitea feed, then bump OtOpcUa and re-run its gate.(One unrelated observation:
ActorContextAfterAwaitTests.Self_and_Context_are_both_unreadable_after_a_real_thread_hopflaked once under the fuller parallel suite — its assertion races on which thread-pool thread aConfigureAwait(false)continuation lands. Passed alone and on re-run; pre-existing sensitivity, not touched by this fix.)0.2.2is PUBLISHED to the Gitea feed (all 5 packages; restore-verified from a scratch consumer — 5 pinned refs, clean restore + build).main@b077ca1.Also folded in since the fix comment: the
ActorContextAfterAwaitTestsflake observed during verification is fixed (51bef63) — its old formulation asserted that a pool-thread continuation always throwsNotSupportedException, which Akka does not guarantee (pool threads legitimately carry the ThreadStatic cell during mailbox runs/async continuations, and a cleared cell makesContextreturn null /Selfreturn_clearedSelfwithout throwing). The illegal reads now run on a dedicated fresh thread, where the no-context state is guaranteed; 11 consecutive full-project runs green.Every item in the definition of done is met and the package is on the feed, so closing. Consumer-side adoption continues in lmxopcua#482 (bump to
0.2.2, re-run the OtOpcUa gate, then consider enablingSecrets:Replication:Enabled).