Repository navigation
fix: a transient infrastructure fault at init retires the activation instead of latching it FAILED (#4067, #4068) - #4162
Conversation
…instead of latching it FAILED (#4067, #4068) A hub whose initialization met a transient infrastructure fault — the database unreachable, a name unresolved — entered the FAILED state: every later request answered with a terminal DeliveryFailure, streams errored, nothing ever re-run, until a recycle or a process restart. Right for a fault of the activation (a type that does not compile); wrong in kind for a fault of the dependency. Measured: memex-cloud 2026-09-12, three DataContexts FAILED on "Name or service not known" in two minutes; memex, 19 sync/* BuildupActions on "Failed to connect" across three weeks. - InfrastructureFault.IsTransient (Messaging.Contract): DbException.IsTransient — every ADO.NET provider classifies its connection failures there, Npgsql for exactly the two shapes measured — or a SocketException, anywhere in the chain. A bare TimeoutException is not transient (the init time-box mints one for a hang). - MessageHubConfiguration.WithReactivationOnDemand(): the hub is re-created by demand routing after it goes away. Declared in the one funnel every per-node activation passes (NodeTypeRebindWatcher.WithNodeTypeRebind). - MessageHub.HandleInitialize and DataContext.SettleInitializationGate: on a transient fault, a hub declared so is RETIRED — Dispose() first (so refusals are ShuttingDown with this activation's identity), FailGate(specific reason) second (the parked backlog is answered now, naming the database rather than the recycle), no InitializationError. The next delivery activates a fresh hub whose initialization runs again. A hub without the declaration (the root mesh, a stream's sync sub-hub) keeps the latch and its log line now names the transient cause and the restart that recovers it. Pinned by TransientInitializationFaultRetiresTheActivationTest (both seams + the control); falsified by reverting the two seams: the positive cases fail with ErrorType.Failed, the controls stay green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical issues remain in mixed-failure handling and transient-classifier coverage.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR enables demand-reactivated hubs to recover from transient infrastructure failures by retiring the affected activation and retrying on the next delivery.
Changes:
- Adds transient infrastructure-fault classification.
- Adds demand-reactivation configuration and retirement behavior.
- Adds regression tests and documentation.
File summaries
| File | Summary |
|---|---|
test/MeshWeaver.Data.Test/TransientInitializationFaultRetiresTheActivationTest.cs |
Tests retirement and failed-latch behavior. Moderate finding (1 vote): assert the DataContext initialization marker directly. |
src/MeshWeaver.Messaging.Hub/MessageHubConfiguration.cs |
Adds reactivation-on-demand configuration. |
src/MeshWeaver.Messaging.Hub/MessageHub.cs |
Retires eligible hubs after transient failures. Nit (1 vote): localize the user-facing shutdown reason. |
src/MeshWeaver.Messaging.Contract/InfrastructureFault.cs |
Classifies transient faults. Critical finding (2 votes): add coverage for excluded, socket, non-transient, aggregate, and mixed exceptions. |
src/MeshWeaver.Graph/Configuration/NodeTypeRebindWatcher.cs |
Enables reactivation for node activations. Moderate finding (1 vote): test that the composed configuration sets the marker. |
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-a-node-whose-database-was-briefly-away-recovers-by-itself.md |
Documents the user-facing fix. |
src/MeshWeaver.Documentation/Data/Architecture/HubInitializationFailure.md |
Documents initialization recovery. Nit (1 vote): update the stale unconditional-failure example. |
src/MeshWeaver.Data/DataContext.cs |
Applies recovery to data-source initialization. Critical finding (2 votes): preserve permanent failures in mixed Task.WhenAll results. Moderate findings (1 vote each): avoid duplicate logging and select the matching transient cause. Nit (1 vote): localize the shutdown reason. |
Review details
Suppressed comments (8)
src/MeshWeaver.Data/DataContext.cs:578
- For a hub without demand reactivation this logs an Error here and then falls through to the generic fault branch below, which logs the same exception again. That doubles Error/Loki/alert volume for the infrastructure incidents this diagnostic is meant to explain; fold the transient wording into the single final failure log instead of emitting a second event.
logger.LogError(allInit.Exception,
"DataContext initialization failed for {Address} on a TRANSIENT infrastructure fault, but this "
+ "hub is not re-created on demand (no WithReactivationOnDemand), so it stays FAILED until it "
+ "is recycled or the process restarts.", Hub.Address);
}
src/MeshWeaver.Data/DataContext.cs:565
- This
whattext is also placed directly intoDeliveryFailure.Message, so it can be shown to a portal user, but it is a hard-coded English error string. Use the same localized reason mechanism as the other initialization seam and keep only stable protocol data in the shutdown NACK.
"its DataContext initialization met a transient infrastructure fault "
+ $"({allInit.Exception!.GetBaseException().GetType().Name}: "
+ $"{allInit.Exception.GetBaseException().Message}) and this activation is retired");
src/MeshWeaver.Data/DataContext.cs:565
Task.WhenAllcan contain a transient exception in a non-first branch, butGetBaseException()follows only the firstInnerExceptionpath. In that case this NACK can describe an unrelated non-transient exception even though the graph classifier retired the activation for a nestedDbException/SocketException; extract the matching transient cause from the same graph before constructing the retry reason.
"its DataContext initialization met a transient infrastructure fault "
+ $"({allInit.Exception!.GetBaseException().GetType().Name}: "
+ $"{allInit.Exception.GetBaseException().Message}) and this activation is retired");
src/MeshWeaver.Documentation/Data/Architecture/HubInitializationFailure.md:64
- The new contract section says transient faults retire the activation, but the later “Where it lives” code block still shows the catch unconditionally calling
EnterInitializationFailedStateand opening the gate. That stale example omits the classifier and retirement branch, so a maintainer following the documented flow could reintroduce the defect; update the sample or label it as abbreviated.
`InfrastructureFault.IsTransient(ex)` first, and for a hub declared
src/MeshWeaver.Graph/Configuration/NodeTypeRebindWatcher.cs:114
- This is the production wiring that makes the retirement branch reachable for real per-node hubs, but the new transient test applies the marker manually and the existing
NodeTypeRebindWatchercoverage only checks that the base delegate ran. A regression removing this call would leave the seam tests green while real node activations still latchFAILED; add an assertion that the composed configuration hasReactivatesOnDemandset.
// 🚨 A per-node hub is re-created by demand routing from its node whenever it
// is gone — that is what makes a recycle safe, and it is what lets a
// TRANSIENT initialization fault (the database away, a name unresolved —
// #4067/#4068) retire the activation instead of latching it FAILED until the
// process restarts. Declared HERE because this is the single funnel every
// per-node activation passes (Monolith routing AND MessageHubGrain).
.WithReactivationOnDemand()
src/MeshWeaver.Messaging.Contract/InfrastructureFault.cs:48
ExceptionChain.Containsis bounded at 256 nodes and uses a LIFO traversal, so an aggregate with more than 256 branches can skip an earlier transientDbException/SocketExceptionand return false. That sends the activation back through the permanentFAILEDlatch—the defect this classifier is meant to prevent; transient classification needs a complete or targeted traversal that cannot silently drop branches.
=> ExceptionChain.Contains(exception, static e =>
e is DbException { IsTransient: true } || e is SocketException);
src/MeshWeaver.Messaging.Hub/MessageHub.cs:774
- This
whattext is carried inDeliveryFailure.Messageby the shutdown NACK and can be rendered to a portal user, but it is a hard-coded English error string. Keep the activation/wire data separate and resolve the human-readable reason through the English/German localization catalog at the delivery or presentation boundary.
"its initialization met a transient infrastructure fault "
+ $"({ex.GetType().Name}: {ex.Message}) and this activation is retired");
test/MeshWeaver.Data.Test/TransientInitializationFaultRetiresTheActivationTest.cs:113
- For the
DataContextseam, this assertion readsMessageHub.InitializationError, but the failure marker owned by that seam isDataContext.InitializationError. A regression could set the DataContext marker and still retire the outer hub, so this test would pass; capture the DataContext before disposal and assert its marker is null forSeam.DataContext.
// 2. That activation is GONE — not parked FAILED with an InitializationError.
await first.DisposalCompleted.FirstOrDefaultAsync().Timeout(TestTimeouts.Convergence).Await(ct);
first.RunLevel.Should().Be(MessageHubRunLevel.Dead);
((MessageHub)first).InitializationError.Should().BeNull(
"a retired activation records no FAILED marker — the marker describes one that stays");
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (allInit.IsFaulted && InfrastructureFault.IsTransient(allInit.Exception)) | ||
| { | ||
| if (Hub.Configuration.ReactivatesOnDemand | ||
| && Hub.Address.Type != AddressExtensions.MeshType) |
| public static bool IsTransient(Exception? exception) | ||
| => ExceptionChain.Contains(exception, static e => | ||
| e is DbException { IsTransient: true } || e is SocketException); |
… funnel declares the marker Copilot review on #4162: - A DataContext initialises its sources under Task.WhenAll, so one aggregate can carry a connection timeout AND a genuine defect. "Any branch transient" would retire the activation, discard the defect and re-run the same failing init on every reactivation. An AggregateException is now transient only when EVERY (flattened) branch is; the walk is bounded and reference-deduplicated. - InfrastructureFaultTest pins the corpus: both incident shapes, the wrappers, the bare TimeoutException, the non-transient provider fault, all-transient / mixed / nested / empty aggregates, a cyclic chain. - NodeTypeRebindWatcherTest asserts the composed per-node configuration carries ReactivatesOnDemand — the funnel is where the declaration lives. - The doc's HandleInitialize excerpt shows the retire arm; the aggregate rule is documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
|
Addressed in the follow-up commit:
|
…be acquired (5 attempts)" Run 34747884888: all six test shards green; "Consolidate test results" never started (runner acquisition, zero steps, BlobNotFound log). rerun-failed-jobs answers HTTP 500 from GitHub, so a fresh run is triggered by this empty commit. No code change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
|
🔀 Merge-queue steward: re-queued alone (bisecting) — bisecting: your group of 3 pull requests failed on an uncatalogued assertion while this PR's own run was green; re-queued ALONE — if the solo build fails too, the PR stays out (group: #4165, #4151).
enqueued |
…ult-does-not-latch
…ide of a coin toss CI (queue build 34750238233, shard 1) failed the BuildupAction row with "No exception was thrown". Its own output says why: TEST START …065 → "retiring this activation" …067 (+2 ms) → FAILED …078. The activation faulted and retired BEFORE the request was posted, so the request activated a fresh hub and was SERVED — which is exactly what step 3 of the same test asserts as the contract. Both orderings are legal; the assertion was written for one of them, and which one occurs was decided by how long GetClient()'s own hub construction took against an immediate fault. Only BuildupAction faults immediately (a DataContext's init is slower), which is why only that row failed. NOT a #4165/#4151 interaction: the BuildupAction host has no AddData and therefore no SynchronizationStream — its output block carries ZERO SYNC_STREAM lines where the passing DataContext row carries five — so #4151's FaultStore/GetStream change cannot reach it. The fence: the first init attempt parks until the test releases it, and the test releases only once the request is provably in flight AT that activation. Step 3 already pins the other ordering, so the test now states the whole contract instead of one side of it. 🚨 The first fence was wrong, and #4154's new report is what showed it: target pump now: host/1 RunLevel=Starting turn=InitializeHubRequest running 35004ms buffer=1 deferred=0 … draining=True openGates=1 A BuildupAction runs ON the init turn, so the pump is busy and the request sits in the MAIN queue, never reaching the deferral decision; a DataContext initialises OFF the turn, so the request parks in the DEFERRED queue. InFlightAt accepts either queue, at that hub. Verified on origin/main merged in (bd4314b, with #4151 and #4165): 4/4 green, MeshWeaver.Data.Test 512/512, MeshWeaver.Messaging.Hub.Test 432/432. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
ℹ️ Merge-queue steward: no action — removed from the queue with reason |
|
🔀 Merge-queue steward: re-queued alone (bisecting) — bisecting: your group of 2 pull requests failed on an uncatalogued assertion while this PR's own run was green; re-queued ALONE — if the solo build fails too, the PR stays out (group: #4172).
enqueued |
Fixes #4067. Fixes #4068.
The defect
Both incidents are one mechanism: a hub whose initialization met a transient infrastructure fault entered the FAILED state —
InitializationErrorset, a front-of-chain rule answering every later request with a terminalDeliveryFailure{Failed}, streams errored, nothing ever re-run — until a recycle or a process restart. That latch is the right outcome for a fault of the activation (a NodeType that does not compile, a handler that throws). It is wrong in kind for a fault of the dependency: nothing about the activation is broken, the database was away for a moment, and the latch turned a two-minute DNS blip into "this address is broken until the process restarts".DataContext.SettleInitializationGate— three DataContexts FAILED onSocketException: Name or service not knownin two minutes (memex-cloud, 2026-09-12). The threemesh/…ids are three boots of one pod: the ROOT's recovery is the process, and that is what happened.MessageHub.HandleInitialize— 19sync/*BuildupActions FAILED onNpgsqlException: Failed to connect … ---> TimeoutExceptionacross three weeks (memex).Neither is a
ReplaySubjectlatch nor a failedLazy: it is the FAILED state machine applied without classifying the fault, and no path back.The fix
InfrastructureFault.IsTransient(Messaging.Contract) —DbException { IsTransient: true }(every ADO.NET provider classifies its connection failures there; Npgsql for exactly the two shapes measured, no provider reference in core) or aSocketException, anywhere in the chain (ExceptionChain, so anAggregateExceptionor reflective wrapper does not hide it). A bareTimeoutExceptionis deliberately NOT transient — the init time-box mints one for a hang, and a hang is a defect.WithReactivationOnDemand()onMessageHubConfiguration— "demand routing re-creates this hub after it goes away" — declared in the one funnel every per-node activation passes (NodeTypeRebindWatcher.WithNodeTypeRebind, Monolith routing andMessageHubGrainalike).Dispose()first (so every refusal isShuttingDownwith this activation's identity),FailGate(reason)second (the parked backlog is answered now with the specific cause: "…its initialization met a transient infrastructure fault (NpgsqlException: Failed to connect …) and this activation is retired. The address may reactivate; retry…"), noInitializationError. The next delivery activates a fresh hub whose initialization runs again against the dependency that has come back — the recycle contract every caller already rides out (paced re-probe, resubscribe latch,OwnerDisposingre-enqueue). Under a sustained outage each demand-driven activation pays one connection timeout and retires; the caller's re-probe is paced and bounded, so no storm and no latch.sync/*sub-hub (re-created by its owner's own recovery, not by routing) — keeps the latch, and its log line now says so: "The cause is a TRANSIENT infrastructure fault, but this hub is not re-created on demand … so it stays FAILED until it is recycled or the process restarts."Test and falsification
TransientInitializationFaultRetiresTheActivationTest(Data.Test, 4 cases, 0.4 s): the first activation's init throws a transientDbExceptionand the second succeeds. Positive cases (both seams): the requester is refusedShuttingDownin the owner's vocabulary, the activation reachesDeadwith noInitializationError, the very next delivery is served by a fresh hub, and the init ran exactly twice. Controls (both seams, no declaration):ErrorType.Failed,"initialization failed", the hub staysStartedwith the marker set, the init ran once.Reverting the two seams (declaration and classifier kept, so it compiles): the two positive cases fail (
found Failed), the two controls pass — the marker, not the fault, selects the retirement.Also green:
MeshWeaver.Data.Test512/512,MeshWeaver.Messaging.Hub.Test409/409, the activation/rebind slice ofMeshWeaver.Graph.Test(37/37),DocumentationLinkIntegrityTest.Residue, named
InitializationErroron/health— the pod's restart in Transient DNS failure at PostgreSQL connect time permanently latches mesh DataContext hubs in FAILED state #4068 came from elsewhere (the three boots). A/healthentry for the root's DataContext state belongs in the host (Plugins).IsTransientonNpgsqlExceptionfor connection failures, timeouts and name resolution.Doc:
Doc/Architecture/HubInitializationFailure(new section). What's New: Fix.Pairs-with: none — nothing leaves
src/; the additions are a new static class, a new configuration method and a marker record.🤖 Generated with Claude Code
https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
2026-09-13 — the queue red was my TEST, not a #4165/#4151 interaction (fixed in
e248b4d63)BuildupAction, whose host has noAddDataand therefore noSynchronizationStream: its output block in queue build 34750238233 carries zeroSYNC_STREAMlines where the passingDataContextrow carries five, so fix(layout): a faulted stream re-delivers its fault to a late GetStream subscriber (Plugins#1715) #4151'sFaultStore/GetStreamchange cannot reach it. The steward's solo re-queue then failed on the same row alone against main, which confirms it independently.BuildupAction's init faults immediately, so the retire can precede the post. The failing row's own log:TEST START …065 → "retiring this activation" …067 (+2 ms) → FAILED …078. The request activated a fresh hub and was served — exactly what step 3 of the same test asserts as the contract — soAssert.Throwssaw aProbeResponse. Which ordering occurred was decided by how longGetClient()took; aDataContext's init is slower, which is why only that row failed.InFlightAtaccepts either queue: fix: a handler that faults past DisposeHostedHubs answers its requester (#4072) #4154's newtarget pump now: … turn=InitializeHubRequest running 35004ms buffer=1 deferred=0is what showed that aBuildupActionruns ON the init turn — the request sits in the MAIN queue and never reaches the deferral decision — while aDataContextinitialises OFF it and parks in the DEFERRED queue.)Verified on
origin/mainmerged in (bd4314bc6, with #4151 and #4165): 4/4 green,MeshWeaver.Data.Test512/512,MeshWeaver.Messaging.Hub.Test432/432.