Skip to content

Scan-resistant eviction + footprint-based cache admission gate - #8

Merged
anoop-narang merged 13 commits into
mainfrom
feat/execution-admission
Jul 22, 2026
Merged

Scan-resistant eviction + footprint-based cache admission gate#8
anoop-narang merged 13 commits into
mainfrom
feat/execution-admission

Conversation

@anoop-narang

Copy link
Copy Markdown
Collaborator

Summary

Two complementary LiquidCache improvements for large-table workloads, plus observability.

1. Scan-resistant eviction (CLOCK)

LiquidPolicy eviction is now scan-resistant (CLOCK / second-chance): a single large sequential scan no longer floods out a hot working set. The reference bit is set on memory hits only (disk hits don't protect), and a bounded second-chance sweep guarantees termination.

2. Footprint-based admission gate

Instead of caching every scan (and thrashing when a scan can't fit), the optimizer estimates a scan's liquid footprint at plan time and only admits scans that fit; the rest are read as vanilla parquet (bypassing the cache). The estimate is:

  • Filter- and projection-aware: counts only the columns the scan materializes (output projection ∪ predicate columns), over the files that survive predicate pruning.
  • Byte-accurate: sums per-column byte_size from catalog stats (Exact or Inexact); falls back to whole-file size only when a column size is Absent.
  • Overcommit-tolerant: admits up to budget × tolerance (default caller value ~3, capped 5) because LiquidCache compacts in RAM and beats the fallback mount until ~5× over budget.
  • Split-aware: dedupes the byte-range PartitionedFiles DataFusion creates for one physical file (keyed by path+size), so a split file's footprint isn't multiplied by the split count.

Parameters (expansion, safety, tolerance, strict) are set by the caller via LocalModeOptimizer::with_admission_gate / the local builder; all are sanitized (finite, clamped).

3. Robustness + observability

  • Panic-safe: the gate decision is guarded — an estimation panic can't abort a query. A strict flag (default on in the caller) re-raises after logging so bugs fail loud in canary; non-strict logs at ERROR and caches normally.
  • Non-panicking column extraction: uses ProjectionExprs::column_indices (handles compound scan projections like get_field) instead of the deprecated ordered_column_indices, which panicked.
  • Decision logging: one line per admission decision (target: liquid_cache::admission) with the full breakdown — charged/partitioned files, raw + footprint bytes, threshold, and fallback_files (the signal that catalog per-column sizes are missing).

Testing

  • Unit tests for the footprint byte-math, the CLOCK reference-bit behavior, and a struct-column get_field regression that reproduces the old projection panic.
  • fmt + clippy clean.
  • Validated end-to-end against ClickBench (100M) on DuckLake: a single-column scan (avg(UserID), ~270 MB) is admitted and cached; a wide multi-string scan (~7 GB) is bypassed.

Notes

  • All parameters and the whole gate are opt-in via the builder; with no admission gate configured, behavior is unchanged (cache every scan).
  • Codex-reviewed; findings on the footprint estimate, panic guard, and dedupe addressed.

LiquidPolicy was FIFO-per-type with a no-op notify_access, so a large
one-pass scan floods the cache and evicts the reused working set (and, once
full, read-once batches still displace hot ones). Add a CLOCK/second-chance
reference bit: notify_access sets it on a cache hit, and find_memory_victim
passes over a referenced entry once (clearing the bit, moving it to the back)
before evicting it. A batch read exactly once starts and stays cold, so it is
evicted before any reused batch — the cache converges on the working set
regardless of how large the surrounding column/scan is. FIFO behaviour is
unchanged when nothing is reused.
A disk-served hit called notify_access before hydration, and the following
Disk->Memory notify_insert preserved the bit, so a batch read once from disk
arrived in memory looking reused and could evict a genuinely reused batch
during a large one-pass scan (the exact overflow case scan-resistance targets).
Gate the reference bit on memory batch types only. (codex review)
Replace LocalModeOptimizer's raw-file-size cap with a byte-accurate,
filter-aware footprint gate. For a parquet scan it estimates the in-memory
liquid footprint from DuckLake per-file stats: required columns (output
projection UNION predicate columns), summed over the files that survive the
predicate (PruningPredicate + PrunableStatistics), using only Exact per-column
byte sizes and falling back to the whole-file size otherwise. A scan whose
estimate (raw x expansion x safety) exceeds the cache memory budget is left as
a plain parquet read (bypass -> mount) instead of thrashing the cache.

Conservative by construction: pruning only drops proven-non-matching files;
non-exact/missing byte sizes over-count; multipliers inflate the estimate, never
the budget -- so the gate never wrongly admits an oversized scan. Complements the
CLOCK eviction (admission vs. eviction). Pure byte-math unit-tested.

Builder: with_max_scan_bytes -> with_admission_gate(expansion, safety).
…iew)

- Prune with the full table schema (file + partition columns) so predicates
  on partition columns resolve; PartitionedFile stats are table-schema too.
- Exclude partition columns from byte accounting -- they are literals, never
  materialized in LiquidCache; only file columns contribute to the footprint.
- Guard per-file stats width against the schema to avoid a mismatch; a
  mismatched/absent file is treated as unknown (kept, not pruned).
Deny only when estimated liquid footprint exceeds budget x tolerance,
not budget x1. LiquidCache compacts in RAM and beats the fallback mount
until ~5x over budget, so gating at 1x over-bypasses a whole band of
scans that would cache and win. Compare as a finite f64 pressure ratio
(footprint/budget > tolerance) with a saturating raw-byte sum and an
explicit budget==0 case. tolerance is the one relaxing knob: clamped to
[1.0, 5.0], defaulting to 3.0, non-finite rejected.
file_column_projection_indices() delegates to the deprecated
ProjectionExprs::ordered_column_indices(), which .expect()-panics on any
non-Column projection expression. DataFusion pushes non-column exprs
(get_field on struct columns, via enable_leaf_expression_pushdown) into
the scan projection, so the gate panicked and killed the query. Use
src.projection().column_indices() (collect_columns-based, handles
arbitrary exprs) instead, matching the reader's own opener. Add a
struct-column get_field regression test that runs without a cache mount.
The gate counted only Precision::Exact per-column byte_size and fell
back to the whole-file size otherwise. DuckLake (the target backend)
always labels byte_size Inexact — it is the real recorded compressed
column size, marked Inexact because catalog stats can go stale after
deletes/compaction — so the fallback fired on every scan, inflating the
estimate ~50-100x (charging all columns for a single-column read) and
leaving the cache near-empty. Count Inexact sizes too; only Absent (or
missing file stats) still falls back to whole-file. expansion/safety
remain the margin against stale-low drift. Sum with saturating_add.
The gate runs in the physical optimizer on every query, so a panic in
footprint estimation (e.g. a DataFusion API that panics on an unusual
plan shape) aborts the whole query. Since the gate is a pure performance
optimization — caching a scan or not yields identical results — guard
the decision with catch_unwind: on panic, log and cache the scan
normally instead of taking down the query.
Add a strict flag to the admission gate. Either way the panic is caught
and logged at ERROR with its message (never silently swallowed):
- strict: log, advise disabling the flag to keep queries running, then
  re-raise so the query aborts and the bug surfaces immediately.
- non-strict: log, advise enabling the flag to fail loud, then cache the
  scan normally so the query survives.
The caller (runtimedb) defaults strict on.
The gate was a black box — no way to see why a scan was admitted or
bypassed without a full benchmark run. Log one INFO line per decision
(target liquid_cache::admission): file, projected cols, surviving/total
files, raw and footprint bytes, budget, threshold, and the multipliers.

Crucially, surface fallback_files: when a file lacks per-column byte
sizes the estimate charges the whole file, so a non-zero count is the
direct signal that the catalog has no column_size_bytes for the table
(the DuckLake write-side stat). estimate_required_bytes now returns a
FootprintEstimate breakdown and file_required_bytes reports whether it
fell back.
DataFusion splits one file into several byte-range PartitionedFiles for
parallelism, each cloning the whole file's statistics and object size.
The estimate summed every range, multiplying a single file's footprint
by the split count (e.g. 7x14.8GB). Charge each distinct file path once;
pruning is file-granular, so a surviving file means its whole required
columns regardless of how it was split for scanning.
Codex review follow-ups: key the file dedupe by (location, size) so two
distinct objects sharing a path can't be collapsed, and rename the
decision-log counters to charged_files / partitioned_files so a
split-into-N file reads unambiguously (e.g. charged_files=1
partitioned_files=7).
Comment thread src/datafusion/src/optimizers/mod.rs Outdated
Comment on lines +423 to +433
"liquid-cache admission gate panicked during footprint estimation: \
{msg}. Aborting query (strict mode). Set \
LIQUID_CACHE_ADMISSION_STRICT=off to fall back to caching and keep \
queries running while this is fixed."
);
std::panic::resume_unwind(payload);
}
log::error!(
"liquid-cache admission gate panicked during footprint estimation: {msg}; \
caching scan normally. Set LIQUID_CACHE_ADMISSION_STRICT=on to fail loud \
instead."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: (not blocking) These messages advise setting LIQUID_CACHE_ADMISSION_STRICT=off/=on, but that env var isn't read anywhere in this crate — strict is only set programmatically via with_admission_gate(..). For a user driving liquid-cache directly through the builder (not via runtimedb), this advice points at a knob that doesn't exist. Consider naming the actual builder parameter (with_admission_gate's strict) instead, or wiring the env var if it's meant to be honored here.

claude[bot]
claude Bot previously approved these changes Jul 22, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CLOCK eviction and the footprint admission gate both look correct and are well-covered by tests. One super nit inline; no blocking issues.

- squeeze_strings snapshot: CLOCK second-chance eviction reorders the
  squeeze victims ([851969,851968,917504] vs prior) — same victims, same
  outcomes, deterministic queue order. Accept the reordered trace.
- admission log/doc: advise the gate's 'strict' builder flag rather than
  a runtimedb env var the crate doesn't read (review nit).
- dev-tools cache_state_view: iterate map keys() (clippy for_kv_map, newly
  flagged by a clippy toolchain bump; pre-existing code unrelated to this
  change but blocks -D warnings CI).
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: 736df61d (Liquid) vs Baseline: 736df61d (DataFusionDefault)

Query Cold Time Δ Warm Time Δ CPU Time Δ
Q1 3.0ms (3.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q2 9.0ms (5.0ms) +80.0% 3.2ms (4.8ms) -31.6% 6.0ms (6.2ms) -4.0%
Q3 15.0ms (13.0ms) +15.4% 5.0ms (11.5ms) -56.5% 2.0ms (23.0ms) -91.3%
Q4 15.0ms (11.0ms) +36.4% 3.8ms (11.2ms) -66.7% 2.0ms (23.2ms) -91.4%
Q5 57.0ms (49.0ms) +16.3% 45.0ms (51.8ms) -13.0% 3.0ms (25.2ms) -88.1%
Q6 155.0ms (110.0ms) +40.9% 72.5ms (110.2ms) -34.2% 21.8ms (78.8ms) -72.4%
Q7 1.0ms (1.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q8 8.0ms (5.0ms) +60.0% 4.0ms (5.8ms) -30.4% 7.0ms (8.8ms) -20.0%
Q9 87.0ms (86.0ms) +1.2% 69.8ms (87.2ms) -20.1% 4.0ms (49.0ms) -91.8%
Q10 98.0ms (96.0ms) +2.1% 73.5ms (90.2ms) -18.6% 5.2ms (62.8ms) -91.6%
Q11 36.0ms (26.0ms) +38.5% 17.5ms (26.5ms) -34.0% 30.0ms (37.8ms) -20.5%
Q12 41.0ms (31.0ms) +32.3% 19.5ms (29.5ms) -33.9% 32.0ms (44.5ms) -28.1%
Q13 167.0ms (106.0ms) +57.5% 75.8ms (105.5ms) -28.2% 54.5ms (83.2ms) -34.5%
Q14 288.0ms (135.0ms) +113.3% 126.0ms (144.0ms) -12.5% 90.5ms (108.0ms) -16.2%
Q15 174.0ms (102.0ms) +70.6% 73.5ms (98.8ms) -25.6% 63.5ms (97.0ms) -34.5%
Q16 65.0ms (70.0ms) -7.1% 51.8ms (62.0ms) -16.5% 3.2ms (27.0ms) -88.0%
Q17 354.0ms (165.0ms) +114.5% 162.0ms (169.2ms) -4.3% 82.2ms (105.8ms) -22.2%
Q18 345.0ms (167.0ms) +106.6% 182.0ms (158.5ms) +14.8% 90.0ms (105.5ms) -14.7%
Q19 679.0ms (316.0ms) +114.9% 396.0ms (301.8ms) +31.2% 135.2ms (156.0ms) -13.3%
Q20 19.0ms (12.0ms) +58.3% 4.5ms (12.5ms) -64.0% 8.0ms (25.0ms) -68.0%
Q21 1.03s (187.0ms) +451.3% 269.0ms (189.0ms) +42.3% 619.5ms (289.8ms) +113.8%
Q22 1.36s (213.0ms) +540.4% 330.8ms (220.5ms) +50.0% 178.2ms (369.0ms) -51.7%
Q23 2.31s (597.0ms) +286.4% 1.01s (628.0ms) +60.2% 526.2ms (767.2ms) -31.4%
Q24 14.94s (966.0ms) +1446.8% 767.8ms (973.5ms) -21.1% 660.0ms (2.69s) -75.4%
Q25 222.0ms (80.0ms) +177.5% 14.5ms (60.8ms) -76.1% 38.2ms (119.8ms) -68.1%
Q26 102.0ms (48.0ms) +112.5% 19.2ms (53.5ms) -64.0% 49.8ms (84.8ms) -41.3%
Q27 188.0ms (60.0ms) +213.3% 28.0ms (62.5ms) -55.2% 79.2ms (124.2ms) -36.2%
Q28 935.0ms (205.0ms) +356.1% 286.2ms (206.8ms) +38.5% 357.0ms (295.5ms) +20.8%
Q29 1.85s (1.09s) +70.4% 1.12s (1.08s) +3.9% 399.5ms (369.0ms) +8.3%
Q30 33.0ms (29.0ms) +13.8% 25.0ms (35.2ms) -29.1% 7.0ms (22.0ms) -68.2%
Q31 259.0ms (105.0ms) +146.7% 55.0ms (95.0ms) -42.1% 51.5ms (146.0ms) -64.7%
Q32 487.0ms (96.0ms) +407.3% 79.0ms (96.5ms) -18.1% 56.5ms (146.8ms) -61.5%
Q33 211.0ms (183.0ms) +15.3% 160.5ms (176.0ms) -8.8% 8.8ms (72.8ms) -88.0%
Q34 1.04s (368.0ms) +181.5% 416.0ms (339.0ms) +22.7% 366.5ms (279.0ms) +31.4%
Q35 1.05s (363.0ms) +190.4% 418.8ms (360.0ms) +16.3% 359.0ms (283.8ms) +26.5%
Q36 68.0ms (61.0ms) +11.5% 53.5ms (57.5ms) -7.0% 4.0ms (25.2ms) -84.2%
Q37 337.0ms (98.0ms) +243.9% 82.5ms (102.5ms) -19.5% 48.2ms (81.8ms) -41.0%
Q38 75.0ms (45.0ms) +66.7% 30.5ms (45.0ms) -32.2% 19.2ms (25.2ms) -23.8%
Q39 297.0ms (51.0ms) +482.4% 12.2ms (50.2ms) -75.6% 11.0ms (75.5ms) -85.4%
Q40 742.0ms (179.0ms) +314.5% 209.5ms (176.2ms) +18.9% 87.8ms (137.0ms) -35.9%
Q41 22.0ms (19.0ms) +15.8% 10.5ms (19.8ms) -46.8% 8.0ms (18.0ms) -55.6%
Q42 20.0ms (20.0ms) +0.0% 9.8ms (19.8ms) -50.6% 7.2ms (17.8ms) -59.2%
Q43 21.0ms (15.0ms) +40.0% 11.5ms (16.2ms) -29.2% 7.5ms (11.5ms) -34.8%

⚠️ LiquidCache is slower on 10 queries (warm)

  • Q23: warm +60.2% (1.01s vs 628.0ms)
  • Q22: warm +50.0% (330.8ms vs 220.5ms)
  • Q21: warm +42.3% (269.0ms vs 189.0ms)
  • Q28: warm +38.5% (286.2ms vs 206.8ms)
  • Q19: warm +31.2% (396.0ms vs 301.8ms)
  • Q34: warm +22.7% (416.0ms vs 339.0ms)
  • Q40: warm +18.9% (209.5ms vs 176.2ms)
  • Q35: warm +16.3% (418.8ms vs 360.0ms)
  • Q18: warm +14.8% (182.0ms vs 158.5ms)
  • Q29: warm +3.9% (1.12s vs 1.08s)

Compared Liquid vs DataFusionDefault on the same runner
Cold Time: first iteration; Warm Time: average of remaining iterations.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the CLOCK eviction and footprint-based admission gate. Logic is correct and well-tested:

  • CLOCK: reference bit is set only on cache hits (notify_access), while the initial miss/insert starts cold, so read-once scan batches are evicted before reused ones. Disk-hit exclusion is correct. find_memory_victim terminates (each entry gets at most one second chance) and second chances never reduce the victim count.
  • Admission gate: inputs sanitized/clamped, comparison done in f64 to avoid overflow, budget == 0 handled, projection∪predicate column accounting is index-consistent for file columns, split dedupe by (path, size) is sound, and the panic guard operates over a lock-free pure read. The column_indices() fix is covered by the get_field regression test.

The prior comment about the strict flag messaging is addressed — the logs now name the strict builder parameter rather than a nonexistent env var. No blocking issues.

@anoop-narang
anoop-narang merged commit d04d2b3 into main Jul 22, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant