Skip to content

Sync upstream (#524–#527): adopt upstream's file-id fix - #60

Merged
anoop-narang merged 5 commits into
mainfrom
sync/upstream-2026-10-16
Oct 16, 2026
Merged

anoop-narang merged 5 commits into
mainfrom
sync/upstream-2026-10-16

Conversation

@anoop-narang

@anoop-narang anoop-narang commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Syncs upstream datafusion-contrib#524, datafusion-contrib#525, datafusion-contrib#526 and datafusion-contrib#527. Merge with a merge commit, not a squash, so upstream's history enters main.

Main change: upstream's file-id fix replaces ours

Upstream datafusion-contrib#526 fixes file-id aliasing in cache keys (the 65,537th file being served the first file's data). It does this by storing the full file id with each entry, in the index and as a disk trailer. We already had our own, more complex fix for the same bug, so the cache code now takes upstream's version and ours is removed:

  • identity-scoped disk keys
  • the reclamation layer built on them (DiskResidue, settle, reclaim_orphaned_disk)
  • WriteIdentity
  • file-id leasing (file_id.rs)

Kept on top of upstream

  • Immediate payload release. When the index removes or replaces an entry, the array is freed right away instead of waiting for crossbeam-epoch's delayed cleanup, which kept large arrays alive past the memory budget. This is ported onto upstream's index slot. Test: removed_and_replaced_entries_are_released_immediately.
  • leased_file_ids, file_ids_over_key_width, identity_mismatches on LiquidCacheParquet, which a downstream consumer exports as metrics. Meaning changes:
    • Without leasing, leased_file_ids counts every file ever registered, not only the files being read.
    • identity_mismatches counts lookups only. The fork's version also counted a write that took over a key held by another file, so the exported value will be lower than before for the same workload.

Dropped

  • The disk-leak fixes that free a disk copy displaced by hydration or overwritten in place. Upstream has the same leak, and it will be fixed there and then synced. Until then, fork main has the leak too. The four datafusion-local snapshots go back to upstream's disk_evictions: 0 for this reason.

Other

Verified

  • Workspace tests, doctests, shuttle tests, fmt and clippy all pass.
  • Test inventory: nothing in upstream's main is missing. The tests that disappear from fork main are all tests of the removed machinery.

The liquid parquet reader ignores
`datafusion.execution.parquet.bloom_filter_on_read`. Whenever a scan has
a pruning predicate, it loads and applies every remaining row group's
bloom filters, even with the option set to `false`.

```rust
let config = SessionConfig::new()
    .set_bool("datafusion.execution.parquet.bloom_filter_on_read", false);
let ctx = SessionContext::new_with_config(config);
// register a parquet table `t` with bloom filters, then:
// SELECT * FROM t WHERE a = 2
```

With `ParquetSource`, this scan reads no bloom filters. Once the scan
goes through `LiquidParquetSource`, it reads them anyway, and
`row_groups_pruned_bloom_filter` reports row groups pruned by a filter
the session turned off.

## Why it matters

Each bloom filter is a separate ranged read per row group and predicate
column, and the reads are not cached across queries. Callers turn the
option off when bloom checks cannot pay for themselves — for example,
many small files where file-level pruning has already selected exactly
the files that match. Today they have no way to avoid those reads.

The option was honoured in the original opener and dropped in d7f0741
("remove some flags"). If leaving it out was deliberate, I'm happy to
hear the reasoning; otherwise this restores it.

## The fix

`LiquidParquetSource` already carries the session's
`TableParquetOptions`. Pass `global.bloom_filter_on_read` into the
morselizer and skip the bloom-filter load when it is off. As in
DataFusion's own opener, the remaining row groups are then counted as
matched by bloom filters, so the pruning metrics stay consistent between
the two sources.

## Tests

The existing `prunes_row_group_with_bloom_filter` test becomes a helper
parameterised on the option, and a new test covers the off case:

- `prunes_row_group_with_bloom_filter` (on): 1 row, 1 row group pruned,
1 matched.
- `skips_bloom_filter_when_bloom_filter_on_read_is_off` (off): 1 row, 0
pruned, 2 matched. The predicate alone still produces the right answer.

With the test applied to `main` and the fix left out:

```
$ cargo test -p liquid-cache-datafusion --lib bloom

test optimizers::tests::prunes_row_group_with_bloom_filter ... ok
test optimizers::tests::skips_bloom_filter_when_bloom_filter_on_read_is_off ... FAILED
assertion `left == right` failed
  left: 1
 right: 0
```

With the fix, `cargo test -p liquid-cache-datafusion` passes (63 tests).
…b#525)

Setting `datafusion.execution.batch_size` to anything other than the
cache's batch size makes liquid-cache scans fail or return the wrong
rows.

```sql
-- cache batch size is the default 8192
SET datafusion.execution.batch_size = 2145;
SELECT id FROM t;            -- errors
SELECT id FROM t LIMIT 4290; -- succeeds, rows from the wrong offsets
```

`LiquidCacheLocalBuilder::build` pins the session batch size to the
cache's, so the default configuration does not hit this. A host that
changes it afterwards — per query, or by sizing batches to row width —
does.

## Why

`LiquidCacheReader` walks the row selection in windows of the session
batch size, and numbers each window with `current_batch_id`. But that id
is a cache chunk index: the cache read and the parquet fallback both
turn it back into rows using the *cache* batch size. When the two sizes
differ, batch id N names one range of rows to the reader and a different
range to everything that resolves it.

- Without a `LIMIT`, the scan runs off the end of the row group:
`parquet fallback ended before batch N`.
- With a `LIMIT` that stops it first, it returns rows from the wrong
offsets and succeeds.
- On a fully cached row group, a selection mask sized by the session
batch size is applied to a chunk sized by the cache's.
`arrow::compute::filter` only rejects a mask *longer* than its target,
so a shorter one silently selects the wrong rows.

The only guard was a `debug_assert_eq!` in `LiquidCacheReader::new`, so
debug builds panic and release builds carry on.

## The fix

Window the selection by the cached row group's batch size, and drop the
session batch size from `LiquidCacheReaderConfig` and
`LiquidRowGroupPlanner`, which no longer need it. Callers still get
session-sized batches: `DataSourceExec` re-splits every source stream to
`datafusion.execution.batch_size`, and
`session_batch_size_still_bounds_the_batches_the_caller_receives` checks
that.

## Tests

`src/datafusion-local/src/tests/batch_size_alignment.rs` covers a
narrowed and a widened session batch size, `LIMIT`, the fully cached
path, multiple row groups and multiple partitions, all against a cache
at the default 8192.

On `main` with the fix left out, in release (the debug build stops at
the assert):

```
$ cargo test --release -p liquid-cache-datafusion-local --lib batch_size_alignment

test result: FAILED. 0 passed; 7 failed
  ... "parquet fallback read failed: Parquet error: parquet fallback ended before batch 1"
  ... narrowed_session_batch_size_with_limit_returns_aligned_rows: assertion `left == right` failed
```

With the fix, all 7 pass in debug and release, and `cargo test -p
liquid-cache-datafusion -p liquid-cache-datafusion-local` passes.

One case is not covered here: page-index pruning that starts the
selection partway into a row group. A scan with page-index pruning plus
a pushed-down predicate panics in `boolean_buffer_and_then` on `main`
even with matching batch sizes, so it cannot be tested until that is
fixed; that is a separate issue.
…afusion-contrib#526)

A process that reads more than 65,536 distinct parquet files gets
another file's cached data back.

A cache key packs the file id into 16 bits (`ParquetArrayID`), but
`LiquidCacheParquet::register_or_get_file` hands out file ids from a
counter that only climbs. The 65,537th distinct `(object store, path)`
gets id 65536, which narrows to 0, so every key it computes is a key of
the first file. Reads then return the first file's arrays:

- silently wrong rows when the column types match;
- a panic in `CachedColumn::array_to_record_batch`
(`RecordBatch::try_new(..).unwrap()`) when they don't;
- and `contains` reports the key as cached, so the newer file never
caches its own data.

The narrowing is guarded only by `debug_assert!`, so release builds hit
this with no signal. A long-running server reading a stream of new files
reaches it in normal use.

## Repro

`files_whose_keys_collide_do_not_share_entries` (in
`src/datafusion/src/cache/mod.rs`) caches a batch for one file,
registers 65,535 more, then reads the same batch through the 65,537th.
On `main`, with the test alone:

- debug: fails at `debug_assert!(file_id <= u16::MAX as u64)`
(`id.rs:150`);
- release: fails with "a file was served another file's cached data" —
the read returns the first file's array.

## Fix

Store the full file id (an "identity") with every cached entry and check
it on every lookup. A mismatch reads as a miss, so the caller falls back
to parquet. An insert replaces whatever held the key.

- **Index:** each slot holds `(identity, entry)`.
- **Disk:** every object gets an 8-byte identity trailer. Objects stay
keyed by entry id. An eviction/flush/hydration that lands after the key
changed hands leaves bytes naming the old identity, and the new owner's
read of them misses, so these races end in a miss rather than in wrong
data and need no extra coordination. The trailer follows the payload so
the payload keeps its alignment; the disk budget is charged for it.
Nothing reads objects across a restart (the index is in-memory), so
there is no on-disk compatibility concern.
- **DataFusion layer:** `CachedFile` already holds the full `u64` file
id; `CachedColumn` now carries it and passes it with every call.

The key width is unchanged: the index (`congee`) supports 8-byte keys
only, so widening the key would mean replacing the index.

## API change

`LiquidCache::insert`, `get`, `eval_predicate`, `prefetch`,
`try_read_liquid`, `contains`, `read_entry` and
`eval_predicate_on_entry` take an `identity: u64`. Callers whose keys
are already unique can pass any constant; the README, example and study
bench pass `0`. `read_entry` / `eval_predicate_on_entry` act on an entry
the caller already holds, so they trust that entry and check the
identity only for data read from disk.

## Cost

8 bytes per index slot and per disk object, and one integer comparison
per lookup.

## Tests

- New: the repro above, `an_entry_is_served_only_to_its_identity`,
`disk_bytes_written_for_another_identity_are_a_miss`,
`a_slot_is_visible_only_to_its_identity`.
- Replaced: `test_cache_entry_id_new_panic_file_id` pinned the
debug-only guard; `test_cache_entry_id_new_keeps_low_16_bits_of_file_id`
pins the narrowing the identity check relies on.
- Two disk-budget tests now size the budget to include the trailer.
- Workspace tests, doctests and the shuttle tests pass; test inventory
is otherwise unchanged.

## Not in this PR

- After 65,536 files, two files that share a narrowed id and are both
being read keep replacing each other's entries. Results are correct;
caching just stops helping for that pair.
- `register_or_get_file`'s map keeps one entry per distinct file
forever.
- Replacing a disk entry (by hydration, or now by another identity) does
not release its disk reservation. That predates this change and is
separate.
…sion-contrib#527)

Closes datafusion-contrib#522.

In-process mode (`liquid-cache-datafusion-local`) no longer pulls in the
client/server network stack. Its normal dependency graph drops from 387
to 343 crates; `arrow-flight`, `tonic`, `axum`, `hyper`, `h2`, `reqwest`
and `rustls` are all gone.

## Changes

- **`liquid-cache-common`: new `rpc` feature, off by default.** It gates
`pub mod rpc` and makes `arrow-flight` and `prost` optional.
`liquid-cache-datafusion-client` and `liquid-cache-datafusion-server`
enable it.
- **`ExecutionMetricsResponse` moves to
`liquid_cache_common::metrics`.** It is a plain serde struct and the
only part of `rpc` the in-process crates used.
`extract_execution_metrics` stays where it is, with the same signature.
- **`liquid-cache-datafusion` no longer enables `object_store`'s `http`
feature.** Nothing in that crate uses it, and it was what pulled in
`reqwest`/`hyper`/`h2`/`rustls`, so gating `rpc` alone would leave those
in place. `liquid-cache-datafusion-client` calls
`object_store::parse_url_opts` and was relying on this transitively, so
it now enables `http` itself. The server, benchmark and examples already
did.
- **`liquid-cache-common` gets `tokio` with `macros` as a
dev-dependency.** Its `#[tokio::test]`s only compiled before because
`tonic` enabled that feature.
- **docs.rs builds `liquid-cache-common` with `rpc`**, and its README
notes the feature.

`prost` remains in the graph through `vortex`; that is outside this
change.

## Breaking changes

- **The metrics struct has a new path.**
`liquid_cache_common::rpc::ExecutionMetricsResponse` is now
`liquid_cache_common::metrics::ExecutionMetricsResponse`. The serialized
JSON is unchanged.
- **`rpc` is now opt-in.** Code that uses `liquid_cache_common::rpc`
directly, or through the `common` re-exports of `liquid-cache` /
`liquid-cache-datafusion` / `liquid-cache-datafusion-local`, needs
`liquid-cache-common = { ..., features = ["rpc"] }`.
- **No more transitive `http`.** Users of `liquid-cache-datafusion` or
`-local` who registered `http(s)://` object stores and got
`object_store`'s `http` feature through this crate need to enable it
themselves.

## Testing

- **Workspace checks.** `cargo test --workspace --exclude dev-tools`
gives 236 passed, 0 failed. The test list is identical to `main` (239
entries). `cargo clippy --workspace --all-targets --all-features -D
warnings`, `cargo fmt --check`, `cargo doc` and `cargo shear` are clean.
- **Each crate built on its own.** `cargo check -p <crate>
--all-targets` passes for every crate. Workspace feature unification
would otherwise hide a missing feature declaration.
- **Standalone in-process crate.** A crate outside the workspace
depending only on `liquid-cache-datafusion-local`:
- It builds and runs the `examples/datafusion-local.rs` flow on
`nano_hits.parquet`; the second run is served from cache and
`extract_execution_metrics` reports correctly.
  - `cargo tree` shows none of the crates listed above.
- **Standalone client crate.** A crate depending only on
`liquid-cache-datafusion-client` registers `http://` and `https://`
object stores.
- With the client's new `http` line removed, the same build fails at
runtime with `feature for Http not enabled`, so that line is required.
- **End-to-end client/server.**
- `bench_server` with `clickbench_client` returns correct query results.
  - The example client/server reads an `https://` parquet file.
- The admin `/execution_metrics` endpoint returns the same JSON keys as
before.
…10-16

Brings in datafusion-contrib#524, datafusion-contrib#525, datafusion-contrib#526 and datafusion-contrib#527.

datafusion-contrib#526 is upstream's fix for file-id aliasing in cache keys: each entry
records the full file id, in the index and as a trailer on its disk
object, and a lookup under any other id misses. It replaces the fork's
own fix for the same bug, so the cache code takes upstream's version
and the fork's is removed: identity-scoped disk keys, the reclamation
layer built on them (DiskResidue, settle, reclaim_orphaned_disk),
WriteIdentity, and file-id leasing (file_id.rs).

Kept on top of upstream's version:

- The index releases a removed or replaced entry's payload immediately
  rather than through crossbeam-epoch's deferred drop, which kept
  multi-megabyte arrays alive past the budget that stopped counting
  them. Ported onto upstream's slot as a cell the index empties.
- leased_file_ids, file_ids_over_key_width and identity_mismatches on
  LiquidCacheParquet, which downstream exports as metrics. Without
  leasing, leased_file_ids counts every file ever registered rather
  than the files being read.

Dropped with the reclamation layer: the fixes that release a disk copy
displaced by hydration or overwritten in place. Upstream has that leak
too; it is to be fixed there and synced. The four datafusion-local
snapshots return to upstream's disk_evictions: 0 for that reason.

datafusion-contrib#525 is the fork's batch-size alignment fix returning from upstream.
batch_size_alignment.rs takes upstream's text and keeps the fork's two
ignored pruned-scan tests and the page_rows helper they use.
@anoop-narang
anoop-narang requested a review from a team as a code owner October 16, 2026 09:40
@anoop-narang
anoop-narang requested review from zfarrell and removed request for a team October 16, 2026 09:40
Comment thread src/datafusion/src/cache/mod.rs

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

The full diff was omitted from the review prompt. The whole patch was fetched with gh pr diff and read for all 35 files. CI checks were still pending when this review ran, so this approval does not rely on test results. Merge with a merge commit, as the PR description states.

@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.21608% with 27 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/src/cache/core.rs 91.35% 4 Missing and 10 partials ⚠️
src/datafusion/src/cache/mod.rs 90.41% 7 Missing ⚠️
src/datafusion/src/cache/stats.rs 81.81% 6 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: 6f9cc862 (Liquid) vs Baseline: 6f9cc862 (DataFusionDefault)

Query Cold Time Δ Warm Time Δ CPU Time Δ
Q1 3.0ms (2.0ms) +50.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q2 9.0ms (5.0ms) +80.0% 4.5ms (4.5ms) +0.0% 7.0ms (6.0ms) +16.7%
Q3 17.0ms (12.0ms) +41.7% 7.0ms (11.5ms) -39.1% 3.0ms (22.5ms) -86.7%
Q4 19.0ms (12.0ms) +58.3% 5.0ms (11.0ms) -54.5% 3.0ms (24.0ms) -87.5%
Q5 91.0ms (51.0ms) +78.4% 51.5ms (50.5ms) +2.0% 7.0ms (25.0ms) -72.0%
Q6 234.0ms (104.0ms) +125.0% 89.0ms (103.5ms) -14.0% 28.5ms (81.0ms) -64.8%
Q7 1.0ms (1.0ms) +0.0% 1.0ms (1.0ms) +0.0% 0.000ms (0.000ms) +0.0%
Q8 9.0ms (6.0ms) +50.0% 7.0ms (5.5ms) +27.3% 10.0ms (6.0ms) +66.7%
Q9 101.0ms (94.0ms) +7.4% 95.5ms (88.5ms) +7.9% 6.0ms (43.0ms) -86.0%
Q10 116.0ms (88.0ms) +31.8% 91.0ms (92.5ms) -1.6% 9.0ms (63.0ms) -85.7%
Q11 48.0ms (26.0ms) +84.6% 23.0ms (25.0ms) -8.0% 43.5ms (36.0ms) +20.8%
Q12 49.0ms (32.0ms) +53.1% 25.5ms (28.0ms) -8.9% 48.0ms (42.5ms) +12.9%
Q13 271.0ms (118.0ms) +129.7% 102.0ms (107.0ms) -4.7% 60.5ms (79.5ms) -23.9%
Q14 582.0ms (135.0ms) +331.1% 145.5ms (145.5ms) +0.0% 85.0ms (109.5ms) -22.4%
Q15 360.0ms (100.0ms) +260.0% 80.5ms (100.5ms) -19.9% 59.5ms (94.0ms) -36.7%
Q16 111.0ms (113.0ms) -1.8% 91.5ms (100.0ms) -8.5% 5.0ms (25.5ms) -80.4%
Q17 610.0ms (209.0ms) +191.9% 218.5ms (212.0ms) +3.1% 68.5ms (106.0ms) -35.4%
Q18 573.0ms (209.0ms) +174.2% 222.0ms (213.0ms) +4.2% 69.5ms (106.0ms) -34.4%
Q19 882.0ms (433.0ms) +103.7% 351.0ms (422.5ms) -16.9% 94.5ms (150.0ms) -37.0%
Q20 13.0ms (11.0ms) +18.2% 3.0ms (11.0ms) -72.7% 8.0ms (23.0ms) -65.2%
Q21 572.0ms (191.0ms) +199.5% 476.0ms (180.5ms) +163.7% 319.0ms (276.5ms) +15.4%
Q22 856.0ms (170.0ms) +403.5% 635.5ms (171.0ms) +271.6% 178.5ms (349.0ms) -48.9%
Q23 1.81s (470.0ms) +285.5% 1.65s (475.0ms) +247.7% 561.5ms (750.0ms) -25.1%
Q24 25.02s (883.0ms) +2733.3% 982.0ms (905.0ms) +8.5% 643.5ms (2.50s) -74.2%
Q25 324.0ms (70.0ms) +362.9% 17.5ms (56.0ms) -68.8% 46.0ms (116.5ms) -60.5%
Q26 137.0ms (42.0ms) +226.2% 19.5ms (43.5ms) -55.2% 53.5ms (81.5ms) -34.4%
Q27 313.0ms (77.0ms) +306.5% 26.5ms (57.0ms) -53.5% 76.5ms (116.5ms) -34.3%
Q28 743.0ms (212.0ms) +250.5% 944.5ms (220.5ms) +328.3% 220.0ms (281.0ms) -21.7%
Q29 1.36s (993.0ms) +37.5% 973.0ms (983.5ms) -1.1% 403.5ms (344.0ms) +17.3%
Q30 30.0ms (26.0ms) +15.4% 22.0ms (26.0ms) -15.4% 7.5ms (21.0ms) -64.3%
Q31 616.0ms (107.0ms) +475.7% 79.0ms (101.0ms) -21.8% 76.0ms (143.0ms) -46.9%
Q32 1.03s (104.0ms) +886.5% 129.0ms (98.0ms) +31.6% 117.0ms (144.0ms) -18.8%
Q33 310.0ms (300.0ms) +3.3% 275.5ms (300.0ms) -8.2% 9.5ms (70.5ms) -86.5%
Q34 840.0ms (394.0ms) +113.2% 466.0ms (393.5ms) +18.4% 137.0ms (272.0ms) -49.6%
Q35 878.0ms (401.0ms) +119.0% 477.0ms (393.0ms) +21.4% 136.0ms (271.0ms) -49.8%
Q36 105.0ms (91.0ms) +15.4% 91.0ms (98.0ms) -7.1% 5.0ms (24.5ms) -79.6%
Q37 306.0ms (98.0ms) +212.2% 73.0ms (94.0ms) -22.3% 27.5ms (69.5ms) -60.4%
Q38 86.0ms (42.0ms) +104.8% 27.0ms (44.5ms) -39.3% 17.0ms (23.0ms) -26.1%
Q39 282.0ms (47.0ms) +500.0% 32.0ms (48.5ms) -34.0% 19.0ms (73.0ms) -74.0%
Q40 699.0ms (189.0ms) +269.8% 226.0ms (179.0ms) +26.3% 60.5ms (124.0ms) -51.2%
Q41 26.0ms (18.0ms) +44.4% 11.5ms (18.0ms) -36.1% 7.0ms (16.0ms) -56.2%
Q42 21.0ms (17.0ms) +23.5% 10.5ms (17.5ms) -40.0% 8.0ms (14.5ms) -44.8%
Q43 17.0ms (18.0ms) -5.6% 12.0ms (15.0ms) -20.0% 8.5ms (10.0ms) -15.0%

⚠️ LiquidCache is slower on 9 queries (warm)

  • Q28: warm +328.3% (944.5ms vs 220.5ms)
  • Q22: warm +271.6% (635.5ms vs 171.0ms)
  • Q23: warm +247.7% (1.65s vs 475.0ms)
  • Q21: warm +163.7% (476.0ms vs 180.5ms)
  • Q32: warm +31.6% (129.0ms vs 98.0ms)
  • Q8: warm +27.3% (7.0ms vs 5.5ms)
  • Q40: warm +26.3% (226.0ms vs 179.0ms)
  • Q35: warm +21.4% (477.0ms vs 393.0ms)
  • Q34: warm +18.4% (466.0ms vs 393.5ms)

Compared Liquid vs DataFusionDefault on the same runner
Regressions: warm-time increases of at least 15%. Cold Time: first iteration; Warm Time: median of remaining iterations.

@anoop-narang
anoop-narang merged commit 6fb0c63 into main Oct 16, 2026
14 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