Skip to content

Add single-stage parameterized query plan cache #226

Draft
praveenc7 wants to merge 1 commit into
masterfrom
praveenc7/sse-parameterized-plan-cache
Draft

Add single-stage parameterized query plan cache #226
praveenc7 wants to merge 1 commit into
masterfrom
praveenc7/sse-parameterized-plan-cache

Conversation

@praveenc7

@praveenc7 praveenc7 commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

High-QPS single-stage workloads are frequently dominated by re-parsing the same query shape with only the literal values changing (e.g. point lookups with large IN lists). Profiling one such broker query showed ~27% of broker CPU in RequestUtils.parseQueryCalciteSqlParser, with no parse/plan cache on the single-stage path. Because the literals vary per request, a plain text-keyed cache has a ~0% hit rate.
https://observe.prod.linkedin.com/profiling-explorer/event/4728628
This adds an opt-in parameterized plan cache keyed on a literal-agnostic template:

  • QueryTemplateNormalizer — a cheap lexer pass (not a full parse) that masks literals with kind-tagged placeholders (?s/?i/?d) and extracts their values in order. Distinct kinds ensure a string can never bind into a numeric leaf.
  • ParameterizedPlanCache — caches the un-rewritten PinotQuery per shape; on a hit it deep-copies the template, rebinds the literals, and runs the query rewriters fresh, producing a result byte-identical to CalciteSqlParser.compileToPinotQuery. Query options are excluded from the key (the normal path applies them after rewrite) and bound per request.
Screenshot 2026-06-18 at 1 17 00 PM

Anything ineligible transparently falls back to a normal parse. Multi-stage is out of scope (it validates against the schema during planning).

Config (all default-safe)

Key Default
pinot.broker.query.plan.cache.enabled false
pinot.broker.query.plan.cache.size 1000
pinot.broker.query.plan.cache.verification.sample.rate 0.0

Observability

Meters QUERY_PLAN_CACHE_HIT / MISS / UNCACHEABLE / VERIFICATION_FAILURE, and gauge QUERY_PLAN_CACHE_SIZE.

Performance (JMH BenchmarkPlanCache, JDK 21)

On a cache hit the compile cost — which already includes normalize + deepCopy + rebind + rewrite — drops:

Query full compile (today) cache hit reduction
26-value IN (the profiled query) 88.0 µs 3.66 µs ~24× (−96%)
simple point lookup 32.9 µs 0.48 µs ~69×

Rollout

Enable with enabled=true and a non-zero verification.sample.rate (e.g. 0.01–1.0) in EI/canary to catch any normalizer bug at near-zero cost, then lower the sample rate in production.

Follow-ups (not in this PR)

  • IN-squashing for count-independent keys (today each IN-arity is a distinct cache entry).
  • Spacing canonicalization in the key (minor hit-rate improvement).

Testing Done

  • Unit tests — QueryTemplateNormalizerTest (round-trip + cross-binding equal to a direct parse, safety fallbacks; 12 cases) and ParameterizedPlanCacheTest (hit/miss/uncacheable, sampled verification pass + mismatch fail-safe, per-query bypass, metrics; 6 cases).
  • Regression — CalciteSqlCompilerTest (78) and BaseSingleStageBrokerRequestHandlerTest (8) unchanged; checkstyle clean.
  • JMH benchmark added (BenchmarkPlanCache); numbers above.
    "/tmp/plan-cache-pr-body.md" 52L, 3622B
    TITLE: Add single-stage parameterized query plan cache (default off)

High-QPS single-stage workloads are often dominated by re-parsing the same
query shape with only the literal values changing (e.g. point lookups with
large IN-lists). Profiling such a query showed ~27% of broker CPU in
RequestUtils.parseQuery -> CalciteSqlParser, with no parse/plan cache.

Add an opt-in plan cache keyed on a literal-agnostic template:
- QueryTemplateNormalizer: a cheap lexer pass (not a full parse) that masks
  literals with kind-tagged placeholders (?s/?i/?d) and extracts their values
  in order. Distinct kinds prevent a string from ever binding into a numeric
  leaf. Correctness is guarded by a per-template self-consistency check
  (isCacheable re-derives each literal and verifies it reproduces the parsed
  plan's leaves); positional bind rebinds them on a hit.
- ParameterizedPlanCache: caches the un-rewritten PinotQuery per shape; on a
  hit it deep-copies the template, rebinds literals, and runs the rewriters
  fresh, producing a result byte-identical to CalciteSqlParser.compileToPinotQuery.

Production safety and observability:
- Only single-stage DQL is eligible; DML and the per-query usePlanCache=false
  option bypass the cache.
- Optional sampled shadow-verification (verification.sample.rate) compares a hit
  against a fresh compile and, on any mismatch, returns the fresh plan, evicts
  the template, and emits QUERY_PLAN_CACHE_VERIFICATION_FAILURE.
- Meters QUERY_PLAN_CACHE_HIT/MISS/UNCACHEABLE/VERIFICATION_FAILURE and gauge
  QUERY_PLAN_CACHE_SIZE.
- Gated by pinot.broker.query.plan.cache.enabled (default false), with
  .size and .verification.sample.rate knobs.

JMH (BenchmarkPlanCache, JDK21): on a cache hit, compile drops from ~88us to
~3.7us for the 26-value IN query (~24x), and 32.9us to 0.48us for a simple query
-- the hit cost already includes normalize + deepCopy + rebind + rewrite.

Tests: QueryTemplateNormalizerTest (round-trip + cross-binding vs direct parse,
safety fallbacks) and ParameterizedPlanCacheTest (hit/miss/uncacheable, sampled
verification pass + mismatch fail-safe, per-query bypass, metrics). Existing
parser/compiler corpus and single-stage handler tests unchanged.
@praveenc7 praveenc7 changed the title Add single-stage parameterized query plan cache (default off) Add single-stage parameterized query plan cache Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant