Skip to content

The upsert's inner create registered its callback after posting, so the reply could be dropped - #1083

Merged
rbuergi merged 4 commits into
mainfrom
fix/981-upsert-inner-create-preregister
Aug 10, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/981-upsert-inner-create-preregister

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes the CreateNodeRequest@mesh/<self> leaked callback that reddened main in
run 31363873251 (df1ed76e1,
shard 2, PandasExplorerLayoutAreaTest). Reopens and addresses #981.

What the capture actually said

The test BODY passed; DisposeAsync threw:

Hub mesh/fIuFzNuTjEahlmI_WY8oHw: 1 pending callback(s) after 2.00s:
  rI5qVdE90UajdNW8Xvdh2g=CreateNodeRequest@mesh/fIuFzNuTjEahlmI_WY8oHw(7328ms)
    AWAITING CreateNodeRequest→mesh/…(+0ms) → REGISTERED_AFTER_POST (Observe(delivery) overload …)(+0ms)

The leaked request is identified. It is CreateOrUpdateNodeRequest's inner create —
MeshExtensions.DispatchInnerCreate — for Doc/…/PandasExplorer/_Activity/compile-state. The same
capture logs [CreateOrUpdate] inner CreateNode faulted for <that path> at teardown, which is the
OnError arm of that subscription firing as the hub tore down. And a CreateNodeRequest posted by
the mesh hub to itself was uniquely that call site: every other awaited CreateNodeRequest in
src/ already used the pre-registering overload.

The defect

var forwarded = hub.Post(inner, o => …WithTarget(hub.Address))!;
hub.Observe(forwarded).Subscribe(…);          // registers the subject AFTER the post

HandleCallbacks drops a response whose correlation id has no registered subject ("No subject
found for response … treating as processed"). So a reply landing in that window is consumed and
gone — the upsert never answers and the callback is pending forever.

The window is not theoretical here:

  • DispatchInnerCreate runs off the hub action block — it is the continuation of
    persistence.Read(...) — so KickDrain finds draining == false and schedules the create's turn
    onto turnScheduler, on another thread, before Post returns (PostImplGeneric calls
    ScheduleNotify synchronously).
  • Preempt the posting thread, which a saturated CI runner does routinely, and the turn answers first.

This is the same defect already fixed at four other sites, with the rule written down in
MeshNodeStreamExtensions and proven deterministically by
GetMeshNode_WarmOwner_DropsResponse_WhenSubjectRegisteredAfterPost.

It also erased the evidence: RequestFateLedger starts a trail at REGISTRATION, so the post-hoc
overload lost POSTED/RECEIVED/ENQUEUED/ROUTED and produced exactly the trail the ledger
itself annotates with "re-run with the caller switched to Observe(request, options), which
registers first."
This PR is that re-run.

Fix

One call instead of two — hub.Observe(inner, options), which registers the AsyncSubject before
posting. Identity is unaffected: inner.CreatedBy pins it as a request FIELD, the post stamps
inboundCtx, ApplyUpdateViaStream opens its own SwitchAccessContext(inboundCtx), and
PostOk/PostFail are ResponseFor posts.

Pin — deterministic, no sleep

UpsertInnerCreateObservationTest pins the drop on the exact #981 message shape (a self-targeted
CreateNodeRequest on the mesh hub), with both orderings side by side so the arms are each other's
control:

  • ARM 1 post-then-observe → the reply is provably gone (NotEmit),
  • ARM 2 pre-register → the same reply is buffered and replayed to an arbitrarily late subscriber.

The fence is node readability plus two complete round-trips, never a fixed delay — so it cannot
race. A second test guards the production seam end to end (an upsert of a missing node must answer).

And the reason it keeps being written

AsynchronousCalls.md presented the two overloads as interchangeable and carried a worked example
headed "When you already have a delivery" that is the defect verbatim. That is fixed too (rule
3a + the Rules Summary row + the RegisterCallback migration row), which is the actual root cause of
the recurrence.

Honest limit

The pin proves the mechanism and identifies this as the only call site that could produce a
CreateNodeRequest@mesh/<self> leak. It does not prove this particular CI capture went through
that window — and it cannot, because the post-hoc registration is what destroyed the trail. If the
leak recurs after this lands, the trail will carry every stage and name the mechanism outright.

Known remaining post-then-observe site, deliberately out of scope: MeshNodeStreamExtensions'
UpdateRemote (post at ~1007, observe at ~1134). It is bounded by UpdateResponseWaitBound with an
optimistic-snapshot fallback, so a lost reply degrades rather than hangs — but it should be migrated.

Verification

  • dotnet build test/MeshWeaver.Hosting.Monolith.Test -c Release -warnaserror — 0 warnings, 0 errors.
  • MeshWeaver.Hosting.Monolith.Test full project: 515 passed, 0 failed, 4 skipped — including
    PandasExplorerLayoutAreaTest and CreateOrUpdateNodeRequestTest.
  • DocumentationLinkIntegrityTest green.
  • Branch merged with current main.
  • What's New entry shipped (Category: Fix).

Not a fix for the shard-0 Hosting.Orleans.Test failure in the same run — that is #1081, owned by
#1073, and unrelated to this change.

🤖 Generated with Claude Code

rbuergi and others added 4 commits August 10, 2026 09:43
…he reply could be dropped

`CreateOrUpdateNodeRequest`'s `DispatchInnerCreate` posted a self-targeted
`CreateNodeRequest` and only THEN registered the response subject via the
`Observe(delivery)` overload. `MessageHub.HandleCallbacks` drops any response whose
correlation id has no registered subject ("No subject found for response … treating as
processed"), so a reply that lands in that window is consumed and gone — the upsert then
never answers and the caller's callback stays pending until the teardown quiescing budget
reports it as a leaked `CreateNodeRequest@mesh/<self>`, which is exactly the #981
signature.

The window is not theoretical here. `DispatchInnerCreate` runs OFF the hub action block —
it is the continuation of `persistence.Read(...)` — so `KickDrain` finds `draining ==
false` and schedules the create's turn onto `turnScheduler`, on another thread, before
`Post` even returns (`PostImplGeneric` calls `ScheduleNotify` synchronously). Preempt the
posting thread and the turn can answer first.

This is the same defect the codebase has already fixed at four other sites, with the rule
written down in `MeshNodeStreamExtensions` and proven deterministically by
`GetMeshNode_WarmOwner_DropsResponse_WhenSubjectRegisteredAfterPost`: always the
pre-registering `hub.Observe(request, options)`, never `hub.Post` then
`hub.Observe(delivery)`.

It also destroyed the evidence. `RequestFateLedger` starts a trail at REGISTRATION, so the
post-hoc overload lost POSTED / RECEIVED / ENQUEUED / ROUTED and left the CI capture
reading "AWAITING → REGISTERED_AFTER_POST" and nothing else — the trail the ledger itself
annotates with "re-run with the caller switched to Observe(request, options), which
registers first".

Adds `UpsertInnerCreateObservationTest`: the drop is pinned deterministically on the exact
#981 message shape (a self-targeted `CreateNodeRequest` on the mesh hub) with both
orderings side by side, fenced on node readability plus two round-trips rather than a
sleep, and the upsert seam is guarded end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GetMeshNode answers null and COMPLETES for a node that is not there yet, so awaiting it
raced the create instead of fencing behind it. Re-query on an interval until the node is
readable, then take the two round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AsynchronousCalls.md presented `Observe(delivery)` and `Observe(request, options)` as
interchangeable, and carried a worked example titled "When you already have a delivery"
that is the defect verbatim. That is why this bug has now been written five times.

Rule 3a states the asymmetry: the pre-registering overload always; `Observe(delivery)` only
when the response provably cannot have arrived, which "the delivery is already posted" is
not. The Rules Summary row and the RegisterCallback migration row now say the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 10, 2026 07:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a race in CreateOrUpdateNodeRequest where the inner self-targeted CreateNodeRequest could post its response before the response subject was registered, causing the response to be dropped and leaving a callback pending until teardown (as reported in #981 / run 31363873251).

Changes:

  • Switched the upsert’s inner create dispatch to the pre-registering hub.Observe(request, options) pattern so responses can’t be dropped between Post and Observe.
  • Added a deterministic regression test that demonstrates the drop with post-then-observe ordering and verifies the safe pre-registered ordering.
  • Updated async/reactive guidance docs to explicitly call out the unsafe pattern, and added a What’s New entry for the user-visible impact (“save could succeed but never confirm”).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
test/MeshWeaver.Hosting.Monolith.Test/UpsertInnerCreateObservationTest.cs Adds regression coverage pinning the dropped-response mechanism and verifying upsert always answers.
src/MeshWeaver.Mesh.Contract/MeshExtensions.cs Fixes CreateOrUpdateNodeRequest inner create to pre-register the callback before posting.
src/MeshWeaver.Documentation/Data/Architecture/AsynchronousCalls.md Documents that Observe(request, options) is required to avoid response drops; updates examples and rules summary.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-10-upsert-always-answers.md Changelog entry describing the resolved “save completed but never reported back” symptom.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

697 tests  ±0   697 ✅ +1   14m 23s ⏱️ +21s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌  - 1 

Results for commit f748e6d. ± Comparison against base commit df1ed76.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 198 tests  +3   1 198 ✅ +3   6m 7s ⏱️ -8s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit f748e6d. ± Comparison against base commit df1ed76.

This pull request removes 18 and adds 21 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ ActivityArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ ActivityArea_WorksForRuntimeCreatedUser
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ EditProfileArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ OverviewArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ ProfileArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserHub_Roland_CanBeCreated
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserNodeType_HasHubConfig_InMeshConfiguration
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserNodeType_IsRegistered
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserNode_Roland_CanBeLoaded
MeshWeaver.Hosting.Monolith.Test.UserIdentityLeakTest ‑ AccessService_Context_ReturnsOnlyAsyncLocal
…
MeshWeaver.Hosting.Monolith.Test.UpsertInnerCreateObservationTest ‑ SelfTargetedCreate_OnTheMeshHub_DropsItsReply_WhenTheSubjectIsRegisteredAfterPost
MeshWeaver.Hosting.Monolith.Test.UpsertInnerCreateObservationTest ‑ Upsert_OfAMissingNode_Answers_ThroughTheInnerCreate
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ BareUserNodeType_StillResolvesToCatalogNode
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ CanonicalBareId_ResolvesToUserRootPartition
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ LegacyUserPrefix_RendersHomeArea_NotAreaNotFoundPlaceholder
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ LegacyUserPrefix_ResolvesToUserRootPartition_NotAreaOnUserHub
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ LegacyUserPrefix_WithTrailingArea_KeepsAreaOnUserRoot
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ SharedResolvePath_DoesNotRewriteLegacyUserHome_PreservesReadRouteInvariant
MeshWeaver.Hosting.Monolith.Test.UserNodeAccessTest ‑ FileBrowserControl_HasReadOnly_WhenNoEditPermission
MeshWeaver.Hosting.Monolith.Test.UserNodeAccessTest ‑ Member_CanRead_Organization
…

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 756 tests  ±0   1 564 ✅ ±0   4m 48s ⏱️ - 1m 17s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit f748e6d. ± Comparison against base commit df1ed76.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 967 tests   - 1   1 965 ✅ ±0   5m 54s ⏱️ -40s
   11 suites ±0       2 💤 ±0 
   11 files   ±0       0 ❌  - 1 

Results for commit f748e6d. ± Comparison against base commit df1ed76.

This pull request removes 19 and adds 18 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ BareUserNodeType_StillResolvesToCatalogNode
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ CanonicalBareId_ResolvesToUserRootPartition
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ LegacyUserPrefix_RendersHomeArea_NotAreaNotFoundPlaceholder
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ LegacyUserPrefix_ResolvesToUserRootPartition_NotAreaOnUserHub
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ LegacyUserPrefix_WithTrailingArea_KeepsAreaOnUserRoot
MeshWeaver.Hosting.Monolith.Test.UserHomeLegacyPathResolutionTest ‑ SharedResolvePath_DoesNotRewriteLegacyUserHome_PreservesReadRouteInvariant
MeshWeaver.Hosting.Monolith.Test.UserNodeAccessTest ‑ FileBrowserControl_HasReadOnly_WhenNoEditPermission
MeshWeaver.Hosting.Monolith.Test.UserNodeAccessTest ‑ Member_CanRead_Organization
MeshWeaver.Hosting.Monolith.Test.UserNodeAccessTest ‑ Member_CanRead_OrganizationChild
MeshWeaver.Hosting.Monolith.Test.UserNodeAccessTest ‑ Owner_CanRead_OwnChildNode
…
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ ActivityArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ ActivityArea_WorksForRuntimeCreatedUser
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ EditProfileArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ OverviewArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ ProfileArea_CanBeResolved_ForUserRoland
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserHub_Roland_CanBeCreated
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserNodeType_HasHubConfig_InMeshConfiguration
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserNodeType_IsRegistered
MeshWeaver.Hosting.Monolith.Test.UserActivityAreaTest ‑ UserNode_Roland_CanBeLoaded
MeshWeaver.Hosting.Monolith.Test.UserIdentityLeakTest ‑ AccessService_Context_ReturnsOnlyAsyncLocal
…

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 42s ⏱️ +37s
1 977 tests ±0  1 869 ✅ ±0  108 💤 ±0  0 ❌ ±0 
2 130 runs  +1  2 022 ✅ +1  108 💤 ±0  0 ❌ ±0 

Results for commit f748e6d. ± Comparison against base commit df1ed76.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 284 tests  ±0   1 279 ✅ ±0   6m 46s ⏱️ +55s
   11 suites ±0       5 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit f748e6d. ± Comparison against base commit df1ed76.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ±0     65 suites  ±0   43m 42s ⏱️ -12s
8 879 tests +2  8 572 ✅ +4  307 💤 ±0  0 ❌  - 2 
9 032 runs  +3  8 725 ✅ +5  307 💤 ±0  0 ❌  - 2 

Results for commit f748e6d. ± Comparison against base commit df1ed76.

@rbuergi
rbuergi merged commit 779c0cc into main Aug 10, 2026
18 checks passed
@rbuergi
rbuergi deleted the fix/981-upsert-inner-create-preregister branch August 19, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants