diff --git a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java index b61abf35f083..2e7e89556a5b 100644 --- a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java +++ b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java @@ -116,7 +116,11 @@ import org.apache.pinot.spi.config.table.FieldConfig; import org.apache.pinot.spi.config.table.QueryConfig; import org.apache.pinot.spi.config.table.RoutingConfig; +import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig; import org.apache.pinot.spi.config.table.TableConfig; +import org.apache.pinot.spi.data.DateTimeFieldSpec; +import org.apache.pinot.spi.data.DateTimeFieldSpec.TimeFormat; +import org.apache.pinot.spi.data.DateTimeFormatSpec; import org.apache.pinot.spi.data.LogicalTableConfig; import org.apache.pinot.spi.data.Schema; import org.apache.pinot.spi.env.PinotConfiguration; @@ -708,12 +712,16 @@ protected BrokerResponse doHandleRequest(long requestId, String query, SqlNodeAn BrokerRequest offlineBrokerRequest = null; BrokerRequest realtimeBrokerRequest = null; + boolean skipExpiredRecords = QueryOptionsUtils.isSkipExpiredRecords(serverPinotQuery.getQueryOptions()); if (routeInfo.isHybrid()) { // Hybrid PinotQuery offlinePinotQuery = serverPinotQuery.deepCopy(); offlinePinotQuery.getDataSource().setTableName(offlineTableName); assert timeBoundaryInfo != null; attachTimeBoundary(offlinePinotQuery, timeBoundaryInfo, true); + if (skipExpiredRecords) { + handleSkipExpiredRecords(offlineTableConfig, schema, offlinePinotQuery); + } handleExpressionOverride(offlinePinotQuery, _tableCache.getExpressionOverrideMap(offlineTableName)); handleTimestampIndexOverride(offlinePinotQuery, offlineTableConfig); // Re-optimize after attaching the time boundary filter so that filter optimizers (e.g. NumericalFilterOptimizer, @@ -724,6 +732,9 @@ protected BrokerResponse doHandleRequest(long requestId, String query, SqlNodeAn PinotQuery realtimePinotQuery = serverPinotQuery.deepCopy(); realtimePinotQuery.getDataSource().setTableName(realtimeTableName); attachTimeBoundary(realtimePinotQuery, timeBoundaryInfo, false); + if (skipExpiredRecords) { + handleSkipExpiredRecords(realtimeTableConfig, schema, realtimePinotQuery); + } handleExpressionOverride(realtimePinotQuery, _tableCache.getExpressionOverrideMap(realtimeTableName)); handleTimestampIndexOverride(realtimePinotQuery, realtimeTableConfig); _queryOptimizer.optimize(realtimePinotQuery, schema); @@ -735,6 +746,10 @@ protected BrokerResponse doHandleRequest(long requestId, String query, SqlNodeAn } else if (routeInfo.isOffline()) { // OFFLINE only setTableName(serverBrokerRequest, offlineTableName); + if (skipExpiredRecords) { + handleSkipExpiredRecords(offlineTableConfig, schema, serverPinotQuery); + _queryOptimizer.optimize(serverPinotQuery, schema); + } handleExpressionOverride(serverPinotQuery, _tableCache.getExpressionOverrideMap(offlineTableName)); handleTimestampIndexOverride(serverPinotQuery, offlineTableConfig); offlineBrokerRequest = serverBrokerRequest; @@ -744,6 +759,10 @@ protected BrokerResponse doHandleRequest(long requestId, String query, SqlNodeAn } else { // REALTIME only setTableName(serverBrokerRequest, realtimeTableName); + if (skipExpiredRecords) { + handleSkipExpiredRecords(realtimeTableConfig, schema, serverPinotQuery); + _queryOptimizer.optimize(serverPinotQuery, schema); + } handleExpressionOverride(serverPinotQuery, _tableCache.getExpressionOverrideMap(realtimeTableName)); handleTimestampIndexOverride(serverPinotQuery, realtimeTableConfig); realtimeBrokerRequest = serverBrokerRequest; @@ -1246,7 +1265,6 @@ private CompileResult compileRequest(long requestId, String query, SqlNodeAndOpt if (_enableDistinctCountBitmapOverride) { handleDistinctCountBitmapOverride(serverPinotQuery); } - Schema schema = _tableCache.getSchema(rawTableName); _queryOptimizer.optimize(serverPinotQuery, schema); @@ -1861,6 +1879,78 @@ private static void handleDistinctCountBitmapOverride(Expression expression) { } } + /// Attaches a `timeColumn >= (now - retention)` filter to the given query so records outside the table's retention + /// window are excluded, even if their segment has not yet been deleted (see issue #16689). Applied per-leg for hybrid + /// tables so the offline and realtime sides each use their own retention. No-ops (with a debug log) when the config, + /// time column, retention, or schema spec is missing/malformed, rather than failing the query. + @VisibleForTesting + static void handleSkipExpiredRecords(@Nullable TableConfig tableConfig, @Nullable Schema schema, + PinotQuery pinotQuery) { + if (tableConfig == null || schema == null) { + return; + } + String tableNameWithType = tableConfig.getTableName(); + SegmentsValidationAndRetentionConfig validationConfig = tableConfig.getValidationConfig(); + if (validationConfig == null) { + LOGGER.debug("skipExpiredRecords: no validation config for table {}, skipping retention filter", + tableNameWithType); + return; + } + + String timeColumnName = validationConfig.getTimeColumnName(); + if (timeColumnName == null) { + LOGGER.debug("skipExpiredRecords: no time column configured for table {}, skipping retention filter", + tableNameWithType); + return; + } + + Long retentionMs = getRetentionMs(validationConfig); + if (retentionMs == null) { + LOGGER.debug("skipExpiredRecords: no valid retention configured for table {}, skipping retention filter", + tableNameWithType); + return; + } + long cutOffMs = System.currentTimeMillis() - retentionMs; + + DateTimeFieldSpec timeFieldSpec = schema.getSpecForTimeColumn(timeColumnName); + if (timeFieldSpec == null) { + LOGGER.debug("skipExpiredRecords: time column {} not found in schema for table {}, skipping retention filter", + timeColumnName, tableNameWithType); + return; + } + + DateTimeFormatSpec formatSpec = timeFieldSpec.getFormatSpec(); + String cutOffValue = formatSpec.fromMillisToFormat(cutOffMs); + Expression cutOffLiteral = formatSpec.getTimeFormat() == TimeFormat.EPOCH + ? RequestUtils.getLiteralExpression(Long.parseLong(cutOffValue)) + : RequestUtils.getLiteralExpression(cutOffValue); + Expression retentionFilter = RequestUtils.getFunctionExpression(FilterKind.GREATER_THAN_OR_EQUAL.name(), + RequestUtils.getIdentifierExpression(timeColumnName), cutOffLiteral); + + Expression existingFilter = pinotQuery.getFilterExpression(); + pinotQuery.setFilterExpression(existingFilter != null + ? RequestUtils.getFunctionExpression(FilterKind.AND.name(), existingFilter, retentionFilter) + : retentionFilter); + LOGGER.debug("skipExpiredRecords: attached retention filter {} >= {} (cutOffMs={}) for table {}", timeColumnName, + cutOffValue, cutOffMs, tableNameWithType); + } + + /// Parses the retention window in millis from the validation config, or `null` when retention is not configured or is + /// malformed (in which case no retention filter is applied rather than failing the query). + @Nullable + private static Long getRetentionMs(SegmentsValidationAndRetentionConfig validationConfig) { + String retentionUnit = validationConfig.getRetentionTimeUnit(); + String retentionValue = validationConfig.getRetentionTimeValue(); + if (StringUtils.isEmpty(retentionUnit) || StringUtils.isEmpty(retentionValue)) { + return null; + } + try { + return TimeUnit.valueOf(retentionUnit.toUpperCase()).toMillis(Long.parseLong(retentionValue)); + } catch (IllegalArgumentException e) { + return null; + } + } + private HandlerContext getHandlerContext(@Nullable QueryConfig offlineTableQueryConfig, @Nullable QueryConfig realtimeTableQueryConfig) { Boolean disableGroovyOverride = null; @@ -2630,6 +2720,8 @@ private ImplicitHybridTableRouteInfo prepareBaseTableHybridRoute(BrokerRequest b TableConfig realtimeTableConfig = baseRouteInfo.getRealtimeTableConfig(); TimeBoundaryInfo timeBoundaryInfo = baseRouteInfo.getTimeBoundaryInfo(); + boolean skipExpiredRecords = + QueryOptionsUtils.isSkipExpiredRecords(baseBrokerRequest.getPinotQuery().getQueryOptions()); if (baseRouteInfo.isHybrid()) { PinotQuery basePinotQuery = baseBrokerRequest.getPinotQuery(); @@ -2638,6 +2730,9 @@ private ImplicitHybridTableRouteInfo prepareBaseTableHybridRoute(BrokerRequest b if (timeBoundaryInfo != null) { attachTimeBoundary(offlinePinotQuery, timeBoundaryInfo, true); } + if (skipExpiredRecords) { + handleSkipExpiredRecords(offlineTableConfig, schema, offlinePinotQuery); + } handleExpressionOverride(offlinePinotQuery, _tableCache.getExpressionOverrideMap(offlineTableName)); handleTimestampIndexOverride(offlinePinotQuery, offlineTableConfig); _queryOptimizer.optimize(offlinePinotQuery, schema); @@ -2648,6 +2743,9 @@ private ImplicitHybridTableRouteInfo prepareBaseTableHybridRoute(BrokerRequest b if (timeBoundaryInfo != null) { attachTimeBoundary(realtimePinotQuery, timeBoundaryInfo, false); } + if (skipExpiredRecords) { + handleSkipExpiredRecords(realtimeTableConfig, schema, realtimePinotQuery); + } handleExpressionOverride(realtimePinotQuery, _tableCache.getExpressionOverrideMap(realtimeTableName)); handleTimestampIndexOverride(realtimePinotQuery, realtimeTableConfig); _queryOptimizer.optimize(realtimePinotQuery, schema); @@ -2657,12 +2755,20 @@ private ImplicitHybridTableRouteInfo prepareBaseTableHybridRoute(BrokerRequest b hybridRoute.setRealtimeBrokerRequest(realtimeBrokerRequest); } else if (baseRouteInfo.isOffline()) { setTableName(baseBrokerRequest, offlineTableName); + if (skipExpiredRecords) { + handleSkipExpiredRecords(offlineTableConfig, schema, baseBrokerRequest.getPinotQuery()); + _queryOptimizer.optimize(baseBrokerRequest.getPinotQuery(), schema); + } handleExpressionOverride(baseBrokerRequest.getPinotQuery(), _tableCache.getExpressionOverrideMap(offlineTableName)); handleTimestampIndexOverride(baseBrokerRequest.getPinotQuery(), offlineTableConfig); hybridRoute.setOfflineBrokerRequest(baseBrokerRequest); } else { setTableName(baseBrokerRequest, realtimeTableName); + if (skipExpiredRecords) { + handleSkipExpiredRecords(realtimeTableConfig, schema, baseBrokerRequest.getPinotQuery()); + _queryOptimizer.optimize(baseBrokerRequest.getPinotQuery(), schema); + } handleExpressionOverride(baseBrokerRequest.getPinotQuery(), _tableCache.getExpressionOverrideMap(realtimeTableName)); handleTimestampIndexOverride(baseBrokerRequest.getPinotQuery(), realtimeTableConfig); diff --git a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SkipExpiredRecordsTest.java b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SkipExpiredRecordsTest.java new file mode 100644 index 000000000000..a8b2f5ebaa5f --- /dev/null +++ b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SkipExpiredRecordsTest.java @@ -0,0 +1,352 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pinot.broker.requesthandler; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.helix.model.InstanceConfig; +import org.apache.pinot.broker.broker.AllowAllAccessControlFactory; +import org.apache.pinot.broker.queryquota.QueryQuotaManager; +import org.apache.pinot.broker.routing.manager.BrokerRoutingManager; +import org.apache.pinot.common.config.provider.TableCache; +import org.apache.pinot.common.metrics.BrokerMetrics; +import org.apache.pinot.common.request.BrokerRequest; +import org.apache.pinot.common.request.Expression; +import org.apache.pinot.common.request.Function; +import org.apache.pinot.common.response.broker.BrokerResponseNative; +import org.apache.pinot.core.routing.RoutingTable; +import org.apache.pinot.core.routing.SegmentsToQuery; +import org.apache.pinot.core.routing.TableRouteInfo; +import org.apache.pinot.core.transport.ServerInstance; +import org.apache.pinot.spi.accounting.ThreadAccountantUtils; +import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig; +import org.apache.pinot.spi.config.table.TableConfig; +import org.apache.pinot.spi.config.table.TenantConfig; +import org.apache.pinot.spi.data.FieldSpec.DataType; +import org.apache.pinot.spi.data.Schema; +import org.apache.pinot.spi.env.PinotConfiguration; +import org.apache.pinot.spi.eventlistener.query.BrokerQueryEventListenerFactory; +import org.apache.pinot.spi.trace.RequestContext; +import org.apache.pinot.spi.utils.CommonConstants.Query.Range; +import org.apache.pinot.sql.FilterKind; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/// Tests for the {@code skipExpiredRecords} query option in {@link BaseBrokerRequestHandler}. +///
When the option is set the broker injects a lower-bound time filter derived from the table's
+/// retention config before the query reaches any server. Tests verify the injected filter's
+/// structure and value, and the skip conditions (option absent, no retention config, missing time
+/// column in schema, ORDER BY wrapper, existing WHERE clause).
+public class SkipExpiredRecordsTest {
+
+ private static final String RAW_TABLE = "myTable";
+ private static final String OFFLINE_TABLE = "myTable_OFFLINE";
+ private static final String TIME_COLUMN = "eventTime";
+ private static final int RETENTION_DAYS = 30;
+ private static final long RETENTION_MS = RETENTION_DAYS * 24L * 3600 * 1000;
+
+ /// Allowed delta between expected and actual cutoff to absorb test execution time.
+ private static final long CUTOFF_TOLERANCE_MS = 5_000L;
+
+ @Test
+ public void testOptionNotSetNoFilterInjected()
+ throws Exception {
+ AtomicReference A standalone {@code >=} injected by the broker stays as {@code GREATER_THAN_OR_EQUAL} in
+ /// the PinotQuery; Pinot only rewrites to a {@code RANGE} when merging two-sided bounds (e.g.
+ /// {@code BETWEEN}, a time-boundary merge). Both forms are handled here.
+ private static long extractLowerBound(Expression filter, String timeColumn) {
+ Assert.assertNotNull(filter, "filter must not be null");
+ Function func = filter.getFunctionCall();
+ Assert.assertNotNull(func, "filter must be a function call");
+
+ if (FilterKind.AND.name().equals(func.getOperator())) {
+ for (Expression operand : func.getOperands()) {
+ Function fn = operand.getFunctionCall();
+ if (fn != null && !fn.getOperands().isEmpty()
+ && timeColumn.equals(fn.getOperands().get(0).getIdentifier().getName())) {
+ if (FilterKind.RANGE.name().equals(fn.getOperator())) {
+ return parseLowerBoundFromRange(fn.getOperands().get(1).getLiteral().getStringValue());
+ }
+ if (FilterKind.GREATER_THAN_OR_EQUAL.name().equals(fn.getOperator())) {
+ return extractLongLiteral(fn.getOperands().get(1));
+ }
+ }
+ }
+ throw new AssertionError(
+ "No RANGE or GREATER_THAN_OR_EQUAL predicate found on " + timeColumn + " inside AND");
+ }
+
+ Assert.assertEquals(func.getOperands().get(0).getIdentifier().getName(), timeColumn,
+ "Predicate must be on " + timeColumn);
+ if (FilterKind.RANGE.name().equals(func.getOperator())) {
+ return parseLowerBoundFromRange(func.getOperands().get(1).getLiteral().getStringValue());
+ }
+ Assert.assertEquals(func.getOperator(), FilterKind.GREATER_THAN_OR_EQUAL.name(),
+ "Expected GREATER_THAN_OR_EQUAL or RANGE but found: " + func.getOperator());
+ return extractLongLiteral(func.getOperands().get(1));
+ }
+
+ /// Parses the lower bound from a RANGE string of the form {@code '[value\0*)'},
+ /// using {@link Range#DELIMITER} as the separator between lower and upper bound.
+ private static long parseLowerBoundFromRange(String rangeStr) {
+ int delimIdx = rangeStr.indexOf(Range.DELIMITER);
+ Assert.assertTrue(delimIdx > 1, "Range string missing delimiter: " + rangeStr);
+ return Long.parseLong(rangeStr.substring(1, delimIdx).trim());
+ }
+
+ /// Extracts a long value from a numeric literal expression.
+ private static long extractLongLiteral(Expression expr) {
+ Assert.assertNotNull(expr.getLiteral(), "Expected a literal expression");
+ if (expr.getLiteral().isSetLongValue()) {
+ return expr.getLiteral().getLongValue();
+ }
+ // Numeric literals compiled from string (e.g. createExactNumeric) may land as stringValue
+ return Long.parseLong(expr.getLiteral().getStringValue());
+ }
+}
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
index a48c88cd1468..a7f0756adddf 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
@@ -331,6 +331,10 @@ public static boolean isSkipUpsertDelete(Map