gortiz commented on code in PR #19396:
URL: https://github.com/apache/pinot/pull/19396#discussion_r4106395375


##########
pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotWindowExchangeNodeInsertRule.java:
##########
@@ -120,13 +120,13 @@ public void onMatch(RelOptRuleCall call) {
         exchange = PinotLogicalExchange.create(input, 
RelDistributions.hash(List.of()));
       } else {
         // Only ORDER BY
-        // Add a LogicalSortExchange with collation on the order by key(s) and 
an empty hash partition key.
-        // The ordering itself is established by the Sort placed over the 
exchange below, not by the receive
-        // operator - see the comment at the transformTo call.
+        // Sort each sender explicitly and merge the sorted mailbox streams at 
the receiver. The Sort retained above
+        // the exchange is the semantic ordering boundary and becomes a 
streaming limit when the merge receiver
+        // advertises this exact collation.
         // TODO: Revisit whether we should use hash distribution
         exchange =
-            PinotLogicalSortExchange.create(input, 
RelDistributions.hash(List.of()), windowGroup.orderKeys, false,
-                false);
+            PinotLogicalSortExchange.create(input, 
RelDistributions.hash(List.of()), windowGroup.orderKeys, true,

Review Comment:
   RISK (rolling upgrade): with a new broker and old servers, which is the 
usual controller→broker→server upgrade window, one query can sort the same rows 
up to three times.
   
   1) An old leaf ServerPlanRequestVisitor.visitSort pushes the new SortNode 
into V1 as ORDER BY ... LIMIT 2147483647. 2) An old receiver sees isSort=true 
and builds the deprecated SortedMailboxReceiveOperator, which does a full sort. 
3) The old SortOperator above it does not know its input is sorted and sorts 
again. The base plan used (false,false), so it sorted once. The results stay 
correct, but queries are slower until all servers are upgraded. A kill switch 
would cover this case too.



##########
pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotWindowExchangeNodeInsertRule.java:
##########
@@ -141,18 +141,17 @@ public void onMatch(RelOptRuleCall call) {
         exchange = PinotLogicalExchange.create(input, 
RelDistributions.hash(windowGroup.keys.toList()), prePartitioned);
       } else {
         // PARTITION BY and ORDER BY on different key(s)
-        // Add a LogicalSortExchange hashed on the partition by keys and 
collation based on order by keys.
-        // The ordering itself is established by the Sort placed over the 
exchange below, not by the receive
-        // operator - see the comment at the transformTo call.
+        // Keep the receiver full-sort path for a partitioned exchange. 
Sorting before the hash exchange compares rows
+        // routed to different receivers and blocks streaming; the explicit 
Sort retained above the exchange establishes
+        // the required ordering after partitioning.
         exchange = PinotLogicalSortExchange.create(input, 
RelDistributions.hash(windowGroup.keys.toList()),
             windowGroup.orderKeys, false, false, prePartitioned);
       }
     }
     // WindowAggregateOperator requires its input ordered on the ORDER BY keys 
and does no ordering of its own, so
-    // where the exchange carries a collation the ordering has to be 
established above it. Place an explicit Sort
-    // rather than asking the receive operator to sort: SortOperator is the 
operator that knows fetch/offset, and
-    // SortedMailboxReceiveOperator is deprecated. The Sort carries no fetch, 
so it keeps every row - the same
-    // semantics as the unbounded list the receive operator used.
+    // where the exchange carries a collation the ordering has to be 
established above it. Keep an explicit Sort as
+    // the semantic boundary. A confirmed merge receiver advertises the exact 
collation, allowing SortOperator to
+    // stream through it; a legacy receiver still performs the full sort.

Review Comment:
   Drop the Sort created just below (line 158) for the sortOnReceiver=true 
branch. It does no work at runtime, and a no-op operator should not stay in the 
plan. Removing a Sort whose input already provides the collation is a normal 
logical rewrite.
   
   With (sortOnSender=true, sortOnReceiver=true), every receiver already 
returns rows in order. A new receiver merges, or falls back to a full sort. An 
old receiver uses SortedMailboxReceiveOperator, which does a full sort. The 
Sort above has no fetch or offset. On new servers it becomes a 
LimitSortOperator with limit MAX_VALUE, which passes every row through. On old 
servers it sorts a second time, and that second sort is part of the triple sort 
during the upgrade window. Proposal: only wrap the exchange in LogicalSort when 
it does not sort on the receiver. Today that is the partitioned (false,false) 
branch. Or let a SortRemove-style rule drop the Sort because the exchange's 
collation already covers it. Consequences: (1) SortOperator.isInputSorted, the 
SortedMultiStageOperator interface, and the LimitSortOperator/SortOperator 
Javadoc edits exist only to turn this Sort into a no-op, so they can be 
removed. (2) WindowFunctionPlans.json and QueryCompilationTest expectations 
lose the Sor
 t above the exchange. (3) The comment that cites #19412's reason for the 
explicit Sort (fetch/offset handling, deprecated receive op) no longer applies 
here, because this Sort has no fetch.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxMergeReceiveOperator.java:
##########
@@ -0,0 +1,668 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.query.runtime.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.query.mailbox.ReceivingMailbox;
+import org.apache.pinot.query.planner.plannode.MailboxReceiveNode;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
+import org.apache.pinot.query.runtime.operator.utils.AsyncStream;
+import org.apache.pinot.query.runtime.operator.utils.SortUtils;
+import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Receives streams that the plan declares sorted on the sender and merges 
them by the exchange collation.
+///
+/// An explicit sender [SortOperator] establishes the row ordering; 
[MailboxSendOperator] only transports that
+/// ordering. The transport marker confirms rollout compatibility and is not 
itself a sorting mechanism.
+///
+/// The plan declaration alone is not trusted during a rolling upgrade. Every 
data block must carry the transport's
+/// sender-sort confirmation. Before this operator emits its first row it 
obtains a head row, or EOS, from every live
+/// sender. If any sender's first data is unconfirmed, all tentatively 
buffered rows are folded into a full receiver
+/// sort. Once output starts, losing the confirmation is a protocol violation 
because already emitted rows cannot be
+/// recovered into that fallback.
+///
+/// The merge reads whichever mailbox is ready instead of blocking on one 
sender. This prevents a sender that is
+/// backpressured by another receiver from creating a cross-receiver wait 
cycle. Rows are emitted in blocks of at most
+/// 10,000 while cursor state carries the ordering frontier across calls. A 
fast sender can be read ahead while another
+/// sender is starved, so retained input is workload-dependent and can 
approach the legacy full receiver sort in the
+/// worst case.
+///
+/// This operator is driven by a single consumer thread and is not thread-safe.
+public class SortedMailboxMergeReceiveOperator extends 
BaseMailboxReceiveOperator implements SortedMultiStageOperator {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(SortedMailboxMergeReceiveOperator.class);
+
+  private static final String EXPLAIN_NAME = "SORTED_MAILBOX_MERGE_RECEIVE";
+  private static final String MERGE_SCOPE = 
"SortedMailboxMergeReceiveOperator";
+  private final DataSchema _dataSchema;
+  private final List<RelFieldCollation> _collations;
+  private final Comparator<Object[]> _comparator;
+  private final SenderCursorHeap _readyCursors;
+  private final boolean _singleSortedSender;
+  /// Senders that have not finished but do not currently have a row ready. 
Nothing can be emitted while this is
+  /// non-empty because any one of these senders may hold the next row.
+  private final Set<SenderCursor> _starvedCursors = 
Collections.newSetFromMap(new IdentityHashMap<>());
+  private final Map<AsyncStream<ReceivingMailbox.MseBlockWithStats>, 
SenderCursor> _cursorsByStream =
+      new IdentityHashMap<>();
+  private boolean _mergeOutputStarted;
+  private boolean _fallbackToSort;
+  private boolean _tryEqualHeadMerge;
+  private int _fallbackOutputIndex = -1;
+
+  /// Rows buffered only for the mixed-version fallback. The sorted list is 
handed downstream as-is, so cleanup must
+  /// drop this reference rather than clear it.
+  @Nullable
+  private List<Object[]> _rows;
+
+  @Nullable
+  private MseBlock _eosBlock;
+
+  public SortedMailboxMergeReceiveOperator(OpChainExecutionContext context, 
MailboxReceiveNode node) {
+    super(context, node);
+    Preconditions.checkState(node.isSort(), "Receiver-side sorting must be 
enabled");
+    Preconditions.checkState(node.isSortedOnSender(), "Sender-side sorting 
must be enabled");
+    Preconditions.checkState(!CollectionUtils.isEmpty(node.getCollations()), 
"Field collations must be set");
+    _dataSchema = node.getDataSchema();
+    _collations = List.copyOf(node.getCollations());
+    _comparator = new SortUtils.SortComparator(_collations, false);

Review Comment:
   The comparator matches the sender side (FullSortOperator also uses 
SortComparator(collations, false)). Null ordering is consistent. OK.
   
   I checked this because a mismatch in null direction between sender and 
receiver would silently corrupt the merge.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxMergeReceiveOperator.java:
##########
@@ -0,0 +1,668 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.query.runtime.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.query.mailbox.ReceivingMailbox;
+import org.apache.pinot.query.planner.plannode.MailboxReceiveNode;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
+import org.apache.pinot.query.runtime.operator.utils.AsyncStream;
+import org.apache.pinot.query.runtime.operator.utils.SortUtils;
+import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Receives streams that the plan declares sorted on the sender and merges 
them by the exchange collation.
+///
+/// An explicit sender [SortOperator] establishes the row ordering; 
[MailboxSendOperator] only transports that
+/// ordering. The transport marker confirms rollout compatibility and is not 
itself a sorting mechanism.
+///
+/// The plan declaration alone is not trusted during a rolling upgrade. Every 
data block must carry the transport's
+/// sender-sort confirmation. Before this operator emits its first row it 
obtains a head row, or EOS, from every live
+/// sender. If any sender's first data is unconfirmed, all tentatively 
buffered rows are folded into a full receiver
+/// sort. Once output starts, losing the confirmation is a protocol violation 
because already emitted rows cannot be
+/// recovered into that fallback.
+///
+/// The merge reads whichever mailbox is ready instead of blocking on one 
sender. This prevents a sender that is
+/// backpressured by another receiver from creating a cross-receiver wait 
cycle. Rows are emitted in blocks of at most
+/// 10,000 while cursor state carries the ordering frontier across calls. A 
fast sender can be read ahead while another
+/// sender is starved, so retained input is workload-dependent and can 
approach the legacy full receiver sort in the
+/// worst case.
+///
+/// This operator is driven by a single consumer thread and is not thread-safe.
+public class SortedMailboxMergeReceiveOperator extends 
BaseMailboxReceiveOperator implements SortedMultiStageOperator {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(SortedMailboxMergeReceiveOperator.class);
+
+  private static final String EXPLAIN_NAME = "SORTED_MAILBOX_MERGE_RECEIVE";
+  private static final String MERGE_SCOPE = 
"SortedMailboxMergeReceiveOperator";
+  private final DataSchema _dataSchema;
+  private final List<RelFieldCollation> _collations;
+  private final Comparator<Object[]> _comparator;
+  private final SenderCursorHeap _readyCursors;
+  private final boolean _singleSortedSender;
+  /// Senders that have not finished but do not currently have a row ready. 
Nothing can be emitted while this is
+  /// non-empty because any one of these senders may hold the next row.
+  private final Set<SenderCursor> _starvedCursors = 
Collections.newSetFromMap(new IdentityHashMap<>());
+  private final Map<AsyncStream<ReceivingMailbox.MseBlockWithStats>, 
SenderCursor> _cursorsByStream =
+      new IdentityHashMap<>();
+  private boolean _mergeOutputStarted;
+  private boolean _fallbackToSort;
+  private boolean _tryEqualHeadMerge;
+  private int _fallbackOutputIndex = -1;
+
+  /// Rows buffered only for the mixed-version fallback. The sorted list is 
handed downstream as-is, so cleanup must
+  /// drop this reference rather than clear it.
+  @Nullable
+  private List<Object[]> _rows;
+
+  @Nullable
+  private MseBlock _eosBlock;
+
+  public SortedMailboxMergeReceiveOperator(OpChainExecutionContext context, 
MailboxReceiveNode node) {
+    super(context, node);
+    Preconditions.checkState(node.isSort(), "Receiver-side sorting must be 
enabled");
+    Preconditions.checkState(node.isSortedOnSender(), "Sender-side sorting 
must be enabled");
+    Preconditions.checkState(!CollectionUtils.isEmpty(node.getCollations()), 
"Field collations must be set");
+    _dataSchema = node.getDataSchema();
+    _collations = List.copyOf(node.getCollations());
+    _comparator = new SortUtils.SortComparator(_collations, false);
+    List<AsyncStream<ReceivingMailbox.MseBlockWithStats>> streams = 
_multiConsumer.getLiveStreamsSnapshot();
+    _readyCursors = new SenderCursorHeap(streams.size(), _comparator);
+    _singleSortedSender = streams.size() == 1;
+    if (!_singleSortedSender) {
+      for (AsyncStream<ReceivingMailbox.MseBlockWithStats> stream : streams) {
+        SenderCursor cursor = new SenderCursor(stream);
+        _cursorsByStream.put(stream, cursor);
+        _starvedCursors.add(cursor);
+      }
+    }
+  }
+
+  @Override
+  protected Logger logger() {
+    return LOGGER;
+  }
+
+  @Override
+  public String toExplainString() {
+    return EXPLAIN_NAME;
+  }
+
+  @Override
+  public List<RelFieldCollation> getCollations() {
+    return _collations;
+  }
+
+  @Override
+  protected MseBlock getNextBlock() {
+    if (_fallbackOutputIndex >= 0 && _rows != null) {
+      return emitFallbackBlock();
+    }
+    if (_eosBlock != null) {
+      return _eosBlock;
+    }
+    if (_isEarlyTerminated) {
+      return readUntilEos();
+    }
+    return _singleSortedSender ? readSingleSortedSender() : mergeNextBlock();
+  }
+
+  /// Passes through one confirmed sorted sender without copying its rows 
through the merge heap.
+  private MseBlock readSingleSortedSender() {
+    while (true) {
+      MseBlock block = _multiConsumer.readMseBlockBlocking();
+      if (block.isEos()) {
+        return terminate(block);
+      }
+      MseBlock.Data dataBlock = (MseBlock.Data) block;
+      checkActiveTerminationAndSampleUsage();
+      if (!_multiConsumer.isLastBlockSortedOnSender()) {
+        fallbackToFullSort(dataBlock.asRowHeap().getRows());
+        return sortAllRows();
+      }
+      if (dataBlock.getNumRows() > 0) {
+        _mergeOutputStarted = true;
+        return dataBlock;
+      }
+    }
+  }
+
+  /// Merges the sorted senders, emitting at most 
[SortOperator#DEFAULT_MAX_ROWS_PER_BLOCK] rows per call.
+  private MseBlock mergeNextBlock() {
+    ArrayList<Object[]> rows = new ArrayList<>(0);
+    while (rows.size() < SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK) {
+      if (!_starvedCursors.isEmpty()) {
+        MseBlock.Eos error;
+        boolean receivedMoreRows = false;
+        if (rows.isEmpty()) {
+          error = readOneBlock();
+        } else {
+          // Rows already removed from the heap are a globally ordered prefix. 
Consume any immediately available
+          // cursor progress so blocks can still be coalesced, but return that 
safe prefix instead of waiting merely
+          // to fill the output block.
+          MseBlock block = _multiConsumer.pollMseBlockOrStreamCompletion();
+          if (block == null && 
_multiConsumer.getFinishedStreamsLastRead().isEmpty()) {
+            break;
+          }
+          error = processReadBlock(block);
+          receivedMoreRows = block != null && block.isData() && 
((MseBlock.Data) block).getNumRows() > 0;
+        }
+        if (error != null) {
+          return terminate(error);
+        }
+        if (_fallbackToSort) {
+          // These rows were already removed from cursors while building this 
not-yet-emitted block.
+          _rows.addAll(rows);
+          return sortAllRows();
+        }
+        if (receivedMoreRows) {
+          // A refill proves this is not a one-block result. Restore the 
established full-block capacity once so
+          // fragmented input cannot trigger repeated growth while this output 
block is assembled.
+          rows.ensureCapacity(SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK);
+        }
+        continue;
+      }
+      if (_readyCursors.isEmpty()) {
+        break;
+      }
+      if (rows.isEmpty()) {
+        // Keep empty and tiny results cheap without sacrificing the 
one-allocation path for full output blocks.
+        // Sum all currently buffered rows once; later output blocks retain 
the established full-block capacity.
+        int initialCapacity = _mergeOutputStarted ? 
SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK
+            : 
_readyCursors.getCappedAvailableRowCount(SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK);
+        // ArrayList grows by 1.5x. Round near-full first blocks up now so a 
small refill cannot allocate an oversized
+        // replacement in addition to the nearly full initial array.
+        if (initialCapacity + (initialCapacity >> 1) >= 
SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK) {
+          initialCapacity = SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK;
+        }
+        rows.ensureCapacity(initialCapacity);
+      }
+      if (_tryEqualHeadMerge && _readyCursors.size() > 1) {

Review Comment:
   The equal-head fast path together with the lazy 'dirty' heap adds the most 
complexity in this PR. I traced it and it looks correct, but the invariants are 
implicit.
   
   Correctness depends on: dirty=true only when active==oldSize>1; every path 
that sets _tryEqualHeadMerge=false either heapified already or hits the else 
branch at 236; add() is only reached when a cursor was starved, which implies 
the heap was heapified. Only one targeted test covers this 
(shouldMergeEqualHeadsAcrossRefillIntoBoundedBlocks). A randomized test would 
be much cheaper insurance than the reasoning above: N senders, random block 
splits, random tie density, random starvation order, and output compared 
against a full sort.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxMergeReceiveOperator.java:
##########
@@ -0,0 +1,668 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.query.runtime.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.query.mailbox.ReceivingMailbox;
+import org.apache.pinot.query.planner.plannode.MailboxReceiveNode;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
+import org.apache.pinot.query.runtime.operator.utils.AsyncStream;
+import org.apache.pinot.query.runtime.operator.utils.SortUtils;
+import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Receives streams that the plan declares sorted on the sender and merges 
them by the exchange collation.
+///
+/// An explicit sender [SortOperator] establishes the row ordering; 
[MailboxSendOperator] only transports that
+/// ordering. The transport marker confirms rollout compatibility and is not 
itself a sorting mechanism.
+///
+/// The plan declaration alone is not trusted during a rolling upgrade. Every 
data block must carry the transport's
+/// sender-sort confirmation. Before this operator emits its first row it 
obtains a head row, or EOS, from every live
+/// sender. If any sender's first data is unconfirmed, all tentatively 
buffered rows are folded into a full receiver
+/// sort. Once output starts, losing the confirmation is a protocol violation 
because already emitted rows cannot be
+/// recovered into that fallback.
+///
+/// The merge reads whichever mailbox is ready instead of blocking on one 
sender. This prevents a sender that is
+/// backpressured by another receiver from creating a cross-receiver wait 
cycle. Rows are emitted in blocks of at most
+/// 10,000 while cursor state carries the ordering frontier across calls. A 
fast sender can be read ahead while another
+/// sender is starved, so retained input is workload-dependent and can 
approach the legacy full receiver sort in the
+/// worst case.
+///
+/// This operator is driven by a single consumer thread and is not thread-safe.
+public class SortedMailboxMergeReceiveOperator extends 
BaseMailboxReceiveOperator implements SortedMultiStageOperator {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(SortedMailboxMergeReceiveOperator.class);
+
+  private static final String EXPLAIN_NAME = "SORTED_MAILBOX_MERGE_RECEIVE";
+  private static final String MERGE_SCOPE = 
"SortedMailboxMergeReceiveOperator";
+  private final DataSchema _dataSchema;
+  private final List<RelFieldCollation> _collations;
+  private final Comparator<Object[]> _comparator;
+  private final SenderCursorHeap _readyCursors;
+  private final boolean _singleSortedSender;
+  /// Senders that have not finished but do not currently have a row ready. 
Nothing can be emitted while this is
+  /// non-empty because any one of these senders may hold the next row.
+  private final Set<SenderCursor> _starvedCursors = 
Collections.newSetFromMap(new IdentityHashMap<>());
+  private final Map<AsyncStream<ReceivingMailbox.MseBlockWithStats>, 
SenderCursor> _cursorsByStream =
+      new IdentityHashMap<>();
+  private boolean _mergeOutputStarted;
+  private boolean _fallbackToSort;
+  private boolean _tryEqualHeadMerge;
+  private int _fallbackOutputIndex = -1;
+
+  /// Rows buffered only for the mixed-version fallback. The sorted list is 
handed downstream as-is, so cleanup must
+  /// drop this reference rather than clear it.
+  @Nullable
+  private List<Object[]> _rows;
+
+  @Nullable
+  private MseBlock _eosBlock;
+
+  public SortedMailboxMergeReceiveOperator(OpChainExecutionContext context, 
MailboxReceiveNode node) {
+    super(context, node);
+    Preconditions.checkState(node.isSort(), "Receiver-side sorting must be 
enabled");
+    Preconditions.checkState(node.isSortedOnSender(), "Sender-side sorting 
must be enabled");
+    Preconditions.checkState(!CollectionUtils.isEmpty(node.getCollations()), 
"Field collations must be set");
+    _dataSchema = node.getDataSchema();
+    _collations = List.copyOf(node.getCollations());
+    _comparator = new SortUtils.SortComparator(_collations, false);
+    List<AsyncStream<ReceivingMailbox.MseBlockWithStats>> streams = 
_multiConsumer.getLiveStreamsSnapshot();
+    _readyCursors = new SenderCursorHeap(streams.size(), _comparator);
+    _singleSortedSender = streams.size() == 1;
+    if (!_singleSortedSender) {
+      for (AsyncStream<ReceivingMailbox.MseBlockWithStats> stream : streams) {
+        SenderCursor cursor = new SenderCursor(stream);
+        _cursorsByStream.put(stream, cursor);
+        _starvedCursors.add(cursor);
+      }
+    }
+  }
+
+  @Override
+  protected Logger logger() {
+    return LOGGER;
+  }
+
+  @Override
+  public String toExplainString() {
+    return EXPLAIN_NAME;
+  }
+
+  @Override
+  public List<RelFieldCollation> getCollations() {
+    return _collations;
+  }
+
+  @Override
+  protected MseBlock getNextBlock() {
+    if (_fallbackOutputIndex >= 0 && _rows != null) {
+      return emitFallbackBlock();
+    }
+    if (_eosBlock != null) {
+      return _eosBlock;
+    }
+    if (_isEarlyTerminated) {
+      return readUntilEos();
+    }
+    return _singleSortedSender ? readSingleSortedSender() : mergeNextBlock();
+  }
+
+  /// Passes through one confirmed sorted sender without copying its rows 
through the merge heap.
+  private MseBlock readSingleSortedSender() {
+    while (true) {
+      MseBlock block = _multiConsumer.readMseBlockBlocking();
+      if (block.isEos()) {
+        return terminate(block);
+      }
+      MseBlock.Data dataBlock = (MseBlock.Data) block;
+      checkActiveTerminationAndSampleUsage();
+      if (!_multiConsumer.isLastBlockSortedOnSender()) {
+        fallbackToFullSort(dataBlock.asRowHeap().getRows());
+        return sortAllRows();
+      }
+      if (dataBlock.getNumRows() > 0) {
+        _mergeOutputStarted = true;
+        return dataBlock;
+      }
+    }
+  }
+
+  /// Merges the sorted senders, emitting at most 
[SortOperator#DEFAULT_MAX_ROWS_PER_BLOCK] rows per call.
+  private MseBlock mergeNextBlock() {
+    ArrayList<Object[]> rows = new ArrayList<>(0);
+    while (rows.size() < SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK) {
+      if (!_starvedCursors.isEmpty()) {

Review Comment:
   MEMORY: when one cursor is starved, readOneBlock pulls from any ready 
mailbox into unbounded per-cursor _pending deques. This effectively removes 
mailbox backpressure for the fast senders.
   
   The PR describes this, and the worst case equals the old full receiver sort. 
The difference is that sender-side FullSortOperators now also hold their full 
buffers during the send, so peak cluster memory can be higher than on master 
when one sender is slow. Consider a follow-up that bounds pending rows per 
cursor, or at least a stat (for example max retained rows) so this can be 
observed in production.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxMergeReceiveOperator.java:
##########
@@ -0,0 +1,668 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.query.runtime.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.query.mailbox.ReceivingMailbox;
+import org.apache.pinot.query.planner.plannode.MailboxReceiveNode;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
+import org.apache.pinot.query.runtime.operator.utils.AsyncStream;
+import org.apache.pinot.query.runtime.operator.utils.SortUtils;
+import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Receives streams that the plan declares sorted on the sender and merges 
them by the exchange collation.
+///
+/// An explicit sender [SortOperator] establishes the row ordering; 
[MailboxSendOperator] only transports that
+/// ordering. The transport marker confirms rollout compatibility and is not 
itself a sorting mechanism.
+///
+/// The plan declaration alone is not trusted during a rolling upgrade. Every 
data block must carry the transport's
+/// sender-sort confirmation. Before this operator emits its first row it 
obtains a head row, or EOS, from every live
+/// sender. If any sender's first data is unconfirmed, all tentatively 
buffered rows are folded into a full receiver
+/// sort. Once output starts, losing the confirmation is a protocol violation 
because already emitted rows cannot be
+/// recovered into that fallback.
+///
+/// The merge reads whichever mailbox is ready instead of blocking on one 
sender. This prevents a sender that is
+/// backpressured by another receiver from creating a cross-receiver wait 
cycle. Rows are emitted in blocks of at most
+/// 10,000 while cursor state carries the ordering frontier across calls. A 
fast sender can be read ahead while another
+/// sender is starved, so retained input is workload-dependent and can 
approach the legacy full receiver sort in the
+/// worst case.
+///
+/// This operator is driven by a single consumer thread and is not thread-safe.
+public class SortedMailboxMergeReceiveOperator extends 
BaseMailboxReceiveOperator implements SortedMultiStageOperator {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(SortedMailboxMergeReceiveOperator.class);
+
+  private static final String EXPLAIN_NAME = "SORTED_MAILBOX_MERGE_RECEIVE";
+  private static final String MERGE_SCOPE = 
"SortedMailboxMergeReceiveOperator";
+  private final DataSchema _dataSchema;
+  private final List<RelFieldCollation> _collations;
+  private final Comparator<Object[]> _comparator;
+  private final SenderCursorHeap _readyCursors;
+  private final boolean _singleSortedSender;
+  /// Senders that have not finished but do not currently have a row ready. 
Nothing can be emitted while this is
+  /// non-empty because any one of these senders may hold the next row.
+  private final Set<SenderCursor> _starvedCursors = 
Collections.newSetFromMap(new IdentityHashMap<>());
+  private final Map<AsyncStream<ReceivingMailbox.MseBlockWithStats>, 
SenderCursor> _cursorsByStream =
+      new IdentityHashMap<>();
+  private boolean _mergeOutputStarted;
+  private boolean _fallbackToSort;
+  private boolean _tryEqualHeadMerge;
+  private int _fallbackOutputIndex = -1;
+
+  /// Rows buffered only for the mixed-version fallback. The sorted list is 
handed downstream as-is, so cleanup must
+  /// drop this reference rather than clear it.
+  @Nullable
+  private List<Object[]> _rows;
+
+  @Nullable
+  private MseBlock _eosBlock;
+
+  public SortedMailboxMergeReceiveOperator(OpChainExecutionContext context, 
MailboxReceiveNode node) {
+    super(context, node);
+    Preconditions.checkState(node.isSort(), "Receiver-side sorting must be 
enabled");
+    Preconditions.checkState(node.isSortedOnSender(), "Sender-side sorting 
must be enabled");
+    Preconditions.checkState(!CollectionUtils.isEmpty(node.getCollations()), 
"Field collations must be set");
+    _dataSchema = node.getDataSchema();
+    _collations = List.copyOf(node.getCollations());
+    _comparator = new SortUtils.SortComparator(_collations, false);
+    List<AsyncStream<ReceivingMailbox.MseBlockWithStats>> streams = 
_multiConsumer.getLiveStreamsSnapshot();
+    _readyCursors = new SenderCursorHeap(streams.size(), _comparator);
+    _singleSortedSender = streams.size() == 1;
+    if (!_singleSortedSender) {
+      for (AsyncStream<ReceivingMailbox.MseBlockWithStats> stream : streams) {
+        SenderCursor cursor = new SenderCursor(stream);
+        _cursorsByStream.put(stream, cursor);
+        _starvedCursors.add(cursor);
+      }
+    }
+  }
+
+  @Override
+  protected Logger logger() {
+    return LOGGER;
+  }
+
+  @Override
+  public String toExplainString() {
+    return EXPLAIN_NAME;
+  }
+
+  @Override
+  public List<RelFieldCollation> getCollations() {
+    return _collations;
+  }
+
+  @Override
+  protected MseBlock getNextBlock() {
+    if (_fallbackOutputIndex >= 0 && _rows != null) {
+      return emitFallbackBlock();
+    }
+    if (_eosBlock != null) {
+      return _eosBlock;
+    }
+    if (_isEarlyTerminated) {
+      return readUntilEos();
+    }
+    return _singleSortedSender ? readSingleSortedSender() : mergeNextBlock();
+  }
+
+  /// Passes through one confirmed sorted sender without copying its rows 
through the merge heap.
+  private MseBlock readSingleSortedSender() {
+    while (true) {
+      MseBlock block = _multiConsumer.readMseBlockBlocking();
+      if (block.isEos()) {
+        return terminate(block);
+      }
+      MseBlock.Data dataBlock = (MseBlock.Data) block;
+      checkActiveTerminationAndSampleUsage();
+      if (!_multiConsumer.isLastBlockSortedOnSender()) {

Review Comment:
   On the single-sender path, blocks pass through unsplit, so output blocks can 
be larger than 10k rows. That is the same as today's receive, but the class 
Javadoc says output blocks are capped at 10,000 rows.
   
   Align the Javadoc or the PR text.



##########
pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotWindowExchangeNodeInsertRule.java:
##########
@@ -120,13 +120,13 @@ public void onMatch(RelOptRuleCall call) {
         exchange = PinotLogicalExchange.create(input, 
RelDistributions.hash(List.of()));
       } else {
         // Only ORDER BY
-        // Add a LogicalSortExchange with collation on the order by key(s) and 
an empty hash partition key.
-        // The ordering itself is established by the Sort placed over the 
exchange below, not by the receive
-        // operator - see the comment at the transformTo call.
+        // Sort each sender explicitly and merge the sorted mailbox streams at 
the receiver. The Sort retained above
+        // the exchange is the semantic ordering boundary and becomes a 
streaming limit when the merge receiver
+        // advertises this exact collation.
         // TODO: Revisit whether we should use hash distribution
         exchange =
-            PinotLogicalSortExchange.create(input, 
RelDistributions.hash(List.of()), windowGroup.orderKeys, false,
-                false);
+            PinotLogicalSortExchange.create(input, 
RelDistributions.hash(List.of()), windowGroup.orderKeys, true,

Review Comment:
   RISK: there is no kill switch. Every global ORDER BY window now takes the 
sender-sort + merge path, and the PR's own numbers show +53% on presorted input 
and +2-4% on small or single-sender cases. Please add a broker config plus a 
query option to turn it off.
   
   Use the same style of mechanism as the normal sort-exchange copy (broker 
config CONFIG_OF_SORT_EXCHANGE_COPY_THRESHOLD + query option 
sortExchangeCopyThreshold). The trigger must not be that threshold itself, 
though. The threshold is keyed on the Sort's fetch, and the window Sort has no 
fetch: a query LIMIT sits above the window and is not pushed through it. The 
sender sort is therefore always a full sort, and a fetch threshold would never 
enable this path. The L x W amplification that motivates the threshold also 
does not happen here: a global window already sends all N rows to one receiver, 
so sorting on the senders does not change how many rows are sent. The costs 
here are different: merge CPU, memory held by both sender buffers and receiver 
read-ahead, and the upgrade-window extra sorts. So a boolean (for example a 
windowSortOnSender option, default on or off per the author's call) fits 
better. A threshold on something meaningful, such as the number of senders, 
would also work
 . If a LIMIT is ever pushed through ROW_NUMBER/RANK or other prefix-frame 
windows, the senders would do a top-L; that is exactly the normal-sort case and 
should reuse sortExchangeCopyThreshold. The switch also gives operators a way 
back from a production regression without a rollback.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/GrpcSendingMailbox.java:
##########
@@ -432,12 +435,14 @@ protected void sendContent(ByteString byteString, boolean 
waitForMore, boolean b
       if (!bypassReady && isTerminated()) {
         return;
       }
-      MailboxContent content = MailboxContent.newBuilder()
+      MailboxContent.Builder contentBuilder = MailboxContent.newBuilder()
           .setMailboxId(_id)
           .setPayload(byteString)
-          .setWaitForMore(waitForMore)
-          .build();
-      _contentObserver.onNext(content);
+          .setWaitForMore(waitForMore);
+      if (_sortedOnSender) {
+        
contentBuilder.putMetadata(ChannelUtils.MAILBOX_METADATA_SORTED_ON_SENDER, 
Boolean.TRUE.toString());

Review Comment:
   nit: the marker is added to every chunk, including split parts 
(waitForMore=true) and EOS.
   
   MailboxContentObserver reads the flag only from the message that completes 
the block, so the flag on the other chunks is unused. It costs 26 bytes each, 
which is negligible. The flag is set per stream but repeated per message; that 
is fine for simplicity.



##########
pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PlanFragmenter.java:
##########
@@ -185,6 +186,16 @@ public PlanNode visitExchange(ExchangeNode node, Context 
context) {
 
     // Create a new context for the next PlanFragment with MailboxSendNode as 
the root node.
     PlanNode nextPlanFragmentRoot = node.getInputs().get(0).visit(this, new 
Context(senderPlanFragmentId));
+    if (node.isSortOnSender()) {
+      Preconditions.checkState(!node.getCollations().isEmpty(),
+          "Sender sorting requires a non-empty exchange collation");
+      // Ordering belongs to an explicit operator in the sender fragment. 
MailboxSendOperator only preserves and
+      // transports this output; ServerPlanRequestVisitor keeps this SortNode 
above the V1 leaf boundary.
+      // SortOperator applies the broker response limit when fetch is absent. 
This internal sort must retain every
+      // sender row, so use the largest representable fetch with a zero 
effective offset.
+      nextPlanFragmentRoot = new SortNode(senderPlanFragmentId, 
nextPlanFragmentRoot.getDataSchema(), null,
+          List.of(nextPlanFragmentRoot), node.getCollations(), 
Integer.MAX_VALUE, -1);

Review Comment:
   Inserting a physical SortNode inside the fragmenter works, but it makes the 
stage plan differ from the RelNode plan.
   
   EXPLAIN (logical) shows only isSortOnSender=[true]. The SortNode with 
fetch=2147483647 appears only in the fragmented or implementation plan. That is 
acceptable, but please confirm that EXPLAIN IMPLEMENTATION PLAN renders it 
clearly. Also note that the MAX_VALUE fetch reaches old servers' V1 leaf as 
LIMIT 2147483647, and that LIMIT replaces any multiStageLeafLimit. On the new 
server path it does not, because the sort stays above the leaf boundary.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MailboxSendOperator.java:
##########
@@ -64,13 +67,17 @@ public class MailboxSendOperator extends MultiStageOperator 
{
   private final BlockExchange _exchange;
   private final StatMap<StatKey> _statMap = new StatMap<>(StatKey.class);
 
-  // TODO: Support sort on sender
   public MailboxSendOperator(OpChainExecutionContext context, 
MultiStageOperator input, MailboxSendNode node) {
-    this(context, input, statMap -> getBlockExchange(context, node, statMap));
+    this(context, input, statMap -> getBlockExchange(context, node, statMap, 
isSortedOnSender(input, node)));
     _statMap.merge(StatKey.STAGE, context.getStageId());
     _statMap.merge(StatKey.PARALLELISM, 1);
   }
 
+  @VisibleForTesting
+  static boolean isSortedOnSender(MultiStageOperator input, MailboxSendNode 
node) {
+    return input instanceof SortOperator && node.hasExplicitSortInput();

Review Comment:
   The marker is keyed on 'input instanceof SortOperator'. That holds for all 
three implementations (Limit/TopN/Full), and each one produces a sorted stream 
here. OK, but the check is fragile.
   
   If someone later wraps the sender op-chain (for example a stats or tracing 
decorator), the marker silently disappears. Every query then takes 
fallbackToFullSort, and nothing fails. Consider asking the op for its collation 
instead, since SortedMultiStageOperator already exists. Or add a test in 
MailboxSendOperatorTest that builds the real op-chain from PlanNodeToOpChain.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/utils/BlockingMultiStreamConsumer.java:
##########
@@ -269,6 +315,37 @@ private E readDroppingSuccessEos() {
     return block;
   }
 
+  /// Returns the stream that produced the element the last blocking read 
returned.
+  ///
+  /// This is only meaningful right after that call returned a data or an 
error element. The element that ends all
+  /// the streams is not produced by any of them, and a stream that emitted 
its EOS is dropped from the ones this
+  /// consumer tracks, so `null` is returned in both cases.
+  ///
+  /// Consumers that need to keep the elements of each stream apart, like a 
merge of already sorted streams, use
+  /// this to tell which stream the element they just read belongs to.
+  ///
+  /// This method is called by the consumer thread.
+  @Nullable
+  public AsyncStream<E> getLastReadStream() {

Review Comment:
   API surface: this adds 3 accessors with 'only valid right after the last 
read' semantics (getLastReadStream, getFinishedStreamsLastRead view, live 
snapshot). They are easy to misuse.
   
   Only the merge receiver uses them. A single read method that returns a small 
result (block, source stream, finished streams) would make the protocol 
explicit instead of relying on temporal coupling. This is not blocking.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxMergeReceiveOperator.java:
##########
@@ -0,0 +1,668 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.query.runtime.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.query.mailbox.ReceivingMailbox;
+import org.apache.pinot.query.planner.plannode.MailboxReceiveNode;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
+import org.apache.pinot.query.runtime.operator.utils.AsyncStream;
+import org.apache.pinot.query.runtime.operator.utils.SortUtils;
+import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Receives streams that the plan declares sorted on the sender and merges 
them by the exchange collation.
+///
+/// An explicit sender [SortOperator] establishes the row ordering; 
[MailboxSendOperator] only transports that
+/// ordering. The transport marker confirms rollout compatibility and is not 
itself a sorting mechanism.
+///
+/// The plan declaration alone is not trusted during a rolling upgrade. Every 
data block must carry the transport's
+/// sender-sort confirmation. Before this operator emits its first row it 
obtains a head row, or EOS, from every live
+/// sender. If any sender's first data is unconfirmed, all tentatively 
buffered rows are folded into a full receiver
+/// sort. Once output starts, losing the confirmation is a protocol violation 
because already emitted rows cannot be
+/// recovered into that fallback.
+///
+/// The merge reads whichever mailbox is ready instead of blocking on one 
sender. This prevents a sender that is
+/// backpressured by another receiver from creating a cross-receiver wait 
cycle. Rows are emitted in blocks of at most
+/// 10,000 while cursor state carries the ordering frontier across calls. A 
fast sender can be read ahead while another
+/// sender is starved, so retained input is workload-dependent and can 
approach the legacy full receiver sort in the
+/// worst case.
+///
+/// This operator is driven by a single consumer thread and is not thread-safe.
+public class SortedMailboxMergeReceiveOperator extends 
BaseMailboxReceiveOperator implements SortedMultiStageOperator {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(SortedMailboxMergeReceiveOperator.class);
+
+  private static final String EXPLAIN_NAME = "SORTED_MAILBOX_MERGE_RECEIVE";
+  private static final String MERGE_SCOPE = 
"SortedMailboxMergeReceiveOperator";
+  private final DataSchema _dataSchema;
+  private final List<RelFieldCollation> _collations;
+  private final Comparator<Object[]> _comparator;
+  private final SenderCursorHeap _readyCursors;
+  private final boolean _singleSortedSender;
+  /// Senders that have not finished but do not currently have a row ready. 
Nothing can be emitted while this is
+  /// non-empty because any one of these senders may hold the next row.
+  private final Set<SenderCursor> _starvedCursors = 
Collections.newSetFromMap(new IdentityHashMap<>());
+  private final Map<AsyncStream<ReceivingMailbox.MseBlockWithStats>, 
SenderCursor> _cursorsByStream =
+      new IdentityHashMap<>();
+  private boolean _mergeOutputStarted;
+  private boolean _fallbackToSort;
+  private boolean _tryEqualHeadMerge;
+  private int _fallbackOutputIndex = -1;
+
+  /// Rows buffered only for the mixed-version fallback. The sorted list is 
handed downstream as-is, so cleanup must
+  /// drop this reference rather than clear it.
+  @Nullable
+  private List<Object[]> _rows;
+
+  @Nullable
+  private MseBlock _eosBlock;
+
+  public SortedMailboxMergeReceiveOperator(OpChainExecutionContext context, 
MailboxReceiveNode node) {
+    super(context, node);
+    Preconditions.checkState(node.isSort(), "Receiver-side sorting must be 
enabled");
+    Preconditions.checkState(node.isSortedOnSender(), "Sender-side sorting 
must be enabled");
+    Preconditions.checkState(!CollectionUtils.isEmpty(node.getCollations()), 
"Field collations must be set");
+    _dataSchema = node.getDataSchema();
+    _collations = List.copyOf(node.getCollations());
+    _comparator = new SortUtils.SortComparator(_collations, false);
+    List<AsyncStream<ReceivingMailbox.MseBlockWithStats>> streams = 
_multiConsumer.getLiveStreamsSnapshot();
+    _readyCursors = new SenderCursorHeap(streams.size(), _comparator);
+    _singleSortedSender = streams.size() == 1;
+    if (!_singleSortedSender) {
+      for (AsyncStream<ReceivingMailbox.MseBlockWithStats> stream : streams) {
+        SenderCursor cursor = new SenderCursor(stream);
+        _cursorsByStream.put(stream, cursor);
+        _starvedCursors.add(cursor);
+      }
+    }
+  }
+
+  @Override
+  protected Logger logger() {
+    return LOGGER;
+  }
+
+  @Override
+  public String toExplainString() {
+    return EXPLAIN_NAME;
+  }
+
+  @Override
+  public List<RelFieldCollation> getCollations() {
+    return _collations;
+  }
+
+  @Override
+  protected MseBlock getNextBlock() {
+    if (_fallbackOutputIndex >= 0 && _rows != null) {
+      return emitFallbackBlock();
+    }
+    if (_eosBlock != null) {
+      return _eosBlock;
+    }
+    if (_isEarlyTerminated) {
+      return readUntilEos();
+    }
+    return _singleSortedSender ? readSingleSortedSender() : mergeNextBlock();
+  }
+
+  /// Passes through one confirmed sorted sender without copying its rows 
through the merge heap.
+  private MseBlock readSingleSortedSender() {
+    while (true) {
+      MseBlock block = _multiConsumer.readMseBlockBlocking();
+      if (block.isEos()) {
+        return terminate(block);
+      }
+      MseBlock.Data dataBlock = (MseBlock.Data) block;
+      checkActiveTerminationAndSampleUsage();
+      if (!_multiConsumer.isLastBlockSortedOnSender()) {
+        fallbackToFullSort(dataBlock.asRowHeap().getRows());
+        return sortAllRows();
+      }
+      if (dataBlock.getNumRows() > 0) {
+        _mergeOutputStarted = true;
+        return dataBlock;
+      }
+    }
+  }
+
+  /// Merges the sorted senders, emitting at most 
[SortOperator#DEFAULT_MAX_ROWS_PER_BLOCK] rows per call.
+  private MseBlock mergeNextBlock() {
+    ArrayList<Object[]> rows = new ArrayList<>(0);
+    while (rows.size() < SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK) {
+      if (!_starvedCursors.isEmpty()) {
+        MseBlock.Eos error;
+        boolean receivedMoreRows = false;
+        if (rows.isEmpty()) {
+          error = readOneBlock();
+        } else {
+          // Rows already removed from the heap are a globally ordered prefix. 
Consume any immediately available
+          // cursor progress so blocks can still be coalesced, but return that 
safe prefix instead of waiting merely
+          // to fill the output block.
+          MseBlock block = _multiConsumer.pollMseBlockOrStreamCompletion();
+          if (block == null && 
_multiConsumer.getFinishedStreamsLastRead().isEmpty()) {
+            break;
+          }
+          error = processReadBlock(block);
+          receivedMoreRows = block != null && block.isData() && 
((MseBlock.Data) block).getNumRows() > 0;
+        }
+        if (error != null) {
+          return terminate(error);
+        }
+        if (_fallbackToSort) {
+          // These rows were already removed from cursors while building this 
not-yet-emitted block.
+          _rows.addAll(rows);
+          return sortAllRows();
+        }
+        if (receivedMoreRows) {
+          // A refill proves this is not a one-block result. Restore the 
established full-block capacity once so
+          // fragmented input cannot trigger repeated growth while this output 
block is assembled.
+          rows.ensureCapacity(SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK);
+        }
+        continue;
+      }
+      if (_readyCursors.isEmpty()) {
+        break;
+      }
+      if (rows.isEmpty()) {
+        // Keep empty and tiny results cheap without sacrificing the 
one-allocation path for full output blocks.
+        // Sum all currently buffered rows once; later output blocks retain 
the established full-block capacity.
+        int initialCapacity = _mergeOutputStarted ? 
SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK
+            : 
_readyCursors.getCappedAvailableRowCount(SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK);
+        // ArrayList grows by 1.5x. Round near-full first blocks up now so a 
small refill cannot allocate an oversized
+        // replacement in addition to the nearly full initial array.
+        if (initialCapacity + (initialCapacity >> 1) >= 
SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK) {
+          initialCapacity = SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK;
+        }
+        rows.ensureCapacity(initialCapacity);
+      }
+      if (_tryEqualHeadMerge && _readyCursors.size() > 1) {
+        int remaining = SortOperator.DEFAULT_MAX_ROWS_PER_BLOCK - rows.size();
+        if (remaining >= _readyCursors.size()) {
+          if (_readyCursors.allHeadsEqual()) {
+            int previousRowCount = rows.size();
+            List<SenderCursor> exhausted = 
_readyCursors.advanceEqualHeads(rows);
+            if (exhausted != null) {
+              for (SenderCursor cursor : exhausted) {
+                if (!cursor._finished) {
+                  _starvedCursors.add(cursor);
+                }
+              }
+            }
+            if (!_starvedCursors.isEmpty()) {
+              _tryEqualHeadMerge = false;
+            }
+            checkActiveTerminationAndSampleUsageAfterBatch(previousRowCount, 
rows.size());
+            continue;
+          } else {
+            _tryEqualHeadMerge = false;
+          }
+        } else {
+          _readyCursors.ensureOrdered();
+        }
+      }
+      SenderCursor cursor = _readyCursors.peek();
+      rows.add(cursor.next());
+      if (cursor.hasRow()) {
+        // The cursor's key can only move forward, so restoring the heap from 
the root takes one sift-down. A generic
+        // PriorityQueue poll followed by add performs two independent heap 
repairs for every emitted row.
+        _readyCursors.updateTop();
+      } else if (!cursor._finished) {
+        _readyCursors.removeTop();
+        _starvedCursors.add(cursor);
+        _tryEqualHeadMerge = false;
+      } else {
+        _readyCursors.removeTop();
+        _tryEqualHeadMerge = true;
+      }
+      
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(rows.size(), 
MERGE_SCOPE,
+          _context.getActiveDeadlineMs());
+    }
+    if (rows.isEmpty()) {
+      return terminate(SuccessMseBlock.INSTANCE);
+    }
+    _mergeOutputStarted = true;
+    return new RowHeapDataBlock(rows, _dataSchema);
+  }
+
+  /// Reads one block from whichever sender is ready and updates only the 
cursor that produced it.
+  ///
+  /// @return the error that ended the read, or `null` when the read succeeded
+  @Nullable
+  private MseBlock.Eos readOneBlock() {
+    return 
processReadBlock(_multiConsumer.readMseBlockOrStreamCompletionBlocking());
+  }
+
+  @Nullable
+  private MseBlock.Eos processReadBlock(@Nullable MseBlock block) {
+    if (block == null) {
+      updateFinishedCursors();
+      if (_starvedCursors.isEmpty()) {
+        _tryEqualHeadMerge = true;
+      }
+      return null;
+    }
+    if (block.isEos()) {
+      updateFinishedCursors();
+      MseBlock.Eos eos = (MseBlock.Eos) block;
+      if (eos.isError()) {
+        return eos;
+      }
+      // Aggregate success is returned only after every sender has emitted EOS.
+      _starvedCursors.clear();
+      _tryEqualHeadMerge = true;
+      return null;
+    }
+    AsyncStream<ReceivingMailbox.MseBlockWithStats> stream = 
_multiConsumer.getLastReadStream();
+    Preconditions.checkState(stream != null, "Read a data block from no 
mailbox on stage: %s", _context.getStageId());
+    SenderCursor cursor = _cursorsByStream.get(stream);
+    Preconditions.checkState(cursor != null, "Read a data block from unknown 
mailbox: %s", stream.getId());
+    List<Object[]> rows = ((MseBlock.Data) block).asRowHeap().getRows();
+    checkActiveTerminationAndSampleUsage();
+    if (!_multiConsumer.isLastBlockSortedOnSender()) {
+      fallbackToFullSort(rows);
+      return null;
+    }
+    cursor.offer(rows);
+    updateFinishedCursors();
+    if (cursor.hasRow() && _starvedCursors.remove(cursor)) {
+      _readyCursors.add(cursor);
+    }
+    if (_starvedCursors.isEmpty()) {
+      _tryEqualHeadMerge = true;
+    }
+    return null;
+  }
+
+  /// Removes only the starved cursors whose EOS was consumed by the last read.
+  private void updateFinishedCursors() {
+    for (AsyncStream<ReceivingMailbox.MseBlockWithStats> stream : 
_multiConsumer.getFinishedStreamsLastRead()) {
+      SenderCursor cursor = _cursorsByStream.get(stream);
+      if (cursor != null) {
+        cursor._finished = true;
+        if (!cursor.hasRow()) {
+          _starvedCursors.remove(cursor);
+          _cursorsByStream.remove(stream);
+        }
+      }
+    }
+  }
+
+  /// Switches to a full receiver sort when a legacy sender omits the 
transport confirmation.
+  private void fallbackToFullSort(List<Object[]> unconfirmedRows) {
+    Preconditions.checkState(!_mergeOutputStarted,

Review Comment:
   This Preconditions check is correct as a safety net, but the message is 
internal-only. If it fires, users see an opaque error.
   
   New senders always mark every message and old senders never do. The check 
can therefore only fire on a bug or a truly mixed stream. Fine, but consider a 
QueryErrorCode, such as INTERNAL, and a message that tells the user to retry 
once the rolling upgrade is complete.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/server/ServerPlanRequestVisitor.java:
##########
@@ -259,8 +259,17 @@ public Void visitMailboxReceive(MailboxReceiveNode node, 
ServerPlanRequestContex
 
   @Override
   public Void visitMailboxSend(MailboxSendNode node, ServerPlanRequestContext 
context) {
-    if (visit(node.getInputs().get(0), context)) {
-      context.setLeafStageBoundaryNode(node.getInputs().get(0));
+    PlanNode input = node.getInputs().get(0);
+    if (node.hasExplicitSortInput()) {

Review Comment:
   Good catch on hybrid and logical tables: if the sort were pushed into V1 per 
physical request, the output would be interleaved sorted runs on one mailbox.
   
   The ServerPlanRequestVisitorTest covers this. The cost is that the leaf no 
longer gets V1 ORDER BY pushdown. That pushdown could have used a sorted index 
or early segment pruning, but the sort has no limit, so little is lost.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/PlanNodeToOpChain.java:
##########
@@ -193,7 +194,9 @@ void record(PlanNode node, MultiStageOperator operator) {
     @Override
     public MultiStageOperator visitMailboxReceive(MailboxReceiveNode node, 
OpChainExecutionContext context) {
       try {
-        if (node.isSort()) {
+        if (node.isSort() && node.isSortedOnSender()) {

Review Comment:
   If the Sort above the exchange is dropped (see the comment at 
PinotWindowExchangeNodeInsertRule:154), isSort()=true becomes the ONLY thing 
that makes a receiver produce ordered rows for the window. Please document that 
here, as a contract.
   
   New receivers: isSort && isSortedOnSender selects the merge, and the merge 
falls back to a full sort on unmarked data. Old receivers: isSort selects 
SortedMailboxReceiveOperator, which does a full sort. Both are needed for 
correct results, and nothing above the receiver sorts again. So (a) 
SortedMailboxReceiveOperator cannot be removed while old servers may still 
receive plans from new brokers, even though it is deprecated; and (b) the 
planner must never emit sortedOnSender=true with sort=false. Consider a 
Preconditions check in the planner or in PlanFragmenter, and a test that builds 
the op-chain for a global ordered window and asserts that the receive operator 
orders its output.



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