Skip to content

fix(cache): leave a disk victim that a read hydrated - #62

Merged
anoop-narang merged 1 commit into
mainfrom
fix/disk-victim-hydrated-race
Nov 13, 2026
Merged

anoop-narang merged 1 commit into
mainfrom
fix/disk-victim-hydrated-race

Conversation

@anoop-narang

Copy link
Copy Markdown
Collaborator

Problem

Under memory and disk pressure, a scan reading through the cache can panic with:

remove_disk_entry called for non-disk entry

Disk-pressure eviction in write_batch_to_disk takes victims from the cache policy's disk queue (find_disk_victim), then removes each one with remove_disk_entry. Between those two steps, a concurrent read of the same entry can hydrate it back into memory. With the default AlwaysHydrate, every disk read does this. So remove_disk_entry finds a MemoryArrow/MemoryLiquid record:

  1. Writer: find_disk_victim pops X from the disk queue. The policy lock is released.
  2. Reader: reads X from disk, hydrates it, index.insert(X, Memory*), and notify_insert files X in a memory queue.
  3. Writer: remove_disk_entry(X) removes the memory record and panics.

awaits on earlier victims in the same batch widen the window, and so does a multi-threaded runtime. The memory record is removed before the panic and its memory charge is never released, so each occurrence also leaks memory budget. evict_victim_inner's EvictionOutcome::Remove path has the same gap: it reads the slot, then removes it later.

The same code and race exist on datafusion-contrib/liquid-cache main.

Fix

  • ArtIndex::remove_if(entry_id, pred) removes a key only if pred holds for the record the key holds at removal time. It is built on congee's compute_if_present, so the check and the removal are one step. A record installed after pred looked makes the tree reject the removal, and pred is asked again.
  • remove_disk_entry removes only a DiskLiquid/DiskArrow record. If the key now holds a memory record, it returns without touching the budget or the policy.

Why this is correct for budget and policy state:

  • Memory budget: the hydrated record keeps its own charge, and nothing here releases it. The test checks that the memory budget equals the sum over index entries afterwards.
  • Policy: the hydrating insert already filed the key in a memory queue (or files it right after its index insert), so the entry stays evictable. The eviction loop moves on to the next disk victim, or returns CacheFull when there is none.
  • A different disk record now at the key: it still matches, and removing it releases exactly that record's bytes and object, as before.

Test

disk_victim_hydrated_before_its_removal_is_left_in_memory is deterministic. It uses the real LiquidPolicy, TranscodeEvict, AlwaysHydrate and t4 store. A wrapper policy runs a hook once, right after find_disk_victim returns: the hook performs a real get of the victim on another thread, then flush_all_to_disk continues. Without the fix, the test panics with the message above. With it, the test passes.

remove_if_removes_only_a_matching_slot covers the new index method.

There is no shuttle variant. Mounting a t4 store inside a shuttle test deadlocks, because IoWorker::new blocks on a channel waiting for a shuttle-spawned thread. That is why no existing shuttle test builds a LiquidCache.


This change was investigated and written with AI assistance (Claude Code).

@anoop-narang
anoop-narang requested a review from a team as a code owner November 12, 2026 21:36
@anoop-narang
anoop-narang requested review from zfarrell and removed request for a team November 12, 2026 21:36
@codecov

codecov Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.01709% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/src/cache/core.rs 91.13% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: 449d74f7 (Liquid) vs Baseline: 449d74f7 (DataFusionDefault)

Query Cold Time Δ Warm Time Δ CPU Time Δ
Q1 2.0ms (2.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q2 5.0ms (4.0ms) +25.0% 3.0ms (3.0ms) +0.0% 5.5ms (5.0ms) +10.0%
Q3 10.0ms (10.0ms) +0.0% 4.0ms (9.0ms) -55.6% 2.0ms (18.0ms) -88.9%
Q4 9.0ms (7.0ms) +28.6% 2.5ms (7.5ms) -66.7% 2.0ms (14.5ms) -86.2%
Q5 41.0ms (37.0ms) +10.8% 34.5ms (40.5ms) -14.8% 3.0ms (16.5ms) -81.8%
Q6 151.0ms (82.0ms) +84.1% 57.5ms (85.5ms) -32.7% 17.5ms (62.0ms) -71.8%
Q7 1.0ms (1.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q8 5.0ms (5.0ms) +0.0% 3.5ms (4.5ms) -22.2% 6.5ms (5.0ms) +30.0%
Q9 74.0ms (66.0ms) +12.1% 55.5ms (66.0ms) -15.9% 4.5ms (32.0ms) -85.9%
Q10 76.0ms (68.0ms) +11.8% 55.0ms (69.0ms) -20.3% 5.5ms (46.5ms) -88.2%
Q11 77.0ms (18.0ms) +327.8% 63.0ms (18.0ms) +250.0% 175.5ms (23.5ms) +646.8%
Q12 98.0ms (22.0ms) +345.5% 79.0ms (21.5ms) +267.4% 224.0ms (30.0ms) +646.7%
Q13 265.0ms (89.0ms) +197.8% 117.0ms (90.0ms) +30.0% 145.5ms (65.0ms) +123.8%
Q14 486.0ms (110.0ms) +341.8% 125.5ms (111.0ms) +13.1% 84.5ms (80.5ms) +5.0%
Q15 380.0ms (84.0ms) +352.4% 132.5ms (86.0ms) +54.1% 122.5ms (76.5ms) +60.1%
Q16 113.0ms (91.0ms) +24.2% 105.0ms (87.5ms) +20.0% 8.5ms (16.0ms) -46.9%
Q17 539.0ms (178.0ms) +202.8% 240.5ms (180.5ms) +33.2% 70.5ms (78.5ms) -10.2%
Q18 535.0ms (179.0ms) +198.9% 215.5ms (182.0ms) +18.4% 61.0ms (79.5ms) -23.3%
Q19 751.0ms (376.0ms) +99.7% 313.0ms (343.5ms) -8.9% 72.0ms (119.0ms) -39.5%
Q20 10.0ms (8.0ms) +25.0% 3.0ms (7.5ms) -60.0% 6.0ms (14.0ms) -57.1%
Q21 1.20s (142.0ms) +742.3% 443.0ms (143.5ms) +208.7% 221.0ms (226.5ms) -2.4%
Q22 1.94s (152.0ms) +1177.6% 532.5ms (137.5ms) +287.3% 123.5ms (278.5ms) -55.7%
Q23 3.31s (385.0ms) +758.4% 1.50s (385.5ms) +290.4% 403.0ms (609.5ms) -33.9%
Q24 20.14s (750.0ms) +2585.6% 978.5ms (754.5ms) +29.7% 421.5ms (2.10s) -80.0%
Q25 279.0ms (55.0ms) +407.3% 25.5ms (44.5ms) -42.7% 51.0ms (90.0ms) -43.3%
Q26 171.0ms (34.0ms) +402.9% 47.0ms (37.5ms) +25.3% 119.5ms (66.0ms) +81.1%
Q27 289.0ms (44.0ms) +556.8% 32.5ms (46.0ms) -29.3% 70.0ms (91.5ms) -23.5%
Q28 1.29s (182.0ms) +608.2% 694.0ms (173.0ms) +301.2% 132.5ms (231.0ms) -42.6%
Q29 1.90s (777.0ms) +144.9% 1.06s (790.0ms) +34.8% 314.0ms (282.5ms) +11.2%
Q30 25.0ms (21.0ms) +19.0% 18.0ms (22.5ms) -20.0% 4.5ms (16.0ms) -71.9%
Q31 526.0ms (82.0ms) +541.5% 78.0ms (85.5ms) -8.8% 71.0ms (117.0ms) -39.3%
Q32 699.0ms (82.0ms) +752.4% 96.0ms (79.0ms) +21.5% 78.0ms (118.5ms) -34.2%
Q33 292.0ms (295.0ms) -1.0% 262.5ms (280.5ms) -6.4% 8.0ms (57.5ms) -86.1%
Q34 1.56s (366.0ms) +326.0% 544.0ms (352.5ms) +54.3% 106.0ms (224.5ms) -52.8%
Q35 1.33s (357.0ms) +271.4% 526.5ms (363.5ms) +44.8% 101.5ms (224.0ms) -54.7%
Q36 86.0ms (75.0ms) +14.7% 73.0ms (85.0ms) -14.1% 4.0ms (16.5ms) -75.8%
Q37 261.0ms (85.0ms) +207.1% 76.0ms (84.0ms) -9.5% 19.0ms (61.0ms) -68.9%
Q38 60.0ms (36.0ms) +66.7% 23.0ms (38.5ms) -40.3% 15.0ms (22.0ms) -31.8%
Q39 259.0ms (37.0ms) +600.0% 24.5ms (38.0ms) -35.5% 11.5ms (58.5ms) -80.3%
Q40 682.0ms (155.0ms) +340.0% 189.5ms (146.0ms) +29.8% 43.0ms (105.5ms) -59.2%
Q41 19.0ms (18.0ms) +5.6% 9.0ms (16.0ms) -43.8% 7.0ms (15.0ms) -53.3%
Q42 16.0ms (16.0ms) +0.0% 7.0ms (14.0ms) -50.0% 5.0ms (12.0ms) -58.3%
Q43 16.0ms (14.0ms) +14.3% 8.0ms (12.0ms) -33.3% 5.0ms (8.5ms) -41.2%

⚠️ LiquidCache is slower on 18 queries (warm)

  • Q28: warm +301.2% (694.0ms vs 173.0ms)
  • Q23: warm +290.4% (1.50s vs 385.5ms)
  • Q22: warm +287.3% (532.5ms vs 137.5ms)
  • Q12: warm +267.4% (79.0ms vs 21.5ms)
  • Q11: warm +250.0% (63.0ms vs 18.0ms)
  • Q21: warm +208.7% (443.0ms vs 143.5ms)
  • Q34: warm +54.3% (544.0ms vs 352.5ms)
  • Q15: warm +54.1% (132.5ms vs 86.0ms)
  • Q35: warm +44.8% (526.5ms vs 363.5ms)
  • Q29: warm +34.8% (1.06s vs 790.0ms)
  • Q17: warm +33.2% (240.5ms vs 180.5ms)
  • Q13: warm +30.0% (117.0ms vs 90.0ms)
  • Q40: warm +29.8% (189.5ms vs 146.0ms)
  • Q24: warm +29.7% (978.5ms vs 754.5ms)
  • Q26: warm +25.3% (47.0ms vs 37.5ms)
  • Q32: warm +21.5% (96.0ms vs 79.0ms)
  • Q16: warm +20.0% (105.0ms vs 87.5ms)
  • Q18: warm +18.4% (215.5ms vs 182.0ms)

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 9478c20 into main Nov 13, 2026
13 of 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