This is an automated email from the ASF dual-hosted git repository.
gortiz 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 568df83b4f4 Add broker startup warmup for the query serve path (SSE)
(#19500)
568df83b4f4 is described below
commit 568df83b4f49c95328a57e378718993bfb166dc5
Author: Jinesh Parakh <[email protected]>
AuthorDate: Wed Sep 9 22:52:09 2026 +0530
Add broker startup warmup for the query serve path (SSE) (#19500)
---
.../broker/broker/helix/BaseBrokerStarter.java | 148 +++++-
.../requesthandler/BrokerRequestHandler.java | 27 ++
.../BrokerRequestHandlerDelegate.java | 8 +
.../broker/requesthandler/BrokerWarmupConfig.java | 60 +++
.../SingleConnectionBrokerRequestHandler.java | 536 +++++++++++++++++++++
.../broker/broker/helix/BrokerWarmupGateTest.java | 50 ++
.../broker/requesthandler/BrokerWarmupTest.java | 454 +++++++++++++++++
.../apache/pinot/common/metrics/BrokerGauge.java | 11 +
.../apache/pinot/common/metrics/BrokerMetrics.java | 7 +
.../apache/pinot/common/metrics/BrokerTimer.java | 3 +
.../tests/BrokerStartupWarmupIntegrationTest.java | 142 ++++++
.../apache/pinot/spi/utils/CommonConstants.java | 26 +
12 files changed, 1467 insertions(+), 5 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 e02549da2e9..826c732d4d5 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
@@ -18,6 +18,7 @@
*/
package org.apache.pinot.broker.broker.helix;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.io.IOException;
import java.net.InetAddress;
@@ -29,6 +30,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
import javax.annotation.Nullable;
import javax.net.ssl.SSLContext;
import nl.altindag.ssl.SSLFactory;
@@ -55,6 +57,7 @@ import
org.apache.pinot.broker.requesthandler.BaseSingleStageBrokerRequestHandle
import org.apache.pinot.broker.requesthandler.BrokerRequestHandler;
import org.apache.pinot.broker.requesthandler.BrokerRequestHandlerDelegate;
import org.apache.pinot.broker.requesthandler.BrokerRequestIdGenerator;
+import org.apache.pinot.broker.requesthandler.BrokerWarmupConfig;
import org.apache.pinot.broker.requesthandler.GrpcBrokerRequestHandler;
import org.apache.pinot.broker.requesthandler.MultiStageBrokerRequestHandler;
import org.apache.pinot.broker.requesthandler.MultiStageQueryThrottler;
@@ -145,6 +148,13 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
/// How often the pre-connect thread re-checks whether Helix has converged.
Short enough not to add
/// meaningful delay to a fast startup, long enough not to hammer the Helix
data accessor.
private static final long HELIX_CONVERGENCE_POLL_INTERVAL_MS = 200L;
+ /// How long shutdown waits for the interrupted warmup thread to unwind
before
+ /// giving up on it. Bounds the teardown race without letting a stuck thread
hold up shutdown.
+ private static final long STARTUP_THREAD_JOIN_TIMEOUT_MS = 1_000L;
+ /// Readiness description reported while the warmup gate holds the broker at
STARTING. Shared with tests so
+ /// the exact gate transition can be asserted.
+ @VisibleForTesting
+ static final String WARMUP_GATE_STARTING_DESCRIPTION = "Warming up broker
data plane";
protected PinotConfiguration _brokerConf;
protected List<ListenerConfig> _listenerConfigs;
@@ -193,6 +203,14 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
protected BrokerGrpcServer _brokerGrpcServer;
protected FailureDetector _failureDetector;
protected ThreadAccountant _threadAccountant;
+ /// Startup data-plane warmup config. Read once in `start()`.
+ protected BrokerWarmupConfig _warmupConfig =
+ new BrokerWarmupConfig(false, 0L, 0, 1);
+ /// Whether the data plane has been warmed. Gates readiness when warmup is
enabled; always true
+ /// otherwise, so the status callback behaves exactly as before for existing
deployments.
+ private volatile boolean _isWarm;
+ @Nullable
+ private volatile Thread _warmupThread;
/// The Helix-convergence half of the service-status composite, held so
startup pre-connect can wait
/// on exactly that signal -- convergence is the point at which routing, and
the servers it
/// references, first exist.
@@ -691,6 +709,11 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
Broker.DEFAULT_BROKER_STARTUP_PRECONNECT_ENABLED);
_preConnectTimeoutMs =
_brokerConf.getProperty(Broker.CONFIG_OF_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS,
Broker.DEFAULT_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS);
+ // Read the warmup config before registering the status handler: the
handler adds the warmup readiness
+ // gate only when warmup is enabled, and the gate must be in place before
the handler is registered so
+ // there is no window where readiness is granted un-gated.
+ _warmupConfig = BrokerWarmupConfig.from(_brokerConf);
+ // Register the service status handler
registerServiceStatusHandler();
if (_preConnectEnabled) {
// Startup is not finished until the broker-to-server channels are open,
so `_isStarting` stays set
@@ -703,6 +726,9 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
} else {
_isStarting = false;
}
+ // Warmup gates readiness on its own _isWarm flag (added to the status
handler above when enabled), so
+ // it composes with pre-connect's _isStarting gate: readiness is granted
only once both are satisfied.
+ startWarmup();
_brokerMetrics.addTimedValue(BrokerTimer.STARTUP_SUCCESS_DURATION_MS,
System.currentTimeMillis() - startTimeMs, TimeUnit.MILLISECONDS);
@@ -894,10 +920,96 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
new
ServiceStatus.IdealStateAndExternalViewMatchServiceStatusCallback(_participantHelixManager,
_clusterName, _instanceId, resourcesToMonitor,
minResourcePercentForStartup)));
+ List<ServiceStatus.ServiceStatusCallback> callbacks = new ArrayList<>(3);
+ callbacks.add(new
ServiceStatus.LifecycleServiceStatusCallback(this::isStarting,
this::isShuttingDown));
+ callbacks.add(_helixConvergenceCallback);
+ if (_warmupConfig.enabled()) {
+ // The warmup readiness gate. Reports STARTING (not a new status value)
until warmup completes:
+ // callers throughout the codebase test for GOOD, and a new enum
constant would be visible to older
+ // mixed-version peers. MultipleCallbackServiceStatusCallback surfaces
the first non-GOOD callback, so
+ // this composes without touching the Helix or lifecycle callbacks.
+ //
+ // Scope: this gates HTTP readiness only -- the /health endpoint
(getBrokerHealth -> ServiceStatus)
+ // that a load balancer or Kubernetes readiness probe polls, so a
warming broker is not put into
+ // rotation there. It does NOT change Helix discovery: the broker is
already ONLINE in the broker
+ // resource's external view by this point, so a client that resolves
brokers straight from Helix could
+ // still route to it while it warms. That is acceptable -- warmup only
makes the first queries faster,
+ // never wrong -- and keeping the broker in Helix is deliberate, since
removing it would be a
+ // routing/discovery change far beyond a startup latency optimization.
+ callbacks.add(warmupGateCallback(() -> _isWarm));
+ }
ServiceStatus.setServiceStatusCallback(_instanceId,
- new ServiceStatus.MultipleCallbackServiceStatusCallback(List.of(
- new ServiceStatus.LifecycleServiceStatusCallback(this::isStarting,
this::isShuttingDown),
- _helixConvergenceCallback)));
+ new ServiceStatus.MultipleCallbackServiceStatusCallback(callbacks));
+ }
+
+ /// The startup-warmup readiness gate as a standalone callback: reports
STARTING with the "warming up"
+ /// description until `isWarm` turns true, then GOOD with no description.
Extracted and package-private so
+ /// the STARTING -> GOOD transition can be unit-tested deterministically,
without standing up a broker or
+ /// racing an actual warmup to observe it mid-flight.
+ @VisibleForTesting
+ static ServiceStatus.ServiceStatusCallback
warmupGateCallback(BooleanSupplier isWarm) {
+ return new ServiceStatus.ServiceStatusCallback() {
+ @Override
+ public ServiceStatus.Status getServiceStatus() {
+ return isWarm.getAsBoolean() ? ServiceStatus.Status.GOOD :
ServiceStatus.Status.STARTING;
+ }
+
+ @Override
+ public String getStatusDescription() {
+ return isWarm.getAsBoolean() ? ServiceStatus.STATUS_DESCRIPTION_NONE :
WARMUP_GATE_STARTING_DESCRIPTION;
+ }
+ };
+ }
+
+ /// Runs the data-plane warmup on a background thread and flips [#_isWarm]
when it finishes.
+ ///
+ /// Asynchronous so `start()` still returns promptly -- the gate is enforced
through `ServiceStatus`, not
+ /// by blocking startup. The flag is set in a `finally` so the gate opens
even if warmup throws:
+ /// readiness held open indefinitely would stall a rolling restart, a worse
failure than serving a cold
+ /// broker. When disabled this is a no-op and readiness behaves exactly as
before.
+ private void startWarmup() {
+ // The gauge is published in both branches so dashboards can rely on it
always existing; with warmup
+ // disabled it simply reads 1 from the start, matching pre-change
behaviour.
+ _brokerMetrics.setOrUpdateGlobalGauge(BrokerGauge.STARTUP_WARMUP_COMPLETE,
() -> _isWarm ? 1L : 0L);
+ if (!_warmupConfig.enabled()) {
+ _isWarm = true;
+ return;
+ }
+ _warmupThread = new Thread(() -> {
+ // Set once Helix converges. Both the budget and the duration metric are
measured from here, not from
+ // thread start, so the deliberately unbounded convergence wait is
charged against neither: readiness
+ // is already withheld until convergence by the Helix callbacks, so it
costs nothing.
+ long warmStartMs = 0L;
+ try {
+ long threadStartMs = System.currentTimeMillis();
+ awaitHelixConvergence();
+ warmStartMs = System.currentTimeMillis();
+ LOGGER.info("Helix converged after {} ms; starting data-plane warmup",
warmStartMs - threadStartMs);
+ // Saturating add: a pathologically large budgetMs must not overflow
the deadline negative (which
+ // would make warmup a silent no-op). A negative/zero budget still
yields a past deadline (no-op),
+ // which is the intended fail-open behaviour.
+ long budgetMs = _warmupConfig.budgetMs();
+ long deadlineMs = budgetMs > Long.MAX_VALUE - warmStartMs ?
Long.MAX_VALUE : warmStartMs + budgetMs;
+ boolean reachedFloor = _brokerRequestHandler.warmUp(_warmupConfig,
deadlineMs);
+ LOGGER.info("Broker warmup finished in {} ms (reachedFloor={})",
System.currentTimeMillis() - warmStartMs,
+ reachedFloor);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ LOGGER.info("Broker warmup interrupted before completion");
+ } catch (Throwable t) {
+ LOGGER.warn("Broker warmup threw; opening readiness anyway", t);
+ } finally {
+ _isWarm = true;
+ // Record the duration only if convergence was reached, so the metric
measures the warmup work
+ // itself and never the (unbounded) convergence wait -- e.g. when
shutdown interrupts the wait.
+ if (warmStartMs > 0L) {
+ _brokerMetrics.addTimedValue(BrokerTimer.STARTUP_WARMUP_DURATION_MS,
+ System.currentTimeMillis() - warmStartMs, TimeUnit.MILLISECONDS);
+ }
+ }
+ }, "broker-startup-warmup");
+ _warmupThread.setDaemon(true);
+ _warmupThread.start();
}
/// Runs startup server pre-connect on a background thread and ends startup
([#_isStarting]) when it
@@ -958,8 +1070,18 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
}
}
- /// Interrupts an in-flight pre-connect so shutdown never waits on it. Best
effort: the thread is a
- /// daemon and records its metric in a `finally` regardless.
+ /// Interrupts an in-flight warmup so a broker stopped mid-warmup does not
keep issuing probe queries
+ /// against a request handler being torn down, then joins briefly so an
in-flight probe unwinds before the
+ /// handler is shut down (avoiding a probe racing a closing [QueryRouter]).
Bounded: the thread is a daemon
+ /// whose `finally` opens the gate regardless, so shutdown never waits on it
beyond the short join.
+ private void stopWarmup() {
+ interruptAndJoin(_warmupThread, "broker warmup");
+ }
+
+ /// Interrupts an in-flight pre-connect so shutdown never waits on it.
Interrupt-only (no join) so this
+ /// PR leaves the already-merged pre-connect feature's shutdown behaviour
unchanged; the join added for
+ /// warmup applies to the warmup thread only. Best effort: the thread is a
daemon that records its metric
+ /// in a `finally` regardless.
private void stopPreConnect() {
Thread thread = _preConnectThread;
if (thread != null && thread.isAlive()) {
@@ -968,6 +1090,21 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
}
}
+ /// Interrupts `thread` (if alive) and waits up to
[#STARTUP_THREAD_JOIN_TIMEOUT_MS] for it to unwind.
+ /// Best effort: a thread that does not stop in time is left to its daemon
`finally` and shutdown proceeds.
+ private static void interruptAndJoin(@Nullable Thread thread, String what) {
+ if (thread == null || !thread.isAlive()) {
+ return;
+ }
+ LOGGER.info("Interrupting in-flight {} for shutdown", what);
+ thread.interrupt();
+ try {
+ thread.join(STARTUP_THREAD_JOIN_TIMEOUT_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
private String getDefaultBrokerId() {
try {
return InetAddress.getLocalHost().getHostName();
@@ -1000,6 +1137,7 @@ public abstract class BaseBrokerStarter implements
ServiceStartable {
public void stop() {
LOGGER.info("Shutting down Pinot broker");
_isShuttingDown = true;
+ stopWarmup();
stopPreConnect();
LOGGER.info("Disconnecting participant Helix manager");
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
index c9dcc497277..57bb219d87d 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
@@ -47,6 +47,33 @@ public interface BrokerRequestHandler {
void shutDown();
+ /// Warms this handler's data plane so the first real query does not pay for
it, and reports whether the
+ /// handler reached its warmth floor. Called during startup after Helix
convergence, before readiness is
+ /// granted.
+ ///
+ /// Implementations must honour three contracts, because the caller depends
on each:
+ /// - **Bounded — must return by `deadlineMs`.** This is the load-bearing
clause of the feature: a
+ /// warmup that runs past the deadline stalls a rolling restart.
`deadlineMs` is an absolute
+ /// [System#currentTimeMillis] value, and every wait an implementation
performs (each probe, each
+ /// blocking get) must be bounded by the remaining budget, not a fixed
constant. Time already spent
+ /// waiting for the cluster view to converge counts against the same
budget, so the rolling-restart
+ /// cost stays bounded by one number.
+ /// - **Must never throw.** The caller opens the readiness gate off this
call; an exception must be
+ /// swallowed and treated as "did not reach the floor", never propagated.
+ /// - **Safe to run against a handler that is already serving.** Nothing
stops this being invoked on a
+ /// started, traffic-serving handler --
[org.apache.pinot.integration.tests] does exactly that, and an
+ /// admin endpoint wiring it later would too -- so an implementation
must not mutate shared serving
+ /// state or assume it is the only in-flight work.
+ ///
+ /// This gate composes with any other startup gate the broker registers
(e.g. [#preConnectServers(long)]):
+ /// each gate is independent with its own budget, and readiness is granted
only once **all** are satisfied.
+ ///
+ /// @return `true` if the handler reached its warmth floor, `false` if the
deadline passed first. Either
+ /// way the caller proceeds; the result is for logging and metrics.
+ default boolean warmUp(BrokerWarmupConfig config, long deadlineMs) {
+ return true;
+ }
+
/// Opens broker-to-server channels ahead of traffic so the first real query
does not pay the blocking
/// connect -- and, when broker-to-server TLS is on, the handshake -- on its
critical path. Called once
/// at startup after Helix has converged, when
`pinot.broker.startup.preconnect.enabled` is set.
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
index bca292de883..ee0586f2815 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
@@ -94,6 +94,14 @@ public class BrokerRequestHandlerDelegate implements
BrokerRequestHandler {
}
}
+ /// Warms only the single-stage handler. It owns the broker-to-server netty
channels, which is the data
+ /// plane that starts empty on a fresh broker; the multi-stage handler
already warms its own compile path
+ /// in `start()`, and the time-series handler shares the single-stage
transport.
+ @Override
+ public boolean warmUp(BrokerWarmupConfig config, long deadlineMs) {
+ return _singleStageBrokerRequestHandler.warmUp(config, deadlineMs);
+ }
+
@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/BrokerWarmupConfig.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerWarmupConfig.java
new file mode 100644
index 00000000000..b3225aac83f
--- /dev/null
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerWarmupConfig.java
@@ -0,0 +1,60 @@
+/**
+ * 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 org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+
+
+/// Settings for the broker startup warmup performed before readiness is
granted.
+///
+/// Warmup always runs the same static probe -- `SELECT * FROM "<t>" LIMIT 1`
over a set-cover of tables
+/// spanning every routable server -- with no query or table overrides to
configure.
+///
+/// The exit condition is a **depth floor OR a budget ceiling**, never a
latency guess:
+/// - [#budgetMs] is a hard ceiling. Warmup releases the readiness gate when
it expires whatever the
+/// probe progress, so a slow or unreachable server cannot stall a rolling
restart.
+/// - [#minIterations] is the depth floor: it declares the broker warm once
this many probe queries have
+/// completed successfully -- enough invocations to drive the query path's
JIT to its top tier. (A
+/// latency target was deliberately avoided: a trivial probe reaches low
latency after a handful of
+/// iterations while the code is still only partially compiled, so latency
is a false early-exit signal.)
+///
+/// [#concurrency] is how many probes are fired at once per round: serial (1,
the default) warms the
+/// serve path; a higher value additionally warms the concurrency step the
first real burst hits.
+///
+/// Immutable; safe to share across threads.
+public record BrokerWarmupConfig(boolean enabled, long budgetMs, int
minIterations, int concurrency) {
+
+ /// Upper bound on the probe concurrency. Warmup runs before any real
traffic, so a modest cap is plenty;
+ /// this only exists so a fat-fingered config value cannot ask for a
pathological thread pool (an
+ /// OutOfMemoryError creating native threads), which would defeat the whole
point of warming up.
+ private static final int MAX_CONCURRENCY = 64;
+
+ public static BrokerWarmupConfig from(PinotConfiguration config) {
+ return new BrokerWarmupConfig(
+ config.getProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_ENABLED,
+ Broker.DEFAULT_BROKER_STARTUP_WARMUP_ENABLED),
+ config.getProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_BUDGET_MS,
+ Broker.DEFAULT_BROKER_STARTUP_WARMUP_BUDGET_MS),
+ Math.max(1,
config.getProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_MIN_ITERATIONS,
+ Broker.DEFAULT_BROKER_STARTUP_WARMUP_MIN_ITERATIONS)),
+ Math.min(MAX_CONCURRENCY, Math.max(1,
config.getProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_CONCURRENCY,
+ Broker.DEFAULT_BROKER_STARTUP_WARMUP_CONCURRENCY))));
+ }
+}
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
index 4fd4ef47bac..ea9364d8af4 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
@@ -20,13 +20,22 @@ package org.apache.pinot.broker.requesthandler;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;
@@ -37,13 +46,19 @@ import org.apache.pinot.common.config.TlsConfig;
import org.apache.pinot.common.config.provider.TableCache;
import org.apache.pinot.common.datatable.DataTable;
import org.apache.pinot.common.failuredetector.FailureDetector;
+import org.apache.pinot.common.metrics.BrokerGauge;
import org.apache.pinot.common.metrics.BrokerMeter;
+import org.apache.pinot.common.metrics.BrokerMetrics;
import org.apache.pinot.common.metrics.BrokerQueryPhase;
import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.request.PinotQuery;
import org.apache.pinot.common.response.broker.BrokerResponseNative;
import org.apache.pinot.common.response.broker.QueryProcessingException;
+import org.apache.pinot.common.response.broker.ResultTable;
+import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.common.utils.config.QueryOptionsUtils;
import org.apache.pinot.core.query.reduce.BrokerReduceService;
+import org.apache.pinot.core.routing.ImplicitHybridTableRouteInfo;
import org.apache.pinot.core.routing.MultiClusterRoutingContext;
import org.apache.pinot.core.routing.RoutingManager;
import org.apache.pinot.core.routing.TableRouteInfo;
@@ -60,9 +75,12 @@ import org.apache.pinot.spi.config.table.TableType;
import org.apache.pinot.spi.env.PinotConfiguration;
import org.apache.pinot.spi.exception.QueryErrorCode;
import org.apache.pinot.spi.exception.QueryException;
+import org.apache.pinot.spi.query.QueryExecutionContext;
+import org.apache.pinot.spi.query.QueryThreadContext;
import org.apache.pinot.spi.trace.RequestContext;
import org.apache.pinot.spi.utils.CommonConstants;
import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -72,6 +90,25 @@ import org.slf4j.LoggerFactory;
@ThreadSafe
public class SingleConnectionBrokerRequestHandler extends
BaseSingleStageBrokerRequestHandler {
private static final Logger LOGGER =
LoggerFactory.getLogger(SingleConnectionBrokerRequestHandler.class);
+ /// Per-probe cap, also bounded by whatever remains of the warmup budget.
+ private static final long WARMUP_PROBE_TIMEOUT_MS = 5_000L;
+ /// Upper bound on the local (stage 1) warmup iterations. Enough to drive
the trivial compile + serialize
+ /// path to JIT, but decoupled from `minIterations` (which governs the far
more expensive NETWORK probe
+ /// depth): a large `minIterations` must not turn stage 1 into a local spin
that eats the whole budget
+ /// before the network probe runs.
+ private static final int LOCAL_WARMUP_MAX_ITERATIONS = 2_000;
+ /// How long `warmUp` waits after `shutdownNow` for interrupted probe
workers to unwind before returning,
+ /// so a probe cannot outlive the call and race the request handler being
torn down on shutdown. Bounded
+ /// so shutdown never hangs on it; interrupted probes unwind well within
this.
+ private static final long PROBE_POOL_SHUTDOWN_WAIT_MS = 1_000L;
+ /// Backoff after an unproductive probe round (nothing routable / every
probe failed), so a not-yet-ready
+ /// cluster cannot busy-spin the warmup thread for the whole budget during
startup.
+ private static final long WARMUP_FAILURE_BACKOFF_MS = 100L;
+ /// Warmup reduces are best-effort JIT warming whose result is discarded, so
their metrics go to the shared
+ /// no-op instance instead of the broker's real counters -- otherwise
synthetic startup probes would
+ /// pollute latency timers, documentsScanned, and the per-table meters
before any real traffic. Safe to
+ /// share across the concurrent probe threads: every increment on a no-op
registry is a no-op.
+ private static final BrokerMetrics WARMUP_NOOP_METRICS =
BrokerMetrics.noop();
protected final BrokerReduceService _brokerReduceService;
protected final QueryRouter _queryRouter;
@@ -117,6 +154,505 @@ public class SingleConnectionBrokerRequestHandler extends
BaseSingleStageBrokerR
_brokerReduceService.shutDown();
}
+ /// Warms this broker's serve path before readiness is granted, in two
stages:
+ /// 1. a local, no-network pass ([#warmUpLocal]) that JIT-warms the
compile and response-serialization
+ /// paths, so they are warm even if no server is reachable; then
+ /// 2. a network probe -- repeatedly running real probe queries until they
have run enough times (the
+ /// `minIterations` depth floor) or the budget expires -- which warms
the scatter/gather/deserialize
+ /// and reduce paths and opens the broker-to-server channels lazily as
it goes.
+ ///
+ /// Probes go through [QueryRouter] rather than [#handleRequest],
deliberately bypassing access control,
+ /// query quota and the query log -- warmup then needs no synthetic identity
and pollutes no
+ /// customer-facing surface (probe reduces also use a throwaway metrics
registry, see
+ /// [#WARMUP_NOOP_METRICS]).
+ ///
+ /// Contract for the readiness caller: this **never throws** and **always
returns by `deadlineMs`** (an
+ /// absolute [System#currentTimeMillis] value). Every wait -- each probe and
each `Future#get` -- is
+ /// bounded by the remaining budget, and once it is spent all outstanding
probes are cancelled, so a slow
+ /// or unreachable server can never stall the rolling restart this gates.
The return value (reached the
+ /// floor vs. hit the budget) is for logging and metrics only; the caller
proceeds either way.
+ ///
+ /// Runs on the single `broker-startup-warmup` thread. The probe pool it
creates is local to the call and
+ /// shut down before returning, so there is no cross-call shared mutable
state.
+ @Override
+ public boolean warmUp(BrokerWarmupConfig config, long deadlineMs) {
+ // Stage 1: local, no-network warmup of the compile +
response-serialization paths, run before the
+ // network probe so they are warm even if no server is reachable. Looped
enough to JIT the serialization
+ // path (a single pass leaves it interpreted): minIterations iterations,
capped at 2000
+ // (LOCAL_WARMUP_MAX_ITERATIONS -- so the cap only bites if minIterations
is raised above 2000) and
+ // deadline-guarded, so it stays a quick prelude and never eats the budget
the network probe needs.
+ warmUpLocal(Math.min(config.minIterations(), LOCAL_WARMUP_MAX_ITERATIONS),
deadlineMs);
+ int concurrency = Math.max(1, config.concurrency());
+ ExecutorService probePool = Executors.newFixedThreadPool(concurrency,
+ new
ThreadFactoryBuilder().setNameFormat("broker-warmup-probe-%d").setDaemon(true).build());
+ // Tables at least one probe actually reached the servers for (probe()
adds to it from the pool threads,
+ // so it must be thread-safe). Read below to report per-server coverage --
only AFTER the pool drains, so
+ // no in-flight probe is still writing it.
+ Set<String> probedTables = ConcurrentHashMap.newKeySet();
+ try {
+ return warmUpNetwork(config, deadlineMs, probePool, concurrency,
probedTables);
+ } catch (Exception e) {
+ LOGGER.warn("Broker warmup failed; proceeding without it", e);
+ return false;
+ } finally {
+ probePool.shutdownNow();
+ awaitPoolDrain(probePool, PROBE_POOL_SHUTDOWN_WAIT_MS);
+ // Pool drained: probedTables is now stable. Report which routable
servers this run warmed (0 on a
+ // clean floor exit; non-zero if the budget expired before the
round-robin reached every server).
+ // Guarded so this finally can never make warmUp throw (the interface
contract is "never throws").
+ try {
+ reportServerCoverage(_routingManager, _brokerMetrics, probedTables);
+ } catch (Exception e) {
+ LOGGER.debug("Warmup coverage report failed (continuing)", e);
+ }
+ }
+ }
+
+ /// Waits up to `waitMs` for `pool` to terminate after a `shutdownNow`, so
an interrupted probe worker
+ /// unwinds before `warmUp` returns and cannot race the request handler
being torn down on shutdown.
+ ///
+ /// Crucially this **saves and clears** the interrupt flag around the wait.
On the shutdown path the warmup
+ /// thread is interrupted (`stopWarmup` -> `interrupt`), and
`awaitTermination` is interruptible -- with the
+ /// flag set it throws `InterruptedException` on entry and waits 0ms,
skipping the drain on exactly the path
+ /// it exists for. Clearing the flag lets the bounded wait actually happen;
the flag is restored afterwards.
+ /// Static and package-private so the interrupted-path behavior is
unit-testable.
+ @VisibleForTesting
+ static void awaitPoolDrain(ExecutorService pool, long waitMs) {
+ boolean interrupted = Thread.interrupted();
+ try {
+ if (!pool.awaitTermination(waitMs, TimeUnit.MILLISECONDS)) {
+ LOGGER.debug("Warmup probe pool did not fully terminate within {} ms;
proceeding", waitMs);
+ }
+ } catch (InterruptedException e) {
+ interrupted = true;
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /// Stage 1: local, no-network warmup. The network probe goes through
[QueryRouter] and stops at the
+ /// gathered DataTables, so it never exercises the response build + JSON
serialization the first real query
+ /// pays (the compile path it does re-warm, but only via a real table). This
compiles a throwaway query and
+ /// serializes a small synthetic [BrokerResponseNative] `iterations` times
so the serialization path
+ /// reaches JIT rather than staying interpreted after a single pass.
`iterations` is capped by the caller
+ /// (see [#LOCAL_WARMUP_MAX_ITERATIONS]); the loop is guarded by both
`deadlineMs` and the interrupt flag,
+ /// so stage 1 stays a quick prelude and stops promptly when shutdown
interrupts the warmup thread. Never
+ /// throws; never runs the network.
+ private void warmUpLocal(int iterations, long deadlineMs) {
+ try {
+ for (int i = 0; i < iterations && System.currentTimeMillis() < deadlineMs
+ && !Thread.currentThread().isInterrupted(); i++) {
+ CalciteSqlCompiler.compileToBrokerRequest("SELECT 1");
+ BrokerResponseNative response = new BrokerResponseNative();
+ response.setResultTable(new ResultTable(
+ new DataSchema(new String[]{"warmup"}, new
DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.LONG}),
+ Collections.singletonList(new Object[]{1L})));
+ response.setNumDocsScanned(1);
+ response.toJsonString();
+ }
+ } catch (Exception e) {
+ LOGGER.debug("Local (stage 1) warmup failed; continuing", e);
+ }
+ }
+
+ /// Fires the given probe tasks concurrently on the shared pool and returns
the latencies (ms) of the
+ /// probes that completed successfully. Empty means the whole round was
unproductive (nothing routable
+ /// yet / all failed); a failed probe simply does not contribute.
+ ///
+ /// The total wait is bounded by `deadlineMs`, never by a fixed per-probe
constant: each `get()` waits
+ /// only the remaining budget, and once the deadline has passed every
still-outstanding future is
+ /// cancelled instead of waited on. This is what makes the budget a hard
ceiling even when more tasks were
+ /// submitted than the pool has threads (so tasks queue) -- otherwise a
queued straggler could hold the
+ /// readiness gate well past the budget.
+ ///
+ /// Static and package-private so the budget / cancellation / interrupt
semantics can be unit-tested with
+ /// injected probe callables, without a live cluster.
`firstProbeThrowLogged` is a 1-element per-run latch
+ /// so a probe that *throws* is surfaced once for the whole warmup run, not
once per round.
+ @VisibleForTesting
+ static List<Long> runConcurrentRound(List<Callable<Long>> tasks,
ExecutorService pool, long deadlineMs,
+ boolean[] firstProbeThrowLogged) {
+ List<Future<Long>> futures = new ArrayList<>(tasks.size());
+ for (Callable<Long> task : tasks) {
+ futures.add(pool.submit(task));
+ }
+ List<Long> latencies = new ArrayList<>(futures.size());
+ for (int i = 0; i < futures.size(); i++) {
+ long remainingMs = deadlineMs - System.currentTimeMillis();
+ if (remainingMs <= 0) {
+ // Budget spent: stop waiting and cancel everything still outstanding
so no probe outlives it.
+ cancelAll(futures, i);
+ break;
+ }
+ Future<Long> future = futures.get(i);
+ try {
+ Long elapsedMs = future.get(remainingMs, TimeUnit.MILLISECONDS);
+ if (elapsedMs != null && elapsedMs >= 0) {
+ latencies.add(elapsedMs);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ cancelAll(futures, i);
+ return latencies;
+ } catch (ExecutionException e) {
+ // The probe task itself threw (not a timeout). It does not count;
surface the first one of the whole
+ // run so a consistently broken probe is diagnosable without a debug
rebuild.
+ if (!firstProbeThrowLogged[0]) {
+ firstProbeThrowLogged[0] = true;
+ LOGGER.warn("Broker warmup probe threw (further occurrences
suppressed)", e.getCause());
+ }
+ future.cancel(true);
+ } catch (Exception e) {
+ // TimeoutException (probe overran the remaining budget) or
CancellationException: expected, does not
+ // count, and is cancelled so it does not keep running behind the next
round (no-op if already done).
+ future.cancel(true);
+ }
+ }
+ return latencies;
+ }
+
+ private static void cancelAll(List<Future<Long>> futures, int from) {
+ for (int j = from; j < futures.size(); j++) {
+ futures.get(j).cancel(true);
+ }
+ }
+
+ /// Sleeps [#WARMUP_FAILURE_BACKOFF_MS] after an unproductive probe round.
Returns `false` if interrupted
+ /// (shutdown) so the caller stops promptly and readiness opens.
+ private boolean warmupBackoff() {
+ try {
+ Thread.sleep(WARMUP_FAILURE_BACKOFF_MS);
+ return true;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+
+ /// The network warmup: probe the static `SELECT * FROM "<t>" LIMIT 1` over
a greedy set-cover of tables
+ /// covering every routable server. Exits once at least `minIterations`
probes have completed successfully
+ /// -- a depth floor that guarantees enough invocations to drive the query
path's JIT to its top tier --
+ /// OR the budget expires. A latency target is deliberately not used: a
trivial probe reaches low latency
+ /// after a handful of iterations while the code is still only partially
compiled, so latency is a false
+ /// early-exit signal.
+ private boolean warmUpNetwork(BrokerWarmupConfig config, long deadlineMs,
ExecutorService pool,
+ int concurrency, Set<String> probedTables) {
+ long successfulProbes = 0;
+ int rounds = 0;
+ // Select the probe tables once and reuse them across rounds. Only
re-select while the result is empty
+ // (routing not populated yet); recomputing the greedy set-cover every
round would repeat O(tables) work
+ // up to minIterations times on a large-table tenant, for a result that
does not change once non-empty.
+ List<String> tables = List.of();
+ // Monotonic across rounds so the round-robin actually advances through
every covered table even at
+ // concurrency 1; resetting per round would probe only the first
`concurrency` tables forever.
+ int probeSeq = 0;
+ boolean reachedFloor = false;
+ // Per-run latch so a probe that throws is logged once for the whole run,
not once per round.
+ boolean[] firstProbeThrowLogged = new boolean[1];
+ while (System.currentTimeMillis() < deadlineMs &&
!Thread.currentThread().isInterrupted()) {
+ if (tables.isEmpty()) {
+ tables = selectProbeTables(_routingManager);
+ }
+ if (tables.isEmpty()) {
+ // Converged but nothing routable yet: back off and retry rather than
declaring the broker warm --
+ // an empty routing table here would otherwise make the gate a no-op
precisely on a cold broker.
+ if (!warmupBackoff()) {
+ return false;
+ }
+ continue;
+ }
+ if (System.currentTimeMillis() >= deadlineMs) {
+ break;
+ }
+ rounds++;
+ // A batch of `concurrency` probes, round-robin over the covered tables
(advancing across rounds via
+ // probeSeq): exercises every server (coverage) AND real concurrency
(contention) at once. Each task
+ // derives its own timeout from the deadline when it actually starts
(see probeWithinDeadline), so a
+ // task that queued behind the pool cannot overrun the budget.
+ List<Callable<Long>> tasks = new ArrayList<>(concurrency);
+ for (String tableNameWithType : roundRobinBatch(tables, probeSeq,
concurrency)) {
+ tasks.add(() -> probeWithinDeadline(compileProbe(tableNameWithType),
deadlineMs, probedTables));
+ }
+ probeSeq += concurrency;
+ List<Long> latencies = runConcurrentRound(tasks, pool, deadlineMs,
firstProbeThrowLogged);
+ if (latencies.isEmpty()) {
+ if (!warmupBackoff()) {
+ return false;
+ }
+ continue;
+ }
+ successfulProbes += latencies.size();
+ if (successfulProbes >= config.minIterations()) {
+ reachedFloor = true;
+ break;
+ }
+ }
+ // Coverage is reported by the caller after the pool drains (probedTables
must be stable). Here we only
+ // signal floor vs. budget.
+ if (reachedFloor) {
+ LOGGER.info("Broker warmup completed after {} round(s) at concurrency
{}; {} probes (floor {})", rounds,
+ concurrency, successfulProbes, config.minIterations());
+ return true;
+ }
+ logBudgetExpiry(rounds, concurrency, successfulProbes,
config.minIterations());
+ return false;
+ }
+
+ /// Logs warmup budget expiry: WARN only when nothing warmed
(`successfulProbes == 0`), INFO otherwise.
+ /// Expiring after warming some probes is the expected steady-state exit for
a probe too expensive to reach
+ /// the floor within the budget -- it warmed as much as the budget allowed
-- so it is not warning-worthy.
+ private static void logBudgetExpiry(int rounds, int concurrency, long
successfulProbes, int floor) {
+ if (successfulProbes == 0) {
+ LOGGER.warn("Broker warmup budget expired after {} round(s) at
concurrency {}; {} probes (floor {}). "
+ + "Proceeding to serve traffic (nothing warmed).", rounds,
concurrency, successfulProbes, floor);
+ } else {
+ LOGGER.info("Broker warmup budget expired after {} round(s) at
concurrency {}; {} probes (floor {}). "
+ + "Proceeding to serve traffic.", rounds, concurrency,
successfulProbes, floor);
+ }
+ }
+
+ /// Compiles the default probe query for a single physical table.
+ private BrokerRequest compileProbe(String tableNameWithType) {
+ return CalciteSqlCompiler.compileToBrokerRequest("SELECT * FROM \"" +
tableNameWithType + "\" LIMIT 1");
+ }
+
+ /// Runs one probe with a timeout derived from the remaining budget **at the
moment the task starts**,
+ /// never a fixed per-probe constant. When more tasks are submitted than the
pool has threads they queue,
+ /// and a queued task may not start until much of the budget is already
gone; deriving the timeout here
+ /// (rather than when the round was built) is what keeps the budget a hard
ceiling. Returns -1 (uncounted)
+ /// if the budget is already spent when the task starts, so no probe is
fired past the deadline.
+ private long probeWithinDeadline(BrokerRequest brokerRequest, long
deadlineMs, Set<String> probedTables) {
+ long timeoutMs = Math.min(deadlineMs - System.currentTimeMillis(),
WARMUP_PROBE_TIMEOUT_MS);
+ return timeoutMs <= 0 ? -1 : probe(brokerRequest, timeoutMs, probedTables);
+ }
+
+ /// Issues one probe query through [QueryRouter] and returns its wall-clock
duration in ms, or -1 on
+ /// failure. Builds the route the same way the normal path does, minus
auth/quota/logging. When at least
+ /// one server responds, records the (type-suffixed) table name in
`probedTables` so the caller can tell,
+ /// at exit, which routable servers were actually warmed.
+ private long probe(BrokerRequest brokerRequest, long timeoutMs, Set<String>
probedTables) {
+ String tableName = brokerRequest.getQuerySource().getTableName();
+ try {
+ String rawTableName = TableNameBuilder.extractRawTableName(tableName);
+ TableType tableType =
TableNameBuilder.getTableTypeFromTableName(tableName);
+ long requestId = _requestIdGenerator.get();
+ TableRouteInfo routeInfo =
+ _implicitHybridTableRouteProvider.getTableRouteInfo(rawTableName,
_tableCache, _routingManager);
+ if (!(routeInfo instanceof ImplicitHybridTableRouteInfo) ||
!routeInfo.isExists()) {
+ return -1;
+ }
+ // The probe table is always type-suffixed (the set-cover picks names
straight from getRoutableTables),
+ // so it targets exactly one type. Each leg's request must carry the
type-suffixed table name -- that
+ // is what routing resolves against -- so build a per-leg request from
the base query (as the normal
+ // path does). shouldRouteOffline/Realtime still handle the general case
for robustness.
+ BrokerRequest offlineBrokerRequest = shouldRouteOffline(tableType,
routeInfo)
+ ? typedRequest(brokerRequest, routeInfo.getOfflineTableName()) :
null;
+ BrokerRequest realtimeBrokerRequest = shouldRouteRealtime(tableType,
routeInfo)
+ ? typedRequest(brokerRequest, routeInfo.getRealtimeTableName()) :
null;
+ if (offlineBrokerRequest == null && realtimeBrokerRequest == null) {
+ // Neither leg is routable right now (e.g. a hybrid table probed via
its offline name before the
+ // time boundary exists). submitQuery asserts at least one leg is
non-null, so skip cleanly rather
+ // than build an empty route -- the table is retried on the next round
once its leg appears.
+ return -1;
+ }
+ // calculateRoutes sets the per-leg broker requests on the routeInfo
itself (nulling a leg whose
+ // routing table turns out empty), so we pass them as arguments and do
not pre-set them here.
+ _implicitHybridTableRouteProvider.calculateRoutes(routeInfo,
_routingManager, offlineBrokerRequest,
+ realtimeBrokerRequest, requestId);
+ if (!routeInfo.isRouteExists()) {
+ return -1;
+ }
+ long startMs = System.currentTimeMillis();
+ // Scatter/gather (+ DataTable deserialize on receive) is the dominant
cost and does not need a
+ // QueryThreadContext; run it directly so its warmth always counts
toward the probe latency.
+ // Side effect worth naming: submitQuery records
adaptive-server-selector stats (AsyncQueryResponse ->
+ // ServerRoutingStatsManager), so probes seed each server's latency EMA
before real traffic. A probe
+ // that gets a real response seeds the EMA with that server's (cold)
response latency; a probe that
+ // times out or errors seeds it with the full timeout (see
AsyncQueryResponse#getFinalResponses). On a
+ // healthy cluster every probe responds, so the seeds are comparable
across servers and the EMA decays
+ // to true warm latencies within a few real requests -- a small,
self-correcting bias. The one case
+ // that biases RELATIVE ordering is a server slow enough to time out
probes while its peers respond:
+ // it is seeded high and the selector routes less to it at first. That
is acceptable (it steers early
+ // traffic away from a genuinely slow server and self-corrects), and
still strictly better than the
+ // selector starting with no per-server history at all.
+ AsyncQueryResponse response = _queryRouter.submitQuery(requestId,
rawTableName, routeInfo, timeoutMs);
+ Map<ServerRoutingInstance, ServerResponse> finalResponses =
response.getFinalResponses();
+ // Coverage: if at least one server returned data, this table's servers
were reached this run. A table
+ // whose servers all timed out is deliberately NOT recorded, so it
counts as uncovered at exit.
+ for (ServerResponse serverResponse : finalResponses.values()) {
+ if (serverResponse.getDataTable() != null) {
+ probedTables.add(tableName);
+ break;
+ }
+ }
+ // Best-effort: also warm the broker reduce path (result discarded) so
the first real query of this
+ // shape does not pay it. Isolated in its own try -- reduceOnDataTable
requires a QueryThreadContext
+ // and could fail for edge cases; a reduce failure must never fail the
probe, since the
+ // scatter/gather/deserialize warmth has already happened.
+ try (QueryThreadContext ignore = QueryThreadContext.open(
+ new QueryExecutionContext(QueryExecutionContext.QueryType.SSE,
requestId, Long.toString(requestId),
+ "warmup", startMs, Long.MAX_VALUE, Long.MAX_VALUE, _brokerId,
_brokerId, ""), _threadAccountant)) {
+ Map<ServerRoutingInstance, DataTable> dataTableMap = new
HashMap<>(finalResponses.size());
+ for (Map.Entry<ServerRoutingInstance, ServerResponse> entry :
finalResponses.entrySet()) {
+ DataTable dataTable = entry.getValue().getDataTable();
+ if (dataTable != null) {
+ dataTableMap.put(entry.getKey(), dataTable);
+ }
+ }
+ if (!dataTableMap.isEmpty()) {
+ // Discard-only warmth: route metrics to the throwaway registry so
the probe does not pollute the
+ // broker's real counters (see WARMUP_NOOP_METRICS).
+ _brokerReduceService.reduceOnDataTable(brokerRequest, brokerRequest,
dataTableMap, timeoutMs,
+ WARMUP_NOOP_METRICS);
+ }
+ } catch (Exception reduceEx) {
+ LOGGER.debug("Warmup reduce step failed (continuing); scatter/gather
already warmed", reduceEx);
+ }
+ return System.currentTimeMillis() - startMs;
+ } catch (Exception e) {
+ if (e instanceof InterruptedException) {
+ // Shutdown interrupted this probe (via shutdownNow); restore the flag
so the pool worker unwinds.
+ Thread.currentThread().interrupt();
+ }
+ LOGGER.debug("Warmup probe failed for query on table: {}", tableName, e);
+ return -1;
+ }
+ }
+
+ /// Builds a per-leg probe request carrying the type-suffixed table name
that routing resolves against,
+ /// from the base (possibly raw-named) request. Deep-copies so the base
request is not mutated.
+ private static BrokerRequest typedRequest(BrokerRequest base, String
tableNameWithType) {
+ PinotQuery pinotQuery = base.getPinotQuery().deepCopy();
+ pinotQuery.getDataSource().setTableName(tableNameWithType);
+ return CalciteSqlCompiler.convertToBrokerRequest(pinotQuery);
+ }
+
+ /// Whether a probe routes the offline leg: the query targets offline (a
type-suffixed offline name) or is
+ /// untyped (a raw name, `tableType` null), AND an offline table exists.
Static so the offline / realtime /
+ /// hybrid decision is unit-testable against a mocked route without a live
cluster.
+ @VisibleForTesting
+ static boolean shouldRouteOffline(@Nullable TableType tableType,
TableRouteInfo routeInfo) {
+ return tableType != TableType.REALTIME && routeInfo.hasOffline();
+ }
+
+ /// Whether a probe routes the realtime leg: the query targets realtime or
is untyped, AND a realtime
+ /// table exists.
+ @VisibleForTesting
+ static boolean shouldRouteRealtime(@Nullable TableType tableType,
TableRouteInfo routeInfo) {
+ return tableType != TableType.OFFLINE && routeInfo.hasRealtime();
+ }
+
+ /// Picks the fewest tables that between them cover **every** routable
server -- the one, only behavior:
+ /// warmup always covers the whole fleet, with no cap to tune.
+ ///
+ /// Greedy set cover rather than "all tables" or a configured list: warmup
needs every broker-to-server
+ /// channel and the whole query path exercised, and coverage delivers
exactly that. The loop stops as soon
+ /// as every server is covered, so it self-limits to at most one table per
server (never the full table
+ /// list) even on a tenant with thousands of tables. Because the coverage
universe is exactly the servers
+ /// some routable table serves, this always achieves full coverage. Static
so it can be unit-tested against
+ /// a mocked [RoutingManager] without constructing a broker.
+ @VisibleForTesting
+ static List<String> selectProbeTables(RoutingManager routingManager) {
+ Set<String> uncoveredServers = routableServers(routingManager);
+ // Sorted so selection is deterministic across brokers and across restarts.
+ List<String> candidates = new
ArrayList<>(routingManager.getRoutableTables());
+ Collections.sort(candidates);
+ // Capacity bounded by the server count -- the true ceiling on how many
tables get selected.
+ List<String> selected = new ArrayList<>(Math.min(uncoveredServers.size(),
candidates.size()));
+ for (String tableNameWithType : candidates) {
+ if (uncoveredServers.isEmpty()) {
+ break;
+ }
+ Set<String> serving =
routingManager.getServingInstances(tableNameWithType);
+ if (serving == null || serving.isEmpty()) {
+ continue;
+ }
+ if (uncoveredServers.removeAll(serving)) {
+ selected.add(tableNameWithType);
+ }
+ }
+ return selected;
+ }
+
+ /// Records [BrokerGauge#STARTUP_WARMUP_UNCOVERED_SERVERS] on warmup exit:
the routable servers that
+ /// `probedTables` (the tables at least one probe actually reached this run)
do NOT, between them, route
+ /// to. The set-cover guarantees the *selected* tables span every server, so
this is `0` when warmup reaches
+ /// its floor (every table probed); it goes non-zero only when warmup exits
early -- the budget expired, or
+ /// servers were too slow, before the round-robin reached every table.
+ ///
+ /// It is a warmup **completeness** signal, not "these servers are stone
cold": the broker's serve-path JIT
+ /// is warmed per-JVM (not per-server) and channels are opened by
pre-connect, so an unreached server is
+ /// only marginally colder. Read a non-zero value as "warmup ran out of
budget before its intended
+ /// coverage", most meaningful alongside whether the floor was reached.
+ ///
+ /// Uncovered is derived from a single [#routableServers] read (via
[#uncoveredRoutableServers]) so the
+ /// count is self-consistent. Static (collaborators as parameters) so the
emission is unit-testable against
+ /// a mocked [RoutingManager] and [BrokerMetrics].
+ @VisibleForTesting
+ static void reportServerCoverage(RoutingManager routingManager,
BrokerMetrics brokerMetrics,
+ Collection<String> probedTables) {
+ Set<String> uncovered = uncoveredRoutableServers(routingManager,
probedTables);
+
brokerMetrics.setValueOfGlobalGauge(BrokerGauge.STARTUP_WARMUP_UNCOVERED_SERVERS,
uncovered.size());
+ if (!uncovered.isEmpty()) {
+ LOGGER.warn("Broker warmup did not reach {} routable server(s) before it
exited (budget expired or "
+ + "servers too slow): {}. Serve-path JIT is warmed regardless; those
servers are only marginally "
+ + "colder.", uncovered.size(), uncovered);
+ }
+ }
+
+ /// Returns the routable servers that none of the given probe tables route
to. Empty means the tables
+ /// span every server this broker can reach. Static so it can be unit-tested
against a mocked
+ /// [RoutingManager].
+ @VisibleForTesting
+ static Set<String> uncoveredRoutableServers(RoutingManager routingManager,
Collection<String> tables) {
+ Set<String> uncovered = routableServers(routingManager);
+ for (String tableNameWithType : tables) {
+ Set<String> serving =
routingManager.getServingInstances(tableNameWithType);
+ if (serving != null) {
+ uncovered.removeAll(serving);
+ }
+ }
+ return uncovered;
+ }
+
+ /// The servers this broker actually routes to: the union of the serving
instances of its routable tables.
+ ///
+ /// Deliberately NOT `getRoutableServerInstanceMap()`, which is every
enabled server in the whole cluster
+ /// (no tenant filter) -- on a multi-tenant cluster that would count other
tenants' servers this broker
+ /// never queries, so the coverage metric would be permanently non-zero and
its warning unactionable. This
+ /// mirrors how startup pre-connect derives its channels from routing.
+ @VisibleForTesting
+ static Set<String> routableServers(RoutingManager routingManager) {
+ Set<String> servers = new HashSet<>();
+ for (String tableNameWithType : routingManager.getRoutableTables()) {
+ Set<String> serving =
routingManager.getServingInstances(tableNameWithType);
+ if (serving != null) {
+ servers.addAll(serving);
+ }
+ }
+ return servers;
+ }
+
+ /// Returns the next `concurrency` items to probe, round-robin starting at
`startSeq`. Kept generic and
+ /// static so the rotation -- which must advance across rounds, not reset
each round, or only the first
+ /// `concurrency` items would ever be probed at low concurrency -- is
unit-testable without a live probe.
+ /// [Math#floorMod(int,int)] keeps the index valid even if `startSeq`
overflows to a negative value.
+ @VisibleForTesting
+ static <T> List<T> roundRobinBatch(List<T> items, int startSeq, int
concurrency) {
+ int size = items.size();
+ if (size == 0) {
+ // Defensive: an empty list would make the floorMod below divide by
zero. Callers already guard, but a
+ // future caller must get an empty batch, not an ArithmeticException.
+ return List.of();
+ }
+ List<T> batch = new ArrayList<>(concurrency);
+ for (int i = 0; i < concurrency; i++) {
+ batch.add(items.get(Math.floorMod(startSeq + i, size)));
+ }
+ return batch;
+ }
+
/// Opens the broker-to-server channels this broker actually routes to,
taking the blocking connect --
/// and, when broker-to-server TLS is on, the handshake -- off the first
real query's critical path. The
/// channels opened are derived from routing (see
[#routableChannelTargets]), so an offline-only cluster
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/broker/helix/BrokerWarmupGateTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/broker/helix/BrokerWarmupGateTest.java
new file mode 100644
index 00000000000..c1a11d7cdfc
--- /dev/null
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/broker/helix/BrokerWarmupGateTest.java
@@ -0,0 +1,50 @@
+/**
+ * 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.broker.helix;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.pinot.common.utils.ServiceStatus;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+
+
+/// Deterministic coverage for the startup-warmup readiness gate's STARTING ->
GOOD transition. Observing a
+/// live broker mid-warmup is inherently racy (warmup against a healthy
cluster completes in well under a
+/// poll interval), so the gate callback is tested directly against a flag
that stands in for `_isWarm` --
+/// the same flag the runtime callback closes over.
+public class BrokerWarmupGateTest {
+
+ @Test
+ public void gateReportsStartingUntilWarmThenGood() {
+ AtomicBoolean warm = new AtomicBoolean(false);
+ ServiceStatus.ServiceStatusCallback gate =
BaseBrokerStarter.warmupGateCallback(warm::get);
+
+ // While warming: STARTING (an existing status value, not a new enum
constant) with the warming-up
+ // description, so the readiness probe holds the broker out of rotation
and the reason is visible.
+ assertEquals(gate.getServiceStatus(), ServiceStatus.Status.STARTING);
+ assertEquals(gate.getStatusDescription(),
BaseBrokerStarter.WARMUP_GATE_STARTING_DESCRIPTION);
+
+ // The same callback flips the instant the flag does, exactly as _isWarm
does at runtime: GOOD, no
+ // description. No new callback is installed -- the gate transitions in
place.
+ warm.set(true);
+ assertEquals(gate.getServiceStatus(), ServiceStatus.Status.GOOD);
+ assertEquals(gate.getStatusDescription(),
ServiceStatus.STATUS_DESCRIPTION_NONE);
+ }
+}
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BrokerWarmupTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BrokerWarmupTest.java
new file mode 100644
index 00000000000..457033f9066
--- /dev/null
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BrokerWarmupTest.java
@@ -0,0 +1,454 @@
+/**
+ * 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.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.pinot.common.metrics.BrokerGauge;
+import org.apache.pinot.common.metrics.BrokerMetrics;
+import org.apache.pinot.core.routing.RoutingManager;
+import org.apache.pinot.core.routing.TableRouteInfo;
+import org.apache.pinot.core.transport.ServerInstance;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+import org.testng.annotations.Test;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Unit coverage for broker startup warmup: config parsing (including the
concurrency and minIterations
+/// knobs and their floors), the no-op interface default, the greedy table
selection that always covers every
+/// routable server, the static-probe routing decision, the round-robin
rotation, the server-coverage gauge,
+/// and the concurrent-round budget semantics.
+public class BrokerWarmupTest {
+
+ @Test
+ public void warmupIsDisabledByDefault() {
+ BrokerWarmupConfig config = BrokerWarmupConfig.from(new
PinotConfiguration());
+ assertFalse(config.enabled());
+ // Defaults still parse, so flipping the flag alone yields sane behavior.
+ assertEquals(config.budgetMs(),
Broker.DEFAULT_BROKER_STARTUP_WARMUP_BUDGET_MS);
+ assertEquals(config.minIterations(),
Broker.DEFAULT_BROKER_STARTUP_WARMUP_MIN_ITERATIONS);
+ // Concurrency defaults to serial.
+ assertEquals(config.concurrency(),
Broker.DEFAULT_BROKER_STARTUP_WARMUP_CONCURRENCY);
+ assertEquals(config.concurrency(), 1);
+ }
+
+ @Test
+ public void configIsReadFromProperties() {
+ PinotConfiguration properties = new PinotConfiguration();
+ properties.setProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_ENABLED,
true);
+ properties.setProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_BUDGET_MS,
4321L);
+
properties.setProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_MIN_ITERATIONS,
250);
+ properties.setProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_CONCURRENCY,
12);
+
+ BrokerWarmupConfig config = BrokerWarmupConfig.from(properties);
+ assertTrue(config.enabled());
+ assertEquals(config.budgetMs(), 4321L);
+ assertEquals(config.minIterations(), 250);
+ assertEquals(config.concurrency(), 12);
+ }
+
+ /// concurrency and minIterations are floored at 1 even if misconfigured to
0/negative, so the probe pool
+ /// and the exit floor are always valid.
+ @Test
+ public void concurrencyAndMinIterationsFlooredAtOne() {
+ PinotConfiguration properties = new PinotConfiguration();
+ properties.setProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_CONCURRENCY,
0);
+
properties.setProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_MIN_ITERATIONS,
-5);
+ BrokerWarmupConfig config = BrokerWarmupConfig.from(properties);
+ assertEquals(config.concurrency(), 1);
+ assertEquals(config.minIterations(), 1);
+ }
+
+ /// concurrency is clamped to an upper bound so a fat-fingered value cannot
ask for a pathological thread
+ /// pool (an OutOfMemoryError creating native threads) at startup.
+ @Test
+ public void concurrencyIsClampedToAnUpperBound() {
+ PinotConfiguration properties = new PinotConfiguration();
+ properties.setProperty(Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_CONCURRENCY,
1_000_000);
+ BrokerWarmupConfig config = BrokerWarmupConfig.from(properties);
+ assertTrue(config.concurrency() >= 1 && config.concurrency() <= 64,
+ "concurrency must be clamped into [1, 64] but was " +
config.concurrency());
+ }
+
+ /// A handler that does not override warmUp must report warm immediately. If
the default blocked or
+ /// returned false, every non-single-stage handler would hold readiness shut
forever.
+ @Test
+ public void defaultWarmUpIsANoOp() {
+ BrokerRequestHandler handler = mock(BrokerRequestHandler.class);
+ when(handler.warmUp(any(), anyLong())).thenCallRealMethod();
+ assertTrue(handler.warmUp(new BrokerWarmupConfig(true, 1L, 1, 1),
+ System.currentTimeMillis() + 1_000L));
+ }
+
+ /// Three tables, three servers, one table per server: all three must be
picked, since dropping any one
+ /// leaves a broker-to-server channel unwarmed.
+ @Test
+ public void selectionCoversEveryServer() {
+ RoutingManager routing = routing(
+ Map.of("a_OFFLINE", Set.of("s1"), "b_OFFLINE", Set.of("s2"),
"c_OFFLINE", Set.of("s3")),
+ Set.of("s1", "s2", "s3"));
+
+ assertEquals(new
java.util.HashSet<>(SingleConnectionBrokerRequestHandler.selectProbeTables(routing)),
+ Set.of("a_OFFLINE", "b_OFFLINE", "c_OFFLINE"));
+ }
+
+ /// A table adding no new server is skipped: probing it costs a query and
warms nothing extra.
+ @Test
+ public void selectionSkipsRedundantTables() {
+ RoutingManager routing = routing(
+ Map.of("a_OFFLINE", Set.of("s1", "s2"), "b_OFFLINE", Set.of("s1"),
"c_OFFLINE", Set.of("s2")),
+ Set.of("s1", "s2"));
+
+ // "a_OFFLINE" sorts first and already covers both servers, so nothing
else is needed.
+
assertEquals(SingleConnectionBrokerRequestHandler.selectProbeTables(routing),
List.of("a_OFFLINE"));
+ }
+
+ /// The one, only behavior: the set-cover always spans EVERY routable
server, however many tables that
+ /// needs (there is no cap). The loop still stops at full coverage, so it
picks exactly one table per
+ /// server here and no more.
+ @Test
+ public void selectionCoversWideFleet() {
+ // Seven servers, each served by exactly one distinct table: covering all
of them needs all seven tables.
+ RoutingManager routing = routing(
+ Map.of("a_OFFLINE", Set.of("s1"), "b_OFFLINE", Set.of("s2"),
"c_OFFLINE", Set.of("s3"),
+ "d_OFFLINE", Set.of("s4"), "e_OFFLINE", Set.of("s5"), "f_OFFLINE",
Set.of("s6"),
+ "g_OFFLINE", Set.of("s7")),
+ Set.of("s1", "s2", "s3", "s4", "s5", "s6", "s7"));
+
+ List<String> selected =
SingleConnectionBrokerRequestHandler.selectProbeTables(routing);
+ assertEquals(selected.size(), 7);
+
assertTrue(SingleConnectionBrokerRequestHandler.uncoveredRoutableServers(routing,
selected).isEmpty());
+ }
+
+ /// Warmup runs before the first query and may legitimately find nothing
routable yet.
+ @Test
+ public void selectionHandlesEmptyCluster() {
+
assertTrue(SingleConnectionBrokerRequestHandler.selectProbeTables(routing(Map.of(),
Set.of())).isEmpty());
+ }
+
+ /// Must not blow up on a table whose serving-instance set is empty or
unknown.
+ @Test
+ public void selectionIgnoresTablesWithoutServingInstances() {
+ RoutingManager routing = routing(Map.of("a_OFFLINE", Set.of(),
"b_OFFLINE", Set.of("s1")), Set.of("s1"));
+
assertEquals(SingleConnectionBrokerRequestHandler.selectProbeTables(routing),
List.of("b_OFFLINE"));
+ }
+
+ /// The auto-select set-cover always leaves nothing uncovered -- every
routable server is spanned.
+ @Test
+ public void coverageIsCompleteForAutoSelect() {
+ RoutingManager routing = routing(
+ Map.of("a_OFFLINE", Set.of("s1"), "b_OFFLINE", Set.of("s2"),
"c_OFFLINE", Set.of("s3"),
+ "d_OFFLINE", Set.of("s4")),
+ Set.of("s1", "s2", "s3", "s4"));
+
+ List<String> selected =
SingleConnectionBrokerRequestHandler.selectProbeTables(routing);
+
assertTrue(SingleConnectionBrokerRequestHandler.uncoveredRoutableServers(routing,
selected).isEmpty());
+ }
+
+ /// uncoveredRoutableServers surfaces the servers a given table set misses:
probing only a_OFFLINE (which
+ /// serves s1) leaves s2 uncovered.
+ @Test
+ public void uncoveredRoutableServersIdentifiesMissedServers() {
+ RoutingManager routing = routing(
+ Map.of("a_OFFLINE", Set.of("s1"), "b_OFFLINE", Set.of("s2")),
Set.of("s1", "s2"));
+
+
assertEquals(SingleConnectionBrokerRequestHandler.uncoveredRoutableServers(routing,
List.of("a_OFFLINE")),
+ Set.of("s2"));
+ }
+
+ /// At concurrency 1 the round-robin must advance across rounds (startSeq
monotonic) so successive batches
+ /// walk every item and wrap. A per-round reset would probe only
items.get(0) forever.
+ @Test
+ public void roundRobinBatchWalksAllItemsAtConcurrencyOne() {
+ List<String> items = List.of("a", "b", "c");
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
0, 1), List.of("a"));
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
1, 1), List.of("b"));
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
2, 1), List.of("c"));
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
3, 1), List.of("a"));
+ }
+
+ /// A concurrency batch wraps around the item list, and an overflowed
(negative) startSeq still yields a
+ /// valid in-range index rather than throwing (Math.floorMod).
+ @Test
+ public void roundRobinBatchWrapsAndSurvivesOverflow() {
+ List<String> items = List.of("a", "b", "c");
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
0, 2), List.of("a", "b"));
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
2, 2), List.of("c", "a"));
+ // Batch larger than the list repeats items.
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
0, 4), List.of("a", "b", "c", "a"));
+ // startSeq + i overflows int on the second element; must not throw and
must stay in range.
+ assertEquals(SingleConnectionBrokerRequestHandler.roundRobinBatch(items,
Integer.MAX_VALUE, 2).size(), 2);
+ }
+
+ /// An empty item list must yield an empty batch, not an ArithmeticException
from `floorMod(x, 0)`. Callers
+ /// guard today, but the helper must be safe for a future caller.
+ @Test
+ public void roundRobinBatchOnEmptyListIsEmpty() {
+ assertTrue(SingleConnectionBrokerRequestHandler.roundRobinBatch(List.of(),
0, 4).isEmpty());
+
assertTrue(SingleConnectionBrokerRequestHandler.roundRobinBatch(List.<String>of(),
7, 1).isEmpty());
+ }
+
+ /// reportServerCoverage records, at exit, the servers left cold given the
tables actually probed: 1 when
+ /// only one of two servers' tables was probed (the other missed, e.g.
budget expired first), 0 when every
+ /// server's table was probed. A cluster server no routable table routes to
is excluded from the universe.
+ @Test
+ public void reportServerCoverageRecordsUncoveredGauge() {
+ // s3 is in the cluster-wide server map but NO table routes to it --
another tenant's server. The
+ // coverage universe is the servers THIS broker routes to (union of
serving instances over its routable
+ // tables), so s3 must NOT be counted as uncovered.
+ RoutingManager routing = routing(
+ Map.of("a_OFFLINE", Set.of("s1"), "b_OFFLINE", Set.of("s2")),
Set.of("s1", "s2", "s3"));
+ BrokerMetrics metrics = mock(BrokerMetrics.class);
+
+ // Only a_OFFLINE was probed (b_OFFLINE never reached -- budget expired)
-> s2 left cold; s3 excluded.
+ SingleConnectionBrokerRequestHandler.reportServerCoverage(routing,
metrics, List.of("a_OFFLINE"));
+
verify(metrics).setValueOfGlobalGauge(BrokerGauge.STARTUP_WARMUP_UNCOVERED_SERVERS,
1L);
+
+ // Both tables probed -> every server warmed.
+ SingleConnectionBrokerRequestHandler.reportServerCoverage(routing, metrics,
+ List.of("a_OFFLINE", "b_OFFLINE"));
+
verify(metrics).setValueOfGlobalGauge(BrokerGauge.STARTUP_WARMUP_UNCOVERED_SERVERS,
0L);
+
+ RoutingManager full = routing(Map.of("a_OFFLINE", Set.of("s1", "s2")),
Set.of("s1", "s2"));
+ BrokerMetrics fullMetrics = mock(BrokerMetrics.class);
+ SingleConnectionBrokerRequestHandler.reportServerCoverage(full,
fullMetrics, List.of("a_OFFLINE"));
+
verify(fullMetrics).setValueOfGlobalGauge(BrokerGauge.STARTUP_WARMUP_UNCOVERED_SERVERS,
0L);
+ }
+
+ /// The offline/realtime/hybrid routing decision the probe uses: a
type-suffixed name routes only that
+ /// type; a type with no matching table routes nothing. (The untyped/`null`
cases are covered too, as a
+ /// general contract of the helper, though the static probe always supplies
a type-suffixed name.)
+ @Test
+ public void probeRoutingDecisionCoversOfflineRealtimeHybrid() {
+ TableRouteInfo offlineOnly = route(true, false);
+ TableRouteInfo realtimeOnly = route(false, true);
+ TableRouteInfo hybrid = route(true, true);
+
+ assertTrue(SingleConnectionBrokerRequestHandler.shouldRouteOffline(null,
offlineOnly));
+ assertFalse(SingleConnectionBrokerRequestHandler.shouldRouteRealtime(null,
offlineOnly));
+ assertFalse(SingleConnectionBrokerRequestHandler.shouldRouteOffline(null,
realtimeOnly));
+ assertTrue(SingleConnectionBrokerRequestHandler.shouldRouteRealtime(null,
realtimeOnly));
+ assertTrue(SingleConnectionBrokerRequestHandler.shouldRouteOffline(null,
hybrid));
+ assertTrue(SingleConnectionBrokerRequestHandler.shouldRouteRealtime(null,
hybrid));
+
+
assertTrue(SingleConnectionBrokerRequestHandler.shouldRouteOffline(TableType.OFFLINE,
hybrid));
+
assertFalse(SingleConnectionBrokerRequestHandler.shouldRouteRealtime(TableType.OFFLINE,
hybrid));
+
assertFalse(SingleConnectionBrokerRequestHandler.shouldRouteOffline(TableType.REALTIME,
hybrid));
+
assertTrue(SingleConnectionBrokerRequestHandler.shouldRouteRealtime(TableType.REALTIME,
hybrid));
+ // A realtime-typed query against an offline-only table routes nothing.
+
assertFalse(SingleConnectionBrokerRequestHandler.shouldRouteOffline(TableType.REALTIME,
offlineOnly));
+
assertFalse(SingleConnectionBrokerRequestHandler.shouldRouteRealtime(TableType.REALTIME,
offlineOnly));
+ }
+
+ /// A round collects the latencies of probes that completed with a
non-negative value, and silently drops
+ /// a probe that failed (returned -1) or threw. This is the "everything
finishes within budget" happy path.
+ @Test
+ public void runConcurrentRoundCollectsSuccessfulLatenciesOnly() {
+ ExecutorService pool = Executors.newFixedThreadPool(4);
+ try {
+ long deadline = System.currentTimeMillis() + 5_000L;
+ List<Callable<Long>> tasks = List.of(
+ () -> 10L,
+ () -> -1L, // failed probe:
excluded
+ () -> 20L,
+ () -> {
+ throw new RuntimeException("probe blew up"); // threw: excluded,
must not fail the round
+ });
+ List<Long> latencies =
+ SingleConnectionBrokerRequestHandler.runConcurrentRound(tasks, pool,
deadline, new boolean[1]);
+ assertEquals(new HashSet<>(latencies), Set.of(10L, 20L));
+ assertEquals(latencies.size(), 2);
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ /// The budget is a hard ceiling even when more probes are submitted than
the pool has threads: probes 2
+ /// and 3 queue behind probe 1, and every probe sleeps far past the budget.
The round must return at ~the
+ /// budget (never sum-of-sleeps), collect nothing, and not leave the queued
probes to run -- the exact
+ /// overshoot that #1/#2 fixed.
+ @Test
+ public void runConcurrentRoundReturnsByBudgetWhenTasksQueue() {
+ ExecutorService pool = Executors.newFixedThreadPool(1);
+ try {
+ AtomicInteger started = new AtomicInteger(0);
+ List<Callable<Long>> tasks = new ArrayList<>();
+ for (int i = 0; i < 3; i++) {
+ tasks.add(() -> {
+ started.incrementAndGet();
+ Thread.sleep(5_000L);
+ return 1L;
+ });
+ }
+ long deadline = System.currentTimeMillis() + 300L;
+ long start = System.currentTimeMillis();
+ List<Long> latencies =
+ SingleConnectionBrokerRequestHandler.runConcurrentRound(tasks, pool,
deadline, new boolean[1]);
+ long elapsed = System.currentTimeMillis() - start;
+ assertTrue(elapsed < 3_000L, "round must return near the 300ms budget,
took " + elapsed + "ms");
+ assertTrue(latencies.isEmpty(), "no 5s probe can finish within a 300ms
budget");
+ // Only the head-of-queue probe ever ran; the two queued behind it were
cancelled, not fired. (<=2
+ // rather than ==1 only to tolerate the microsecond window where probe
1's interrupt frees the single
+ // pool thread before cancelAll marks probe 2 cancelled.)
+ assertTrue(started.get() <= 2, "queued probes must not run past the
budget, started=" + started.get());
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ /// A deadline already in the past collects nothing and returns at once --
it never waits on a submitted
+ /// probe. Guards the loop's leading remaining-budget check.
+ @Test
+ public void runConcurrentRoundWithExpiredDeadlineCollectsNothing() {
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ List<Callable<Long>> tasks = List.of(() -> 1L, () -> 2L);
+ List<Long> latencies =
SingleConnectionBrokerRequestHandler.runConcurrentRound(tasks, pool,
+ System.currentTimeMillis() - 1L, new boolean[1]);
+ assertTrue(latencies.isEmpty());
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ /// Interrupting the thread running the round (shutdown mid-warmup) must end
it promptly -- not wait out the
+ /// in-flight probes -- return what it had, and preserve the interrupt
status so the warmup loop above it
+ /// sees the interrupt and stops.
+ @Test
+ public void runConcurrentRoundReturnsPromptlyWhenInterrupted() throws
Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ List<Callable<Long>> tasks = List.of(
+ () -> {
+ Thread.sleep(10_000L);
+ return 1L;
+ },
+ () -> {
+ Thread.sleep(10_000L);
+ return 2L;
+ });
+ // Deadline far off, so only the interrupt -- not the budget -- can end
the round.
+ long deadline = System.currentTimeMillis() + 30_000L;
+ AtomicReference<List<Long>> result = new AtomicReference<>();
+ AtomicBoolean interruptPreserved = new AtomicBoolean(false);
+ Thread runner = new Thread(() -> {
+ List<Long> r =
SingleConnectionBrokerRequestHandler.runConcurrentRound(tasks, pool, deadline,
new boolean[1]);
+ result.set(r);
+ interruptPreserved.set(Thread.currentThread().isInterrupted());
+ });
+ runner.start();
+ Thread.sleep(200L); // let it enter Future.get()
+ runner.interrupt();
+ runner.join(3_000L);
+ assertFalse(runner.isAlive(), "interrupt must end the round promptly,
not wait 10s for the probes");
+ assertTrue(result.get().isEmpty());
+ assertTrue(interruptPreserved.get(), "interrupt status must be re-set
for the caller loop to observe");
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ /// The probe-pool drain must actually wait even when the caller's interrupt
flag is set -- the state
+ /// shutdown leaves the warmup thread in. A busy task that ignores
interrupts stands in for a probe
+ /// mid-flight: a naive `awaitTermination` would throw immediately on the
interrupt flag and skip the
+ /// drain, leaving the pool un-terminated; the save-and-clear must let the
wait complete and restore the
+ /// flag. This is the shutdown path #17 was about.
+ @Test
+ public void awaitPoolDrainWaitsForTasksEvenWhenCallerInterrupted() throws
Exception {
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ try {
+ CountDownLatch started = new CountDownLatch(1);
+ pool.submit(() -> {
+ started.countDown();
+ long end = System.currentTimeMillis() + 300L;
+ while (System.currentTimeMillis() < end) {
+ // Busy-wait that deliberately ignores interrupts, standing in for a
probe mid-flight.
+ }
+ });
+ assertTrue(started.await(2, TimeUnit.SECONDS));
+ pool.shutdownNow(); // interrupts the
(interrupt-ignoring) task
+ Thread.currentThread().interrupt(); // as stopWarmup() leaves the
warmup thread on shutdown
+
+ SingleConnectionBrokerRequestHandler.awaitPoolDrain(pool, 5_000L);
+
+ // Flag restored (and cleared here so it does not leak to other tests);
and the drain actually waited
+ // for the task rather than returning 0ms on the interrupt flag.
+ assertTrue(Thread.interrupted(), "the interrupt flag must be restored");
+ assertTrue(pool.isTerminated(), "drain must wait for the task, not skip
on the interrupt flag");
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ private static TableRouteInfo route(boolean hasOffline, boolean hasRealtime)
{
+ TableRouteInfo routeInfo = mock(TableRouteInfo.class);
+ when(routeInfo.hasOffline()).thenReturn(hasOffline);
+ when(routeInfo.hasRealtime()).thenReturn(hasRealtime);
+ return routeInfo;
+ }
+
+ /// The coverage universe is tenant-scoped: the union of serving instances
over the broker's routable
+ /// tables, NOT the cluster-wide getRoutableServerInstanceMap(). A server no
routable table routes to
+ /// (another tenant's server) is excluded, so it is never picked as a cover
target nor flagged uncovered.
+ @Test
+ public void routableServersIsTenantScopedNotClusterWide() {
+ // s4 is in the cluster-wide server map but no table routes to it.
+ RoutingManager routing = routing(
+ Map.of("a_OFFLINE", Set.of("s1", "s2"), "b_OFFLINE", Set.of("s3")),
Set.of("s1", "s2", "s3", "s4"));
+
assertEquals(SingleConnectionBrokerRequestHandler.routableServers(routing),
Set.of("s1", "s2", "s3"));
+ // With every routable server covered by the two tables, nothing is
uncovered even though s4 is in the
+ // cluster-wide map.
+ assertTrue(SingleConnectionBrokerRequestHandler.uncoveredRoutableServers(
+ routing, List.of("a_OFFLINE", "b_OFFLINE")).isEmpty());
+ }
+
+ private static RoutingManager routing(Map<String, Set<String>>
tableToServers, Set<String> routableServers) {
+ RoutingManager routingManager = mock(RoutingManager.class);
+
when(routingManager.getRoutableTables()).thenReturn(tableToServers.keySet());
+ tableToServers.forEach((table, servers) ->
when(routingManager.getServingInstances(table)).thenReturn(servers));
+ Map<String, ServerInstance> serverMap = new HashMap<>();
+ for (String server : routableServers) {
+ serverMap.put(server, mock(ServerInstance.class));
+ }
+ when(routingManager.getRoutableServerInstanceMap()).thenReturn(serverMap);
+ return routingManager;
+ }
+}
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java
index 9387ac13f8d..83289ca40c8 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java
@@ -28,6 +28,17 @@ public enum BrokerGauge implements AbstractMetrics.Gauge {
MAX_BURST_QPS("tables", false),
QUERY_RATE_LIMIT_DISABLED("queryQuota", true),
NETTY_CONNECTION_CONNECT_TIME_MS("nettyConnection", true),
+ // 1 once startup warmup has completed and readiness may be granted, 0 while
still warming. Always 1
+ // when warmup is disabled, so dashboards read identically on untouched
deployments.
+ STARTUP_WARMUP_COMPLETE("status", true),
+ // Number of routable servers (the servers this broker's routable tables
route to) that startup warmup did
+ // NOT reach before it exited -- no probe hit them. Set once at warmup exit.
0 on a normal floor exit (the
+ // round-robin probed every table). Non-zero means warmup stopped short of
its intended per-server
+ // coverage: the budget expired, or servers were too slow to respond, before
every server was probed. It
+ // is a completeness signal, not "these servers are cold" -- serve-path JIT
is warmed per-JVM and channels
+ // are opened by pre-connect, so an unreached server is only marginally
colder. Not recorded when warmup
+ // is disabled.
+ STARTUP_WARMUP_UNCOVERED_SERVERS("servers", true),
REQUEST_SIZE("requestSize", false),
RESIZE_TIME_MS("milliseconds", false),
UNHEALTHY_SERVERS("servers", true),
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMetrics.java
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMetrics.java
index 4f027e1702d..df0715e930c 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMetrics.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMetrics.java
@@ -47,6 +47,13 @@ public class BrokerMetrics extends
AbstractMetrics<BrokerQueryPhase, BrokerMeter
return BROKER_METRICS_INSTANCE.get();
}
+ /// A shared no-op instance backed by a
[org.apache.pinot.spi.metrics.NoopPinotMetricsRegistry]: every
+ /// record is discarded. For paths that must supply a [BrokerMetrics] but
must not pollute the real
+ /// counters -- e.g. startup warmup priming the reduce path with synthetic
queries.
+ public static BrokerMetrics noop() {
+ return NOOP;
+ }
+
/// Constructs the broker metrics.
///
/// @param metricsRegistry The metric registry used to register timers and
meters.
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java
index b049e51f66c..7b64714c452 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java
@@ -54,6 +54,9 @@ public enum BrokerTimer implements AbstractMetrics.Timer {
REALTIME_TOTAL_CPU_TIME_NS(false),
// How long it took the server to start.
STARTUP_SUCCESS_DURATION_MS(true),
+ // Wall-clock duration of startup warmup, from the moment it begins (Helix
convergence) to the moment
+ // readiness is released. Distinguishes "warmed to the floor" from "hit the
budget ceiling".
+ STARTUP_WARMUP_DURATION_MS(true),
// How long the startup server pre-connect (open broker->server channels,
including TLS handshake)
// took, from Helix convergence to the last channel connecting or the budget
expiring.
STARTUP_PRECONNECT_DURATION_MS(true),
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BrokerStartupWarmupIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BrokerStartupWarmupIntegrationTest.java
new file mode 100644
index 00000000000..3567ae46d1d
--- /dev/null
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BrokerStartupWarmupIntegrationTest.java
@@ -0,0 +1,142 @@
+/**
+ * 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 java.io.File;
+import java.util.List;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.broker.requesthandler.BrokerWarmupConfig;
+import org.apache.pinot.common.utils.ServiceStatus;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.util.TestUtils;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+
+/// Integration test for the broker startup data-plane warmup feature
+/// (`pinot.broker.startup.warmup.*`). Brings up a real ZK + controller +
server + broker with an offline
+/// table, then verifies two things end-to-end:
+///
+/// 1. With warmup enabled the readiness gate opens: the broker's
[ServiceStatus] reaches `GOOD`.
+/// Readiness is held at `STARTING` until warmup completes, and the
broker's query path serves
+/// regardless of [ServiceStatus], so a gate that never released would not
fail `setUp` -- asserting
+/// `GOOD` is what actually proves the gate released after warmup.
+/// 2. The production warmup path (`RoutingManager` set-cover ->
[QueryRouter] probe -> reduce) reaches
+/// its depth floor and returns `true` within the budget against a live
server.
+public class BrokerStartupWarmupIntegrationTest extends
BaseClusterIntegrationTest {
+ private static final long WARMUP_BUDGET_MS = 30_000L;
+ // Small floor so the integration test warms and opens readiness quickly
(the depth floor is only large
+ // in production, to drive JIT to its top tier).
+ private static final int WARMUP_MIN_ITERATIONS = 10;
+
+ @Override
+ protected void overrideBrokerConf(PinotConfiguration brokerConf) {
+
brokerConf.setProperty(CommonConstants.Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_ENABLED,
true);
+
brokerConf.setProperty(CommonConstants.Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_BUDGET_MS,
WARMUP_BUDGET_MS);
+
brokerConf.setProperty(CommonConstants.Broker.CONFIG_OF_BROKER_STARTUP_WARMUP_MIN_ITERATIONS,
+ WARMUP_MIN_ITERATIONS);
+ }
+
+ @BeforeClass
+ public void setUp()
+ throws Exception {
+ TestUtils.ensureDirectoriesExistAndEmpty(_tempDir, _segmentDir, _tarDir);
+ startZk();
+ startController();
+ startBroker();
+ startServer();
+
+ Schema schema = createSchema();
+ addSchema(schema);
+ TableConfig tableConfig = createOfflineTableConfig();
+ addTableConfig(tableConfig);
+
+ // Build and upload segments so the broker has a live server to route
probe queries to.
+ List<File> avroFiles = unpackAvroData(_tempDir);
+ ClusterIntegrationTestUtils.buildSegmentsFromAvro(avroFiles, tableConfig,
schema, 0, _segmentDir, _tarDir);
+ uploadSegments(getTableName(), _tarDir);
+
+ waitForAllDocsLoaded(600_000L);
+ }
+
+ @AfterClass
+ public void tearDown()
+ throws Exception {
+ dropOfflineTable(getTableName());
+ stopBroker();
+ stopServer();
+ stopController();
+ stopZk();
+ FileUtils.deleteDirectory(_tempDir);
+ }
+
+ @Test
+ public void warmupEnabledBrokerReachesGoodServiceStatus() {
+ String instanceId = _brokerStarters.get(0).getInstanceId();
+ TestUtils.waitForCondition(aVoid ->
ServiceStatus.getServiceStatus(instanceId) == ServiceStatus.Status.GOOD,
+ WARMUP_BUDGET_MS, "Broker with warmup enabled never reported GOOD
service status");
+ // Once GOOD, the warmup gate has released: its description is no longer
the "warming up" text. (The
+ // composite ServiceStatus description concatenates every callback's
"<name>:<desc>;", so it is never
+ // literally "None"; the meaningful assertion is that the warming
description is gone.) The full
+ // STARTING -> "Warming up broker data plane" -> GOOD transition is
covered deterministically in
+ // BrokerWarmupGateTest, since a healthy cluster warms faster than this
poll can observe.
+
Assert.assertFalse(ServiceStatus.getStatusDescription(instanceId).contains("Warming
up broker data plane"),
+ "Once GOOD, the warmup gate must no longer report the warming-up
description");
+ }
+
+ @Test
+ public void warmUpReachesFloorAgainstLiveServer() {
+ BrokerWarmupConfig config = new BrokerWarmupConfig(true, WARMUP_BUDGET_MS,
WARMUP_MIN_ITERATIONS, 1);
+ boolean reachedFloor = _brokerStarters.get(0).getBrokerRequestHandler()
+ .warmUp(config, System.currentTimeMillis() + WARMUP_BUDGET_MS);
+ Assert.assertTrue(reachedFloor,
+ "Warmup should reach its probe-count floor against a live server
within the budget");
+ }
+
+ @Test
+ public void warmUpReachesFloorWithConcurrentProbes() {
+ // Concurrency 3: probes fire in parallel on a 3-thread pool, exercising
the concurrent scatter/gather
+ // path the serial arms do not.
+ BrokerWarmupConfig config = new BrokerWarmupConfig(true, WARMUP_BUDGET_MS,
WARMUP_MIN_ITERATIONS, 3);
+ boolean reachedFloor = _brokerStarters.get(0).getBrokerRequestHandler()
+ .warmUp(config, System.currentTimeMillis() + WARMUP_BUDGET_MS);
+ Assert.assertTrue(reachedFloor, "Concurrent warmup should reach its
probe-count floor within the budget");
+ }
+
+ @Test
+ public void warmUpReturnsFalseWhenBudgetExpiresBeforeFloor() {
+ // An unreachable floor with a short budget must exit on the budget
(returning false) and must not hang.
+ long shortBudgetMs = 2_000L;
+ BrokerWarmupConfig config = new BrokerWarmupConfig(true, shortBudgetMs,
100_000_000, 1);
+ long start = System.currentTimeMillis();
+ boolean reachedFloor = _brokerStarters.get(0).getBrokerRequestHandler()
+ .warmUp(config, start + shortBudgetMs);
+ long elapsed = System.currentTimeMillis() - start;
+ Assert.assertFalse(reachedFloor, "Warmup must return false when the budget
expires before the floor");
+ // Tight bound: with the budget a hard ceiling (each Future.get is bounded
by the remaining budget and
+ // stragglers are cancelled), warmUp returns within a small epsilon of the
budget, not merely "eventually".
+ Assert.assertTrue(elapsed < shortBudgetMs + 2_000L,
+ "Warmup must return within ~budget, not hang (elapsed " + elapsed + "
ms, budget " + shortBudgetMs + ")");
+ }
+}
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 02c91c1afe1..2c76cf1e39f 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
@@ -435,6 +435,32 @@ public class CommonConstants {
"pinot.broker.startup.minResourcePercent";
public static final double DEFAULT_BROKER_MIN_RESOURCE_PERCENT_FOR_START =
100.0;
+ // Startup data-plane warmup: before readiness is granted, run probe
queries so the JIT-compiled query
+ // path and per-query caches are warm before the first real traffic. Off
by default; opt-in per
+ // deployment.
+ public static final String CONFIG_OF_BROKER_STARTUP_WARMUP_ENABLED =
"pinot.broker.startup.warmup.enabled";
+ public static final boolean DEFAULT_BROKER_STARTUP_WARMUP_ENABLED = false;
+ // Hard ceiling on the whole warmup (measured from Helix convergence):
readiness opens when it expires
+ // whatever the probe progress, so a slow or unreachable server cannot
stall a rolling restart. Sized so
+ // the minIterations floor is actually reachable on a constrained/TLS
broker (~1000 serial probes take
+ // ~20-25s there) -- the budget is the safety cap, not the normal exit. A
healthy broker reaches the
+ // floor and serves well before this; only a genuinely slow one runs to
the cap.
+ public static final String CONFIG_OF_BROKER_STARTUP_WARMUP_BUDGET_MS =
"pinot.broker.startup.warmup.budgetMs";
+ public static final long DEFAULT_BROKER_STARTUP_WARMUP_BUDGET_MS = 30_000L;
+ // Minimum number of successful probe queries before warmup declares the
broker warm. This is a depth
+ // floor, not a latency guess: enough probe invocations to drive the query
path's JIT to its top tier.
+ // Warmup exits when this many probes have run OR the budget expires --
whichever comes first. The probe
+ // is always the static `SELECT * FROM "<t>" LIMIT 1` over a set-cover of
tables spanning every server.
+ public static final String CONFIG_OF_BROKER_STARTUP_WARMUP_MIN_ITERATIONS =
+ "pinot.broker.startup.warmup.minIterations";
+ public static final int DEFAULT_BROKER_STARTUP_WARMUP_MIN_ITERATIONS =
1000;
+ // Number of probe queries fired concurrently per round. Serial (1) warms
the serve path; a higher value
+ // additionally warms the concurrency step (channel-lock contention,
concurrent scatter/gather/reduce)
+ // that the first real traffic burst hits. Default 1 (serial); raise it to
warm closer to the expected
+ // burst.
+ public static final String CONFIG_OF_BROKER_STARTUP_WARMUP_CONCURRENCY =
+ "pinot.broker.startup.warmup.concurrency";
+ public static final int DEFAULT_BROKER_STARTUP_WARMUP_CONCURRENCY = 1;
// When enabled, once Helix converges at startup the broker opens a Netty
channel to every (server,
// table type) it routes to -- including the TLS handshake when
broker->server TLS is on -- so the
// first real query does not pay the blocking connect on its critical
path. Runs on a background
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]