This is an automated email from the ASF dual-hosted git repository.

xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new a8b6b89b1eb Avoid duplicate row allocations in unordered 
single-aggregation group-by merges (#19825)
a8b6b89b1eb is described below

commit a8b6b89b1ebc0c41745f259363ff5c8d7fb1942e
Author: Xiang Fu <[email protected]>
AuthorDate: Mon Oct 12 16:29:05 2026 -0700

    Avoid duplicate row allocations in unordered single-aggregation group-by 
merges (#19825)
    
    Rows are created only for new groups, and scalar/eager inputs share the 
table’s admission and merge logic.
    A paused insertion followed by saturation and another update reproduces the 
race; pending updates now remain atomic.
    Validated with 52 focused tests and the complete PR CI checks.
---
 .../core/data/table/ConcurrentIndexedTable.java    |  10 +-
 .../table/DeterministicConcurrentIndexedTable.java |  11 +-
 .../apache/pinot/core/data/table/IndexedTable.java |  66 ++-
 .../pinot/core/data/table/SimpleIndexedTable.java  |  16 +-
 .../operator/combine/GroupByCombineOperator.java   |  15 +-
 .../table/UnboundedConcurrentIndexedTableTest.java | 473 +++++++++++++++++++++
 6 files changed, 567 insertions(+), 24 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/ConcurrentIndexedTable.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/ConcurrentIndexedTable.java
index 8c61d401d62..4ccee4f175c 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/ConcurrentIndexedTable.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/ConcurrentIndexedTable.java
@@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.core.query.request.context.QueryContext;
 
@@ -43,7 +44,7 @@ public class ConcurrentIndexedTable extends IndexedTable {
     if (_hasOrderBy) {
       upsertWithOrderBy(key, record);
     } else {
-      upsertWithoutOrderBy(key, record);
+      upsertWithoutOrderBy(key, record, null);
     }
     return true;
   }
@@ -68,11 +69,12 @@ public class ConcurrentIndexedTable extends IndexedTable {
     }
   }
 
-  protected void upsertWithoutOrderBy(Key key, Record record) {
+  @Override
+  protected void upsertWithoutOrderBy(Key key, @Nullable Record record, 
@Nullable Object result) {
     if (_noMoreNewRecords.get()) {
-      updateExistingRecord(key, record);
+      updateExistingRecord(key, record, result);
     } else {
-      addOrUpdateRecord(key, record);
+      addOrUpdateRecord(key, record, result);
       if (_lookupMap.size() >= _resultSize) {
         _noMoreNewRecords.set(true);
       }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/DeterministicConcurrentIndexedTable.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/DeterministicConcurrentIndexedTable.java
index f95bbaf7e5e..1caf53cbad7 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/DeterministicConcurrentIndexedTable.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/DeterministicConcurrentIndexedTable.java
@@ -21,6 +21,7 @@ package org.apache.pinot.core.data.table;
 
 import java.util.concurrent.ConcurrentSkipListMap;
 import java.util.concurrent.ExecutorService;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.core.query.request.context.QueryContext;
 
@@ -36,17 +37,19 @@ public class DeterministicConcurrentIndexedTable extends 
IndexedTable {
   /// Thread safe implementation of upsert for inserting [Record] into [Table]
   @Override
   public boolean upsert(Key key, Record record) {
-    upsertWithoutOrderBy(key, record);
+    upsertWithoutOrderBy(key, record, null);
     return true;
   }
 
-  protected void upsertWithoutOrderBy(Key key, Record record) {
+  @Override
+  protected synchronized void upsertWithoutOrderBy(Key key, @Nullable Record 
record, @Nullable Object result) {
+    // ConcurrentSkipListMap.compute can retry its callback; protect in-place 
mutable aggregation updates and eviction.
     ConcurrentSkipListMap<Key, Record> map = (ConcurrentSkipListMap<Key, 
Record>) _lookupMap;
 
     if (map.size() < _resultSize || map.containsKey(key)) {
-      addOrUpdateRecord(key, record);
+      addOrUpdateRecord(key, record, result);
     } else if (!map.isEmpty() && key.compareTo(map.lastKey()) < 0) {
-      addOrUpdateRecord(key, record);
+      addOrUpdateRecord(key, record, result);
       map.pollLastEntry(); // evict the largest key after insertion
     }
   }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/IndexedTable.java 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/IndexedTable.java
index 6f26f25e84d..46912937db6 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/IndexedTable.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/IndexedTable.java
@@ -29,6 +29,7 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
@@ -108,14 +109,58 @@ public abstract class IndexedTable extends BaseTable {
     return upsert(new Key(keyValues), record);
   }
 
+  /// Upserts an already-extracted result into a table with exactly one 
aggregation function.
+  /// The table may retain the key and result; callers must not modify them 
after this call.
+  /// A null result follows the aggregation function's existing merge 
semantics.
+  /// Final inputs must be Comparable, as required by the aggregation 
function's final-result merge.
+  /// Returns true even when new groups are rejected; existing groups can 
still accept updates.
+  public boolean upsertSingleAggregationResult(Key key, @Nullable Object 
result) {
+    Preconditions.checkState(_aggregationFunctions.length == 1,
+        "upsertSingleAggregationResult requires exactly one aggregation 
function");
+    Preconditions.checkArgument(!_hasFinalInput || result == null || result 
instanceof Comparable,
+        "Final aggregation inputs must be Comparable");
+    if (_hasOrderBy) {
+      return upsert(key, createSingleAggregationRecord(key, result));
+    }
+    upsertWithoutOrderBy(key, null, result);
+    return true;
+  }
+
+  /// Applies the table's unordered admission policy to either an eager record 
or a single aggregation result.
+  /// A null record selects the single-result input, which may itself be null.
+  protected abstract void upsertWithoutOrderBy(Key key, @Nullable Record 
record, @Nullable Object result);
+
+  protected Record createSingleAggregationRecord(Key key, @Nullable Object 
result) {
+    Object[] values = Arrays.copyOf(key.getValues(), _numKeyColumns + 1);
+    values[_numKeyColumns] = result;
+    return new Record(values);
+  }
+
+  private Record updateSingleAggregationRecord(Record record, @Nullable Object 
result) {
+    Object[] values = record.getValues();
+    values[_numKeyColumns] = mergeValue(0, values[_numKeyColumns], result, 
_hasFinalInput);
+    return record;
+  }
+
   /// Adds a record with new key or updates a record with existing key.
   protected void addOrUpdateRecord(Key key, Record newRecord) {
-    _lookupMap.compute(key, (k, v) -> v == null ? newRecord : updateRecord(v, 
newRecord));
+    addOrUpdateRecord(key, newRecord, null);
+  }
+
+  protected void addOrUpdateRecord(Key key, @Nullable Record newRecord, 
@Nullable Object result) {
+    _lookupMap.compute(key, (k, existing) -> {
+      if (existing == null) {
+        return newRecord != null ? newRecord : 
createSingleAggregationRecord(k, result);
+      }
+      return newRecord != null ? updateRecord(existing, newRecord) : 
updateSingleAggregationRecord(existing, result);
+    });
   }
 
   /// Updates a record with existing key. Record with new key will be ignored.
-  protected void updateExistingRecord(Key key, Record newRecord) {
-    _lookupMap.computeIfPresent(key, (k, v) -> updateRecord(v, newRecord));
+  protected void updateExistingRecord(Key key, @Nullable Record newRecord, 
@Nullable Object result) {
+    // computeIfPresent waits for pending insertions; a containsKey check 
would discard their updates.
+    _lookupMap.computeIfPresent(key, (k, existing) -> newRecord != null
+        ? updateRecord(existing, newRecord) : 
updateSingleAggregationRecord(existing, result));
   }
 
   private Record updateRecord(Record existingRecord, Record newRecord) {
@@ -125,18 +170,25 @@ public abstract class IndexedTable extends BaseTable {
     int index = _numKeyColumns;
     if (!_hasFinalInput) {
       for (int i = 0; i < numAggregations; i++, index++) {
-        existingValues[index] =
-            AggregationFunctionUtils.merge(_aggregationFunctions[i], 
existingValues[index], newValues[index]);
+        existingValues[index] = mergeValue(i, existingValues[index], 
newValues[index], false);
       }
     } else {
       for (int i = 0; i < numAggregations; i++, index++) {
-        existingValues[index] = 
AggregationFunctionUtils.mergeFinalResult(_aggregationFunctions[i],
-            (Comparable) existingValues[index], (Comparable) newValues[index]);
+        existingValues[index] = mergeValue(i, existingValues[index], 
newValues[index], true);
       }
     }
     return existingRecord;
   }
 
+  @Nullable
+  private Object mergeValue(int functionIndex, @Nullable Object existing, 
@Nullable Object input,
+      boolean hasFinalInput) {
+    return hasFinalInput
+        ? 
AggregationFunctionUtils.mergeFinalResult(_aggregationFunctions[functionIndex], 
(Comparable) existing,
+            (Comparable) input)
+        : AggregationFunctionUtils.merge(_aggregationFunctions[functionIndex], 
existing, input);
+  }
+
   /// Resizes the lookup map based on the trim size.
   protected void resize() {
     assert _hasOrderBy;
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/SimpleIndexedTable.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/SimpleIndexedTable.java
index a9ef55d11e3..d2f23a235ea 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/SimpleIndexedTable.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/SimpleIndexedTable.java
@@ -20,6 +20,7 @@ package org.apache.pinot.core.data.table;
 
 import java.util.HashMap;
 import java.util.concurrent.ExecutorService;
+import javax.annotation.Nullable;
 import javax.annotation.concurrent.NotThreadSafe;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.core.query.request.context.QueryContext;
@@ -44,12 +45,17 @@ public class SimpleIndexedTable extends IndexedTable {
         resize();
       }
     } else {
-      if (_lookupMap.size() < _resultSize) {
-        addOrUpdateRecord(key, record);
-      } else {
-        updateExistingRecord(key, record);
-      }
+      upsertWithoutOrderBy(key, record, null);
     }
     return true;
   }
+
+  @Override
+  protected void upsertWithoutOrderBy(Key key, @Nullable Record record, 
@Nullable Object result) {
+    if (_lookupMap.size() < _resultSize) {
+      addOrUpdateRecord(key, record, result);
+    } else {
+      updateExistingRecord(key, record, result);
+    }
+  }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/GroupByCombineOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/GroupByCombineOperator.java
index abc88149a06..3d8dac5aeb2 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/GroupByCombineOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/GroupByCombineOperator.java
@@ -59,6 +59,7 @@ public class GroupByCombineOperator extends 
BaseSingleBlockCombineOperator<Group
   /// Key columns precede the aggregation columns in the record layout.
   private final int _numKeyColumns;
   private final int _numColumns;
+  private final boolean _useSingleAggregationResult;
   // We use a CountDownLatch to track if all Futures are finished by the query 
timeout, and cancel the unfinished
   // _futures (try to interrupt the execution if it already started).
   private final CountDownLatch _operatorLatch;
@@ -77,6 +78,7 @@ public class GroupByCombineOperator extends 
BaseSingleBlockCombineOperator<Group
     assert _queryContext.getGroupByExpressions() != null;
     _numKeyColumns = _queryContext.getNumGroupByKeyColumns();
     _numColumns = _numKeyColumns + _numAggregationFunctions;
+    _useSingleAggregationResult = _numAggregationFunctions == 1;
     _operatorLatch = new CountDownLatch(_numTasks);
   }
 
@@ -143,12 +145,17 @@ public class GroupByCombineOperator extends 
BaseSingleBlockCombineOperator<Group
                 
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(mergedKeys++, 
EXPLAIN_NAME);
                 GroupKeyGenerator.GroupKey groupKey = 
dicGroupKeyIterator.next();
                 Object[] keys = groupKey._keys;
-                Object[] values = Arrays.copyOf(keys, _numColumns);
                 int groupId = groupKey._groupId;
-                for (int i = 0; i < _numAggregationFunctions; i++) {
-                  values[_numKeyColumns + i] = 
aggregationGroupByResult.getResultForGroupId(i, groupId);
+                if (_useSingleAggregationResult) {
+                  Object result = 
aggregationGroupByResult.getResultForGroupId(0, groupId);
+                  _indexedTable.upsertSingleAggregationResult(new Key(keys), 
result);
+                } else {
+                  Object[] values = Arrays.copyOf(keys, _numColumns);
+                  for (int i = 0; i < _numAggregationFunctions; i++) {
+                    values[_numKeyColumns + i] = 
aggregationGroupByResult.getResultForGroupId(i, groupId);
+                  }
+                  _indexedTable.upsert(new Key(keys), new Record(values));
                 }
-                _indexedTable.upsert(new Key(keys), new Record(values));
               }
             } finally {
               // Release the resources used by the group key generator
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/data/table/UnboundedConcurrentIndexedTableTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/data/table/UnboundedConcurrentIndexedTableTest.java
new file mode 100644
index 00000000000..ba35b2c7352
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/data/table/UnboundedConcurrentIndexedTableTest.java
@@ -0,0 +1,473 @@
+/**
+ * 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.core.data.table;
+
+import com.clearspring.analytics.stream.cardinality.HyperLogLog;
+import it.unimi.dsi.fastutil.ints.IntArrayList;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import 
org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
+import org.apache.pinot.segment.local.customobject.AvgPair;
+import org.apache.pinot.util.TestUtils;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+
+
+/// Verifies unordered group insertion and atomic updates using independent 
concurrent writers.
+public class UnboundedConcurrentIndexedTableTest {
+  @DataProvider(name = "inputModes")
+  public Object[][] inputModes() {
+    return new Object[][]{{false, false}, {true, false}, {false, true}, {true, 
true}};
+  }
+
+  @DataProvider(name = "concurrentInputModes")
+  public Object[][] concurrentInputModes() {
+    return new Object[][]{{false, false, false}, {true, false, false}, {false, 
true, false}, {true, true, false},
+        {false, false, true}, {true, false, true}, {false, true, true}, {true, 
true, true}};
+  }
+
+  @DataProvider(name = "scalarInputs")
+  public Object[][] scalarInputs() {
+    return new Object[][]{{false}, {true}};
+  }
+
+  @DataProvider(name = "singleAggregations")
+  public Object[][] singleAggregations() {
+    Supplier<Object[]> numericInputs = () -> new Object[]{3d, 5d};
+    Supplier<Object[]> avgInputs = () -> new Object[]{new AvgPair(9, 3), new 
AvgPair(11, 1)};
+    Supplier<Object[]> distinctInputs = () -> new Object[]{new 
IntOpenHashSet(new int[]{1, 2}),
+        new IntOpenHashSet(new int[]{2, 3})};
+    Supplier<Object[]> finalDistinctInputs = () -> new Object[]{3, 5};
+    Supplier<Object[]> hllInputs = () -> {
+      HyperLogLog first = new HyperLogLog(8);
+      first.offer(1);
+      first.offer(2);
+      HyperLogLog second = new HyperLogLog(8);
+      second.offer(2);
+      second.offer(3);
+      return new Object[]{first, second};
+    };
+    Supplier<Object[]> arrayInputs = () -> new Object[]{new IntArrayList(new 
int[]{1, 2}),
+        new IntArrayList(new int[]{2, 3})};
+    return new Object[][]{
+        {"SUM(metric)", false, numericInputs, 8d, null},
+        {"SUM(metric)", true, numericInputs, 8d, null},
+        {"MIN(metric)", false, numericInputs, 3d, null},
+        {"MIN(metric)", true, numericInputs, 3d, null},
+        {"MAX(metric)", false, numericInputs, 5d, null},
+        {"MAX(metric)", true, numericInputs, 5d, null},
+        {"AVG(metric)", false, avgInputs, 5d, null},
+        {"DISTINCTCOUNT(metric)", false, distinctInputs, 3, 0},
+        {"DISTINCTCOUNT(metric)", true, finalDistinctInputs, 8, null},
+        {"DISTINCTCOUNTHLL(metric)", false, hllInputs, 3L, 0L},
+        {"ARRAYAGG(metric, 'INT')", false, arrayInputs, new IntArrayList(new 
int[]{1, 2, 2, 3}), new IntArrayList()}
+    };
+  }
+
+  @Test(dataProvider = "singleAggregations")
+  public void testSingleAggregationParity(String expression, boolean 
hasFinalInput, Supplier<Object[]> inputSupplier,
+      Object mergedResult, Object emptyResult)
+      throws IOException {
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      IndexedTable eagerTable = singleAggregationTable(expression, 
hasFinalInput, inputSupplier, false, executor);
+      IndexedTable scalarTable = singleAggregationTable(expression, 
hasFinalInput, inputSupplier, true, executor);
+      assertEquals(scalarTable._lookupMap.keySet(), 
eagerTable._lookupMap.keySet());
+      if (!hasFinalInput) {
+        for (Map.Entry<Key, Record> entry : eagerTable._lookupMap.entrySet()) {
+          
assertEquivalentResult(scalarTable._lookupMap.get(entry.getKey()).getValues()[2],
+              entry.getValue().getValues()[2], expression + ": intermediate " 
+ entry.getKey());
+        }
+      }
+      Map<Key, Object> eagerResults = finishSingleAggregation(eagerTable);
+      Map<Key, Object> scalarResults = finishSingleAggregation(scalarTable);
+      assertEquals(eagerResults.size(), 3);
+      assertEquals(scalarResults.keySet(), eagerResults.keySet());
+      for (String group : List.of("null-first", "null-last", "all-null")) {
+        Key key = new Key(new Object[]{group, 1d});
+        assertTrue(eagerResults.containsKey(key));
+        Object expectedResult = group.equals("all-null") ? emptyResult : 
mergedResult;
+        assertTrue(Objects.deepEquals(eagerResults.get(key), expectedResult), 
expression + ": " + group);
+        assertTrue(Objects.deepEquals(scalarResults.get(key), 
eagerResults.get(key)), expression + ": " + group);
+      }
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test(dataProvider = "concurrentInputModes", timeOut = 30_000)
+  public void testConcurrentCountsWithHashCollisions(boolean scalarInput, 
boolean hasFinalInput,
+      boolean deterministicTable)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    int numThreads = 4;
+    int iterations = 1_000;
+    assertEquals(new Key(new Object[]{"Aa", 1d}).hashCode(), new Key(new 
Object[]{"BB", 1d}).hashCode());
+    ExecutorService executor = Executors.newFixedThreadPool(numThreads);
+    CountDownLatch ready = new CountDownLatch(numThreads);
+    CountDownLatch start = new CountDownLatch(1);
+    try {
+      QueryContext queryContext = QueryContextConverterUtils.getQueryContext(
+          "SELECT COUNT(*) FROM testTable GROUP BY tv_id, weights");
+      IndexedTable table = deterministicTable
+          ? new DeterministicConcurrentIndexedTable(newDataSchema(), 
hasFinalInput, queryContext, Integer.MAX_VALUE,
+              Integer.MAX_VALUE, Integer.MAX_VALUE, 1, executor)
+          : newTable(Integer.MAX_VALUE, hasFinalInput, executor);
+      List<Future<?>> futures = new ArrayList<>();
+      for (int threadIndex = 0; threadIndex < numThreads; threadIndex++) {
+        String distinctKey = "thread-" + threadIndex;
+        futures.add(executor.submit(() -> {
+          ready.countDown();
+          start.await();
+          for (int i = 0; i < iterations; i++) {
+            upsert(table, new Record(new Object[]{"Aa", 1d, 1L}), scalarInput);
+            upsert(table, new Record(new Object[]{"BB", 1d, 1L}), scalarInput);
+            upsert(table, new Record(new Object[]{distinctKey, 1d, 1L}), 
scalarInput);
+          }
+          return null;
+        }));
+      }
+      assertTrue(ready.await(10, TimeUnit.SECONDS), "Writers did not become 
ready");
+      start.countDown();
+      for (Future<?> future : futures) {
+        future.get(10, TimeUnit.SECONDS);
+      }
+      Map<Key, Long> counts = finishAndCount(table, hasFinalInput);
+      assertEquals(counts.size(), numThreads + 2);
+      assertEquals(counts.get(new Key(new Object[]{"Aa", 1d})), 
Long.valueOf(numThreads * iterations));
+      assertEquals(counts.get(new Key(new Object[]{"BB", 1d})), 
Long.valueOf(numThreads * iterations));
+      for (int threadIndex = 0; threadIndex < numThreads; threadIndex++) {
+        assertEquals(counts.get(new Key(new Object[]{"thread-" + threadIndex, 
1d})), Long.valueOf(iterations));
+      }
+    } finally {
+      start.countDown();
+      executor.shutdownNow();
+    }
+  }
+
+  @Test(dataProvider = "scalarInputs", timeOut = 30_000)
+  public void testConcurrentSaturatedMutableUpdates(boolean scalarInput)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    int numThreads = 4;
+    int iterations = 1_000;
+    ExecutorService executor = Executors.newFixedThreadPool(numThreads);
+    CountDownLatch ready = new CountDownLatch(numThreads);
+    CountDownLatch start = new CountDownLatch(1);
+    try {
+      QueryContext queryContext = QueryContextConverterUtils.getQueryContext(
+          "SELECT AVG(metric) FROM testTable GROUP BY tv_id, weights");
+      DataSchema dataSchema = new DataSchema(new String[]{"tv_id", "weights", 
"avg(metric)"},
+          new ColumnDataType[]{ColumnDataType.STRING, ColumnDataType.DOUBLE, 
ColumnDataType.OBJECT});
+      IndexedTable table = new UnboundedConcurrentIndexedTable(dataSchema, 
false, queryContext, 1, 1, executor);
+      Key hotKey = new Key(new Object[]{"hot", 1d});
+      upsert(table, new Record(new Object[]{"hot", 1d, new AvgPair(9, 3)}), 
scalarInput);
+      List<Future<?>> futures = new ArrayList<>();
+      for (int threadIndex = 0; threadIndex < numThreads; threadIndex++) {
+        String rejectedKey = "rejected-" + threadIndex;
+        futures.add(executor.submit(() -> {
+          ready.countDown();
+          start.await();
+          for (int i = 0; i < iterations; i++) {
+            upsert(table, new Record(new Object[]{"hot", 1d, new AvgPair(3, 
1)}), scalarInput);
+            upsert(table, new Record(new Object[]{rejectedKey, 1d, new 
AvgPair(100, 1)}), scalarInput);
+          }
+          return null;
+        }));
+      }
+      assertTrue(ready.await(10, TimeUnit.SECONDS), "Writers did not become 
ready");
+      start.countDown();
+      for (Future<?> future : futures) {
+        future.get(10, TimeUnit.SECONDS);
+      }
+      assertEquals(table._lookupMap.keySet(), Set.of(hotKey));
+      AvgPair result = (AvgPair) table._lookupMap.get(hotKey).getValues()[2];
+      assertEquals(result.getCount(), 3L + numThreads * iterations);
+      assertEquals(result.getSum(), 9d + 3d * numThreads * iterations);
+      assertEquals(finishSingleAggregation(table), Map.of(hotKey, 3d));
+    } finally {
+      start.countDown();
+      executor.shutdownNow();
+    }
+  }
+
+  @Test(dataProvider = "scalarInputs", timeOut = 30_000)
+  public void testPendingInsertionUpdateAfterGroupLimit(boolean scalarInput)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    ExecutorService executor = Executors.newFixedThreadPool(3);
+    CountDownLatch insertionStarted = new CountDownLatch(1);
+    CountDownLatch releaseInsertion = new CountDownLatch(1);
+    CountDownLatch updateReady = new CountDownLatch(1);
+    AtomicReference<Thread> updateThread = new AtomicReference<>();
+    Key hotKey = new Key(new Object[]{"hot", 1d});
+    Key otherKey = new Key(new Object[]{"other-key", 1d});
+    int hotHash = hotKey.hashCode();
+    int otherHash = otherKey.hashCode();
+    // Different low spread-hash bits put the keys in separate buckets at 
every map capacity above one.
+    assertTrue(((hotHash ^ (hotHash >>> 16)) & 1) != ((otherHash ^ (otherHash 
>>> 16)) & 1));
+    try {
+      QueryContext queryContext = QueryContextConverterUtils.getQueryContext(
+          "SELECT COUNT(*) FROM testTable GROUP BY tv_id, weights");
+      IndexedTable table = new 
UnboundedConcurrentIndexedTable(newDataSchema(), false, queryContext, 1, 64, 
executor) {
+        @Override
+        protected Record createSingleAggregationRecord(Key key, Object result) 
{
+          if (key.equals(hotKey)) {
+            insertionStarted.countDown();
+            try {
+              assertTrue(releaseInsertion.await(10, TimeUnit.SECONDS), 
"Pending insertion was not released");
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+              throw new IllegalStateException(e);
+            }
+          }
+          return super.createSingleAggregationRecord(key, result);
+        }
+      };
+      Future<?> insertion = executor.submit(() -> 
table.upsertSingleAggregationResult(hotKey, 1L));
+      assertTrue(insertionStarted.await(10, TimeUnit.SECONDS), "Insertion did 
not reach the map computation");
+      Future<?> otherInsertion = executor.submit(() -> 
table.upsertSingleAggregationResult(otherKey, 1L));
+      otherInsertion.get(10, TimeUnit.SECONDS);
+      assertEquals(table.size(), 1);
+      Future<?> update = executor.submit(() -> {
+        updateThread.set(Thread.currentThread());
+        updateReady.countDown();
+        upsert(table, new Record(new Object[]{"hot", 1d, 1L}), scalarInput);
+      });
+      assertTrue(updateReady.await(10, TimeUnit.SECONDS), "Update writer did 
not become ready");
+      TestUtils.waitForCondition(ignored -> update.isDone() || 
updateThread.get().getState() == Thread.State.BLOCKED,
+          10, 10_000,
+          "Update must complete or wait for the pending insertion before it is 
released");
+      releaseInsertion.countDown();
+      insertion.get(10, TimeUnit.SECONDS);
+      update.get(10, TimeUnit.SECONDS);
+      assertEquals(finishAndCount(table, false), Map.of(hotKey, 2L, otherKey, 
1L));
+    } finally {
+      releaseInsertion.countDown();
+      executor.shutdownNow();
+      assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS), "Writers did 
not terminate");
+    }
+  }
+
+  @Test
+  public void testScalarInputRequiresSingleAggregation() {
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      QueryContext queryContext = QueryContextConverterUtils.getQueryContext(
+          "SELECT COUNT(*), SUM(metric) FROM testTable GROUP BY tv_id, 
weights");
+      DataSchema dataSchema = new DataSchema(new String[]{"tv_id", "weights", 
"count(*)", "sum(metric)"},
+          new ColumnDataType[]{ColumnDataType.STRING, ColumnDataType.DOUBLE, 
ColumnDataType.LONG,
+              ColumnDataType.DOUBLE});
+      List<IndexedTable> tables = List.of(
+          new SimpleIndexedTable(dataSchema, false, queryContext, 1, 
Integer.MAX_VALUE, Integer.MAX_VALUE, 1, executor),
+          new ConcurrentIndexedTable(dataSchema, false, queryContext, 1, 
Integer.MAX_VALUE, Integer.MAX_VALUE, 1,
+              executor));
+      for (IndexedTable table : tables) {
+        assertThrows(IllegalStateException.class,
+            () -> table.upsertSingleAggregationResult(new Key(new 
Object[]{"hot", 1d}), 1L));
+        assertEquals(table.size(), 0);
+      }
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test(dataProvider = "inputModes")
+  public void testMaterializedKeyEquality(boolean scalarInput, boolean 
hasFinalInput) {
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      IndexedTable table = newTable(Integer.MAX_VALUE, hasFinalInput, 
executor);
+      Object[][] keys = {{null, null}, {"", 0d}, {null, 0d}, {"", null}, 
{"zero", 0d}, {"zero", -0d},
+          {"nan", Double.NaN}, {"nan", 
Double.longBitsToDouble(0x7ff8000000000001L)}};
+      for (Object[] key : keys) {
+        upsert(table, new Record(new Object[]{key[0], key[1], 1L}), 
scalarInput);
+        upsert(table, new Record(new Object[]{key[0], key[1], 1L}), 
scalarInput);
+      }
+      assertEquals(finishAndCount(table, hasFinalInput), Map.of(new Key(new 
Object[]{null, null}), 2L,
+          new Key(new Object[]{"", 0d}), 2L, new Key(new Object[]{null, 0d}), 
2L,
+          new Key(new Object[]{"", null}), 2L, new Key(new Object[]{"zero", 
0d}), 2L,
+          new Key(new Object[]{"zero", -0d}), 2L, new Key(new Object[]{"nan", 
Double.NaN}), 4L));
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test(dataProvider = "inputModes")
+  public void testGroupLimitAndRecordOwnership(boolean scalarInput, boolean 
hasFinalInput) {
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      IndexedTable table = newTable(1, hasFinalInput, executor);
+      Record first = new Record(new Object[]{"Aa", 1d, 3L});
+      Record rejected = new Record(new Object[]{"BB", 1d, 7L});
+      Record duplicate = new Record(new Object[]{"Aa", 1d, 5L});
+      upsert(table, first, scalarInput);
+      Record retained = table._lookupMap.get(new Key(new Object[]{"Aa", 1d}));
+      if (scalarInput) {
+        assertNotSame(retained, first);
+      } else {
+        assertSame(retained, first);
+      }
+      assertEquals(retained.getValues(), first.getValues());
+      upsert(table, rejected, scalarInput);
+      upsert(table, duplicate, scalarInput);
+      table.finish(false, hasFinalInput);
+      assertEquals(table.size(), 1);
+      assertSame(table.iterator().next(), retained);
+      assertEquals(retained.getValues(), new Object[]{"Aa", 1d, 8L});
+      if (scalarInput) {
+        assertEquals(first.getValues(), new Object[]{"Aa", 1d, 3L});
+      }
+      assertEquals(duplicate.getValues(), new Object[]{"Aa", 1d, 5L});
+      assertEquals(rejected.getValues(), new Object[]{"BB", 1d, 7L});
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test(dataProvider = "inputModes")
+  public void testScalarFallbacksRespectTableLimits(boolean scalarInput, 
boolean hasFinalInput) {
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      QueryContext queryContext = QueryContextConverterUtils.getQueryContext(
+          "SELECT COUNT(*) FROM testTable GROUP BY tv_id, weights");
+      QueryContext orderedQueryContext = 
QueryContextConverterUtils.getQueryContext(
+          "SELECT COUNT(*) FROM testTable GROUP BY tv_id, weights ORDER BY 
COUNT(*) DESC");
+      Map<Key, Long> firstGroup = Map.of(new Key(new Object[]{"BB", 1d}), 12L);
+      Map<IndexedTable, Map<Key, Long>> expectedByTable = Map.of(
+          new SimpleIndexedTable(newDataSchema(), hasFinalInput, queryContext, 
1, Integer.MAX_VALUE,
+              Integer.MAX_VALUE, 1, executor), firstGroup,
+          new DeterministicConcurrentIndexedTable(newDataSchema(), 
hasFinalInput, queryContext, 1,
+              Integer.MAX_VALUE, Integer.MAX_VALUE, 1, executor), Map.of(new 
Key(new Object[]{"Aa", 1d}), 3L),
+          new ConcurrentIndexedTable(newDataSchema(), hasFinalInput, 
orderedQueryContext, 1, 1, 2, 1, executor),
+          firstGroup);
+      for (Map.Entry<IndexedTable, Map<Key, Long>> entry : 
expectedByTable.entrySet()) {
+        IndexedTable table = entry.getKey();
+        upsert(table, new Record(new Object[]{"BB", 1d, 7L}), scalarInput);
+        upsert(table, new Record(new Object[]{"Aa", 1d, 3L}), scalarInput);
+        upsert(table, new Record(new Object[]{"BB", 1d, 5L}), scalarInput);
+        assertEquals(finishAndCount(table, hasFinalInput), entry.getValue());
+      }
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  private IndexedTable newTable(int resultSize, boolean hasFinalInput, 
ExecutorService executor) {
+    QueryContext queryContext = QueryContextConverterUtils.getQueryContext(
+        "SELECT COUNT(*) FROM testTable GROUP BY tv_id, weights");
+    return new UnboundedConcurrentIndexedTable(newDataSchema(), hasFinalInput, 
queryContext, resultSize, 1, executor);
+  }
+
+  private IndexedTable singleAggregationTable(String expression, boolean 
hasFinalInput,
+      Supplier<Object[]> inputSupplier, boolean scalarInput, ExecutorService 
executor) {
+    QueryContext queryContext = QueryContextConverterUtils.getQueryContext(
+        "SET enableNullHandling=true; SELECT " + expression + " FROM testTable 
GROUP BY tv_id, weights");
+    ColumnDataType resultType = hasFinalInput
+        ? queryContext.getAggregationFunctions()[0].getFinalResultColumnType()
+        : 
queryContext.getAggregationFunctions()[0].getIntermediateResultColumnType();
+    DataSchema dataSchema = new DataSchema(new String[]{"tv_id", "weights", 
expression},
+        new ColumnDataType[]{ColumnDataType.STRING, ColumnDataType.DOUBLE, 
resultType});
+    IndexedTable table = new UnboundedConcurrentIndexedTable(dataSchema, 
hasFinalInput, queryContext, 3, 1, executor);
+    Object[] nullFirstInputs = inputSupplier.get();
+    Object[] nullLastInputs = inputSupplier.get();
+    upsert(table, new Record(new Object[]{"null-first", 1d, null}), 
scalarInput);
+    upsert(table, new Record(new Object[]{"null-last", 1d, 
nullLastInputs[0]}), scalarInput);
+    upsert(table, new Record(new Object[]{"all-null", 1d, null}), scalarInput);
+    upsert(table, new Record(new Object[]{"rejected", 1d, 
inputSupplier.get()[0]}), scalarInput);
+    upsert(table, new Record(new Object[]{"null-first", 1d, 
nullFirstInputs[0]}), scalarInput);
+    upsert(table, new Record(new Object[]{"null-first", 1d, 
nullFirstInputs[1]}), scalarInput);
+    upsert(table, new Record(new Object[]{"null-last", 1d, 
nullLastInputs[1]}), scalarInput);
+    upsert(table, new Record(new Object[]{"null-last", 1d, null}), 
scalarInput);
+    upsert(table, new Record(new Object[]{"all-null", 1d, null}), scalarInput);
+    return table;
+  }
+
+  private Map<Key, Object> finishSingleAggregation(IndexedTable table) {
+    table.finish(false, true);
+    Map<Key, Object> results = new HashMap<>();
+    Iterator<Record> iterator = table.iterator();
+    while (iterator.hasNext()) {
+      Object[] values = iterator.next().getValues();
+      results.put(new Key(new Object[]{values[0], values[1]}), values[2]);
+    }
+    return results;
+  }
+
+  private void assertEquivalentResult(Object actual, Object expected, String 
message)
+      throws IOException {
+    if (expected instanceof AvgPair expectedPair) {
+      assertTrue(actual instanceof AvgPair, message);
+      AvgPair actualPair = (AvgPair) actual;
+      assertEquals(actualPair.getSum(), expectedPair.getSum(), message);
+      assertEquals(actualPair.getCount(), expectedPair.getCount(), message);
+    } else if (expected instanceof HyperLogLog expectedHll) {
+      assertTrue(actual instanceof HyperLogLog, message);
+      assertEquals(((HyperLogLog) actual).getBytes(), expectedHll.getBytes(), 
message);
+    } else {
+      assertTrue(Objects.deepEquals(actual, expected), message);
+    }
+  }
+
+  private DataSchema newDataSchema() {
+    return new DataSchema(new String[]{"tv_id", "weights", "count(*)"},
+        new ColumnDataType[]{ColumnDataType.STRING, ColumnDataType.DOUBLE, 
ColumnDataType.LONG});
+  }
+
+  private void upsert(IndexedTable table, Record record, boolean scalarInput) {
+    if (scalarInput) {
+      Object[] values = record.getValues();
+      table.upsertSingleAggregationResult(new Key(new Object[]{values[0], 
values[1]}), values[2]);
+    } else {
+      table.upsert(record);
+    }
+  }
+
+  private Map<Key, Long> finishAndCount(Table table, boolean hasFinalInput) {
+    table.finish(false, hasFinalInput);
+    Map<Key, Long> counts = new HashMap<>();
+    Iterator<Record> iterator = table.iterator();
+    while (iterator.hasNext()) {
+      Object[] values = iterator.next().getValues();
+      counts.put(new Key(new Object[]{values[0], values[1]}), (Long) 
values[2]);
+    }
+    return counts;
+  }
+}


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

Reply via email to