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


  was:
{{UnsafeSorterSpillMerger.addSpillIfNotEmpty}} reads the incoming iterator's 
record 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}

The two {{UnsafeSorterIterator}} implementations that reach this method 
disagree about what {{getNumRecords()}} means:

* {{UnsafeSorterSpillReader.getNumRecords()}} returns the count stored in the 
spill file header. It never changes, however many times {{loadNext()}} is 
called.
* {{UnsafeExternalSorter.SpillableIterator.getNumRecords()}} returns the number 
of records _remaining_, and decrements on every {{loadNext()}}.

{{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.

This is latent on master: the merged iterator is consumed with a plain {{while 
(hasNext())}} loop, and no caller reads the declared count, so there is no 
user-visible symptom today. It is still an incorrect value, and it is a trap 
for any future caller that hands a merged iterator to 
{{UnsafeSorterSpillWriter}}, which enforces the declared count and fails with 
{{IllegalStateException: Number of records written exceeded numRecordsToWrite}}.

The fix is to read the count before {{loadNext()}}.

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


        Summary: Fix record undercount in UnsafeSorterSpillMerger and document 
the getNumRecords() contract  (was: Fix record undercount in 
UnsafeSorterSpillMerger when merging in-memory records)

> Fix record undercount in UnsafeSorterSpillMerger and document the 
> getNumRecords() contract
> ------------------------------------------------------------------------------------------
>
>                 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
>
> 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.



--
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