Skip to content

fix(delete): a vanished descendant no longer refuses the whole recursive delete - #4696

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/4680-recursive-delete-vanished-descendant
Sep 18, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/4680-recursive-delete-vanished-descendant

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

The defect

A recursive delete plans its subtree by enumerating storage once (stage 3), and both later
stages address that one snapshot: the bulk-atomic pre-flight (3b) and the bottom-up commit (4). A
concurrent delete that removed one of the planned leaves at any point afterwards left the operation
holding a path that really was gone — and the whole subtree delete was refused over a node already
in exactly the state the caller asked for. That is #4668's decision one level down: an absent node
satisfies the delete's postcondition for itself.

Why the obvious fix does not apply

The issue measured it, and this PR re-measured it before changing anything. Relaxing the
pre-flight's NodeNotFound verdict is dead code for the dominant shape: with no row at the
address and no activated hub to short-circuit on, the post never ROUTES, so no verdict is produced
at all — the leg's fall-through reports

Cannot delete 'TestData/vanishing-descendant/gone-by-preflight':
No node found at 'TestData/vanishing-descendant/gone-by-preflight'.
Closest ancestor is 'TestData/vanishing-descendant' (remainder='gone-by-preflight').
This usually means the node is missing, has no NodeType, or has an invalid NodeType.

That sentence names three different situations, and the last two are precisely what the
bulk-atomic pre-flight exists to refuse before any storage side effect fires (#1198 / #1446 — a
partially destroyed subtree). The verdict half is no better: it says what the handler could read,
not that the row is gone.

The fix

MeshExtensions.ConfirmDescendantGone asks the store of record — IStorageAdapter.Exists — and
emits confirmed gone, never exists, so failing closed is structural: a probe that errors or
does not answer inside its own bound emits false and the original refusal stands, reason and
message untouched. It is a read on an error path only (a leg that has already failed, never once
per planned descendant), bounded one rung inside the leg it runs in — it runs in that leg's .Catch,
past the point the leg's own bound covers — derived with MeshOperationOptions.Nest like every other
rung on this path.

Both stages take the reading, because both address the same snapshot. Fixing only the pre-flight
moves the failure one stage later, and that was measured too: [DeleteNode] not-found … partial-deleted=0. The commit leg then emits nothing, so the vanished path is not recorded as
removed and the operation still says truthfully what it took away rather than claiming somebody
else's removal.

🚨 This cannot report success over a live node, and the reason is structural: "drained" is decided by
DeleteSubtreeUntilDrained's own storage re-enumeration, independent of every leg's verdict. A
path that is still there comes back as a survivor and is deleted in a follow-up pass; a subtree that
never drains still fails loudly at MaxDeleteDrainPasses.

The test, and the control

RecursiveDeleteVanishedDescendantTest — an IStorageAdapter decorator over a real
InMemoryStorageAdapter (nothing mocked; every call reaches the store) that removes one enumerated
descendant after the snapshot is taken and before it is answered, through the inner adapter's own
Delete so it publishes on the change feed exactly as another writer's delete would.

The second test is the discriminator, not a restatement: same leaf, same unroutable address, same
refusal wording — and a store of record that says the node is there (the stand-in for a NodeType
that will not load). The delete must still be refused, with the original message, and nothing may be
removed. Without it the first test would pass equally well on a pre-flight that had simply been
taught to ignore routing failures.

Measured both ways

dotnet build … -c Release -warnaserror, one project per invocation, 0 Error(s) confirmed before
every run, and the built assembly checked to carry (or not carry) the new symbols.

RecursiveDeleteVanishedDescendantTest
with the fix 2 total, 0 failed, 2 succeeded — 1.1 s
fix reverted (git apply -R, rebuilt, symbols absent from the test bin) 2 total, 1 failed, 1 succeeded — the subject fails on the routing refusal; the control still passes
pre-flight half only subject fails one stage later: [DeleteNode] not-found … partial-deleted=0

No regression: MeshWeaver.Graph.Test ~Delete 23/23 · MeshWeaver.Hosting.Test ~Delete 48/48 ·
MeshWeaver.Documentation.Test guards (link integrity, What's New integrity, front matter, unkeyed
activity log, hub-reachable async, hand-woven gates, test-timeout literals, subscribe error arms,
ToTask bridges) 37/37.

Conserved

  • Doc/Architecture/IdempotentDelete — the "🚧 measured and NOT fixed here" section is replaced by
    the account above (why the obvious fix fails, what the discriminator is, why both stages).
  • Doc/Architecture/RecursiveDeleteDrain §4 — the pre-flight section gains the same rule.
  • What's New entry (Category: Fix, Order: -20260918).

Fixes #4680

🤖 Generated with Claude Code

…ive delete

A recursive delete plans its subtree by enumerating storage ONCE (stage 3), and
both later stages address that one snapshot: the bulk-atomic pre-flight (3b) and
the bottom-up commit (4). A concurrent delete that removed one of the planned
leaves at any point afterwards left the operation holding a path that really was
gone, and the WHOLE subtree delete was refused over a node already in exactly the
state the caller asked for -- #4668's decision one level down.

Relaxing the pre-flight's NodeNotFound verdict is dead code for the dominant
shape, which is why #4680 was filed rather than bundled: with no row at the
address and no activated hub to short-circuit on, the post does not ROUTE, so no
verdict is produced at all and the leg's fall-through reports "No node found at
'X'. Closest ancestor is ...". That sentence names three different situations,
and the last two ("has no NodeType", "has an invalid NodeType") are precisely
what the bulk-atomic pre-flight exists to refuse before any storage side effect
fires (#1198/#1446). A verdict is no better: it says what the handler could read,
not that the row is gone.

So the discriminator is the store of record. ConfirmDescendantGone asks
IStorageAdapter.Exists and emits "confirmed gone" -- never "exists" -- so failing
closed is structural: a probe that errors or does not answer inside its own bound
emits false and the original refusal stands, reason and message untouched. It is
a read on an ERROR PATH ONLY, bounded one rung inside the leg it runs in (it runs
in that leg's .Catch, past the point the leg's bound covers), derived with
MeshOperationOptions.Nest like every other rung here.

Both stages take it, because both address the same snapshot -- fixing only the
pre-flight moved the failure one stage later, measured as
"[DeleteNode] not-found ... partial-deleted=0". The commit leg then emits
NOTHING, so the vanished path is not recorded as removed and the operation still
says truthfully what IT took away. This cannot report success over a live node:
"drained" is decided by DeleteSubtreeUntilDrained's own storage re-enumeration,
independent of every leg's verdict, so a surviving path comes back as a survivor
and a subtree that never drains still fails at MaxDeleteDrainPasses.

RecursiveDeleteVanishedDescendantTest is the pair. An IStorageAdapter decorator
over a real InMemoryStorageAdapter removes one enumerated descendant after the
snapshot is taken and before it is answered. The second test is the
discriminator: same leaf, same unroutable address, same refusal wording, and a
store of record that says the node IS there -- the delete must still be refused
with nothing removed.

Measured, Release -warnaserror, one project per invocation:
  with the fix     RecursiveDeleteVanishedDescendantTest  2/2 passed, 1.1 s
  fix reverted     1 failed (the subject), 1 succeeded (the control)
  regression       Graph.Test ~Delete 23/23 · Hosting.Test ~Delete 48/48 ·
                   Documentation.Test guards 37/37

Fixes #4680

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

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.

🟡 Changes recommended

Address the critical test setup issue and three moderate implementation issues before approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR makes recursive deletes tolerate descendants removed concurrently while preserving refusal for nodes still present.

Changes:

  • Adds storage-backed absence handling during delete pre-flight and commit.
  • Adds regression and negative-control tests.
  • Updates architecture documentation and What's New.
File summaries
File Review
test/MeshWeaver.Graph.Test/RecursiveDeleteVanishedDescendantTest.cs Critical: Fix adapter initialization before registration.
src/MeshWeaver.Mesh.Contract/MeshExtensions.cs Moderate: Use writable-only existence checks, fail closed on empty probe results, and handle AlreadyAbsent commit responses without recording deletion.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-18-recursive-delete-tolerates-vanished-descendant.md No findings.
src/MeshWeaver.Documentation/Data/Architecture/RecursiveDeleteDrain.md No findings.
src/MeshWeaver.Documentation/Data/Architecture/IdempotentDelete.md No findings.
Review details

Suppressed comments (2)

src/MeshWeaver.Mesh.Contract/MeshExtensions.cs:4562

  • Use the writable-only existence predicate here. ListDescendantPaths (the plan) and the drain verifier both operate on writable providers, but PersistenceService.Exists ORs read-only providers too (PersistenceService.cs:454-474). If a concurrent delete removes a writable override while an embedded/static row remains at the same path, this probe reports the descendant as present and aborts even though the planned/deletable row is gone. Call ExistsInWritableStorage so this check matches the delete's snapshot and drain semantics.
        => storage.Exists(path)

src/MeshWeaver.Mesh.Contract/MeshExtensions.cs:4565

  • This does not fail closed when the probe completes without emitting: Take(1) followed by Select leaves an empty source empty, and Timeout does not fire after a normal completion. The outer SelectMany can therefore treat an unanswered existence check as no blocker and proceed. Add a DefaultIfEmpty(false) to the confirmed-gone result before the timeout so an empty probe remains a refusal.
        => storage.Exists(path)
            .Take(1)
            .Select(exists => !exists)
            .Timeout(probeBudget)
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +183 to +193
private readonly VanishingDescendantStorageAdapter storage = new(new InMemoryStorageAdapter());

/// <inheritdoc />
protected override MeshBuilder ConfigureMesh(MeshBuilder builder)
=> base.ConfigureMesh(builder.ConfigureServices(services =>
{
// Registered BEFORE the bootstrap's TryAddSingleton, so this IS the store of record and
// production's version-writing / monotonic-guard decorators wrap it unchanged.
services.AddSingleton<IStorageAdapter>(storage);
return services;
}));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This one I believe is incorrect, and the language semantics are the reason — but it is a good thing to have checked, because if it held the test would be VACUOUS.

In C#, a derived class's field initializers run before the base constructor is invoked — the reverse of Java, where field initializers run after super() and the virtual-call-from-constructor bug is genuinely live. So the order here is:

  1. storage = new VanishingDescendantStorageAdapter(new InMemoryStorageAdapter()) (line 183, derived field initializer)
  2. MonolithMeshTestBase(output) — whose body reaches ConfigureMesh(...) at MonolithMeshTestBase.cs:216
  3. the derived constructor body

storage is therefore non-null when ConfigureMesh registers it.

The empirical corroboration is the negative control, which is why it was run: with the fix patched out and the project rebuilt (new log symbols verified absent from the assembly), the subject test fails with Cannot delete …: No node found at … — the vanished-descendant refusal. That failure is only reachable if the decorator was installed and actually removed the leaf mid-operation; a null or absent registration would fail differently, or not at all. storage.Vanished.Should().BeTrue(...) in the subject test asserts the same thing directly.

Leaving the registration as it is.

Comment on lines +4451 to +4455
// 🚨 THE LEAF VANISHED WHILE THE CASCADE WAS IN FLIGHT — #4680, the commit half
// of the same fact the pre-flight now settles. The plan is a snapshot, so a
// concurrent delete can remove one of its leaves at ANY point after it is taken;
// the pre-flight's window is merely the first one. With no row at the address
// and no activated hub to short-circuit on, this post does not ROUTE, and the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, and it is the same false claim reached by the other door — fixed in e76ab8c8d4.

The .Catch only sees the leaf whose hub is GONE, so the post does not route. With the hub still ACTIVATED the post routes normally, HandleDeleteNodeRequest answers NothingToDelete() (Success=true, AlreadyAbsent=true, #4668), and that lands in the success branch — returning the path so .Do(RecordDeleted) counts a removal this cascade did not perform. That is exactly the truthfulness this change exists to keep, and it is the COMMON production shape: delete a node, then delete its parent recursively.

The success branch now emits nothing when resp.AlreadyAbsent, identically to the confirmed-gone arm.

🚨 Being explicit about what this is NOT: there is no discriminating test for this arm. IMeshService.DeleteNode returns IObservable<bool>, so the deleted-path list this affects is not observable through the public API — a test that could actually fail needs AffectedPaths/DeletedPaths surfaced to the caller, or the activity log captured. I would rather leave that stated than imply coverage that does not exist. The existing pair still passes 2/2 and still discriminates the arm it was written for.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

264 tests  ±0   264 ✅ ±0   2m 40s ⏱️ -7s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit e76ab8c. ± Comparison against base commit 869e30f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

398 tests   - 31   398 ✅  - 31   49s ⏱️ -11s
  2 suites ± 0     0 💤 ± 0 
  2 files   ± 0     0 ❌ ± 0 

Results for commit e76ab8c. ± Comparison against base commit 869e30f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

533 tests  ±0   533 ✅ ±0   1m 0s ⏱️ +5s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit e76ab8c. ± Comparison against base commit 869e30f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

494 tests   - 1 003   494 ✅  - 1 001   5m 34s ⏱️ - 2m 3s
  2 suites  -     2     0 💤  -     2 
  2 files    -     2     0 ❌ ±    0 

Results for commit e76ab8c. ± Comparison against base commit 869e30f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    3 files   -   3      3 suites   - 3   5m 42s ⏱️ - 5m 21s
2 739 tests  - 480  2 739 ✅  - 288  0 💤  - 192  0 ❌ ±0 
2 743 runs   - 480  2 743 ✅  - 288  0 💤  - 192  0 ❌ ±0 

Results for commit e76ab8c. ± Comparison against base commit 869e30f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    3 files  ±    0      3 suites  ±0   13m 34s ⏱️ + 7m 37s
3 626 tests +1 246  3 626 ✅ +1 246  0 💤 ±0  0 ❌ ±0 
3 627 runs  +1 246  3 627 ✅ +1 246  0 💤 ±0  0 ❌ ±0 

Results for commit e76ab8c. ± Comparison against base commit 869e30f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   12 files   -   5     12 suites   - 5   29m 22s ⏱️ -1s
8 054 tests  - 268  8 054 ✅  - 74  0 💤  - 194  0 ❌ ±0 
8 059 runs   - 268  8 059 ✅  - 74  0 💤  - 194  0 ❌ ±0 

Results for commit e76ab8c. ± Comparison against base commit 869e30f.

♻️ This comment has been updated with latest results.

… absent

Copilot's second finding is right, and it undermines the exact property this
change exists to keep.

The `.Catch` added below only sees the leaf whose hub is GONE, so the post does
not route. When the hub is still ACTIVATED the post routes normally and
`HandleDeleteNodeRequest` answers `NothingToDelete()` — `Success=true`,
`AlreadyAbsent=true` (#4668) — which lands in the SUCCESS branch, returns the
path, and `.Do(RecordDeleted)` then counts a removal this cascade did not
perform. That is the same false claim as the one the fix removes, reached by the
other door, and it is the common production shape: delete a node, then delete its
parent recursively.

The success branch now emits NOTHING for `AlreadyAbsent`, identically to the
confirmed-gone arm, so the operation still says what IT removed.

🚨 Not covered by a discriminating test, and I would rather say so than imply
otherwise: `IMeshService.DeleteNode` returns `IObservable<bool>`, so the
deleted-path list this arm affects is not observable through the public API. A
test that could actually fail needs the reported paths (`AffectedPaths` /
`DeletedPaths`) exposed to the caller, or the activity log captured and asserted.
The existing pair still passes (2/2) and still discriminates the arm it was
written for.

Verified: MeshWeaver.Graph.Test builds Release `-warnaserror`, 0 Warning(s),
0 Error(s); RecursiveDeleteVanishedDescendantTest 2/2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 4ebef2b Sep 18, 2026
41 of 42 checks passed
@rbuergi
rbuergi deleted the fix/4680-recursive-delete-vanished-descendant branch October 10, 2026 13:10
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.

Recursive delete is still aborted by a descendant that vanished between its plan and its pre-flight

2 participants