Skip to content

DataContext's transient-fault retirement races its own disposal for the deferred backlog — the requester gets the generic disposal nack instead of the cause (#4162) #4261

Closed

Description

@rbuergi

The failure

MeshWeaver.Data.Test.TransientInitializationFaultRetiresTheActivationTest.ATransientFault_RetiresTheActivation_AndTheNextDeliveryIsServedByAFreshOne(which: DataContext), verbatim:

MeshWeaver.Reactive.Assertions.AssertionException :
  Expected "Hub host/1 was disposed while ProbeRequest (id=b2fedf570e6142e7b623e9af19a785a3) was
  still deferred; initialization gates closed at deferral: [DataContextInit] — the message was
  never processed. The address may reactivate (recycle / restart); retry to get the authoritative
  answer."
  to contain "transient infrastructure fault"
  because the refusal names the cause the caller should expect to clear.
  at TransientInitializationFaultRetiresTheActivationTest.cs(184,0)

The requester got the disposal nack instead of the transient-infrastructure-fault nack. Both are ErrorType.ShuttingDown and both say "retry", so the behaviour #4067/#4068 shipped is intact — what is lost is the CAUSE, which is the half of the fix a caller reads.

The mechanism, read from the code (not inferred from the failure)

DataContext.SettleInitializationGate, the transient branch:

// Dispose FIRST so the refusal is classified ShuttingDown (the reporters read IsShuttingDown),
// FailGate SECOND so the caller's error names the database rather than the recycle.
Hub.Dispose();
Hub.FailGate(InitializationGateName, reason);

The comment states the required ordering; the code cannot enforce it, because the two calls do not run on the same thread:

  1. SettleInitializationGate runs on the thread pool — OpenInitializationGate ends with .ObserveOn(TaskPoolScheduler.Default).Subscribe(_ => SettleInitializationGate(allInit), …).
  2. Hub.Dispose() only starts the teardown: it posts ShutdownRequest and returns; MessageService.Dispose() runs later, inside that request's turn, on the hub's action block.
  3. Both ends then drain the same queue through DrainDeferredDeliveries: the disposal drain answers with "Hub {Address} was disposed while {Message} … was never processed", and FailGate → FailDeferredBacklog answers with the transient reason.

So the requester's answer is decided by whether the hub's action block reaches MessageService.Dispose before the thread-pool thread reaches FailGate. Under load the action block can win — and then the reason the retirement exists to carry is replaced by a generic one. FailDeferredBacklog also Clear()s the parked turns precisely so nothing is answered twice, which confirms both paths are expected to compete for the same backlog.

(There is a second, narrower branch of the same race: SettleInitializationGate opens with if (Hub.IsShuttingDown), which answers with the shutdown reason. A disposal that progresses far enough before the settle runs takes that branch instead of the transient one.)

Denominator, honestly

  • 1 failure in 6 consecutive full-project MeshWeaver.Data.Test runs (macOS, Release, alone on the machine), 2026-09-14.
  • 0 in 8 further full-project runs since, split across two trees (origin/main and a feature branch), and 0 in 8 --filtered runs of this test alone — it wants the concurrent load of the whole project, which is why a filtered re-run is not a control.
  • Not reproduced yet on origin/main specifically; the mechanism above is entirely inside DataContext.cs + MessageService.cs at origin/main (1c1d62adff), and the tree it was observed on differs from origin/main only in SynchronizationStream's fault-flag publication (fix(data): a faulted stream is refused from the instant its terminal is delivered (#4180, #4244) #4255), which has no path to the deferred-queue drain.

Filed rather than catalogued: it is load-dependent, i.e. a race, i.e. a defect.

What a fix has to preserve

Both halves of the comment, at once:

  • the refusal must classify as ErrorType.ShuttingDown (which is why Dispose() is called first today), and
  • the backlog must be answered with the transient cause, exactly once.

Answering the backlog before starting the teardown, with a reason and a classification chosen explicitly rather than read off hub.IsShuttingDown at drain time, satisfies both without depending on which thread wins. That is one shape; the point of this issue is that any fix has to stop the two drains from racing, because ordering two calls on two threads cannot.

Related

Activity

  1. rbuergi commented on Sep 14, 2026

    @rbuergi
    ContributorAuthor

    Confirmed from a failing run's own trail — the two drains, 1 ms apart

    [06:18:12.542] [Warning] [DataContext] DataContext initialization for host/1 met a transient
                   infrastructure fault — retiring this activation instead of latching it FAILED …
    [06:18:12.543] [Error]  [MessageService] [DISPOSE-DISCARD] Hub host/1 is disposing with
                   ProbeRequest (id=d7f7a18c…, from client/deb2a8c9f082) still deferred
    

    That is the sequence this issue describes, observed: Hub.Dispose() returned on the thread-pool
    settle thread, the hub's action block reached MessageService.Dispose one millisecond later, and
    its drain answered the deferred ProbeRequest before the settle thread's very next statement —
    Hub.FailGate(InitializationGateName, reason) — got there. The requester is told "the message was
    never processed"
    instead of "transient infrastructure fault". The fence trail confirms the request
    was parked exactly where the test intends (DEFERRED gates=[DataContextInit] … QUEUED queue=deferred pos=1), so the test's own setup is not at fault.

    Measured rate, and one honest loose end

    A/B, full-project MeshWeaver.Data.Test, alternating trees, alone on the machine, macOS Release,
    30 pairs (2026-09-14):

    tree runs failures
    origin/main (1c1d62adff) 30 0
    the same tree + #4255's diff 30 2

    Plus one earlier failure in 6 runs on the #4255 tree — 3 in 36 vs 0 in 30.

    🚨 That asymmetry is NOT significant and must not be read as a cause. If both arms had the same
    rate, all three failures landing on one arm has p ≈ 0.16. It is recorded because it is what was
    measured, and because the next person should neither dismiss it nor act on it. Two things argue it is
    sampling rather than coupling:

    • fix(data): a faulted stream is refused from the instant its terminal is delivered (#4180, #4244) #4255's diff cannot reach this window. It changes only when a SynchronizationStream publishes
      its terminal fault relative to Store.OnError. The race here is between a thread-pool continuation
      executing its next statement and the hub's action block reaching its ShutdownRequest turn —
      neither side is touched. What a diff can do is shift how many threads are busy at that instant,
      which changes the odds of an already-unguarded race without being its cause.
    • The window is 1 ms of pure OS scheduling, so any load difference moves it.

    Settling it would need ~60 more pairs. The better use of that time is to remove the window, after
    which neither arm can fail.

    What a fix has to solve, stated precisely

    Calling FailGate before Dispose() is not sufficient on its own: AnswerUnreleasableDelivery
    derives the classification at drain time — hub.IsShuttingDown ? ErrorType.ShuttingDown : ErrorType.Failed — so a backlog answered before the teardown starts would be classified Failed,
    which is the terminal verdict #4067/#4068 exist to avoid (and the test's first assertion). Hence the
    current Dispose()-first ordering, and hence why it cannot simply be swapped.

    So the classification has to become explicit — carried with the reason rather than read off the
    hub's state at the moment the drain happens — and the backlog answered once, before the teardown can
    answer it differently. Then the order stops being a race, and Dispose() can follow.

  2. rbuergi commented on Sep 14, 2026

    @rbuergi
    ContributorAuthor

    The test that shipped with #4278 fails on another PR's branch — and it is the SAME race, from the other side

    RetirementNamesTheCauseWhenTheTeardownWinsTest.TheBacklogIsAnswered_BeforeTheTeardownThatCouldAnswerItDifferently failed on #4282's run 34817271676, shard 1:

    AssertionException : Expected value not to be <null> because the retirement must have disposed
    the activation — without a teardown there is no ordering to assert
    RetirementNamesTheCauseWhenTheTeardownWinsTest.cs(148,0)
    

    #4282's diff is resolve-gate-platform.sh and its shell tests — it cannot reach DataContext's retirement path. main is green on the same commit range (34817860374, 07:26Z).

    What the failure says is the interesting part. The assertion is not the ordering this test exists to pin — it is the precondition for having an ordering at all: the teardown did not happen, so there was nothing to race. That is this issue's own mechanism observed from the other side. #4261 measured Hub.Dispose() (async: posts ShutdownRequest and returns) racing Hub.FailGate(...) on a thread-pool continuation, with the disposal drain winning by ~1 ms. Here the disposal drain apparently did not arrive within the test's window at all, so NotBeNull fired instead of the order assertion.

    So the new guard is itself timing-dependent on the same window it guards, which makes it a source of unattributed reds on unrelated PRs — exactly the cost pattern recorded in #4244 (six occurrences, four PRs, two dequeues). Flagging rather than fixing, because the shape of the fix is this issue's open question: #4261 already records that swapping the two calls is insufficient while AnswerUnreleasableDelivery derives ShuttingDown vs Failed from hub.IsShuttingDown at drain time — the classification has to become explicit before any ordering, including this test's, can stop being a race.

    Until then: a red on this assertion on a PR that cannot reach DataContext is this issue, not that PR's diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions