Skip to content

fix: an upsert always answers (#2454), and CD gains a force-rebuild button (#2622) - #2825

Merged
rbuergi merged 3 commits into
mainfrom
fix/2454-upsert-empty-completion
Aug 30, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/2454-upsert-empty-completion

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

HandleCreateOrUpdateNodeRequest subscribed to its existing-node read with two arms — a value and a fault — and returned request.Processed() immediately. A source that completes without emitting matches neither, so no response was ever posted while the delivery had already been marked handled.

That is #2454's signature verbatim, and it is not theoretical. Measured on shard 3 of run 33325655611:

Hub Cornerstone/Insured RunLevel=Quiescing Disposal=Pending
  PendingCallbacks=1[CreateOrUpdateNodeRequest]
  CreateOrUpdateNodeRequest: AWAITING … → HANDLER_ENTER@portal/nodeops-…
    → HANDLER_EXIT state=Processed
  ⇒ a handler was entered and no reply, completion or fault has been recorded since

— a per-node hub holding an entire mesh disposal open for 19 s, taking the test's 20 s method timeout with it. #2454 recorded the identical trail on RolePlay/Story.

Why DefaultIfEmpty would be the wrong repair

DefaultIfEmpty() emits null, and this handler's value arm reads null as "the node does not exist" and turns it into a create. An empty read means the read produced no answer — not that the node is absent. Inventing a create from it is a silent write on missing information, which is worse than the hang it replaces.

So empty gets its own outcome, and that outcome is a refusal: the caller is told the read produced no result, so create-vs-update could not be decided, and it can retry.

Same idiom as SelfUpdateHostedService's .DefaultIfEmpty(SelfUpdateVerdict.NoOutcome()), and for the reason stated there — "the only thing that stops a third one being added is making 'produced nothing' itself an outcome that gets reported."

The test

Decorates the registered IStorageAdapter so exactly one Read — on a marked path — completes empty; everything else forwards to the real adapter, so the guard and version-writing chain stay intact and the mesh does all the real work. Not a mock of the mesh.

Mutation-checked — and the first two attempts were false passes worth recording. A mutation that does not compile leaves --no-build running the previous binary, which reports green. The third attempt used a runtime-true condition the compiler cannot fold, verified Build succeeded before trusting the result, and then failed correctly:

Expected the observable to emit a value within 20s … but it did not.
The observable emitted nothing at all.
Failed!  - Failed: 1, Duration: 20 s
dotnet build src/MeshWeaver.Mesh.Contract -c Release -warnaserror                    0 Error(s)
dotnet test --filter UpsertEmptyReadIsAnsweredTest|CreateOrUpdateNodeRequestTest     12 passed, 0 failed

Fixes #2454

🤖 Generated with Claude Code

https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK


CD gains a force-rebuild button — #2622 (option 1 of three)

CD's image-set check is keyed on core's HEAD sha. The portal hosts live in MeshWeaver.Plugins, so a merge there editing a file that ships inside the image (appsettings, csproj) changes what the image should contain and changes nothing this check can see.

Measured when it bit: Plugins#814 fixed fresh-install engine activation by editing src/Memex.Portal.Distributed/appsettings.json at 14:54Z; the newest image ci.6327 was built at ~14:4x from core bf1ae3a and therefore predated it — with no producer that would ever rebuild.

🚨 And a manual dispatch was a structural no-op for exactly that case: it takes reason=reconcile → check-image-set.sh <core-sha> → COMPLETE=true → bake_only. An operator who knew the hosts had changed had no way to say so.

workflow_dispatch now takes rebuild (boolean, default false). When set, the complete-image-set branch is skipped and the full set is built, with its own decision line — so a forced build is never mistaken for an ordinary publish in the log.

This is the escape hatch, not the fix. The durable shape is option 2 — record the plugins sha the image was baked from and have check-image-set.sh compare it against plugins@main — so the reconciler notices rather than a human who happens to know. That stays open; the issue is referenced, not closed.

Verified through the extracted step, not a copy. test-cd-steps.py executes the real decide body (found by id:, never copied) and gains three cases — the override publishes; it says it was an explicit rebuild; and rebuild=false leaves the bake-only branch exactly as it was. That last one matters: without it, "the override works" would also pass if the override were always on.

Mutation-checked: removing the FORCE_REBUILD guard fails the first case; restored, all pass.

python3 .github/scripts/test-cd-steps.py            all cases passed, 2 steps
python3 .github/scripts/check-workflow-shell.py     0 live findings

Refs #2622

… completes empty

HandleCreateOrUpdateNodeRequest subscribed to its existing-node read with TWO arms
— a value and a fault — and returned request.Processed() immediately. A source that
COMPLETES WITHOUT EMITTING matches neither arm, so no response was ever posted while
the delivery had already been marked handled.

That is #2454's signature verbatim, and it is not theoretical. Measured on shard 3
of run 33325655611: a per-node hub that could not quiesce —

    Hub Cornerstone/Insured RunLevel=Quiescing Disposal=Pending
      PendingCallbacks=1[CreateOrUpdateNodeRequest]
      CreateOrUpdateNodeRequest: AWAITING … → HANDLER_ENTER@portal/nodeops-…
        → HANDLER_EXIT state=Processed
      ⇒ a handler was entered and no reply, completion or fault has been recorded since

— holding an entire mesh disposal open for 19 s, and taking the test's 20 s method
timeout with it. #2454 recorded the identical trail on RolePlay/Story.

## Why DefaultIfEmpty would be the WRONG repair

DefaultIfEmpty() emits null, and this handler's value arm reads null as "the node
does not exist" and turns it into a CREATE. An empty read means the read produced no
answer — not that the node is absent. Inventing a create from it is a silent write on
missing information, which is worse than the hang it replaces.

So empty gets its own outcome and that outcome is a refusal: the caller is told the
read produced no result, so create-vs-update could not be decided, and it can retry.

Same idiom as SelfUpdateHostedService's .DefaultIfEmpty(SelfUpdateVerdict.NoOutcome())
and for the reason stated there: "the only thing that stops a third one being added is
making 'produced nothing' itself an outcome that gets reported."

## The test

Decorates the registered IStorageAdapter so exactly one Read — on a marked path —
completes empty; everything else forwards to the real adapter, so the guard and
version-writing chain stay intact and the mesh does all the real work.

Mutation-checked, and the first two attempts were FALSE PASSES worth recording: a
mutation that does not compile leaves --no-build running the previous binary, which
reports green. The third attempt used a runtime-true condition the compiler cannot
fold, verified "Build succeeded" BEFORE trusting the result, and then failed correctly:

    Expected the observable to emit a value within 20s … but it did not.
    The observable emitted nothing at all.
    Failed: 1, Duration: 20 s

    dotnet build src/MeshWeaver.Mesh.Contract -c Release -warnaserror   0 Error(s)
    dotnet test  --filter UpsertEmptyReadIsAnsweredTest|CreateOrUpdateNodeRequestTest
                 12 passed, 0 failed

Fixes #2454

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK

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 request/response correctness hole in HandleCreateOrUpdateNodeRequest where an existing-node read that completed without emitting could leave the caller without any response, despite the handler already returning Processed()—matching the teardown/quiesce hang reported in #2454.

Changes:

  • Update the upsert handler to explicitly handle the “completed empty” terminal state by posting a refusal response instead of remaining silent.
  • Add an integration-style regression test that decorates IStorageAdapter.Read to complete empty for one marked path and asserts the caller receives a refusal.
  • Add a What’s New entry documenting the failure mode and the behavioral change.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
test/MeshWeaver.Hosting.Monolith.Test/UpsertEmptyReadIsAnsweredTest.cs Adds regression coverage by forcing a single storage read to complete empty and asserting the upsert answers with a refusal.
src/MeshWeaver.Mesh.Contract/MeshExtensions.cs Adds an onCompleted arm to ensure the upsert always answers, including when the existing-node read completes empty.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-an-upsert-always-answers-its-caller.md Documents the bug, impact, and why “completed empty” must be answered as a refusal.

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

Comment on lines 3918 to +3922
PostFail(grantRejection, NodeUpsertRejectionReason.ValidationFailed);
return Observable.Empty<MeshNode?>();
});

// 🚨 THREE terminal states, not two (MeshWeaver#2454). A two-arm Subscribe answers the
@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

790 tests  ±0   786 ✅ ±0   4m 48s ⏱️ +14s
  7 suites ±0     4 💤 ±0 
  7 files   ±0     0 ❌ ±0 

Results for commit fb0d308. ± Comparison against base commit a6c5e87.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits August 30, 2026 20:48
…nge can ship

CD's image-set check is keyed on CORE's HEAD sha. The portal hosts live in
MeshWeaver.Plugins, so a merge there that edits a file shipping IN the image
(appsettings, csproj) changes what the image should contain and changes nothing
this check can see.

Measured when it bit: Plugins#814 fixed fresh-install engine activation by editing
src/Memex.Portal.Distributed/appsettings.json at 14:54Z; the newest image, ci.6327,
was built at ~14:4x from core bf1ae3a and therefore predated it — with no producer
that would ever rebuild. Delivery had nothing to notice.

🚨 And a manual dispatch was a STRUCTURAL NO-OP for exactly that case: it takes
reason=reconcile → check-image-set.sh <core-sha> → COMPLETE=true → bake_only. So an
operator who KNEW the hosts had changed had no way to say so.

`workflow_dispatch` now takes `rebuild` (boolean, default false). When set, the
decide step's complete-image-set branch is skipped and the full set is built, with
its own decision line naming why — so a forced build is never mistaken for an
ordinary publish in the log.

## This is the escape hatch, and the commit says so

Option 1 of the three the issue lists. The durable shape is option 2 — record the
plugins sha the image was baked from and have check-image-set.sh compare it against
plugins@main — so the RECONCILER notices rather than a human who happens to know.
That stays open; this only means someone who does know can now act.

## Verified through the extracted step, not a copy

test-cd-steps.py executes the real `decide` body (extracted from the workflow by
`id:`, never copied) and gains three cases:

  * rebuild=true overrides a COMPLETE set and publishes;
  * ...and says it was an explicit rebuild, not an ordinary publish;
  * rebuild=false leaves the bake-only branch exactly as it was — without this,
    "the override works" would also pass if the override were always on.

Mutation-checked: removing the `FORCE_REBUILD` guard fails "rebuild=true overrides
a COMPLETE image set and publishes"; restored, all pass.

    python3 .github/scripts/test-cd-steps.py     all cases passed, 2 steps
    python3 .github/scripts/check-workflow-shell.py   clean

Refs #2622

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
@rbuergi rbuergi changed the title fix(#2454): the upsert answers its caller when the existing-node read completes empty fix: an upsert always answers (#2454), and CD gains a force-rebuild button (#2622) Aug 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

949 tests  +2   949 ✅ +2   5m 10s ⏱️ +12s
  7 suites ±0     0 💤 ±0 
  7 files   ±0     0 ❌ ±0 

Results for commit fb0d308. ± Comparison against base commit a6c5e87.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 085 tests  ±0   1 085 ✅ ±0   5m 19s ⏱️ +5s
    7 suites ±0       0 💤 ±0 
    7 files   ±0       0 ❌ ±0 

Results for commit fb0d308. ± Comparison against base commit a6c5e87.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    8 files  ±0      8 suites  ±0   6m 4s ⏱️ +30s
1 530 tests ±0  1 529 ✅ ±0  1 💤 ±0  0 ❌ ±0 
2 220 runs  +2  2 219 ✅ +2  1 💤 ±0  0 ❌ ±0 

Results for commit fb0d308. ± Comparison against base commit a6c5e87.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 016 tests  ±0   2 824 ✅ ±0   5m 40s ⏱️ -7s
    8 suites ±0     192 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit fb0d308. ± Comparison against base commit a6c5e87.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 175 tests   - 1   1 175 ✅  - 1   7m 59s ⏱️ -5s
    7 suites ±0       0 💤 ±0 
    7 files   ±0       0 ❌ ±0 

Results for commit fb0d308. ± Comparison against base commit a6c5e87.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   44 files  ±0     44 suites  ±0   35m 3s ⏱️ +49s
8 545 tests +1  8 348 ✅ +1  197 💤 ±0  0 ❌ ±0 
9 235 runs  +3  9 038 ✅ +3  197 💤 ±0  0 ❌ ±0 

Results for commit fb0d308. ± Comparison against base commit a6c5e87.

@rbuergi
rbuergi added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit 70736c1 Aug 30, 2026
25 checks passed
@rbuergi
rbuergi deleted the fix/2454-upsert-empty-completion branch September 13, 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.

Race: RolePlay/Story install has a CreateOrUpdateNodeRequest outstanding at its own hub disposal (the gate now reports it Inconclusive, #2649)

2 participants