[SPARK-59509][CORE] Make UnsafeSorterIterator.getNumRecords() a consistent total by qingfureal · Pull Request #58801 · apache/spark · GitHub
Skip to content

[SPARK-59509][CORE] Make UnsafeSorterIterator.getNumRecords() a consistent total - #58801

Open
qingfureal wants to merge 1 commit into
apache:masterfrom
qingfureal:spark-spillmerger-numrecords
Open

qingfureal wants to merge 1 commit into
apache:masterfrom
qingfureal:spark-spillmerger-numrecords

Conversation

@qingfureal

@qingfureal qingfureal commented Sep 14, 2026

Copy link
Copy Markdown

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 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().

This PR finishes that normalisation by splitting SpillableIterator's counter the same way:

+    private final int numRecords;
+    private int numRecordsRemaining;

     SpillableIterator(UnsafeSorterIterator inMemIterator) {
       this.upstream = inMemIterator;
       this.numRecords = inMemIterator.getNumRecords();
+      this.numRecordsRemaining = this.numRecords;
     }

numRecordsRemaining keeps driving hasNext() and the spill-writer sizing; getNumRecords() returns the fixed numRecords. The field names deliberately mirror UnsafeSorterSpillReader, 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. UnsafeSorterSpillMerger under-reports. It reads each input's count after calling loadNext(), so a merged iterator that includes the in-memory SpillableIterator reports 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 of UnsafeSorterBoundedSpillMerger.merge().

2. getSortedIterator() is incoherent with itself. Its no-spill branch returns a SpillableIterator, 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's hasNext() is driven by the priority queue rather than the counter. Every other read of getNumRecords() 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 same loadNext()-then-count ordering, and feeds a SpillableIterator into it; its new tests pass only because they use non-decrementing test iterators.

Fixing SpillableIterator rather than the merger's statement order was deliberate: it is the implementation that diverges, its getNumRecords() 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.numRecords is an unguarded int summed across all spills, so a sort of more than Integer.MAX_VALUE records reports a negative total. UnsafeSorterBoundedSpillMerger already sums in a long with an explicit check. Happy to file that separately.
  • ChainedIterator sums its children's totals, but getIterator(startIndex) advances those children via moveOver() 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 a SpillableIterator; 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 SpillableIterator change:

[ERROR] UnsafeExternalSorterSuite.testGetNumRecordsIsATotalNotARemainingCount
        getNumRecords() must not count down ==> expected: <15> but was: <0>

And on master before this change, the merged count was expected: <15> but was: <14>.

UnsafeExternalSorterSuite (29), UnsafeExternalSorterRadixSortSuite (29) and UnsafeInMemorySorterSuite (3) pass, and dev/lint-java is clean.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5)

…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.
@qingfureal
qingfureal force-pushed the spark-spillmerger-numrecords branch from f187b12 to 6369815 Compare September 15, 2026 00:20
@qingfureal qingfureal changed the title [SPARK-59509][CORE] Fix record undercount in UnsafeSorterSpillMerger and document the getNumRecords() contract [SPARK-59509][CORE] Make UnsafeSorterIterator.getNumRecords() a consistent total Sep 15, 2026
@qingfureal
qingfureal force-pushed the spark-spillmerger-numrecords branch from 6369815 to 0b05111 Compare September 15, 2026 00:36

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.addSpillIfNotEmpty does loadNext() and then numRecords += spillReader.getNumRecords(). For a UnsafeSorterSpillReader that sum is fine (its count is a stable field since SPARK-12295), but SpillableIterator.getNumRecords() returned the same field it decremented in loadNext(), so each in-memory input contributed total - 1 — the exact per-SpillableIterator undercount you describe.
  • Fix is behavior-preserving except for the intended semantics. numRecordsRemaining keeps driving hasNext(), the spill() guard, and the spill-writer sizing (still the remaining count, which is what those need), while getNumRecords() now returns the fixed numRecords. Making numRecords final nicely enforces the invariant. I checked the whole SpillableIterator body — no stale reference where numRecordsRemaining was 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 of getNumRecords() are the two mergers' own sums, the SpillableIterator constructor, and ChainedIterator — 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, and testGetNumRecordsIsATotalNotARemainingCount guards 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.

@uros-b uros-b left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.

3 participants