Skip to content

fix(cache): release disk when an entry is hydrated - #64

Merged
anoop-narang merged 2 commits into
mainfrom
fix/release-disk-on-hydrate
Nov 13, 2026
Merged

anoop-narang merged 2 commits into
mainfrom
fix/release-disk-on-hydrate

Conversation

@anoop-narang

Copy link
Copy Markdown
Collaborator

What goes wrong

With a disk cap set (with_max_disk_bytes) and a hydrating policy such as AlwaysHydrate, the disk tier fills up even though it holds little or nothing.

Each time a read hydrates a disk entry back into memory, the memory record replaces the disk record, but the disk charge for it is never given back and its stored object is left in place. When that entry is spilled again, the spill charges a second copy. So every hydrate → spill cycle of one entry adds a full copy to stats().disk_usage_bytes. Once the charge reaches the cap, spills start failing, flushes drop the entries they cannot place, and the reported disk usage stays pinned at the cap with nothing left on disk to evict. Recovering needs a reset.

A loop of insert → flush_all_to_disk → get (hydrates) → flush, on one 4.6 KB entry with a 4-copy cap, on main today:

round   charged after spill   served   charged after read   objects stored
1        4624                 yes       4624                1
2        9248                 yes       9248                1
3       13872                 yes      13872                1
4       18496                 yes      18496                1
5..12   18496                 no       18496                1

With this change, every round charges 4624 after the spill and 0 after the read, with no object left behind.

The same accounting gap shows up on two other paths:

  • Two spills of the same entry, for example a flush and an eviction that both read it before either wrote it. Both writes charge, but they land on the same stored object, so one copy is charged twice.
  • A flush that runs out of disk drops the memory entry it read. If another writer spilled that entry in the meantime, the flush removed the disk record without releasing its charge, and a debug build panicked (flush should only drop memory entries).

The fix

  • A disk charge is now an owned value. A disk record in the index owns its charge.
    • When a record is replaced or removed, its charge comes back as a value that releases itself when dropped.
    • A write holds its reservation the same way, from the moment it reserves until a record takes the charge over.
    • So an operation dropped mid-await (a cancelled query) still gives its disk back.
  • The charge goes back on replacement. When an insert replaces a disk record, that record's charge is released exactly once. The index now returns the slot it displaced, and that slot is handed to exactly one insert or removal.
    • If the new record's write put a new object, it has already overwritten the old one, so only the charge is released.
    • If the new record is in memory (hydration, or a caller inserting over the key), the old object is also deleted.
  • Store operations on one key are ordered. Objects are keyed by entry id alone, so a later spill of the same entry reuses the key. Without ordering, a delete decided earlier could land after a newer spill's write and destroy it, leaving the newer record pointing at nothing. Each key now maps to one of a fixed set of async locks.
    • A spill holds its key's lock from the moment it writes the object until the record naming that object is installed.
    • A delete holds it from its check that no record names the object until the object is gone.
    • Disk-pressure removal (remove_disk_entry) now takes the same lock.
    • No code path holds one key lock while taking another; victims are evicted before the writer takes its own lock.
  • A cancelled delete is finished later. A read cancelled while deleting the old object of an entry it hydrated has already given the charge back, but its object would otherwise stay in the store with nothing tracking it. The same goes for a spill dropped after writing its object but before its record is installed, and for a removal interrupted before its delete lands. All three leave the key in a small set, and the next spill deletes those objects first, under each key's lock and only if no record names the object by then. A spill may therefore wait on that cleanup once after a cancellation. The same holds when a custom cache policy panics during the insert or removal notification and the caller carries on.
  • A failed flush drops only a memory record, never one that another writer has since spilled.
  • An eviction policy that hands a disk record back unchanged, with nothing to write, leaves the key alone. The key may hold something newer by then, so the record is not reinstalled.
  • Custom policies have one new constraint. CachePolicy::notify_remove is called while the cache holds the entry's lock, so an implementation must not wait on a cache operation there. This is documented on the trait.

This ports the intent of #52, which reclaimed disk on the same kind of replacement under the earlier store-key scheme. That code did not survive the move to the current identity-trailer scheme in #60. #62 handled the reverse race: a disk victim that a read hydrates before its removal.

Tests

  • hydrate_and_spill_cycles_hold_one_disk_copy: the loop above, asserting the charge and the stored object count each round. It fails on main with the table shown.
  • Interleavings driven step by step, with no sleeps. Each one fails if the guard it covers is removed; all of them were checked by removing that guard.
    • a_hydrating_read_waits_for_a_spill_in_flight_on_its_key: a real get hydrates while a spill's write has landed but its record is not installed. The read must wait, then be woken when the record goes in.
    • a_read_dropped_while_reclaiming_still_releases_the_charge: the same read, aborted while it waits.
    • a_read_dropped_while_reclaiming_leaves_its_object_to_the_next_spill: the read is blocked by another key sharing its lock; its charge comes back once, and the next spill deletes its object.
    • a_spill_dropped_after_its_put_leaves_its_object_to_the_next_spill
    • a_removal_whose_policy_panics_leaves_its_object_to_the_next_spill
    • an_insert_whose_policy_panics_leaves_the_displaced_object_to_the_next_spill
    • a_spill_dropped_before_its_record_releases_its_reservation
    • a_reclaim_leaves_the_object_a_later_spill_installed
    • a_second_spill_of_an_entry_releases_the_first_ones_charge
    • a_disk_record_an_eviction_keeps_keeps_its_charge: an eviction policy hands a disk record back unchanged; it keeps its charge, and removing it later releases that charge once.
    • a_stale_disk_record_an_eviction_keeps_is_not_reinstalled: a read hydrates the entry while the policy decides; the stale record is not put back over it.
    • a_failed_flush_leaves_a_record_another_writer_spilled: a real flush runs out of disk while an eviction spills the same entry in a smaller form.
    • a_disk_removal_waits_for_a_spill_in_flight_on_its_key
  • Each test also checks that the charged disk equals the disk records in the index, and that the store holds exactly the objects those records name, each readable.

Known issues, not changed here

These were found while working on this and exist on main already:

  • reset() is not coordinated with operations in flight. A charge released after a reset can underflow the disk budget. It also leaves stored objects in place.
  • The memory budget in try_insert can drift under concurrent replacement. It is computed from the slot read before the insert, not the one actually replaced, so concurrent replacements of one entry can drift or underflow it.
  • A reader holding a stale disk record can fail to decode. If the entry is respilled in another format under the same key and identity, the reader may fetch the new object and fail to decode it.
  • The store can return another key's bytes. If a value's space is freed and reused by a concurrent put while a read is in flight, get returns the new owner's bytes. Tracked in Disk reads can return another entry's bytes (t4 slot reuse) #63.
  • A cancelled store write can leak file space. Cancelling a store put while its value write is pending can leak the file space the store allocated for it. This happens inside the store, below anything the cache can clean up by key.
  • Policy queues can go stale. Index changes and cache-policy notifications are not applied atomically, so a delayed notification can leave an entry in the wrong eviction queue.
  • A cancelled spill loses its disk victims. A spill cancelled while removing disk victims loses the other victims it took from the policy's queue. They stay on disk and charged, but are no longer offered for eviction.
  • No test in this module runs under the shuttle scheduler.

This change was investigated and written with AI assistance (Claude Code), and reviewed by Codex.

A hydrating read replaced a disk record with a memory one without
releasing the disk charge or deleting the stored object, so every
hydrate -> spill cycle charged another copy until the disk cap filled.

Disk charges are now owned values released exactly once when a record
is replaced or removed, or when an operation is dropped. Store
operations on one key are ordered by a striped async lock, so a delete
decided earlier cannot remove a newer spill's object. Objects left by
a cancelled delete, spill or removal are deleted by the next spill.
@anoop-narang
anoop-narang requested a review from a team as a code owner November 13, 2026 05:58
@anoop-narang
anoop-narang requested review from shefeek-jinnah and removed request for a team November 13, 2026 05:58
Comment thread src/core/src/cache/core.rs Resolved

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

Review — Blocking Issues: src/core/src/cache/core.rs:545 (insert_inner): A failed try_insert of a written disk record drops the DiskWrite and releases its charge. Line 571 keeps the disk record and sets write to None. The retry then installs the record with no charge. A later remove_disk_entry releases the charge again, and release_disk (fetch_sub) wraps used_disk_bytes. After that, every disk reservation fails.

Action Required: Never install a disk record without a live DiskWrite on a retry. For example, keep the bytes and rewrite before the next try_insert. Do not hold the key lock across evict_victims. Add a test where the install of a written disk record fails once and then succeeds. The test must assert assert_disk_matches_index afterwards.

@codecov

codecov Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.63953% with 30 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/src/cache/core.rs 95.58% 28 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: e27fc2fe (Liquid) vs Baseline: e27fc2fe (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 6.0ms (4.0ms) +50.0% 3.0ms (4.0ms) -25.0% 6.0ms (5.0ms) +20.0%
Q3 12.0ms (10.0ms) +20.0% 4.0ms (9.0ms) -55.6% 2.5ms (18.5ms) -86.5%
Q4 10.0ms (8.0ms) +25.0% 3.0ms (7.5ms) -60.0% 2.0ms (14.0ms) -85.7%
Q5 43.0ms (39.0ms) +10.3% 35.5ms (38.5ms) -7.8% 3.5ms (16.0ms) -78.1%
Q6 165.0ms (89.0ms) +85.4% 56.0ms (91.0ms) -38.5% 17.0ms (64.0ms) -73.4%
Q7 1.0ms (1.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q8 6.0ms (4.0ms) +50.0% 4.0ms (5.0ms) -20.0% 7.0ms (4.5ms) +55.6%
Q9 72.0ms (62.0ms) +16.1% 58.5ms (69.5ms) -15.8% 5.0ms (31.0ms) -83.9%
Q10 81.0ms (72.0ms) +12.5% 68.0ms (69.0ms) -1.4% 9.5ms (47.0ms) -79.8%
Q11 94.0ms (18.0ms) +422.2% 80.0ms (18.5ms) +332.4% 225.0ms (23.5ms) +857.4%
Q12 104.0ms (22.0ms) +372.7% 81.0ms (20.5ms) +295.1% 226.5ms (28.5ms) +694.7%
Q13 248.0ms (92.0ms) +169.6% 133.0ms (93.5ms) +42.2% 155.5ms (63.0ms) +146.8%
Q14 497.0ms (109.0ms) +356.0% 129.0ms (111.0ms) +16.2% 80.0ms (81.0ms) -1.2%
Q15 333.0ms (85.0ms) +291.8% 131.0ms (83.5ms) +56.9% 146.5ms (76.5ms) +91.5%
Q16 120.0ms (94.0ms) +27.7% 107.0ms (91.5ms) +16.9% 7.0ms (16.0ms) -56.2%
Q17 502.0ms (204.0ms) +146.1% 246.0ms (186.5ms) +31.9% 73.5ms (79.5ms) -7.5%
Q18 514.0ms (179.0ms) +187.2% 200.0ms (183.5ms) +9.0% 50.0ms (81.0ms) -38.3%
Q19 758.0ms (332.0ms) +128.3% 314.0ms (388.5ms) -19.2% 71.5ms (113.5ms) -37.0%
Q20 10.0ms (8.0ms) +25.0% 2.5ms (7.0ms) -64.3% 6.0ms (14.0ms) -57.1%
Q21 1.18s (150.0ms) +685.3% 425.0ms (148.0ms) +187.2% 226.0ms (226.0ms) +0.0%
Q22 1.54s (137.0ms) +1024.1% 530.5ms (138.0ms) +284.4% 124.0ms (284.5ms) -56.4%
Q23 3.40s (401.0ms) +748.1% 1.58s (408.5ms) +288.0% 387.0ms (614.0ms) -37.0%
Q24 20.76s (754.0ms) +2652.8% 949.5ms (781.0ms) +21.6% 429.5ms (2.14s) -79.9%
Q25 275.0ms (56.0ms) +391.1% 24.5ms (45.0ms) -45.6% 50.0ms (93.0ms) -46.2%
Q26 158.0ms (42.0ms) +276.2% 48.5ms (37.0ms) +31.1% 123.5ms (66.0ms) +87.1%
Q27 281.0ms (44.0ms) +538.6% 34.0ms (47.5ms) -28.4% 71.0ms (96.0ms) -26.0%
Q28 1.30s (184.0ms) +609.2% 932.5ms (179.5ms) +419.5% 156.0ms (233.5ms) -33.2%
Q29 1.82s (793.0ms) +129.5% 950.5ms (785.0ms) +21.1% 306.0ms (280.5ms) +9.1%
Q30 24.0ms (24.0ms) +0.0% 19.0ms (20.0ms) -5.0% 4.5ms (16.0ms) -71.9%
Q31 533.0ms (103.0ms) +417.5% 82.0ms (93.0ms) -11.8% 72.5ms (123.0ms) -41.1%
Q32 840.0ms (93.0ms) +803.2% 103.5ms (86.5ms) +19.7% 82.0ms (121.0ms) -32.2%
Q33 293.0ms (286.0ms) +2.4% 265.5ms (280.0ms) -5.2% 8.0ms (57.0ms) -86.0%
Q34 1.33s (364.0ms) +264.8% 533.0ms (359.5ms) +48.3% 110.0ms (223.5ms) -50.8%
Q35 1.34s (363.0ms) +270.0% 553.5ms (367.5ms) +50.6% 104.5ms (237.5ms) -56.0%
Q36 88.0ms (85.0ms) +3.5% 80.5ms (85.5ms) -5.8% 4.5ms (17.0ms) -73.5%
Q37 285.0ms (82.0ms) +247.6% 64.0ms (85.0ms) -24.7% 21.0ms (63.5ms) -66.9%
Q38 66.0ms (36.0ms) +83.3% 23.5ms (35.0ms) -32.9% 16.0ms (19.5ms) -17.9%
Q39 255.0ms (38.0ms) +571.1% 25.0ms (39.0ms) -35.9% 12.5ms (59.0ms) -78.8%
Q40 709.0ms (157.0ms) +351.6% 191.5ms (155.5ms) +23.2% 44.5ms (111.0ms) -59.9%
Q41 18.0ms (15.0ms) +20.0% 9.0ms (15.0ms) -40.0% 6.0ms (13.5ms) -55.6%
Q42 18.0ms (16.0ms) +12.5% 8.0ms (15.0ms) -46.7% 6.0ms (13.0ms) -53.8%
Q43 14.0ms (14.0ms) +0.0% 9.0ms (12.5ms) -28.0% 6.0ms (9.0ms) -33.3%

⚠️ LiquidCache is slower on 18 queries (warm)

  • Q28: warm +419.5% (932.5ms vs 179.5ms)
  • Q11: warm +332.4% (80.0ms vs 18.5ms)
  • Q12: warm +295.1% (81.0ms vs 20.5ms)
  • Q23: warm +288.0% (1.58s vs 408.5ms)
  • Q22: warm +284.4% (530.5ms vs 138.0ms)
  • Q21: warm +187.2% (425.0ms vs 148.0ms)
  • Q15: warm +56.9% (131.0ms vs 83.5ms)
  • Q35: warm +50.6% (553.5ms vs 367.5ms)
  • Q34: warm +48.3% (533.0ms vs 359.5ms)
  • Q13: warm +42.2% (133.0ms vs 93.5ms)
  • Q17: warm +31.9% (246.0ms vs 186.5ms)
  • Q26: warm +31.1% (48.5ms vs 37.0ms)
  • Q40: warm +23.2% (191.5ms vs 155.5ms)
  • Q24: warm +21.6% (949.5ms vs 781.0ms)
  • Q29: warm +21.1% (950.5ms vs 785.0ms)
  • Q32: warm +19.7% (103.5ms vs 86.5ms)
  • Q16: warm +16.9% (107.0ms vs 91.5ms)
  • Q14: warm +16.2% (129.0ms vs 111.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.

try_insert reserved memory for every record, including disk records
that hold none. With no slot under the key, that zero-byte reservation
fails whenever the memory budget already reads over its limit, which
drift in the memory accounting can leave it at. A spill refused there
dropped its write, releasing the charge, and insert_inner retried the
same disk record with no write: the record went in holding no charge,
and its later removal released that charge a second time, wrapping
the disk budget so that every later reservation failed.

A record that holds no memory now installs however full the memory
budget reads, so a written disk record always goes in with its write.
A debug assertion states the invariant: a disk record is installed
only by the write that charged it.
@anoop-narang
anoop-narang merged commit aa63b30 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