Skip to content

fix(cache): stop evicted entries outliving the budget - #45

Merged
zfarrell merged 7 commits into
mainfrom
fix/43-cache-memory-footprint
Sep 3, 2026
Merged

fix(cache): stop evicted entries outliving the budget#45
zfarrell merged 7 commits into
mainfrom
fix/43-cache-memory-footprint

Conversation

@zfarrell

@zfarrell zfarrell commented Sep 3, 2026

Copy link
Copy Markdown

Addresses #43 with the two defects a counting-allocator test reproduces locally in two seconds, independent of #44.

The index (CongeeArc) frees a removed or replaced entry through crossbeam-epoch deferred destruction, so every evicted array stayed alive until a later collection: idle heap ran 6.14x the budget tally and the read-pass peak 6.09x the tier limit; the ART now holds a slot whose payload is taken out eagerly (1.01x and 1.71x after). Separately, a hydrated entry re-serialised and rewrote its own disk copy on eviction and reserved it twice (190 writes in a pure read pass, disk tally 2.7x the real objects); reuse_disk_copy now drops the write when the policy's chosen form is backed by the copy already on disk. The new memory_footprint test asserts all of it and fails on main.

Review follow-ups (7cb9f7c, 3ae1a4e, ad483ad): a caller overwrite drops the superseded disk copy, taking a stub or squeezed entry out of the index with it, so a demotion of the new value cannot flip to a stub over the old bytes and a failed insert cannot leave an entry over a deleted object; a flushed arrow entry is recorded as an Arrow copy; a put that replaces the object under a key releases the previous reservation; drop_memory_entry discards the dropped entry's disk copy; and ArtIndex::get retries a lookup that raced a replace. Each has a test that fails on the previous head. The three datafusion-local snapshots were refreshed for the removed rewrites.

Comment thread src/core/src/cache/core.rs Outdated
// there: demote it by flipping the index entry rather than
// re-serialising it into a hybrid whose backing would have to be
// written all over again.
let outcome = match (to_squeeze_batch.as_ref(), self.disk_copy(&to_squeeze)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing invalidates disk_copies when a caller overwrites an entry. This arm then flips the index to a disk stub that points at the previous value.

Failure scenario:

  1. cache.insert(id, v1) stores MemoryArrow(v1).
  2. Memory pressure demotes id to DiskLiquid and records disk_copies[id] = Liquid/N.
  3. cache.insert(id, v2) replaces the index entry with MemoryArrow(v2). No write reaches the store, so the store still holds v1.
  4. Memory pressure transcodes v2 to MemoryLiquid.
  5. This arm matches and inserts disk_liquid(dt, N) with bytes_to_write: None.
  6. cache.get(&id) reads the store and returns v1.

The shortcuts at line 287 and line 365 have the same defect.

Overwrite is a supported operation. try_insert at line 498 handles the existing-entry case explicitly, and test_basic_cache_operations at line 1166 replaces entry 1 with an array of a different length. LiquidCache::insert is also public API.

Fix: drop the disk_copies record for caller-supplied inserts. Insert::run in src/core/src/cache/builders.rs:206 is the boundary, because insert_inner is shared with maybe_hydrate and hydration must keep the record.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 3ae1a4e. Insert::run now calls supersede_disk_copy before insert_inner: if the index still holds the stub, the whole entry goes through remove_disk_entry; if it was hydrated, the record is dropped, the store object removed and its reservation released. Hydration is unaffected since it enters through insert_inner. Two tests cover the stub and the hydrated case, overwrite_of_disk_stub_invalidates_disk_copy and overwrite_of_hydrated_entry_invalidates_disk_copy (insert, demote, overwrite with a different-length array, demote, read back the new value, and check the old reservation is gone). Both fail on the previous head.

While writing them I found a second reachable defect in the same code: flush_all_to_disk writes an arrow entry as Arrow IPC but write_batch_to_disk recorded the copy as Liquid, so a flushed, hydrated, then transcoded entry was flipped to a liquid stub over arrow bytes and the read panicked in the IPC reader. Fixed in the same commit, covered by flushed_arrow_copy_is_not_reused_as_liquid.

Comment thread src/core/src/cache/core.rs Outdated
bytes,
}),
) => SqueezeOutcome::Replace {
entry: CacheEntry::disk_liquid(liquid.original_arrow_data_type(), bytes),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: this arm bypasses the configured SqueezePolicy for every rehydrated liquid entry (not blocking).

The default policy is TranscodeSqueezeEvict. That policy turns MemoryLiquid into MemorySqueezedLiquid (src/core/src/cache/policies/squeeze.rs:131), which keeps a compact in-memory form for date32 and variant pushdown. Once an entry has been demoted to DiskLiquid and rehydrated, disk_copy returns Some, so the squeezed tier is never reached again for that entry.

Consider extending reuse_disk_copy to also drop the write for a MemorySqueezedLiquid outcome whose disk_backing() size and kind match the recorded copy. That avoids the rewrite without changing the entry shape the policy chose.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 3ae1a4e. The shortcut is now gated on squeeze_hint.is_none(): no LiquidArray::squeeze implementation produces a squeezed form without a hint, so unhinted the shortcut arrives at the same stub every policy would, minus the re-serialisation. With a hint the policy runs, and reuse_disk_copy now also handles a MemorySqueezedLiquid outcome, dropping the write when disk_backing() matches the recorded copy in kind and length. Covered by rehydrated_hinted_entry_returns_to_squeezed_tier_without_rewrite, which asserts the squeezed tier is reached with zero writes and the disk budget unchanged.

Comment thread src/core/src/cache/core.rs Outdated
},
_ => DiskKind::Liquid,
};
self.disk_copies

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: release the previous copy's bytes when this call overwrites an existing disk object (not blocking).

try_reserve_disk(len) at line 910 reserves on every call. Overwriting a key that already has a DiskCopy holds the old reservation forever, so the disk tally exceeds the indexed on-disk bytes and the tier evicts real entries early.

A kind change reaches this path. An entry with an Arrow copy that later writes liquid bytes fails the copy.kind != stub.kind check in reuse_disk_copy, so the write proceeds. The new budget_disk_bytes == index_disk_bytes assertion in memory_footprint.rs passes only because that test never changes kind.

The map now carries the old size, so the fix is local: call self.budget.release_disk(old.bytes) when disk_copies already holds a record for entry_id.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 3ae1a4e: write_batch_to_disk releases the previous record's bytes when the put replaces the object under the key. The kind-change path you describe is exactly what flushed_arrow_copy_is_not_reused_as_liquid exercises now, and it asserts the disk budget equals the stub's bytes afterwards.

Some(batch)
// A slot emptied by a concurrent remove reads as a miss, exactly as if
// the remove had won the race outright.
self.art.get(*entry_id, &guard)?.load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: the comment covers the remove race but not the replace race (not blocking).

ArtIndex::insert empties the old slot at line 76. A reader that already loaded the old slot pointer gets None, although the key is present with a new value. That is not equivalent to a remove winning, because the entry is still cached.

Two consequences follow. is_cached reports false for a cached entry. In try_insert (src/core/src/cache/core.rs:498) the None branch calls try_reserve_memory instead of try_update_memory_usage, so the replaced entry's bytes are never released from the memory tally.

Consider retrying the ART lookup once when load() returns None, and updating the comment to name the replace case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 7cb9f7c: ArtIndex::get re-runs the ART lookup once when the loaded slot is empty, and the comment now names the replace case.

@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:624 — the disk_copies record is never invalidated when a caller overwrites an entry. A later demotion flips the index to a disk stub that points at the previous value, so get returns stale data. The shortcuts at line 287 and line 365 share the defect. Details are in the inline comment.

Action Required

Invalidate the disk_copies record for caller-supplied inserts. Insert::run in src/core/src/cache/builders.rs:206 is the right boundary, because insert_inner is shared with maybe_hydrate and hydration must keep the record. Add a test that inserts an entry, demotes it to disk, overwrites it with different data, demotes it again, and reads the new value back.

Notes

Three non-blocking comments are inline: a SqueezePolicy bypass for rehydrated entries, a disk reservation that is never released on overwrite, and a replace race in ArtIndex::get.

CI had not reported any result when this review ran, so no check outcome is claimed here.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.69274% with 19 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/src/cache/core.rs 95.72% 7 Missing and 6 partials ⚠️
src/core/src/cache/index.rs 88.67% 4 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: f168a7e6 (Liquid) vs Baseline: f168a7e6 (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.5ms (4.2ms) -17.6% 5.8ms (6.0ms) -4.2%
Q3 15.0ms (12.0ms) +25.0% 4.5ms (13.5ms) -66.7% 2.0ms (24.0ms) -91.7%
Q4 16.0ms (12.0ms) +33.3% 3.2ms (10.8ms) -69.8% 2.0ms (23.5ms) -91.5%
Q5 54.0ms (56.0ms) -3.6% 43.0ms (51.2ms) -16.1% 3.0ms (25.5ms) -88.2%
Q6 142.0ms (107.0ms) +32.7% 69.2ms (110.0ms) -37.0% 23.0ms (78.8ms) -70.8%
Q7 1.0ms (1.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q8 6.0ms (5.0ms) +20.0% 4.2ms (6.0ms) -29.2% 6.8ms (8.5ms) -20.6%
Q9 88.0ms (92.0ms) -4.3% 87.0ms (91.0ms) -4.4% 5.2ms (45.5ms) -88.5%
Q10 116.0ms (93.0ms) +24.7% 83.8ms (97.8ms) -14.3% 7.5ms (64.8ms) -88.4%
Q11 46.0ms (25.0ms) +84.0% 23.0ms (26.2ms) -12.4% 39.0ms (36.8ms) +6.1%
Q12 53.0ms (29.0ms) +82.8% 24.8ms (28.8ms) -13.9% 41.0ms (41.8ms) -1.8%
Q13 233.0ms (119.0ms) +95.8% 114.2ms (117.5ms) -2.8% 73.2ms (81.0ms) -9.6%
Q14 382.0ms (144.0ms) +165.3% 158.0ms (163.2ms) -3.2% 116.5ms (119.5ms) -2.5%
Q15 271.0ms (109.0ms) +148.6% 136.5ms (109.8ms) +24.4% 88.0ms (97.8ms) -10.0%
Q16 171.0ms (114.0ms) +50.0% 144.8ms (116.8ms) +24.0% 6.2ms (25.0ms) -75.0%
Q17 508.0ms (246.0ms) +106.5% 315.5ms (233.8ms) +35.0% 120.8ms (104.8ms) +15.3%
Q18 486.0ms (235.0ms) +106.8% 280.0ms (229.2ms) +22.1% 101.2ms (104.2ms) -2.9%
Q19 623.0ms (425.0ms) +46.6% 412.2ms (510.5ms) -19.2% 109.5ms (145.2ms) -24.6%
Q20 13.0ms (11.0ms) +18.2% 3.0ms (11.8ms) -74.5% 7.0ms (24.0ms) -70.8%
Q21 781.0ms (180.0ms) +333.9% 267.2ms (184.2ms) +45.0% 505.2ms (288.2ms) +75.3%
Q22 1.08s (174.0ms) +521.3% 293.2ms (173.2ms) +69.3% 157.2ms (352.8ms) -55.4%
Q23 2.31s (467.0ms) +394.0% 985.0ms (481.8ms) +104.5% 498.8ms (758.2ms) -34.2%
Q24 14.40s (905.0ms) +1491.4% 752.8ms (929.2ms) -19.0% 627.5ms (2.60s) -75.9%
Q25 181.0ms (75.0ms) +141.3% 14.8ms (57.8ms) -74.5% 36.8ms (116.5ms) -68.5%
Q26 95.0ms (44.0ms) +115.9% 19.8ms (45.5ms) -56.6% 47.8ms (80.8ms) -40.9%
Q27 179.0ms (68.0ms) +163.2% 26.2ms (60.0ms) -56.2% 77.8ms (119.5ms) -34.9%
Q28 1.02s (219.0ms) +364.8% 268.8ms (226.0ms) +18.9% 365.0ms (292.5ms) +24.8%
Q29 1.81s (1.01s) +78.9% 1.07s (1.01s) +5.9% 571.5ms (353.8ms) +61.6%
Q30 29.0ms (26.0ms) +11.5% 22.0ms (26.2ms) -16.2% 5.0ms (21.2ms) -76.5%
Q31 281.0ms (103.0ms) +172.8% 76.2ms (107.2ms) -28.9% 43.2ms (149.5ms) -71.1%
Q32 472.0ms (105.0ms) +349.5% 97.2ms (101.0ms) -3.7% 54.5ms (145.8ms) -62.6%
Q33 379.0ms (371.0ms) +2.2% 334.0ms (350.0ms) -4.6% 8.5ms (71.5ms) -88.1%
Q34 1.08s (428.0ms) +152.3% 478.8ms (441.0ms) +8.6% 355.5ms (287.0ms) +23.9%
Q35 1.04s (461.0ms) +125.8% 494.8ms (446.8ms) +10.7% 368.0ms (279.2ms) +31.8%
Q36 118.0ms (128.0ms) -7.8% 96.8ms (111.0ms) -12.8% 4.5ms (26.5ms) -83.0%
Q37 293.0ms (107.0ms) +173.8% 86.2ms (101.5ms) -15.0% 43.0ms (74.2ms) -42.1%
Q38 81.0ms (56.0ms) +44.6% 29.5ms (47.0ms) -37.2% 17.2ms (25.5ms) -32.4%
Q39 303.0ms (47.0ms) +544.7% 13.2ms (49.8ms) -73.4% 11.8ms (75.5ms) -84.4%
Q40 761.0ms (201.0ms) +278.6% 216.8ms (185.8ms) +16.7% 88.8ms (134.0ms) -33.8%
Q41 24.0ms (22.0ms) +9.1% 12.2ms (18.8ms) -34.7% 7.5ms (17.2ms) -56.5%
Q42 21.0ms (17.0ms) +23.5% 10.8ms (17.8ms) -39.4% 7.5ms (15.2ms) -50.8%
Q43 18.0ms (15.0ms) +20.0% 12.8ms (16.8ms) -23.9% 8.0ms (11.5ms) -30.4%

⚠️ LiquidCache is slower on 12 queries (warm)

  • Q23: warm +104.5% (985.0ms vs 481.8ms)
  • Q22: warm +69.3% (293.2ms vs 173.2ms)
  • Q21: warm +45.0% (267.2ms vs 184.2ms)
  • Q17: warm +35.0% (315.5ms vs 233.8ms)
  • Q15: warm +24.4% (136.5ms vs 109.8ms)
  • Q16: warm +24.0% (144.8ms vs 116.8ms)
  • Q18: warm +22.1% (280.0ms vs 229.2ms)
  • Q28: warm +18.9% (268.8ms vs 226.0ms)
  • Q40: warm +16.7% (216.8ms vs 185.8ms)
  • Q35: warm +10.7% (494.8ms vs 446.8ms)
  • Q34: warm +8.6% (478.8ms vs 441.0ms)
  • Q29: warm +5.9% (1.07s vs 1.01s)

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

`ArtIndex::insert` empties the replaced slot, so a reader that had
already loaded the old slot saw `None` for a key that is still present.
`is_cached` then reported false and `try_insert` reserved fresh memory
for the new value instead of releasing the replaced one. Look the key
up once more when the slot is empty.
A caller overwriting an entry left the disk-copy record pointing at
the previous value, so a later demotion flipped the index to a stub
over stale bytes and `get` returned them. `Insert` now removes the
object and its reservation before inserting; hydration, which shares
`insert_inner`, keeps the record because the bytes are still current.

Also in `write_batch_to_disk`: a flushed arrow entry was recorded as a
liquid copy, so a hydrated-then-transcoded entry could be demoted to a
liquid stub over Arrow IPC bytes; and a put that replaces the object
under a key now releases the previous copy's reservation.

The hydrated-liquid shortcut in `squeeze_victim_inner` now applies
only without a squeeze hint (no `LiquidArray::squeeze` produces a
squeezed form unhinted); with one the policy runs, so the entry can
return to the squeezed tier, and `reuse_disk_copy` drops the write
when the squeezed form's backing is the copy already on disk.
let Some(copy) = self.disk_copies.lock().unwrap().remove(&entry_id) else {
return;
};
self.store

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This branch deletes the store object of a MemorySqueezedLiquid entry and leaves that entry in the index. A squeezed entry reads its full value back through that object, so the entry becomes unreadable.

Failure scenario:

  1. Entry E sits in the squeezed tier. disk_copies[E] records the backing and the store holds it.
  2. cache.insert(E, v2) reaches this branch. The object is removed and the reservation is released.
  3. insert_inner cannot fit v2 in memory, and find_memory_victim returns nothing.
  4. write_in_memory_batch_to_disk finds a full disk tier with no disk victim and returns CacheFull.
  5. insert returns Err(CacheFull), so the index still holds the squeezed entry for E.
  6. cache.get(&E) reads the backing, store.get fails, and src/core/src/liquid_array/squeezed_date32_array.rs:241 panics on .expect("read squeezed backing").

A concurrent reader of E hits the same panic during the window between step 2 and the new value reaching the index.

Fix: remove the index entry for a MemorySqueezedLiquid entry too, as the stub branch above already does. drop_memory_entry releases the memory tally for that entry.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in ad483ad. supersede_disk_copy now sends a MemorySqueezedLiquid entry through drop_memory_entry (index entry out, memory tally released) before the object goes, the same way a stub goes through remove_disk_entry. Covered by overwrite_of_squeezed_entry_that_fails_to_insert_leaves_no_entry: a squeezed entry with the disk tier sized to exactly its backing, a NoVictims policy, and an oversized overwrite that returns CacheFull; afterwards the entry is absent, get is None, and both tallies are zero. Fails on the previous head.

Comment thread src/core/src/cache/core.rs Outdated
kind: DiskKind::Liquid,
bytes,
}),
None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: LiquidFloatArray::squeeze ignores the hint, so this arm skips the squeezed tier for float entries (not blocking).

src/core/src/liquid_array/float_array.rs:341 names the parameter _expression_hint and returns Some for any bit width of 8 or more. An unhinted float entry therefore does have a squeezed form. This arm flips such an entry straight to a disk stub, so TranscodeSqueezeEvict never halves its bit width again after the first disk round trip. Float columns are common in the TPC-H tables this repo benchmarks.

Fix the comment above, or add a LiquidArray method that reports whether an unhinted squeeze exists and gate this arm on it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, the claim was wrong for floats. Rather than gate the arm on a new trait method I removed the shortcut in ad483ad: the policy always runs and reuse_disk_copy drops the rewrite when the chosen form is backed by the copy already on disk, so a rehydrated float entry halves its bit width again and an unhinted string entry still demotes without a write. The only cost is the serialisation the policy produces for the outcome, which is then discarded.

.remove(&entry_id_to_key(&entry_id))
.await
.expect("disk remove failed");
self.disk_copies.lock().unwrap().remove(&entry_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: drop_memory_entry drops no disk-copy record, unlike this line (not blocking).

flush_all_to_disk calls drop_memory_entry when write_batch_to_disk returns CacheFull (line 294 and line 324). For an entry that holds a disk copy, the index entry then disappears while disk_copies keeps the record, the store keeps the object, and the budget keeps the reservation. No later path removes that object, so the disk tier stays smaller than configured, and the new budget_disk_bytes == index_disk_bytes assertion in src/core/tests/memory_footprint.rs no longer holds.

Remove the record in drop_memory_entry, delete the store object, and release the recorded bytes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in ad483ad: drop_memory_entry now calls the same discard_disk_copy that overwrite uses (record out, store object removed, reservation released). Covered by flush_dropping_hydrated_entry_releases_its_disk_copy: a flushed then hydrated arrow entry, a disk tier holding exactly its copy, and a second flush that cannot rewrite it; the entry is dropped and the disk tally returns to zero. Fails on the previous head.

@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

The prior blocking finding is fixed for a disk stub and for a hydrated liquid entry. supersede_disk_copy opens one new hole for a squeezed entry.

Blocking Issues

  • src/core/src/cache/core.rs:503supersede_disk_copy deletes the store object of a MemorySqueezedLiquid entry and keeps that entry in the index. A squeezed entry reads its value back through that object. When insert_inner then returns CacheFull, the entry stays in the index over a deleted object, and every later read panics at src/core/src/liquid_array/squeezed_date32_array.rs:241.

Action Required

  • In supersede_disk_copy, remove the index entry for a MemorySqueezedLiquid entry as well, the way the stub branch does. Release its memory tally through drop_memory_entry.
  • Add a test that overwrites an entry which sits in the squeezed tier, then reads that entry.

Two non-blocking comments sit inline on the float squeeze arm and on drop_memory_entry.

CI checks were queued or in progress when this review started, so no check result is reported here.

@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:500-507supersede_disk_copy deletes the store object of a MemorySqueezedLiquid entry and leaves that entry in the index. The finding is unchanged from thread 3926032595. The diff since the last review touches only snapshot files.

A squeezed entry reads its full value back through that store object, so the entry becomes unreadable.

Failure scenario:

  1. Entry E sits in the squeezed tier. disk_copies[E] records the backing and the store holds it.
  2. cache.insert(E, v2) reaches line 500. The object is removed and the reservation is released.
  3. insert_inner (line 406) cannot fit v2 in memory and find_memory_victim returns nothing.
  4. write_in_memory_batch_to_disk finds a full disk tier with no disk victim and returns CacheFull.
  5. insert returns Err(CacheFull), so the index still holds the squeezed entry for E.
  6. cache.get(&E) reads the backing, store.get fails, and src/core/src/liquid_array/squeezed_date32_array.rs:241 panics on .expect("read squeezed backing").

A second path reaches an unreadable entry without any error return. TranscodeSqueezeEvict::squeeze at src/core/src/cache/policies/squeeze.rs:151 turns a squeezed entry into a disk stub over the same object, with bytes_to_write: None. If E is picked as a squeeze victim during step 3, the index holds a stub over the deleted object, and a concurrent reader of E reads that stub.

A concurrent reader also hits the panic in the window between step 2 and the new value reaching the index.

Action Required

Remove the index entry for a MemorySqueezedLiquid entry in supersede_disk_copy, as the stub branch at line 490 already does. drop_memory_entry (line 593) releases the memory tally for that entry.

A squeezed entry reads back through its store object, so
`supersede_disk_copy` cannot delete that object and leave the entry in
the index: an insert that then fails with `CacheFull` left an entry
whose next read panicked. The squeezed entry now goes the way of a
disk stub. `drop_memory_entry` likewise discards the disk copy of the
entry it drops, which the flush overflow path leaked as an orphaned
object and a permanent reservation.

The hydrated-liquid shortcut in `squeeze_victim_inner` is gone: float
arrays squeeze without a hint, so it kept them from the squeezed tier.
The policy always runs and `reuse_disk_copy` drops the rewrite.
if let Some(previous) = previous {
// The put replaced the object under this key, so the previous
// copy's reservation goes with it.
self.budget.release_disk(previous.bytes);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: reserve the size delta instead of the full length when this put replaces an existing object (not blocking).

The reserve loop at line 976 asks for len while the previous copy still holds previous.bytes. Replacing an object therefore needs both sizes free at once, and the release at line 1014 happens only after the reserve succeeds. On a nearly full disk tier the reserve fails, so write_batch_to_disk returns CacheFull and the caller drops the entry — flush_all_to_disk calls drop_memory_entry at line 294 and line 324. The new flush_dropping_hydrated_entry_releases_its_disk_copy test shows that outcome: the entry disappears although the rewrite would not have grown the tier.

Read the record before the loop. Reserve len - previous.bytes when the value grows, and release previous.bytes - len after the put when it shrinks. Keep the record in place if the reserve fails, so discard_disk_copy still matches a live reservation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, and deferred to #46 rather than folded in here: reserving the delta changes what a flush does on a full tier, and flush_dropping_hydrated_entry_releases_its_disk_copy would need a different route to CacheFull (it relies on exactly this over-reservation today). The overwrite and drop paths in this PR keep the record and the reservation consistent either way.

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

Prior blocking findings are addressed. supersede_disk_copy now routes a MemorySqueezedLiquid entry through drop_memory_entry, and drop_memory_entry discards the disk copy, so no index entry survives over a deleted store object. Removing the unhinted shortcut in squeeze_victim_inner lets a float entry reach the squeezed tier again, and reuse_disk_copy still drops the rewrite.

One non-blocking comment on write_batch_to_disk reservation ordering.

CI was still queued or in progress at review time, so the new tests are unverified here.

@zfarrell
zfarrell merged commit 1a8838c into main Sep 3, 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