Skip to content

fix(graph): deleting a stranded partition's RECORD finishes its teardown (#5073, defect 2) - #5203

Merged
rbuergi merged 2 commits into
mainfrom
fix/5073-orphaned-partition-is-not-warmed
Sep 22, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/5073-orphaned-partition-is-not-warmed

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

#5073, defect 2 — "The enumeration snapshot must not outlive the partitions it names"

Issue #5073 stays OPEN after this merge — sev:H is verified in production after the roll, never at a merge. This PR is the second half; #5163 was the first. (Worded this way on purpose: GitHub parses a negated closing keyword as a closing keyword, which is how the sibling issue was auto-closed at its PR merge and had to be reopened.)

What is actually wrong, measured on the control instance after #5163 was live

Image c55301f3 (carries #5163 and #5152), replica booted 2026-09-22 09:03Z:

So the rows outlive the ROOT, not the store, and no witness the sweep could ask can tell "hasn't existed in ages" from "exists to every instrument" — by the platform's own definition. What was missing was a verb.

The change

StrandedPartitionTeardownValidator (INodeValidator on Delete, registered by AddGraph next to the structural teardown): deleting the partition's RECORD — delete @Admin/Partition/{partition}, reachable by a platform admin through the ordinary delete — finishes the teardown with the same provider drop and cache eviction the root delete runs (PartitionDropPostDeletionHandler.DropStores, now shared so the two cannot drift), under the same tombstone (#3451). A validator, not a post-deletion handler (review): the drop runs BEFORE the record goes, and a drop that fails REFUSES the delete so the record stays as the retry handle — the ordinary teardown's own ordering, and OwnsPartitionProvisioningValidator's shape on the creation side. The claim (BeginSubtreeDeletion) is taken first and the verdict inside it. Only a DIRECT record delete counts.

Only for a STRANDED partition — the property that keeps a global admin a platform admin rather than a data superuser. Stranded is a CONJUNCTION (review): a root nobody can reach the partition through AND nobody who owns it:

leg asked why
the root absent (no durable row, no static root), OR exactly the heal's fingerprint — Space, named after the partition, no content, created by System the ordinary delete needs a root; the shell can only come from the bootstrap re-rooting an orphan
and nobody owns it no grant under {p}/_Access (durable or static), no GitSync config — asked for BOTH root shapes the recursive delete that orphaned a partition never visits the access satellite, so a rootless partition can still carry its grants, and its grant holders reach it through the placeholder root

Anything else (a real root, a surviving grant, a synced or static partition) is somebody's: the store is untouched and the record delete says so at Warning. Every probe fails closed (unreadable ⇒ owned). The validator stands down while the partition's own deletion is in flight or on record, so the ordinary teardown — which deletes this same record as its step 2 — never drops twice.

Controls, both sides (StrandedPartitionRecordTeardownTest, monolith mesh, store stand-in as in PartitionResurrectionTest)

  • On this branch: Total: 8, Failed: 0 — rootless drops; ownerless shell drops; live plugin-like partition keeps its store; shell with one grant keeps its store; rootless + surviving grant keeps its store (review); a faulting drop REFUSES the delete with the record intact and the tombstone lifted (review); the ordinary root delete records exactly ONE drop; the shell fingerprint member by member.
  • With the registration reverted to main's (validator present, unregistered): Total: 8, Failed: 3 — exactly the three positive cases, the five controls hold.
  • Neighbours unchanged: PartitionResurrectionTest, PartitionTeardownCoverageTest, PartitionTeardownDropsAnchoredQueriesTest + this class — Total: 19, Failed: 0. DocumentationLinkIntegrityTest — Passed (trx).
  • The first draft (a post-deletion handler writing the record back on a failed drop) was refuted by its own test — the write-back is refused inside the record's deletion scope — which is why this is a validator.
  • Builds: MeshWeaver.Graph, MeshWeaver.Graph.Test, MeshWeaver.Documentation.Test — -c Release -warnaserror, 0 Error(s) each.

Retracted on the way

A root-row witness in the bake sweep ("a listing that does not hold the root is a real negative") was implemented, tested, and retracted: it contradicts the pinned heal invariant above, and it would have been inert on a re-healed UWDeepfield anyway. The doc page says why.

Docs

  • Doc/Architecture/AStaleIndexRowConfirmsItself — the second defect, the measured state, why no witness can see an orphan, the verb and its decision table, what is still not fixed (the running-replica synced-query residue is named as NOT measured).
  • Doc/Architecture/PartitionTeardown → Auditing a portal for orphans now ends in the verb.

Post-roll verification (the read that closes defect 2 — on the thread)

On the control instance, after the roll carrying this: delete @Admin/Partition/UWDeepfield (a platform admin) → the log carries [StrandedPartition] finishing the teardown of 'UWDeepfield' … and Dropped partition 'UWDeepfield' across N provider(s) …; get @Admin/Partition/UWDeepfield → Not found; the next replica's /health bake-report no longer names UWDeepfield/…, and its total drops by that partition's NodeType count. If the log instead says "NOT stranded", the partition still carries a grant or was re-rooted and granted — then the grant holder tears it down through its root, or the stale grants are removed first and the record deleted again. If the delete is REFUSED naming a drop failure, the record is still there and the drop can be retried.

Pairs-with: none — additive only (one new public validator type; PartitionDropPostDeletionHandler's public surface is unchanged, two private members became internal static).

🤖 Generated with Claude Code

…own (#5073, defect 2)

A partition root deleted before the structural teardown existed (#3436) left its
schema, every row in it — NodeType definitions included — and its Admin/Partition
record behind. Nothing could drop it: the ordinary delete needs a root, and the
first child create into the partition (the compile watcher cutting a Release for a
surviving NodeType row is one) makes EnsurePartitionBootstrap heal a Space root
under System with no grant, so the partition comes back as a shell nobody can
see, own or delete. Every instrument then reports it live and the bake sweep
compiles its NodeTypes on every roll — which is why #5163's store witness, right
for a DROPPED schema, is inert on UWDeepfield (its record still exists, v4).

The record is the one artefact that outlives the data by design and lives in
Admin, so a platform admin reaches it with the ordinary delete.
StrandedPartitionRecordTeardownHandler makes `delete @Admin/Partition/{p}` run
the same provider drop and cache eviction the root delete runs, under the same
tombstone (#3451) — ONLY for a stranded partition: no root at all, or the
bootstrap's own ownerless Space shell (its exact fingerprint, no grant, no
GitSync). A live, owned partition keeps its store, so a global admin stays a
platform admin and not a data superuser. The drop is extracted from
PartitionDropPostDeletionHandler so the two teardowns cannot drift, and the
record handler stands down while the ordinary teardown is on record.

Controls (StrandedPartitionRecordTeardownTest, monolith mesh): rootless drops,
ownerless shell drops, live plugin-like partition keeps its store, shell with one
grant keeps its store, ordinary teardown drops exactly once, shell fingerprint
member by member — 6/6; with the registration reverted the two positive cases
fail (2/6), the four controls hold. A root-row witness in the sweep was tried and
retracted: it contradicts AMissingRootOverALivePartition_IsStillHealed.

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

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.

Copilot review overview

🟡 Changes recommended

Unresolved critical concurrency and ownership-safety issues can cause destructive cleanup.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity · 1 Medium severity

Open (3)
What changed in this PR

Adds record-based teardown for stranded partitions, sharing cleanup with normal root deletion and documenting recovery.

Changes:

  • Detects stranded partitions and drops their stores.
  • Registers shared teardown handling.
  • Adds integration tests and architecture documentation.
File Summary
test/​MeshWeaver.Graph.Test/​StrandedPartitionRecordTeardownTest.cs Adds coverage for stranded, live, and duplicate-drop scenarios.
src/​MeshWeaver.Graph/​Configuration/​StrandedPartitionRecordTeardownHandler.cs Implements record-triggered cleanup. Findings: Critical (3 votes): deletion classification and claim are not atomic. Critical (1 vote): rootless partitions skip ownership checks. Moderate (2 votes): failed drops can leave no repair handle. Nit (1 vote): missing tests for static grants, GitSync, and indeterminate probes.
src/​MeshWeaver.Graph/​Configuration/​PartitionDropPostDeletionHandler.cs Shares provider-drop and cache-eviction logic.
src/​MeshWeaver.Graph/​Configuration/​GraphConfigurationExtensions.cs Registers the new handler.
src/​MeshWeaver.Documentation/​Data/​Architecture/​PartitionTeardown.md Documents the orphan recovery procedure.
src/​MeshWeaver.Documentation/​Data/​Architecture/​AStaleIndexRowConfirmsItself.md Documents the defect, recovery verb, and limitations.

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

Comment on lines +131 to +159
return StrandedBecause(partition)
.SelectMany(reason =>
{
if (reason is null)
{
logger?.LogWarning(
"[StrandedPartition] the record 'Admin/Partition/{Partition}' was deleted by "
+ "{User} but the partition is LIVE — it has a root somebody owns, or is served "
+ "by configuration — so its backing store is NOT dropped. The partition is now "
+ "missing from partition listings and the routing prime; to tear it down, "
+ "delete its root node, which runs the structural teardown.",
partition, deletedBy ?? "system");
return Observable.Return(Unit.Default);
}

logger?.LogWarning(
"[StrandedPartition] finishing the teardown of '{Partition}' on the deletion of its "
+ "record by {User}: {Reason}. No ordinary delete could reach it — dropping its "
+ "backing store on every provider and evicting its cached queries (#5073).",
partition, deletedBy ?? "system", reason);

// The same tombstone the ordinary teardown holds across its drop (#3451): marked
// before the first DDL so a child write landing mid-drop is refused by the write
// guard instead of healing a root back over a schema that is being dropped.
return Observable.Using(
() => registry.BeginSubtreeDeletion(partition),
_ =>
{
registry.MarkDeleted(partition);

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 — the verdict was a reading taken at one instant and acted on at another, the #3451 shape. Fixed in the next push: the deletion scope (registry.BeginSubtreeDeletion(partition)) is now acquired FIRST and StrandedBecause runs inside it, so SubtreeDeletionGuardStorageAdapter refuses any write at or under the partition root between the reading and the drop — a recreate (an installer provisioning the same name, a child write healing a root) cannot land and then be dropped on a stale verdict. The tombstone checks at the top still stand down while a partition's deletion is in flight or on record.

What the claim does NOT make exclusive, stated rather than implied: BeginSubtreeDeletion is ref-counted, not a lock, so a root delete that STARTS after the claim proceeds too. The residue is two idempotent drops of one schema, serialised by the provider's own advisory lock (DROP SCHEMA IF EXISTS), which cannot destroy anything — the recreate window was the non-idempotent half, and the claim closes it. Also folded into the same push: this is now a DELETE VALIDATOR rather than a post-deletion handler (see the third thread), so the whole classify-and-drop runs before the record goes, with the record still in place as the retry handle.

Comment on lines +203 to +206
if (r.Node is null)
return Observable.Return<string?>(
"its root node is gone and no static root serves it — the shape a partition-root "
+ "delete left behind before the structural teardown existed");

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.

Correct, and it is exactly the mechanism that produced the orphan: the recursive delete enumerates mesh_nodes descendants only, and _Access rows live in the access satellite table the fan-out never visits, so a rootless partition can still carry every grant it ever had — and a grant holder can still reach its nodes through the synthesized placeholder root and re-roots it on their next write. Such a partition is somebody's. Fixed in the next push: ownership is asked for BOTH root shapes. StrandedBecause now folds the root into a shape ("absent" / "the heal's shell") and only then asks the grant listing (durable) + static grants + _GitSync; any positive or indeterminate probe reads as owned and the store is preserved. New control DeletingTheRecordOfARootlessPartitionSomebodyStillHoldsAGrantOn_LeavesItsStoreAlone (rootless + one surviving durable grant → store kept, no drop in the ledger). The class remark's decision table is rewritten as the conjunction. Consequence, named on the thread and in the doc: if UWDeepfield still carries its old _Access rows the record delete refuses and says so, and the clean-up is then the grant holder's (or the stale grants go first) — which is the right answer under the platform-admin-is-not-a-data-superuser rule.

Comment on lines +146 to +150
logger?.LogWarning(
"[StrandedPartition] finishing the teardown of '{Partition}' on the deletion of its "
+ "record by {User}: {Reason}. No ordinary delete could reach it — dropping its "
+ "backing store on every provider and evicting its cached queries (#5073).",
partition, deletedBy ?? "system", reason);

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 the first fix I tried (a post-deletion handler that writes the record back on a failed drop) was refuted by its own test: a post-deletion handler runs INSIDE the record's deletion scope, where SubtreeDeletionGuardStorageAdapter refuses the write-back (REFUSED write to Admin/Partition/{p}: the subtree … is being deleted). So the next push takes your first option: the teardown is now StrandedPartitionTeardownValidator, an INodeValidator on NodeOperation.Delete, registered as a scoped validator next to OwnsPartitionProvisioningValidator (the creation-side mirror, which provisions the schema from a validator for the same reason). Delete validation runs before the deletion scope opens (step 2 vs step 3 in HandleDeleteNodeRequest), so the drop runs first and a drop that faults returns Invalid(...) naming the exception — the delete is refused, the record stays as the retry handle, the partition tombstone is lifted. That is the ordinary teardown's own ordering (store drop → definition delete, "so the partition remains visible for a retry"). Only a DIRECT delete of the record counts (DeleteCascadeRootPath == Node.Path); a record removed as a leaf of a wider recursive delete is bookkeeping going, not a request to drop every stranded schema. Control AFailedDrop_RefusesTheDelete_SoTheRecordStaysAsTheRetryHandle: provider drop faults → Success == false, error names "could not be dropped", record readable, store still provisioned, IsRecentlyDeleted(partition) == false.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files  ±0    1 suites  ±0   2m 46s ⏱️ -4s
318 tests ±0  318 ✅ ±0  0 💤 ±0  0 ❌ ±0 
322 runs  ±0  322 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 544e96f. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

396 tests   - 1 194   396 ✅  - 1 194   39s ⏱️ - 2m 33s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 544e96f. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   1      2 suites   - 1   2m 37s ⏱️ - 3m 29s
1 578 tests  - 378  1 578 ✅  - 378  0 💤 ±0  0 ❌ ±0 
1 578 runs   - 379  1 578 ✅  - 379  0 💤 ±0  0 ❌ ±0 

Results for commit 544e96f. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

    1 files   -     2      1 suites   - 2   5m 35s ⏱️ -18s
1 941 tests +1 215  1 941 ✅ +1 407  0 💤  - 192  0 ❌ ±0 
1 942 runs  +1 216  1 942 ✅ +1 408  0 💤  - 192  0 ❌ ±0 

Results for commit 544e96f. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    2 files   -     3      2 suites   - 3   6m 18s ⏱️ - 9m 13s
2 477 tests  - 1 478  2 477 ✅  - 1 476  0 💤  - 2  0 ❌ ±0 
2 481 runs   - 1 478  2 481 ✅  - 1 476  0 💤  - 2  0 ❌ ±0 

Results for commit 544e96f. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

934 tests  +491   934 ✅ +491   7m 47s ⏱️ + 7m 0s
  1 suites  -   2     0 💤 ±  0 
  1 files    -   2     0 ❌ ±  0 

Results for commit 544e96f. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    8 files   -     9      8 suites   - 9   25m 44s ⏱️ - 8m 37s
7 644 tests  - 1 344  7 644 ✅  - 1 150  0 💤  - 194  0 ❌ ±0 
7 653 runs   - 1 344  7 653 ✅  - 1 150  0 💤  - 194  0 ❌ ±0 

Results for commit 544e96f. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

…rop first, refuse on failure, ownership for both shapes (review on #5203)

Three review findings, each real:

1. The verdict was taken before the deletion claim (#3451's shape). The scope is
   now acquired first and StrandedBecause runs inside it, so a recreate cannot
   land between the reading and the drop; a root delete starting after the
   claim is two idempotent, provider-serialised drops — named, not hidden.
2. A rootless partition can still carry every _Access grant it ever had (the
   satellite table the recursive delete never visits), and its grant holders
   reach it through the placeholder root. Ownership is now asked for BOTH root
   shapes; any positive or indeterminate probe keeps the store.
3. A post-deletion handler runs inside the record's own deletion scope, where
   the write guard refuses to write the record back after a failed drop — its
   own test caught it. The teardown is now StrandedPartitionTeardownValidator
   (INodeValidator, Delete): the drop runs before the record goes, and a drop
   that faults refuses the delete so the record stays as the retry handle — the
   ordinary teardown's own ordering, and OwnsPartitionProvisioningValidator's
   shape on the creation side. Only a DIRECT record delete counts.

Controls: 19/19 across the four teardown classes; with the validator
unregistered 3/8 fail (exactly the three positive cases), 5 controls hold.
New: rootless + surviving grant keeps its store; a faulting drop refuses the
delete with the record intact and the tombstone lifted.

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

rbuergi commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Shard 3 red on 544e96f27b is the harness's wall-clock cap, not this diff — measured, filed, and the failed jobs re-run once.

  • Green run 35714641961 (e1a137f8cd): MeshWeaver.Hosting.Test — Total: 934, Failed: 0, Time: 472.4s, marker exit=0 elapsed=473s. The per-project cap is 480 s. Headroom: 7 s.
  • Red run 35716353979 (544e96f27b): same host, marker exit=124 TIMEOUT elapsed=481s → synthetic HOST_CRASHED. Same test sequence; killed ~8 s before completion (159 of 167 class starts reached, the last class needs ~16 s on the green run).
  • Per-test start-to-start intervals over the first 158 tests: green 451.7 s, red 457.6 s — a uniform +5.9 s across unrelated tests, largest single delta 1.1 s. No test hung. The diff between the two heads registers a scoped delete validator that short-circuits on three string compares for anything that is not an Admin/Partition record, and no Hosting.Test test deletes such a record.

So this is a suite sitting at 98.5 % of its cap on a runner 2 % slower — a capacity defect, filed into triage (rbuergi/Feedback/20260922-hosting-test-suite-sits-at-the-shard-wall-clock-cap, with the two 36-s stretches that account for 15 % of the cap on both runs). Not raising the cap; re-running the failed jobs because the harness killed the host and the cause is measured, not guessed.

@rbuergi
rbuergi added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit cc889c1 Sep 22, 2026
56 of 58 checks passed
@rbuergi
rbuergi deleted the fix/5073-orphaned-partition-is-not-warmed 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.

2 participants