FrankChen021 commented on code in PR #20452:
URL: https://github.com/apache/druid/pull/20452#discussion_r4144657295
##########
sql/src/main/java/org/apache/druid/sql/calcite/planner/ProjectionSpecTranslator.java:
##########
@@ -288,6 +306,128 @@ private static void collectDependencies(
}
}
+ /**
+ * Lift a GROUP BY body into the rollup layout's metadata. The body plans
like an aggregate projection body, and its
+ * outputs must name every declared column in declared order: an output
backed by a grouping dimension must reference
+ * the declared column itself (or {@code TIME_FLOOR(__time, <period>) AS
__time}, which becomes the query
+ * granularity carrier) and an output backed by an aggregate becomes the
aggregator filling the declared metric
+ * column it is named for.
+ */
+ private static RollupTableBaseTableMetadata liftRollupBaseTable(
+ final String tableName,
+ final List<ColumnSpec> columns,
+ final DruidQuery druidQuery
+ )
+ {
+ final PlannedAggregation planned =
plannedAggregation(BASE_PROJECTION_NAME, tableName, druidQuery);
+ if (planned.virtualColumnsAndFilter.filter(BASE_PROJECTION_NAME) != null) {
+ throw invalid(BASE_PROJECTION_NAME, "its body filters. The base table
stores every ingested row");
+ }
+
+ final List<String> outputNames =
druidQuery.getOutputRowType().getFieldNames();
+ if (outputNames.size() != columns.size()) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ StringUtils.format(
+ "it selects %d column(s) but the table declares %d. The body
lists the columns in the order segments"
+ + " store them, so it must name every declared column",
+ outputNames.size(),
+ columns.size()
+ )
+ );
+ }
+
+ final Map<String, DimensionSpec> dimensionsByInternalName = new
HashMap<>();
+ for (DimensionSpec dimension : planned.dimensions) {
+ dimensionsByInternalName.put(dimension.getOutputName(), dimension);
+ }
+ final Map<String, AggregatorFactory> aggregatorsByInternalName = new
HashMap<>();
+ for (AggregatorFactory aggregator : planned.aggregators) {
+ aggregatorsByInternalName.put(aggregator.getName(), aggregator);
+ }
+
+ final RowSignature sources = druidQuery.getOutputRowSignature();
+ final List<VirtualColumn> carrier = new ArrayList<>(1);
+ final List<AggregatorFactory> aggregators = new
ArrayList<>(planned.aggregators.size());
+ for (int i = 0; i < columns.size(); i++) {
+ final String declared = columns.get(i).name();
+ if (!declared.equals(outputNames.get(i))) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ StringUtils.format(
+ "its column %d is [%s] but the table declares [%s] there. The
body lists the columns in the order"
+ + " segments store them",
+ i + 1,
+ outputNames.get(i),
+ declared
+ )
+ );
+ }
+ final String source = sources.getColumnName(i);
+ final AggregatorFactory aggregator =
aggregatorsByInternalName.get(source);
+ if (aggregator != null) {
+ aggregators.add(aggregator.withName(declared));
Review Comment:
[P1] Use combining aggregators for rollup reaggregation
**Finding:** This keeps the planner's raw-ingestion factory instead of the
combining factory. For an accepted body such as COUNT(*) AS rows, the new
rollup base-table spec is fed directly into MSQ compaction, whose GroupBy uses
dataSchema.getAggregators() on already-stored rows, so each collapsed row
contributes 1 rather than its stored count. The same issue affects sketch-build
aggregators and any aggregator whose input field differs from its output,
silently changing results on compaction or re-rollup.
**Suggestion:** Make the compaction/reaggregation schema use each
aggregator's combining form, or reject and normalize rollup definitions whose
factory is not self-combining; add a COUNT/sketch compaction round-trip test.
##########
processing/src/main/java/org/apache/druid/data/input/impl/RollupTableProjectionSpec.java:
##########
@@ -0,0 +1,333 @@
+/*
+ * 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.data.input.impl;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.java.util.common.granularity.Granularity;
+import org.apache.druid.query.OrderBy;
+import org.apache.druid.query.aggregation.AggregatorFactory;
+import org.apache.druid.segment.VirtualColumn;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnHolder;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * {@link BaseTableProjectionSpec} for a rollup table: the plain {@link
TableProjectionSpec} shape plus
+ * {@link #aggregators}. The declared {@link #groupingColumns} are the
grouping columns, rows with identical grouping values
+ * (after query-granularity flooring of {@code __time}) are aggregated into
one at ingest time, with each aggregator
+ * producing a metric column after the grouping columns. As in the plain
layout, the time position is an explicit
+ * positional entry in {@link #groupingColumns} named {@code __time}, declared
column order is the segment storage and sort
+ * order, and query granularity rides as the {@link
Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} carrier virtual
+ * column (the only virtual column accepted).
+ * <p>
+ * Operator-facing counterpart of the segment metadata-side
+ * {@link org.apache.druid.segment.projections.RollupTableProjectionSchema}.
+ * <p>
+ * Aggregators may be empty: a rollup table without metrics collapses rows
with identical grouping values, mirroring
+ * the legacy {@code rollup=true} granularity spec without a {@code
metricsSpec}.
+ */
+@JsonTypeName(RollupTableProjectionSpec.TYPE_NAME)
+public final class RollupTableProjectionSpec implements BaseTableProjectionSpec
+{
+ public static final String TYPE_NAME = "rollupTable";
+
+ private final VirtualColumns virtualColumns;
+ private final List<DimensionSchema> groupingColumns;
+ private final AggregatorFactory[] aggregators;
+ private final DimensionsSpec dimensionsSpec;
+ private final List<OrderBy> ordering;
+
+ public static Builder builder()
+ {
+ return new Builder();
+ }
+
+ @JsonCreator
+ public RollupTableProjectionSpec(
+ @JsonProperty("virtualColumns") @Nullable VirtualColumns virtualColumns,
+ @JsonProperty("groupingColumns") List<DimensionSchema> groupingColumns,
+ @JsonProperty("aggregators") @Nullable AggregatorFactory[] aggregators
+ )
+ {
+ BaseTableProjectionSpec.validateDeclaredColumns(groupingColumns,
"groupingColumns", TYPE_NAME);
+ this.virtualColumns = virtualColumns == null ? VirtualColumns.EMPTY :
virtualColumns;
+
BaseTableProjectionSpec.validateGranularityOnlyVirtualColumns(this.virtualColumns,
TYPE_NAME);
+ this.groupingColumns = Collections.unmodifiableList(new
ArrayList<>(groupingColumns));
+ this.aggregators = aggregators == null ? new AggregatorFactory[0] :
aggregators;
+ validateAggregators(this.groupingColumns, this.aggregators);
+ this.dimensionsSpec = DimensionsSpec.builder()
+ .setDimensions(this.groupingColumns)
+ .setForceSegmentSortByTime(false)
+ .build();
+ this.ordering =
BaseTableProjectionSpec.declaredOrderAscending(this.groupingColumns);
+ }
+
+ @Override
+ @JsonProperty
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+ public VirtualColumns getVirtualColumns()
+ {
+ return virtualColumns;
+ }
+
+ /**
+ * The full, ordered list of grouping columns in segment order, with the
explicit {@code __time} (or
+ * query-granularity) marker at its declared position.
+ */
+ @JsonProperty("groupingColumns")
+ public List<DimensionSchema> getGroupingColumns()
+ {
+ return groupingColumns;
+ }
+
+ /**
+ * The aggregators computed over the rows collapsed into each grouping
tuple, each producing a metric column stored
+ * after the grouping columns.
+ */
+ @Override
+ @JsonProperty("aggregators")
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public AggregatorFactory[] getMetrics()
+ {
+ return aggregators;
+ }
+
+ @Override
+ @JsonIgnore
+ public List<OrderBy> getOrdering()
+ {
+ return ordering;
+ }
+
+ @Override
+ @JsonIgnore
+ public DimensionsSpec getDimensionsSpec()
+ {
+ return dimensionsSpec;
+ }
+
+ @Override
+ public boolean isRollup()
+ {
+ return true;
+ }
+
+ /**
+ * Returns a copy of this spec with a new {@code queryGranularity},
expressed as a
+ * {@link Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} virtual column
added to {@link #getVirtualColumns()}. A
+ * {@code null} or {@code NONE} granularity is a no-op (no flooring), so
this returns {@code this} unchanged.
+ * <p>
+ * {@code ALL} is rejected: it has no granularity virtual column
representation, so accepting it would silently
+ * degrade to {@code NONE} when the spec is read back.
+ * <p>
+ * Idempotent: if the spec already declares a query-granularity virtual
column, that one is authoritative and this is
+ * a no-op. (The compaction path attaches the virtual column up front; the
MSQ generation path then calls this again
+ * with the query-derived granularity, which must not double-add.)
+ */
+ @Override
+ public RollupTableProjectionSpec withQueryGranularity(@Nullable Granularity
queryGranularity)
+ {
+ if (Granularities.ALL.equals(queryGranularity)) {
+ throw InvalidInput.exception(
+ "Query granularity[ALL] is not supported for [%s] base tables",
+ TYPE_NAME
+ );
+ }
+ if (queryGranularity == null
+ || Granularities.NONE.equals(queryGranularity)
+ ||
virtualColumns.getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
!= null) {
+ return this;
+ }
+ BaseTableProjectionSpec.validateQueryGranularity(queryGranularity,
TYPE_NAME);
+ final VirtualColumn granularityVirtualColumn =
+ Granularities.toVirtualColumn(queryGranularity,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
+ final List<VirtualColumn> merged = new
ArrayList<>(Arrays.asList(virtualColumns.getVirtualColumns()));
+ merged.add(granularityVirtualColumn);
+ return new RollupTableProjectionSpec(VirtualColumns.create(merged),
groupingColumns, aggregators);
+ }
+
+ /**
+ * Compares rollup-spec state for compaction up-to-date checks: query
granularity is compared separately (via its
+ * carrier virtual column), so it is stripped from both sides; everything
else (the grouping columns, the
+ * aggregators, and any other virtual columns) must match. A spec of a
different type is never equivalent.
+ */
+ @Override
+ public boolean hasEqualCompactionState(BaseTableProjectionSpec other)
+ {
+ if (!(other instanceof RollupTableProjectionSpec)) {
+ return false;
+ }
+ return withoutQueryGranularity().equals(((RollupTableProjectionSpec)
other).withoutQueryGranularity());
+ }
+
+ @Override
+ public RollupTableProjectionSpec withAdditionalColumns(@Nullable
List<DimensionSchema> additionalColumns)
+ {
+ if (additionalColumns == null || additionalColumns.isEmpty()) {
+ return this;
+ }
+ final List<DimensionSchema> revised = new
ArrayList<>(groupingColumns.size() + additionalColumns.size());
+ revised.addAll(groupingColumns);
+ for (DimensionSchema additionalColumn : additionalColumns) {
+ if (ColumnHolder.TIME_COLUMN_NAME.equals(additionalColumn.getName())) {
+ throw InvalidInput.exception(
+ "Cannot append column [%s] to a [%s] base table; it must be
declared at its position in the grouping column list",
+ ColumnHolder.TIME_COLUMN_NAME,
+ TYPE_NAME
+ );
+ }
+ revised.add(additionalColumn);
+ }
+ // Duplicates of a declared column or aggregator, and a column named for
the query-granularity carrier, are
+ // rejected by the constructor's validation.
+ return new RollupTableProjectionSpec(virtualColumns, revised, aggregators);
+ }
+
+ /**
+ * Returns a copy of this spec with the {@link
Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} virtual column removed,
+ * the inverse of {@link #withQueryGranularity(Granularity)}. If no such
virtual column is present this returns
+ * {@code this} unchanged. Used to compare schema independently of query
granularity in {@link #hasEqualCompactionState}.
+ */
+ private RollupTableProjectionSpec withoutQueryGranularity()
+ {
+ if
(virtualColumns.getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
== null) {
+ return this;
+ }
+ final List<VirtualColumn> remaining = new ArrayList<>();
+ for (VirtualColumn vc : virtualColumns.getVirtualColumns()) {
+ if
(!Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME.equals(vc.getOutputName())) {
+ remaining.add(vc);
+ }
+ }
+ return new RollupTableProjectionSpec(VirtualColumns.create(remaining),
groupingColumns, aggregators);
+ }
+
+ /**
+ * Aggregators produce the metric columns, so their names share a namespace
with the grouping columns: a collision
+ * means one column with two definitions.
+ */
+ private static void validateAggregators(List<DimensionSchema>
groupingColumns, AggregatorFactory[] aggregators)
+ {
+ final Set<String> names = new HashSet<>();
+ for (DimensionSchema column : groupingColumns) {
+ names.add(column.getName());
+ }
+ for (AggregatorFactory aggregator : aggregators) {
Review Comment:
[P2] Reject metric names that collide with the granularity carrier
**Finding:** The shared declared-column validation is applied only to
groupingColumns, while this loop accepts an aggregator named
__virtualGranularity. Once query granularity is present, the spec contains both
that metric and the carrier virtual column;
IncrementalIndexColumnSelectorFactory resolves virtual columns before metrics,
so a metric selector is shadowed by the time-floor expression, while catalog
INSERTs reject the target as computed. This lets an invalid and potentially
corrupting rollup schema pass validation.
**Suggestion:** Reject aggregator names equal to
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME when constructing the rollup
spec, with catalog and ingestion regression coverage.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]