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 = 42SELECT * FROM users WHERE id = 1337Fingerprinting 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
- GRAPH_ENGINE_TUTORIAL — sister RAG-native module shipped in v3.6.
- HYBRID_SEARCH_TUTORIAL — pair compiled plans with the BM25 + vector pipeline.
- PARAMETERIZED_QUERIES_QUICK_START — the parameter-binding cheat sheet.
- GETTING_STARTED_TUTORIAL — install Lite, run your first query.