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