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

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


The following commit(s) were added to refs/heads/master by this push:
     new 80ae6bc7d0f feat: support time ordered cursor for clustered segments 
when possible (#19726)
80ae6bc7d0f is described below

commit 80ae6bc7d0f7e4f5907d7623f40d0842bde90dde
Author: Clint Wylie <[email protected]>
AuthorDate: Thu Jul 23 17:40:33 2026 -0700

    feat: support time ordered cursor for clustered segments when possible 
(#19726)
---
 .../druid/segment/MergingClusterGroupCursor.java   | 208 +++++++++++++
 .../druid/segment/QueryableIndexCursorFactory.java | 340 ++++++++++++--------
 .../projections/MergingColumnSelectorFactory.java  | 342 +++++++++++++++++++++
 .../ClusteredSegmentTimeOrderedQueryTest.java      | 320 +++++++++++++++++++
 .../druid/segment/IndexMergerV10ClusteredTest.java | 128 ++++++++
 .../segment/MergingClusterGroupCursorTest.java     | 302 ++++++++++++++++++
 .../MergingColumnSelectorFactoryTest.java          | 170 ++++++++++
 7 files changed, 1688 insertions(+), 122 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/MergingClusterGroupCursor.java
 
b/processing/src/main/java/org/apache/druid/segment/MergingClusterGroupCursor.java
new file mode 100644
index 00000000000..a3a22fe55e3
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/MergingClusterGroupCursor.java
@@ -0,0 +1,208 @@
+/*
+ * 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.druid.segment;
+
+import com.google.common.base.Supplier;
+import it.unimi.dsi.fastutil.ints.IntHeapPriorityQueue;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.column.ColumnHolder;
+import org.apache.druid.segment.projections.MergingColumnSelectorFactory;
+
+import java.util.List;
+
+/**
+ * {@link Cursor} that presents a set of individually {@code __time}-sorted 
per-cluster-group cursors as a single
+ * globally {@code __time}-ordered cursor, via a streaming k-way merge. Used 
for clustered base tables where
+ * {@code __time} is the first non-clustering column (so each group, whose 
clustering prefix is constant, is sorted on
+ * {@code __time}); built by {@code 
QueryableIndexCursorFactory#makeTimeMergedClusteredCursorHolder}.
+ * <p>
+ * This is the time-ordered sibling of {@link ConcatenatingCursor}: where the 
concatenating cursor walks groups
+ * back-to-back (order = {@code [clustering…, __time, …]}), this cursor 
interleaves them by {@code __time}. It opens
+ * <em>all</em> surviving group cursors up front (so unlike the concatenating 
path it does not benefit from early-exit
+ * laziness) and, on each {@link #advance()}, emits the row with the smallest 
(or largest, when descending)
+ * {@code __time} across the groups. Each per-group sub-index already exposes 
the clustering columns as constants, so
+ * the {@link MergingColumnSelectorFactory} simply dispatches every column to 
the winning group.
+ * <p>
+ * An {@link IntHeapPriorityQueue} of group indices, keyed on each group's 
current {@code __time} via
+ * {@link #compareGroups}, drives the merge: the queue head is the winning 
group, {@link IntHeapPriorityQueue#changed()}
+ * re-settles it after its cursor advances, and it is dropped once exhausted. 
Ties on {@code __time} across groups break
+ * by group index (arbitrary but deterministic); the advertised ordering is 
only {@code [__time]}, so secondary sort
+ * columns are not preserved across groups. The outer {@link CursorHolder} 
owns the lifecycle of the per-group holders.
+ */
+public final class MergingClusterGroupCursor implements Cursor
+{
+  private final List<Supplier<CursorHolder>> holderSuppliers;
+  private final boolean descending;
+
+  private boolean initialized;
+  // Indexed by group. groupCursors[i] is null if that group's holder produced 
no cursor.
+  private Cursor[] groupCursors;
+  private BaseLongColumnValueSelector[] timeSelectors;
+  private long[] currentTimes;
+  // Group indices ordered by current __time (min for ascending, max for 
descending); the head is the winning group.
+  private IntHeapPriorityQueue heap;
+  // heap head while not done; retains the last winner once done (selectors 
are undefined after isDone() anyway).
+  private int currentGroup;
+  // Monotonically increasing output-row id, one per emitted row; the merge 
emits exactly one row per advance.
+  private long outputRowId;
+  private MergingColumnSelectorFactory factory;
+
+  public MergingClusterGroupCursor(
+      List<Supplier<CursorHolder>> holderSuppliers,
+      boolean descending
+  )
+  {
+    if (holderSuppliers.isEmpty()) {
+      throw DruidException.defensive("MergingClusterGroupCursor requires at 
least one cluster group");
+    }
+    this.holderSuppliers = holderSuppliers;
+    this.descending = descending;
+  }
+
+  private void initializeIfNeeded()
+  {
+    if (initialized) {
+      return;
+    }
+    initialized = true;
+    final int n = holderSuppliers.size();
+    groupCursors = new Cursor[n];
+    timeSelectors = new BaseLongColumnValueSelector[n];
+    currentTimes = new long[n];
+    heap = new IntHeapPriorityQueue(this::compareGroups);
+    outputRowId = 0;
+    final ColumnSelectorFactory[] groupFactories = new 
ColumnSelectorFactory[n];
+    for (int i = 0; i < n; i++) {
+      final CursorHolder holder = holderSuppliers.get(i).get();
+      final Cursor cursor = holder.asCursor();
+      groupCursors[i] = cursor;
+      if (cursor == null) {
+        continue;
+      }
+      // A per-group cursor is still queryable for its factory/capabilities 
even when empty (done); only non-empty
+      // groups join the heap.
+      groupFactories[i] = cursor.getColumnSelectorFactory();
+      timeSelectors[i] = 
groupFactories[i].makeColumnValueSelector(ColumnHolder.TIME_COLUMN_NAME);
+      if (!cursor.isDone()) {
+        currentTimes[i] = timeSelectors[i].getLong();
+        heap.enqueue(i);
+      }
+    }
+    currentGroup = heap.isEmpty() ? 0 : heap.firstInt();
+    factory = new MergingColumnSelectorFactory(groupFactories, () -> 
currentGroup, () -> outputRowId);
+  }
+
+  @Override
+  public ColumnSelectorFactory getColumnSelectorFactory()
+  {
+    initializeIfNeeded();
+    return factory;
+  }
+
+  @Override
+  public void advance()
+  {
+    if (isDone()) {
+      return;
+    }
+    final int root = heap.firstInt();
+    groupCursors[root].advance();
+    settleRoot(root);
+    outputRowId++;
+  }
+
+  @Override
+  public void advanceUninterruptibly()
+  {
+    if (isDone()) {
+      return;
+    }
+    final int root = heap.firstInt();
+    groupCursors[root].advanceUninterruptibly();
+    settleRoot(root);
+    outputRowId++;
+  }
+
+  /**
+   * Re-settle the heap after the winning group's cursor has been advanced: 
drop the group if it is now exhausted,
+   * otherwise re-key it on its new {@code __time} and let the queue re-sift 
the head. Refreshes {@link #currentGroup}.
+   */
+  private void settleRoot(int root)
+  {
+    if (groupCursors[root].isDone()) {
+      heap.dequeueInt();
+    } else {
+      currentTimes[root] = timeSelectors[root].getLong();
+      heap.changed();
+    }
+    if (!heap.isEmpty()) {
+      currentGroup = heap.firstInt();
+    }
+  }
+
+  @Override
+  public boolean isDone()
+  {
+    initializeIfNeeded();
+    return heap.isEmpty();
+  }
+
+  @Override
+  public boolean isDoneOrInterrupted()
+  {
+    return isDone() || Thread.currentThread().isInterrupted();
+  }
+
+  @Override
+  public void reset()
+  {
+    if (!initialized) {
+      return;
+    }
+    // Reset the held per-group cursors in place (do NOT re-fetch via 
asCursor(), which would produce fresh factories
+    // and invalidate the selectors already handed out through the 
MergingColumnSelectorFactory), then rebuild the heap.
+    heap.clear();
+    outputRowId = 0;
+    for (int i = 0; i < groupCursors.length; i++) {
+      final Cursor cursor = groupCursors[i];
+      if (cursor == null) {
+        continue;
+      }
+      cursor.reset();
+      if (!cursor.isDone()) {
+        currentTimes[i] = timeSelectors[i].getLong();
+        heap.enqueue(i);
+      }
+    }
+    currentGroup = heap.isEmpty() ? 0 : heap.firstInt();
+  }
+
+  /**
+   * Heap ordering over group indices: earlier {@code __time} sits at the head 
for ascending, later for descending,
+   * breaking ties by group index for determinism.
+   */
+  private int compareGroups(int a, int b)
+  {
+    final int cmp = descending
+                    ? Long.compare(currentTimes[b], currentTimes[a])
+                    : Long.compare(currentTimes[a], currentTimes[b]);
+    return cmp != 0 ? cmp : Integer.compare(a, b);
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorFactory.java
 
b/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorFactory.java
index a29b2b57072..ac0e843260a 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorFactory.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorFactory.java
@@ -23,6 +23,7 @@ import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.io.Closer;
+import org.apache.druid.query.Order;
 import org.apache.druid.query.OrderBy;
 import org.apache.druid.query.aggregation.AggregatorFactory;
 import org.apache.druid.query.dimension.DimensionSpec;
@@ -48,6 +49,7 @@ import org.apache.druid.segment.vector.VectorCursor;
 import org.apache.druid.segment.vector.VectorObjectSelector;
 import org.apache.druid.segment.vector.VectorOffset;
 import org.apache.druid.segment.vector.VectorValueSelector;
+import org.apache.druid.utils.CloseableUtils;
 
 import javax.annotation.Nullable;
 import java.io.IOException;
@@ -116,59 +118,6 @@ public class QueryableIndexCursorFactory implements 
ResidentCursorFactory
     return new QueryableIndexCursorHolder(index, spec, timeBoundaryInspector);
   }
 
-  private CursorHolder 
makeAggregateProjectionCursorHolder(QueryableProjection<QueryableIndex> 
projection)
-  {
-    return new QueryableIndexCursorHolder(
-        projection.getRowSelector(),
-        projection.getCursorBuildSpec(),
-        QueryableIndexTimeBoundaryInspector.create(projection.getRowSelector())
-    )
-    {
-      @Override
-      protected ColumnSelectorFactory makeColumnSelectorFactoryForOffset(
-          ColumnCache columnCache,
-          Offset baseOffset
-      )
-      {
-        return projection.wrapColumnSelectorFactory(
-            super.makeColumnSelectorFactoryForOffset(columnCache, baseOffset)
-        );
-      }
-
-      @Override
-      protected VectorColumnSelectorFactory 
makeVectorColumnSelectorFactoryForOffset(
-          ColumnCache columnCache,
-          VectorOffset baseOffset
-      )
-      {
-        return projection.wrapVectorColumnSelectorFactory(
-            super.makeVectorColumnSelectorFactoryForOffset(columnCache, 
baseOffset)
-        );
-      }
-
-      @Override
-      public boolean isPreAggregated()
-      {
-        return true;
-      }
-
-      @Nullable
-      @Override
-      public List<AggregatorFactory> getAggregatorsForPreAggregated()
-      {
-        return projection.getCursorBuildSpec().getAggregators();
-      }
-    };
-  }
-
-  private CursorHolder makeClusteredCursorHolder(CursorBuildSpec spec)
-  {
-    return makeClusteredCursorHolder(
-        spec,
-        Projections.planClusterGroupQuery(new 
ArrayList<>(index.getClusterGroupSchemas()), spec)
-    );
-  }
-
   /**
    * Build a clustered-base-table cursor holder from an already-computed 
{@link ClusterGroupQueryPlan}. Exposed so the
    * partial (on-demand) cursor factory can plan the cluster groups once — to 
decide which group bundles to download —
@@ -186,6 +135,53 @@ public class QueryableIndexCursorFactory implements 
ResidentCursorFactory
     return makeMultiGroupClusteredCursorHolder(spec, plan);
   }
 
+  @Override
+  public RowSignature getRowSignature()
+  {
+    final ClusteredValueGroupsBaseTableSchema clusterSummary = 
index.getClusteredBaseSummary();
+    if (clusterSummary != null) {
+      return getClusteredRowSignature(clusterSummary);
+    }
+
+    final LinkedHashSet<String> columns = new LinkedHashSet<>();
+
+    for (final OrderBy orderBy : index.getOrdering()) {
+      columns.add(orderBy.getColumnName());
+    }
+
+    // Add __time after the defined ordering, if __time wasn't part of it.
+    columns.add(ColumnHolder.TIME_COLUMN_NAME);
+    columns.addAll(index.getColumnNames());
+
+    final RowSignature.Builder builder = RowSignature.builder();
+    for (final String column : columns) {
+      final ColumnType columnType = 
ColumnType.fromCapabilities(index.getColumnCapabilities(column));
+
+      // index.getOrdering() may include columns that don't exist, such as if 
they were omitted due to
+      // being 100% nulls. Don't add those to the row signature.
+      if (columnType != null) {
+        builder.add(column, columnType);
+      }
+    }
+
+    return builder.build();
+  }
+
+  @Nullable
+  @Override
+  public ColumnCapabilities getColumnCapabilities(String column)
+  {
+    return index.getColumnCapabilities(column);
+  }
+
+  private CursorHolder makeClusteredCursorHolder(CursorBuildSpec spec)
+  {
+    return makeClusteredCursorHolder(
+        spec,
+        Projections.planClusterGroupQuery(new 
ArrayList<>(index.getClusterGroupSchemas()), spec)
+    );
+  }
+
   private CursorHolder makeSingleGroupClusteredCursorHolder(
       CursorBuildSpec spec,
       ClusterGroupQueryPlan plan,
@@ -200,12 +196,18 @@ public class QueryableIndexCursorFactory implements 
ResidentCursorFactory
       );
     }
 
+    // Omit cluster key from ordering if the caller requests (and is granted) 
time ordering.
+    // This way, the returned ordering will begin with {@code __time}.
+    final ClusteredValueGroupsBaseTableSchema summary = 
valueGroup.getSummary();
+    final List<OrderBy> ordering =
+        useTimeOrderedCursors(spec, summary) ? summary.getGroupOrdering() : 
summary.getOrdering();
+
     // groupIndex exposes the group's clustering columns as constant columns, 
no selector wrapper is needed
     return new QueryableIndexCursorHolder(
         groupIndex,
         plan.rebuildCursorBuildSpec(spec, valueGroup),
         QueryableIndexTimeBoundaryInspector.create(groupIndex),
-        valueGroup.getSummary().getOrdering()
+        ordering
     );
   }
 
@@ -251,10 +253,132 @@ public class QueryableIndexCursorFactory implements 
ResidentCursorFactory
       );
     }
 
+    // Use k-way merged group cursors for time ordering, or concatenated 
cursors otherwise.
+    if (useTimeOrderedCursors(spec, clusterSummary)) {
+      return makeTimeMergedClusteredCursorHolder(
+          holderSuppliers,
+          closer,
+          Cursors.getTimeOrdering(spec.getPreferredOrdering())
+      );
+    }
+
+    return makeConcatenatedClusteredCursorHolder(
+        spec,
+        this,
+        clusteringColumns,
+        clusteringValuesByGroup,
+        holderSuppliers,
+        clusterSummary,
+        closer
+    );
+  }
+
+  /**
+   * Build the row signature for a clustered segment. Top-level columns are 
empty, so column types are sourced from:
+   *   - the summary's clustering {@link RowSignature} for clustering columns;
+   *   - the first cluster group's sub-index for everything else (all groups 
share the same data-column shape).
+   */
+  private RowSignature 
getClusteredRowSignature(ClusteredValueGroupsBaseTableSchema clusterSummary)
+  {
+    final LinkedHashSet<String> columns = new LinkedHashSet<>();
+
+    for (final OrderBy orderBy : clusterSummary.getOrdering()) {
+      columns.add(orderBy.getColumnName());
+    }
+    columns.add(ColumnHolder.TIME_COLUMN_NAME);
+    columns.addAll(clusterSummary.getColumnNames());
+
+    final RowSignature.Builder builder = RowSignature.builder();
+    for (final String column : columns) {
+      final ColumnType columnType = 
ColumnType.fromCapabilities(index.getColumnCapabilities(column));
+      if (columnType != null) {
+        builder.add(column, columnType);
+      }
+    }
+    return builder.build();
+  }
+
+  /**
+   * Whether the query requests {@code __time} ordering and each cluster group 
is individually time-ordered. In that
+   * case, we return time ordered cursors.
+   */
+  private static boolean useTimeOrderedCursors(CursorBuildSpec spec, 
ClusteredValueGroupsBaseTableSchema summary)
+  {
+    if (Cursors.getTimeOrdering(spec.getPreferredOrdering()) == Order.NONE) {
+      return false;
+    }
+    final List<OrderBy> groupOrdering = summary.getGroupOrdering();
+    if (groupOrdering.isEmpty()) {
+      return false;
+    }
+    final OrderBy first = groupOrdering.get(0);
+    // Require __time to be the first non-clustering column AND natively 
ASCENDING. Each per-group cursor can flip
+    // ascending->descending on request but never the reverse, so an ascending 
group ordering guarantees the per-group
+    // cursors emit the direction the merge's heap (and the single-group 
holder) assume. Druid always writes __time
+    // ascending; guarding here keeps a hypothetical descending-written group 
from being mis-ordered rather than served.
+    return ColumnHolder.TIME_COLUMN_NAME.equals(first.getColumnName()) && 
first.getOrder() == Order.ASCENDING;
+  }
+
+  private static CursorHolder 
makeAggregateProjectionCursorHolder(QueryableProjection<QueryableIndex> 
projection)
+  {
+    return new QueryableIndexCursorHolder(
+        projection.getRowSelector(),
+        projection.getCursorBuildSpec(),
+        QueryableIndexTimeBoundaryInspector.create(projection.getRowSelector())
+    )
+    {
+      @Override
+      protected ColumnSelectorFactory makeColumnSelectorFactoryForOffset(
+          ColumnCache columnCache,
+          Offset baseOffset
+      )
+      {
+        return projection.wrapColumnSelectorFactory(
+            super.makeColumnSelectorFactoryForOffset(columnCache, baseOffset)
+        );
+      }
+
+      @Override
+      protected VectorColumnSelectorFactory 
makeVectorColumnSelectorFactoryForOffset(
+          ColumnCache columnCache,
+          VectorOffset baseOffset
+      )
+      {
+        return projection.wrapVectorColumnSelectorFactory(
+            super.makeVectorColumnSelectorFactoryForOffset(columnCache, 
baseOffset)
+        );
+      }
+
+      @Override
+      public boolean isPreAggregated()
+      {
+        return true;
+      }
+
+      @Nullable
+      @Override
+      public List<AggregatorFactory> getAggregatorsForPreAggregated()
+      {
+        return projection.getCursorBuildSpec().getAggregators();
+      }
+    };
+  }
+
+  /**
+   * Builds a {@link CursorHolder} that concatenates cluster group cursors 
together.
+   */
+  private static CursorHolder makeConcatenatedClusteredCursorHolder(
+      CursorBuildSpec spec,
+      ColumnInspector inspector,
+      RowSignature clusteringColumns,
+      List<Object[]> clusteringValuesByGroup,
+      List<Supplier<CursorHolder>> holderSuppliers,
+      ClusteredValueGroupsBaseTableSchema clusterSummary,
+      Closer closer
+  )
+  {
     // Initial wrapper state uses the first group's clustering values + a 
throwing placeholder delegate. The
-    // ConcatenatingCursor immediately calls setDelegate on init (before any 
selector is exposed). The vector
-    // wrapper carries the query-level max vector size from the build spec, 
the placeholder delegate can't be
-    // queried for sizing, and the value is constant across groups anyway.
+    // ConcatenatingCursor immediately calls setDelegate on init (before any 
selector is exposed).
     final int vectorSize = spec.getQueryContext().getVectorSize();
     final ClusteringColumnSelectorFactory wrapperFactory = new 
ClusteringColumnSelectorFactory(
         ClusteringColumnSelectorFactory.UNINITIALIZED_DELEGATE,
@@ -284,7 +408,7 @@ public class QueryableIndexCursorFactory implements 
ResidentCursorFactory
     // original filter (clustering leaves fold to constant TRUE/FALSE, other 
leaves pass through unchanged)
     final Filter queryFilter = spec.getFilter();
     final boolean filterCanVectorize =
-        queryFilter == null || 
queryFilter.canVectorizeMatcher(spec.getVirtualColumns().wrapInspector(this));
+        queryFilter == null || 
queryFilter.canVectorizeMatcher(spec.getVirtualColumns().wrapInspector(inspector));
     // we still check that the first holder is vectorizable to make sure all 
the non-filter parts can be vectorized
     final boolean canVectorize = filterCanVectorize && 
holderSuppliers.get(0).get().canVectorize();
 
@@ -318,6 +442,42 @@ public class QueryableIndexCursorFactory implements 
ResidentCursorFactory
         return clusterSummary.getOrdering();
       }
 
+      @Override
+      public void close()
+      {
+        CloseableUtils.closeAndWrapExceptions(closer);
+      }
+    };
+  }
+
+  /**
+   * Builds a {@link CursorHolder} whose non-vectorized cursor is a globally 
{@code __time}-ordered {@link
+   * MergingClusterGroupCursor} k-way-merging the per-group cursors. Only 
invoked when the query requested {@code
+   * __time} ordering and each group is individually {@code __time}-sorted 
(see caller).
+   */
+  private static CursorHolder makeTimeMergedClusteredCursorHolder(
+      List<Supplier<CursorHolder>> holderSuppliers,
+      Closer closer,
+      Order timeOrder
+  )
+  {
+    final boolean descending = timeOrder == Order.DESCENDING;
+    final MergingClusterGroupCursor cursor = new 
MergingClusterGroupCursor(holderSuppliers, descending);
+    final List<OrderBy> ordering = descending ? Cursors.descendingTimeOrder() 
: Cursors.ascendingTimeOrder();
+    return new CursorHolder()
+    {
+      @Override
+      public Cursor asCursor()
+      {
+        return cursor;
+      }
+
+      @Override
+      public List<OrderBy> getOrdering()
+      {
+        return ordering;
+      }
+
       @Override
       public void close()
       {
@@ -374,68 +534,4 @@ public class QueryableIndexCursorFactory implements 
ResidentCursorFactory
       return null;
     }
   };
-
-  @Override
-  public RowSignature getRowSignature()
-  {
-    final ClusteredValueGroupsBaseTableSchema clusterSummary = 
index.getClusteredBaseSummary();
-    if (clusterSummary != null) {
-      return getClusteredRowSignature(clusterSummary);
-    }
-
-    final LinkedHashSet<String> columns = new LinkedHashSet<>();
-
-    for (final OrderBy orderBy : index.getOrdering()) {
-      columns.add(orderBy.getColumnName());
-    }
-
-    // Add __time after the defined ordering, if __time wasn't part of it.
-    columns.add(ColumnHolder.TIME_COLUMN_NAME);
-    columns.addAll(index.getColumnNames());
-
-    final RowSignature.Builder builder = RowSignature.builder();
-    for (final String column : columns) {
-      final ColumnType columnType = 
ColumnType.fromCapabilities(index.getColumnCapabilities(column));
-
-      // index.getOrdering() may include columns that don't exist, such as if 
they were omitted due to
-      // being 100% nulls. Don't add those to the row signature.
-      if (columnType != null) {
-        builder.add(column, columnType);
-      }
-    }
-
-    return builder.build();
-  }
-
-  /**
-   * Build the row signature for a clustered segment. Top-level columns are 
empty, so column types are sourced from:
-   *   - the summary's clustering {@link RowSignature} for clustering columns;
-   *   - the first cluster group's sub-index for everything else (all groups 
share the same data-column shape).
-   */
-  private RowSignature 
getClusteredRowSignature(ClusteredValueGroupsBaseTableSchema clusterSummary)
-  {
-    final LinkedHashSet<String> columns = new LinkedHashSet<>();
-
-    for (final OrderBy orderBy : clusterSummary.getOrdering()) {
-      columns.add(orderBy.getColumnName());
-    }
-    columns.add(ColumnHolder.TIME_COLUMN_NAME);
-    columns.addAll(clusterSummary.getColumnNames());
-
-    final RowSignature.Builder builder = RowSignature.builder();
-    for (final String column : columns) {
-      final ColumnType columnType = 
ColumnType.fromCapabilities(index.getColumnCapabilities(column));
-      if (columnType != null) {
-        builder.add(column, columnType);
-      }
-    }
-    return builder.build();
-  }
-
-  @Nullable
-  @Override
-  public ColumnCapabilities getColumnCapabilities(String column)
-  {
-    return index.getColumnCapabilities(column);
-  }
 }
diff --git 
a/processing/src/main/java/org/apache/druid/segment/projections/MergingColumnSelectorFactory.java
 
b/processing/src/main/java/org/apache/druid/segment/projections/MergingColumnSelectorFactory.java
new file mode 100644
index 00000000000..b6224e8d0e7
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/projections/MergingColumnSelectorFactory.java
@@ -0,0 +1,342 @@
+/*
+ * 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.druid.segment.projections;
+
+import org.apache.druid.error.DruidException;
+import org.apache.druid.query.dimension.DimensionSpec;
+import org.apache.druid.query.filter.DruidPredicateFactory;
+import org.apache.druid.query.filter.ValueMatcher;
+import org.apache.druid.query.monomorphicprocessing.RuntimeShapeInspector;
+import org.apache.druid.segment.ColumnSelectorFactory;
+import org.apache.druid.segment.ColumnValueSelector;
+import org.apache.druid.segment.DimensionDictionarySelector;
+import org.apache.druid.segment.DimensionSelector;
+import org.apache.druid.segment.IdLookup;
+import org.apache.druid.segment.RowIdSupplier;
+import org.apache.druid.segment.column.ColumnCapabilities;
+import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
+import org.apache.druid.segment.data.IndexedInts;
+
+import javax.annotation.Nullable;
+import java.util.function.IntSupplier;
+import java.util.function.LongSupplier;
+
+/**
+ * {@link ColumnSelectorFactory} for a time-ordered k-way merge across 
per-cluster-group cursors (see
+ * {@code MergingClusterGroupCursor}). Unlike {@link 
ClusteringColumnSelectorFactory}, which swaps a single delegate
+ * on each <em>group</em> transition and rebuilds selectors via a generation 
counter, this factory pre-builds one
+ * inner selector <em>per group</em> for each requested column and, on every 
access, dispatches to whichever group is
+ * currently winning the merge (which changes per <em>row</em>).
+ *
+ * <p>Because the merge interleaves groups row-by-row, per-group-local 
dictionary ids are never stable across the
+ * merged stream, so {@link #getColumnCapabilities} strips dictionary encoding 
for every column (forcing value-based
+ * grouping, correct across groups) exactly as {@link 
ClusteringColumnSelectorFactory} does for non-clustering columns.
+ * The row id is minted from the merge's output-row counter rather than any 
delegate's, since the merge emits exactly
+ * one output row per advance.
+ */
+public class MergingColumnSelectorFactory implements ColumnSelectorFactory
+{
+  // Per-group factories, indexed by group. Entries may be null for groups 
whose cursor was null/absent; such groups
+  // never win the merge, so their slots are never dispatched to.
+  private final ColumnSelectorFactory[] groupFactories;
+  // Index of the group currently winning the merge (the row being exposed). 
Valid while the cursor is not done.
+  private final IntSupplier currentGroup;
+  // First non-null group factory; a valid stand-in for every group's 
capabilities (see getColumnCapabilities).
+  @Nullable
+  private final ColumnSelectorFactory representative;
+  // Row id minted from the merge's output-row counter (one per emitted row), 
forwarded to callers for caching.
+  private final RowIdSupplier rowIdSupplier;
+
+  public MergingColumnSelectorFactory(
+      ColumnSelectorFactory[] groupFactories,
+      IntSupplier currentGroup,
+      LongSupplier currentRowId
+  )
+  {
+    this.groupFactories = groupFactories;
+    this.currentGroup = currentGroup;
+    this.representative = firstNonNull(groupFactories);
+    this.rowIdSupplier = currentRowId::getAsLong;
+  }
+
+  @Nullable
+  private static ColumnSelectorFactory firstNonNull(ColumnSelectorFactory[] 
factories)
+  {
+    for (ColumnSelectorFactory factory : factories) {
+      if (factory != null) {
+        return factory;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Resolve the current winning group's per-group entry (selector/matcher). 
{@code currentGroup} only ever points at
+   * a non-empty (hence non-null) group while the merge cursor is not done, 
and the {@link org.apache.druid.segment.Cursor}
+   * contract requires callers to check {@code isDone()} before reading 
selectors, so a null here means that contract
+   * was violated (a read past exhaustion). Fail fast rather than NPE opaquely.
+   */
+  private <T> T requireCurrent(T[] perGroup)
+  {
+    final int group = currentGroup.getAsInt();
+    final T current = perGroup[group];
+    if (current == null) {
+      throw DruidException.defensive(
+          "No entry for current cluster group [%s]; merge selectors must not 
be read after isDone()",
+          group
+      );
+    }
+    return current;
+  }
+
+  @Override
+  public DimensionSelector makeDimensionSelector(DimensionSpec dimensionSpec)
+  {
+    final DimensionSelector[] perGroup = new 
DimensionSelector[groupFactories.length];
+    for (int i = 0; i < groupFactories.length; i++) {
+      if (groupFactories[i] != null) {
+        perGroup[i] = groupFactories[i].makeDimensionSelector(dimensionSpec);
+      }
+    }
+    return new MergingDimensionSelector(perGroup, dimensionSpec);
+  }
+
+  @Override
+  public ColumnValueSelector makeColumnValueSelector(String columnName)
+  {
+    final ColumnValueSelector[] perGroup = new 
ColumnValueSelector[groupFactories.length];
+    for (int i = 0; i < groupFactories.length; i++) {
+      if (groupFactories[i] != null) {
+        perGroup[i] = groupFactories[i].makeColumnValueSelector(columnName);
+      }
+    }
+    return new MergingColumnValueSelector(perGroup, columnName);
+  }
+
+  @Nullable
+  @Override
+  public ColumnCapabilities getColumnCapabilities(String column)
+  {
+    if (representative == null) {
+      return null;
+    }
+    // Precondition: every cluster group shares one schema (the sub-indexes 
are the same table split by clustering
+    // key), so the first non-null group is a valid stand-in for all groups' 
capabilities of any given column.
+    final ColumnCapabilities capabilities = 
representative.getColumnCapabilities(column);
+    if (capabilities == null) {
+      return null;
+    }
+    // Per-group-local dictionary ids are not stable across the merged stream 
(the same id means different values in
+    // different groups), so advertise non-dictionary-encoded to force 
value-based grouping, which is correct across
+    // groups.
+    return ColumnCapabilitiesImpl.copyOf(capabilities)
+                                 .setDictionaryEncoded(false)
+                                 .setDictionaryValuesSorted(false)
+                                 .setDictionaryValuesUnique(false)
+                                 .setHasBitmapIndexes(false);
+  }
+
+  @Nullable
+  @Override
+  public RowIdSupplier getRowIdSupplier()
+  {
+    return rowIdSupplier;
+  }
+
+  private final class MergingDimensionSelector implements DimensionSelector
+  {
+    private final DimensionSelector[] perGroup;
+    private final DimensionSpec spec;
+
+    private MergingDimensionSelector(DimensionSelector[] perGroup, 
DimensionSpec spec)
+    {
+      this.perGroup = perGroup;
+      this.spec = spec;
+    }
+
+    private DimensionSelector current()
+    {
+      return requireCurrent(perGroup);
+    }
+
+    @Override
+    public IndexedInts getRow()
+    {
+      return current().getRow();
+    }
+
+    @Override
+    public ValueMatcher makeValueMatcher(@Nullable String value)
+    {
+      final ValueMatcher[] matchers = new ValueMatcher[perGroup.length];
+      for (int i = 0; i < perGroup.length; i++) {
+        if (perGroup[i] != null) {
+          matchers[i] = perGroup[i].makeValueMatcher(value);
+        }
+      }
+      return new MergingValueMatcher(matchers);
+    }
+
+    @Override
+    public ValueMatcher makeValueMatcher(DruidPredicateFactory 
predicateFactory)
+    {
+      final ValueMatcher[] matchers = new ValueMatcher[perGroup.length];
+      for (int i = 0; i < perGroup.length; i++) {
+        if (perGroup[i] != null) {
+          matchers[i] = perGroup[i].makeValueMatcher(predicateFactory);
+        }
+      }
+      return new MergingValueMatcher(matchers);
+    }
+
+    @Override
+    public int getValueCardinality()
+    {
+      // Per-group dictionaries are not stable across the merged stream; 
CARDINALITY_UNKNOWN forces value-based
+      // grouping (see class javadoc and ClusteringColumnSelectorFactory).
+      return DimensionDictionarySelector.CARDINALITY_UNKNOWN;
+    }
+
+    @Nullable
+    @Override
+    public String lookupName(int id)
+    {
+      return current().lookupName(id);
+    }
+
+    @Override
+    public boolean nameLookupPossibleInAdvance()
+    {
+      return false;
+    }
+
+    @Nullable
+    @Override
+    public IdLookup idLookup()
+    {
+      return null;
+    }
+
+    @Nullable
+    @Override
+    public Object getObject()
+    {
+      return current().getObject();
+    }
+
+    @Override
+    public Class<?> classOfObject()
+    {
+      return current().classOfObject();
+    }
+
+    @Override
+    public void inspectRuntimeShape(RuntimeShapeInspector inspector)
+    {
+      inspector.visit("merging", spec.getDimension());
+    }
+
+    /**
+     * Dispatches to the winning group's matcher per row. Pre-built per group 
so a matcher held across the merge
+     * observes each row's winning group without rebuilding.
+     */
+    private final class MergingValueMatcher implements ValueMatcher
+    {
+      private final ValueMatcher[] matchers;
+
+      private MergingValueMatcher(ValueMatcher[] matchers)
+      {
+        this.matchers = matchers;
+      }
+
+      @Override
+      public boolean matches(boolean includeUnknown)
+      {
+        return requireCurrent(matchers).matches(includeUnknown);
+      }
+
+      @Override
+      public void inspectRuntimeShape(RuntimeShapeInspector inspector)
+      {
+        inspector.visit("merging-matcher", spec.getDimension());
+      }
+    }
+  }
+
+  private final class MergingColumnValueSelector implements 
ColumnValueSelector<Object>
+  {
+    private final ColumnValueSelector[] perGroup;
+    private final String columnName;
+
+    private MergingColumnValueSelector(ColumnValueSelector[] perGroup, String 
columnName)
+    {
+      this.perGroup = perGroup;
+      this.columnName = columnName;
+    }
+
+    private ColumnValueSelector current()
+    {
+      return requireCurrent(perGroup);
+    }
+
+    @Override
+    public double getDouble()
+    {
+      return current().getDouble();
+    }
+
+    @Override
+    public float getFloat()
+    {
+      return current().getFloat();
+    }
+
+    @Override
+    public long getLong()
+    {
+      return current().getLong();
+    }
+
+    @Override
+    public boolean isNull()
+    {
+      return current().isNull();
+    }
+
+    @Nullable
+    @Override
+    public Object getObject()
+    {
+      return current().getObject();
+    }
+
+    @Override
+    public Class<?> classOfObject()
+    {
+      return current().classOfObject();
+    }
+
+    @Override
+    public void inspectRuntimeShape(RuntimeShapeInspector inspector)
+    {
+      inspector.visit("merging", columnName);
+    }
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/segment/ClusteredSegmentTimeOrderedQueryTest.java
 
b/processing/src/test/java/org/apache/druid/segment/ClusteredSegmentTimeOrderedQueryTest.java
new file mode 100644
index 00000000000..e3eb0b0778d
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/ClusteredSegmentTimeOrderedQueryTest.java
@@ -0,0 +1,320 @@
+/*
+ * 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.druid.segment;
+
+import org.apache.druid.data.input.InputRow;
+import org.apache.druid.data.input.MapBasedInputRow;
+import 
org.apache.druid.data.input.impl.ClusteredValueGroupsBaseTableProjectionSpec;
+import org.apache.druid.data.input.impl.DimensionsSpec;
+import org.apache.druid.data.input.impl.LongDimensionSchema;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
+import org.apache.druid.data.input.impl.TimestampSpec;
+import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.query.DefaultGenericQueryMetricsFactory;
+import org.apache.druid.query.Druids;
+import org.apache.druid.query.Order;
+import org.apache.druid.query.QueryPlus;
+import org.apache.druid.query.QueryRunnerTestHelper;
+import org.apache.druid.query.Result;
+import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
+import org.apache.druid.query.scan.ScanQuery;
+import org.apache.druid.query.scan.ScanQueryConfig;
+import org.apache.druid.query.scan.ScanQueryEngine;
+import org.apache.druid.query.scan.ScanQueryQueryToolChest;
+import org.apache.druid.query.scan.ScanQueryRunnerFactory;
+import org.apache.druid.query.scan.ScanResultValue;
+import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
+import org.apache.druid.query.timeseries.TimeseriesQuery;
+import org.apache.druid.query.timeseries.TimeseriesQueryEngine;
+import org.apache.druid.query.timeseries.TimeseriesQueryQueryToolChest;
+import org.apache.druid.query.timeseries.TimeseriesQueryRunnerFactory;
+import org.apache.druid.query.timeseries.TimeseriesResultValue;
+import org.apache.druid.segment.column.ColumnHolder;
+import org.apache.druid.segment.incremental.IncrementalIndexSchema;
+import 
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
+import org.apache.druid.testing.InitializedNullHandlingTest;
+import org.apache.druid.timeline.SegmentId;
+import org.joda.time.Interval;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Functional coverage that the time-ordering query engines work over a 
CLUSTERED base-table segment whose {@code
+ * __time} is the first non-clustering column (so the read path serves them 
via the k-way {@code __time} merge; see
+ * {@link MergingClusterGroupCursor}). Runs a granular {@code timeseries} and 
an ascending/descending native {@code
+ * scan} — scan hard-enforces cursor time ordering, so a passing time-ordered 
scan over a multi-group clustered segment
+ * could not happen without the merge. Each result is also compared against an 
equivalent non-clustered (naturally
+ * {@code __time}-sorted) segment to pin correctness.
+ */
+class ClusteredSegmentTimeOrderedQueryTest extends InitializedNullHandlingTest
+{
+  private static final String DATA_SOURCE = "clustered-time-ordered";
+  private static final TimestampSpec TIMESTAMP_SPEC = new TimestampSpec("ts", 
"millis", null);
+  private static final long T0 = 
DateTimes.of("2026-01-01T00:00:00.000Z").getMillis();
+  private static final long MINUTE = 60_000L;
+  private static final Interval INTERVAL = Intervals.utc(T0, T0 + 4 * MINUTE);
+
+  // Two tenants => two cluster groups; timestamps interleave across the 
groups, ingested out of order. So the
+  // clustering-first concatenation is NOT globally time-ordered, and the 
merge must interleave by __time.
+  private static final List<InputRow> ROWS = List.of(
+      row(T0, "acme", 1),
+      row(T0 + 2 * MINUTE, "acme", 4),
+      row(T0 + MINUTE, "globex", 2),
+      row(T0 + 3 * MINUTE, "globex", 8)
+  );
+
+  // A single tenant => a single cluster group, so queries take the 
single-group clustered path.
+  private static final List<InputRow> SINGLE_GROUP_ROWS = List.of(
+      row(T0, "acme", 1),
+      row(T0 + MINUTE, "acme", 2),
+      row(T0 + 2 * MINUTE, "acme", 4)
+  );
+
+  @TempDir
+  File tempDir;
+
+  private Segment clusteredSegment;
+  private Segment nonClusteredSegment;
+
+  @BeforeEach
+  void setUp()
+  {
+    clusteredSegment = new QueryableIndexSegment(buildClustered("clustered", 
ROWS), SegmentId.dummy(DATA_SOURCE));
+    nonClusteredSegment = new QueryableIndexSegment(buildNonClustered("plain", 
ROWS), SegmentId.dummy(DATA_SOURCE));
+  }
+
+  @Test
+  void testGranularTimeseriesOverClusteredSegment()
+  {
+    // MINUTE-granular sum requires a globally __time-ordered cursor (the 
granularizer advances buckets as time
+    // increases); each bucket here comes from a different group, so the merge 
is what makes it correct.
+    final List<List<Long>> expected = List.of(
+        List.of(T0, 1L),
+        List.of(T0 + MINUTE, 2L),
+        List.of(T0 + 2 * MINUTE, 4L),
+        List.of(T0 + 3 * MINUTE, 8L)
+    );
+    Assertions.assertEquals(expected, runTimeseries(clusteredSegment));
+    // ... and identical to the equivalent non-clustered, naturally 
time-sorted segment.
+    Assertions.assertEquals(runTimeseries(nonClusteredSegment), 
runTimeseries(clusteredSegment));
+  }
+
+  @Test
+  void testAscendingScanOverClusteredSegment()
+  {
+    final List<List<Object>> expected = List.of(
+        Arrays.asList(T0, "acme", 1L),
+        Arrays.asList(T0 + MINUTE, "globex", 2L),
+        Arrays.asList(T0 + 2 * MINUTE, "acme", 4L),
+        Arrays.asList(T0 + 3 * MINUTE, "globex", 8L)
+    );
+    Assertions.assertEquals(expected, runScanRows(clusteredSegment, 
Order.ASCENDING));
+    Assertions.assertEquals(
+        runScanRows(nonClusteredSegment, Order.ASCENDING),
+        runScanRows(clusteredSegment, Order.ASCENDING)
+    );
+  }
+
+  @Test
+  void testDescendingScanOverClusteredSegment()
+  {
+    final List<List<Object>> expected = List.of(
+        Arrays.asList(T0 + 3 * MINUTE, "globex", 8L),
+        Arrays.asList(T0 + 2 * MINUTE, "acme", 4L),
+        Arrays.asList(T0 + MINUTE, "globex", 2L),
+        Arrays.asList(T0, "acme", 1L)
+    );
+    Assertions.assertEquals(expected, runScanRows(clusteredSegment, 
Order.DESCENDING));
+    Assertions.assertEquals(
+        runScanRows(nonClusteredSegment, Order.DESCENDING),
+        runScanRows(clusteredSegment, Order.DESCENDING)
+    );
+  }
+
+  @Test
+  void testAscendingScanOverSingleGroupClusteredSegment()
+  {
+    // A clustered segment that resolves to a SINGLE cluster group must also 
serve time-ordered scans. Previously the
+    // single-group path advertised the clustering-first ordering 
(getTimeOrder()==NONE) and the scan engine rejected
+    // it, so the same query succeeded with >=2 groups but failed with one. 
Scan throws unless the cursor is
+    // time-ordered, so this passing is the proof the single-group path now 
reports __time ordering.
+    final Segment clustered =
+        new QueryableIndexSegment(buildClustered("clustered-single", 
SINGLE_GROUP_ROWS), SegmentId.dummy(DATA_SOURCE));
+    final Segment plain =
+        new QueryableIndexSegment(buildNonClustered("plain-single", 
SINGLE_GROUP_ROWS), SegmentId.dummy(DATA_SOURCE));
+    final List<List<Object>> expected = List.of(
+        Arrays.asList(T0, "acme", 1L),
+        Arrays.asList(T0 + MINUTE, "acme", 2L),
+        Arrays.asList(T0 + 2 * MINUTE, "acme", 4L)
+    );
+    Assertions.assertEquals(expected, runScanRows(clustered, Order.ASCENDING));
+    Assertions.assertEquals(runScanRows(plain, Order.ASCENDING), 
runScanRows(clustered, Order.ASCENDING));
+  }
+
+  @Test
+  void testDescendingScanOverSingleGroupClusteredSegment()
+  {
+    final Segment clustered = new QueryableIndexSegment(
+        buildClustered("clustered-single-desc", SINGLE_GROUP_ROWS),
+        SegmentId.dummy(DATA_SOURCE)
+    );
+    final Segment plain = new QueryableIndexSegment(
+        buildNonClustered("plain-single-desc", SINGLE_GROUP_ROWS),
+        SegmentId.dummy(DATA_SOURCE)
+    );
+    final List<List<Object>> expected = List.of(
+        Arrays.asList(T0 + 2 * MINUTE, "acme", 4L),
+        Arrays.asList(T0 + MINUTE, "acme", 2L),
+        Arrays.asList(T0, "acme", 1L)
+    );
+    Assertions.assertEquals(expected, runScanRows(clustered, 
Order.DESCENDING));
+    Assertions.assertEquals(runScanRows(plain, Order.DESCENDING), 
runScanRows(clustered, Order.DESCENDING));
+  }
+
+  private static List<List<Long>> runTimeseries(Segment segment)
+  {
+    final TimeseriesQuery query = Druids.newTimeseriesQueryBuilder()
+                                        .dataSource(DATA_SOURCE)
+                                        .granularity(Granularities.MINUTE)
+                                        .intervals(new 
MultipleIntervalSegmentSpec(List.of(INTERVAL)))
+                                        .aggregators(new 
LongSumAggregatorFactory("sum_m", "m"))
+                                        .build();
+    final TimeseriesQueryRunnerFactory factory = new 
TimeseriesQueryRunnerFactory(
+        new TimeseriesQueryQueryToolChest(),
+        new TimeseriesQueryEngine(),
+        QueryRunnerTestHelper.NOOP_QUERYWATCHER
+    );
+    final List<Result<TimeseriesResultValue>> results =
+        factory.createRunner(segment).run(QueryPlus.wrap(query)).toList();
+    final List<List<Long>> out = new ArrayList<>();
+    for (Result<TimeseriesResultValue> result : results) {
+      final long sum = ((Number) 
result.getValue().getMetric("sum_m")).longValue();
+      out.add(List.of(result.getTimestamp().getMillis(), sum));
+    }
+    return out;
+  }
+
+  private static List<List<Object>> runScanRows(Segment segment, Order order)
+  {
+    final ScanQuery query = Druids.newScanQueryBuilder()
+                                  .dataSource(DATA_SOURCE)
+                                  .intervals(new 
MultipleIntervalSegmentSpec(List.of(INTERVAL)))
+                                  .columns(ColumnHolder.TIME_COLUMN_NAME, 
"tenant", "m")
+                                  .order(order)
+                                  
.resultFormat(ScanQuery.ResultFormat.RESULT_FORMAT_LIST)
+                                  .build();
+    final ScanQueryRunnerFactory factory = new ScanQueryRunnerFactory(
+        new 
ScanQueryQueryToolChest(DefaultGenericQueryMetricsFactory.instance()),
+        new ScanQueryEngine(),
+        new ScanQueryConfig()
+    );
+    final List<ScanResultValue> results = 
factory.createRunner(segment).run(QueryPlus.wrap(query)).toList();
+    // Extract full [__time, tenant, m] rows so the comparison against the 
non-clustered baseline verifies per-column
+    // dispatch (winning group's tenant/m paired with the right __time), not 
just the time column.
+    final List<List<Object>> rows = new ArrayList<>();
+    for (ScanResultValue result : results) {
+      for (Object event : (List<?>) result.getEvents()) {
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> row = (Map<String, Object>) event;
+        rows.add(Arrays.asList(
+            
DimensionHandlerUtils.convertObjectToLong(row.get(ColumnHolder.TIME_COLUMN_NAME)),
+            row.get("tenant"),
+            DimensionHandlerUtils.convertObjectToLong(row.get("m"))
+        ));
+      }
+    }
+    return rows;
+  }
+
+  private QueryableIndex buildClustered(String dirName, List<InputRow> rows)
+  {
+    // columns [tenant, __time, m], clustering [tenant] => ordering [tenant, 
__time, m], so __time is the first
+    // non-clustering column and each cluster group is individually 
__time-sorted.
+    final ClusteredValueGroupsBaseTableProjectionSpec clusterSpec =
+        ClusteredValueGroupsBaseTableProjectionSpec.builder()
+            .columns(
+                new StringDimensionSchema("tenant"),
+                new LongDimensionSchema("__time"),
+                new LongDimensionSchema("m")
+            )
+            .clusteringColumns("tenant")
+            .build();
+    final IncrementalIndexSchema schema =
+        IncrementalIndexSchema.builder()
+                              .withMinTimestamp(T0)
+                              .withTimestampSpec(TIMESTAMP_SPEC)
+                              .withQueryGranularity(Granularities.NONE)
+                              
.withDimensionsSpec(clusterSpec.getDimensionsSpec())
+                              .withRollup(false)
+                              .withClusterSpec(clusterSpec)
+                              .build();
+    return IndexBuilder.create()
+                       .useV10()
+                       .tmpDir(new File(tempDir, dirName))
+                       
.segmentWriteOutMediumFactory(OffHeapMemorySegmentWriteOutMediumFactory.instance())
+                       .schema(schema)
+                       .rows(rows)
+                       .buildMMappedIndex();
+  }
+
+  private QueryableIndex buildNonClustered(String dirName, List<InputRow> rows)
+  {
+    final IncrementalIndexSchema schema =
+        IncrementalIndexSchema.builder()
+                              .withMinTimestamp(T0)
+                              .withTimestampSpec(TIMESTAMP_SPEC)
+                              .withQueryGranularity(Granularities.NONE)
+                              .withDimensionsSpec(
+                                  DimensionsSpec.builder()
+                                                .setDimensions(List.of(
+                                                    new 
StringDimensionSchema("tenant"),
+                                                    new 
LongDimensionSchema("m")
+                                                ))
+                                                .build()
+                              )
+                              .withRollup(false)
+                              .build();
+    return IndexBuilder.create()
+                       .tmpDir(new File(tempDir, dirName))
+                       .schema(schema)
+                       .rows(rows)
+                       .buildMMappedIndex();
+  }
+
+  private static InputRow row(long ts, String tenant, long m)
+  {
+    final Map<String, Object> event = new HashMap<>();
+    event.put("ts", ts);
+    event.put("tenant", tenant);
+    event.put("m", m);
+    return new MapBasedInputRow(ts, List.of("tenant", "m"), event);
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/segment/IndexMergerV10ClusteredTest.java
 
b/processing/src/test/java/org/apache/druid/segment/IndexMergerV10ClusteredTest.java
index d19d174d812..93eaf89d0ed 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/IndexMergerV10ClusteredTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/IndexMergerV10ClusteredTest.java
@@ -29,6 +29,8 @@ import org.apache.druid.data.input.impl.TimestampSpec;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.query.Order;
+import org.apache.druid.query.OrderBy;
 import org.apache.druid.query.aggregation.AggregatorFactory;
 import org.apache.druid.query.aggregation.CountAggregatorFactory;
 import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
@@ -36,6 +38,7 @@ import org.apache.druid.query.dimension.DefaultDimensionSpec;
 import org.apache.druid.query.groupby.GroupByQuery;
 import org.apache.druid.query.groupby.GroupingEngine;
 import org.apache.druid.query.groupby.orderby.OrderByColumnSpec.Direction;
+import org.apache.druid.segment.column.ColumnHolder;
 import org.apache.druid.segment.incremental.IncrementalIndexSchema;
 import 
org.apache.druid.segment.projections.ClusteredValueGroupsBaseTableSchema;
 import org.apache.druid.segment.projections.TableClusterGroupSpec;
@@ -139,6 +142,51 @@ class IndexMergerV10ClusteredTest extends 
InitializedNullHandlingTest
     return out;
   }
 
+  private static ClusteredValueGroupsBaseTableProjectionSpec 
timeOrderedClusterSpec()
+  {
+    // __time declared as the first non-clustering column, so the segment 
ordering is [tenant, __time, region] and each
+    // cluster group is individually __time-sorted -- the precondition for the 
time-ordered merge cursor.
+    return ClusteredValueGroupsBaseTableProjectionSpec.builder()
+        .columns(
+            new StringDimensionSchema("tenant"),
+            new LongDimensionSchema("__time"),
+            new StringDimensionSchema("region")
+        )
+        .clusteringColumns("tenant")
+        .build();
+  }
+
+  private QueryableIndex buildTimeOrderedSegment(String dirName, 
List<InputRow> rows)
+  {
+    return IndexBuilder.create()
+                       .useV10()
+                       .tmpDir(new File(tempDir, dirName))
+                       
.segmentWriteOutMediumFactory(OffHeapMemorySegmentWriteOutMediumFactory.instance())
+                       .schema(clusteredSchema(timeOrderedClusterSpec()))
+                       .rows(rows)
+                       .buildMMappedIndex();
+  }
+
+  /**
+   * Walk (@code __time}, {@code region}) pairs from a holder's scalar cursor, 
in whatever order the holder produces.
+   */
+  private static List<List<Object>> scanTimeRegion(CursorHolder holder)
+  {
+    final Cursor cursor = holder.asCursor();
+    final ColumnValueSelector timeSelector =
+        
cursor.getColumnSelectorFactory().makeColumnValueSelector(ColumnHolder.TIME_COLUMN_NAME);
+    final DimensionSelector regionSelector =
+        
cursor.getColumnSelectorFactory().makeDimensionSelector(DefaultDimensionSpec.of("region"));
+    final List<List<Object>> out = new ArrayList<>();
+    while (!cursor.isDone()) {
+      final String region =
+          regionSelector.getRow().size() == 0 ? null : 
regionSelector.lookupName(regionSelector.getRow().get(0));
+      out.add(Arrays.asList(timeSelector.getLong(), region));
+      cursor.advance();
+    }
+    return out;
+  }
+
   /**
    * Cluster spec with a numeric column {@code x} (in addition to {@code 
tenant}/{@code region}) so an aggregate
    * projection can sum it. Clustering is still on {@code tenant}.
@@ -453,6 +501,86 @@ class IndexMergerV10ClusteredTest extends 
InitializedNullHandlingTest
     );
   }
 
+  @Test
+  void testTimeOrderedMergeAcrossGroups()
+  {
+    // Two tenants (=> two cluster groups) with interleaved timestamps, 
ingested out of order.
+    final QueryableIndex index = buildTimeOrderedSegment(
+        "time-merge",
+        List.of(
+            row(T0 + 4, "acme", "a4"),
+            row(T0 + 1, "acme", "a1"),
+            row(T0 + 3, "globex", "g3"),
+            row(T0 + 2, "globex", "g2")
+        )
+    );
+
+    // Layout precondition: __time is the first non-clustering column, so each 
group is individually __time-sorted.
+    final ClusteredValueGroupsBaseTableSchema summary = 
index.getClusteredBaseSummary();
+    Assertions.assertEquals(
+        ColumnHolder.TIME_COLUMN_NAME,
+        summary.getGroupOrdering().get(0).getColumnName()
+    );
+
+    final QueryableIndexCursorFactory factory = new 
QueryableIndexCursorFactory(
+        index,
+        QueryableIndexTimeBoundaryInspector.create(index)
+    );
+
+    // No preferred ordering => concatenation: clustering-first ordering, NOT 
globally __time-ordered (each group's
+    // rows are contiguous: acme's two rows, then globex's two rows).
+    try (CursorHolder holder = 
factory.makeCursorHolder(CursorBuildSpec.FULL_SCAN)) {
+      Assertions.assertEquals("tenant", 
holder.getOrdering().get(0).getColumnName());
+      Assertions.assertEquals(
+          List.of(
+              Arrays.asList(T0 + 1, "a1"),
+              Arrays.asList(T0 + 4, "a4"),
+              Arrays.asList(T0 + 2, "g2"),
+              Arrays.asList(T0 + 3, "g3")
+          ),
+          scanTimeRegion(holder)
+      );
+    }
+
+    // Ascending __time preferred => the time-ordered merge cursor: holder 
advertises __time ASC and rows are globally
+    // time-ordered across groups.
+    final CursorBuildSpec ascending = 
CursorBuildSpec.builder(CursorBuildSpec.FULL_SCAN)
+        
.setPreferredOrdering(List.of(OrderBy.ascending(ColumnHolder.TIME_COLUMN_NAME)))
+        .build();
+    try (CursorHolder holder = factory.makeCursorHolder(ascending)) {
+      Assertions.assertEquals(Cursors.ascendingTimeOrder(), 
holder.getOrdering());
+      Assertions.assertEquals(Order.ASCENDING, holder.getTimeOrder());
+      Assertions.assertFalse(holder.canVectorize(), "scalar-first: the merge 
holder is not vectorizable");
+      Assertions.assertEquals(
+          List.of(
+              Arrays.asList(T0 + 1, "a1"),
+              Arrays.asList(T0 + 2, "g2"),
+              Arrays.asList(T0 + 3, "g3"),
+              Arrays.asList(T0 + 4, "a4")
+          ),
+          scanTimeRegion(holder)
+      );
+    }
+
+    // Descending __time preferred => descending merge (each per-group cursor 
reverses, merged with a max-heap).
+    final CursorBuildSpec descending = 
CursorBuildSpec.builder(CursorBuildSpec.FULL_SCAN)
+        
.setPreferredOrdering(List.of(OrderBy.descending(ColumnHolder.TIME_COLUMN_NAME)))
+        .build();
+    try (CursorHolder holder = factory.makeCursorHolder(descending)) {
+      Assertions.assertEquals(Cursors.descendingTimeOrder(), 
holder.getOrdering());
+      Assertions.assertEquals(Order.DESCENDING, holder.getTimeOrder());
+      Assertions.assertEquals(
+          List.of(
+              Arrays.asList(T0 + 4, "a4"),
+              Arrays.asList(T0 + 3, "g3"),
+              Arrays.asList(T0 + 2, "g2"),
+              Arrays.asList(T0 + 1, "a1")
+          ),
+          scanTimeRegion(holder)
+      );
+    }
+  }
+
   @Test
   void testMergeClusteredSegmentsAlignsGroupsAcrossSegments() throws Exception
   {
diff --git 
a/processing/src/test/java/org/apache/druid/segment/MergingClusterGroupCursorTest.java
 
b/processing/src/test/java/org/apache/druid/segment/MergingClusterGroupCursorTest.java
new file mode 100644
index 00000000000..daf38d3a2cc
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/MergingClusterGroupCursorTest.java
@@ -0,0 +1,302 @@
+/*
+ * 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.druid.segment;
+
+import com.google.common.base.Supplier;
+import org.apache.druid.query.dimension.DimensionSpec;
+import org.apache.druid.segment.column.ColumnCapabilities;
+import org.apache.druid.segment.column.ColumnHolder;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+
+class MergingClusterGroupCursorTest
+{
+  @Test
+  void testAscendingMergeAcrossGroups()
+  {
+    // Three individually time-sorted groups; the merge interleaves them into 
one globally ascending stream, and each
+    // emitted value must come from the group that owns that timestamp 
(verifies per-row dispatch to the winner).
+    final MergingClusterGroupCursor cursor = cursor(
+        false,
+        group(new long[]{1, 5, 9}, new String[]{"g0@1", "g0@5", "g0@9"}),
+        group(new long[]{2, 3, 10}, new String[]{"g1@2", "g1@3", "g1@10"}),
+        group(new long[]{4, 6}, new String[]{"g2@4", "g2@6"})
+    );
+    final List<Object[]> rows = drain(cursor);
+    assertRows(
+        rows,
+        new long[]{1, 2, 3, 4, 5, 6, 9, 10},
+        new String[]{"g0@1", "g1@2", "g1@3", "g2@4", "g0@5", "g2@6", "g0@9", 
"g1@10"}
+    );
+  }
+
+  @Test
+  void testDescendingMerge()
+  {
+    // Descending: each per-group cursor is itself descending (mirrors 
QueryableIndexCursorHolder reversing offsets),
+    // and the merge uses a max-heap.
+    final MergingClusterGroupCursor cursor = cursor(
+        true,
+        group(new long[]{9, 5, 1}, new String[]{"g0@9", "g0@5", "g0@1"}),
+        group(new long[]{10, 3, 2}, new String[]{"g1@10", "g1@3", "g1@2"}),
+        group(new long[]{6, 4}, new String[]{"g2@6", "g2@4"})
+    );
+    final List<Object[]> rows = drain(cursor);
+    assertRows(
+        rows,
+        new long[]{10, 9, 6, 5, 4, 3, 2, 1},
+        new String[]{"g1@10", "g0@9", "g2@6", "g0@5", "g2@4", "g1@3", "g1@2", 
"g0@1"}
+    );
+  }
+
+  @Test
+  void testTiesBreakByGroupIndex()
+  {
+    // Equal timestamps across groups break by ascending group index 
(deterministic). Within a group, rows are emitted
+    // in the group's own order.
+    final MergingClusterGroupCursor cursor = cursor(
+        false,
+        group(new long[]{5, 5}, new String[]{"g0a", "g0b"}),
+        group(new long[]{5}, new String[]{"g1a"})
+    );
+    final List<Object[]> rows = drain(cursor);
+    assertRows(rows, new long[]{5, 5, 5}, new String[]{"g0a", "g0b", "g1a"});
+  }
+
+  @Test
+  void testEmptyGroupsSkipped()
+  {
+    final MergingClusterGroupCursor cursor = cursor(
+        false,
+        group(new long[]{}, new String[]{}),
+        group(new long[]{1, 2}, new String[]{"x", "y"}),
+        group(new long[]{}, new String[]{})
+    );
+    assertRows(drain(cursor), new long[]{1, 2}, new String[]{"x", "y"});
+  }
+
+  @Test
+  void testSingleNonEmptyGroup()
+  {
+    final MergingClusterGroupCursor cursor = cursor(
+        false,
+        group(new long[]{}, new String[]{}),
+        group(new long[]{7, 8, 9}, new String[]{"a", "b", "c"})
+    );
+    assertRows(drain(cursor), new long[]{7, 8, 9}, new String[]{"a", "b", 
"c"});
+  }
+
+  @Test
+  void testAllEmptyIsDoneImmediately()
+  {
+    final MergingClusterGroupCursor cursor = cursor(
+        false,
+        group(new long[]{}, new String[]{}),
+        group(new long[]{}, new String[]{})
+    );
+    Assertions.assertTrue(cursor.isDone());
+  }
+
+  @Test
+  void testResetReplaysSameSequence()
+  {
+    final MergingClusterGroupCursor cursor = cursor(
+        false,
+        group(new long[]{1, 4}, new String[]{"g0@1", "g0@4"}),
+        group(new long[]{2, 3}, new String[]{"g1@2", "g1@3"})
+    );
+    final List<Object[]> first = drain(cursor);
+    cursor.reset();
+    final List<Object[]> second = drain(cursor);
+    assertRows(first, new long[]{1, 2, 3, 4}, new String[]{"g0@1", "g1@2", 
"g1@3", "g0@4"});
+    assertRows(second, new long[]{1, 2, 3, 4}, new String[]{"g0@1", "g1@2", 
"g1@3", "g0@4"});
+  }
+
+  @Test
+  void testRowIdMonotonicAndStableWithinRow()
+  {
+    final MergingClusterGroupCursor cursor = cursor(
+        false,
+        group(new long[]{1, 3}, new String[]{"g0@1", "g0@3"}),
+        group(new long[]{2, 4}, new String[]{"g1@2", "g1@4"})
+    );
+    final RowIdSupplier rowIdSupplier = 
cursor.getColumnSelectorFactory().getRowIdSupplier();
+    Assertions.assertNotNull(rowIdSupplier);
+    long previous = -1;
+    while (!cursor.isDone()) {
+      final long id = rowIdSupplier.getRowId();
+      Assertions.assertEquals(id, rowIdSupplier.getRowId(), "row id must be 
stable within a row");
+      Assertions.assertTrue(id > previous, "row id must strictly increase 
across rows");
+      previous = id;
+      cursor.advance();
+    }
+  }
+
+  @SafeVarargs
+  private static MergingClusterGroupCursor cursor(boolean descending, 
Supplier<CursorHolder>... groups)
+  {
+    return new MergingClusterGroupCursor(new ArrayList<>(List.of(groups)), 
descending);
+  }
+
+  private static List<Object[]> drain(MergingClusterGroupCursor cursor)
+  {
+    final ColumnSelectorFactory factory = cursor.getColumnSelectorFactory();
+    final ColumnValueSelector timeSelector = 
factory.makeColumnValueSelector(ColumnHolder.TIME_COLUMN_NAME);
+    final ColumnValueSelector valueSelector = 
factory.makeColumnValueSelector("v");
+    final List<Object[]> rows = new ArrayList<>();
+    while (!cursor.isDone()) {
+      rows.add(new Object[]{timeSelector.getLong(), 
valueSelector.getObject()});
+      cursor.advance();
+    }
+    return rows;
+  }
+
+  private static void assertRows(List<Object[]> rows, long[] expectedTimes, 
String[] expectedValues)
+  {
+    Assertions.assertEquals(expectedTimes.length, rows.size(), "row count");
+    for (int i = 0; i < expectedTimes.length; i++) {
+      Assertions.assertEquals(expectedTimes[i], (long) (Long) rows.get(i)[0], 
"time at row " + i);
+      Assertions.assertEquals(expectedValues[i], rows.get(i)[1], "value at row 
" + i);
+    }
+  }
+
+  private static Supplier<CursorHolder> group(long[] times, String[] values)
+  {
+    final Cursor cursor = new ListCursor(times, values);
+    final CursorHolder holder = new CursorHolder()
+    {
+      @Override
+      public Cursor asCursor()
+      {
+        return cursor;
+      }
+    };
+    return () -> holder;
+  }
+
+  /**
+   * A minimal scalar {@link Cursor} over an in-memory {@code (times, values)} 
pair, exposing {@code __time} (long) and
+   * {@code v} (object) selectors backed by the current position. Stands in 
for a per-group cluster cursor.
+   */
+  private static final class ListCursor implements Cursor
+  {
+    private final long[] times;
+    private final String[] values;
+    private final ColumnSelectorFactory factory;
+    private int pos;
+
+    private ListCursor(long[] times, String[] values)
+    {
+      this.times = times;
+      this.values = values;
+      this.factory = new ColumnSelectorFactory()
+      {
+        @Override
+        public DimensionSelector makeDimensionSelector(DimensionSpec 
dimensionSpec)
+        {
+          throw new UnsupportedOperationException("not used");
+        }
+
+        @Override
+        public ColumnValueSelector makeColumnValueSelector(String columnName)
+        {
+          if (ColumnHolder.TIME_COLUMN_NAME.equals(columnName)) {
+            return new TestLongColumnSelector()
+            {
+              @Override
+              public long getLong()
+              {
+                return times[pos];
+              }
+
+              @Override
+              public boolean isNull()
+              {
+                return false;
+              }
+            };
+          }
+          return new TestObjectColumnSelector<String>()
+          {
+            @Override
+            public Class<String> classOfObject()
+            {
+              return String.class;
+            }
+
+            @Nullable
+            @Override
+            public String getObject()
+            {
+              return values[pos];
+            }
+          };
+        }
+
+        @Nullable
+        @Override
+        public ColumnCapabilities getColumnCapabilities(String column)
+        {
+          return null;
+        }
+      };
+    }
+
+    @Override
+    public ColumnSelectorFactory getColumnSelectorFactory()
+    {
+      return factory;
+    }
+
+    @Override
+    public void advance()
+    {
+      pos++;
+    }
+
+    @Override
+    public void advanceUninterruptibly()
+    {
+      pos++;
+    }
+
+    @Override
+    public boolean isDone()
+    {
+      return pos >= times.length;
+    }
+
+    @Override
+    public boolean isDoneOrInterrupted()
+    {
+      return isDone();
+    }
+
+    @Override
+    public void reset()
+    {
+      pos = 0;
+    }
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/segment/projections/MergingColumnSelectorFactoryTest.java
 
b/processing/src/test/java/org/apache/druid/segment/projections/MergingColumnSelectorFactoryTest.java
new file mode 100644
index 00000000000..a8ea723a979
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/projections/MergingColumnSelectorFactoryTest.java
@@ -0,0 +1,170 @@
+/*
+ * 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.druid.segment.projections;
+
+import org.apache.druid.query.dimension.DefaultDimensionSpec;
+import org.apache.druid.query.dimension.DimensionSpec;
+import org.apache.druid.segment.ColumnSelectorFactory;
+import org.apache.druid.segment.ColumnValueSelector;
+import org.apache.druid.segment.DimensionDictionarySelector;
+import org.apache.druid.segment.DimensionSelector;
+import org.apache.druid.segment.RowIdSupplier;
+import org.apache.druid.segment.TestObjectColumnSelector;
+import org.apache.druid.segment.column.ColumnCapabilities;
+import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
+import org.apache.druid.segment.column.ValueType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+import java.util.function.IntSupplier;
+import java.util.function.LongSupplier;
+
+class MergingColumnSelectorFactoryTest
+{
+  private final int[] currentGroup = {0};
+  private final long[] rowId = {0};
+
+  private MergingColumnSelectorFactory factory(ColumnSelectorFactory... groups)
+  {
+    final IntSupplier currentGroupSupplier = () -> currentGroup[0];
+    final LongSupplier rowIdSupplier = () -> rowId[0];
+    return new MergingColumnSelectorFactory(groups, currentGroupSupplier, 
rowIdSupplier);
+  }
+
+  @Test
+  void testGetColumnCapabilitiesStripsDictionaryEncoding()
+  {
+    // The representative group advertises "v" as a dictionary-encoded string 
with bitmap indexes; because per-group
+    // dictionary ids are not stable across the merged stream, the factory 
must strip those flags (forcing value-based
+    // grouping) while keeping the type.
+    final MergingColumnSelectorFactory factory = factory(groupFactory("g0"), 
groupFactory("g1"));
+    final ColumnCapabilities caps = factory.getColumnCapabilities("v");
+    Assertions.assertNotNull(caps);
+    Assertions.assertTrue(caps.is(ValueType.STRING));
+    Assertions.assertTrue(caps.isDictionaryEncoded().isFalse());
+    Assertions.assertTrue(caps.areDictionaryValuesSorted().isFalse());
+    Assertions.assertTrue(caps.areDictionaryValuesUnique().isFalse());
+    Assertions.assertFalse(caps.hasBitmapIndexes());
+  }
+
+  @Test
+  void testGetColumnCapabilitiesNullWhenRepresentativeHasNone()
+  {
+    // Unknown column -> representative returns null -> factory returns null 
(caller skips it).
+    final MergingColumnSelectorFactory factory = factory(groupFactory("g0"));
+    Assertions.assertNull(factory.getColumnCapabilities("nope"));
+    // No non-null group factory at all -> null capabilities.
+    Assertions.assertNull(factory(new 
ColumnSelectorFactory[]{null}).getColumnCapabilities("v"));
+  }
+
+  @Test
+  void testColumnValueSelectorDispatchesToCurrentGroup()
+  {
+    final MergingColumnSelectorFactory factory = factory(groupFactory("g0"), 
groupFactory("g1"));
+    final ColumnValueSelector selector = factory.makeColumnValueSelector("v");
+    currentGroup[0] = 0;
+    Assertions.assertEquals("g0", selector.getObject());
+    currentGroup[0] = 1;
+    Assertions.assertEquals("g1", selector.getObject());
+    currentGroup[0] = 0;
+    Assertions.assertEquals("g0", selector.getObject());
+  }
+
+  @Test
+  void testDimensionSelectorDispatchesAndReportsUnstableDictionary()
+  {
+    final MergingColumnSelectorFactory factory = factory(groupFactory("g0"), 
groupFactory("g1"));
+    final DimensionSelector selector = 
factory.makeDimensionSelector(DefaultDimensionSpec.of("v"));
+
+    currentGroup[0] = 0;
+    Assertions.assertEquals("g0", selector.getObject());
+    Assertions.assertEquals("g0", 
selector.lookupName(selector.getRow().get(0)));
+    currentGroup[0] = 1;
+    Assertions.assertEquals("g1", selector.getObject());
+
+    // Cross-group dictionary instability is advertised so engines use 
value-based grouping.
+    Assertions.assertEquals(DimensionDictionarySelector.CARDINALITY_UNKNOWN, 
selector.getValueCardinality());
+    Assertions.assertNull(selector.idLookup());
+    Assertions.assertFalse(selector.nameLookupPossibleInAdvance());
+  }
+
+  @Test
+  void testRowIdSupplierReflectsMintedOutputRowId()
+  {
+    final MergingColumnSelectorFactory factory = factory(groupFactory("g0"));
+    final RowIdSupplier supplier = factory.getRowIdSupplier();
+    Assertions.assertNotNull(supplier);
+    rowId[0] = 0;
+    Assertions.assertEquals(0L, supplier.getRowId());
+    rowId[0] = 7;
+    Assertions.assertEquals(7L, supplier.getRowId());
+  }
+
+  /**
+   * A stub group factory that reports column "v" as a dictionary-encoded 
string (to exercise stripping) and returns
+   * constant selectors carrying {@code tag} (to verify per-group dispatch).
+   */
+  private static ColumnSelectorFactory groupFactory(String tag)
+  {
+    return new ColumnSelectorFactory()
+    {
+      @Override
+      public DimensionSelector makeDimensionSelector(DimensionSpec 
dimensionSpec)
+      {
+        return DimensionSelector.constant(tag);
+      }
+
+      @Override
+      public ColumnValueSelector makeColumnValueSelector(String columnName)
+      {
+        return new TestObjectColumnSelector<String>()
+        {
+          @Override
+          public Class<String> classOfObject()
+          {
+            return String.class;
+          }
+
+          @Nullable
+          @Override
+          public String getObject()
+          {
+            return tag;
+          }
+        };
+      }
+
+      @Nullable
+      @Override
+      public ColumnCapabilities getColumnCapabilities(String column)
+      {
+        if (!"v".equals(column)) {
+          return null;
+        }
+        return 
ColumnCapabilitiesImpl.createSimpleSingleValueStringColumnCapabilities()
+                                     .setDictionaryEncoded(true)
+                                     .setDictionaryValuesSorted(true)
+                                     .setDictionaryValuesUnique(true)
+                                     .setHasBitmapIndexes(true);
+      }
+    };
+  }
+}


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

Reply via email to