Skip to content

Orphaned continuation chunks are never reclaimed, though three comments promise a database check will #6294

Description

@lvca

Found while building the #6196 fixtures (PR #6287), and confirmed on main at a03af88.

What happens

Force-deleting a record with a broken chunk chain frees the HEAD slot only. Its continuation chunks are
orphaned - and nothing ever collects them. A bucket whose one 200 KB record was force-deleted by
CHECK DATABASE FIX:

before FIX:  totalMultiPageRecords=1  totalChunks=4  totalAllocatedRecords=11
after  FIX:  totalMultiPageRecords=0  totalChunks=4  totalAllocatedRecords=10

Four NEXT_CHUNK slots, each holding up to a page of content, reachable by nothing and reported as chunks of a
record that no longer exists. Repeated runs do not change it.

Why the documented answer is not the actual one

Three places in the tree tell the reader these get cleaned up later:

  • deleteRecordInternal: "the chunks past this break are orphaned - a bounded space leak reclaimed by
    compaction or a later database check"
  • check(): "any unreachable chunks are reclaimed later by compaction or a database check"
  • deleteRecord(RID, boolean)'s javadoc: "orphaned (a bounded space leak) to be reclaimed by compaction or a
    database check"

Neither mechanism does it:

  • check() counts a NEXT_CHUNK slot under totalChunks and moves on. There is no pass that walks the live
    chains to find which chunk slots nothing points at, and no orphanedChunks field beside the
    orphanedEdgeSegmentsReclaimed / orphanedExternalRecordsFixed the checker already reports for the OTHER two
    kinds of orphan it knows about.
  • compressPage/getOrderedRecordsInPage re-flow a page's live slots; an orphaned chunk still has a live slot
    entry and a NEXT_CHUNK marker, so it is re-flowed along with everything else, never dropped.

The leak is bounded per incident but permanent, and it survives every repair an operator can run short of
exporting and reimporting the database.

Worth considering

Reclaiming a chunk requires knowing that no chain points at it, which is not answerable from the chunk's own
slot - the same asymmetry that makes #6196's content records need a marker. Two shapes fit the checker as it
already works:

  1. Mark-and-sweep within the bucket, on the single pass check() already makes (Follow-ups from #5764: CHECK DATABASE materialises every record twice, two arms still say "removing it" when nothing is removed, and three smaller checker/test cleanups #5773): collect the
    continuation pointers of every chunk head it walks into a LongHashSet, and after the pass report - and with
    fix, free - every NEXT_CHUNK slot not in it. One set entry per chunk, on an operation that already reads
    every page and walks every chain. This is what orphanedEdgeSegments already does for edge segments, so it
    is a shape the checker and its result schema both already have.

  2. Free them at the source: have the force-delete keep walking past the break rather than stopping, so the
    chunks it can still reach are freed instead of abandoned. Cheaper, but strictly weaker - it cannot reach
    chunks past a second break, or ones orphaned by the paths that leave chunks behind without deleting anything
    (GraphEngine.rebuildVertexEdgeChain, an interrupted updateMultiPageRecord shrink).

The two compose: (2) shrinks the population, (1) is what makes the claim in those three comments true. Until one
of them exists, those comments should say that the chunks leak.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions