Skip to content

fix(cache): re-check ownership after the disk write, not only before - #49

Merged
anoop-narang merged 2 commits into
mainfrom
fix/stale-rewrite-record-race
Sep 17, 2026
Merged

anoop-narang merged 2 commits into
mainfrom
fix/stale-rewrite-record-race

Conversation

@anoop-narang

Copy link
Copy Markdown
Collaborator

Follow-up to #48. A reviewer on the consuming repo found this in the merged code.

The race

write_batch_to_disk checks that a Rewrite still owns the key, then awaits store.put. A takeover landing during that await leaves the rewrite still reaching the record swap below, where it replaces the current owner's DiskCopy and deletes the object that record names:

  1. The index says A owns key K. A begins a rewrite and passes the check.
  2. A awaits the write and yields.
  3. B takes the key over, writes its own object, installs its record.
  4. A resumes, swaps in its record, sees previous.identity != identity, and deletes B's object.
  5. B's index entry now points at a deleted object — the next read panics in read_disk_arrow_array.

The comment on that branch claimed a stale writer never reaches it. That was the wrong assumption rather than a wrong line: the check is before the await, the swap is after.

This is only reachable because the identity went into the store key in #48 — before that, a put overwrote the shared object rather than writing beside it, so there was no other owner's object to delete.

The fix

Ask the index again after the write. The index, not the record: the record may still name a previous owner this writer is legitimately superseding, while the index names whoever the bytes can actually be read by. A rewrite that is no longer that owner removes the object it just wrote and hands back its reservation, leaving the current owner's record and object alone.

I first guarded on the record instead, which broke the legitimate case — flush_all_to_disk writes as a Rewrite, so the current owner flushing over a previous owner's stale record was turned away. taking_a_key_over_removes_the_previous_owner_s_object caught it.

What this does not do

It narrows the window, it does not close it. The index read and the record swap are still not atomic. Closing it needs both under one lock, which is a larger change and has to reason about lock ordering against the ART — the cache does not currently serialise any check-then-act pair, as the note above supersede_disk_copy says.

There is no test for it, and I want to be explicit about that. Two attempts, both discarded:

  • The first passed the mutation check — it never reached the new code, because the pre-await check turned it away first.
  • The second built the state directly (index naming the rewriter, record naming the new owner) and asserted on it. That state is unreachable: a takeover moves the index too, not only the record. A test asserting an unreachable state is worse than none, so it is gone.

Reproducing the real interleaving needs the rewrite paused mid-await, which this suite has no way to do. Shuttle is the right tool — it is already wired here and already covers the file-id pool — and I would rather see the whole check-then-act class model-checked than keep patching instances. That is a bigger piece of work than this fix and I have not attempted it.

So: this is reasoned, not test-proven, which is weaker than the rest of #48. Judge it on that basis.

Existing suite is green — fmt, clippy -D warnings, 23 test targets, and the shuttle job's own command.

The ownership check ran before `store.put().await`, so a takeover landing
during the write left a rewrite still reaching the record swap — where it
replaced the current owner's record and deleted the object that record
named, leaving that owner's index entry pointing at nothing. The comment
there claimed a stale writer never reaches it, which was the wrong
assumption rather than a wrong line.

Ask the index again after the write. The index, not the record: the
record may still name a previous owner this writer is legitimately
superseding, while the index names whoever the bytes can be read by. A
rewrite that is no longer that owner removes the object it just wrote and
hands back its reservation instead.

This narrows the window rather than closing it — the index read and the
record swap are still not atomic, as nothing else in the cache is
either (see the note above `supersede_disk_copy`). Closing it needs the
two under one lock, which is a larger change than this fix.
Comment thread src/core/src/cache/core.rs
Comment thread src/core/src/cache/core.rs Outdated
Comment thread src/core/src/cache/core.rs Outdated
Comment thread src/core/src/cache/core.rs
claude[bot]
claude Bot previously approved these changes Sep 17, 2026

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

Approved with nits. The post-write index re-check is a strict improvement: a stale rewrite no longer reaches the record swap that deletes the current owner's object. The remaining window and the missing test are stated in the PR description, so both are accepted as known. Nits cover a duplicated comment, a nested Option<Option<_>>, an unclaimed store.remove, and one blank line.

@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: 0d8f95a3 (Liquid) vs Baseline: 0d8f95a3 (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 8.0ms (5.0ms) +60.0% 3.5ms (4.5ms) -22.2% 7.0ms (6.0ms) +16.7%
Q3 14.0ms (13.0ms) +7.7% 12.0ms (11.5ms) +4.3% 23.2ms (23.8ms) -2.1%
Q4 16.0ms (11.0ms) +45.5% 3.8ms (10.0ms) -62.5% 1.8ms (23.5ms) -92.6%
Q5 50.0ms (49.0ms) +2.0% 39.8ms (50.0ms) -20.5% 3.0ms (25.0ms) -88.0%
Q6 131.0ms (103.0ms) +27.2% 103.2ms (98.8ms) +4.6% 100.8ms (78.2ms) +28.8%
Q7 1.0ms (1.0ms) +0.0% 0.000ms (1.0ms) -100.0% 0.000ms (0.000ms) +0.0%
Q8 7.0ms (6.0ms) +16.7% 4.2ms (5.0ms) -15.0% 6.8ms (6.0ms) +12.5%
Q9 90.0ms (83.0ms) +8.4% 67.0ms (79.0ms) -15.2% 4.0ms (42.2ms) -90.5%
Q10 89.0ms (87.0ms) +2.3% 64.2ms (84.2ms) -23.7% 5.0ms (60.8ms) -91.8%
Q11 40.0ms (31.0ms) +29.0% 18.5ms (24.2ms) -23.7% 32.2ms (35.0ms) -7.9%
Q12 44.0ms (27.0ms) +63.0% 19.0ms (29.8ms) -36.1% 32.8ms (44.0ms) -25.6%
Q13 143.0ms (108.0ms) +32.4% 178.2ms (103.0ms) +73.1% 242.2ms (80.0ms) +202.8%
Q14 264.0ms (129.0ms) +104.7% 187.2ms (135.0ms) +38.7% 189.2ms (105.0ms) +80.2%
Q15 158.0ms (94.0ms) +68.1% 153.2ms (97.5ms) +57.2% 240.2ms (93.0ms) +158.3%
Q16 101.0ms (98.0ms) +3.1% 87.8ms (100.0ms) -12.2% 3.0ms (26.0ms) -88.5%
Q17 405.0ms (207.0ms) +95.7% 222.8ms (205.8ms) +8.3% 89.2ms (104.0ms) -14.2%
Q18 381.0ms (200.0ms) +90.5% 222.5ms (204.8ms) +8.7% 90.5ms (104.5ms) -13.4%
Q19 547.0ms (364.0ms) +50.3% 403.5ms (384.8ms) +4.9% 112.0ms (147.2ms) -23.9%
Q20 16.0ms (11.0ms) +45.5% 4.2ms (11.2ms) -62.2% 7.0ms (23.5ms) -70.2%
Q21 927.0ms (166.0ms) +458.4% 267.8ms (171.2ms) +56.4% 578.0ms (274.5ms) +110.6%
Q22 1.03s (166.0ms) +522.3% 317.8ms (160.2ms) +98.3% 178.8ms (331.8ms) -46.1%
Q23 2.38s (437.0ms) +444.2% 1.03s (444.8ms) +132.2% 498.8ms (719.5ms) -30.7%
Q24 15.74s (855.0ms) +1741.3% 829.8ms (878.5ms) -5.5% 620.8ms (2.43s) -74.5%
Q25 157.0ms (68.0ms) +130.9% 14.8ms (56.8ms) -74.0% 36.0ms (113.5ms) -68.3%
Q26 90.0ms (46.0ms) +95.7% 20.2ms (46.8ms) -56.7% 51.0ms (83.0ms) -38.6%
Q27 172.0ms (60.0ms) +186.7% 25.8ms (58.2ms) -55.8% 77.5ms (117.2ms) -33.9%
Q28 756.0ms (209.0ms) +261.7% 288.2ms (211.5ms) +36.3% 350.5ms (268.0ms) +30.8%
Q29 1.52s (962.0ms) +57.7% 1.04s (965.2ms) +7.5% 573.8ms (329.5ms) +74.1%
Q30 28.0ms (25.0ms) +12.0% 21.0ms (27.0ms) -22.2% 5.2ms (20.5ms) -74.4%
Q31 246.0ms (98.0ms) +151.0% 68.8ms (96.5ms) -28.8% 45.8ms (138.0ms) -66.8%
Q32 317.0ms (92.0ms) +244.6% 107.2ms (99.0ms) +8.3% 52.0ms (144.0ms) -63.9%
Q33 309.0ms (288.0ms) +7.3% 275.2ms (286.2ms) -3.8% 7.5ms (72.5ms) -89.7%
Q34 931.0ms (374.0ms) +148.9% 468.2ms (373.8ms) +25.3% 355.0ms (265.2ms) +33.8%
Q35 916.0ms (398.0ms) +130.2% 459.5ms (385.0ms) +19.4% 355.8ms (278.0ms) +28.0%
Q36 97.0ms (95.0ms) +2.1% 81.8ms (93.5ms) -12.6% 3.8ms (23.2ms) -83.9%
Q37 297.0ms (98.0ms) +203.1% 82.5ms (91.2ms) -9.6% 47.5ms (69.2ms) -31.4%
Q38 69.0ms (41.0ms) +68.3% 27.2ms (45.0ms) -39.4% 16.5ms (25.2ms) -34.7%
Q39 282.0ms (55.0ms) +412.7% 12.0ms (45.5ms) -73.6% 10.5ms (70.2ms) -85.1%
Q40 596.0ms (182.0ms) +227.5% 216.8ms (170.2ms) +27.3% 82.2ms (123.0ms) -33.1%
Q41 27.0ms (18.0ms) +50.0% 10.8ms (18.8ms) -42.7% 6.2ms (17.5ms) -64.3%
Q42 24.0ms (19.0ms) +26.3% 9.5ms (21.5ms) -55.8% 6.5ms (16.8ms) -61.2%
Q43 18.0ms (17.0ms) +5.9% 10.8ms (16.0ms) -32.8% 6.5ms (11.2ms) -42.2%

⚠️ LiquidCache is slower on 17 queries (warm)

  • Q23: warm +132.2% (1.03s vs 444.8ms)
  • Q22: warm +98.3% (317.8ms vs 160.2ms)
  • Q13: warm +73.1% (178.2ms vs 103.0ms)
  • Q15: warm +57.2% (153.2ms vs 97.5ms)
  • Q21: warm +56.4% (267.8ms vs 171.2ms)
  • Q14: warm +38.7% (187.2ms vs 135.0ms)
  • Q28: warm +36.3% (288.2ms vs 211.5ms)
  • Q40: warm +27.3% (216.8ms vs 170.2ms)
  • Q34: warm +25.3% (468.2ms vs 373.8ms)
  • Q35: warm +19.4% (459.5ms vs 385.0ms)
  • Q18: warm +8.7% (222.5ms vs 204.8ms)
  • Q32: warm +8.3% (107.2ms vs 99.0ms)
  • Q17: warm +8.3% (222.8ms vs 205.8ms)
  • Q29: warm +7.5% (1.04s vs 965.2ms)
  • Q19: warm +4.9% (403.5ms vs 384.8ms)
  • Q6: warm +4.6% (103.2ms vs 98.8ms)
  • Q3: warm +4.3% (12.0ms vs 11.5ms)

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

Drop the duplicated comment, early-return instead of nesting the record
swap in an Option, and note that the removal on this path holds no claim
on the key.

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

All four prior nits are addressed in 70c4c94. No new findings.

The stale path leaves the previous DiskCopy record in place while deleting the object it names, but the index no longer names that identity, and every reader filters records by the index identity (disk_copy at src/core/src/cache/core.rs:537), so no read path reaches the deleted object. The reclaim paths only remove and release, which Ok(false) covers.

CI was still queued or in progress at review time, so no claim is made here about test results.

@anoop-narang
anoop-narang merged commit d565081 into main Sep 17, 2026
14 checks passed
anoop-narang added a commit that referenced this pull request Sep 23, 2026
Found by review of the file-id commit; three defects, one cause.

Scoping store keys by identity means an object stops being reachable
through the index the moment its key changes hands — `release_disk` is
driven by an index entry, and by then no index entry names it. Under the
shared key this port replaced, the next write simply overwrote the same
object, so none of this had to be handled and there was nothing to copy.

Three paths stranded bytes and objects for the life of the process:

A rewrite dropped as stale had already written to the store. `try_insert`
refunded its memory and returned Ok, and the bytes stayed charged with no
entry naming them. It now reports what it left behind and each caller —
evict, flush, insert — deletes the object and releases the reservation.

An `Owned` takeover replaced a disk-resident entry and dropped it. The
index now hands the displaced entry back with the identity that held it,
for the same treatment. Only across identities: a write under the
identity that already held the key addresses the same object and its put
overwrote it, so reclaiming there would delete the bytes just written —
which the policy snapshots caught.

`remove_disk_entry` took an identity to address the object but removed
the index record unchecked, so a caller holding a stale identity deleted
the current owner's record while deleting the old identity's object.
That is the window fork #49 closed on the write path, left open on the
removal path. Removal is identity-checked now, and re-checked after the
tree removal rather than only before.

Two tests cover the reclamation and the refused removal. Disabling the
reclamation strands 968 bytes in the first of them.
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