Skip to content

fix(cache): stop a reclaim deleting an object written after it was decided - #52

Merged
anoop-narang merged 1 commit into
mainfrom
fix/reclaim-identity-aba
Sep 24, 2026
Merged

anoop-narang merged 1 commit into
mainfrom
fix/reclaim-identity-aba

Conversation

@anoop-narang

Copy link
Copy Markdown
Collaborator

Follows up the two findings review raised on the identity-scoped store keys.

A store key is (entry id, identity), and an identity comes back — the file-id
pool hands a re-opened path its previous record so its cached entries stay
readable. Two consequences were unhandled:

A settlement could delete a live object. settle decides to reclaim a key
and deletes behind an await. In between, a fresh write can occupy that key,
and the delete then destroyed bytes a live record named. reclaim_orphaned_disk
now asks the index whether a record still names the object, as late as it can,
and releases the byte count either way.

This narrows the window rather than closing it — a put that has landed but whose
record is not yet installed is still invisible. Closing it needs the store key to
name the write and not just the writer, i.e. a generation in the key. That also
removes the in-place-overwrite case entirely, so it is written up in the code
rather than done here.

remove_checked could strand a record. It tested the identity, removed, then
put back what it found if the test had gone stale — an unconditional insert, so a
writer that took the key over in between lost its record while entry_count kept
counting it. Deciding and removing are now one compute_if_present.

That closure runs under an optimistic read and the tree upgrades afterwards, so a
losing upgrade runs it again against whatever the key holds by then. The verdict
is recomputed each run rather than latched on the first: latching it would
decrement entry_count for a record still in the tree and hand the caller
another writer's entry to release.

Tests. Each was run against the unfixed code first.

  • a_settlement_does_not_delete_an_object_written_after_it_was_decided stages
    A-B-A and fails without the guard.
  • a_checked_removal_that_retries_reports_the_verdict_it_acted_on keeps writers
    on one key until the tree makes a removal retry — caught a latched verdict
    10/10 runs, where the single-round test passed 30/30.
  • an_in_place_disk_overwrite_releases_the_copy_it_supersedes was vacuous: the
    crate passed with the handling it names deleted. It now drives the two steps a
    spill performs and asserts the branch was taken.
  • only_a_same_identity_disk_write_lands_on_the_object_it_displaces covers
    DiskResidue::displacing over every written/displaced combination.

Workspace suite 273 passed / 0 failed; shuttle 3 passed; fmt and clippy clean.

@anoop-narang
anoop-narang requested a review from a team as a code owner September 24, 2026 10:19
@anoop-narang
anoop-narang requested review from eddietejeda and removed request for a team September 24, 2026 10:19
claude[bot]
claude Bot previously approved these changes Sep 24, 2026
Comment thread src/core/src/cache/index.rs
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.95122% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/src/cache/index.rs 94.28% 4 Missing ⚠️
src/core/src/cache/core.rs 98.93% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: 03d8befc (Liquid) vs Baseline: 03d8befc (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 7.0ms (5.0ms) +40.0% 4.0ms (4.0ms) +0.0% 7.5ms (5.5ms) +36.4%
Q3 14.0ms (12.0ms) +16.7% 6.0ms (12.0ms) -50.0% 3.5ms (22.5ms) -84.4%
Q4 15.0ms (11.0ms) +36.4% 4.0ms (11.0ms) -63.6% 3.0ms (23.0ms) -87.0%
Q5 68.0ms (51.0ms) +33.3% 46.5ms (51.0ms) -8.8% 4.0ms (26.5ms) -84.9%
Q6 222.0ms (108.0ms) +105.6% 87.5ms (99.0ms) -11.6% 34.5ms (79.5ms) -56.6%
Q7 1.0ms (1.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q8 9.0ms (5.0ms) +80.0% 7.0ms (5.0ms) +40.0% 9.5ms (6.0ms) +58.3%
Q9 117.0ms (81.0ms) +44.4% 92.5ms (82.5ms) +12.1% 9.0ms (43.0ms) -79.1%
Q10 117.0ms (94.0ms) +24.5% 89.0ms (87.0ms) +2.3% 8.5ms (62.5ms) -86.4%
Q11 57.0ms (27.0ms) +111.1% 23.5ms (25.0ms) -6.0% 48.5ms (36.0ms) +34.7%
Q12 52.0ms (28.0ms) +85.7% 26.0ms (28.5ms) -8.8% 45.0ms (42.5ms) +5.9%
Q13 257.0ms (101.0ms) +154.5% 110.5ms (104.0ms) +6.2% 62.0ms (79.5ms) -22.0%
Q14 600.0ms (132.0ms) +354.5% 150.0ms (132.5ms) +13.2% 80.0ms (105.5ms) -24.2%
Q15 329.0ms (100.0ms) +229.0% 114.0ms (101.5ms) +12.3% 75.0ms (94.5ms) -20.6%
Q16 136.0ms (99.0ms) +37.4% 119.5ms (107.0ms) +11.7% 8.0ms (25.0ms) -68.0%
Q17 661.0ms (212.0ms) +211.8% 262.0ms (207.0ms) +26.6% 86.5ms (106.5ms) -18.8%
Q18 619.0ms (207.0ms) +199.0% 285.0ms (206.0ms) +38.3% 97.5ms (105.0ms) -7.1%
Q19 908.0ms (359.0ms) +152.9% 358.5ms (387.5ms) -7.5% 93.5ms (151.5ms) -38.3%
Q20 14.0ms (13.0ms) +7.7% 3.5ms (10.5ms) -66.7% 8.5ms (24.5ms) -65.3%
Q21 561.0ms (171.0ms) +228.1% 475.5ms (172.5ms) +175.7% 304.0ms (266.0ms) +14.3%
Q22 882.0ms (158.0ms) +458.2% 595.5ms (162.5ms) +266.5% 173.0ms (335.5ms) -48.4%
Q23 1.87s (447.0ms) +317.9% 1.67s (463.0ms) +261.2% 568.5ms (734.5ms) -22.6%
Q24 25.31s (871.0ms) +2806.2% 959.5ms (906.5ms) +5.8% 588.0ms (2.50s) -76.5%
Q25 350.0ms (75.0ms) +366.7% 16.0ms (61.5ms) -74.0% 42.0ms (121.5ms) -65.4%
Q26 125.0ms (47.0ms) +166.0% 20.5ms (46.5ms) -55.9% 55.5ms (84.0ms) -33.9%
Q27 279.0ms (62.0ms) +350.0% 26.0ms (58.5ms) -55.6% 72.0ms (118.5ms) -39.2%
Q28 805.0ms (211.0ms) +281.5% 935.0ms (209.0ms) +347.4% 208.5ms (268.5ms) -22.3%
Q29 1.41s (976.0ms) +44.3% 954.5ms (972.5ms) -1.9% 384.5ms (343.0ms) +12.1%
Q30 29.0ms (29.0ms) +0.0% 21.0ms (28.5ms) -26.3% 6.0ms (21.5ms) -72.1%
Q31 643.0ms (103.0ms) +524.3% 75.5ms (103.5ms) -27.1% 72.5ms (144.0ms) -49.7%
Q32 1.05s (102.0ms) +932.4% 137.5ms (97.5ms) +41.0% 112.0ms (145.0ms) -22.8%
Q33 318.0ms (316.0ms) +0.6% 289.5ms (303.0ms) -4.5% 8.0ms (71.0ms) -88.7%
Q34 807.0ms (385.0ms) +109.6% 463.0ms (391.5ms) +18.3% 134.0ms (270.5ms) -50.5%
Q35 816.0ms (376.0ms) +117.0% 456.5ms (395.5ms) +15.4% 131.5ms (277.0ms) -52.5%
Q36 97.0ms (94.0ms) +3.2% 85.5ms (96.0ms) -10.9% 4.0ms (24.5ms) -83.7%
Q37 308.0ms (94.0ms) +227.7% 87.0ms (95.0ms) -8.4% 29.5ms (69.0ms) -57.2%
Q38 78.0ms (42.0ms) +85.7% 30.0ms (41.5ms) -27.7% 20.0ms (22.0ms) -9.1%
Q39 281.0ms (46.0ms) +510.9% 31.5ms (46.5ms) -32.3% 18.0ms (70.0ms) -74.3%
Q40 681.0ms (179.0ms) +280.4% 226.5ms (177.5ms) +27.6% 61.0ms (122.0ms) -50.0%
Q41 21.0ms (18.0ms) +16.7% 11.0ms (18.0ms) -38.9% 6.5ms (16.0ms) -59.4%
Q42 23.0ms (17.0ms) +35.3% 9.5ms (18.5ms) -48.6% 6.5ms (16.0ms) -59.4%
Q43 19.0ms (15.0ms) +26.7% 10.0ms (16.5ms) -39.4% 7.0ms (11.0ms) -36.4%

⚠️ LiquidCache is slower on 11 queries (warm)

  • Q28: warm +347.4% (935.0ms vs 209.0ms)
  • Q22: warm +266.5% (595.5ms vs 162.5ms)
  • Q23: warm +261.2% (1.67s vs 463.0ms)
  • Q21: warm +175.7% (475.5ms vs 172.5ms)
  • Q32: warm +41.0% (137.5ms vs 97.5ms)
  • Q8: warm +40.0% (7.0ms vs 5.0ms)
  • Q18: warm +38.3% (285.0ms vs 206.0ms)
  • Q40: warm +27.6% (226.5ms vs 177.5ms)
  • Q17: warm +26.6% (262.0ms vs 207.0ms)
  • Q34: warm +18.3% (463.0ms vs 391.5ms)
  • Q35: warm +15.4% (456.5ms vs 395.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.

…cided

A store key is `(entry id, identity)` and an identity comes back: the
file-id pool hands a re-opened path its previous record so its cached
entries stay readable. `settle` decides to delete such a key and does it
behind an await, and in between the key can be occupied again — by that
same identity's next spill, or by the identity itself returning. The
delete then destroyed a live record's bytes and left it pointing at
nothing.

`reclaim_orphaned_disk` now asks the index whether a live record names
the object before deleting, as late as it can, and releases the byte
count either way: an object left standing is one a newer record
overwrote, and that record reserved its own bytes for it.
`remove_disk_entry` goes through the same reclaim, since the gap between
its removal and its delete is the same gap.

That narrows the window rather than closing it. A put that has landed
but whose record is not yet installed is invisible to the check. Closing
it needs the store key to name the write and not just the writer, which
means a generation in the key — and with one, a disk write never lands
on the object it displaces, so there is no in-place overwrite left to
keep. That is a larger change than this, and it is written up rather
than made here.

`ArtIndex::remove_checked` is closed outright. It tested the identity,
removed, then put back what it found if the test had gone stale — and
the restore was an unconditional insert, so a writer that took the key
over in between lost its record while `entry_count` went on counting it.
Deciding and removing are now one `compute_if_present`, so a key that
changed hands is left alone instead.

The verdict is recomputed on every run of that closure rather than
latched on the first. `compute_if_present` decides under an optimistic
read and upgrades to a write afterwards, so a removal whose upgrade
loses runs the closure again — against whatever the key holds by then.
A first run that finds the record ours followed by a retry that finds it
someone else's acts on the retry, and must report the mismatch: latching
the first verdict would decrement `entry_count` for a record still in
the tree and hand the caller the new owner's entry to release.

Tests:

- `a_settlement_does_not_delete_an_object_written_after_it_was_decided`
  stages the A-B-A order: identity 7 spills, 9 takes the key over, 7
  comes back and spills again, and only then does the settlement 9
  displaced run. Without the check the settlement deletes 7's new
  object and the read misses.
- `checked_removal_against_takeovers` takes the identities that race the
  removal, so the three tests over it state only what they vary. It runs
  as one round under threads, as every interleaving under shuttle, and
  as `a_checked_removal_that_retries_reports_the_verdict_it_acted_on`:
  more writers and enough rounds to reach a removal whose upgrade loses,
  which one round reaches too rarely to rely on. Against a latched
  verdict the last fails every run while the single round passes.
- `an_in_place_disk_overwrite_releases_the_copy_it_supersedes` could not
  fail. It asserted through `insert`, which reached the branch it names
  only as a consequence of budget arithmetic, and the whole crate passed
  with the superseded handling deleted. It now writes out the two steps
  a spill performs — bytes to the store, then the record — and asserts
  the branch was taken. Its memory budget goes back to an ordinary one,
  since it no longer needs to provoke a particular eviction.
- `only_a_same_identity_disk_write_lands_on_the_object_it_displaces`
  covers `DiskResidue::displacing` directly, for each combination of
  written form and displaced form.
@anoop-narang
anoop-narang merged commit ab0c334 into main Sep 24, 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