gortiz opened a new pull request, #19412:
URL: https://github.com/apache/pinot/pull/19412

   ## Two operators solve the same problem, and one of them is worse
   
   The multi-stage engine has two ways to produce sorted output.
   
   | | `SortedMailboxReceiveOperator` | `SortOperator` |
   |---|---|---|
   | Where it can be used | only directly over mailboxes | anywhere in a stage |
   | Knows `fetch` / `offset` | **no** | yes |
   | Rows held | **the whole input, always** | `fetch + offset`, or the whole 
input when there is no limit |
   | Requires sorted senders | no | no |
   | Exploits sorted senders | no | no |
   | Output | one block | one block |
   
   `SortedMailboxReceiveOperator` sits in an awkward middle: it neither 
requires its senders to be sorted nor takes any advantage when they are, and it 
cannot see the `fetch`/`offset` that `SortOperator` has been given.
   
   ## What master builds
   
   `PinotSortExchangeNodeInsertRule` re-parents the `Sort` on top of the 
exchange it creates, so **an `ORDER BY` always gets both operators**:
   
   ```
   SortOperator            <- has fetch/offset, but skips sorting: 
_priorityQueue = null
     SortedMailboxReceiveOperator   <- does the sort, knows nothing about the 
limit
   ```
   
   `SortOperator` recognises its input by type and stands down:
   
   ```java
   if (collations.isEmpty() || input instanceof SortedMailboxReceiveOperator) {
     _priorityQueue = null;                    // degenerate to a limit/offset 
trim
   ```
   
   So the operator that knows the bound does nothing but trim, and the operator 
that does the work is blind to the bound. Window functions and `WITHIN GROUP` 
aggregates are worse still: `PinotWindowExchangeNodeInsertRule` and 
`PinotAggregateExchangeNodeInsertRule` insert `SortedMailboxReceiveOperator` 
alone, with no `SortOperator` above it at all. `WindowAggregateOperator` states 
the contract it depends on and does no ordering of its own:
   
   ```java
   /// keys are already ordered based on the 'ORDER BY' keys. No ordering is 
performed in this operator. The planner
   /// should handle adding a 'SortExchange' to do the ordering prior to 
pipelining the data to the upstream operators
   /// wherever ordering is required.
   ```
   
   ## How it got this way
   
   The split is not a considered design. #10408 (Mar 2023) moved sorting *into* 
the receive operator, adding a
   `PriorityQueue`, `_collationKeys` and `_isSortOnReceiver` to what was then a 
single `MailboxReceiveOperator`. Its own
   commit body says what it was waiting for:
   
   ```
   - MailboxSendOperator will be modified later to add sort support.
   ```
   
   That forced one loop to serve two incompatible disciplines — *return the 
first block that arrives* for an unsorted
   receive, and *drain every mailbox before emitting anything* for a sorted 
one. #10570 split the class along exactly
   that seam a month later; its message says the non-sorted half restores 
"behavior identical to prior to #10408". The
   sorted half was born with the TODO it still carries today:
   
   ```java
   /// TODO: Once sorting on the `MailboxSendOperator` is available, modify 
this to use a k-way merge instead of
   ///       resorting via the PriorityQueue.
   ```
   
   Sender-side sorting arrived later, through `PinotSortExchangeCopyRule`. The 
receive operator never caught up, and
   `SortOperator` — which can be placed anywhere and already knows the bound — 
was the better home for the work all
   along.
   
   ## Why the pairing is worse than the alternative
   
   **It fetches everything, then applies the limit.** 
`SortedMailboxReceiveOperator` has no `numRowsToKeep`; it accumulates 
unconditionally:
   
   ```java
   private final List<Object[]> _rows = new ArrayList<>();
   ...
   _rows.addAll(((MseBlock.Data) block).asRowHeap().getRows());   // every row, 
every mailbox
   ...
   _rows.sort(new SortUtils.SortComparator(_collations, false));
   return new RowHeapDataBlock(_rows, _dataSchema);               // one block, 
the whole result
   ```
   
   For `SELECT ... ORDER BY k LIMIT 10` across `k` senders that is `k × (limit 
+ offset)` rows held to return `limit`. A plain `MailboxReceiveOperator` 
feeding `SortOperator`'s bounded heap holds `limit + offset`. The 
`earlyTerminate()` `SortOperator` sends afterwards saves nothing — the 
buffering has already happened.
   
   **It is slower even with no limit.** Its `ArrayList` + `sort` is fine, but 
it emits the whole result as a single block, which is expensive to serialize 
and forces the consumer to materialize all of it at once.
   
   **It makes the work built on top of it harder than it needs to be.** #19121 
adds a streaming k-way merge so a receive can finally exploit sorted senders — 
the right feature, and the measured payoff is real. But because the merge has 
to live inside `SortedMailboxReceiveOperator`, it also needs a per-stream 
handle abstraction on `BlockingMultiStreamConsumer`, a read-mode latch, a 
planner gate and a runtime order check; and it lands in the one operator that 
cannot see `fetch`/`offset`, so it has nothing to bound what it buffers. The 
same feature implemented over `SortOperator` has the limit in hand and one 
fewer abstraction to introduce. The goal here is to make that PR smaller, not 
to argue against it.
   
   ## This PR
   
   Deprecates `SortedMailboxReceiveOperator` and builds `ORDER BY` as 
`MailboxReceiveOperator` + `SortOperator`, so that sorting has one home instead 
of two — and so that work layered on top of it, #19121 included, has one place 
to go rather than the more awkward of the two.
   
   **All three exchange rules stop setting `isSortOnReceiver`**, so nothing in 
the planner produces `SortedMailboxReceiveOperator` any more:
   
   | Rule | Before | After |
   |---|---|---|
   | `PinotSortExchangeNodeInsertRule` | receive sorts; the `Sort` it 
re-parents only trims | the `Sort` does the work |
   | `PinotWindowExchangeNodeInsertRule` | receive sorts; nothing above it | an 
explicit `Sort` over the exchange |
   | `PinotAggregateExchangeNodeInsertRule` | receive sorts; nothing above it | 
an explicit `Sort` over the exchange |
   
   The exchange stays a `PinotLogicalSortExchange` throughout, so 
`PinotSortExchangeCopyRule` still matches and pushes the sender-side top-N down 
— emitting a plain exchange would look tidier and would silently cost far more 
than this saves.
   
   The `Sort` inserted for a window or ordered aggregate carries no `fetch`, so 
`RexExpressionUtils.getValueAsInt(null)` returns `-1`, `_numRowsToKeep` falls 
back to `Integer.MAX_VALUE`, and every row is kept — the same semantics as the 
unbounded list the receive operator used. An explicit `Integer.MAX_VALUE` fetch 
would be wrong here, because `SortOperator` computes `fetch + offset` and would 
overflow.
   
   **`PinotWindowExchangeNodeInsertRule.matches()` had to change too**, and 
this is a behavioural fix rather than cleanup. Its idempotence guard was 
`!isExchange(window.getInput())`. Placing a `Sort` over the exchange makes the 
window's input a `Sort`, so the rule stopped recognising its own output and 
re-fired forever — `SELECT AVG(col3), AVG(col3) OVER(PARTITION BY col3) FROM a 
GROUP BY col3 ORDER BY col3` hung the planner. The guard now also treats `Sort` 
over an exchange as already-processed.
   
   `SortOperator` then has to cover everything the receive operator used to, so 
it becomes an abstract base with a factory that picks by *what bounds the 
result*. Each implementation names itself in the explain plan:
   
   | Implementation | Explain | Chosen when | Peak memory |
   |---|---|---|---|
   | `LimitSortOperator` | `SORT_LIMIT` | no collation | one input block |
   | `TopNSortOperator` | `SORT_TOP_N` | `fetch`, or a finite response limit, 
bounds the result | `fetch + offset` |
   | `FullSortOperator` | `SORT_FULL` | nothing bounds it | the input |
   
   All three emit their result as blocks of at most 10000 rows instead of one 
block holding everything. `LimitSortOperator` additionally streams — input 
blocks are forwarded as they arrive with `offset` skipped and at most `fetch` 
rows emitted, then the input is early-terminated.
   
   The `instanceof SortedMailboxReceiveOperator` coupling in `SortOperator` 
goes away with it.
   
   ## Relationship to #19396 and #19121
   
   Two open PRs add a k-way merge so a receive can exploit sorted senders: 
#19396 (window exchanges, sender-side sorting plus a `sortedOnSender` 
confirmation through the mailbox protocol) and #19121 (the 
`streamingSortedMailboxReceive` option). They were written independently and 
neither references the other. Both build on `SortedMailboxReceiveOperator`.
   
   **Nothing here argues against the merge.** It is the right feature and both 
PRs measure a real payoff. The argument is only about where it should live, and 
the proposal is one of ordering:
   
   1. **This PR first.** Collapse sorting to one implementation, in the 
operator that knows the collation *and* the `fetch`/`offset`. Today the same 
job is done in two places and neither is the one that can bound it; adding a 
merge on top of that is building on the wrong base.
   2. **Then decide** whether to take #19396, #19121, or both. The merge itself 
belongs in a *receive* operator — it needs per-mailbox access that no operator 
above the receive has — so the follow-up is a k-way receiver, whatever it ends 
up being called. What changes is where the sender's order comes from: an 
explicit `Sort` in the sending opchain rather than sorting inside 
`MailboxSendOperator`, and no sort at all when the sending stage's output is 
ordered by construction, as a sorted merge join's would be.
   
   The one thing that must not serve as the proof is the table's sorted-column 
property. It guarantees only that each segment is sorted internally, and says 
nothing about the order of a stage's output stream.
   
   One related observation for #19396: it sorts inside `MailboxSendOperator`. 
Pinot already sorts in the sender fragment — `PinotSortExchangeCopyRule` has 
been emitting a `LogicalSort` below the exchange for `ORDER BY` for years, 
executed by `SortOperator`:
   
   ```
   LogicalSort(sort0=[$0], dir0=[ASC], offset=[0], fetch=[10])     <- receiver 
fragment
     PinotLogicalSortExchange(...)
       LogicalSort(sort0=[$0], dir0=[ASC], fetch=[10])             <- sender 
fragment
         PinotLogicalTableScan(table=[[default, a]])
   ```
   
   Doing it in the send operator instead adds a third place that sorts, one 
that cannot see a limit and that the planner cannot reason about — #19396's own 
comment on `PinotSortExchangeNodeInsertRule` notes the sender would then be 
"sorting rows that are sorted already". Keeping `MailboxSendOperator` agnostic 
about ordering and putting a `Sort` in the sender opchain avoids that.
   
   ## Why the class is deprecated rather than deleted
   
   No planner path reaches `SortedMailboxReceiveOperator` after this change, 
but `MailboxReceiveNode.sort` is a proto field, so a broker running an older 
build still sends `sort=true` and the server must still honour it. Deleting the 
class needs a release of overlap.
   
   The other direction is already safe: a new broker sends `sort=false`, and an 
older server builds a plain receive plus its own `SortOperator`, whose 
`instanceof SortedMailboxReceiveOperator` check correctly fails so it does sort.
   
   ## Effect
   
   | Query | Before | After |
   |---|---|---|
   | `ORDER BY k LIMIT n` | `k × (limit + offset)` rows at the receiver | 
`limit + offset` |
   | `ORDER BY k`, no limit | whole input, one output block | whole input, 
streamed in 10000-row blocks, sorted 1.6–4.5× faster |
   | window / `WITHIN GROUP` | whole input, one output block | whole input, 
streamed in 10000-row blocks, sorted 1.6–4.5× faster |
   
   ## Benchmark
   
   `FullSortOperator` replaces an unbounded `PriorityQueue` with a single sort. 
Both are `O(n log n)`, so the claim rests on constants and locality. 
`BenchmarkMseSortImplementations` measures it with the real 
`SortUtils.SortComparator` and `SelectionOperatorUtils.addToPriorityQueue`, 
3-column rows, 3 forks × 10 iterations:
   
   | input | rows | sort | heap | speedup |
   |---|---|---|---|---|
   | random | 10K | 0.695 ± 0.032 ms | 1.085 ± 0.043 ms | 1.6× |
   | random | 1M | 302 ± 16 ms | 590 ± 29 ms | 2.0× |
   | 64 sorted runs | 10K | 0.229 ± 0.016 ms | 0.907 ± 0.028 ms | 4.0× |
   | 64 sorted runs | 1M | 82 ± 4 ms | 369 ± 16 ms | 4.5× |
   
   The sorted-runs rows are the realistic ones: once the sender-side sort is 
pushed down, a receive stage sees one sorted run per sender concatenated, and 
TimSort detects those runs while a heap cannot.
   
   The trade is allocation: the merge buffer costs 1.2× (1M rows) to 1.7× (10K 
rows) what the heap allocates. Faster and hungrier, not strictly better.
   
   ## Testing
   
   - `pinot-query-planner`: 1505 tests, 0 failures
   - `pinot-query-runtime`: 4566 tests, 0 failures
   - `SortOperatorTest` extended to pin implementation selection, streaming, 
offset/fetch across block boundaries, block splitting, and the `requireSort` 
stat per implementation.
   - 200 expected-plan updates: 79 where the exchange's parent was already a 
`LogicalSort`, and 121 window / ordered-aggregate plans that now show an 
explicit `LogicalSort` over the exchange. The 121 were regenerated from actual 
planner output rather than hand-edited.
   
   ## Follow-ups
   
   - Recover a k-way merging receive operator, fed by senders whose order comes 
from an explicit `Sort` in their opchain, or from a stage that is ordered by 
construction — the work #19396 and #19121 are doing.
   - Delete `SortedMailboxReceiveOperator` once no supported broker sets 
`MailboxReceiveNode.sort`, and drop `isSortOnReceiver` from 
`PinotLogicalSortExchange` with it.
   - `PinotSortExchangeCopyRule` only pushes the sender-side sort down when 
`limit + offset <= sortExchangeCopyThreshold` (10000 by default). Above that, 
and with no `LIMIT`, senders ship unsorted and the receiver must full-sort.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to