This is an automated email from the ASF dual-hosted git repository.
capistrant 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 957121248f3 feat: clustered segment time ordering for realtime queries
(#19753)
957121248f3 is described below
commit 957121248f31e957d9f261215e99548558d50ebd
Author: Clint Wylie <[email protected]>
AuthorDate: Sun Jul 26 16:28:44 2026 -0700
feat: clustered segment time ordering for realtime queries (#19753)
---
.../druid/segment/MergingClusterGroupCursor.java | 82 +++++++++++-
.../druid/segment/QueryableIndexCursorFactory.java | 83 ++-----------
.../incremental/IncrementalIndexCursorFactory.java | 27 ++--
.../projections/MergingColumnSelectorFactory.java | 137 +++++++++++++++++++--
.../druid/segment/projections/Projections.java | 15 +++
.../ClusteredSegmentTimeOrderedQueryTest.java | 134 ++++++++++++++++++++
.../segment/MergingClusterGroupCursorTest.java | 25 +++-
.../MergingColumnSelectorFactoryTest.java | 106 +++++++++++++++-
8 files changed, 509 insertions(+), 100 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
index 33404f95958..16786a12cc3 100644
---
a/processing/src/main/java/org/apache/druid/segment/MergingClusterGroupCursor.java
+++
b/processing/src/main/java/org/apache/druid/segment/MergingClusterGroupCursor.java
@@ -22,8 +22,12 @@ 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.java.util.common.io.Closer;
+import org.apache.druid.query.OrderBy;
import org.apache.druid.segment.column.ColumnHolder;
+import org.apache.druid.segment.column.RowSignature;
import org.apache.druid.segment.projections.MergingColumnSelectorFactory;
+import org.apache.druid.utils.CloseableUtils;
import java.util.List;
import java.util.Map;
@@ -32,14 +36,14 @@ import java.util.Map;
* {@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}.
+ * {@code __time}).
* <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. When the plan carries a
+ * {@code __time} across the groups. The {@link MergingColumnSelectorFactory}
exposes each row's clustering values
+ * (constant per group) and dispatches non-clustering columns to the winning
group. When the plan carries a
* query-virtual-column-to-materialized-column remap, the merging factory is
wrapped in a
* {@link RemapColumnSelectorFactory} so reads of a remapped query virtual
column's output name resolve to its
* materialized column (mirroring {@link ConcatenatingCursor}); the per-group
specs already dropped those virtual
@@ -54,7 +58,10 @@ import java.util.Map;
public final class MergingClusterGroupCursor implements Cursor
{
private final List<Supplier<CursorHolder>> holderSuppliers;
+ private final RowSignature clusteringColumns;
+ private final List<Object[]> clusteringValuesByGroup;
private final boolean descending;
+ private final VirtualColumns queryVirtualColumns;
private final Map<String, String> virtualColumnRemap;
private boolean initialized;
@@ -74,18 +81,77 @@ public final class MergingClusterGroupCursor implements
Cursor
public MergingClusterGroupCursor(
List<Supplier<CursorHolder>> holderSuppliers,
+ RowSignature clusteringColumns,
+ List<Object[]> clusteringValuesByGroup,
boolean descending,
+ VirtualColumns queryVirtualColumns,
Map<String, String> virtualColumnRemap
)
{
if (holderSuppliers.isEmpty()) {
throw DruidException.defensive("MergingClusterGroupCursor requires at
least one cluster group");
}
+ if (holderSuppliers.size() != clusteringValuesByGroup.size()) {
+ throw DruidException.defensive(
+ "holderSuppliers size [%s] must equal clusteringValuesByGroup size
[%s]",
+ holderSuppliers.size(),
+ clusteringValuesByGroup.size()
+ );
+ }
this.holderSuppliers = holderSuppliers;
+ this.clusteringColumns = clusteringColumns;
+ this.clusteringValuesByGroup = clusteringValuesByGroup;
this.descending = descending;
+ this.queryVirtualColumns = queryVirtualColumns;
this.virtualColumnRemap = virtualColumnRemap;
}
+ /**
+ * Build a {@link CursorHolder} over a globally {@code __time}-ordered merge
of the per-group holders, advertising
+ * ascending/descending {@code __time} ordering per {@code timeOrder}.The
supplied {@link Closer} owns the per-group
+ * holders and is closed with the returned holder.
+ */
+ public static CursorHolder makeCursorHolder(
+ List<Supplier<CursorHolder>> holderSuppliers,
+ RowSignature clusteringColumns,
+ List<Object[]> clusteringValuesByGroup,
+ boolean descending,
+ VirtualColumns queryVirtualColumns,
+ Map<String, String> virtualColumnRemap,
+ Closer closer
+ )
+ {
+ final MergingClusterGroupCursor cursor = new MergingClusterGroupCursor(
+ holderSuppliers,
+ clusteringColumns,
+ clusteringValuesByGroup,
+ descending,
+ queryVirtualColumns,
+ virtualColumnRemap
+ );
+ 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()
+ {
+ CloseableUtils.closeAndWrapExceptions(closer);
+ }
+ };
+ }
+
private void initializeIfNeeded()
{
if (initialized) {
@@ -116,8 +182,14 @@ public final class MergingClusterGroupCursor implements
Cursor
}
}
currentGroup = heap.isEmpty() ? 0 : heap.firstInt();
- final MergingColumnSelectorFactory mergingFactory =
- new MergingColumnSelectorFactory(groupFactories, () -> currentGroup,
() -> outputRowId);
+ final MergingColumnSelectorFactory mergingFactory = new
MergingColumnSelectorFactory(
+ groupFactories,
+ clusteringColumns,
+ clusteringValuesByGroup,
+ queryVirtualColumns,
+ () -> currentGroup,
+ () -> outputRowId
+ );
exposedFactory = virtualColumnRemap.isEmpty()
? mergingFactory
: new RemapColumnSelectorFactory(mergingFactory,
virtualColumnRemap);
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 1206000e6cc..96cc9d6fb6b 100644
---
a/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorFactory.java
+++
b/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorFactory.java
@@ -53,12 +53,10 @@ import org.apache.druid.segment.vector.VectorValueSelector;
import org.apache.druid.utils.CloseableUtils;
import javax.annotation.Nullable;
-import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
-import java.util.Map;
public class QueryableIndexCursorFactory implements ResidentCursorFactory
{
@@ -202,7 +200,7 @@ public class QueryableIndexCursorFactory implements
ResidentCursorFactory
// 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();
+ Projections.useTimeOrderedCursors(spec, summary) ?
summary.getGroupOrdering() : summary.getOrdering();
if (plan.virtualColumnRemap().isEmpty()) {
return new QueryableIndexCursorHolder(
@@ -289,12 +287,16 @@ public class QueryableIndexCursorFactory implements
ResidentCursorFactory
}
// Use k-way merged group cursors for time ordering, or concatenated
cursors otherwise.
- if (useTimeOrderedCursors(spec, clusterSummary)) {
- return makeTimeMergedClusteredCursorHolder(
+ if (Projections.useTimeOrderedCursors(spec, clusterSummary)) {
+ final boolean descending =
Cursors.getTimeOrdering(spec.getPreferredOrdering()) == Order.DESCENDING;
+ return MergingClusterGroupCursor.makeCursorHolder(
holderSuppliers,
- closer,
- Cursors.getTimeOrdering(spec.getPreferredOrdering()),
- plan.virtualColumnRemap()
+ clusteringColumns,
+ clusteringValuesByGroup,
+ descending,
+ spec.getVirtualColumns(),
+ plan.virtualColumnRemap(),
+ closer
);
}
@@ -335,27 +337,6 @@ public class QueryableIndexCursorFactory implements
ResidentCursorFactory
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(
@@ -492,50 +473,6 @@ public class QueryableIndexCursorFactory implements
ResidentCursorFactory
};
}
- /**
- * 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). The {@code virtualColumnRemap}
- * (of query virtual columns equivalent to a materialized column) is applied
on top of the merge, mirroring
- * {@link #makeConcatenatedClusteredCursorHolder}.
- */
- private static CursorHolder makeTimeMergedClusteredCursorHolder(
- List<Supplier<CursorHolder>> holderSuppliers,
- Closer closer,
- Order timeOrder,
- Map<String, String> virtualColumnRemap
- )
- {
- final boolean descending = timeOrder == Order.DESCENDING;
- final MergingClusterGroupCursor cursor = new
MergingClusterGroupCursor(holderSuppliers, descending, virtualColumnRemap);
- 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()
- {
- try {
- closer.close();
- }
- catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
- };
- }
-
/**
* Vector counterpart of {@link
ClusteringColumnSelectorFactory#UNINITIALIZED_DELEGATE}. Replaced by
* {@link ConcatenatingVectorCursor}'s lazy init before the wrapper is
exposed.
diff --git
a/processing/src/main/java/org/apache/druid/segment/incremental/IncrementalIndexCursorFactory.java
b/processing/src/main/java/org/apache/druid/segment/incremental/IncrementalIndexCursorFactory.java
index 74f2ddaa014..ac90cf6fb66 100644
---
a/processing/src/main/java/org/apache/druid/segment/incremental/IncrementalIndexCursorFactory.java
+++
b/processing/src/main/java/org/apache/druid/segment/incremental/IncrementalIndexCursorFactory.java
@@ -24,6 +24,7 @@ import com.google.common.base.Suppliers;
import com.google.common.collect.Iterables;
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.segment.ColumnSelectorFactory;
@@ -31,7 +32,9 @@ import org.apache.druid.segment.ConcatenatingCursor;
import org.apache.druid.segment.Cursor;
import org.apache.druid.segment.CursorBuildSpec;
import org.apache.druid.segment.CursorHolder;
+import org.apache.druid.segment.Cursors;
import org.apache.druid.segment.EmptyCursorHolder;
+import org.apache.druid.segment.MergingClusterGroupCursor;
import org.apache.druid.segment.ResidentCursorFactory;
import org.apache.druid.segment.column.ColumnCapabilities;
import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
@@ -43,9 +46,9 @@ import
org.apache.druid.segment.projections.ClusteringColumnSelectorFactory;
import org.apache.druid.segment.projections.Projections;
import org.apache.druid.segment.projections.QueryableProjection;
import org.apache.druid.segment.projections.TableClusterGroupSpec;
+import org.apache.druid.utils.CloseableUtils;
import javax.annotation.Nullable;
-import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -188,6 +191,21 @@ public class IncrementalIndexCursorFactory implements
ResidentCursorFactory
);
}
+ // when the query asks for __time ordering and each group is individually
__time-sorted (__time first
+ // non-clustering column), present a globally __time-ordered cursor via a
k-way merge across groups
+ if (Projections.useTimeOrderedCursors(spec, summary)) {
+ final boolean descending =
Cursors.getTimeOrdering(spec.getPreferredOrdering()) == Order.DESCENDING;
+ return MergingClusterGroupCursor.makeCursorHolder(
+ holderSuppliers,
+ clusteringColumns,
+ clusteringValuesByGroup,
+ descending,
+ spec.getVirtualColumns(),
+ plan.virtualColumnRemap(),
+ closer
+ );
+ }
+
// The wrapper starts with a throwing placeholder delegate;
ConcatenatingCursor swaps in each group's real
// selector factory (and clustering constants) on init, before any
selector is exposed.
final ClusteringColumnSelectorFactory wrapperFactory = new
ClusteringColumnSelectorFactory(
@@ -222,12 +240,7 @@ public class IncrementalIndexCursorFactory implements
ResidentCursorFactory
@Override
public void close()
{
- try {
- closer.close();
- }
- catch (IOException e) {
- throw new RuntimeException(e);
- }
+ CloseableUtils.closeAndWrapExceptions(closer);
}
};
}
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
index b6224e8d0e7..79484fe4ded 100644
---
a/processing/src/main/java/org/apache/druid/segment/projections/MergingColumnSelectorFactory.java
+++
b/processing/src/main/java/org/apache/druid/segment/projections/MergingColumnSelectorFactory.java
@@ -20,21 +20,29 @@
package org.apache.druid.segment.projections;
import org.apache.druid.error.DruidException;
+import org.apache.druid.math.expr.ExprEval;
+import org.apache.druid.math.expr.ExpressionType;
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.ConstantExprEvalSelector;
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.VirtualColumns;
import org.apache.druid.segment.column.ColumnCapabilities;
import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.column.ValueType;
import org.apache.druid.segment.data.IndexedInts;
import javax.annotation.Nullable;
+import java.util.List;
import java.util.function.IntSupplier;
import java.util.function.LongSupplier;
@@ -45,20 +53,31 @@ import java.util.function.LongSupplier;
* 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>Clustering columns are handled directly rather than dispatched as they
are constant within a group, so this
+ * factory returns the winning group's clustering value as a per-group
constant. Non-clustering columns dispatch to the
+ * winning group's selector. A clustering column whose name is also a query
virtual column's output is NOT served as the
+ * constant: it dispatches to the winning group (whose factory resolves the
virtual column), so a shadowing VC observes
+ * the computed value rather than the constant. Names remapped away (a query
VC equivalent to a materialized column)
+ * never reach this factory: the enclosing {@code RemapColumnSelectorFactory}
rewrites them to their materialized
+ * target first.
+ *
* <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.
+ * merged stream, so {@link #getColumnCapabilities} advertises
non-dictionary-encoded for every column (forcing
+ * value-based grouping, correct across groups) exactly as {@link
ClusteringColumnSelectorFactory} does. The row id is
+ * set 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;
+ private final RowSignature clusteringColumns;
+ private final List<Object[]> clusteringValuesByGroup;
+ private final VirtualColumns queryVirtualColumns;
// 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).
+ // First non-null group factory; a valid stand-in for every group's
non-clustering capabilities (schema-homogeneous).
@Nullable
private final ColumnSelectorFactory representative;
// Row id minted from the merge's output-row counter (one per emitted row),
forwarded to callers for caching.
@@ -66,16 +85,34 @@ public class MergingColumnSelectorFactory implements
ColumnSelectorFactory
public MergingColumnSelectorFactory(
ColumnSelectorFactory[] groupFactories,
+ RowSignature clusteringColumns,
+ List<Object[]> clusteringValuesByGroup,
+ VirtualColumns queryVirtualColumns,
IntSupplier currentGroup,
LongSupplier currentRowId
)
{
this.groupFactories = groupFactories;
+ this.clusteringColumns = clusteringColumns;
+ this.clusteringValuesByGroup = clusteringValuesByGroup;
+ this.queryVirtualColumns = queryVirtualColumns;
this.currentGroup = currentGroup;
this.representative = firstNonNull(groupFactories);
this.rowIdSupplier = currentRowId::getAsLong;
}
+ /**
+ * Index of {@code name} in the clustering columns when it should be served
as this group's clustering constant, or
+ * {@code -1} otherwise. A clustering column shadowed by a query virtual
column of the same output name is NOT served
+ * as the constant (returns {@code -1}); it dispatches to the winning group
so the computed value wins. Mirrors
+ * {@link ClusteringColumnSelectorFactory}'s {@code
servesClusteringConstant}.
+ */
+ private int clusteringConstantIndex(String name)
+ {
+ final int idx = clusteringColumns.indexOf(name);
+ return idx >= 0 && !queryVirtualColumns.exists(name) ? idx : -1;
+ }
+
@Nullable
private static ColumnSelectorFactory firstNonNull(ColumnSelectorFactory[]
factories)
{
@@ -109,9 +146,17 @@ public class MergingColumnSelectorFactory implements
ColumnSelectorFactory
@Override
public DimensionSelector makeDimensionSelector(DimensionSpec dimensionSpec)
{
+ final int clusteringIdx =
clusteringConstantIndex(dimensionSpec.getDimension());
final DimensionSelector[] perGroup = new
DimensionSelector[groupFactories.length];
for (int i = 0; i < groupFactories.length; i++) {
- if (groupFactories[i] != null) {
+ if (clusteringIdx >= 0) {
+ // Clustering column: the winning group's constant value, decorated
with any extraction fn.
+ final Object value = clusteringValuesByGroup.get(i)[clusteringIdx];
+ perGroup[i] = DimensionSelector.constant(
+ value == null ? null : String.valueOf(value),
+ dimensionSpec.getExtractionFn()
+ );
+ } else if (groupFactories[i] != null) {
perGroup[i] = groupFactories[i].makeDimensionSelector(dimensionSpec);
}
}
@@ -121,19 +166,93 @@ public class MergingColumnSelectorFactory implements
ColumnSelectorFactory
@Override
public ColumnValueSelector makeColumnValueSelector(String columnName)
{
+ final int clusteringIdx = clusteringConstantIndex(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);
+ if (clusteringIdx >= 0) {
+ // Clustering column: the winning group's constant value, as a typed
value selector.
+ final ExpressionType type =
+
ExpressionType.fromColumnTypeStrict(clusteringColumns.getColumnType(clusteringIdx).orElseThrow());
+ for (int i = 0; i < groupFactories.length; i++) {
+ perGroup[i] = constantClusteringValueSelector(type,
clusteringValuesByGroup.get(i)[clusteringIdx]);
+ }
+ } else {
+ for (int i = 0; i < groupFactories.length; i++) {
+ if (groupFactories[i] != null) {
+ perGroup[i] = groupFactories[i].makeColumnValueSelector(columnName);
+ }
}
}
return new MergingColumnValueSelector(perGroup, columnName);
}
+ /**
+ * A constant {@link ColumnValueSelector} for a clustering column's
per-group value, unwrapping the {@link ExprEval}
+ * so {@code getObject()} yields the raw typed value (matching an ordinary
column selector).
+ */
+ private static ColumnValueSelector<?>
constantClusteringValueSelector(ExpressionType type, @Nullable Object value)
+ {
+ final ConstantExprEvalSelector eval = new
ConstantExprEvalSelector(ExprEval.ofType(type, value));
+ return new ColumnValueSelector<>()
+ {
+ @Override
+ public double getDouble()
+ {
+ return eval.getDouble();
+ }
+
+ @Override
+ public float getFloat()
+ {
+ return eval.getFloat();
+ }
+
+ @Override
+ public long getLong()
+ {
+ return eval.getLong();
+ }
+
+ @Override
+ public boolean isNull()
+ {
+ return eval.isNull();
+ }
+
+ @Nullable
+ @Override
+ public Object getObject()
+ {
+ return eval.getObject().value();
+ }
+
+ @Override
+ public Class<?> classOfObject()
+ {
+ return Object.class;
+ }
+
+ @Override
+ public void inspectRuntimeShape(RuntimeShapeInspector inspector)
+ {
+ eval.inspectRuntimeShape(inspector);
+ }
+ };
+ }
+
@Nullable
@Override
public ColumnCapabilities getColumnCapabilities(String column)
{
+ final int clusteringIdx = clusteringConstantIndex(column);
+ if (clusteringIdx >= 0) {
+ // Clustering columns are exposed as per-group constants; report simple
type-based capabilities (never
+ // dictionary-encoded across the merge), exactly as
ClusteringColumnSelectorFactory does.
+ final ColumnType type =
clusteringColumns.getColumnType(clusteringIdx).orElseThrow();
+ if (type.is(ValueType.STRING)) {
+ return
ColumnCapabilitiesImpl.createSimpleSingleValueStringColumnCapabilities();
+ }
+ return
ColumnCapabilitiesImpl.createSimpleNumericColumnCapabilities(type);
+ }
if (representative == null) {
return null;
}
diff --git
a/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
b/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
index ef211ae6e24..3649071d70e 100644
---
a/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
+++
b/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
@@ -27,6 +27,7 @@ import
org.apache.druid.java.util.common.granularity.Granularities;
import org.apache.druid.java.util.common.granularity.Granularity;
import org.apache.druid.java.util.common.granularity.PeriodGranularity;
import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.query.Order;
import org.apache.druid.query.QueryContext;
import org.apache.druid.query.QueryContexts;
import org.apache.druid.query.aggregation.AggregatorFactory;
@@ -40,6 +41,7 @@ import org.apache.druid.query.filter.TypedInFilter;
import org.apache.druid.segment.AggregateProjectionMetadata;
import org.apache.druid.segment.CursorBuildSpec;
import org.apache.druid.segment.CursorHolder;
+import org.apache.druid.segment.Cursors;
import org.apache.druid.segment.VirtualColumn;
import org.apache.druid.segment.VirtualColumns;
import org.apache.druid.segment.column.ColumnHolder;
@@ -792,6 +794,19 @@ public class Projections
return remap;
}
+ /**
+ * Whether a clustered read should serve a globally {@code __time}-ordered
cursor for {@code spec}. True when the
+ * query requests {@code __time} ordering AND each cluster group is
individually {@code __time}-sorted, i.e.
+ * {@code __time} is the first non-clustering column.
+ */
+ public static boolean useTimeOrderedCursors(CursorBuildSpec spec,
ClusteredValueGroupsBaseTableSchema summary)
+ {
+ if (Cursors.getTimeOrdering(spec.getPreferredOrdering()) == Order.NONE) {
+ return false;
+ }
+ return Cursors.getTimeOrdering(summary.getGroupOrdering()) ==
Order.ASCENDING;
+ }
+
/**
* Walk the filter tree against {@code group}'s constant clustering tuple
and return a rewritten filter where each
* recognized equality / in / null leaf whose column resolves to a
clustering column (physical or virtual) is folded
diff --git
a/processing/src/test/java/org/apache/druid/segment/ClusteredSegmentTimeOrderedQueryTest.java
b/processing/src/test/java/org/apache/druid/segment/ClusteredSegmentTimeOrderedQueryTest.java
index b067b4ccc94..d5fa466bf2e 100644
---
a/processing/src/test/java/org/apache/druid/segment/ClusteredSegmentTimeOrderedQueryTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/ClusteredSegmentTimeOrderedQueryTest.java
@@ -52,7 +52,9 @@ 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.column.ColumnType;
+import org.apache.druid.segment.incremental.IncrementalIndex;
import org.apache.druid.segment.incremental.IncrementalIndexSchema;
+import org.apache.druid.segment.incremental.OnheapIncrementalIndex;
import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
import
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
import org.apache.druid.testing.InitializedNullHandlingTest;
@@ -225,6 +227,64 @@ class ClusteredSegmentTimeOrderedQueryTest extends
InitializedNullHandlingTest
Assertions.assertEquals(expected, runScanWithLowerTenantVc(clustered,
Order.ASCENDING));
}
+ @Test
+ void testAscendingScanWithQueryVcShadowingClusteringColumn()
+ {
+ // A query VC whose OUTPUT name shadows the clustering column `tenant` but
computes a different value (from `m`, not
+ // equivalent to any materialized column, so it is NOT remapped away). On
the __time merge path the merge factory
+ // must dispatch reads of `tenant` to the winning group (which resolves
the VC) rather than serve the raw clustering
+ // constant. Without the shadowing guard this returned the group constant
("acme"/"globex") instead of the computed
+ // value, diverging from the concatenating path.
+ final List<List<Object>> expected = List.of(
+ Arrays.asList(T0, "t_1", 1L),
+ Arrays.asList(T0 + MINUTE, "t_2", 2L),
+ Arrays.asList(T0 + 2 * MINUTE, "t_4", 4L),
+ Arrays.asList(T0 + 3 * MINUTE, "t_8", 8L)
+ );
+ Assertions.assertEquals(expected,
runScanWithShadowingTenantVc(clusteredSegment, Order.ASCENDING));
+ }
+
+ @Test
+ void testTimeOrderedQueriesOverIncrementalClusteredSegment()
+ {
+ // The realtime path (IncrementalIndexCursorFactory) must also k-way-merge
groups for a __time-ordered query.
+ // Incremental per-group cursors do not carry clustering columns, so this
also exercises the merge factory
+ // injecting the winning group's clustering value (tenant). Results must
match the non-clustered baseline.
+ final Segment incremental = buildClusteredIncremental(ROWS);
+
+ Assertions.assertEquals(
+ List.of(
+ List.of(T0, 1L),
+ List.of(T0 + MINUTE, 2L),
+ List.of(T0 + 2 * MINUTE, 4L),
+ List.of(T0 + 3 * MINUTE, 8L)
+ ),
+ runTimeseries(incremental)
+ );
+ Assertions.assertEquals(runTimeseries(nonClusteredSegment),
runTimeseries(incremental));
+
+ Assertions.assertEquals(
+ 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)
+ ),
+ runScanRows(incremental, Order.ASCENDING)
+ );
+ Assertions.assertEquals(runScanRows(nonClusteredSegment, Order.ASCENDING),
runScanRows(incremental, Order.ASCENDING));
+
+ Assertions.assertEquals(
+ 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)
+ ),
+ runScanRows(incremental, Order.DESCENDING)
+ );
+ }
+
private static List<List<Long>> runTimeseries(Segment segment)
{
final TimeseriesQuery query = Druids.newTimeseriesQueryBuilder()
@@ -321,6 +381,48 @@ class ClusteredSegmentTimeOrderedQueryTest extends
InitializedNullHandlingTest
return rows;
}
+ /**
+ * Time-ordered scan projecting a query virtual column {@code tenant :=
concat('t_', m)} whose output name shadows the
+ * clustering column {@code tenant} (plus {@code __time} and {@code m}),
extracting {@code [__time, tenant, m]} rows.
+ * Exercises the shadowing-VC guard on the {@code __time} merge path: reads
of {@code tenant} must resolve to the
+ * computed VC value, not the group's clustering constant.
+ */
+ private static List<List<Object>> runScanWithShadowingTenantVc(Segment
segment, Order order)
+ {
+ final ScanQuery query = Druids.newScanQueryBuilder()
+ .dataSource(DATA_SOURCE)
+ .intervals(new
MultipleIntervalSegmentSpec(List.of(INTERVAL)))
+ .virtualColumns(new ExpressionVirtualColumn(
+ "tenant",
+ "concat('t_', m)",
+ ColumnType.STRING,
+ TestExprMacroTable.INSTANCE
+ ))
+ .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();
+ 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 buildClusteredWithMaterializedVc(String dirName)
{
// Cluster on the group VC tenant_lower := lower(tenant); declare __time
immediately after the clustering column so
@@ -412,6 +514,38 @@ class ClusteredSegmentTimeOrderedQueryTest extends
InitializedNullHandlingTest
.buildMMappedIndex();
}
+ private static Segment buildClusteredIncremental(List<InputRow> rows)
+ {
+ // Realtime counterpart of buildClustered: an OnheapIncrementalIndex
clustered by tenant with __time as the first
+ // non-clustering column, served through IncrementalIndexCursorFactory
(via IncrementalIndexSegment).
+ 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();
+ final IncrementalIndex index = new OnheapIncrementalIndex.Builder()
+ .setIndexSchema(schema)
+ .setMaxRowCount(10_000)
+ .build();
+ for (final InputRow row : rows) {
+ index.add(row);
+ }
+ return new IncrementalIndexSegment(index, SegmentId.dummy(DATA_SOURCE));
+ }
+
private static InputRow row(long ts, String tenant, long m)
{
final Map<String, Object> event = new HashMap<>();
diff --git
a/processing/src/test/java/org/apache/druid/segment/MergingClusterGroupCursorTest.java
b/processing/src/test/java/org/apache/druid/segment/MergingClusterGroupCursorTest.java
index 9c3f3b187f1..836f4072014 100644
---
a/processing/src/test/java/org/apache/druid/segment/MergingClusterGroupCursorTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/MergingClusterGroupCursorTest.java
@@ -23,11 +23,13 @@ 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.apache.druid.segment.column.RowSignature;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import javax.annotation.Nullable;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -161,12 +163,16 @@ class MergingClusterGroupCursorTest
// factory must resolve to the materialized "v" column of the winning
group. Without the remap the per-group
// factories reject "aliased" (see ListCursor), so this proves the
RemapColumnSelectorFactory is applied on top of
// the merge.
+ final List<Supplier<CursorHolder>> suppliers = new ArrayList<>(List.of(
+ group(new long[]{1, 3}, new String[]{"g0@1", "g0@3"}),
+ group(new long[]{2, 4}, new String[]{"g1@2", "g1@4"})
+ ));
final MergingClusterGroupCursor cursor = new MergingClusterGroupCursor(
- new ArrayList<>(List.of(
- group(new long[]{1, 3}, new String[]{"g0@1", "g0@3"}),
- group(new long[]{2, 4}, new String[]{"g1@2", "g1@4"})
- )),
+ suppliers,
+ RowSignature.empty(),
+ List.of(new Object[0], new Object[0]),
false,
+ VirtualColumns.EMPTY,
Map.of("aliased", "v")
);
final ColumnSelectorFactory factory = cursor.getColumnSelectorFactory();
@@ -183,7 +189,16 @@ class MergingClusterGroupCursorTest
@SafeVarargs
private static MergingClusterGroupCursor cursor(boolean descending,
Supplier<CursorHolder>... groups)
{
- return new MergingClusterGroupCursor(new ArrayList<>(List.of(groups)),
descending, Map.of());
+ // These cases only read non-clustering columns ("v"), so no clustering
columns/values are needed.
+ final List<Supplier<CursorHolder>> suppliers = new
ArrayList<>(List.of(groups));
+ return new MergingClusterGroupCursor(
+ suppliers,
+ RowSignature.empty(),
+ Collections.nCopies(suppliers.size(), new Object[0]),
+ descending,
+ VirtualColumns.EMPTY,
+ Map.of()
+ );
}
private static List<Object[]> drain(MergingClusterGroupCursor cursor)
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
index a8ea723a979..236b81b049e 100644
---
a/processing/src/test/java/org/apache/druid/segment/projections/MergingColumnSelectorFactoryTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/projections/MergingColumnSelectorFactoryTest.java
@@ -19,6 +19,7 @@
package org.apache.druid.segment.projections;
+import org.apache.druid.math.expr.ExprMacroTable;
import org.apache.druid.query.dimension.DefaultDimensionSpec;
import org.apache.druid.query.dimension.DimensionSpec;
import org.apache.druid.segment.ColumnSelectorFactory;
@@ -27,13 +28,19 @@ 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.VirtualColumns;
import org.apache.druid.segment.column.ColumnCapabilities;
import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.RowSignature;
import org.apache.druid.segment.column.ValueType;
+import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import javax.annotation.Nullable;
+import java.util.Collections;
+import java.util.List;
import java.util.function.IntSupplier;
import java.util.function.LongSupplier;
@@ -43,10 +50,37 @@ class MergingColumnSelectorFactoryTest
private final long[] rowId = {0};
private MergingColumnSelectorFactory factory(ColumnSelectorFactory... groups)
+ {
+ // No clustering columns: every requested column dispatches to the
per-group factories.
+ return factory(RowSignature.empty(), Collections.nCopies(groups.length,
new Object[0]), groups);
+ }
+
+ private MergingColumnSelectorFactory factory(
+ RowSignature clusteringColumns,
+ List<Object[]> clusteringValuesByGroup,
+ ColumnSelectorFactory... groups
+ )
+ {
+ return factory(clusteringColumns, clusteringValuesByGroup,
VirtualColumns.EMPTY, groups);
+ }
+
+ private MergingColumnSelectorFactory factory(
+ RowSignature clusteringColumns,
+ List<Object[]> clusteringValuesByGroup,
+ VirtualColumns queryVirtualColumns,
+ ColumnSelectorFactory... groups
+ )
{
final IntSupplier currentGroupSupplier = () -> currentGroup[0];
final LongSupplier rowIdSupplier = () -> rowId[0];
- return new MergingColumnSelectorFactory(groups, currentGroupSupplier,
rowIdSupplier);
+ return new MergingColumnSelectorFactory(
+ groups,
+ clusteringColumns,
+ clusteringValuesByGroup,
+ queryVirtualColumns,
+ currentGroupSupplier,
+ rowIdSupplier
+ );
}
@Test
@@ -106,6 +140,76 @@ class MergingColumnSelectorFactoryTest
Assertions.assertFalse(selector.nameLookupPossibleInAdvance());
}
+ @Test
+ void testClusteringColumnExposedAsWinningGroupConstant()
+ {
+ // Clustering columns are not read from the per-group cursors (which may
not carry them); the factory injects the
+ // winning group's clustering value. Verify both the value selector and
dimension selector track the current group.
+ final RowSignature clusteringColumns =
RowSignature.builder().add("tenant", ColumnType.STRING).build();
+ final List<Object[]> clusteringValuesByGroup = List.of(new
Object[]{"acme"}, new Object[]{"globex"});
+ final MergingColumnSelectorFactory factory =
+ factory(clusteringColumns, clusteringValuesByGroup,
groupFactory("g0"), groupFactory("g1"));
+
+ final ColumnValueSelector valueSelector =
factory.makeColumnValueSelector("tenant");
+ final DimensionSelector dimensionSelector =
factory.makeDimensionSelector(DefaultDimensionSpec.of("tenant"));
+ currentGroup[0] = 0;
+ Assertions.assertEquals("acme", valueSelector.getObject());
+ Assertions.assertEquals("acme", dimensionSelector.getObject());
+ currentGroup[0] = 1;
+ Assertions.assertEquals("globex", valueSelector.getObject());
+ Assertions.assertEquals("globex", dimensionSelector.getObject());
+
+ // Clustering capabilities are type-based and never dictionary-encoded
across the merge.
+ final ColumnCapabilities caps = factory.getColumnCapabilities("tenant");
+ Assertions.assertNotNull(caps);
+ Assertions.assertTrue(caps.is(ValueType.STRING));
+ Assertions.assertTrue(caps.isDictionaryEncoded().isFalse());
+ Assertions.assertEquals(DimensionDictionarySelector.CARDINALITY_UNKNOWN,
dimensionSelector.getValueCardinality());
+ }
+
+ @Test
+ void testNumericClusteringColumnExposedAsWinningGroupConstant()
+ {
+ final RowSignature clusteringColumns =
RowSignature.builder().add("priority", ColumnType.LONG).build();
+ final List<Object[]> clusteringValuesByGroup = List.of(new Object[]{5L},
new Object[]{9L});
+ final MergingColumnSelectorFactory factory =
+ factory(clusteringColumns, clusteringValuesByGroup,
groupFactory("g0"), groupFactory("g1"));
+
+ final ColumnValueSelector selector =
factory.makeColumnValueSelector("priority");
+ currentGroup[0] = 0;
+ Assertions.assertEquals(5L, selector.getLong());
+ Assertions.assertEquals(5L, selector.getObject());
+ currentGroup[0] = 1;
+ Assertions.assertEquals(9L, selector.getLong());
+
+
Assertions.assertTrue(factory.getColumnCapabilities("priority").is(ValueType.LONG));
+ }
+
+ @Test
+ void testClusteringColumnShadowedByQueryVcDispatchesToGroup()
+ {
+ // A query virtual column whose output name equals a clustering column
must NOT be served as the group's clustering
+ // constant; it dispatches to the winning group (whose factory resolves
the VC), mirroring
+ // ClusteringColumnSelectorFactory. The stub group factory answers
"g0"/"g1" for any column, standing in for the
+ // computed VC value; the assertion is that we get the dispatched value,
not the "acme"/"globex" constant.
+ final RowSignature clusteringColumns =
RowSignature.builder().add("tenant", ColumnType.STRING).build();
+ final List<Object[]> clusteringValuesByGroup = List.of(new
Object[]{"acme"}, new Object[]{"globex"});
+ final VirtualColumns shadowingVc = VirtualColumns.create(
+ new ExpressionVirtualColumn("tenant", "concat('t_', m)",
ColumnType.STRING, ExprMacroTable.nil())
+ );
+ final MergingColumnSelectorFactory factory =
+ factory(clusteringColumns, clusteringValuesByGroup, shadowingVc,
groupFactory("g0"), groupFactory("g1"));
+
+ final ColumnValueSelector valueSelector =
factory.makeColumnValueSelector("tenant");
+ final DimensionSelector dimensionSelector =
factory.makeDimensionSelector(DefaultDimensionSpec.of("tenant"));
+ currentGroup[0] = 0;
+ Assertions.assertEquals("g0", valueSelector.getObject());
+ Assertions.assertEquals("g0", dimensionSelector.getObject());
+ currentGroup[0] = 1;
+ Assertions.assertEquals("g1", valueSelector.getObject());
+ Assertions.assertEquals("g1", dimensionSelector.getObject());
+ }
+
@Test
void testRowIdSupplierReflectsMintedOutputRowId()
{
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]