Skip to content

fix(#6351): a recursive delete's commit fans out through one bounded lane; a leaf's refusal keeps its reason - #6357

Merged
rbuergi merged 3 commits into
mainfrom
fix/6351-bounded-delete-commit-fanout
Oct 9, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/6351-bounded-delete-commit-fanout

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Refs #6351, Refs #1198, Refs #6034

What broke

On the control instance, two bulk recursive deletes failed today, 45 minutes apart: Marketing, after removing 1,383 paths, and SocialMedia, after removing 1,060. Each failure was a single leaf whose own commit stage "made no progress for 25s — 0 of 1 planned". The write pool was saturated at the time: pg:Postgres(cap 1) had 219 and 764 waiting, and more than 1,000 admissions waited at least 1 s within the stage.

The governed Logs read Ops/Actions/logs-sevM-a4315-20261009-q1 found 194 such leaf timeouts inside one second, all under SocialMedia/. The backlog was the delete's own leaf writes, not unrelated writes from other partitions.

Root cause

HierarchicalPathDeletion fans every sibling set out with an unbounded Merge, and the levels nest. So the commit posted one leaf delete per planned path all at once. Each leaf re-enters HandleDeleteNodeRequest at its own hub and opens its own no-progress watchdog, which starts when that hub takes the request. Each leaf then ends in one write on the single process-wide cap-1 pool. A leaf deep in that self-made queue saw no progress for its whole budget, even though the cascade was removing rows steadily. Its timeout then failed the whole delete.

The pre-flight has been capped at 64 for this reason all along (PreValidateFanOutConcurrency). The commit was not.

Fix

  • One admission lane for the whole tree: HierarchicalPathDeletion.DeleteSubtreeBounded, sized by MeshOperationOptions.CascadeFanOutConcurrency (default 64, the pre-flight's value). A per-level cap does not work because nested levels multiply.
    • Ordering, independent branches and fail-fast are unchanged.
    • A leg still queued when the traversal fails or is disposed never runs its delete.
    • No budget, cap, timeout or retry changed.
  • A leaf's own NodeDeletionRejectionReason now rides up with the cascade's wrapper. Before this, the root read the wrapper's type as the reason: ValidationFailed plus an Unexpected error: prefix. MeshService.DeleteNode turned that into an UnauthorizedAccessException, which is what the delete-space script logged for an availability failure.

Tests (negative controls run)

  • WideDeleteBoundedFanOutTest / WideDeleteUnboundedFanOutTest (Graph.Test): a 160-leaf root over a storage adapter that serves deletes one at a time at 50 ms (the cap-1 pool), against a 2.5 s leaf budget.
    • Bounded: the delete succeeds and storage is empty.
    • Unbounded: reproduces the production made no progress line.
    • With the reason carry disabled, the unbounded test fails with got UnauthorizedAccessException, which is the production shape.
  • HierarchicalPathDeletionTests (Hosting.Test):
    • the lane holds a two-branch tree to N legs in flight;
    • the unbounded overload starts all 20 leaves (negative control);
    • no queued leg starts after disposal (this test failed before the stop flag was added);
    • fail-fast is preserved.
  • Second write racing a retype's hub recycle is refused as Unknown instead of retry-this #6034: NodeTypePathOccupancyTest.Upsert_RepairIssuedStraightAfterAStrandingRetype_Lands re-adds the back-to-back retype that the filing removed. It is green in the full Graph.Test project run, which is the run that originally failed.

Docs: Doc/Architecture/RecursiveDeleteDrain gains a section with the 2026-10-09 reading on the producing portal and the fix.

Pairs-with: none — only public members are ADDED (HierarchicalPathDeletion.DeleteSubtreeBounded, MeshOperationOptions.CascadeFanOutConcurrency); nothing is removed or renamed.

Deploy: host code, so no recycle is needed. Verify on memex.systemorph.com once an image carrying this rolls: a bulk recursive delete of a 1,000-path space no longer produces [DeleteNode:commit] … 0 of 1 planned lines.

🤖 Generated with Claude Code

rbuergi and others added 2 commits October 9, 2026 16:54
…lane

The bottom-up walk posted one leaf delete per planned path at once. Every
leaf's commit ended on the process-wide cap-1 pg write pool, and each leaf's
own no-progress watchdog counted its wait behind its siblings' writes as a
stall, so a wide delete (Marketing, 1,383 paths; SocialMedia, 1,060) failed
on a leaf while the cascade was progressing. The commit's legs now share one
admission lane for the whole tree (HierarchicalPathDeletion.DeleteSubtreeBounded,
MeshOperationOptions.CascadeFanOutConcurrency, default 64 like the pre-flight).

A failed leaf's own NodeDeletionRejectionReason now rides up with the
cascade's wrapper, so an Unavailable leaf is no longer reported as
ValidationFailed / "Unexpected error" and surfaced by MeshService.DeleteNode
as an UnauthorizedAccessException.

Refs #6351, Refs #1198

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…route the lane's fault arm

Refs #6034, Refs #6351

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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

The retype regression test does not deterministically exercise the failure path, and new mutable collections violate repository rules.

3 open findings
What changed in this PR

Bounds recursive-delete commit fan-out to prevent self-induced write-pool starvation while preserving leaf rejection reasons.

Changes:

  • Adds a tree-wide bounded deletion lane and configurable concurrency.
  • Preserves leaf failure classification through cascade wrappers.
  • Adds regression tests and operational documentation.
File Description
HierarchicalPathDeletionTests.cs Tests bounded traversal behavior.
NodeTypePathOccupancyTest.cs Restores the retype-repair race test.
DeleteDrainCompletionTest.cs Reproduces serialized write-pool pressure.
MeshOperationOptions.cs Adds cascade concurrency configuration.
MeshExtensions.cs Applies bounded deletion and preserves rejection reasons.
HierarchicalPathDeletion.cs Implements the shared admission lane.
RecursiveDeleteDrain.md Documents evidence, cause, and fix.

🧠 Review effort: Balanced


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

Comment on lines +443 to +444
// Straight after — no wait for the rebind recycle to finish, which is the whole point.
var repaired = await Upsert(Instance(id, "Markdown"), allowUnresolvable: false);

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.

Agreed that this fact cannot prove the re-drive happened: whether the second write lands inside the teardown is timing, so it can stay green without the re-drive. It is the end-to-end repro that #6034 named as its close condition, and it is not meant as the pin. The re-drive is pinned deterministically in MeshWeaver.FaultInjection.Test (FleetWatchFreezeTests: mesh.Linger(…) holds the owner in its stop and the write must be re-driven past it; that is #5011's case, which failed with exactly Unknown … Rejecting now before the fix). Fixed in 7af86e7: the doc comment now says this and points at that pin, so nobody reads this fact as the guard.

Comment on lines +319 to +324
private static readonly string[] TwoWideBranches = Enumerable.Range(0, 10)
.SelectMany(i => new[] { $"root/a/{i}", $"root/b/{i}" })
.Concat(new[] { "root/a", "root/b" })
.ToArray();

private static string[] GatedLeaves => TwoWideBranches.Where(p => p.Count(c => c == '/') == 2).ToArray();

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.

Fixed in 7af86e7: both lookups are now ImmutableArray<string>, and GatedLeaves is computed once instead of being re-materialised per access.

Comment thread test/MeshWeaver.Hosting.Test/HierarchicalPathDeletionTests.cs Outdated
… the re-drive deterministically

Refs #6351, Refs #6034

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@meshweaver-cloud
meshweaver-cloud Bot disabled auto-merge October 9, 2026 15:10
@rbuergi
rbuergi enabled auto-merge October 9, 2026 15:42
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    19 files      19 suites   49m 25s ⏱️
10 977 tests 10 977 ✅ 0 💤 0 ❌
10 990 runs  10 990 ✅ 0 💤 0 ❌

Results for commit 7af86e7.

♻️ This comment has been updated with latest results.

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