Skip to content

feat: Store only the last row in the previous cursor for round robin tie-breaking purposes - #23619

Merged
kumarUjjawal merged 5 commits into
apache:mainfrom
ariel-miculas:fix-23606
Aug 13, 2026
Merged

feat: Store only the last row in the previous cursor for round robin tie-breaking purposes#23619
kumarUjjawal merged 5 commits into
apache:mainfrom
ariel-miculas:fix-23606

Conversation

@ariel-miculas

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

See the linked issue.
Note that this PR also contains the changes in #23606, so this will have to be rebased

What changes are included in this PR?

Store only the last row in prev_cursors instead of keeping the entire cursor

Are these changes tested?

Added a test to show the improvement

Are there any user-facing changes?

No

/// any buffer the cursor's [`CursorValues`] holds. Used to remember a
/// partition's last row across a batch boundary without keeping the
/// whole exhausted batch's memory alive (see [`Self::is_eq_to_prev_one`]).
pub fn last_value(&self) -> T::SingleRowValue {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I feel like this should return an Option, in case values() is empty

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

values() shouldn't be empty, there are some asserts in the code:

        assert!(rows.num_rows() > 0);
        assert!(array.len() > 0, "Empty array passed to FieldCursor");

I don't imagine a Cursor with empty values being useful.

impl RowValues {
    /// Create a new [`RowValues`] from `rows` and a `reservation`
    /// that tracks its memory. There must be at least one row
    ///
    /// Panics if the reservation is not for exactly `rows.size()`
    /// bytes or if `rows` is empty.
    pub fn new(rows: Arc<Rows>, reservation: MemoryReservation) -> Self {
        assert_eq!(
            rows.size(),
            reservation.size(),
            "memory reservation mismatch"
        );
        assert!(rows.num_rows() > 0);
        Self {
            rows,
            _reservation: reservation,
        }
    }
}
    pub fn new<A: CursorArray<Values = T>>(
        options: SortOptions,
        array: &A,
        reservation: MemoryReservation,
    ) -> Self {
        assert!(array.len() > 0, "Empty array passed to FieldCursor");
        let null_threshold = match options.nulls_first {
            true => array.null_count(),
            false => array.len() - array.null_count(),
        };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The previous code also assumed values is never empty:

    fn is_eq_to_prev_row_in_prev_batch(&self, other: &Self) -> bool {
        assert_eq!(self.offset, 0);
        T::eq(
            &self.values,
            self.offset,
            &other.values,
            other.values.len() - 1,
        )
    }

@ariel-miculas

Copy link
Copy Markdown
Contributor Author

@Dandandan wondering whether we can change ReusableRows to only hold one slot after this change, since the previous cursor will no longer need to be kept alive for the round robin tie-breaking feature.

@ariel-miculas
ariel-miculas force-pushed the fix-23606 branch 2 times, most recently from d85bdd0 to 28a916d Compare July 20, 2026 13:37
@codecov-commenter

codecov-commenter commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.48148% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.03%. Comparing base (0646a31) to head (06bde7b).
⚠️ Report is 75 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-plan/src/sorts/merge.rs 11.11% 8 Missing ⚠️
datafusion/physical-plan/src/sorts/cursor.rs 95.55% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23619      +/-   ##
==========================================
+ Coverage   81.02%   81.03%   +0.01%     
==========================================
  Files        1105     1106       +1     
  Lines      380669   381026     +357     
  Branches   380669   381026     +357     
==========================================
+ Hits       308446   308778     +332     
- Misses      53994    53997       +3     
- Partials    18229    18251      +22     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

// there is also one cursor needed per stream
let max_peak = 3 * ipc_batch_size + 2 * cursor_unit + converter_size;

assert!(

@ariel-miculas ariel-miculas Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

tldr: This no longer works after the refactor of SortPreservingMergeStream because peak_bytes reports 0.

SortPreservingMergeStream's reservation is unregistered from the memory pool before the test reads .metrics(), because of how the stream is now (meaning after #23407 and #23702) implemented.

Before: SortPreservingMergeStream implemented Stream directly (fn poll_next(self: Pin<&mut Self>, ...)). The struct — including its MemoryReservation — was boxed and returned as the SendableRecordBatchStream itself. It stayed alive for exactly as long as the caller held the stream, and was only dropped (triggering unregister()) when the caller dropped it — in the test, after tracking_pool.metrics() was read. Peak was correctly captured.

After: the implementation moved to (merge.rs:225-226):

  fn create_stream(mut self) -> impl Stream<Item = Result<RecordBatch>> {
      async_try_stream(|mut emitter| async move { /* ...self... */ })
  }

self (and everything it owns, including the reservation) is now captured inside the generator's async block. Once that block runs to completion — which happens on the final poll_next() call that returns None, i.e. during the test's draining loop, not when the caller later drops the stream object — self is dropped right there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was wondering where this comment went, I meant to post it in #23606

@ariel-miculas

Copy link
Copy Markdown
Contributor Author

@kumarUjjawal this is the follow-up to #23606, could you please take a look?

@kumarUjjawal kumarUjjawal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks you @ariel-miculas

I have left few comments. Let me know what you think.

Comment thread datafusion/physical-plan/src/sorts/merge.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we should keep at least the single-column no-round-robin case added by #23606? Removing all three disabled-mode cases drops the only direct peak-memory regression coverage for that fix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We could keep all three, I removed them since there's no longer any difference in the reservation between round robin tie-breaker enabled / disabled

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sure! Thanks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've added back the no round robin test cases

@ariel-miculas

Copy link
Copy Markdown
Contributor Author

Thanks for the review! As soon as this is merged I'll rebase #23802 on top of main and the tests should pass.

@ariel-miculas

Copy link
Copy Markdown
Contributor Author

@kumarUjjawal could you please merge this PR?

@kumarUjjawal

Copy link
Copy Markdown
Contributor

Thank you @ariel-miculas

@kumarUjjawal
kumarUjjawal added this pull request to the merge queue Aug 13, 2026
Merged via the queue into apache:main with commit 591ae10 Aug 13, 2026
40 checks passed
ariel-miculas added a commit to ariel-miculas/datafusion that referenced this pull request Aug 13, 2026
Fixes: apache#23801

It will only work after apache#23619
is merged, until then test_round_robin_tie_breaker_success will fail:

Error: Internal("Rows from RowCursorStream is still in use by consumer")
test sorts::sort_preserving_merge::tests::test_round_robin_tie_breaker_success ... FAILED

The failure is triggered by prev_cursors from SortPreservingMergeStream
keeping the previous Cursor alive for round robin tie breaking purposes.
The optimization from apache#23619 only keeps the last Row, so there's no
longer a need to keep two Rows cached in ReusableRows.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants