Skip to content

Query Plan Caching Tutorial

Query Plan Caching Tutorial

Available since: v3.6.0 (2026-04-15) Build: default — no feature flag required Module: heliosdb_lite::sql::compiled (src/sql/compiled.rs)


UVP

The same canned query runs ten thousand times an hour and gets re-parsed and re-planned every single time. The compiled module fixes that with three surgical primitives: PlanFingerprint (a stable 64-bit hash of normalised SQL — comments stripped, whitespace collapsed, literals parameterised, keywords lowercased), ParamSlot (typed late-binding slots for Int, Float, Text, Any), and PlanCache (a bounded LRU keyed by fingerprint, parking_lot::Mutex, with per-entry hit counters). Cache is testable standalone today; executor integration ships in a follow-up — but the fingerprint already lets you fast-path your own dispatch logic.


Prerequisites

  • HeliosDB Lite v3.6+
  • Rust 1.75+
  • 10 minutes

1. Why Fingerprints Matter

Two queries that differ only in literal payload should hit the same plan:

SELECT * FROM users WHERE id = 42
SELECT * FROM users WHERE id = 1337

Fingerprinting normalises both to a canonical shape (literals replaced by ?) and hashes the result:

use heliosdb_lite::sql::compiled::PlanFingerprint;
let a = PlanFingerprint::compute("SELECT * FROM users WHERE id = 42");
let b = PlanFingerprint::compute("SELECT * FROM users WHERE id = 1337");
assert_eq!(a, b);

Comments, casing, and whitespace are also normalised:

let a = PlanFingerprint::compute("SELECT * FROM users WHERE id = 1");
let b = PlanFingerprint::compute("select * from USERS where id=1");
let c = PlanFingerprint::compute("SELECT * FROM users -- pick everything\nWHERE id = 1");
let d = PlanFingerprint::compute("SELECT /* fancy */ * FROM users WHERE id = 1");
assert_eq!(a, b);
assert_eq!(a, c);
assert_eq!(a, d);

Different queries hash apart:

assert_ne!(
PlanFingerprint::compute("SELECT * FROM users"),
PlanFingerprint::compute("SELECT * FROM orders"),
);

The 64-bit hash is exposed via as_u64() if you want to log it or use it as a key in your own structures.


2. The PlanCache

use std::sync::Arc;
use heliosdb_lite::sql::compiled::{
CompiledPlan, ParamSlot, PlanCache, DEFAULT_CACHE_CAPACITY,
};
let cache = PlanCache::with_capacity(512); // or: PlanCache::new() — 512 default
let plan = Arc::new(CompiledPlan::new(
"SELECT * FROM users WHERE id = :1",
vec![ParamSlot::Int],
Arc::new(()) as Arc<dyn std::any::Any + Send + Sync>, // your opaque plan body
));
// Insert.
cache.insert(plan.clone());
// Look up by raw SQL — fingerprint computed automatically.
let hit = cache.get_for_sql("SELECT * FROM users WHERE id = 42")
.expect("cache hit");
assert_eq!(hit.fingerprint, plan.fingerprint);

Both get and get_for_sql update the LRU recency on hit and bump the per-entry hit counter. The cache is Clone (interior Arc<Mutex<...>>) — clone it across threads freely.


3. ParamSlot — Typed Late Binding

pub enum ParamSlot {
Int, // expects i64
Float, // expects f64
Text, // expects String
Any, // accepts any value (best-effort late binding)
}

When you build a CompiledPlan, the slot order matches the :1, :2, … placeholders in the original SQL:

let plan = CompiledPlan::new(
"SELECT * FROM t WHERE id = :1 AND name = :2",
vec![ParamSlot::Int, ParamSlot::Text],
Arc::new(()) as Arc<dyn std::any::Any + Send + Sync>,
);
assert_eq!(plan.param_count(), 2);

ParamSlot::Any is the escape hatch for cases where you can’t statically pin the type — leave the runtime check to the executor.


4. The CompiledPlan Shape

pub struct CompiledPlan {
pub sql: String, // original SQL
pub fingerprint: PlanFingerprint, // cache key
pub params: Vec<ParamSlot>, // ordered slots
pub plan: Arc<dyn std::any::Any + Send + Sync>, // opaque body
pub compiled_at_ms: u128, // millis since epoch
}

The plan field is Arc<dyn Any> so the cache itself doesn’t need to know about the executor’s plan node type — your downstream code downcasts when it pulls a hit.

compiled_at_ms makes staleness reporting cheap: subtract from now() to find old entries.


5. Cache Eviction and Stats

LRU. When you cross DEFAULT_CACHE_CAPACITY (or whatever you passed to with_capacity), the least-recently-used entry is evicted:

let cache = PlanCache::with_capacity(2);
for i in 0..5 {
let sql = format!("SELECT {} FROM t", i);
let plan = Arc::new(CompiledPlan::new(
sql,
vec![],
Arc::new(()) as Arc<dyn std::any::Any + Send + Sync>,
));
cache.insert(plan);
}
assert!(cache.len() <= 2);

Inspect per-entry stats with snapshot_stats:

let stats = cache.snapshot_stats();
for (fp, s) in stats {
println!("{:016x}: {} hits, {}us saved", fp.as_u64(), s.hits, s.time_saved_us);
}

Drop everything (e.g. after DDL invalidates plans) with cache.clear().


6. Wrapping Your Own Dispatch Path

Until the executor integrates the cache automatically, the high-level pattern is:

use std::sync::Arc;
use heliosdb_lite::sql::compiled::{
CompiledPlan, ParamSlot, PlanCache,
};
fn execute(cache: &PlanCache, sql: &str) -> ExecResult {
if let Some(plan) = cache.get_for_sql(sql) {
// Fast path: bind parameters, execute the cached plan body.
return run_with_bindings(&plan, extract_params(sql));
}
// Slow path: parse + plan + insert.
let (param_slots, plan_body) = parse_and_plan(sql)?;
let plan = Arc::new(CompiledPlan::new(sql, param_slots, plan_body));
cache.insert(plan.clone());
run_with_bindings(&plan, extract_params(sql))
}

Anywhere you have a tight loop firing the same template, the cache pays for itself within a handful of hits.


7. Edge Cases the Normaliser Handles

The normaliser is defensive — these all fingerprint identically to the literal-free form:

use heliosdb_lite::sql::compiled::PlanFingerprint;
// Quoted strings, including escaped apostrophes.
let a = PlanFingerprint::compute("SELECT * FROM t WHERE s = 'It''s fine'");
let b = PlanFingerprint::compute("SELECT * FROM t WHERE s = 'whatever'");
assert_eq!(a, b);
// Numeric literals (decimals included).
let a = PlanFingerprint::compute("SELECT * FROM t WHERE x = 3.14");
let b = PlanFingerprint::compute("SELECT * FROM t WHERE x = 0.001");
assert_eq!(a, b);
// Block comments.
let a = PlanFingerprint::compute("SELECT /* explain */ * FROM t");
let b = PlanFingerprint::compute("SELECT * FROM t");
assert_eq!(a, b);

8. Status

The module is fully unit-tested standalone. Wiring the cache into src/sql/executor is intentionally NOT done in v3.6.0 — that change touches hot SQL-execution paths and is reserved for a follow-up PR with dedicated benchmarks. Today, use the cache via the dispatcher pattern in section 6 to fast-path your own application-level query loops.


Where Next