[ 
https://issues.apache.org/jira/browse/SPARK-59509?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Qing Fu updated SPARK-59509:
----------------------------
    Description: 
h3. The contract

{{UnsafeSorterIterator.getNumRecords()}} is meant to be a fixed total. 
SPARK-12295 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()}}.

|| Implementation || {{getNumRecords()}} returns || Decrements on 
{{loadNext()}}? ||
| {{UnsafeSorterSpillReader}} | total from the spill file header | no |
| {{UnsafeInMemorySorter.SortedIterator}} | total | no |
| {{UnsafeSorterSpillMerger}} (anonymous) | sum of the counts added | no |
| {{UnsafeExternalSorter.ChainedIterator}} | sum taken at construction | no |
| {{UnsafeExternalSorter.SpillableIterator}} | records *remaining* | *yes* |

h3. Consequences

*1. The spill merger under-reports.* 
{{UnsafeSorterSpillMerger.addSpillIfNotEmpty}} 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()}}.

Reproducer: two spills plus five records still in memory. 
{{getSortedIterator().getNumRecords()}} returns 14, and the iterator then 
yields 15 records.

*2. {{getSortedIterator()}} is incoherent with itself.* Its no-spill branch 
returns a {{SpillableIterator}}, whose count falls to zero as it is consumed; 
its spilled branch returns a merged iterator reporting a stable total. Same 
public method, two different meanings.

h3. Impact

None today, and this should be stated plainly. 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, and every other read of {{getNumRecords()}} is 
of a fresh, unconsumed iterator.

Wrong since {{getNumRecords()}} was introduced in SPARK-12295 (2016-01-06); 
first shipped in 2.0.0; never reported. In the ten years since, the only edits 
to the relevant lines have been a line-length reflow and a comment typo.

The reason to fix it now rather than leave it is that the undocumented split 
contract is actively propagating: the open PR for SPARK-57714 adds a third 
merger, {{UnsafeLoserTreeSpillMerger}}, with the same {{loadNext()}}-then-count 
ordering, and feeds a {{SpillableIterator}} into it.

h3. Proposed change

Give {{SpillableIterator}} a {{totalRecords}} field for {{getNumRecords()}}, 
leaving {{numRecords}} to count down for {{hasNext()}} and for sizing the spill 
writer. Its {{getNumRecords()}} has exactly one caller, so this is contained; 
it completes the normalisation SPARK-12295 started, resolves consequence 2 as 
well, and makes the statement ordering in all three mergers immaterial. 
Document the contract on the abstract method, including that the total does not 
account for records an iterator was advanced past before being handed on.

h3. Related, not included

* {{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. Worth a separate ticket.
* {{ChainedIterator}} sums its children's totals, but 
{{getIterator(startIndex)}} advances those children via {{moveOver()}} first, 
so the sum includes skipped records. Consistent with the documented contract, 
but the call site's use of it is misleading. Also unread today.


  was:
h3. The contract

{{UnsafeSorterIterator.getNumRecords()}} has two contracts, and the abstract 
method documents neither. Four of the five implementations report the *total* 
number of records and never change it as the iterator is consumed:

|| Implementation || {{getNumRecords()}} returns || Decrements on 
{{loadNext()}}? ||
| {{UnsafeSorterSpillReader}} | total from the spill file header | no |
| {{UnsafeInMemorySorter.SortedIterator}} | total | no |
| {{UnsafeSorterSpillMerger}} (anonymous) | sum of the counts added | no |
| {{UnsafeExternalSorter.ChainedIterator}} | sum taken at construction | no |
| {{UnsafeExternalSorter.SpillableIterator}} | records *remaining* | *yes* |

The two readings agree only while an iterator is untouched.

h3. The bug

{{UnsafeSorterSpillMerger.addSpillIfNotEmpty}} reads the count *after* calling 
{{loadNext()}}:

{code:java}
public void addSpillIfNotEmpty(UnsafeSorterIterator spillReader) throws 
IOException {
  if (spillReader.hasNext()) {
    spillReader.loadNext();
    priorityQueue.add(spillReader);
    numRecords += spillReader.getNumRecords();
  }
}
{code}

{{UnsafeExternalSorter.getSortedIterator()}} adds its in-memory 
{{SpillableIterator}} to the merger alongside the spill readers, so the merged 
iterator reports one record fewer than it will actually produce, per in-memory 
iterator.

Reproducer: two spills plus five records still in memory. 
{{getSortedIterator().getNumRecords()}} returns 14, and the iterator then 
yields 15 records.

h3. Impact

None today, and this should be stated plainly. A scan of the repository finds 
no caller that reads a merged iterator's {{getNumRecords()}} - the value is 
computed and never consumed. Iteration itself has always been correct, because 
the merged iterator's {{hasNext()}} is driven by the priority queue and not by 
the counter. The other three call sites that do read {{getNumRecords()}} all 
read fresh, unconsumed iterators, so none of them is affected.

That is also why this has gone unnoticed since the line was introduced in 
SPARK-12295 (2016).

It is still a wrong value, and a trap for the next caller who hands a merged 
iterator to {{UnsafeSorterSpillWriter}}, which enforces the declared count and 
fails with {{IllegalStateException: Number of records written exceeded 
numRecordsToWrite}}.

h3. Proposed change

Read the count before {{loadNext()}}, which is correct under either contract, 
and document the contract on {{UnsafeSorterIterator.getNumRecords()}} so the 
ambiguity that produced this does not produce the next one.


        Summary: Make UnsafeSorterIterator.getNumRecords() a consistent total  
(was: Fix record undercount in UnsafeSorterSpillMerger and document the 
getNumRecords() contract)

> Make UnsafeSorterIterator.getNumRecords() a consistent total
> ------------------------------------------------------------
>
>                 Key: SPARK-59509
>                 URL: https://issues.apache.org/jira/browse/SPARK-59509
>             Project: Spark
>          Issue Type: Bug
>          Components: Spark Core
>    Affects Versions: 5.0.0
>            Reporter: Qing Fu
>            Priority: Major
>              Labels: pull-request-available
>
> h3. The contract
> {{UnsafeSorterIterator.getNumRecords()}} is meant to be a fixed total. 
> SPARK-12295 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()}}.
> || Implementation || {{getNumRecords()}} returns || Decrements on 
> {{loadNext()}}? ||
> | {{UnsafeSorterSpillReader}} | total from the spill file header | no |
> | {{UnsafeInMemorySorter.SortedIterator}} | total | no |
> | {{UnsafeSorterSpillMerger}} (anonymous) | sum of the counts added | no |
> | {{UnsafeExternalSorter.ChainedIterator}} | sum taken at construction | no |
> | {{UnsafeExternalSorter.SpillableIterator}} | records *remaining* | *yes* |
> h3. Consequences
> *1. The spill merger under-reports.* 
> {{UnsafeSorterSpillMerger.addSpillIfNotEmpty}} 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()}}.
> Reproducer: two spills plus five records still in memory. 
> {{getSortedIterator().getNumRecords()}} returns 14, and the iterator then 
> yields 15 records.
> *2. {{getSortedIterator()}} is incoherent with itself.* Its no-spill branch 
> returns a {{SpillableIterator}}, whose count falls to zero as it is consumed; 
> its spilled branch returns a merged iterator reporting a stable total. Same 
> public method, two different meanings.
> h3. Impact
> None today, and this should be stated plainly. 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, and every other read of 
> {{getNumRecords()}} is of a fresh, unconsumed iterator.
> Wrong since {{getNumRecords()}} was introduced in SPARK-12295 (2016-01-06); 
> first shipped in 2.0.0; never reported. In the ten years since, the only 
> edits to the relevant lines have been a line-length reflow and a comment typo.
> The reason to fix it now rather than leave it is that the undocumented split 
> contract is actively propagating: the open PR for SPARK-57714 adds a third 
> merger, {{UnsafeLoserTreeSpillMerger}}, with the same 
> {{loadNext()}}-then-count ordering, and feeds a {{SpillableIterator}} into it.
> h3. Proposed change
> Give {{SpillableIterator}} a {{totalRecords}} field for {{getNumRecords()}}, 
> leaving {{numRecords}} to count down for {{hasNext()}} and for sizing the 
> spill writer. Its {{getNumRecords()}} has exactly one caller, so this is 
> contained; it completes the normalisation SPARK-12295 started, resolves 
> consequence 2 as well, and makes the statement ordering in all three mergers 
> immaterial. Document the contract on the abstract method, including that the 
> total does not account for records an iterator was advanced past before being 
> handed on.
> h3. Related, not included
> * {{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. Worth a separate ticket.
> * {{ChainedIterator}} sums its children's totals, but 
> {{getIterator(startIndex)}} advances those children via {{moveOver()}} first, 
> so the sum includes skipped records. Consistent with the documented contract, 
> but the call site's use of it is misleading. Also unread today.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to