[SPARK-59509][CORE] Make UnsafeSorterIterator.getNumRecords() a consistent total - #58801
qingfureal wants to merge 1 commit into
Conversation
…stent total SPARK-12295 introduced getNumRecords() on UnsafeSorterIterator with a "fixed total" meaning. It gave UnsafeSorterSpillReader a stable numRecords field alongside its existing numRecordsRemaining, and converted UnsafeInMemorySorter.SortedIterator.numRecordsLeft() into a stable getNumRecords(). UnsafeExternalSorter.SpillableIterator was the one implementation left wired to a field that counts down on every loadNext(). That inconsistency has two consequences. UnsafeSorterSpillMerger reads each input's count after calling loadNext(), so a merged iterator that includes the in-memory SpillableIterator under-reports by one record per such iterator. And getSortedIterator() is itself incoherent: the no-spill branch returns a SpillableIterator, whose count falls to zero as it is consumed, while the spilled branch returns a merged iterator reporting a stable total. Split SpillableIterator's counter the same way UnsafeSorterSpillReader already does: numRecords is the fixed total returned by getNumRecords(), and numRecordsRemaining counts down for hasNext() and for sizing the spill writer. The merger is the only caller, so this completes the normalisation SPARK-12295 started and makes all five implementations agree; the merger's statement ordering then no longer matters and is left alone. Document the contract on the abstract method, including that the total does not account for records an iterator was advanced past and will not emit. Nothing reads a merged iterator's count today, so there is no user-visible change.
f187b12 to
6369815
Compare
6369815 to
0b05111
Compare
There was a problem hiding this comment.
Careful change with an unusually clear writeup, and I agree with both the diagnosis and the decision to fix it in SpillableIterator rather than reorder the merger.
I walked through the reasoning independently and it holds:
- Root cause.
UnsafeSorterSpillMerger.addSpillIfNotEmptydoesloadNext()and thennumRecords += spillReader.getNumRecords(). For aUnsafeSorterSpillReaderthat sum is fine (its count is a stable field since SPARK-12295), butSpillableIterator.getNumRecords()returned the same field it decremented inloadNext(), so each in-memory input contributedtotal - 1— the exact per-SpillableIteratorundercount you describe. - Fix is behavior-preserving except for the intended semantics.
numRecordsRemainingkeeps drivinghasNext(), thespill()guard, and the spill-writer sizing (still the remaining count, which is what those need), whilegetNumRecords()now returns the fixednumRecords. MakingnumRecordsfinalnicely enforces the invariant. I checked the wholeSpillableIteratorbody — no stale reference wherenumRecordsRemainingwas intended. - No impact today, confirmed. The merged iterator's
hasNext()/loadNext()run off the priority queue, not the counter, so iteration was always correct. Repo-wide, the only production readers ofgetNumRecords()are the two mergers' own sums, theSpillableIteratorconstructor, andChainedIterator— nothing reads a merged or partially-consumed total. So the reported values follow purely from static reasoning (merged 15->14 on master; the no-spill branch's count decaying to 0 once consumed) without needing a build. - Tests. Good — pinning
spillFilesCreated.size()so each test fails rather than silently stops exercising its branch is exactly right, andtestGetNumRecordsIsATotalNotARemainingCountguards the core regression. Added lines are ASCII and within 100 cols; title/JIRA/tag format is correct.
The two deferred items are the right call for keeping this focused. The UnsafeSorterSpillMerger.numRecords unguarded-int overflow (it sums into a plain int at line 59, unlike UnsafeSorterBoundedSpillMerger which uses a checked long) is a genuine latent bug — worth the separate JIRA you offered, and the new method doc is a good place to have nailed down the contract.
There was a problem hiding this comment.
Code changes make sense to me, this does fix the SpillableIterator one-record merger undercount. Leaving up to committers with more expertise in this area, for further review! Thank you @qingfureal for working on this

What changes were proposed in this pull request?
UnsafeSorterIterator.getNumRecords()is meant to be a fixed total. SPARK-12295 (2016) introduced it and normalised two implementations to that meaning in the same commit — it gaveUnsafeSorterSpillReadera stablenumRecordsfield alongside its existingnumRecordsRemaining, and convertedUnsafeInMemorySorter.SortedIterator.numRecordsLeft()into a stablegetNumRecords().UnsafeExternalSorter.SpillableIteratorwas the one implementation left wired to a field that counts down on everyloadNext().This PR finishes that normalisation by splitting
SpillableIterator's counter the same way:numRecordsRemainingkeeps drivinghasNext()and the spill-writer sizing;getNumRecords()returns the fixednumRecords. The field names deliberately mirrorUnsafeSorterSpillReader, which already models the same pair of concepts under exactly these names.All five implementations now agree, and the contract is documented on the abstract method.
Why are the changes needed?
The inconsistency has two consequences.
1.
UnsafeSorterSpillMergerunder-reports. It reads each input's count after callingloadNext(), so a merged iterator that includes the in-memorySpillableIteratorreports one record fewer than it will produce, per such iterator. Two call sites feed it one:UnsafeExternalSorter.getSortedIterator(), and — since SPARK-56410 — the final round ofUnsafeSorterBoundedSpillMerger.merge().2.
getSortedIterator()is incoherent with itself. Its no-spill branch returns aSpillableIterator, whose count falls to zero as it is consumed, while its spilled branch returns a merged iterator reporting a stable total. Same public method, two different meanings.To be clear about impact: there is none today. No caller in the repository reads a merged iterator's
getNumRecords(). Iteration has always been correct, because the merged iterator'shasNext()is driven by the priority queue rather than the counter. Every other read ofgetNumRecords()is of a fresh, unconsumed iterator. That is why this has gone unnoticed since 2016; it first shipped in 2.0.0.The reason to fix it now rather than leave it is that the undocumented split contract is actively propagating. The open PR #56804 (SPARK-57714) adds a third merger,
UnsafeLoserTreeSpillMerger, with the sameloadNext()-then-count ordering, and feeds aSpillableIteratorinto it; its new tests pass only because they use non-decrementing test iterators.Fixing
SpillableIteratorrather than the merger's statement order was deliberate: it is the implementation that diverges, itsgetNumRecords()has exactly one caller, and fixing it there also resolves consequence 2 and makes the ordering in all three mergers immaterial. The merger is therefore left untouched by this PR.Two related items are deliberately not included, to keep this focused:
UnsafeSorterSpillMerger.numRecordsis an unguardedintsummed across all spills, so a sort of more thanInteger.MAX_VALUErecords reports a negative total.UnsafeSorterBoundedSpillMergeralready sums in alongwith an explicit check. Happy to file that separately.ChainedIteratorsums its children's totals, butgetIterator(startIndex)advances those children viamoveOver()first, so the sum includes skipped records. That is consistent with the documented contract — the total does not track advancement — but the call site's use of it is misleading. Also unread today.Does this PR introduce any user-facing change?
No. No public API, behaviour or output changes.
SpillableIterator.getNumRecords()has a single in-tree caller, and nothing reads the value it ultimately feeds.How was this patch tested?
Three tests in
UnsafeExternalSorterSuite:testGetNumRecordsCountsUnspilledRecords— two spills plus five records still in memory, so the merger receives aSpillableIterator; asserts the merged count is 15 and that 15 records are produced.testGetNumRecordsIsATotalNotARemainingCount— the no-spill branch, asserting the count does not fall as the iterator is consumed.testGetNumRecordsWithoutUnspilledRecords— the all-spilled case, already correct, pinned against regression.The first two assert
spillFilesCreated.size()so they fail rather than silently pass if they stop exercising the branch they target.Verified each half is load-bearing. Reverting only the
SpillableIteratorchange:And on master before this change, the merged count was
expected: <15> but was: <14>.UnsafeExternalSorterSuite(29),UnsafeExternalSorterRadixSortSuite(29) andUnsafeInMemorySorterSuite(3) pass, anddev/lint-javais clean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)