yashmayya commented on code in PR #19066:
URL: https://github.com/apache/pinot/pull/19066#discussion_r3641764127


##########
pinot-core/src/main/java/org/apache/pinot/core/operator/streaming/StreamingGroupByCombineOperator.java:
##########
@@ -158,28 +207,7 @@ private void mergeBlock(GroupByResultsBlock resultsBlock) {
       _numGroupsWarningLimitReached = true;
     }
     List<IntermediateRecord> intermediateRecords = 
resultsBlock.getIntermediateRecords();
-    if (intermediateRecords == null) {
-      AggregationGroupByResult aggregationGroupByResult = 
resultsBlock.getAggregationGroupByResult();
-      if (aggregationGroupByResult != null) {
-        try {
-          Iterator<GroupKeyGenerator.GroupKey> groupKeyIterator = 
aggregationGroupByResult.getGroupKeyIterator();
-          int mergedKeys = 0;
-          while (groupKeyIterator.hasNext()) {
-            
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(mergedKeys++, 
EXPLAIN_NAME);
-            GroupKeyGenerator.GroupKey groupKey = groupKeyIterator.next();
-            Object[] keys = groupKey._keys;
-            Object[] values = Arrays.copyOf(keys, _numColumns);
-            int groupId = groupKey._groupId;
-            for (int i = 0; i < _numAggregationFunctions; i++) {
-              values[_numGroupByExpressions + i] = 
aggregationGroupByResult.getResultForGroupId(i, groupId);
-            }
-            _indexedTable.upsert(new Key(keys), new Record(values));
-          }
-        } finally {
-          aggregationGroupByResult.closeGroupKeyGenerator();
-        }
-      }
-    } else {
+    if (intermediateRecords != null) {

Review Comment:
   Since raw blocks should never reach the consumer anymore, silently ignoring 
them here makes a future regression hard to spot — if a new enqueue path ever 
skips `detachFromWorkerThreadState`, we'd quietly drop an entire segment's 
results. Can we fail loudly instead?
   
   ```java
   } else if (resultsBlock.getAggregationGroupByResult() != null) {
     throw new IllegalStateException("Raw group-by result reached the consumer 
thread; it must be detached on the worker thread");
   }
   ```



##########
pinot-core/src/test/java/org/apache/pinot/core/operator/streaming/StreamingGroupByCombineOperatorTest.java:
##########
@@ -238,6 +240,111 @@ public void testCountAggregation() {
     }
   }
 
+  /// Regression test for a data race in the streaming group-by leaf (see
+  /// {@link StreamingGroupByCombineOperator#detachFromWorkerThreadState}).
+  ///
+  /// <p>The per-segment {@link 
org.apache.pinot.core.query.aggregation.groupby.DictionaryBasedGroupKeyGenerator}
+  /// switches from a per-instance array holder to a REUSED thread-local 
{@code IntGroupIdMap} once a segment's
+  /// group cardinality exceeds {@code maxInitialResultHolderCapacity} 
(default 10000). If the streaming operator
+  /// hands a raw per-segment result to the consumer thread, that consumer 
iterates the thread-local map while
+  /// the producing worker clears/expands it for its next segment — corrupting 
group ids and blowing past the
+  /// result holder bounds. This test forces the thread-local holder (>10000 
groups/segment) across many
+  /// segments with a low flush threshold, and asserts the merged sums are 
exact over several iterations.
+  @Test
+  public void testHighCardinalityConcurrentMergeIsCorrect()

Review Comment:
   All tests in this class go through the raw-result path, so the pass-through 
branch of `detachFromWorkerThreadState` (blocks that already carry intermediate 
records) is uncovered. Might be worth one ORDER BY test with a small segment 
trim size to lock in that trimmed blocks flow through unchanged.



##########
pinot-core/src/main/java/org/apache/pinot/core/operator/streaming/StreamingGroupByCombineOperator.java:
##########
@@ -143,6 +144,54 @@ protected BaseResultsBlock getNextBlock() {
     return attachExecutionStats(new MetadataResultsBlock());
   }
 
+  /// Detaches a per-segment group-by result from the worker thread's reused 
thread-local group-key state.
+  ///
+  /// <p>A raw {@link AggregationGroupByResult} is backed by the group-key map 
returned by
+  /// {@link 
org.apache.pinot.core.query.aggregation.groupby.DictionaryBasedGroupKeyGenerator}'s
 thread-locals,
+  /// which the same worker thread clears and repopulates for its next 
segment. If such a block were handed to
+  /// the consumer thread (as {@link BaseStreamingCombineOperator} does), the 
consumer would iterate that map
+  /// while the worker mutates it — a data race that yields corrupted group 
ids and out-of-bounds result-holder
+  /// access. We therefore materialize the result into self-contained {@link 
IntermediateRecord}s here, on the
+  /// producing worker thread, before hand-off — mirroring how the 
non-streaming
+  /// {@link org.apache.pinot.core.operator.combine.GroupByCombineOperator} 
merges inline on the worker thread.
+  ///
+  /// <p>Blocks that already carry intermediate records (order-by / 
segment-trim path) are self-contained and
+  /// returned unchanged.
+  @Override
+  protected GroupByResultsBlock 
detachFromWorkerThreadState(GroupByResultsBlock resultsBlock) {
+    AggregationGroupByResult aggregationGroupByResult = 
resultsBlock.getAggregationGroupByResult();
+    if (aggregationGroupByResult == null || 
resultsBlock.getIntermediateRecords() != null) {
+      return resultsBlock;
+    }
+    List<IntermediateRecord> records = new ArrayList<>();

Review Comment:
   nit: `aggregationGroupByResult.getNumGroups()` is O(1), so this could be 
presized — saves the grow-copies on exactly the >10k-group segments this path 
targets.



##########
pinot-core/src/main/java/org/apache/pinot/core/operator/streaming/StreamingGroupByCombineOperator.java:
##########
@@ -143,6 +144,54 @@ protected BaseResultsBlock getNextBlock() {
     return attachExecutionStats(new MetadataResultsBlock());
   }
 
+  /// Detaches a per-segment group-by result from the worker thread's reused 
thread-local group-key state.
+  ///
+  /// <p>A raw {@link AggregationGroupByResult} is backed by the group-key map 
returned by
+  /// {@link 
org.apache.pinot.core.query.aggregation.groupby.DictionaryBasedGroupKeyGenerator}'s
 thread-locals,
+  /// which the same worker thread clears and repopulates for its next 
segment. If such a block were handed to
+  /// the consumer thread (as {@link BaseStreamingCombineOperator} does), the 
consumer would iterate that map
+  /// while the worker mutates it — a data race that yields corrupted group 
ids and out-of-bounds result-holder
+  /// access. We therefore materialize the result into self-contained {@link 
IntermediateRecord}s here, on the
+  /// producing worker thread, before hand-off — mirroring how the 
non-streaming
+  /// {@link org.apache.pinot.core.operator.combine.GroupByCombineOperator} 
merges inline on the worker thread.
+  ///
+  /// <p>Blocks that already carry intermediate records (order-by / 
segment-trim path) are self-contained and
+  /// returned unchanged.
+  @Override
+  protected GroupByResultsBlock 
detachFromWorkerThreadState(GroupByResultsBlock resultsBlock) {
+    AggregationGroupByResult aggregationGroupByResult = 
resultsBlock.getAggregationGroupByResult();
+    if (aggregationGroupByResult == null || 
resultsBlock.getIntermediateRecords() != null) {
+      return resultsBlock;
+    }
+    List<IntermediateRecord> records = new ArrayList<>();
+    try {
+      Iterator<GroupKeyGenerator.GroupKey> groupKeyIterator = 
aggregationGroupByResult.getGroupKeyIterator();
+      int extractedKeys = 0;
+      while (groupKeyIterator.hasNext()) {
+        
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(extractedKeys++, 
EXPLAIN_NAME);
+        GroupKeyGenerator.GroupKey groupKey = groupKeyIterator.next();
+        Object[] keys = groupKey._keys;
+        Object[] values = Arrays.copyOf(keys, _numColumns);

Review Comment:
   Pre-existing rather than introduced here (the old consumer-side loop had the 
same math), but since this loop now mirrors `GroupByCombineOperator`: that one 
sizes with `_numKeyColumns = _queryContext.getNumGroupByKeyColumns()`, which 
includes the synthetic `$groupingId` key column for grouping sets, whereas this 
class uses `_numGroupByExpressions`. 
`GroupByUtils.buildGroupingSetsResultsBlock` returns a raw block when per-set 
trim doesn't trigger, with the discriminator as the trailing key, and the 
`CombinePlanNode` gate doesn't exclude grouping sets from the streaming path — 
so the first aggregation value would clobber the `$groupingId` slot here. Not 
reachable via MSE today (grouping sets aren't pushed to leaf stages), so fine 
as a follow-up too, but aligning this with the non-streaming operator is a 
one-liner while the loop is being touched.



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