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

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


The following commit(s) were added to refs/heads/master by this push:
     new 22470be7320 Add a cluster config to rewrite exact DISTINCTCOUNT and 
PERCENTILE into approximations (#19510)
22470be7320 is described below

commit 22470be732070645e59611cc73fd4a6948bd02a3
Author: Yash Mayya <[email protected]>
AuthorDate: Wed Sep 16 18:21:16 2026 -0400

    Add a cluster config to rewrite exact DISTINCTCOUNT and PERCENTILE into 
approximations (#19510)
---
 .../broker/broker/helix/BaseBrokerStarter.java     |  10 +
 .../apache/pinot/broker/querylog/QueryLogger.java  |   6 +
 .../ApproximateFunctionOverrideProvider.java       | 161 ++++++++++++++++
 .../requesthandler/BaseBrokerRequestHandler.java   |  10 +
 .../BaseSingleStageBrokerRequestHandler.java       |  98 +++++++---
 .../MultiStageBrokerRequestHandler.java            |  18 ++
 .../pinot/broker/querylog/QueryLoggerTest.java     |   1 +
 .../ApproximateFunctionOverrideProviderTest.java   | 134 ++++++++++++++
 .../broker/requesthandler/QueryOverrideTest.java   |  56 +++++-
 .../apache/pinot/common/metrics/BrokerMeter.java   |   5 +
 .../pinot/common/response/BrokerResponse.java      |  13 ++
 .../response/broker/BrokerResponseNative.java      |  19 +-
 .../response/broker/BrokerResponseNativeV2.java    |  18 +-
 .../response/broker/CursorResponseNative.java      |   5 +-
 .../common/utils/config/QueryOptionsUtils.java     |   8 +
 ...istinctCountSmartSketchAggregationFunction.java |  77 ++++++--
 .../DistinctCountSmartHLLAggregationFunction.java  |  19 +-
 ...stinctCountSmartHLLPlusAggregationFunction.java |  19 +-
 .../DistinctCountSmartULLAggregationFunction.java  |  19 +-
 .../PercentileSmartTDigestAggregationFunction.java |  69 +++++--
 ...stinctCountSmartHLLAggregationFunctionTest.java | 126 +++++++++++++
 ...centileSmartTDigestAggregationFunctionTest.java |  76 ++++++++
 ...ApproximateFunctionOverrideIntegrationTest.java | 202 +++++++++++++++++++++
 .../PinotApproximateAggregateRewriteRule.java      | 191 +++++++++++++++++++
 .../calcite/rel/rules/PinotQueryRuleSets.java      |   4 +
 .../org/apache/pinot/query/QueryEnvironment.java   |  19 ++
 .../apache/pinot/query/context/PlannerContext.java |  14 ++
 .../query/ApproximateAggregateRewriteTest.java     | 162 +++++++++++++++++
 .../pinot/segment/spi/AggregationFunctionType.java |  23 ++-
 .../apache/pinot/spi/utils/CommonConstants.java    |  35 +++-
 30 files changed, 1527 insertions(+), 90 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
index 826c732d4d5..ebdad1eaf06 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
@@ -651,6 +651,16 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
     _brokerRequestHandler =
         new BrokerRequestHandlerDelegate(singleStageBrokerRequestHandler, 
multiStageBrokerRequestHandler,
             timeSeriesRequestHandler, _responseStore);
+    // Lets the approximate-function rewrite defaults be changed from the 
cluster config without a broker restart.
+    // Registering here is a no-op in itself, because the cluster config 
handler is not wired to Helix yet, so the
+    // snapshot it hands the listener is empty. The real values arrive from 
that handler's first Helix callback,
+    // which is set up below and still runs before the broker starts serving 
traffic.
+    _clusterConfigChangeHandler.registerClusterConfigChangeListener(
+        
singleStageBrokerRequestHandler.getApproximateFunctionOverrideProvider());
+    if (multiStageBrokerRequestHandler != null) {
+      _clusterConfigChangeHandler.registerClusterConfigChangeListener(
+          
multiStageBrokerRequestHandler.getApproximateFunctionOverrideProvider());
+    }
     _brokerRequestHandler.start();
 
     String controllerUrl = _brokerConf.getProperty(Broker.CONTROLLER_URL);
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java 
b/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java
index 6276ddeb265..e4be84010fa 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java
@@ -436,6 +436,12 @@ public class QueryLogger {
         builder.append(params._response.getRLSFiltersApplied());
       }
     },
+    APPROXIMATE_FUNCTION_APPLIED("approximateFunctionApplied") {
+      @Override
+      void doFormat(StringBuilder builder, QueryLogger logger, QueryLogParams 
params) {
+        builder.append(params._response.isApproximateFunctionApplied());
+      }
+    },
     WORKLOAD_NAME("workloadName") {
       @Override
       void doFormat(StringBuilder builder, QueryLogger logger, QueryLogParams 
params) {
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/ApproximateFunctionOverrideProvider.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/ApproximateFunctionOverrideProvider.java
new file mode 100644
index 00000000000..fa94214e316
--- /dev/null
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/ApproximateFunctionOverrideProvider.java
@@ -0,0 +1,161 @@
+/**
+ * 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.Set;
+import java.util.function.Consumer;
+import javax.annotation.Nullable;
+import org.apache.pinot.common.request.Literal;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import 
org.apache.pinot.core.query.aggregation.function.DistinctCountSmartHLLAggregationFunction;
+import 
org.apache.pinot.core.query.aggregation.function.PercentileSmartTDigestAggregationFunction;
+import org.apache.pinot.spi.config.provider.PinotClusterConfigChangeListener;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Resolves the defaults for the approximate function rewrite, from the 
broker conf and from the Helix cluster config.
+/// The cluster config wins where it sets a key, and it is applied live: 
registering this as a
+/// [PinotClusterConfigChangeListener] means a change takes effect on the next 
query without a broker restart.
+///
+/// Thread-safe. [#getSettings()] hands out one immutable snapshot, so a query 
that reads it once cannot see a config
+/// change half applied.
+public class ApproximateFunctionOverrideProvider implements 
PinotClusterConfigChangeListener {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(ApproximateFunctionOverrideProvider.class);
+
+  private static final ExpressionContext PROBE_COLUMN = 
ExpressionContext.forIdentifier("__probe__");
+  private static final ExpressionContext PROBE_PERCENTILE = 
ExpressionContext.forLiteral(Literal.doubleValue(50.0));
+
+  private final Settings _brokerConfSettings;
+  private volatile Settings _settings;
+
+  public ApproximateFunctionOverrideProvider(PinotConfiguration config) {
+    _brokerConfSettings = new Settings(
+        config.getProperty(Broker.USE_APPROXIMATE_FUNCTION, 
Broker.DEFAULT_USE_APPROXIMATE_FUNCTION),
+        
validated(config.getProperty(Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS,
+            Broker.DEFAULT_APPROXIMATE_FUNCTION_PARAMS), 
Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS,
+            Broker.DEFAULT_APPROXIMATE_FUNCTION_PARAMS, 
ApproximateFunctionOverrideProvider::probeDistinctCount),
+        
validated(config.getProperty(Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS,
+            Broker.DEFAULT_APPROXIMATE_FUNCTION_PARAMS), 
Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS,
+            Broker.DEFAULT_APPROXIMATE_FUNCTION_PARAMS, 
ApproximateFunctionOverrideProvider::probePercentile));
+    _settings = _brokerConfSettings;
+  }
+
+  @Override
+  public void onChange(Set<String> changedConfigs, Map<String, String> 
clusterConfigs) {
+    if (!changedConfigs.contains(Broker.USE_APPROXIMATE_FUNCTION)
+        && 
!changedConfigs.contains(Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS)
+        && 
!changedConfigs.contains(Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS)) {
+      return;
+    }
+    // A key the cluster config does not set, or that was removed from it, 
falls back to the broker conf value. The
+    // fallback is the broker conf rather than the last good live value, so a 
broker that restarts resolves the same.
+    Settings updated = new Settings(
+        validatedEnabled(clusterConfigs.get(Broker.USE_APPROXIMATE_FUNCTION)),
+        
validated(clusterConfigs.get(Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS),
+            Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS, 
_brokerConfSettings._distinctCountParams,
+            ApproximateFunctionOverrideProvider::probeDistinctCount),
+        
validated(clusterConfigs.get(Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS),
+            Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, 
_brokerConfSettings._percentileParams,
+            ApproximateFunctionOverrideProvider::probePercentile));
+    _settings = updated;
+    LOGGER.info("Updated approximate function override: enabled: {}, 
distinctCountParams: '{}', percentileParams: '{}'",
+        updated._enabled, updated._distinctCountParams, 
updated._percentileParams);
+  }
+
+  /// Returns the current defaults. Read it once per query, so that the 
enabled flag and the parameters a query uses
+  /// come from the same version of the config.
+  public Settings getSettings() {
+    return _settings;
+  }
+
+  /// Anything that is not true or false keeps the broker conf value. Silently 
reading a typo as `false` would turn
+  /// the guard rail off, which is the worse direction to fail in.
+  private boolean validatedEnabled(@Nullable String enabled) {
+    if (enabled == null) {
+      return _brokerConfSettings._enabled;
+    }
+    if ("true".equalsIgnoreCase(enabled.trim())) {
+      return true;
+    }
+    if ("false".equalsIgnoreCase(enabled.trim())) {
+      return false;
+    }
+    LOGGER.error("Ignoring invalid value '{}' for {}, falling back to '{}'", 
enabled,
+        Broker.USE_APPROXIMATE_FUNCTION, _brokerConfSettings._enabled);
+    return _brokerConfSettings._enabled;
+  }
+
+  /// Parses the value with the aggregation function itself, so an unknown key 
or a non-numeric value is caught here
+  /// instead of failing every rewritten query on the servers. A value the 
parser accepts but the sketch rejects, such
+  /// as an out-of-range `log2m`, still fails on the servers.
+  ///
+  /// @param params the configured value, or `null` if unset
+  /// @param fallback the value to use if `params` is unset or invalid
+  private static String validated(@Nullable String params, String key, String 
fallback, Consumer<String> probe) {
+    if (params == null) {
+      return fallback;
+    }
+    if (params.isEmpty()) {
+      return params;
+    }
+    try {
+      probe.accept(params);
+      return params;
+    } catch (Exception e) {
+      LOGGER.error("Ignoring invalid value '{}' for {}, falling back to '{}'", 
params, key, fallback, e);
+      return fallback;
+    }
+  }
+
+  private static void probeDistinctCount(String params) {
+    new DistinctCountSmartHLLAggregationFunction(
+        List.of(PROBE_COLUMN, 
ExpressionContext.forLiteral(Literal.stringValue(params))), false);
+  }
+
+  private static void probePercentile(String params) {
+    new PercentileSmartTDigestAggregationFunction(
+        List.of(PROBE_COLUMN, PROBE_PERCENTILE, 
ExpressionContext.forLiteral(Literal.stringValue(params))), false);
+  }
+
+  /// The resolved defaults. Fields are read directly by the request handlers 
in this package.
+  public static class Settings {
+    final boolean _enabled;
+    final String _distinctCountParams;
+    final String _percentileParams;
+
+    Settings(boolean enabled, String distinctCountParams, String 
percentileParams) {
+      _enabled = enabled;
+      _distinctCountParams = distinctCountParams;
+      _percentileParams = percentileParams;
+    }
+
+    /// Applies the precedence query option > table config > cluster config > 
broker conf. `null` means unset.
+    boolean isEnabled(@Nullable Boolean queryOptionOverride, @Nullable Boolean 
tableConfigOverride) {
+      if (queryOptionOverride != null) {
+        return queryOptionOverride;
+      }
+      return tableConfigOverride != null ? tableConfigOverride : _enabled;
+    }
+  }
+}
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseBrokerRequestHandler.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseBrokerRequestHandler.java
index 605fcda0ce8..742cf545d02 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseBrokerRequestHandler.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseBrokerRequestHandler.java
@@ -106,6 +106,9 @@ public abstract class BaseBrokerRequestHandler implements 
BrokerRequestHandler {
   protected final Map<Long, String> _queriesById;
   /// Maps broker-generated query id to client-provided query id.
   protected final Map<Long, String> _clientQueryIds;
+  /// Resolves the approximate-function rewrite defaults, live from the Helix 
cluster config. Registered as a
+  /// listener by the broker starter through 
[#getApproximateFunctionOverrideProvider()].
+  protected final ApproximateFunctionOverrideProvider 
_approximateFunctionOverrideProvider;
 
   public BaseBrokerRequestHandler(PinotConfiguration config, String brokerId,
       BrokerRequestIdGenerator requestIdGenerator, RoutingManager 
routingManager,
@@ -120,6 +123,7 @@ public abstract class BaseBrokerRequestHandler implements 
BrokerRequestHandler {
     _tableCache = tableCache;
     _threadAccountant = threadAccountant;
     _multiClusterRoutingContext = multiClusterRoutingContext;
+    _approximateFunctionOverrideProvider = new 
ApproximateFunctionOverrideProvider(config);
     _brokerMetrics = BrokerMetrics.get();
     _brokerQueryEventListener = 
BrokerQueryEventListenerFactory.getBrokerQueryEventListener();
     _trackedHeaders = BrokerQueryEventListenerFactory.getTrackedHeaders();
@@ -147,6 +151,12 @@ public abstract class BaseBrokerRequestHandler implements 
BrokerRequestHandler {
     }
   }
 
+  /// Returns the provider that resolves the approximate-function rewrite 
defaults. The broker starter registers it
+  /// with the cluster config change handler so that the defaults reload 
without a restart.
+  public ApproximateFunctionOverrideProvider 
getApproximateFunctionOverrideProvider() {
+    return _approximateFunctionOverrideProvider;
+  }
+
   @Override
   public BrokerResponse handleRequest(JsonNode request, @Nullable 
SqlNodeAndOptions sqlNodeAndOptions,
       @Nullable RequesterIdentity requesterIdentity, RequestContext 
requestContext, @Nullable HttpHeaders httpHeaders)
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 b61abf35f08..a099861f951 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
@@ -160,7 +160,6 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
   @Nullable
   protected final MaterializedViewHandler _materializedViewHandler;
   protected final boolean _disableGroovy;
-  protected final boolean _useApproximateFunction;
   protected final int _defaultHllLog2m;
   protected final boolean _enableQueryLimitOverride;
   protected final boolean _enableDistinctCountBitmapOverride;
@@ -200,7 +199,6 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
     _materializedViewHandler = materializedViewHandler;
     _serverAdminAuthProvider = AuthProviderUtils.extractAuthProvider(config, 
Broker.SERVER_ADMIN_AUTH_PREFIX);
     _disableGroovy = _config.getProperty(Broker.DISABLE_GROOVY, 
Broker.DEFAULT_DISABLE_GROOVY);
-    _useApproximateFunction = 
_config.getProperty(Broker.USE_APPROXIMATE_FUNCTION, false);
     _defaultHllLog2m = 
_config.getProperty(CommonConstants.Helix.DEFAULT_HYPERLOGLOG_LOG2M_KEY,
         CommonConstants.Helix.DEFAULT_HYPERLOGLOG_LOG2M);
     _enableQueryLimitOverride = 
_config.getProperty(Broker.CONFIG_OF_ENABLE_QUERY_LIMIT_OVERRIDE, false);
@@ -673,10 +671,16 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
     QueryConfig realtimeTableQueryConfig = 
routeInfo.getRealtimeTableQueryConfig();
     TimeBoundaryInfo timeBoundaryInfo = routeInfo.getTimeBoundaryInfo();
 
-    HandlerContext handlerContext = getHandlerContext(offlineTableQueryConfig, 
realtimeTableQueryConfig);
+    HandlerContext handlerContext =
+        getHandlerContext(offlineTableQueryConfig, realtimeTableQueryConfig, 
pinotQuery.getQueryOptions());
     validateGroovyScript(serverPinotQuery, handlerContext._disableGroovy);
+    boolean approximateFunctionApplied = false;
     if (handlerContext._useApproximateFunction) {
-      handleApproximateFunctionOverride(serverPinotQuery);
+      approximateFunctionApplied = 
handleApproximateFunctionOverride(serverPinotQuery,
+          handlerContext._distinctCountParams, 
handlerContext._percentileParams);
+      if (approximateFunctionApplied) {
+        _brokerMetrics.addMeteredTableValue(rawTableName, 
BrokerMeter.APPROXIMATE_FUNCTION_OVERRIDES, 1);
+      }
     }
 
     // Validate the request
@@ -1082,6 +1086,7 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
     }
 
     brokerResponse.setRLSFiltersApplied(rlsFiltersApplied.get());
+    brokerResponse.setApproximateFunctionApplied(approximateFunctionApplied);
 
     // Record per-server stats on the SSE BrokerResponse so downstream 
consumers can read it.
     brokerResponse.setServerStats(serverStats.getServerStats());
@@ -1862,7 +1867,7 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
   }
 
   private HandlerContext getHandlerContext(@Nullable QueryConfig 
offlineTableQueryConfig,
-      @Nullable QueryConfig realtimeTableQueryConfig) {
+      @Nullable QueryConfig realtimeTableQueryConfig, @Nullable Map<String, 
String> queryOptions) {
     Boolean disableGroovyOverride = null;
     Boolean useApproximateFunctionOverride = null;
     if (offlineTableQueryConfig != null) {
@@ -1897,18 +1902,31 @@ public abstract class 
BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
     }
 
     boolean disableGroovy = disableGroovyOverride != null ? 
disableGroovyOverride : _disableGroovy;
+    // Precedence: query option > table config > cluster config > broker conf. 
One snapshot, so that the flag and the
+    // parameters this query uses come from the same version of the config.
+    Boolean queryOptionOverride =
+        queryOptions != null ? 
QueryOptionsUtils.isUseApproximateFunction(queryOptions) : null;
+    ApproximateFunctionOverrideProvider.Settings approximateFunctionSettings =
+        _approximateFunctionOverrideProvider.getSettings();
     boolean useApproximateFunction =
-        useApproximateFunctionOverride != null ? 
useApproximateFunctionOverride : _useApproximateFunction;
-    return new HandlerContext(disableGroovy, useApproximateFunction);
+        approximateFunctionSettings.isEnabled(queryOptionOverride, 
useApproximateFunctionOverride);
+    return new HandlerContext(disableGroovy, useApproximateFunction,
+        approximateFunctionSettings._distinctCountParams, 
approximateFunctionSettings._percentileParams);
   }
 
   private static class HandlerContext {
     final boolean _disableGroovy;
     final boolean _useApproximateFunction;
+    /// Parameters for the rewritten calls, empty when the aggregation 
function defaults apply.
+    final String _distinctCountParams;
+    final String _percentileParams;
 
-    HandlerContext(boolean disableGroovy, boolean useApproximateFunction) {
+    HandlerContext(boolean disableGroovy, boolean useApproximateFunction, 
String distinctCountParams,
+        String percentileParams) {
       _disableGroovy = disableGroovy;
       _useApproximateFunction = useApproximateFunction;
+      _distinctCountParams = distinctCountParams;
+      _percentileParams = percentileParams;
     }
   }
 
@@ -1972,57 +1990,79 @@ public abstract class 
BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
   /// Rewrites potential expensive functions to their approximation 
counterparts.
   /// - DISTINCT_COUNT -> DISTINCT_COUNT_SMART_HLL
   /// - PERCENTILE -> PERCENTILE_SMART_TDIGEST
+  ///
+  /// The rewritten functions stay exact until an accumulator crosses their 
conversion threshold, which the
+  /// parameters carry. An empty parameter string appends nothing, so the 
aggregation function defaults apply.
+  ///
+  /// @return true if at least one function was rewritten, which makes the 
result of the query approximate
   @VisibleForTesting
-  static void handleApproximateFunctionOverride(PinotQuery pinotQuery) {
+  static boolean handleApproximateFunctionOverride(PinotQuery pinotQuery, 
String distinctCountParams,
+      String percentileParams) {
+    boolean applied = false;
     for (Expression expression : pinotQuery.getSelectList()) {
-      handleApproximateFunctionOverride(expression);
+      applied |= handleApproximateFunctionOverride(expression, 
distinctCountParams, percentileParams);
     }
     List<Expression> orderByExpressions = pinotQuery.getOrderByList();
     if (orderByExpressions != null) {
       for (Expression expression : orderByExpressions) {
         // NOTE: Order-by is always a Function with the ordering of the 
Expression
-        
handleApproximateFunctionOverride(expression.getFunctionCall().getOperands().get(0));
+        applied |= 
handleApproximateFunctionOverride(expression.getFunctionCall().getOperands().get(0),
+            distinctCountParams, percentileParams);
       }
     }
     Expression havingExpression = pinotQuery.getHavingExpression();
     if (havingExpression != null) {
-      handleApproximateFunctionOverride(havingExpression);
+      applied |= handleApproximateFunctionOverride(havingExpression, 
distinctCountParams, percentileParams);
     }
+    return applied;
   }
 
-  private static void handleApproximateFunctionOverride(Expression expression) 
{
+  private static boolean handleApproximateFunctionOverride(Expression 
expression, String distinctCountParams,
+      String percentileParams) {
     Function function = expression.getFunctionCall();
     if (function == null) {
-      return;
+      return false;
     }
     String functionName = function.getOperator();
     if (functionName.equals("distinctcount") || 
functionName.equals("distinctcountmv")) {
       function.setOperator("distinctcountsmarthll");
+      appendParams(function, distinctCountParams, 1);
+      return true;
     } else if (functionName.startsWith("percentile")) {
-      String remainingFunctionName = functionName.substring(10);
-      if (remainingFunctionName.isEmpty() || 
remainingFunctionName.equals("mv")) {
+      String suffix = functionName.substring(10);
+      if (suffix.isEmpty() || suffix.equals("mv")) {
         function.setOperator("percentilesmarttdigest");
-      } else if (remainingFunctionName.matches("\\d+")) {
+      } else if (suffix.matches("\\d+(mv)?")) {
+        // The percentile is in the function name, so it becomes an explicit 
argument.
+        String digits = suffix.endsWith("mv") ? suffix.substring(0, 
suffix.length() - 2) : suffix;
+        int percentile;
         try {
-          int percentile = Integer.parseInt(remainingFunctionName);
-          function.setOperator("percentilesmarttdigest");
-          
function.addToOperands(RequestUtils.getLiteralExpression(percentile));
-        } catch (Exception e) {
-          throw new BadQueryRequestException("Illegal function name: " + 
functionName);
-        }
-      } else if (remainingFunctionName.matches("\\d+mv")) {
-        try {
-          int percentile = Integer.parseInt(remainingFunctionName.substring(0, 
remainingFunctionName.length() - 2));
-          function.setOperator("percentilesmarttdigest");
-          
function.addToOperands(RequestUtils.getLiteralExpression(percentile));
+          percentile = Integer.parseInt(digits);
         } catch (Exception e) {
           throw new BadQueryRequestException("Illegal function name: " + 
functionName);
         }
+        function.setOperator("percentilesmarttdigest");
+        function.addToOperands(RequestUtils.getLiteralExpression(percentile));
+      } else {
+        // An already approximate variant, e.g. PERCENTILE_TDIGEST. Nothing to 
rewrite.
+        return false;
       }
+      appendParams(function, percentileParams, 2);
+      return true;
     } else {
+      boolean applied = false;
       for (Expression operand : function.getOperands()) {
-        handleApproximateFunctionOverride(operand);
+        applied |= handleApproximateFunctionOverride(operand, 
distinctCountParams, percentileParams);
       }
+      return applied;
+    }
+  }
+
+  /// Appends the parameters as the trailing argument of a rewritten call, 
unless the call has an unexpected arity,
+  /// in which case the function defaults apply rather than a call the server 
cannot construct.
+  private static void appendParams(Function function, String params, int 
expectedNumOperands) {
+    if (!params.isEmpty() && function.getOperandsSize() == 
expectedNumOperands) {
+      function.addToOperands(RequestUtils.getLiteralExpression(params));
     }
   }
 
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
index 08005e3be51..299be308c2c 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
@@ -575,6 +575,12 @@ public class MultiStageBrokerRequestHandler extends 
BaseBrokerRequestHandler {
         CommonConstants.Broker.DEFAULT_UNNEST_COLUMN_PRUNING);
     WorkerManager workerManager = 
QueryOptionsUtils.isMultiClusterRoutingEnabled(queryOptions, false)
         ? _multiClusterWorkerManager : _workerManager;
+    // Unlike the single-stage engine there is no table-level layer here, 
because a multi-stage query can span tables.
+    // Precedence is therefore query option > cluster config > broker conf.
+    ApproximateFunctionOverrideProvider.Settings approximateFunctionSettings =
+        _approximateFunctionOverrideProvider.getSettings();
+    boolean useApproximateFunction =
+        
approximateFunctionSettings.isEnabled(QueryOptionsUtils.isUseApproximateFunction(queryOptions),
 null);
     return QueryEnvironment.configBuilder()
         .requestId(requestId)
         .database(database)
@@ -587,6 +593,9 @@ public class MultiStageBrokerRequestHandler extends 
BaseBrokerRequestHandler {
         .defaultUnnestColumnPruning(defaultUnnestColumnPruning)
         
.defaultUseLeafServerForIntermediateStage(defaultUseLeafServerForIntermediateStage)
         .defaultEnableGroupTrim(defaultEnableGroupTrim)
+        .useApproximateFunction(useApproximateFunction)
+        
.approximateFunctionDistinctCountParams(approximateFunctionSettings._distinctCountParams)
+        
.approximateFunctionPercentileParams(approximateFunctionSettings._percentileParams)
         
.defaultEnableDynamicFilteringSemiJoin(defaultEnableDynamicFilteringSemiJoin)
         .defaultUsePhysicalOptimizer(defaultUsePhysicalOptimizer)
         .defaultUseLiteMode(defaultUseLiteMode)
@@ -908,6 +917,15 @@ public class MultiStageBrokerRequestHandler extends 
BaseBrokerRequestHandler {
       // set if rls (row level security) filters have been applied on the query
       brokerResponse.setRLSFiltersApplied(rlsFiltersApplied);
 
+      // set if an exact aggregation was rewritten into its approximate 
counterpart, which makes the result
+      // approximate rather than exact
+      if (query.getPlannerContext().isApproximateFunctionApplied()) {
+        brokerResponse.setApproximateFunctionApplied(true);
+        for (String tableName : tableNames) {
+          _brokerMetrics.addMeteredTableValue(tableName, 
BrokerMeter.APPROXIMATE_FUNCTION_OVERRIDES, 1);
+        }
+      }
+
       // Log query and stats
       _queryLogger.logQueryCompleted(
           new QueryLogger.QueryLogParams(requestContext, 
tableNames.toString(), brokerResponse,
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
index 58dd30cc3e8..eb2f32d62de 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
@@ -137,6 +137,7 @@ public class QueryLoggerTest {
         + "realtimeMemAllocatedBytes(total/thread/resSer):0/0/0,"
         + "pools=[],"
         + "rlsFiltersApplied=true,"
+        + "approximateFunctionApplied=false,"
         + "workloadName=workloadName,"
         + "query=SELECT * FROM foo");
     //@formatter:on
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/ApproximateFunctionOverrideProviderTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/ApproximateFunctionOverrideProviderTest.java
new file mode 100644
index 00000000000..9935b1b565b
--- /dev/null
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/ApproximateFunctionOverrideProviderTest.java
@@ -0,0 +1,134 @@
+/**
+ * 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.Map;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+public class ApproximateFunctionOverrideProviderTest {
+
+  @Test
+  public void testBrokerConfProvidesTheDefault() {
+    ApproximateFunctionOverrideProvider unset = newProvider(Map.of());
+    assertFalse(unset.getSettings().isEnabled(null, null));
+    assertEquals(unset.getSettings()._distinctCountParams, "");
+    assertEquals(unset.getSettings()._percentileParams, "");
+
+    ApproximateFunctionOverrideProvider provider = newProvider(Map.of(
+        Broker.USE_APPROXIMATE_FUNCTION, "true",
+        Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS, 
"threshold=10;log2m=8",
+        Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, 
"threshold=20;compression=50"));
+    assertTrue(provider.getSettings().isEnabled(null, null));
+    assertEquals(provider.getSettings()._distinctCountParams, 
"threshold=10;log2m=8");
+    assertEquals(provider.getSettings()._percentileParams, 
"threshold=20;compression=50");
+  }
+
+  @Test
+  public void testPrecedenceIsQueryOptionThenTableThenDefault() {
+    ApproximateFunctionOverrideProvider disabledByDefault = 
newProvider(Map.of());
+    assertTrue(disabledByDefault.getSettings().isEnabled(Boolean.TRUE, null));
+    assertTrue(disabledByDefault.getSettings().isEnabled(null, Boolean.TRUE));
+    // The query option outranks the table config in both directions.
+    assertFalse(disabledByDefault.getSettings().isEnabled(Boolean.FALSE, 
Boolean.TRUE));
+
+    ApproximateFunctionOverrideProvider enabledByDefault =
+        newProvider(Map.of(Broker.USE_APPROXIMATE_FUNCTION, "true"));
+    assertFalse(enabledByDefault.getSettings().isEnabled(Boolean.FALSE, null));
+    assertFalse(enabledByDefault.getSettings().isEnabled(null, Boolean.FALSE));
+    assertTrue(enabledByDefault.getSettings().isEnabled(Boolean.TRUE, 
Boolean.FALSE));
+  }
+
+  @Test
+  public void testClusterConfigWinsOverBrokerConfAndReloads() {
+    ApproximateFunctionOverrideProvider provider = newProvider(Map.of(
+        Broker.USE_APPROXIMATE_FUNCTION, "false",
+        Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, "threshold=20"));
+
+    Map<String, String> clusterConfigs = Map.of(
+        Broker.USE_APPROXIMATE_FUNCTION, "true",
+        Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, 
"threshold=5;compression=200");
+    provider.onChange(clusterConfigs.keySet(), clusterConfigs);
+
+    assertTrue(provider.getSettings().isEnabled(null, null));
+    assertEquals(provider.getSettings()._percentileParams, 
"threshold=5;compression=200");
+
+    // Removing the keys from the cluster config falls back to the broker conf.
+    provider.onChange(clusterConfigs.keySet(), Map.of());
+    assertFalse(provider.getSettings().isEnabled(null, null));
+    assertEquals(provider.getSettings()._percentileParams, "threshold=20");
+  }
+
+  @Test
+  public void testUnrelatedClusterConfigChangeIsIgnored() {
+    ApproximateFunctionOverrideProvider provider =
+        newProvider(Map.of(Broker.USE_APPROXIMATE_FUNCTION, "true"));
+    provider.onChange(Map.of("some.other.key", "1").keySet(), 
Map.of("some.other.key", "1"));
+    assertTrue(provider.getSettings().isEnabled(null, null));
+  }
+
+  /// A typo in the cluster config would otherwise fail every rewritten query 
on the servers, so a bad value is
+  /// rejected in favour of the broker conf value. Falling back to the broker 
conf rather than to the last good live
+  /// value is what makes a broker that restarts resolve the same value as one 
that stayed up.
+  @Test
+  public void testInvalidParamsAreRejectedInFavourOfTheBrokerConfValue() {
+    ApproximateFunctionOverrideProvider provider = newProvider(Map.of(
+        Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS, "threshold=10",
+        Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, "threshold=10"));
+
+    onChange(provider, Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS, 
"thresold=10");
+    assertEquals(provider.getSettings()._distinctCountParams, "threshold=10");
+
+    onChange(provider, Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, 
"threshold=abc");
+    assertEquals(provider.getSettings()._percentileParams, "threshold=10");
+
+    // The percentile parameters must not be accepted for distinct count, and 
the other way round.
+    onChange(provider, Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS, 
"compression=50");
+    assertEquals(provider.getSettings()._distinctCountParams, "threshold=10");
+
+    // A good live value does not become the fallback for a later bad one.
+    onChange(provider, Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, 
"threshold=500");
+    assertEquals(provider.getSettings()._percentileParams, "threshold=500");
+    onChange(provider, Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, 
"threshold=nope");
+    assertEquals(provider.getSettings()._percentileParams, "threshold=10");
+  }
+
+  /// A broker conf that is invalid at startup must not leave the broker 
emitting calls the servers reject.
+  @Test
+  public void testInvalidBrokerConfParamsFallBackToTheFunctionDefaults() {
+    ApproximateFunctionOverrideProvider provider =
+        newProvider(Map.of(Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, 
"threshold=10;log2m=8"));
+    assertEquals(provider.getSettings()._percentileParams, "");
+  }
+
+  private static void onChange(ApproximateFunctionOverrideProvider provider, 
String key, String value) {
+    Map<String, String> clusterConfigs = Map.of(key, value);
+    provider.onChange(clusterConfigs.keySet(), clusterConfigs);
+  }
+
+  private static ApproximateFunctionOverrideProvider newProvider(Map<String, 
Object> properties) {
+    return new ApproximateFunctionOverrideProvider(new 
PinotConfiguration(properties));
+  }
+}
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryOverrideTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryOverrideTest.java
index 6647bd20ff5..b60107c65b6 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryOverrideTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryOverrideTest.java
@@ -20,12 +20,15 @@ package org.apache.pinot.broker.requesthandler;
 
 import com.google.common.collect.ImmutableSet;
 import java.util.Arrays;
+import org.apache.pinot.common.request.Function;
 import org.apache.pinot.common.request.PinotQuery;
 import org.apache.pinot.common.utils.request.RequestUtils;
 import org.apache.pinot.sql.parsers.CalciteSqlParser;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
 
 
 public class QueryOverrideTest {
@@ -70,7 +73,7 @@ public class QueryOverrideTest {
       String query = "SELECT DISTINCT_COUNT(col1) FROM myTable GROUP BY col2 
HAVING DISTINCT_COUNT(col1) > 10 "
           + "ORDER BY DISTINCT_COUNT(col1) DESC";
       PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"distinctcountsmarthll");
       assertEquals(
           
pinotQuery.getOrderByList().get(0).getFunctionCall().getOperands().get(0).getFunctionCall().getOperator(),
@@ -81,22 +84,22 @@ public class QueryOverrideTest {
 
       query = "SELECT DISTINCT_COUNT_MV(col1) FROM myTable";
       pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"distinctcountsmarthll");
 
       query = "SELECT DISTINCT col1 FROM myTable";
       pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"distinct");
 
       query = "SELECT DISTINCT_COUNT_HLL(col1) FROM myTable";
       pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"distinctcounthll");
 
       query = "SELECT DISTINCT_COUNT_BITMAP(col1) FROM myTable";
       pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"distinctcountbitmap");
     }
 
@@ -104,7 +107,7 @@ public class QueryOverrideTest {
         "SELECT PERCENTILE_MV(col1, 95) FROM myTable", "SELECT 
PERCENTILE95(col1) FROM myTable",
         "SELECT PERCENTILE95MV(col1) FROM myTable")) {
       PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"percentilesmarttdigest");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperands().get(1),
           RequestUtils.getLiteralExpression(95));
@@ -112,13 +115,50 @@ public class QueryOverrideTest {
     {
       String query = "SELECT PERCENTILE_TDIGEST(col1, 95) FROM myTable";
       PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"percentiletdigest");
 
       query = "SELECT PERCENTILE_EST(col1, 95) FROM myTable";
       pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery);
+      
BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "", "");
       
assertEquals(pinotQuery.getSelectList().get(0).getFunctionCall().getOperator(), 
"percentileest");
     }
   }
+
+  @Test
+  public void testApproximateFunctionOverrideReportsWhetherItRewroteAnything() 
{
+    PinotQuery rewritten = CalciteSqlParser.compileToPinotQuery("SELECT 
DISTINCT_COUNT(col1) FROM myTable");
+    
assertTrue(BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(rewritten,
 "", ""));
+
+    PinotQuery untouched = CalciteSqlParser.compileToPinotQuery("SELECT 
SUM(col1) FROM myTable");
+    
assertFalse(BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(untouched,
 "", ""));
+
+    // An already approximate variant is left alone, so the query keeps 
reporting exact results.
+    PinotQuery approximate = CalciteSqlParser.compileToPinotQuery("SELECT 
PERCENTILE_TDIGEST(col1, 95) FROM myTable");
+    
assertFalse(BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(approximate,
 "", ""));
+  }
+
+  @Test
+  public void testApproximateFunctionOverrideAppendsConfiguredParams() {
+    PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery("SELECT 
DISTINCT_COUNT(col1) FROM myTable");
+    
assertTrue(BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(pinotQuery,
 "threshold=10", ""));
+    Function distinctCount = 
pinotQuery.getSelectList().get(0).getFunctionCall();
+    assertEquals(distinctCount.getOperator(), "distinctcountsmarthll");
+    assertEquals(distinctCount.getOperands().size(), 2);
+    assertEquals(distinctCount.getOperands().get(1), 
RequestUtils.getLiteralExpression("threshold=10"));
+
+    // The percentile parameters go after the percentile itself, whichever 
spelling the query used.
+    for (String query : Arrays.asList("SELECT PERCENTILE(col1, 95) FROM 
myTable",
+        "SELECT PERCENTILE95(col1) FROM myTable")) {
+      PinotQuery percentileQuery = CalciteSqlParser.compileToPinotQuery(query);
+      
assertTrue(BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride(percentileQuery,
 "",
+          "threshold=20;compression=50"));
+      Function percentile = 
percentileQuery.getSelectList().get(0).getFunctionCall();
+      assertEquals(percentile.getOperator(), "percentilesmarttdigest");
+      assertEquals(percentile.getOperands().size(), 3);
+      assertEquals(percentile.getOperands().get(1), 
RequestUtils.getLiteralExpression(95));
+      assertEquals(percentile.getOperands().get(2),
+          RequestUtils.getLiteralExpression("threshold=20;compression=50"));
+    }
+  }
 }
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
index 18fa4545f91..bc2a8b354e9 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
@@ -42,6 +42,11 @@ public class BrokerMeter implements AbstractMetrics.Meter {
   ///
   /// At this moment this counter does not include queries executed in 
multi-stage mode.
   public static final BrokerMeter QUERIES = create("QUERIES", "queries", 
false);
+
+  /// Queries in which the broker rewrote at least one exact aggregation into 
its approximate counterpart, because
+  /// `pinot.broker.use.approximate.function` was on. The results of those 
queries are approximate.
+  public static final BrokerMeter APPROXIMATE_FUNCTION_OVERRIDES =
+      create("APPROXIMATE_FUNCTION_OVERRIDES", "queries", false);
   /// Number of single-stage queries that have been started.
   ///
   /// Unlike [#QUERIES], this metric is global and not attached to a 
particular table.
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
index 83e2d945dfc..98362b1b4e2 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
@@ -311,6 +311,19 @@ public interface BrokerResponse {
   /// @return true if RLS filters were applied, false otherwise
   boolean getRLSFiltersApplied();
 
+  /// Set whether the broker rewrote an exact aggregation into its approximate 
counterpart, for example
+  /// `DISTINCT_COUNT` into `DISTINCT_COUNT_SMART_HLL`. The result is then 
approximate, not exact.
+  /// The default is a no-op so that implementations which do not track this 
need no change.
+  /// @param approximateFunctionApplied true if at least one function was 
rewritten
+  default void setApproximateFunctionApplied(boolean 
approximateFunctionApplied) {
+  }
+
+  /// Get whether the broker rewrote an exact aggregation into its approximate 
counterpart.
+  /// @return true if at least one function was rewritten, false otherwise
+  default boolean isApproximateFunctionApplied() {
+    return false;
+  }
+
   /// Get the materialized view table name that was hit (used) for this query, 
or `null`
   /// if no materialized view was used.  The default returns `null` so impls 
that do not track MV
   /// rewrite (e.g. MSE response paths) need no explicit override; the 
matching *setter* is
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
index a414f565934..b38bae56605 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
@@ -42,7 +42,8 @@ import org.apache.pinot.spi.utils.JsonUtils;
 /// This class can be used to serialize/deserialize the broker response.
 @JsonPropertyOrder({
     "resultTable", "numRowsResultSet", "partialResult", "exceptions", 
"numGroupsLimitReached",
-    "numGroupsWarningLimitReached", "maxRowsInDistinctReached", 
"maxRowsWithoutChangeInDistinctReached",
+    "numGroupsWarningLimitReached", "maxRowsInDistinctReached",
+    "maxRowsWithoutChangeInDistinctReached",
     "maxExecutionTimeInDistinctReached", "timeUsedMs",
     "requestId", "clientRequestId", "brokerId", "numDocsScanned", "totalDocs",
     "numEntriesScannedInFilter",
@@ -56,7 +57,8 @@ import org.apache.pinot.spi.utils.JsonUtils;
     "explainPlanNumEmptyFilterSegments", 
"explainPlanNumMatchAllFilterSegments", "traceInfo", "tablesQueried",
     "offlineThreadMemAllocatedBytes", "realtimeThreadMemAllocatedBytes", 
"offlineResponseSerMemAllocatedBytes",
     "realtimeResponseSerMemAllocatedBytes", "offlineTotalMemAllocatedBytes", 
"realtimeTotalMemAllocatedBytes",
-    "pools", "rlsFiltersApplied", "groupsTrimmed", "materializedViewQueried", 
"serverStats"
+    "pools", "rlsFiltersApplied", "approximateFunctionApplied", 
"groupsTrimmed", "materializedViewQueried",
+    "serverStats"
 })
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class BrokerResponseNative implements BrokerResponse {
@@ -120,6 +122,7 @@ public class BrokerResponseNative implements BrokerResponse 
{
 
   private Set<Integer> _pools = Set.of();
   private boolean _rlsFiltersApplied = false;
+  private boolean _approximateFunctionApplied = false;
 
   @Nullable
   private String _materializedViewQueried;
@@ -636,6 +639,18 @@ public class BrokerResponseNative implements 
BrokerResponse {
     return _rlsFiltersApplied;
   }
 
+  @JsonProperty("approximateFunctionApplied")
+  @Override
+  public void setApproximateFunctionApplied(boolean 
approximateFunctionApplied) {
+    _approximateFunctionApplied = approximateFunctionApplied;
+  }
+
+  @JsonProperty("approximateFunctionApplied")
+  @Override
+  public boolean isApproximateFunctionApplied() {
+    return _approximateFunctionApplied;
+  }
+
   @JsonProperty("materializedViewQueried")
   public void setMaterializedViewQueried(@Nullable String 
materializedViewQueried) {
     _materializedViewQueried = materializedViewQueried;
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
index efbb57ead5a..9919057b565 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
@@ -40,7 +40,8 @@ import org.apache.pinot.common.response.ProcessingException;
 /// TODO: Currently this class cannot be used to deserialize the JSON response.
 @JsonPropertyOrder({
     "resultTable", "numRowsResultSet", "partialResult", "exceptions", 
"numGroupsLimitReached",
-    "numGroupsWarningLimitReached", "numGroups", "earlyTerminationReasons", 
"maxRowsInJoinReached",
+    "numGroupsWarningLimitReached", "numGroups", "earlyTerminationReasons",
+    "maxRowsInJoinReached",
     "maxRowsInJoin", "maxRowsInWindowReached", "maxRowsInWindow", 
"timeUsedMs", "stageStats", "streamStatsCoverage",
     "maxRowsInOperator", "requestId", "clientRequestId", "brokerId", 
"numDocsScanned", "totalDocs",
     "numEntriesScannedInFilter", "numEntriesScannedPostFilter", 
"numServersQueried", "numServersResponded",
@@ -53,7 +54,7 @@ import org.apache.pinot.common.response.ProcessingException;
     "explainPlanNumEmptyFilterSegments", 
"explainPlanNumMatchAllFilterSegments", "traceInfo", "tablesQueried",
     "offlineThreadMemAllocatedBytes", "realtimeThreadMemAllocatedBytes", 
"offlineResponseSerMemAllocatedBytes",
     "realtimeResponseSerMemAllocatedBytes", "offlineTotalMemAllocatedBytes", 
"realtimeTotalMemAllocatedBytes",
-    "pools", "rlsFiltersApplied", "groupsTrimmed",
+    "pools", "rlsFiltersApplied", "approximateFunctionApplied", 
"groupsTrimmed",
     "mseLiteLeafStageLimitReached", "mseLiteLeafStageEffectiveLimit", 
"mseLiteFanOutAdjustedLimitApplied",
     "responseMetadata"
 })
@@ -91,6 +92,7 @@ public class BrokerResponseNativeV2 implements BrokerResponse 
{
 
   private Set<Integer> _pools = Set.of();
   private boolean _rlsFiltersApplied = false;
+  private boolean _approximateFunctionApplied = false;
   @Nullable
   private Integer _mseLiteLeafStageEffectiveLimit;
   @Nullable
@@ -520,6 +522,18 @@ public class BrokerResponseNativeV2 implements 
BrokerResponse {
     return _rlsFiltersApplied;
   }
 
+  @JsonProperty("approximateFunctionApplied")
+  @Override
+  public void setApproximateFunctionApplied(boolean 
approximateFunctionApplied) {
+    _approximateFunctionApplied = approximateFunctionApplied;
+  }
+
+  @JsonProperty("approximateFunctionApplied")
+  @Override
+  public boolean isApproximateFunctionApplied() {
+    return _approximateFunctionApplied;
+  }
+
   public void addBrokerStats(StatMap<StatKey> brokerStats) {
     _brokerStats.merge(brokerStats);
   }
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
index e1ba761767d..96565b68cad 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
@@ -25,7 +25,8 @@ import org.apache.pinot.common.response.CursorResponse;
 
 @JsonPropertyOrder({
     "resultTable", "numRowsResultSet", "partialResult", "exceptions", 
"numGroupsLimitReached",
-    "numGroupsWarningLimitReached", "timeUsedMs", "requestId", "brokerId", 
"numDocsScanned", "totalDocs",
+    "numGroupsWarningLimitReached", "timeUsedMs", "requestId", "brokerId",
+    "numDocsScanned", "totalDocs",
     "numEntriesScannedInFilter", "numEntriesScannedPostFilter", 
"numServersQueried", "numServersResponded",
     "numSegmentsQueried", "numSegmentsProcessed", "numSegmentsMatched", 
"numConsumingSegmentsQueried",
     "numConsumingSegmentsProcessed", "numConsumingSegmentsMatched", 
"minConsumingFreshnessTimeMs",
@@ -34,7 +35,7 @@ import org.apache.pinot.common.response.CursorResponse;
     "offlineSystemActivitiesCpuTimeNs", "realtimeSystemActivitiesCpuTimeNs", 
"offlineResponseSerializationCpuTimeNs",
     "realtimeResponseSerializationCpuTimeNs", "offlineTotalCpuTimeNs", 
"realtimeTotalCpuTimeNs",
     "explainPlanNumEmptyFilterSegments", 
"explainPlanNumMatchAllFilterSegments", "traceInfo", "tableQueries",
-    "groupsTrimmed",
+    "groupsTrimmed", "approximateFunctionApplied",
     // Fields specific to CursorResponse
     "offset", "numRows", "cursorResultWriteTimeMs", "cursorFetchTimeMs", 
"submissionTimeMs", "expirationTimeMs",
     "brokerHost", "brokerPort", "bytesWritten"
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 a48c88cd146..8a9bdb8639b 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
@@ -437,6 +437,14 @@ public class QueryOptionsUtils {
     return new HashSet<>(List.of(useRules));
   }
 
+  /// Returns the per-query override of the approximate-function rewrite, or 
`null` if the query does not set one, in
+  /// which case the table config and then the cluster/broker default decide.
+  @Nullable
+  public static Boolean isUseApproximateFunction(Map<String, String> 
queryOptions) {
+    return checkedParseBooleanNullable(QueryOptionKey.USE_APPROXIMATE_FUNCTION,
+        queryOptions.get(QueryOptionKey.USE_APPROXIMATE_FUNCTION));
+  }
+
   @Nullable
   public static Boolean isUseFixedReplica(Map<String, String> queryOptions) {
     String useFixedReplica = 
queryOptions.get(CommonConstants.Broker.Request.QueryOptionKey.USE_FIXED_REPLICA);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/BaseDistinctCountSmartSketchAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/BaseDistinctCountSmartSketchAggregationFunction.java
index fd3fbe0bbc8..181fc11297b 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/BaseDistinctCountSmartSketchAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/BaseDistinctCountSmartSketchAggregationFunction.java
@@ -61,6 +61,10 @@ abstract class 
BaseDistinctCountSmartSketchAggregationFunction
 
   protected abstract Object convertSetToSketch(Set valueSet, DataType 
storedType);
 
+  /// Adds every value of the set into a sketch that [#convertSetToSketch] 
already created. Used to fold the values
+  /// that arrive after a group has been converted, without building a second 
sketch.
+  protected abstract void addSetToSketch(Object sketch, Set valueSet, DataType 
storedType);
+
   protected abstract Object convertToSketch(DictIdsWrapper dictIdsWrapper);
 
   protected abstract IllegalStateException 
getIllegalDataTypeException(DataType dataType, boolean singleValue);
@@ -586,9 +590,16 @@ abstract class 
BaseDistinctCountSmartSketchAggregationFunction
 
     if (result instanceof DictIdsWrapper) {
       return convertToValueSet((DictIdsWrapper) result);
-    } else {
-      return result;
     }
+    if (result instanceof SketchWithPendingValues) {
+      SketchWithPendingValues converted = (SketchWithPendingValues) result;
+      if (!converted._pendingValues.isEmpty()) {
+        addSetToSketch(converted._sketch, converted._pendingValues, 
converted._storedType);
+        converted._pendingValues.clear();
+      }
+      return converted._sketch;
+    }
+    return result;
   }
 
   /// Returns the dictionary id bitmap from the result holder or creates a new 
one if it does not exist.
@@ -643,14 +654,38 @@ abstract class 
BaseDistinctCountSmartSketchAggregationFunction
     return dictIdsWrapper._dictIdBitmap;
   }
 
-  /// Returns the value set for the given group key or creates a new one if it 
does not exist.
-  protected static Set getValueSet(GroupByResultHolder groupByResultHolder, 
int groupKey, DataType valueType) {
-    Set valueSet = groupByResultHolder.getResult(groupKey);
-    if (valueSet == null) {
-      valueSet = getValueSet(valueType);
+  /// Returns the value set for the given group key, creating one if it does 
not exist, and converting the group to a
+  /// sketch once its set has grown past the threshold. A group that already 
converted returns its pending set, which
+  /// this folds into the sketch. Without any of this the per-group sets of a 
non-dictionary column grow for the whole
+  /// segment, because the only other check runs at merge time.
+  ///
+  /// The check runs here, on a set that has just been fetched anyway, rather 
than as a second pass over the batch. It
+  /// therefore converts on the first touch after the threshold is crossed, 
which bounds a group at the threshold plus
+  /// one batch of values.
+  protected final Set getValueSet(GroupByResultHolder groupByResultHolder, int 
groupKey, DataType valueType) {
+    Object result = groupByResultHolder.getResult(groupKey);
+    if (result == null) {
+      Set valueSet = getValueSet(valueType);
       groupByResultHolder.setValueForKey(groupKey, valueSet);
+      return valueSet;
     }
-    return valueSet;
+    int threshold = getThreshold();
+    if (result instanceof SketchWithPendingValues) {
+      SketchWithPendingValues converted = (SketchWithPendingValues) result;
+      if (converted._pendingValues.size() > threshold) {
+        addSetToSketch(converted._sketch, converted._pendingValues, valueType);
+        converted._pendingValues.clear();
+      }
+      return converted._pendingValues;
+    }
+    Set valueSet = (Set) result;
+    if (valueSet.size() <= threshold) {
+      return valueSet;
+    }
+    SketchWithPendingValues converted =
+        new SketchWithPendingValues(convertSetToSketch(valueSet, valueType), 
getValueSet(valueType), valueType);
+    groupByResultHolder.setValueForKey(groupKey, converted);
+    return converted._pendingValues;
   }
 
   /// Helper method to set dictionary id for the given group keys into the 
result holder.
@@ -662,42 +697,42 @@ abstract class 
BaseDistinctCountSmartSketchAggregationFunction
   }
 
   /// Helper method to set INT value for the given group keys into the result 
holder.
-  protected static void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, int value) {
+  protected final void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, int value) {
     for (int groupKey : groupKeys) {
       ((IntOpenHashSet) getValueSet(groupByResultHolder, groupKey, 
DataType.INT)).add(value);
     }
   }
 
   /// Helper method to set LONG value for the given group keys into the result 
holder.
-  protected static void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, long value) {
+  protected final void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, long value) {
     for (int groupKey : groupKeys) {
       ((LongOpenHashSet) getValueSet(groupByResultHolder, groupKey, 
DataType.LONG)).add(value);
     }
   }
 
   /// Helper method to set FLOAT value for the given group keys into the 
result holder.
-  protected static void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, float value) {
+  protected final void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, float value) {
     for (int groupKey : groupKeys) {
       ((FloatOpenHashSet) getValueSet(groupByResultHolder, groupKey, 
DataType.FLOAT)).add(value);
     }
   }
 
   /// Helper method to set DOUBLE value for the given group keys into the 
result holder.
-  protected static void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, double value) {
+  protected final void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, double value) {
     for (int groupKey : groupKeys) {
       ((DoubleOpenHashSet) getValueSet(groupByResultHolder, groupKey, 
DataType.DOUBLE)).add(value);
     }
   }
 
   /// Helper method to set STRING value for the given group keys into the 
result holder.
-  protected static void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, String value) {
+  protected final void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys, String value) {
     for (int groupKey : groupKeys) {
       ((ObjectOpenHashSet<String>) getValueSet(groupByResultHolder, groupKey, 
DataType.STRING)).add(value);
     }
   }
 
   /// Helper method to set BYTES value for the given group keys into the 
result holder.
-  protected static void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys,
+  protected final void setValueForGroupKeys(GroupByResultHolder 
groupByResultHolder, int[] groupKeys,
       ByteArray value) {
     for (int groupKey : groupKeys) {
       ((ObjectOpenHashSet<ByteArray>) getValueSet(groupByResultHolder, 
groupKey, DataType.BYTES)).add(value);
@@ -810,6 +845,20 @@ abstract class 
BaseDistinctCountSmartSketchAggregationFunction
     }
   }
 
+  /// Per-group state after a group converted to a sketch. New values keep 
landing in a plain set, which the next
+  /// threshold check folds into the sketch, so the typed aggregation loops 
never see a sketch.
+  private static final class SketchWithPendingValues {
+    final Object _sketch;
+    final DataType _storedType;
+    final Set _pendingValues;
+
+    SketchWithPendingValues(Object sketch, Set pendingValues, DataType 
storedType) {
+      _sketch = sketch;
+      _pendingValues = pendingValues;
+      _storedType = storedType;
+    }
+  }
+
   /// Wrapper of dictionary and dict-id bitmap used during aggregation.
   protected static final class DictIdsWrapper {
     final Dictionary _dictionary;
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunction.java
index e3ffccf2cc7..d40686e8d2f 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunction.java
@@ -44,8 +44,9 @@ import org.roaringbitmap.RoaringBitmap;
 /// The `DistinctCountSmartHLLAggregationFunction` calculates the number of 
distinct values for a given expression
 /// (both single-valued and multi-valued are supported).
 ///
-/// For aggregation-only queries, the distinct values are stored in a Set 
initially. Once the number of distinct values
-/// exceeds a threshold, the Set will be converted into a HyperLogLog, and 
approximate result will be returned.
+/// The distinct values are stored in a Set initially. Once the number of 
distinct values exceeds a threshold, the
+/// Set will be converted into a HyperLogLog, and approximate result will be 
returned. The threshold is applied per
+/// accumulator, which means per group for a group-by query.
 ///
 /// The function takes an optional second argument for parameters:
 /// - threshold: Threshold of the number of distinct values to trigger the 
conversion, 100_000 by default. Non-positive
@@ -425,6 +426,20 @@ public class DistinctCountSmartHLLAggregationFunction 
extends BaseDistinctCountS
     return hyperLogLog;
   }
 
+  @Override
+  protected void addSetToSketch(Object sketch, Set valueSet, DataType 
storedType) {
+    HyperLogLog hll = (HyperLogLog) sketch;
+    if (storedType == DataType.BYTES) {
+      for (Object value : valueSet) {
+        hll.offer(((ByteArray) value).getBytes());
+      }
+    } else {
+      for (Object value : valueSet) {
+        hll.offer(value);
+      }
+    }
+  }
+
   @Override
   protected Object convertSetToSketch(Set valueSet, DataType storedType) {
     return convertSetToHLL(valueSet, storedType);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLPlusAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLPlusAggregationFunction.java
index f974c54f13a..a2d11c9351e 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLPlusAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLPlusAggregationFunction.java
@@ -44,8 +44,9 @@ import org.roaringbitmap.RoaringBitmap;
 /// The `DistinctCountSmartHLLPlusAggregationFunction` calculates the number 
of distinct values for a given
 /// expression (both single-valued and multi-valued are supported).
 ///
-/// For aggregation-only queries, the distinct values are stored in a Set 
initially. Once the number of distinct values
-/// exceeds a threshold, the Set will be converted into a HyperLogLogPlus, and 
approximate result will be returned.
+/// The distinct values are stored in a Set initially. Once the number of 
distinct values exceeds a threshold, the
+/// Set will be converted into a HyperLogLogPlus, and approximate result will 
be returned. The threshold is applied per
+/// accumulator, which means per group for a group-by query.
 ///
 /// The function takes an optional second argument for parameters:
 /// - threshold: Threshold of the number of distinct values to trigger the 
conversion, 100_000 by default. Non-positive
@@ -373,6 +374,20 @@ public class DistinctCountSmartHLLPlusAggregationFunction 
extends BaseDistinctCo
     return hllPlus;
   }
 
+  @Override
+  protected void addSetToSketch(Object sketch, Set valueSet, DataType 
storedType) {
+    HyperLogLogPlus hllPlus = (HyperLogLogPlus) sketch;
+    if (storedType == DataType.BYTES) {
+      for (Object value : valueSet) {
+        hllPlus.offer(((ByteArray) value).getBytes());
+      }
+    } else {
+      for (Object value : valueSet) {
+        hllPlus.offer(value);
+      }
+    }
+  }
+
   @Override
   protected Object convertSetToSketch(Set valueSet, DataType storedType) {
     return convertSetToHLLPlus(valueSet, storedType);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartULLAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartULLAggregationFunction.java
index 23243762790..a3634cf4006 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartULLAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartULLAggregationFunction.java
@@ -44,8 +44,9 @@ import org.roaringbitmap.RoaringBitmap;
 /// The `DistinctCountSmartULLAggregationFunction` calculates the number of 
distinct values for a given expression
 /// (both single-valued and multi-valued are supported).
 ///
-/// For aggregation-only queries, the distinct values are stored in a Set 
initially. Once the number of distinct values
-/// exceeds a threshold, the Set will be converted into an UltraLogLog, and 
approximate result will be returned.
+/// The distinct values are stored in a Set initially. Once the number of 
distinct values exceeds a threshold, the
+/// Set will be converted into an UltraLogLog, and approximate result will be 
returned. The threshold is applied per
+/// accumulator, which means per group for a group-by query.
 ///
 /// The function takes an optional second argument for parameters:
 /// - threshold: Threshold of the number of distinct values to trigger the 
conversion, 100_000 by default. Non-positive
@@ -446,6 +447,20 @@ public class DistinctCountSmartULLAggregationFunction 
extends BaseDistinctCountS
 
   // threshold accessor for base class is provided by getThreshold()
 
+  @Override
+  protected void addSetToSketch(Object sketch, Set valueSet, DataType 
storedType) {
+    UltraLogLog ull = (UltraLogLog) sketch;
+    if (storedType == DataType.BYTES) {
+      for (Object value : valueSet) {
+        UltraLogLogUtils.hashObject(((ByteArray) 
value).getBytes()).ifPresent(ull::add);
+      }
+    } else {
+      for (Object value : valueSet) {
+        UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+      }
+    }
+  }
+
   @Override
   protected Object convertSetToSketch(Set valueSet, DataType storedType) {
     return convertSetToULL(valueSet, storedType);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunction.java
index 6ada67ef34f..062c1ca9252 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunction.java
@@ -43,9 +43,9 @@ import org.apache.pinot.spi.data.FieldSpec.DataType;
 /// The `PercentileSmartTDigestAggregationFunction` calculates the percentile 
of the values for a given
 /// expression (both single-valued and multi-valued are supported).
 ///
-/// For aggregation-only queries, the values are stored in a [DoubleArrayList] 
initially. Once the number of
-/// values exceeds a threshold, the list will be converted into a [TDigest], 
and approximate result will be
-/// returned.
+/// The values are stored in a [DoubleArrayList] initially. Once the number of 
values exceeds a threshold, the list
+/// will be converted into a [TDigest], and approximate result will be 
returned. The threshold is applied per
+/// accumulator, which means per group for a group-by query.
 ///
 /// The function takes an optional third argument for parameters:
 /// - threshold: Threshold of the number of values to trigger the conversion, 
100_000 by default. Non-positive value
@@ -201,30 +201,19 @@ public class PercentileSmartTDigestAggregationFunction 
extends BaseSingleInputAg
       double[] doubleValues = blockValSet.getDoubleValuesSV();
       forEachNotNull(length, blockValSet, (from, to) -> {
         for (int i = from; i < to; i++) {
-          DoubleArrayList valueList = getValueList(groupByResultHolder, 
groupKeyArray[i]);
-          valueList.add(doubleValues[i]);
+          addValueForGroup(groupByResultHolder, groupKeyArray[i], 
doubleValues[i], _threshold);
         }
       });
     } else {
       double[][] doubleValues = blockValSet.getDoubleValuesMV();
       forEachNotNull(length, blockValSet, (from, to) -> {
         for (int i = from; i < to; i++) {
-          DoubleArrayList valueList = getValueList(groupByResultHolder, 
groupKeyArray[i]);
-          valueList.addElements(valueList.size(), doubleValues[i]);
+          addValuesForGroup(groupByResultHolder, groupKeyArray[i], 
doubleValues[i], _threshold);
         }
       });
     }
   }
 
-  private static DoubleArrayList getValueList(GroupByResultHolder 
groupByResultHolder, int groupKey) {
-    DoubleArrayList valueList = groupByResultHolder.getResult(groupKey);
-    if (valueList == null) {
-      valueList = new DoubleArrayList();
-      groupByResultHolder.setValueForKey(groupKey, valueList);
-    }
-    return valueList;
-  }
-
   @Override
   public void aggregateGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
@@ -235,7 +224,7 @@ public class PercentileSmartTDigestAggregationFunction 
extends BaseSingleInputAg
       forEachNotNull(length, blockValSet, (from, to) -> {
         for (int i = from; i < to; i++) {
           for (int groupKey : groupKeysArray[i]) {
-            getValueList(groupByResultHolder, groupKey).add(doubleValues[i]);
+            addValueForGroup(groupByResultHolder, groupKey, doubleValues[i], 
_threshold);
           }
         }
       });
@@ -244,14 +233,56 @@ public class PercentileSmartTDigestAggregationFunction 
extends BaseSingleInputAg
       forEachNotNull(length, blockValSet, (from, to) -> {
         for (int i = from; i < to; i++) {
           for (int groupKey : groupKeysArray[i]) {
-            DoubleArrayList valueList = getValueList(groupByResultHolder, 
groupKey);
-            valueList.addElements(valueList.size(), doubleValues[i]);
+            addValuesForGroup(groupByResultHolder, groupKey, doubleValues[i], 
_threshold);
           }
         }
       });
     }
   }
 
+  /// Adds one value into the accumulator of the given group, converting it 
once it holds more values than the
+  /// threshold. The accumulator is a [DoubleArrayList] until then and a 
[TDigest] after. Without this the per-group
+  /// lists grow for the whole segment: it is the group-by counterpart of the 
check in [#aggregateIntoValueList].
+  private void addValueForGroup(GroupByResultHolder groupByResultHolder, int 
groupKey, double value, int threshold) {
+    Object result = groupByResultHolder.getResult(groupKey);
+    if (result instanceof TDigest) {
+      ((TDigest) result).add(value);
+      return;
+    }
+    DoubleArrayList valueList = getOrCreateValueList(groupByResultHolder, 
groupKey, (DoubleArrayList) result);
+    valueList.add(value);
+    if (valueList.size() > threshold) {
+      groupByResultHolder.setValueForKey(groupKey, 
convertValueListToTDigest(valueList));
+    }
+  }
+
+  /// As [#addValueForGroup], for every value of a multi-valued entry.
+  private void addValuesForGroup(GroupByResultHolder groupByResultHolder, int 
groupKey, double[] values,
+      int threshold) {
+    Object result = groupByResultHolder.getResult(groupKey);
+    if (result instanceof TDigest) {
+      TDigest tDigest = (TDigest) result;
+      for (double value : values) {
+        tDigest.add(value);
+      }
+      return;
+    }
+    DoubleArrayList valueList = getOrCreateValueList(groupByResultHolder, 
groupKey, (DoubleArrayList) result);
+    valueList.addElements(valueList.size(), values);
+    if (valueList.size() > threshold) {
+      groupByResultHolder.setValueForKey(groupKey, 
convertValueListToTDigest(valueList));
+    }
+  }
+
+  private static DoubleArrayList getOrCreateValueList(GroupByResultHolder 
groupByResultHolder, int groupKey,
+      @Nullable DoubleArrayList valueList) {
+    if (valueList == null) {
+      valueList = new DoubleArrayList();
+      groupByResultHolder.setValueForKey(groupKey, valueList);
+    }
+    return valueList;
+  }
+
   @Override
   public Object extractAggregationResult(AggregationResultHolder 
aggregationResultHolder) {
     return aggregationResultHolder.getResult();
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunctionTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunctionTest.java
index 4655ab6a95a..69d2274f0b6 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunctionTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLAggregationFunctionTest.java
@@ -20,9 +20,12 @@ package org.apache.pinot.core.query.aggregation.function;
 
 import com.clearspring.analytics.stream.cardinality.HyperLogLog;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.common.SyntheticBlockValSets;
 import 
org.apache.pinot.core.query.aggregation.groupby.ObjectGroupByResultHolder;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.testng.annotations.Test;
@@ -33,6 +36,7 @@ import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertTrue;
 
 
+@SuppressWarnings("rawtypes")
 public class DistinctCountSmartHLLAggregationFunctionTest {
 
   @Test
@@ -170,6 +174,128 @@ public class DistinctCountSmartHLLAggregationFunctionTest 
{
     assertTrue(cardinality >= 90 && cardinality <= 110, "Cardinality out of 
range: " + cardinality);
   }
 
+  @Test
+  public void testRawValueGroupBySvStaysExactBelowThreshold() {
+    DistinctCountSmartHLLAggregationFunction function = 
newFunctionWithThreshold(100);
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+    function.aggregateGroupBySV(3, new int[]{0, 0, 1}, holder,
+        Map.of(ExpressionContext.forIdentifier("col"), 
SyntheticBlockValSets.Int.create(null, new int[]{1, 2, 3})));
+
+    assertTrue(function.extractGroupByResult(holder, 0) instanceof Set);
+    assertTrue(function.extractGroupByResult(holder, 1) instanceof Set);
+  }
+
+  /// Values that arrive after a group converted must reach that group's 
sketch, not a fresh value set.
+  @Test
+  public void testRawValueGroupBySvKeepsFeedingTheSketchAfterConversion() {
+    DistinctCountSmartHLLAggregationFunction function = 
newFunctionWithThreshold(4);
+    int[] groupKeys = {0, 0, 0};
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+    ExpressionContext column = ExpressionContext.forIdentifier("col");
+
+    function.aggregateGroupBySV(3, groupKeys, holder,
+        Map.of(column, SyntheticBlockValSets.Int.create(null, new int[]{1, 2, 
3})));
+    assertTrue(function.extractGroupByResult(holder, 0) instanceof Set);
+
+    function.aggregateGroupBySV(3, groupKeys, holder,
+        Map.of(column, SyntheticBlockValSets.Int.create(null, new int[]{4, 5, 
6})));
+    assertTrue(function.extractGroupByResult(holder, 0) instanceof 
HyperLogLog);
+
+    function.aggregateGroupBySV(3, groupKeys, holder,
+        Map.of(column, SyntheticBlockValSets.Int.create(null, new int[]{7, 8, 
9})));
+    Object result = function.extractGroupByResult(holder, 0);
+    assertTrue(result instanceof HyperLogLog);
+    assertEquals(function.extractFinalResult(result), 9);
+  }
+
+  @Test
+  public void testRawValueGroupByMvConvertsEveryGroupTheRowBelongsTo() {
+    DistinctCountSmartHLLAggregationFunction function = 
newFunctionWithThreshold(3);
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+    int[][] groupKeysArray = {{0, 1}, {0, 1}, {0, 1}, {0, 1}};
+    ExpressionContext column = ExpressionContext.forIdentifier("col");
+
+    function.aggregateGroupByMV(4, groupKeysArray, holder,
+        Map.of(column, SyntheticBlockValSets.Int.create(null, new int[]{1, 2, 
3, 4})));
+    function.aggregateGroupByMV(4, groupKeysArray, holder,
+        Map.of(column, SyntheticBlockValSets.Int.create(null, new int[]{5, 6, 
7, 8})));
+
+    for (int groupKey = 0; groupKey < 2; groupKey++) {
+      Object result = function.extractGroupByResult(holder, groupKey);
+      assertTrue(result instanceof HyperLogLog, "Group " + groupKey + " was 
not converted: " + result);
+      assertEquals(function.extractFinalResult(result), 8);
+    }
+  }
+
+  /// BYTES values are the one branch of `addSetToSketch` that unwraps a 
[org.apache.pinot.spi.utils.ByteArray] before
+  /// hashing, so it has to fold post-conversion values into the sketch the 
same way the initial conversion did.
+  @Test
+  public void testRawValueGroupByConvertsBytesColumn() {
+    DistinctCountSmartHLLAggregationFunction function = 
newFunctionWithThreshold(2);
+    int[] groupKeys = {0, 0, 0};
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+    ExpressionContext column = ExpressionContext.forIdentifier("col");
+
+    function.aggregateGroupBySV(3, groupKeys, holder, Map.of(column,
+        SyntheticBlockValSets.Bytes.create(null, new byte[][]{{1}, {2}, 
{3}})));
+
+    // The conversion happens on the first touch after the threshold is 
crossed, and values that arrive after it must
+    // reach the same sketch, hashed the same way.
+    function.aggregateGroupBySV(3, groupKeys, holder, Map.of(column,
+        SyntheticBlockValSets.Bytes.create(null, new byte[][]{{3}, {4}, 
{5}})));
+    Object result = function.extractGroupByResult(holder, 0);
+    assertTrue(result instanceof HyperLogLog);
+    assertEquals(function.extractFinalResult(result), 5);
+  }
+
+  /// The other two sketch families have their own hashing, and UltraLogLog 
drops values its hasher rejects, so a
+  /// post-conversion value has to be seen to land. STRING is the other 
ObjectOpenHashSet branch. The counts are large
+  /// enough that a dropped batch would halve the estimate, well outside the 
error of these sketches.
+  @Test
+  public void 
testRawValueGroupByConvertsForEverySketchFamilyAndStringColumns() {
+    ExpressionContext column = ExpressionContext.forIdentifier("col");
+    ExpressionContext params = ExpressionContext.forLiteral(DataType.STRING, 
"threshold=10");
+    List<AggregationFunction> functions = List.of(
+        new DistinctCountSmartHLLAggregationFunction(List.of(column, params), 
false),
+        new DistinctCountSmartHLLPlusAggregationFunction(List.of(column, 
params), false),
+        new DistinctCountSmartULLAggregationFunction(List.of(column, params), 
false));
+    int[] first = new int[50];
+    int[] second = new int[50];
+    String[] firstStrings = new String[50];
+    String[] secondStrings = new String[50];
+    for (int i = 0; i < 50; i++) {
+      first[i] = i;
+      second[i] = 50 + i;
+      firstStrings[i] = "v" + i;
+      secondStrings[i] = "v" + (50 + i);
+    }
+    int[] groupKeys = new int[50];
+
+    for (AggregationFunction function : functions) {
+      for (BlockValSet[] batches : List.of(
+          new BlockValSet[]{
+              SyntheticBlockValSets.Int.create(null, first), 
SyntheticBlockValSets.Int.create(null, second)},
+          new BlockValSet[]{
+              SyntheticBlockValSets.Str.create(null, firstStrings),
+              SyntheticBlockValSets.Str.create(null, secondStrings)})) {
+        ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 
10);
+        function.aggregateGroupBySV(50, groupKeys, holder, Map.of(column, 
batches[0]));
+        function.aggregateGroupBySV(50, groupKeys, holder, Map.of(column, 
batches[1]));
+
+        Object result = function.extractGroupByResult(holder, 0);
+        assertFalse(result instanceof Set, function.getType() + " did not 
convert: " + result);
+        int estimate = ((Number) 
function.extractFinalResult(result)).intValue();
+        assertTrue(estimate >= 90 && estimate <= 110, function.getType() + " 
estimated " + estimate + ", expected 100");
+      }
+    }
+  }
+
+  private static DistinctCountSmartHLLAggregationFunction 
newFunctionWithThreshold(int threshold) {
+    return new DistinctCountSmartHLLAggregationFunction(
+        List.of(ExpressionContext.forIdentifier("col"),
+            ExpressionContext.forLiteral(DataType.STRING, "threshold=" + 
threshold)), false);
+  }
+
   @Test
   public void testAdaptiveConversion() {
     // Test adaptive conversion enabled by default (100K threshold)
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunctionTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunctionTest.java
index 324e736d266..256ef347a29 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunctionTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunctionTest.java
@@ -32,6 +32,7 @@ import 
org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.core.common.ObjectSerDeUtils;
 import org.apache.pinot.core.common.SyntheticBlockValSets;
 import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
+import 
org.apache.pinot.core.query.aggregation.groupby.ObjectGroupByResultHolder;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
@@ -178,6 +179,81 @@ public class PercentileSmartTDigestAggregationFunctionTest 
{
     assertDuplicateInfinityResult((TDigest) merged, 2L * values.length);
   }
 
+  @Test
+  public void testGroupBySvStaysExactBelowThreshold() {
+    PercentileSmartTDigestAggregationFunction function = newFunction(100);
+    int[] groupKeys = {0, 0, 1};
+
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+    function.aggregateGroupBySV(3, groupKeys, holder,
+        Map.of(EXPRESSION, SyntheticBlockValSets.Double.create(null, new 
double[]{1.0, 2.0, 3.0})));
+
+    assertTrue(function.extractGroupByResult(holder, 0) instanceof 
DoubleArrayList);
+    assertTrue(function.extractGroupByResult(holder, 1) instanceof 
DoubleArrayList);
+  }
+
+  /// The accumulator of a converted group must keep taking values from later 
batches, and it must take them into the
+  /// digest instead of into a new value list.
+  @Test
+  public void testGroupBySvKeepsFeedingTheDigestAfterConversion() {
+    PercentileSmartTDigestAggregationFunction function = newFunction(4);
+    int[] groupKeys = {0, 0, 0};
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+
+    function.aggregateGroupBySV(3, groupKeys, holder,
+        Map.of(EXPRESSION, SyntheticBlockValSets.Double.create(null, new 
double[]{1.0, 2.0, 3.0})));
+    assertTrue(function.extractGroupByResult(holder, 0) instanceof 
DoubleArrayList);
+
+    function.aggregateGroupBySV(3, groupKeys, holder,
+        Map.of(EXPRESSION, SyntheticBlockValSets.Double.create(null, new 
double[]{4.0, 5.0, 6.0})));
+    Object converted = function.extractGroupByResult(holder, 0);
+    assertTrue(converted instanceof TDigest);
+    assertEquals(((TDigest) converted).size(), 6L);
+
+    function.aggregateGroupBySV(3, groupKeys, holder,
+        Map.of(EXPRESSION, SyntheticBlockValSets.Double.create(null, new 
double[]{7.0, 8.0, 9.0})));
+    Object stillConverted = function.extractGroupByResult(holder, 0);
+    assertTrue(stillConverted instanceof TDigest);
+    assertEquals(((TDigest) stillConverted).size(), 9L);
+    assertEquals(function.extractFinalResult(stillConverted), 5.0, 1.0);
+  }
+
+  @Test
+  public void testGroupBySvConvertsForMultiValuedColumn() {
+    PercentileSmartTDigestAggregationFunction function = newFunction(4);
+    int[] groupKeys = {0, 0};
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+
+    function.aggregateGroupBySV(2, groupKeys, holder, Map.of(EXPRESSION,
+        SyntheticBlockValSets.DoubleMV.create(null, new double[][]{{1.0, 2.0, 
3.0}, {4.0, 5.0, 6.0}})));
+
+    Object result = function.extractGroupByResult(holder, 0);
+    assertTrue(result instanceof TDigest);
+    assertEquals(((TDigest) result).size(), 6L);
+  }
+
+  @Test
+  public void testGroupByMvConvertsEveryGroupTheRowBelongsTo() {
+    PercentileSmartTDigestAggregationFunction function = newFunction(4);
+    int[][] groupKeysArray = {{0, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}};
+    ObjectGroupByResultHolder holder = new ObjectGroupByResultHolder(10, 10);
+
+    function.aggregateGroupByMV(5, groupKeysArray, holder,
+        Map.of(EXPRESSION, SyntheticBlockValSets.Double.create(null, new 
double[]{1.0, 2.0, 3.0, 4.0, 5.0})));
+
+    for (int groupKey = 0; groupKey < 2; groupKey++) {
+      Object result = function.extractGroupByResult(holder, groupKey);
+      assertTrue(result instanceof TDigest, "Group " + groupKey + " was not 
converted");
+      assertEquals(((TDigest) result).size(), 5L);
+    }
+  }
+
+  private static PercentileSmartTDigestAggregationFunction newFunction(int 
threshold) {
+    return new PercentileSmartTDigestAggregationFunction(
+        List.of(EXPRESSION, 
ExpressionContext.forLiteral(Literal.doubleValue(50.0)),
+            ExpressionContext.forLiteral(Literal.stringValue("THRESHOLD=" + 
threshold + ";COMPRESSION=20"))), false);
+  }
+
   private static PercentileSmartTDigestAggregationFunction newFunction() {
     return new PercentileSmartTDigestAggregationFunction(
         List.of(EXPRESSION, 
ExpressionContext.forLiteral(Literal.doubleValue(50.0)),
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/ApproximateFunctionOverrideIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/ApproximateFunctionOverrideIntegrationTest.java
new file mode 100644
index 00000000000..67596a1072f
--- /dev/null
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/ApproximateFunctionOverrideIntegrationTest.java
@@ -0,0 +1,202 @@
+/**
+ * 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.integration.tests;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.File;
+import java.util.List;
+import org.apache.commons.io.FileUtils;
+import org.apache.helix.model.HelixConfigScope;
+import org.apache.helix.model.builder.HelixConfigScopeBuilder;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+import org.apache.pinot.util.TestUtils;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+
+
+/// End-to-end test of the cluster config that rewrites exact aggregations 
into their approximate counterparts. It
+/// covers the two properties unit tests cannot show: the config takes effect 
on a running broker without a restart,
+/// and the rewrite is invisible to the caller apart from the flag on the 
response.
+public class ApproximateFunctionOverrideIntegrationTest extends 
BaseClusterIntegrationTestSet {
+  private static final String DISTINCT_COUNT_QUERY =
+      "SELECT Carrier, DISTINCTCOUNT(AirlineID) FROM mytable GROUP BY Carrier 
ORDER BY Carrier LIMIT 100";
+  private static final String COUNT_DISTINCT_QUERY =
+      "SELECT Carrier, COUNT(DISTINCT AirlineID) FROM mytable GROUP BY Carrier 
ORDER BY Carrier LIMIT 100";
+  private static final String PERCENTILE_QUERY =
+      "SELECT Carrier, PERCENTILE(ArrDelay, 90) FROM mytable GROUP BY Carrier 
ORDER BY Carrier LIMIT 100";
+
+  private HelixConfigScope _clusterScope;
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    TestUtils.ensureDirectoriesExistAndEmpty(_tempDir, _segmentDir, _tarDir);
+
+    startZk();
+    startController();
+    startBroker();
+    startServer();
+    _clusterScope =
+        new 
HelixConfigScopeBuilder(HelixConfigScope.ConfigScopeProperty.CLUSTER).forCluster(getHelixClusterName())
+            .build();
+
+    Schema schema = createSchema();
+    addSchema(schema);
+    TableConfig tableConfig = createOfflineTableConfig();
+    addTableConfig(tableConfig);
+
+    List<File> avroFiles = unpackAvroData(_tempDir);
+    ClusterIntegrationTestUtils.buildSegmentsFromAvro(avroFiles, tableConfig, 
schema, 0, _segmentDir, _tarDir);
+    uploadSegments(getTableName(), _tarDir);
+    waitForAllDocsLoaded(600_000L);
+  }
+
+  /// Clears the config after every method, so that a method that fails part 
way cannot make the next one fail too.
+  @AfterMethod
+  public void clearOverride() {
+    clearApproximateFunctionOverride();
+    waitForOverride(false);
+  }
+
+  @AfterClass
+  public void tearDown()
+      throws Exception {
+    dropOfflineTable(getTableName());
+    stopServer();
+    stopBroker();
+    stopController();
+    stopZk();
+    FileUtils.deleteDirectory(_tempDir);
+  }
+
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testClusterConfigAppliesWithoutRestart(boolean 
useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+
+    JsonNode exactDistinctCount = postQuery(DISTINCT_COUNT_QUERY);
+    JsonNode exactPercentile = postQuery(PERCENTILE_QUERY);
+    assertApplied(exactDistinctCount, false);
+    assertApplied(exactPercentile, false);
+
+    // A threshold far above the cardinality of the test data keeps the 
rewritten functions exact, which is what makes
+    // the answers comparable before and after the flip.
+    setApproximateFunctionOverride("threshold=1000000");
+    waitForOverride(true);
+
+    JsonNode distinctCount = postQuery(DISTINCT_COUNT_QUERY);
+    assertApplied(distinctCount, true);
+    assertSameResult(exactDistinctCount, distinctCount);
+    JsonNode percentile = postQuery(PERCENTILE_QUERY);
+    assertApplied(percentile, true);
+    assertSameResult(exactPercentile, percentile);
+
+    // COUNT(DISTINCT x) is the standard SQL spelling and must be rewritten 
too.
+    assertApplied(postQuery(COUNT_DISTINCT_QUERY), true);
+
+    // The query option is the escape hatch back to exact results.
+    JsonNode forcedExact = postQuery("SET useApproximateFunction = false; " + 
DISTINCT_COUNT_QUERY);
+    assertApplied(forcedExact, false);
+    assertSameResult(exactDistinctCount, forcedExact);
+
+    // A threshold below the cardinality has to make the answer approximate 
rather than fail, keeping the same shape.
+    // This is also what proves the parameters reach the servers and parse 
there.
+    setApproximateFunctionOverride("threshold=1");
+    waitForOverride(true);
+    for (JsonNode exact : List.of(exactDistinctCount, exactPercentile)) {
+      String query = exact == exactDistinctCount ? DISTINCT_COUNT_QUERY : 
PERCENTILE_QUERY;
+      JsonNode approximate = postQuery(query);
+      assertApplied(approximate, true);
+      assertEquals(approximate.get("exceptions").size(), 0, 
approximate.toString());
+      assertEquals(columnDataTypes(approximate), columnDataTypes(exact));
+      assertEquals(rows(approximate).size(), rows(exact).size());
+    }
+  }
+
+  /// Pins the one visible difference the rewrite makes to a single-stage 
response, so that changing it is a
+  /// deliberate decision. The multi-stage engine keeps the Calcite-derived 
column name.
+  @Test
+  public void testSingleStageRenamesTheColumn()
+      throws Exception {
+    setUseMultiStageQueryEngine(false);
+    assertEquals(columnName(postQuery(DISTINCT_COUNT_QUERY)), 
"distinctcount(AirlineID)");
+
+    setApproximateFunctionOverride("threshold=1000000");
+    waitForOverride(true);
+    assertEquals(columnName(postQuery(DISTINCT_COUNT_QUERY)), 
"distinctcountsmarthll(AirlineID)");
+
+    // An explicit alias is the way to keep the old name.
+    assertEquals(columnName(postQuery(
+        "SELECT Carrier, DISTINCTCOUNT(AirlineID) AS dc FROM mytable GROUP BY 
Carrier ORDER BY Carrier LIMIT 100")),
+        "dc");
+  }
+
+  /// Compares the part of the result the rewrite must not change: the values 
and their types. The column name is
+  /// excluded on purpose, see [#testSingleStageRenamesTheColumn].
+  private static void assertSameResult(JsonNode expected, JsonNode actual) {
+    assertEquals(columnDataTypes(actual), columnDataTypes(expected));
+    assertEquals(rows(actual), rows(expected));
+  }
+
+  private static void assertApplied(JsonNode response, boolean expected) {
+    assertEquals(response.get("approximateFunctionApplied").asBoolean(), 
expected, response.toString());
+  }
+
+  private static JsonNode columnDataTypes(JsonNode response) {
+    return 
response.get("resultTable").get("dataSchema").get("columnDataTypes");
+  }
+
+  private static JsonNode rows(JsonNode response) {
+    return response.get("resultTable").get("rows");
+  }
+
+  private static String columnName(JsonNode response) {
+    return 
response.get("resultTable").get("dataSchema").get("columnNames").get(1).asText();
+  }
+
+  private void setApproximateFunctionOverride(String params) {
+    _helixManager.getConfigAccessor().set(_clusterScope, 
Broker.USE_APPROXIMATE_FUNCTION, "true");
+    _helixManager.getConfigAccessor().set(_clusterScope, 
Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS, params);
+    _helixManager.getConfigAccessor().set(_clusterScope, 
Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS, params);
+  }
+
+  /// Removes the keys, so the broker falls back to its own conf.
+  private void clearApproximateFunctionOverride() {
+    _helixManager.getConfigAccessor().remove(_clusterScope, 
Broker.USE_APPROXIMATE_FUNCTION);
+    _helixManager.getConfigAccessor().remove(_clusterScope, 
Broker.APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS);
+    _helixManager.getConfigAccessor().remove(_clusterScope, 
Broker.APPROXIMATE_FUNCTION_PERCENTILE_PARAMS);
+  }
+
+  /// The broker picks the change up from a ZooKeeper watch, so the test waits 
for it instead of restarting anything.
+  private void waitForOverride(boolean expected) {
+    TestUtils.waitForCondition(aVoid -> {
+      try {
+        return 
postQuery(DISTINCT_COUNT_QUERY).get("approximateFunctionApplied").asBoolean() 
== expected;
+      } catch (Exception e) {
+        return false;
+      }
+    }, 100L, 60_000L, "Broker did not pick up the approximate function cluster 
config");
+  }
+}
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotApproximateAggregateRewriteRule.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotApproximateAggregateRewriteRule.java
new file mode 100644
index 00000000000..5f2bf560704
--- /dev/null
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotApproximateAggregateRewriteRule.java
@@ -0,0 +1,191 @@
+/**
+ * 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.calcite.rel.rules;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.calcite.plan.Context;
+import org.apache.calcite.plan.RelOptRule;
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Aggregate;
+import org.apache.calcite.rel.core.AggregateCall;
+import org.apache.calcite.rel.logical.LogicalAggregate;
+import org.apache.calcite.rel.logical.LogicalProject;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.SqlAggFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.tools.RelBuilderFactory;
+import org.apache.pinot.common.function.sql.PinotSqlAggFunction;
+import org.apache.pinot.query.QueryEnvironment;
+import org.apache.pinot.query.context.PlannerContext;
+import org.apache.pinot.segment.spi.AggregationFunctionType;
+import org.apache.pinot.spi.utils.CommonConstants.Broker.PlannerRuleNames;
+
+
+/// Rewrites exact aggregations into their threshold-based approximate 
counterparts, so that one cluster config can
+/// stop unbounded per-group accumulators from taking servers down:
+/// - `DISTINCT_COUNT(x)` and `COUNT(DISTINCT x)` -> 
`DISTINCT_COUNT_SMART_HLL(x)`
+/// - `PERCENTILE(x, p)` -> `PERCENTILE_SMART_TDIGEST(x, p)`
+///
+/// This is the multi-stage counterpart of 
`BaseSingleStageBrokerRequestHandler.handleApproximateFunctionOverride`,
+/// gated by the same resolved setting, read from 
[QueryEnvironment.Config#useApproximateFunction()]. The rewrite keeps
+/// the original return type, so switching the config on does not change the 
result schema.
+///
+/// It must run before [PinotAggregateExchangeNodeInsertRule], which derives 
the leaf-to-final intermediate result
+/// format from the function name, hence `Phase.BASIC` rather than that rule's 
`POST_LOGICAL`. That is also why the
+/// runtime is not a valid place to do this rewrite.
+public class PinotApproximateAggregateRewriteRule extends RelOptRule {
+  public static final PinotApproximateAggregateRewriteRule INSTANCE =
+      new 
PinotApproximateAggregateRewriteRule(PinotRuleUtils.PINOT_REL_FACTORY);
+
+  private PinotApproximateAggregateRewriteRule(RelBuilderFactory factory) {
+    super(operand(LogicalAggregate.class, any()), factory, 
PlannerRuleNames.APPROXIMATE_AGGREGATE_REWRITE);
+  }
+
+  @Override
+  public boolean matches(RelOptRuleCall call) {
+    QueryEnvironment.Config envConfig = envConfig(call);
+    if (envConfig == null || !envConfig.useApproximateFunction()) {
+      return false;
+    }
+    // Requiring a rewritable call is also what makes the rule terminate: the 
rewritten names no longer match.
+    Aggregate aggRel = call.rel(0);
+    return aggRel.getAggCallList().stream().anyMatch(aggCall -> 
targetOf(aggCall) != null);
+  }
+
+  @Override
+  public void onMatch(RelOptRuleCall call) {
+    Aggregate aggRel = call.rel(0);
+    QueryEnvironment.Config envConfig = 
Objects.requireNonNull(envConfig(call));
+    RelNode input = aggRel.getInput();
+    int numInputFields = input.getRowType().getFieldCount();
+
+    boolean hasDistinctCount = false;
+    boolean hasPercentile = false;
+    for (AggregateCall aggCall : aggRel.getAggCallList()) {
+      AggregationFunctionType target = targetOf(aggCall);
+      hasDistinctCount |= target == 
AggregationFunctionType.DISTINCTCOUNTSMARTHLL;
+      hasPercentile |= target == 
AggregationFunctionType.PERCENTILESMARTTDIGEST;
+    }
+    String distinctCountParams = hasDistinctCount ? 
envConfig.approximateFunctionDistinctCountParams() : "";
+    String percentileParams = hasPercentile ? 
envConfig.approximateFunctionPercentileParams() : "";
+
+    // The parameters must be input fields, because an aggregate call holds 
field indices rather than inline literals,
+    // so they are projected underneath the aggregate. With no parameters 
configured the plan keeps the shape it had
+    // before, with no extra project.
+    //
+    // Invariant: this project has to stay directly beneath the aggregate. 
Both PinotAggregateExchangeNodeInsertRule
+    // and AggregatePushdownRule inline the literal only through 
findImmediateProjects(input), so anything that puts a
+    // node between them would leave the leaf with a column reference where 
the function expects a parameter string.
+    // Nothing in the later phases does: AggregateProjectMergeRule needs an 
all-input-ref project and the literals
+    // block it, and ProjectMergeRule collapses into a single project that 
stays adjacent.
+    RelNode newInput = input;
+    int distinctCountParamsIndex = -1;
+    int percentileParamsIndex = -1;
+    if (!distinctCountParams.isEmpty() || !percentileParams.isEmpty()) {
+      RexBuilder rexBuilder = aggRel.getCluster().getRexBuilder();
+      List<RexNode> projects = new ArrayList<>(numInputFields + 2);
+      for (int i = 0; i < numInputFields; i++) {
+        projects.add(rexBuilder.makeInputRef(input, i));
+      }
+      if (!distinctCountParams.isEmpty()) {
+        distinctCountParamsIndex = projects.size();
+        projects.add(rexBuilder.makeLiteral(distinctCountParams));
+      }
+      if (!percentileParams.isEmpty()) {
+        percentileParamsIndex = projects.size();
+        projects.add(rexBuilder.makeLiteral(percentileParams));
+      }
+      newInput = LogicalProject.create(input, List.of(), projects, 
(List<String>) null);
+    }
+
+    List<AggregateCall> rewrittenAggCalls = new 
ArrayList<>(aggRel.getAggCallList().size());
+    for (AggregateCall aggCall : aggRel.getAggCallList()) {
+      AggregationFunctionType target = targetOf(aggCall);
+      if (target == null) {
+        rewrittenAggCalls.add(aggCall);
+        continue;
+      }
+      int paramsIndex = target == AggregationFunctionType.DISTINCTCOUNTSMARTHLL
+          ? distinctCountParamsIndex : percentileParamsIndex;
+      List<Integer> argList = aggCall.getArgList();
+      if (paramsIndex >= 0) {
+        argList = new ArrayList<>(argList);
+        argList.add(paramsIndex);
+      }
+      rewrittenAggCalls.add(rewrite(aggCall, target, argList, newInput, 
aggRel.getGroupCount()));
+    }
+
+    PlannerContext plannerContext = 
call.getPlanner().getContext().unwrap(PlannerContext.class);
+    if (plannerContext != null) {
+      plannerContext.setApproximateFunctionApplied();
+    }
+    call.transformTo(
+        aggRel.copy(aggRel.getTraitSet(), newInput, aggRel.getGroupSet(), 
aggRel.getGroupSets(), rewrittenAggCalls));
+  }
+
+  /// Returns the approximate function this call should become, or `null` when 
the call must be left alone.
+  @Nullable
+  private static AggregationFunctionType targetOf(AggregateCall aggCall) {
+    SqlAggFunction aggFunction = aggCall.getAggregation();
+    if (aggCall.isDistinct()) {
+      // COUNT(DISTINCT x) is the standard SQL spelling of DISTINCT_COUNT, and 
PinotAggregateExchangeNodeInsertRule
+      // only renames it in POST_LOGICAL, after this rule, so it has to be 
matched on the kind here. Multi-argument
+      // COUNT(DISTINCT a, b) means something else and is left alone.
+      return aggFunction.getKind() == SqlKind.COUNT && 
aggCall.getArgList().size() == 1
+          ? AggregationFunctionType.DISTINCTCOUNTSMARTHLL : null;
+    }
+    // The smart functions take multi-valued input themselves, so the MV 
spellings map onto the same targets, which
+    // keeps this in step with the single-stage rewrite.
+    String name = 
AggregationFunctionType.getNormalizedAggregationFunctionName(aggFunction.getName());
+    if (name.equals(AggregationFunctionType.DISTINCTCOUNT.name())
+        || name.equals(AggregationFunctionType.DISTINCTCOUNTMV.name())) {
+      return AggregationFunctionType.DISTINCTCOUNTSMARTHLL;
+    }
+    if (name.equals(AggregationFunctionType.PERCENTILE.name())
+        || name.equals(AggregationFunctionType.PERCENTILEMV.name())) {
+      return AggregationFunctionType.PERCENTILESMARTTDIGEST;
+    }
+    return null;
+  }
+
+  private static AggregateCall rewrite(AggregateCall aggCall, 
AggregationFunctionType target, List<Integer> argList,
+      RelNode input, int groupCount) {
+    // Pinning the original type keeps the rewrite invisible to the caller: 
PERCENTILE infers ARG0 while
+    // PERCENTILE_SMART_TDIGEST infers DOUBLE, and a silent cluster-wide 
rewrite must not change the result schema.
+    SqlAggFunction newAggFunction = new PinotSqlAggFunction(target.name(), 
SqlKind.OTHER_FUNCTION,
+        ReturnTypes.explicit(aggCall.getType()), 
target.getOperandTypeChecker(),
+        SqlFunctionCategory.USER_DEFINED_FUNCTION);
+    return AggregateCall.create(newAggFunction, false, 
aggCall.isApproximate(), aggCall.ignoreNulls(), argList,
+        aggCall.filterArg, aggCall.distinctKeys, aggCall.getCollation(), 
groupCount, input, aggCall.getType(),
+        aggCall.getName());
+  }
+
+  @Nullable
+  private static QueryEnvironment.Config envConfig(RelOptRuleCall call) {
+    Context context = call.getPlanner().getContext();
+    return context != null ? context.unwrap(QueryEnvironment.Config.class) : 
null;
+  }
+}
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
index 38822d4dfad..f62294c58ef 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
@@ -175,6 +175,10 @@ public class PinotQueryRuleSets {
       PinotAggregateFunctionRewriteRule
           
.instanceWithDescription(PlannerRuleNames.AGGREGATE_FUNCTION_REWRITE),
 
+      // Must stay ahead of PinotAggregateExchangeNodeInsertRule 
(POST_LOGICAL), which fixes the leaf-to-final
+      // intermediate result format from the function name.
+      PinotApproximateAggregateRewriteRule.INSTANCE,
+
       // convert CASE-style filtered aggregates into true filtered aggregates
       // put it after AGGREGATE_REDUCE_FUNCTIONS where SUM is converted to SUM0
       AggregateCaseToFilterRule.Config.DEFAULT
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
index 3f30e35547a..d07f2089082 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
@@ -805,6 +805,25 @@ public class QueryEnvironment {
       return CommonConstants.Broker.DEFAULT_MSE_ENABLE_GROUP_TRIM;
     }
 
+    /// Whether to rewrite exact aggregations into their approximate 
counterparts, already resolved from the query
+    /// option and the defaults. See 
[CommonConstants.Broker#USE_APPROXIMATE_FUNCTION].
+    @Value.Default
+    default boolean useApproximateFunction() {
+      return CommonConstants.Broker.DEFAULT_USE_APPROXIMATE_FUNCTION;
+    }
+
+    /// Parameters appended to the rewritten calls, empty for the aggregation 
function defaults.
+    /// See 
[CommonConstants.Broker#APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS].
+    @Value.Default
+    default String approximateFunctionDistinctCountParams() {
+      return CommonConstants.Broker.DEFAULT_APPROXIMATE_FUNCTION_PARAMS;
+    }
+
+    @Value.Default
+    default String approximateFunctionPercentileParams() {
+      return CommonConstants.Broker.DEFAULT_APPROXIMATE_FUNCTION_PARAMS;
+    }
+
     @Value.Default
     default boolean defaultEnableDynamicFilteringSemiJoin() {
       return CommonConstants.Broker.DEFAULT_ENABLE_DYNAMIC_FILTERING_SEMI_JOIN;
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/context/PlannerContext.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/context/PlannerContext.java
index 34f1c4c6965..f5748199acb 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/context/PlannerContext.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/context/PlannerContext.java
@@ -61,6 +61,10 @@ public class PlannerContext implements AutoCloseable, 
Context {
   private final SqlExplainFormat _sqlExplainFormat;
   @Nullable
   private final PhysicalPlannerContext _physicalPlannerContext;
+  /// Set by the approximate aggregation rewrite rule when it rewrites at 
least one aggregation, so that the broker
+  /// can report on the response that the results are approximate. Written 
during planning, which is single threaded
+  /// for a query, and read after planning completes.
+  private boolean _approximateFunctionApplied;
 
   public PlannerContext(FrameworkConfig config, Prepare.CatalogReader 
catalogReader, RelDataTypeFactory typeFactory,
       HepProgram optProgram, HepProgram traitProgram, Map<String, String> 
options, QueryEnvironment.Config envConfig,
@@ -123,6 +127,16 @@ public class PlannerContext implements AutoCloseable, 
Context {
     return _envConfig;
   }
 
+  /// Records that an exact aggregation was rewritten into its approximate 
counterpart.
+  public void setApproximateFunctionApplied() {
+    _approximateFunctionApplied = true;
+  }
+
+  /// Returns whether any exact aggregation was rewritten into its approximate 
counterpart while planning this query.
+  public boolean isApproximateFunctionApplied() {
+    return _approximateFunctionApplied;
+  }
+
   /// Unwraps this context. Returns `this` when asked for [PlannerContext] or
   /// [Context], and delegates to [#_envConfig] when asked for
   /// [QueryEnvironment.Config] so that existing rules remain compatible.
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/ApproximateAggregateRewriteTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/ApproximateAggregateRewriteTest.java
new file mode 100644
index 00000000000..7a93ab1966e
--- /dev/null
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/ApproximateAggregateRewriteTest.java
@@ -0,0 +1,162 @@
+/**
+ * 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.query;
+
+import java.util.Map;
+import org.apache.pinot.core.routing.MockRoutingManagerFactory;
+import org.apache.pinot.core.routing.RoutingManager;
+import org.apache.pinot.query.routing.WorkerManager;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Tests the multi-stage counterpart of the single-stage approximate function 
override.
+public class ApproximateAggregateRewriteTest extends QueryEnvironmentTestBase {
+
+  @Test
+  public void testDisabledByDefault() {
+    String plan = explain(newQueryEnvironment(false, "", ""), "SELECT 
DISTINCTCOUNT(col1) FROM a");
+    assertTrue(plan.toLowerCase().contains("distinctcount"), plan);
+    assertFalse(plan.toLowerCase().contains("distinctcountsmarthll"), plan);
+  }
+
+  @Test
+  public void testRewritesDistinctCountAndPercentile() {
+    QueryEnvironment env = newQueryEnvironment(true, "", "");
+
+    assertRewritten(explain(env, "SELECT DISTINCTCOUNT(col1) FROM a"), 
"distinctcountsmarthll");
+    assertRewritten(explain(env, "SELECT col2, DISTINCTCOUNT(col1) FROM a 
GROUP BY col2"), "distinctcountsmarthll");
+    assertRewritten(explain(env, "SELECT PERCENTILE(col3, 90) FROM a"), 
"percentilesmarttdigest");
+    assertRewritten(explain(env, "SELECT col2, PERCENTILE(col3, 90) FROM a 
GROUP BY col2"), "percentilesmarttdigest");
+
+    // COUNT(DISTINCT x) is the standard SQL spelling, and the plan only 
renames it to DISTINCTCOUNT after this rule
+    // has run, so the rule has to recognise it by kind.
+    assertRewritten(explain(env, "SELECT COUNT(DISTINCT col1) FROM a"), 
"distinctcountsmarthll");
+    assertRewritten(explain(env, "SELECT col2, COUNT(DISTINCT col1) FROM a 
GROUP BY col2"), "distinctcountsmarthll");
+
+    // The multi-valued spellings too, so that the two engines agree.
+    assertRewritten(explain(env, "SELECT DISTINCTCOUNTMV(mcol1) FROM e"), 
"distinctcountsmarthll");
+    assertRewritten(explain(env, "SELECT PERCENTILEMV(mcol2, 90) FROM e"), 
"percentilesmarttdigest");
+  }
+
+  @Test
+  public void testLeavesOtherAggregationsAlone() {
+    QueryEnvironment env = newQueryEnvironment(true, "", "");
+    assertNotRewritten(explain(env, "SELECT DISTINCTCOUNTHLL(col1) FROM a"));
+    assertNotRewritten(explain(env, "SELECT PERCENTILETDIGEST(col3, 90) FROM 
a"));
+    assertNotRewritten(explain(env, "SELECT SUM(col3), COUNT(*) FROM a"));
+    // COUNT(DISTINCT a, b) means something different from DISTINCT_COUNT.
+    assertNotRewritten(explain(env, "SELECT COUNT(DISTINCT col1, col2) FROM 
a"));
+  }
+
+  @Test
+  public void testAppendsConfiguredParams() {
+    QueryEnvironment env = newQueryEnvironment(true, "threshold=17", 
"threshold=23;compression=50");
+
+    // Separate queries, so that each parameter string is seen to reach only 
its own function.
+    String distinctCount = explain(env, "SELECT col2, DISTINCTCOUNT(col1) FROM 
a GROUP BY col2");
+    assertRewritten(distinctCount, "distinctcountsmarthll");
+    assertTrue(distinctCount.contains("threshold=17"), distinctCount);
+    assertFalse(distinctCount.contains("threshold=23"), distinctCount);
+
+    String percentile = explain(env, "SELECT col2, PERCENTILE(col3, 90) FROM a 
GROUP BY col2");
+    assertRewritten(percentile, "percentilesmarttdigest");
+    assertTrue(percentile.contains("threshold=23;compression=50"), percentile);
+
+    // Both in one query needs two literals in the same project.
+    String both = explain(env, "SELECT col2, DISTINCTCOUNT(col1), 
PERCENTILE(col3, 90) FROM a GROUP BY col2");
+    assertRewritten(both, "distinctcountsmarthll");
+    assertRewritten(both, "percentilesmarttdigest");
+    assertTrue(both.contains("threshold=17"), both);
+    assertTrue(both.contains("threshold=23;compression=50"), both);
+  }
+
+  /// The parameter literal has to stay in the project directly beneath the 
aggregate, or the split rules cannot
+  /// inline it. These shapes put a join and a subquery under the aggregate, 
unlike the single-table cases above.
+  @Test
+  public void testAppendsParamsUnderAJoinAndASubquery() {
+    QueryEnvironment env = newQueryEnvironment(true, "threshold=17", 
"threshold=23");
+
+    String join = explain(env,
+        "SELECT a.col2, DISTINCTCOUNT(a.col1) FROM a JOIN b ON a.col1 = b.col1 
GROUP BY a.col2");
+    assertRewritten(join, "distinctcountsmarthll");
+    assertTrue(join.contains("threshold=17"), join);
+
+    String subquery = explain(env,
+        "SELECT col2, PERCENTILE(col3, 90) FROM (SELECT col2, col3 FROM a 
WHERE col3 > 0) GROUP BY col2");
+    assertRewritten(subquery, "percentilesmarttdigest");
+    assertTrue(subquery.contains("threshold=23"), subquery);
+  }
+
+  /// The physical optimizer splits aggregates in its own rule, which also 
keys the leaf-to-final intermediate format
+  /// off the function name, so the rewrite has to hold on that path too.
+  @Test
+  public void testRewritesUnderThePhysicalOptimizer() {
+    QueryEnvironment env = newQueryEnvironment(true, "threshold=17", 
"threshold=23", true);
+    String distinctCount = explain(env, "SELECT col2, DISTINCTCOUNT(col1) FROM 
a GROUP BY col2");
+    assertRewritten(distinctCount, "distinctcountsmarthll");
+    assertTrue(distinctCount.contains("threshold=17"), distinctCount);
+
+    String percentile = explain(env, "SELECT col2, PERCENTILE(col3, 90) FROM a 
GROUP BY col2");
+    assertRewritten(percentile, "percentilesmarttdigest");
+    assertTrue(percentile.contains("threshold=23"), percentile);
+  }
+
+  private static void assertRewritten(String plan, String expectedFunction) {
+    assertTrue(plan.toLowerCase().contains(expectedFunction), "Expected " + 
expectedFunction + " in:\n" + plan);
+  }
+
+  private static void assertNotRewritten(String plan) {
+    assertFalse(plan.toLowerCase().contains("smart"), "Unexpected rewrite 
in:\n" + plan);
+  }
+
+  private static String explain(QueryEnvironment env, String query) {
+    return env.explainQuery("EXPLAIN PLAN FOR " + query, 
RANDOM_REQUEST_ID_GEN.nextLong());
+  }
+
+  private static QueryEnvironment newQueryEnvironment(boolean 
useApproximateFunction, String distinctCountParams,
+      String percentileParams) {
+    return newQueryEnvironment(useApproximateFunction, distinctCountParams, 
percentileParams, false);
+  }
+
+  private static QueryEnvironment newQueryEnvironment(boolean 
useApproximateFunction, String distinctCountParams,
+      String percentileParams, boolean usePhysicalOptimizer) {
+    MockRoutingManagerFactory factory = new MockRoutingManagerFactory(1, 2);
+    for (Map.Entry<String, Schema> entry : TABLE_SCHEMAS.entrySet()) {
+      factory.registerTable(entry.getValue(), entry.getKey());
+    }
+    SERVER1_SEGMENTS.forEach((table, segments) -> segments.forEach(s -> 
factory.registerSegment(1, table, s)));
+    SERVER2_SEGMENTS.forEach((table, segments) -> segments.forEach(s -> 
factory.registerSegment(2, table, s)));
+    RoutingManager routingManager = factory.buildRoutingManager(null);
+    return new QueryEnvironment(QueryEnvironment.configBuilder()
+        .requestId(-1L)
+        .database(CommonConstants.DEFAULT_DATABASE)
+        .tableCache(factory.buildTableCache())
+        .workerManager(new WorkerManager("Broker_localhost", "localhost", 3, 
routingManager))
+        .useApproximateFunction(useApproximateFunction)
+        .approximateFunctionDistinctCountParams(distinctCountParams)
+        .approximateFunctionPercentileParams(percentileParams)
+        .defaultUsePhysicalOptimizer(usePhysicalOptimizer)
+        .build());
+  }
+}
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
index 080138b3a32..06d04efbb00 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
@@ -90,9 +90,11 @@ public enum AggregationFunctionType {
   DISTINCTCOUNTRAWHLL("distinctCountRawHLL", ReturnTypes.VARCHAR,
       OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.INTEGER), i 
-> i == 1), SqlTypeName.OTHER),
   DISTINCTCOUNTSMARTHLL("distinctCountSmartHLL", ReturnTypes.BIGINT,
-      OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER), 
i -> i == 1), SqlTypeName.OTHER),
+      OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER), 
i -> i == 1), SqlTypeName.OTHER,
+      SqlTypeName.INTEGER),
   DISTINCTCOUNTSMARTHLLPLUS("distinctCountSmartHLLPlus", ReturnTypes.BIGINT,
-      OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER), 
i -> i == 1), SqlTypeName.OTHER),
+      OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER), 
i -> i == 1), SqlTypeName.OTHER,
+      SqlTypeName.INTEGER),
   @Deprecated FASTHLL("fastHLL"),
   DISTINCTCOUNTHLLPLUS("distinctCountHLLPlus", ReturnTypes.BIGINT,
       OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.INTEGER), i 
-> i == 1), SqlTypeName.OTHER),
@@ -103,7 +105,8 @@ public enum AggregationFunctionType {
   DISTINCTCOUNTRAWULL("distinctCountRawULL", ReturnTypes.VARCHAR,
       OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.INTEGER), i 
-> i == 1), SqlTypeName.OTHER),
   DISTINCTCOUNTSMARTULL("distinctCountSmartULL", ReturnTypes.BIGINT,
-      OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER), 
i -> i == 1), SqlTypeName.OTHER),
+      OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER), 
i -> i == 1), SqlTypeName.OTHER,
+      SqlTypeName.INTEGER),
   DISTINCTCOUNTTHETASKETCH("distinctCountThetaSketch", ReturnTypes.BIGINT, 
OperandTypes.ONE_OR_MORE, SqlTypeName.OTHER),
   DISTINCTCOUNTRAWTHETASKETCH("distinctCountRawThetaSketch", 
ReturnTypes.VARCHAR, OperandTypes.ONE_OR_MORE,
       SqlTypeName.OTHER),
@@ -129,9 +132,12 @@ public enum AggregationFunctionType {
   PERCENTILERAWTDIGEST("percentileRawTDigest", ReturnTypes.VARCHAR,
       OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.NUMERIC, 
SqlTypeFamily.INTEGER), i -> i == 2),
       SqlTypeName.OTHER),
+  // The final return type is redundant with the standard return type above, 
and is declared because
+  // PinotApproximateAggregateRewriteRule pins the rewritten call to 
PERCENTILE's type, which is ARG0. Without it a
+  // leaf that returns the final result is typed from that pinned type and 
truncates on a non-DOUBLE column.
   PERCENTILESMARTTDIGEST("percentileSmartTDigest", ReturnTypes.DOUBLE,
       OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.NUMERIC, 
SqlTypeFamily.CHARACTER), i -> i == 2),
-      SqlTypeName.OTHER),
+      SqlTypeName.OTHER, SqlTypeName.DOUBLE),
   PERCENTILEKLL("percentileKLL", ReturnTypes.DOUBLE,
       OperandTypes.family(List.of(SqlTypeFamily.ANY, SqlTypeFamily.NUMERIC, 
SqlTypeFamily.INTEGER), i -> i == 2),
       SqlTypeName.OTHER),
@@ -371,6 +377,13 @@ public enum AggregationFunctionType {
   public static AggregationFunctionType getAggregationFunctionType(String 
functionName) {
     String normalizedFunctionName = 
getNormalizedAggregationFunctionName(functionName);
     if (normalizedFunctionName.regionMatches(false, 0, "PERCENTILE", 0, 10)) {
+      // A canonical name wins over the numeric-suffix spellings below. 
Without this, names that are canonical but
+      // are not one of those spellings, such as PERCENTILESMARTTDIGEST, would 
fall through and be rejected.
+      try {
+        return AggregationFunctionType.valueOf(normalizedFunctionName);
+      } catch (IllegalArgumentException ignored) {
+        // Not a canonical name, so try the numeric-suffix spellings.
+      }
       // This style of aggregation functions is not supported in the 
multistage engine
       String remainingFunctionName = 
normalizedFunctionName.substring(10).toUpperCase();
       if (remainingFunctionName.isEmpty() || 
remainingFunctionName.matches("\\d+")) {
@@ -400,7 +413,7 @@ public enum AggregationFunctionType {
       } else if (remainingFunctionName.equals("KLLMV") || 
remainingFunctionName.matches("KLL\\d+MV")) {
         return PERCENTILEKLLMV;
       } else if (remainingFunctionName.equals("RAWKLLMV") || 
remainingFunctionName.matches("RAWKLL\\d+MV")) {
-        return PERCENTILEKLLMV;
+        return PERCENTILERAWKLLMV;
       } else {
         throw new IllegalArgumentException("Invalid aggregation function name: 
" + functionName);
       }
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index bb29493d497..7fbf935c687 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -541,10 +541,34 @@ public class CommonConstants {
     public static final String DISABLE_GROOVY = 
"pinot.broker.disable.query.groovy";
     public static final boolean DEFAULT_DISABLE_GROOVY = true;
 
-    // Rewrite potential expensive functions to their approximation 
counterparts
-    // - DISTINCT_COUNT -> DISTINCT_COUNT_SMART_HLL
-    // - PERCENTILE -> PERCENTILE_SMART_TDIGEST
+    /// Rewrite potential expensive functions to their approximation 
counterparts, in both query engines:
+    /// - DISTINCT_COUNT and COUNT(DISTINCT) -> DISTINCT_COUNT_SMART_HLL
+    /// - PERCENTILE -> PERCENTILE_SMART_TDIGEST
+    ///
+    /// The rewritten functions stay exact until an accumulator exceeds their 
conversion threshold, so this bounds
+    /// server memory without changing the answer for low-cardinality inputs.
+    ///
+    /// Settable in the broker conf, read once at startup, or in the Helix 
cluster config, which wins and which
+    /// brokers pick up without a restart. Both are defaults: the 
`useApproximateFunction` query option overrides
+    /// them, and so does `QueryConfig.useApproximateFunction`, though only in 
the single-stage engine, because a
+    /// multi-stage query can span tables and so resolves the setting before 
it knows the table set.
     public static final String USE_APPROXIMATE_FUNCTION = 
"pinot.broker.use.approximate.function";
+    public static final boolean DEFAULT_USE_APPROXIMATE_FUNCTION = false;
+
+    /// Parameters passed verbatim as the trailing argument of the calls the 
rewrite produces, for example
+    /// `threshold=10000;log2m=12;dictThreshold=10000` and 
`threshold=1000;compression=100`. Empty means no argument
+    /// is added, so the aggregation function defaults apply. The two 
functions reject each other's parameter names,
+    /// hence one key each.
+    ///
+    /// Before conversion a group holds up to `threshold` values; after it, a 
sketch whose registers are allocated
+    /// eagerly, around 2.7 KB at `log2m=12`. A low threshold therefore trades 
raw values for sketches and can raise
+    /// group-by memory rather than lower it, so size `threshold` together 
with `log2m` or `compression` and with
+    /// `pinot.server.query.executor.num.groups.limit` rather than in 
isolation.
+    public static final String APPROXIMATE_FUNCTION_DISTINCT_COUNT_PARAMS =
+        "pinot.broker.approximate.function.distinct.count.params";
+    public static final String APPROXIMATE_FUNCTION_PERCENTILE_PARAMS =
+        "pinot.broker.approximate.function.percentile.params";
+    public static final String DEFAULT_APPROXIMATE_FUNCTION_PARAMS = "";
 
     public static final String CONTROLLER_URL = "pinot.broker.controller.url";
 
@@ -762,6 +786,9 @@ public class CommonConstants {
 
       public static class QueryOptionKey {
         public static final String TIMEOUT_MS = "timeoutMs";
+        /// Per-query override of 
[CommonConstants.Broker#USE_APPROXIMATE_FUNCTION], outranking both the table 
config
+        /// and the cluster or broker default. `false` forces exact results, 
`true` opts one expensive query in.
+        public static final String USE_APPROXIMATE_FUNCTION = 
"useApproximateFunction";
         /// Broker-internal marker set on the rewritten server-side PinotQuery 
after a FULL_REWRITE
         /// materialized-view rewrite. Read by BrokerReduceService to 
distinguish MV-rewritten
         /// queries from gapfill / future federated paths without relying on a 
brittle structural
@@ -1157,6 +1184,8 @@ public class CommonConstants {
       public static final String AGGREGATE_UNION_TRANSPOSE = 
"AggregateUnionTranspose";
       public static final String AGGREGATE_REDUCE_FUNCTIONS = 
"AggregateReduceFunctions";
       public static final String AGGREGATE_FUNCTION_REWRITE = 
"AggregateFunctionRewrite";
+      /// Inert unless [CommonConstants.Broker#USE_APPROXIMATE_FUNCTION] is 
on; name it here to switch it off entirely.
+      public static final String APPROXIMATE_AGGREGATE_REWRITE = 
"ApproximateAggregateRewrite";
       public static final String AGGREGATE_CASE_TO_FILTER = 
"AggregateCaseToFilter";
       public static final String PROJECT_FILTER_TRANSPOSE = 
"ProjectFilterTranspose";
       public static final String PROJECT_MERGE = "ProjectMerge";


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

Reply via email to