Skip to content

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

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

letian-jiang wants to merge 17 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 exec/java-exec/src/main/java/org/apache/drill/exec/server/DrillbitContext.java Outdated
Comment thread docs/dev/PLAN_CACHE_DESIGN.md
@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.

}
TableMetadata metadata = ((HasTableOperations) table).operations().current();
return metadata == null || metadata.uuid() == null ? null
: new PlanCacheTable(identifier, metadata.uuid() + ":" + metadata.currentSchemaId());

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] A change to the provided schema doesn't invalidate the entry.

The version is uuid:currentSchemaId. However, the cached IcebergGroupScan also carries Drill's provided schema from .drill.schema when store.table.use_schema_file=true. After CREATE OR REPLACE SCHEMA ... FOR TABLE, hits keep using the old types and casts. The options fingerprint only covers the flag, not the file contents.

Could you fold a hash of the provided schema into the version (or the schema file's modification time and length), or return null when a schema provider is present?

Separately, tables without a UUID silently never cache. That's safe, but worth a line in the plugin guide.

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.

Provided schema contents now contribute to table compatibility. For example, changing id VARCHAR to id BIGINT invalidates the old entry. Added tests with and without provided schemas, including native schema changes. Tables without an Iceberg UUID still bypass caching; this is now explicit in the plugin guide.

String identifier = ((HBaseScanSpec) selection).getTableName();
try (Table table = getConnection().getTable(TableName.valueOf(identifier))) {
String version = Hashing.sha256().hashBytes(
TableDescriptorBuilder.toByteArray(table.getDescriptor())).toString();

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.

[Performance] Thanks for removing tableExists here. The descriptor is still fetched about three times per query:

  • once by the existing AbstractHBaseDrillTable lookup, which also opens an Admin it never closes;
  • once here;
  • once in HBaseGroupScan.init() on a hit.

Could this hash the descriptor DrillHBaseTable already holds, or memoize it briefly? Iceberg has the same pattern: HadoopTables.load runs here and again in IcebergGroupScan.initTableScan.

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.

Agreed. I’ll leave further metadata reuse and the existing Admin lifetime issue for a follow-up optimization.

}
HBaseFilterBuilder builder = new HBaseFilterBuilder(this, pushedFilter);
HBaseScanSpec rebuilt = builder.parseTree();
if (rebuilt == null || (pushedFilterFullyConverted && !builder.isAllExpressionsConverted())) {

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.

Minor: this rebuild runs whenever an HBase scan is read from JSON, not only on a cache bind, and a failure is now a hard IllegalArgumentException. Plans submitted as physical-plan JSON, or replayed from a profile, take this path too.

Could non-cache reads fall back to the serialized hbaseScanSpec? Also, TestHBasePlanCache uses a single region and fully converted predicates only. A test for a region split between warm-up and hit would be valuable, as would a partially converted filter and a value that flips full conversion to partial.

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.

Cache hits now require scan reconstruction; ordinary physical-plan JSON uses its saved scan specification. Added tests for JSON replay, region splits, partial filtering and parameter changes that require replanning.

return dynamicParamIndex >= 0;
}

public void setDynamicParamIndex(int dynamicParamIndex) {

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.

Minor hardening: this public mutator is inherited by shared singletons (BooleanExpression.TRUE/FALSE, QuotedString.EMPTY_STRING). It's unreachable today because DrillOptiq allocates fresh literals. But if anything ever returns a singleton and sets a slot on it, every query in the JVM would serialize that constant as a slot.

A withDynamicParamIndex(i) that returns a copy would make this impossible. Relatedly, the Decimal9–38 classes now extend LiteralExpression, but their serializers would silently drop a slot. Throwing for slotted types without a wrapper would be safer.

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 pointing this out. The current binding paths allocate fresh literals and use VARDECIMAL for bound decimals. Given that these cases seem unlikely in the current implementation, I’d prefer to keep the design simple for now and revisit the additional hardening if those paths expand.

| '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.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants