Table of Contents

Crash-Recovery Chain Scrub & Orphan Sweep

After a crash, every Versioned component's revision history collapses back to its single committed value.

Status: โœ… Implemented ยท Visibility: Internal ยท Category: Revision

๐ŸŽฏ What it solves

A live Versioned component can carry several historical revisions on disk at once, kept around so snapshot-isolated readers that started before the latest write still see a consistent value. That reasoning stops applying the instant the process crashes: every in-flight reader is gone, so there is no snapshot left to protect. Left untouched, the surviving chains would carry pre-crash MVCC history into the recovered database โ€” and on top of that, an operation interrupted mid-allocation (a revision or content chunk claimed but never linked into a chain) leaves chunks marked allocated that nothing references at all. Both classes of leftover need to be gone before the recovered database is trusted as a base, or storage grows unbounded and stale history becomes indistinguishable from live data.

โš™๏ธ How it works (in brief)

After the WAL window is applied, recovery walks every Versioned component table's revision chains and collapses each one to its single highest-TSN committed element, freeing every other revision's content chunk and every overflow chunk the chain had grown. A chain with no committed survivor (every element was either voided or belonged to a transaction that never durably committed) collapses to one invisible element instead, so the entity still reads as deleted rather than left in an ambiguous state. Once every chain is down to its single root chunk, a second pass scans the table's revision and content chunk pools and frees anything still marked allocated that no surviving chain root or its head value reaches โ€” the chunks an interrupted pre-crash operation leaked. Chain root locations never move, which is exactly what lets the EntityMap rebuild โ€” a separate, earlier step keyed off the same chain-root scan โ€” run safely before this scrub, and what gives the secondary-index rebuild that follows the scrub a stable, already-correct address for every entity.

๐Ÿ’ป Usage

This is an internal recovery step, not something application code calls โ€” it runs automatically as part of opening a database that crashed (WAL files present), right after the WAL window is applied and before secondary B+Tree indexes are rebuilt. (The EntityMap is rebuilt separately, before WAL apply, off the same chain-root scan โ€” see Guarantees.)

using Typhon.Engine;

// Recovery โ€” including the scrub โ€” completes before this call returns. There is
// no separate API, flag, or step to trigger it.
var engine = serviceProvider.GetRequiredService<DatabaseEngine>();

// Observable effect: every Versioned revision chain that survived recovery holds
// exactly one element, however many updates landed against it before the crash.
foreach (var seg in engine.EnumerateStorageSegments())
{
    if (seg.Kind == StorageSegmentKind.Revision)
    {
        Console.WriteLine($"revision table allocChunks={seg.AllocatedChunkCount} " +
                           $"freeChunks={seg.FreeChunkCount}"); // no pre-crash overflow chunks remain
    }
}

โš ๏ธ Guarantees & limits

  • Crash path only. Runs solely when recovery detects WAL files at open; a clean reopen leaves chains untouched for the regular revision garbage collector to trim lazily.
  • Exactly one committed survivor per chain โ€” the highest-TSN element with no in-flight isolation marker. Elements from transactions that never durably committed are discarded along with their content, never kept "just in case."
  • No-committed-head chains read as deleted, not corrupt โ€” the chain collapses to a single invisible element rather than vanishing outright; the entity's EntityMap entry and root chunk stay in place (a known, harmless residual, not a leak the orphan sweep will touch).
  • Cluster HEAD values are unaffected โ€” the scrub only discards non-head revisions; a cluster archetype's already-correct head slot is never rewritten by this step.
  • Orphan sweep is per-table and scrub-ordered โ€” it only reclaims a table's own leaked revision/content chunks, and only after that table's chains are fully scrubbed; running it earlier would treat still-live non-head revisions as orphans and free data that should have survived.
  • Must run before secondary-index rebuild โ€” B+Tree indexes are built from the scrubbed chain heads, never from pre-scrub history, so a torn ordering would bake stale or duplicate entries into freshly rebuilt indexes. The EntityMap is the one derived structure rebuilt before this scrub (and before WAL apply): it only needs each chain's stable root location plus the entity's primary key, not the chain's post-scrub content, so running it earlier is safe and lets every later recovery phase resolve entities immediately.
  • No application-facing trigger or inspection API โ€” entirely internal to crash recovery; the only visible effect is the post-recovery chunk footprint (via EnumerateStorageSegments) and the absence of stale revisions on subsequent reads.

๐Ÿงช Tests

  • DeferredCleanupTests โ€” Scrub_CollapsesMultiRevisionChain_ToHeadValue_Idempotent (built directly against ScrubChainToHead, verifies the single-committed-HEAD collapse and idempotency) and SweepOrphans_FreesUnreachableChunks_KeepsReachableData (frees an interrupted-alloc orphan chunk while keeping the reachable chain root and content intact)