Skip to content

DRILL-8555: Physical plan cache for parameterized SQL queries - #3086

Open
letian-jiang wants to merge 21 commits into
apache:masterfrom
letian-jiang:physical-plan-cache
Open

letian-jiang wants to merge 21 commits into
apache:masterfrom
letian-jiang:physical-plan-cache

Conversation

@letian-jiang

Copy link
Copy Markdown
Contributor

DRILL-8555: Physical plan cache for parameterized SQL queries

Description

This PR adds a Drillbit-scoped physical plan cache with HBase and Iceberg support, reusing plans across connections to reduce repeated validation and optimization. It is disabled by default; enable it with ALTER SESSION SET planner.enable_plan_cache = true.

Method

flowchart LR
    SQL[SQL] --> Template[SQL template]
    Template --> Cache{Plan cache}
    Cache -->|Hit| Bind[Bind literals]
    Cache -->|Miss| Planner[Plan query]
    Bind --> Plan[Physical plan]
    Planner --> Plan
Loading

Eligible literals become parameter slots in the SQL template. A hit binds current values to a fresh copy of the cached plan; a miss plans the query normally and populates the cache after successful execution.

Safety guarantees

  • Only supported read queries are cached. Volatile or query-context functions and unsupported scans bypass caching. Structural literals and function configuration arguments stay in the cache key.
  • Reuse requires matching effective options, plugin configurations and table compatibility versions. Binding checks parameter types and numeric ranges; compatibility or reconstruction failures fall back to normal planning.
  • Cached plans are immutable, and each execution gets a fresh operator graph. Plugins opt in explicitly and must rebuild scan state from current parameters and metadata while preserving residual filters.

Benchmark

Measured on one local Drillbit with a Ryzen 7 9700X, 30 GiB RAM and OpenJDK 21. HBase used a 1,000-row mini-cluster for point reads, 50-row range scans and column filters. Iceberg ran all 22 TPC-H queries over eight SF0.01 tables (Q15 used a derived table; Q19 exposed the common equijoin).

Cache hits

Workload Planning off → hit (reduction) End-to-end off → hit (reduction)
HBase point read 50 → 14 ms (72.0%) 66 → 29 ms (56.1%)
HBase range scan 40 → 12 ms (70.0%) 55 → 26 ms (52.7%)
HBase column filter 32 → 10 ms (68.8%) 44 → 22 ms (50.0%)
Iceberg TPC-H SF0.01 2,161 → 355 ms (83.6%) 18,831 → 16,906 ms (10.2%)

The benefit is largest when planning dominates latency: it accounts for roughly 73–76% of the reported HBase baseline latency, and hits reduce end-to-end latency by 50–56%. Analytical queries also benefit: Iceberg planning drops 83.6%, reducing aggregate end-to-end latency by 10.2%.

HBase values are medians of three run medians (15 pairs per workload per run). Iceberg values are sums of per-query medians (three pairs per query), not suite wall time. Percentages are latency reductions relative to cache-off execution.

Cache misses

A separate warmed comparison cleared the plan cache before each cache-on query and drained background writes before both modes. Misses added a median 3–5 ms of paired end-to-end latency for HBase (45 pairs per workload). For Iceberg, the sum of query end-to-end medians changed from 20,624 to 21,806 ms (+5.7%). Miss overhead was modest in these local measurements, while hits provided the largest benefit for short queries.

Documentation

  • PLAN_CACHE_DESIGN.md: basic principles and supported scope.
  • PLAN_CACHE_PLUGIN_GUIDE.md: plugin APIs and scan reconstruction requirements.

@cgivre

cgivre commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

@shfshihuafeng Thank you for this PR. Did you take a look at #3023? I realize they are somewhat different, but it seems the overall goal is similar in trying to cache plans. Are there components of that PR that you could incorporate in yours?

@letian-jiang

Copy link
Copy Markdown
Contributor Author

@cgivre Thanks for pointing me to #3023. I have reviewed it, and I agree that both PRs share the same underlying goal: reusing planning work to reduce the overhead of repeated queries.

Before considering #3023 production-ready, I think we would need to establish the conditions for safe plan reuse, even for identical SQL. For example:

  1. Time-dependent or non-deterministic expressions, such as date/time and random functions, need special handling when expressions are evaluated or folded during planning.
  2. Changes to options that affect planning may invalidate a cached plan.
  3. Physical plans contain mutable state, such as node assignments, which should be isolated between executions.
  4. Changes to the underlying data files may require rebuilding scan state.
  5. Changes to the underlying table schema may invalidate the plan.

There is also a practical consideration: production workloads often repeat the same query shape with different literals. Restricting reuse to identical SQL text would substantially limit the benefit for those workloads.

My implementation focuses on these correctness requirements through eligibility checks, context and table compatibility checks, and reconstruction of a fresh plan and scan state for each execution. It also preserves typed parameter slots so queries with different literals can reuse compatible cached plans.

@cgivre cgivre left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few issues with parameterized planning and the BIGINT literal change; details inline.

@letian-jiang
letian-jiang requested a review from cgivre October 6, 2026 08:18
@letian-jiang

Copy link
Copy Markdown
Contributor Author

Hi @cgivre, thanks again for your review! I’ve pushed updates addressing your feedback on parameterized planning and BIGINT literal round trips. When you have a chance, could you take another look? Please let me know if there’s anything else you’d like me to address. Thanks!

@cgivre

cgivre commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Suggestion: make the plan cache size and expiration configurable

PlanCache hard-codes its limits (PlanCache.java:98-102): a 32 MB maximum weight and expireAfterWrite(10, MINUTES). Operators can't tune either one. A deployment with many distinct query templates may want a larger cache. One where tables change often, or where memory on the planning Drillbit is tight, may want a smaller cache or a shorter lifetime.

Could these be boot options in drill-module.conf, read in DrillbitContext and passed to the PlanCache constructor? The generated-code cache already does this with drill.exec.compile.cache_max_size. For example:

drill.exec.plan_cache: {
  # Upper bound on the total size of cached plan JSON, per Drillbit
  max_size_bytes: 33554432,
  # How long an entry may live after it is written; 0 disables time-based expiry
  expire_after_write: 10m,
  # Optional: evict entries that have not been used for this long; 0 disables
  expire_after_access: 0
}

A few related points:

  • The weigher uses value.json.length(), which counts characters, not bytes. If the setting is named in bytes, the weigher should measure bytes (or the setting should say characters).
  • expireAfterAccess would let rarely used templates age out sooner, while a hot query isn't replanned every 10 minutes just because it was written 10 minutes ago.
  • It would help to log the settings in use when the Drillbit starts, and to document them in the plan-cache design doc.
  • Startup settings are a good fit because the cache is created once per Drillbit, and Guava can't change these limits on a cache that's already built. planner.enable_plan_cache stays the runtime on/off switch.

@cgivre

cgivre commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Question: plans to support the other storage plugins?

Right now only HBase and Iceberg opt in (PLAN_CACHE_DESIGN.md:75). What are the plans for expanding this to the rest of the plugins? I'd like to see it cover as many as possible. The most heavily used ones (Parquet and the other dfs formats, and JDBC) are the ones that would benefit most, and they're currently excluded.

I understand why they can't simply be switched on. On a hit, the cache rebinds literals in Drill expressions. But most plugins turn those literals into native state during planning and keep only the result:

Plugin Value-dependent state built at planning time
Parquet Row-group / file pruning based on filter values
All dfs formats Directory (partition) pruning, and a fixed file list
JDBC Pushed-down SQL text with literals inlined
Mongo, Elasticsearch, Splunk, HTTP Native query / request built from the filter
Kafka Offset / timestamp ranges

Reusing those plans without rebuilding that state would silently return wrong results. So each plugin needs to keep the source expression in its scan JSON and redo pushdown/pruning on bind, as PLAN_CACHE_PLUGIN_GUIDE.md describes.

A possible path, roughly in order of effort:

  1. dfs formats without filter pushdown (CSV/TSV, JSON, Avro, etc.): filtering stays in Drill's Filter operator, so the plugin only needs to re-list files and redo directory pruning on bind, plus a table version that catches schema changes.
  2. Parquet: redo row-group/partition pruning for the new values, reusing the existing Parquet metadata cache to keep it cheap.
  3. JDBC and other pushdown plugins: a shared "rebuild pushdown on bind" hook in the plugin API, so each plugin doesn't reinvent it.

It would be worth measuring planning time against bind-time rebuild cost for dfs/Parquet, since re-listing and re-pruning eat into what a hit saves.

Given how large this PR already is, I'm fine with plugin expansion coming in follow-ups. But I'd like to understand the roadmap, and to make sure the plugin API in this PR makes opting in as cheap as possible. Could you add the plan (or JIRAs for each plugin) to the design doc?

@cgivre

cgivre commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Review findings

Line numbers refer to the PR head. The first three are correctness issues I'd like to see fixed before merge.

Correctness

  1. No fallback when planning the parameterized SQL fails (DrillSqlWorker.java:354). On a miss, the query is planned once from candidate.sql. The try/catch covers lookup and preparation but not handler.getPlan(planningSql). Any query that only plans with real literals then fails, but only when the cache is enabled. Examples: JOIN ... ON TRUE (becomes ON ?), PERCENTILE_CONT(0.5) WITHIN GROUP, and functions needing a literal operand that aren't on the hard-coded list. Please re-plan from the original literal SqlNode on failure. The comment about validation mutating the tree suggests keeping an unmodified copy (or re-parsing) for the fallback.
  2. HAVING/QUALIFY are parameterized while GROUP BY and the select list are not (PlanCacheParameterizer.java:130). GROUP BY x || 'a' HAVING x || 'a' = 'ba' becomes HAVING x || ? = ?, which no longer matches the grouped expression. Calcite then rejects it with "Expression 'x' is not being grouped", and because of Mavenized query-parse subproject #1 the query fails.
  3. Global change to BIGINT literal serialization (ExpressionStringBuilder.java:191). visitLongConstant now always emits cast(N as BIGINT), even with caching off. That changes EXPLAIN output, scan digests and filter strings (e.g. AbstractGroupScanWithMetadata.getFilterString) for everyone, and relies on a special case in ExprParser.createCast to undo it. Could this be limited to bound parameters?

Performance / design

  1. Context mismatch evicts instead of coexisting (DrillSqlWorker.java:334). The options/config fingerprint is checked inside the entry rather than included in the key. Two sessions running the same template with different options (e.g. planner.slice_target) invalidate each other's entry on every run, so the hit rate is 0 and each query also pays to republish. Including the fingerprints in the key fixes this.
  2. Remote metadata I/O on every lookup, including hits (HBaseStoragePlugin.java:77, IcebergFormatPlugin.java:124). HBase opens an Admin and makes two master RPCs. Iceberg runs HadoopTables.load, and then loads the table again in IcebergGroupScan during bind. A hit can cost more than the planning it skips. Benchmarks of hit latency against planning time would help.
  3. Entries are published without checking they can be rebound (PlanCache.java:187). put() only validates that the JSON reads back. An entry whose bind always fails is published, fails on every hit, is invalidated, gets replanned and is republished, forever. A trial bind before publishing would catch this.
  4. The writer queue holds live plan graphs (PlanCache.java:162). Up to 64 full PhysicalPlan object graphs (region maps, Iceberg task lists, plugin references) stay alive until serialized. They're also serialized after execution, which may have changed them. Serializing to a string right after planning and queuing only the string avoids both.
  5. Hard-coded allowlist of literal-only function operands (PlanCacheParameterizer.java:142). Any function not on the list (format strings, scale arguments, new UDFs) gets a slot and fails planning because of Mavenized query-parse subproject #1. Could this come from operator operand metadata instead?
  6. On a miss, the executed plan loses literal-based optimization (DrillSqlWorker.java:345). WHERE 1 = 1 and flag = TRUE become ? = ? / flag = ?, so constant reduction and selectivity estimates are lost for that run. This happens even if the plan is never published.

Observability and operations

  1. No logging of cache activity. Hits, misses, evictions and context-mismatch invalidations aren't logged; only some failures are logged at debug level. PlanCache.getHitCount() exists but nothing outside tests reads it. Please add debug logging for hits, misses and invalidation reasons, and expose hit/miss/eviction counts through Drill metrics or a sys table.
  2. No way to flush the cache. Entries are only removed on expiry or when found stale on lookup. Turning off planner.enable_plan_cache doesn't clear them, and there's no admin command or REST endpoint. (Configurable size and expiration are covered in my earlier comment.)

Docs

  1. PLAN_CACHE_DESIGN.md and PLAN_CACHE_PLUGIN_GUIDE.md are at the repo root. Developer docs belong under docs/dev/ and should be linked from docs/dev/DevDocs.md.

@letian-jiang

Copy link
Copy Markdown
Contributor Author

@cgivre Thank you for the detailed and thoughtful review! I’ve learned a lot from your comments.

@letian-jiang

Copy link
Copy Markdown
Contributor Author

Suggestion: make the plan cache size and expiration configurable

PlanCache hard-codes its limits (PlanCache.java:98-102): a 32 MB maximum weight and expireAfterWrite(10, MINUTES). Operators can't tune either one. A deployment with many distinct query templates may want a larger cache. One where tables change often, or where memory on the planning Drillbit is tight, may want a smaller cache or a shorter lifetime.

Could these be boot options in drill-module.conf, read in DrillbitContext and passed to the PlanCache constructor? The generated-code cache already does this with drill.exec.compile.cache_max_size. For example:

drill.exec.plan_cache: {
  # Upper bound on the total size of cached plan JSON, per Drillbit
  max_size_bytes: 33554432,
  # How long an entry may live after it is written; 0 disables time-based expiry
  expire_after_write: 10m,
  # Optional: evict entries that have not been used for this long; 0 disables
  expire_after_access: 0
}

A few related points:

  • The weigher uses value.json.length(), which counts characters, not bytes. If the setting is named in bytes, the weigher should measure bytes (or the setting should say characters).
  • expireAfterAccess would let rarely used templates age out sooner, while a hot query isn't replanned every 10 minutes just because it was written 10 minutes ago.
  • It would help to log the settings in use when the Drillbit starts, and to document them in the plan-cache design doc.
  • Startup settings are a good fit because the cache is created once per Drillbit, and Guava can't change these limits on a cache that's already built. planner.enable_plan_cache stays the runtime on/off switch.

I’ve made the cache size and both expiration policies configurable.

  1. The defaults are 32 MiB, expire_after_access = 10m, and expire_after_write = 0 (disabled). This keeps actively used plans available while allowing idle entries to expire. Since either expiration condition can expire an entry, retaining a 10-minute write expiry would still force hot plans to expire regardless of access. Operators who want periodic re-optimization can enable write-based expiration.
  2. The size limit now counts the combined UTF-8 byte lengths of the cache key, physical-plan JSON, and optional text plan, rather than Java character counts. This bounds cached text content; it does not include context metadata, Java object overhead, or Guava’s internal structures, so it is not an exact heap-memory limit.

@cgivre cgivre left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line-by-line follow-up to my earlier review comment, with suggested changes where there's a concrete fix.

I applied all the suggestions together on top of 53306c1, and exec/java-exec (with its dependencies, including logical) builds cleanly, checkstyle included. Several depend on each other, so it's easiest to add them to a batch and commit them together:

  • the PlanCache constructor, its three imports, and new PlanCache(config) in DrillbitContext
  • writeAfterSuccess / put in PlanCache and the serialization change in DrillSqlWorker
  • keyFingerprint() in PlanCache and the key in DrillSqlWorker

The drill-module.conf defaults are in the comment on the PlanCache fields, because the right spot in that file is outside the diff.

Tests: I couldn't find any plan-cache tests in this PR. PLAN_CACHE_PLUGIN_GUIDE.md describes the checks a plugin should pass (results with caching off vs. a hit with different literals, schema changes, plugin config changes, joins with unsupported plugins, etc.), but none are included for HBase, Iceberg or the engine. Could you add them? They should cover at least:

  • the fallback path
  • HAVING with GROUP BY
  • two sessions with different options sharing a template
  • a stale table version
  • the new boot options

Comment on lines +130 to +131
select.getGroup(), visitNullable(select.getHaving()),
select.getWindowList(), visitNullable(select.getQualify()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] Keep HAVING and QUALIFY verbatim when GROUP BY / the select list are.

When structuralProjection is true, GROUP BY and the select list keep their literals, but HAVING and QUALIFY still get slots. So they no longer match the grouped or windowed expressions:

SELECT x || 'a', COUNT(*) FROM hbase.t GROUP BY x || 'a' HAVING x || 'a' = 'ba'

HAVING becomes x || ? = ?, and Calcite raises "Expression 'x' is not being grouped". Without a fallback, the query fails only when the cache is on. Using the same condition for HAVING and QUALIFY keeps them consistent with what they have to match.

Suggested change
select.getGroup(), visitNullable(select.getHaving()),
select.getWindowList(), visitNullable(select.getQualify()),
select.getGroup(),
structuralProjection ? select.getHaving() : visitNullable(select.getHaving()),
select.getWindowList(),
structuralProjection ? select.getQualify() : visitNullable(select.getQualify()),

Comment thread docs/dev/PLAN_CACHE_DESIGN.md Resolved
@letian-jiang

Copy link
Copy Markdown
Contributor Author

Question: plans to support the other storage plugins?

Right now only HBase and Iceberg opt in (PLAN_CACHE_DESIGN.md:75). What are the plans for expanding this to the rest of the plugins? I'd like to see it cover as many as possible. The most heavily used ones (Parquet and the other dfs formats, and JDBC) are the ones that would benefit most, and they're currently excluded.

I understand why they can't simply be switched on. On a hit, the cache rebinds literals in Drill expressions. But most plugins turn those literals into native state during planning and keep only the result:

Plugin Value-dependent state built at planning time
Parquet Row-group / file pruning based on filter values
All dfs formats Directory (partition) pruning, and a fixed file list
JDBC Pushed-down SQL text with literals inlined
Mongo, Elasticsearch, Splunk, HTTP Native query / request built from the filter
Kafka Offset / timestamp ranges
Reusing those plans without rebuilding that state would silently return wrong results. So each plugin needs to keep the source expression in its scan JSON and redo pushdown/pruning on bind, as PLAN_CACHE_PLUGIN_GUIDE.md describes.

A possible path, roughly in order of effort:

  1. dfs formats without filter pushdown (CSV/TSV, JSON, Avro, etc.): filtering stays in Drill's Filter operator, so the plugin only needs to re-list files and redo directory pruning on bind, plus a table version that catches schema changes.
  2. Parquet: redo row-group/partition pruning for the new values, reusing the existing Parquet metadata cache to keep it cheap.
  3. JDBC and other pushdown plugins: a shared "rebuild pushdown on bind" hook in the plugin API, so each plugin doesn't reinvent it.

It would be worth measuring planning time against bind-time rebuild cost for dfs/Parquet, since re-listing and re-pruning eat into what a hit saves.

Given how large this PR already is, I'm fine with plugin expansion coming in follow-ups. But I'd like to understand the roadmap, and to make sure the plugin API in this PR makes opting in as cheap as possible. Could you add the plan (or JIRAs for each plugin) to the design doc?

  1. I chose HBase because I expect high-concurrency, short-query workloads to benefit most from plan caching: planning can account for a substantial share of their latency. Since Drill is also an analytical engine, I wanted to test a data lake format such as Iceberg to evaluate the benefit for analytical workloads as well.

  2. I agree that the progression from DFS formats without filter pushdown, to Parquet, and then JDBC is reasonable. I plan to add support for these commonly used plugins in follow-up PRs and document the roadmap in the design doc.

@letian-jiang

letian-jiang commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author
  1. No fallback when planning the parameterized SQL fails (DrillSqlWorker.java:354). On a miss, the query is planned once from candidate.sql. The try/catch covers lookup and preparation but not handler.getPlan(planningSql). Any query that only plans with real literals then fails, but only when the cache is enabled. Examples: JOIN ... ON TRUE (becomes ON ?), PERCENTILE_CONT(0.5) WITHIN GROUP, and functions needing a literal operand that aren't on the hard-coded list. Please re-plan from the original literal SqlNode on failure. The comment about validation mutating the tree suggests keeping an unmodified copy (or re-parsing) for the fallback.
  2. HAVING/QUALIFY are parameterized while GROUP BY and the select list are not (PlanCacheParameterizer.java:130). GROUP BY x || 'a' HAVING x || 'a' = 'ba' becomes HAVING x || ? = ?, which no longer matches the grouped expression. Calcite then rejects it with "Expression 'x' is not being grouped", and because of Mavenized query-parse subproject #1 the query fails.

I've updated the parameterizer to preserve HAVING and QUALIFY expressions whenever the SELECT expressions are preserved for grouping, ordering, or window definitions. This keeps matching expressions consistent across clauses. Eligible WHERE literals can still be rebound.
I don't think replanning the original SQL after a planning failure is a reliable general fallback. Exception types alone cannot distinguish failures introduced by dynamic parameters from errors in the original query. Also, parameterization can produce incorrect results without throwing an exception.
For example, before this fix:

SELECT x + 1 AS k,
       ROW_NUMBER() OVER (ORDER BY x + 1) AS rn
FROM (VALUES (1, 1), (1, 2), (2, 3)) AS t(x, v)
WHERE v > 0
QUALIFY x + 1 = 2
    AND ROW_NUMBER() OVER (ORDER BY x + 1) = 1
ORDER BY x + 1;

With plan caching disabled, this returned one row: (k = 2, rn = 1). With caching enabled, it returned zero rows without any exception, both on initial planning and on a confirmed cache hit. An exception-triggered fallback would not catch this regression.

@letian-jiang

Copy link
Copy Markdown
Contributor Author
  1. Hard-coded allowlist of literal-only function operands (PlanCacheParameterizer.java:142). Any function not on the list (format strings, scale arguments, new UDFs) gets a slot and fails planning because of Mavenized query-parse subproject #1. Could this come from operator operand metadata instead?

This is a difficult problem, and I agree that a hard-coded list carries a maintenance risk. Given Drill’s current architecture, though, I don’t see a clearly better practical approach: DrillOptiq itself uses function-name switches for value-dependent rewrites, so the parameterizer needs explicit knowledge of which operands must remain literals. Moving this information into function metadata could improve organization, but would still require manually declaring and maintaining those rules. For now, I think keeping this list aligned with the existing rewrites and adding targeted regression tests is a reasonable approach, while acknowledging that it does not establish safety for every SQL pattern.

@letian-jiang

Copy link
Copy Markdown
Contributor Author

Following up on the remaining points:

  • 3. BIGINT serialization: Ordinary BIGINT literals now retain their existing serialization format. Bound dynamic parameters preserve their type through the parameter wrapper. I also removed the parser special case that converted BIGINT casts back into literals, so explicit casts retain their normal behavior.

  • 4. Context fingerprints: Effective option and storage-plugin configuration fingerprints are now included in the cache key. Different configurations can coexist instead of repeatedly invalidating each other. Table versions remain compatibility checks so incompatible table changes invalidate the cached entry.

  • 5. Metadata I/O: The [benchmark section](DRILL-8555: Physical plan cache for parameterized SQL queries #3086 (comment)) already compares ordinary planning with confirmed cache hits, including planning time and end-to-end latency. For example, HBase point-read planning drops from 50 to 14 ms, and end-to-end latency from 66 to 29 ms. I’ve also applied the two suggested HBase improvements: removed the separate tableExists check and switched to Connection.getTable(name).getDescriptor(), avoiding an Admin handle per lookup. TableNotFoundException returns no cache metadata, while other I/O failures retain the existing fallback behavior.

  • 6. Trial binding: My intended contract is that parameter binding should be reliable for every eligible plan. Deterministic binding failures indicate a gap in the binder or eligibility checks that should be fixed there. A trial bind with the first execution’s values can catch some defects, but does not establish correctness for subsequent values. I have retained JSON read-back validation without adding a trial bind. Scan reconstruction can still fail because of metadata or I/O errors; the existing invalidation and replanning path handles those failures.

  • 7. Writer queue: The plan is now serialized immediately after planning, before parallelization or execution can mutate its operators. Pending publication and the writer queue retain the immutable JSON string instead of the live physical-plan graph. Publication still happens only after successful execution.

  • 9. Plan quality: I agree that this is an expected trade-off. The cached plan must remain general enough for subsequent parameter values, so constant folding based on those values cannot specialize it to one execution. Value-specific selectivity, join ordering and distribution choices may also be less effective, including on the initial miss. This limitation is now documented.

  • 10. Observability: Added DEBUG logging for hits, misses and invalidation reasons. The hits, misses, invalidations, evictions and entries gauges are registered in DrillMetrics under drill.plan_cache., using the existing JMX and log reporters.

  • 11. Cache clearing: Added ALTER SYSTEM CLEAR PLAN CACHE. It clears the receiving Drillbit’s cache and returns its address. With authentication enabled, it follows the existing SYSTEM administrator policy. Clearing also prevents plans prepared before the command, including queued or pending publications, from restoring old entries.

  • 12. Documentation: Both documents have been moved to docs/dev/ and linked from DevDocs.md. They now cover configuration, metrics, the clearing command, plan-quality trade-offs and the plugin expansion roadmap.

@letian-jiang

Copy link
Copy Markdown
Contributor Author

I’ve rerun both benchmarks on the latest commit, [6ee1c592b](letian-jiang@6ee1c59), using the same machine, datasets, queries and sampling method as the original measurements.

Workload Planning off → hit Reduction End-to-end off → hit Reduction
HBase point read 48 → 14 ms 70.8% 63 → 29 ms 54.0%
HBase range scan 40 → 13 ms 67.5% 54 → 26 ms 51.9%
HBase column filter 34 → 10 ms 70.6% 47 → 22 ms 53.2%
Iceberg TPC-H SF0.01 1,950 → 616 ms 68.4% 18,691 → 17,101 ms 8.5%

@letian-jiang
letian-jiang requested a review from cgivre October 15, 2026 11:04
SqlSelect select = (SqlSelect) call;
// Preserve matching expressions across SELECT, GROUP BY, HAVING and
// QUALIFY. Parameterizing only some occurrences breaks validation or rewrites.
boolean structuralProjection = preserveProjection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] QUALIFY with an inline OVER and no ORDER BY is still parameterized.

The SELECT list, HAVING and QUALIFY are preserved only when there is a GROUP BY, ORDER BY or WINDOW clause. An inline OVER(...) is preserved on its own, but the expressions around it are not. Your zero-rows example from the last round was fixed because it ended in ORDER BY. The same query without it still gets x + ? = ? in both the select list and QUALIFY, and the root cause of the zero rows was never identified.

Could structuralProjection also be true when the SELECT contains any OVER call or a QUALIFY clause? Please also add the no-ORDER-BY variant to TestPlanCachePlanning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I now preserve literals in SELECT, HAVING and QUALIFY unconditionally. For example, SELECT x + 1 keeps the 1 in the template key. I expect most of the benefit to come from rebinding WHERE predicates, so this conservative restriction seems reasonable. Added tests for QUALIFY without an outer ORDER BY and inline windows.

case FunctionNames.GE: {
LogicalExpression nameRef = call.args().get(0);
Expression expression = call.args().get(0).accept(this, null);
Expression expression = call.args().get(1).accept(this, null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Behaviour change for all queries] This changes Iceberg pushdown with the cache off too.

Several changes in this file and in IcebergPluginImplementor change pushdown for every Iceberg query:

  • >= previously read args().get(0) and was never pushed; now it is.
  • AND/OR are now pushed.
  • Successive filters are combined with AND.
  • DATE constants become days, and TIME/TIMESTAMP constants become microseconds. Previously they were milliseconds, which could prune the wrong files.

These all look like genuine fixes, but there are no tests for any of them. When a format config sets ignoreResiduals: true, the newly pushed predicates mean rows that should have been filtered can come back.

Could you add translator unit tests and a few Iceberg query tests with the cache off? Cover >=, AND/OR, and date/timestamp filters. Alternatively, move these into their own PR so they can be reviewed separately. Either is fine with me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added translator and cache-off query tests for >=, AND/OR and temporal values. Also fixed NULL handling and retained Drill filtering when ignoreResiduals=true.

new DrillParseContext(PrelUtil.getPlannerSettings(filter.getCluster().getPlanner())),
filter.getInput(),
condition);
if (groupScan.getCondition() != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the translator comment: combining with the existing condition is the right behaviour, but it also affects queries with the cache off. A test with two stacked filters would pin it down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an implementor regression test for two stacked filters, such as id >= 2 AND id < 4. It checks values inside and outside the range to verify that both bounds are retained.


// Plan exactly once, outside cache fallback. Validation can mutate the SQL tree,
// so retrying a failed planning attempt could reuse partially rewritten nodes.
PhysicalPlan planned = handler.getPlan(planningSql);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] No fallback when the parameterized SQL fails to plan.

I understand why you don't want to retry on the same SqlNode, since validation mutates it. The fallback doesn't need to reuse it, though. On failure, set planner.enable_plan_cache=false for this query and call getQueryPlan again from the original SQL string. That parses a fresh tree.

Without this, queries that only plan with real literals fail only when the cache is on, with no workaround short of turning the cache off. Examples are ON TRUE, a function that needs a literal operand but isn't on the hard-coded list, and a new UDF.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two-line comment above getPlan is misleading and should be removed: it suggests that a failed planning attempt would retry a partially rewritten SQL tree, but that does not describe the current fallback path.

I checked the cases you raised.

  1. JOIN ... ON TRUE/FALSE works with the current parameterization; Drill’s Cartesian join setting applies equally with or without the cache.
  2. Functions that require a literal argument also work because the bound parameter retains the literal value through planning.
  3. A newly registered UDF takes the cache-miss path and can use Drill’s existing function-registry synchronization and planning retry. These cases do not require an additional fallback for parameterized SQL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most functions work with the plan cache without special handling, including UDFs and functions that require literal arguments. We only need a special case when planning depends on the value of a literal.
For example, date_trunc uses its unit argument to choose a specific function during planning. The parameterizer must keep that unit in the template key. By contrast, now() is folded into a query-specific constant during planning, so the eligibility check excludes it from caching.
This gives us a narrow rule for reviewing functions: check whether the planner uses their values to shape the plan.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My biggest concern are queries randomly failing and not giving the user a clear reason of what happened and how to fix it. But it sounds like that won't happen here, so that's good!

return false;
}
// Confirm that standard PhysicalPlan JSON can be read back before publishing.
reader.readPhysicalPlan(json);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Robustness] A bind that always fails cycles forever.

The read-back here proves the JSON parses, but not that Entry.bind succeeds. If a plan's bind always fails, each hit fails, invalidates and replans, and the entry is published again. An example is a non-expression text field that happens to contain bound_dynamic_param(.

A trial bind with the original literals before publishing would catch this. A small per-key failure counter that stops republishing would also work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added trial binding before publication, replacing JSON-only validation. Unbindable templates—for example, ordinary text containing the parameter marker—are skipped without affecting the completed query.

| 'false'
;

BoundDynamicParam : 'bound_dynamic_param';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this makes bound_dynamic_param a reserved word for all expression parsing, so an unquoted column or function with that name no longer parses. It's unlikely to matter, and date/as already behave this way. If you want to avoid it, match Identifier with a semantic predicate instead of a lexer token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. A semantic predicate could avoid this restriction. Since a naming conflict seems unlikely, I’d prefer to keep the simpler token-based approach for now and revisit it if a practical use case arises.

@letian-jiang

Copy link
Copy Markdown
Contributor Author

@cgivre Could you please take another look?

I’ve narrowed the scope of this PR by removing the HBase and Iceberg integrations and their plugin-specific tests. The PR now uses single-file CSV/TSV scans to exercise the end-to-end cache path, with predicates remaining in Drill’s Filter operator.

@cgivre cgivre added the performance PRs that Improve Performance label Nov 5, 2026

@cgivre cgivre left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks very much for this contribution, Letian — this is a substantial and well-executed piece of work, and the design doc made it much easier to follow. I went through all 57 files carefully, and I want to say up front that we are very close to being able to merge this. What's below is a short list of things I'd like resolved, not a request for rework.

Should be addressed before merge

1. No fallback when parameterized planning fails — DrillSqlWorker.java:377

On a cache miss, planningSql is swapped to the parameterized tree and planned exactly once. The cache's try/catch (RuntimeException) ends before this line, so a failure here isn't degraded to ordinary planning — it surfaces to the user. That means any construct that plans fine with literals but not with bound dynamic params turns a working query into a failure as soon as planner.enable_plan_cache=true.

A concrete path: DrillOptiq.visitDynamicParam falls through to doUnknown (→ DrillRuntimeException) whenever RexBoundDynamicParam.getLiteral().accept(this) doesn't yield a LiteralExpression — e.g. a parameter Calcite types as DATE/TIME/TIMESTAMP, per item 2 below.

I see commit 3abcce8 removed the fallback deliberately, citing tree mutation during validation, so this needs a different fix rather than a revert — re-planning the original sqlNode with a fresh SqlConverter on failure would sidestep the mutated-tree problem. Given that this is the blast radius of a feature flag flip, I think a fallback path is worth having.

2. Temporal literals make the cache permanently unusable, and do so silently — PlanCache.java:770

bindExpression accepts LiteralExpression, FunctionCall/BooleanOperator, CastExpression, ConvertExpression, IfExpression, AnyValueExpression, SchemaPath, NullExpression and TypedNullConstant. But ValueExpressions.DateExpression, TimeExpression, TimeStampExpression, IntervalYearExpression, IntervalDayExpression and ParameterExpression were not reparented onto LiteralExpression in this PR, so any plan expression containing a temporal literal alongside a parameter hits the final throw.

For example:

SELECT * FROM dfs.tmp.`t.csv` WHERE name = 'bob' AND CAST(d AS DATE) > DATE '2020-01-01'

The trial bind in put() always throws, so the entry is never published — and every execution then pays snapshot resolution plus parameterized planning for a cache that can never hit. Only logger.debug records it, so this is invisible in practice. Either extend bindExpression to pass these through unchanged, or exclude such expressions during eligibility so the overhead isn't paid.

Smaller items

3. Decimal visitors drop the bound-param marker — ExpressionStringBuilder.java:237

Decimal9/18/28/38Expression were changed to extend LiteralExpression so they can carry dynamicParamIndex, but their visitors — unlike visitIntConstant, visitVarDecimalConstant and friends — don't call startBoundLiteral/endBoundLiteral. If a bound parameter is ever materialized as one of these, the marker is dropped, collectTypes finds no slot in the reparsed template, rewrite returns the string unchanged, and the first query's literal value stays baked into the cached plan — wrong results for every later bind, with no error.

This is unreachable today (DrillOptiq.visitLiteral maps DECIMAL to VARDECIMAL or FLOAT8, and bindLiteral never builds these), so it's not a blocker. But it's a silent-wrong-answer failure mode rather than a loud one, and I'd rather it be loud: consider throwing from startBoundLiteral for any literal type whose visitor doesn't emit the wrapper.

4. Mutable dynamicParamIndex on process-wide singletons — ValueExpressions.java:184, :700

BooleanExpression.TRUE/FALSE and QuotedString.EMPTY_STRING are shared singletons that now inherit a mutable dynamicParamIndex, and setDynamicParamIndex mutates in place (called from DrillOptiq.visitDynamicParam and PlanCache.bindLiteral). QuotedString.EMPTY_STRING is handed out by the concat rewrite in DrillOptiq. If any path ever binds one of these instances, every expression in the JVM sharing it is permanently serialized as bound_dynamic_param(...). A withDynamicParamIndex(int) factory returning a copy, with the field final per instance, removes the hazard entirely.

5. bound_dynamic_param token collides with Identifier — ExprLexer.g4:97

BoundDynamicParam : 'bound_dynamic_param'; is declared before the Identifier rule and matches the same 19 characters, so ANTLR's earliest-rule tie-break makes the exact token win. A logical expression referencing a column or UDF literally named bound_dynamic_param — a SchemaPath in a serialized plan, or a LogicalExpressionParser.parse call from a provided-schema path — now fails to parse where it previously worked. A less collidable spelling, or placing the rule after Identifier behind a semantic predicate, avoids it.

6. ALTER SYSTEM CLEAR PLAN CACHE is node-local — ClearPlanCacheHandler.java:40

The statement is admin-gated and spelled with SYSTEM scope, but getPlanCache().clear() clears only the Drillbit that happened to plan it (the response message does say so). On a multi-node cluster an admin has no reliable way to invalidate stale plans, since repeated invocations land on whichever foreman the client connects to. Broadcasting via the control plane would be ideal; at minimum I'd document the per-node semantics in PLAN_CACHE_DESIGN.md.

Thanks again — nice work on this. Happy to take another look as soon as 1 and 2 are sorted.

@letian-jiang

Copy link
Copy Markdown
Contributor Author

A concrete path: DrillOptiq.visitDynamicParam falls through to doUnknown (→ DrillRuntimeException) whenever RexBoundDynamicParam.getLiteral().accept(this) doesn't yield a LiteralExpression — e.g. a parameter Calcite types as DATE/TIME/TIMESTAMP, per item 2 below.

@cgivre Thanks for the detailed review. I’d like to clarify the design choice around fallback and the temporal-parameter example.

1. General fallback

I agree that retrying ordinary planning could protect users from parameterization regressions. However, we cannot reliably distinguish a parameterization failure from an error that the original SQL would also produce. A general fallback would therefore plan some invalid queries twice, potentially doubling their planning cost.

A fresh parse tree and SqlConverter would address the mutation concern, but would not resolve this distinction. It could also mask parameterization bugs: queries would succeed through fallback, making those regressions harder to detect and fix. For these reasons, I prefer to keep parameterized-planning failures visible and fix their causes directly.

2. The DATE/TIME/TIMESTAMP example

The SQL parameterizer deliberately leaves DATE, TIME, TIMESTAMP and INTERVAL literals unchanged. Their values remain part of the cache key; other eligible literals in the same query can still be parameterized.

Consequently, these temporal literals do not become RexBoundDynamicParam nodes and do not reach the visitDynamicParam failure described here. Temporal parameter binding is outside the current supported scope.

3. A parameterization bug we did find

Further investigation did uncover a concrete issue:

WHERE x > CAST('0' AS INTEGER)

After parameterization, the existing CAST inference treated the bound parameter like an unbound parameter and assigned it the target type, INTEGER. Its stored value remained the string "0", so conversion attempted to construct an INTEGER literal from a Java String and failed.

The fix preserves the bound parameter’s source string type and retains the outer CAST. On a cache hit, only the string value is replaced; CAST still performs the conversion. Regression tests verify that the CAST survives, compatible strings reuse the plan, and numeric literals do not select the string-parameter template.

This is the kind of regression I would prefer to expose and fix rather than conceal behind a general retry.

4. Broader testing

Differential SQL fuzzing could help expand coverage: generate queries and compare cache-disabled execution with cache-enabled misses and hits, including executions with different compatible literals. Comparing both results and failures would help identify parameterization regressions while distinguishing them from queries that already fail without caching.

@cgivre

cgivre commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

@letian-jiang Thanks, and you're right about the temporal example. I checked the code again: PlanCacheParameterizer.visit(SqlLiteral) only opens a slot for BigDecimal, Boolean and NlsString, and DrillSqlValidator.inferUnknownTypes returns early for a SqlBoundDynamicParam so the slot keeps its own literal's type and Calcite never coerces it to DATE. My example doesn't reach visitDynamicParam. Withdrawn.

A DECIMAL slot can still get there by another route, though. DrillSqlToRelConverter calls rexBuilder.makeLiteral(literalValue, valueType) with a DECIMAL type, and with planner.enable_decimal_data_type off, DrillOptiq.visitLiteral takes its unsupported-type path. Same shape of problem, visible only when the cache is on. I haven't built a failing case for it yet, so treat that as a question rather than a claim. But if you believe nothing can reach doUnknown from a bound parameter, please make that explicit: throw IllegalStateException with the parameter type in the message, or drop the branch. Falling through quietly to a DrillRuntimeException reads like a path you expect to be taken.

Let me back up and say where my concern actually comes from. A lot of work has gone into Drill over the years to make queries behave the way users expect, and to make the errors clear enough that users know what to do about them when they don't. Plenty of that work was unglamorous: reworking messages, adding context, chasing down cases where Drill failed in a way nobody could act on. I don't want a plan cache to quietly undo it. A query that worked last week and now throws a DrillRuntimeException out of DrillOptiq because a cluster-wide option got flipped is exactly the kind of error a user can't diagnose or fix, and they have no reason to suspect caching at all.

On the fallback, then, I think we're arguing past each other, because distinguishing the two failure causes isn't the goal. Re-plan the original tree whenever parameterized planning throws, without trying to classify the exception. If the original SQL was broken to begin with, the user gets the identical error they'd have gotten with the cache off, a few milliseconds later; the doubled planning cost only ever lands on queries that were already failing. What I'm asking for is narrow: turning on an optimization must not change which queries succeed. Once an operator sets planner.enable_plan_cache at the system level, every query on that Drillbit inherits the risk, and "we'll fix the causes as they surface" is a promise about the whole SQL surface rather than the slice the tests cover.

You don't have to give up that containment to keep the failures visible. Log the fallback at WARN with the parameterized SQL and the exception, and register a parameterization_failures counter beside the hits/misses/invalidations gauges you already added under drill.plan_cache.. Operators get something they can alert on, developers get something they can grep, and users get their query. If you want the loud version during development, a hidden option that rethrows instead of falling back would give the fuzzer and the regression suite a hard failure to assert on.

Your October QUALIFY example argues something different from what you're using it for. Silent wrong results need an eligibility fix, and no exception-triggered retry will ever catch them; I agree. That makes fallback insufficient rather than harmful. The retry contains exceptions, eligibility contains incorrect results, and doing both covers strictly more than doing either.

Which brings me to the CAST bug in your point 3. WHERE x > CAST('0' AS INTEGER) is the strongest case for fallback anyone has made in this thread. It's an ordinary predicate, it fails only with caching enabled, and it got past seven new test classes until someone read the inference code closely. I don't doubt you'll fix each one you find. I doubt we'll find them all before users do.

Yes to differential fuzzing on point 4, and I'd like it to be a gate rather than a follow-up: comparing cache-off against miss and hit, over several compatible literal values per template, landing in CI before the plugin opt-ins start arriving. If that runs for a while across the existing test queries and turns up nothing new, it makes a far better case for exposing failures than the current test suite can.

One question on scope now that the HBase and Iceberg integrations are out. With CSV/TSV single-file scans and predicates staying in the Filter operator, what do the benchmarks look like? The HBase numbers carried most of the argument for the feature, and I'd like to see what the narrowed PR claims on its own.

@letian-jiang

Copy link
Copy Markdown
Contributor Author

@cgivre I’ve added a general fallback: when parameterized planning fails, Drill logs the SQL template and exception at WARN, then retries the original SQL with a fresh parser, converter and handler, bypassing the cache.

planner.enable_plan_cache_fallback defaults to true; setting it to false exposes the parameterized-planning error without this retry.

drill.plan_cache.planning_failures increments only when parameterized planning fails but ordinary planning succeeds. This can produce false positives: transient metadata changes or resource conditions may allow the second attempt to succeed even when parameterization did not cause the first failure. The option and counter semantics are documented.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance PRs that Improve Performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants