jineshparakh opened a new pull request, #19500:
URL: https://github.com/apache/pinot/pull/19500

   ## Summary
   
   A freshly (re)started broker reports Ready as soon as Helix routing 
converges, but its query serve path
   is still cold at that instant: the SQL compile path, the scatter gather and 
reduce code, and the response
   build path are all uncompiled (interpreted or only partially JIT compiled), 
and per query caches are
   empty. The first burst of real traffic therefore runs the serve path cold, 
which on a constrained broker
   saturates CPU and inflates latency enough that clients cancel (the cold 
start cancellation storm).
   
   This PR warms the serve path with real probe queries **before** readiness is 
granted, behind a readiness
   gate. When enabled the broker reports `STARTING` until the probes have 
driven the query path warm (or a
   budget expires), so the first real traffic hits an already warm broker. It 
is off by default, opt in per
   deployment, bounded so it can never stall a rolling restart, and it 
complements the server pre-connect
   change (which removes the broker to server connect and TLS handshake from 
the first query; this change
   removes the serve path cold start).
   
   ## What it does
   
   After Helix converges, a background thread runs probe queries through 
`QueryRouter`, deliberately
   bypassing access control, query quota, and the query log, so warmup needs no 
synthetic identity and
   pollutes no customer facing surface. Each probe exercises the full serve 
path: compile, route, scatter,
   receive and deserialize the server DataTables, and reduce. A small local no 
network step (stage 1) also
   compiles a throwaway query and serializes a synthetic response, warming the 
SQL compile and JSON response
   paths even if no server is reachable.
   
   The exit condition is a **depth floor OR a budget ceiling**, never a latency 
guess:
   
   - **Default probe** (`SELECT * FROM "<t>" LIMIT 1` over a greedy set cover 
of tables that covers every
     routable server): warmup declares the broker warm once at least 
`minIterations` probes have completed
     successfully. This is a depth floor that guarantees enough invocations to 
drive the query path's JIT to
     its top tier. 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 (this was verified experimentally, see below).
   - **Custom probe query** (`warmup.query`): one or more representative 
production query shapes (separated
     by `;`), all run every round, exiting on the same `minIterations` depth 
floor or the budget. A
     probe-count floor is a cost-independent warm signal; latency plateauing is 
not (a trivial query reaches a
     stable latency while still cold), so it is deliberately not used. Several 
shapes together warm several
     serve-path branches, so a scan, an aggregation, a group by and a join can 
each warm their own branch.
   - **Budget**: a hard ceiling measured from Helix convergence. Readiness 
opens when it expires whatever the
     probe progress, so a slow or unreachable server can never stall a rolling 
restart. The gate also opens
     if warmup throws or is interrupted.
   
   Probes are fired `concurrency` at a time per round. Serial (1, the default) 
warms the serve path; a higher
   value additionally warms the concurrency step (channel lock contention, 
concurrent scatter gather and
   reduce, GC under load) that the first real burst hits.
   
   This is **single stage (SSE) only**. The multi stage (gRPC) and time series 
paths are unaffected.
   
   ## Flow
   
   Warmup runs after Helix convergence, behind the readiness gate. Every path 
is bounded by the budget
   (it never hangs), and the gate opens whether warmup succeeds or times out. 
The default probe picks
   its tables by a deterministic greedy set cover over routable servers; a 
custom `warmup.query`
   bypasses selection.
   
   ```text
   warmUp(config, deadline)              after Helix convergence, behind the 
STARTING readiness gate
    |
    |- warmUpLocal()                 stage 1: compile "SELECT 1" + serialize a 
response (no network)
    |- create probe pool                 (concurrency daemon threads)
    |
    |- hasCustomQuery() ?
    |
    |=== NO: AUTO-SELECT ===   default probe:  SELECT * FROM "<t>" LIMIT 1
    |    |- tables = hasCustomTables ? filterRoutableTables()   (keep only 
routable)
    |    |                           : selectProbeTables()      (greedy 
set-cover, see below)
    |    |- tables empty (routing not ready)?  -> backoff + retry, until budget
    |    |- reportServerCoverage() ONCE  -> WARN + 
BROKER_WARMUP_UNCOVERED_SERVERS gauge
    |    |- each round: roundRobinBatch(tables, probeSeq, concurrency) -> 
probe()
    |    '- EXIT: successfulProbes >= minIterations -> TRUE   |   budget 
expired -> FALSE
    |
    |=== YES: CUSTOM QUERY ===   one or more shapes separated by ';'
    |    |- compile-validate each query  (skip bad;  none valid -> FALSE)
    |    |- each round: run ALL queries x concurrency -> probe()
    |    '- EXIT: successfulProbes >= minIterations -> TRUE  |  budget -> FALSE 
  (same floor as default)
    |
    '- return TRUE | FALSE  ->  readiness gate opens EITHER WAY   (bounded by 
budget; never hangs)
   
   
   selectProbeTables(maxTables)          greedy set-cover, deterministic
      uncovered  = servers THIS broker routes to     (union of servingInstances 
over routable tables,
                                                       NOT the cluster-wide 
getRoutableServerInstanceMap)
      candidates = routable tables, SORTED            (same choice on every 
broker / restart)
      for table in candidates:
         stop if  |selected| == maxTables  OR  uncovered is empty
         servers = servingInstances(table)            (null/empty -> skip)
         if servers cover >= 1 uncovered server:  select table;  remove them 
from uncovered
      return selected
      # maxTables hit with servers still uncovered -> reportServerCoverage() 
warns + sets the gauge
   
   
   probe(request, timeout)
      getTableRouteInfo(raw name)
        -> shouldRouteOffline / shouldRouteRealtime -> typedRequest(typed name) 
per leg
        -> calculateRoutes -> submitQuery -> getFinalResponses -> reduce 
(best-effort)
        -> return latency         (or -1 on failure;  never throws out)
   ```
   
   ## Configuration
   
   | Property | Default | Description |
   |---|---|---|
   | `pinot.broker.startup.warmup.enabled` | `false` | Run probe queries at 
startup and gate readiness on warmup. Off by default. |
   | `pinot.broker.startup.warmup.budgetMs` | `15000` | Hard ceiling on the 
whole warmup (from Helix convergence). Readiness opens when it expires. |
   | `pinot.broker.startup.warmup.minIterations` | `1000` | Default probe depth 
floor: warmup exits once this many probes have completed, or the budget 
expires. Enough invocations to reach top tier JIT. |
   | `pinot.broker.startup.warmup.maxTables` | `5` | Upper bound on tables 
probed by the default probe (a set cover covering every routable server). 
Governs coverage breadth: raise it if `BROKER_WARMUP_UNCOVERED_SERVERS` is 
non-zero. |
   | `pinot.broker.startup.warmup.query` | `` | Optional. One or more complete 
probe queries (each names its own table), separated by `;` when more than one, 
so several representative shapes warm several serve paths. Empty means auto 
select the default probe. All configured queries run every round and exit on 
the same `minIterations` floor. |
   | `pinot.broker.startup.warmup.tables` | `` | Optional comma separated 
tables to probe with the default query. Empty means auto select via set cover. 
Ignored when `warmup.query` is set. |
   | `pinot.broker.startup.warmup.concurrency` | `1` | Probes fired 
concurrently per round. Serial by default. |
   
   ## Metrics
   
   | Metric | Type | Meaning |
   |---|---|---|
   | `BROKER_WARM` (gauge) | gauge | `1` once warmup has completed and 
readiness may be granted, `0` while warming. Always `1` when warmup is 
disabled. |
   | `STARTUP_WARMUP_DURATION_MS` | timer | Wall clock duration of warmup, from 
Helix convergence to readiness being released. |
   | `BROKER_WARMUP_UNCOVERED_SERVERS` | gauge | Number of servers this broker 
routes to (the union of serving instances over its routable tables, 
tenant-scoped, not the cluster-wide server map) that the chosen probe tables do 
not, between them, cover. `0` means the probes span every server this broker 
routes to. Non-zero means `maxTables` capped the set cover short (raise it) or 
a custom `warmup.tables` list omits some servers. Recorded once per warmup run; 
not recorded when warmup is disabled. |
   
   ## Performance
   
   ### Setup
   
   All numbers below are from a **TLS-enabled** single broker test cluster (a 
real ZK plus controller plus
   server plus broker, not a synthetic mock), with both client to broker and 
broker to server TLS on:
   
   - One offline table: about **4.0 million documents** across **41 segments**, 
**MMAP** load mode, single
     replica.
   - **Open loop** synthetic cold load (step arrival, no ramp) at a fixed 
arrival rate. Because arrivals do
     not wait for responses, under elevated cold latency the in flight count 
builds to several hundred
     concurrent requests.
   - Each run is split into a **cold** window `[0, 8 s)` and a **warm** window 
`[150, 180 s)` measured from
     the first traffic at readiness. A cancellation is a query whose client 
round trip exceeds 2 s.
   - **5 cold restarts per arm** (each arm = one setting of the warmup config), 
toggled via controller
     cluster config so the operator stays running.
   
   ### Gain
   
   On a **constrained (2 vCPU) broker at about 288 queries/second**, deep 
warmup nearly eliminates the cold
   start cancellation storm (median / mean / range of cold cancellation rate, 5 
restarts per arm):
   
   ```
   warmup OFF (pre-connect only)        : 39.4% / 39.6% / [22.1, 52.7]   serve 
time (timeUsed) avg 1095 ms
   warmup ON, deep                      :  4.8% /  3.5% / [ 0.0,  6.3]   serve 
time (timeUsed) avg  115 ms
   ```
   
   That is about a **91% reduction** in cold cancellations, with cold serve 
time down about **9.5x**
   (1095 ms to 115 ms) and peak concurrent in flight down about **4x** (1008 to 
250), because faster serve
   means lower latency, which by Little's law means lower concurrency, which 
avoids the CPU saturation that
   drove the storm. Every warm window across every arm was 0% cancellations at 
about 2.5 ms wall.
   
   A shallow warmup is not enough: an early exit after only 2 to 3 probes left 
the path only partially
   compiled and the cancellation rate at about 29%, which is why the shipped 
default exits on a probe count
   floor (`minIterations`) rather than on a latency target.
   
   ## Testing
   
   - **Unit** (`BrokerWarmupTest`, 20 tests): config parsing including the 
`concurrency`, `minIterations` and
     `maxTables` floors, the custom `query` (multiple queries separated by `;`) 
and `tables` overrides, and
     the no op interface default; the greedy set cover selection (covers every 
server, skips redundant tables,
     respects `maxTables`, handles an empty cluster, ignores tables without 
serving instances); the coverage
     check (`uncoveredRoutableServers` and the 
`BROKER_WARMUP_UNCOVERED_SERVERS` gauge emission); the
     round-robin batch rotation (advances across rounds, wraps, survives index 
overflow); the
     offline/realtime/hybrid probe routing decision across every table-type 
combination; the custom-table
     filter; the tenant-scoped coverage universe (a cluster server no table 
routes to is not counted); and
     the `concurrency` upper clamp.
   - **Integration** (`BrokerStartupWarmupIntegrationTest`, 6 tests): brings up 
a real ZK plus controller plus
     server plus broker with an offline table and asserts, against a live 
server: (1) with warmup enabled the
     readiness gate opens and the broker's `ServiceStatus` reaches `GOOD`; (2) 
the default auto-select path
     (`RoutingManager` set cover to `QueryRouter` probe to reduce) reaches its 
probe-count floor and returns
     `true`; (3) the custom multi-query path warms and returns `true`; (4) 
concurrent probes (`concurrency`
     greater than 1) reach the floor; (5) an explicit `tables` list warms; and 
(6) an unreachable floor with a
     short budget returns `false` promptly rather than hanging.
   
   ## Backward compatibility
   
   - Off by default, so existing deployments are unchanged.
   - Readiness reports the existing `STARTING` status, with no new status enum 
value, so mixed version
     broker/controller peers are unaffected.
   - The `BROKER_WARM` gauge always reads `1` when warmup is disabled, so 
dashboards read identically on
     untouched deployments.
   - No wire protocol or serialization changes.
   
   ## Relationship to server pre-connect
   
   This change is independent of and complementary to the broker startup server 
pre-connect change.
   Pre-connect removes the broker to server connect and TLS handshake from the 
first query; this change
   removes the serve path cold start. Each is independently valuable and either 
can be enabled without the
   other. When both are present they compose: each registers its own `STARTING` 
readiness gate, and readiness
   is granted only once both are satisfied.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to