-
Type:
Task
-
Resolution: Unresolved
-
Priority:
Major - P3
-
None
-
Affects Version/s: None
-
Component/s: None
-
Storage Execution
-
Storage Execution 2026-10-26
-
None
-
None
-
None
-
None
-
None
-
None
-
None
Problem
For a primary-driven index build of N indexes, the load phase calls done() for all N indexes before committing any, so every sorter builds its final merge iterator up front:
multi_index_block.cpp:1271-1275
for (auto&& index : _indexes) { // If the build has spilled, force all builders to spill their in-memory remainder so the // persisted sorter is a complete copy and it's safe to resume from the load phase. index.bulk->done(/*forceSpill=*/_anyIndexSpilled); }
Building a sorter's merge iterator reads the head of every spilled range, which positions every range's cursor and pins its leaf page. The chain:
- done() builds the merge iterator: index_access_method.cpp:1388-1394, sorter_template_defs.h:641-656
- the MergeIterator constructor creates a Stream per range: sorter_template_defs.h:249-259
- the Stream constructor reads the head key: sorter.h:389-390
- that read calls find(), positioning the cursor: container_based_spiller.h:164-171
After the done() loop, all N sorters' cursors (about N ranges each) are positioned and pinned, and they stay pinned until _releaseReplicatedSorters at the end of the phase (multi_index_block.cpp:1424-1434, index_access_method.cpp:1608-1610).
On a large collection with a small WT cache this pins enough pages to stall clean eviction.
Proposed fix
Decouple the force-spill (required before the kBulkLoad phase write for resumability) from merge-iterator construction:
- Force-spill all sorters without building merge iterators (add BulkBuilder::forceSpill, or split done()).
- Write the phase metadata as today.
- Per index: done() then commit() then releaseSorter(), so only one sorter's cursors are positioned at a time and its iterators/cursors/buffers are freed before the next index.
Peak pinned cursors drops from about (numIndexes x rangesPerSorter) to rangesPerSorter.
Why it is safe
- _writeIndexBuildMetadataToContainer writes only IndexBuildMetadata (buildUUID, phase, collectionUUID) and no sorter state: multi_index_block.cpp:1829-1847. It does not require done().
- Per-index IndexStateInfo is persisted by the ContainerSpillYielder::onSpill callback via getPersistedState(), which snapshots the spiller's current ranges: multi_index_block.cpp:1939-1958, sorter_template_defs.h:438-449. It works before or after a merge; it only requires the in-memory remainder to be spilled.
- The only ordering constraint (all sorters force-spilled before the phase flip) is preserved. Add a tassert to enforce it.
- BulkBuilder::done has one caller and _sortedIterator has no other readers.
Caveats
- Subtle behavior diff: today a mergeSpills-triggered onSpill during done() runs while _phase == kCollectionScan, so the persisted IndexStateInfo includes lastSpilledRecordId. Deferred, it will not. Harmless (that field is only read when resuming from kCollectionScan), but resume-info parsing tests may observe it.
- Smaller alternative (Option 2): make MergeIterator/Stream lazy so positioning defers to the first read. It stops the pinning but does not free iterators per index, and it touches shared query-path machinery. Option 1 is preferred.
- is related to
-
SERVER-124382 Measure per-cursor memory overhead for kPerCursorOverheadBytes
-
- Backlog
-
-
SERVER-126498 Improve inefficiencies in container-based sorter spiller mergeSpills
-
- Backlog
-
- related to
-
SERVER-136756 Sorter iteratorsMaxNum under-accounts for the container cursor cost
-
- Open
-