Materialization claim needs an identity, not just CLEAN (#92) - #93
Merged
Conversation
`write_materialized` gated its commit on `state == CLEAN`. CLEAN is not an identity: across a slow S3 fetch the claimed block can be stolen (CLEAN->DIRTY), uploaded and evicted by a flush (->SYNCING->NOT_PRESENT), and then re-claimed by a LATER writer — arriving back at CLEAN with someone else's write pending. The check passes on that foreign claim and pwrites the fetched pre-image over data that was already acknowledged AND uploaded. The re-claim does not come through `try_claim_block` (that one linearizes through the promote claim and cannot run while a materialization is live). It comes from `cache.pre_write` -> `set_present`: the ublk ZC path marks its blocks present and only lands the guest bytes afterwards, via the kernel WRITE_FIXED, so it cannot hold the claim across the transition. Nor does the per-block `lock_write_range` cover it — that is taken by `BlockHandler::write` only, not by `pre_write`/`zc_prepare_write`, `trim` or `write_zeroes`. Give every claim a `ClaimToken`: - `PromoteClaimBitmap` becomes a sparse claim registry (`HashMap<usize, ClaimSlot>`, same one-entry-per-in-flight-claim cost as the `HashSet` it replaces) carrying a monotonic token plus a valid bit. - `CacheInner::set_present` — the one NOT_PRESENT->CLEAN that bypasses the claim — invalidates the live claim BEFORE flipping the state, so a holder that reads CLEAN and then finds its token valid is guaranteed to be looking at its own claim. - `write_materialized` takes the `MaterializationClaim` instead of a bare block index and checks state + token in one critical section (`if_valid` holds the claim map across the pwrite; the data_file WRITE lock is already held there, which excludes everything else the map guards). - `MaterializationClaim::unclaim` is claim-checked too: an aborting materializer must not knock a later writer's CLEAN back to NOT_PRESENT and strand its in-flight data write. Steady state is unchanged: `set_present` early-returns on `is_present` before touching the claim map, so an overwrite of an already-present block never takes the mutex. Only a genuine NOT_PRESENT->CLEAN pays a lock, on a block that is about to do an S3 GET. `bench workloads` random + sequential writes show no measurable difference (all deltas within run-to-run noise). Regression test `write_materialized_aborts_when_block_was_reclaimed` walks the full cycle (claim -> steal -> flush -> evict -> re-claim -> guest bytes land) and asserts the stale pre-image does not land; it fails without the invalidation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #92.
The bug is real
write_materializedgated its commit onstate == CLEAN. CLEAN is not an identity. Across a slow S3 fetch the claimed block can cycle:The check passes on that foreign claim, and M pwrites its fetched pre-image over data that was already acknowledged and uploaded to S3. Silent rollback of a durable write.
Where the re-claim comes from
Not from
try_claim_block— that linearizes through the promote claim and cannot run while a materialization is live. It comes fromcache.pre_write→CacheInner::set_present: the ublk ZC path marks its blocks present and only lands the guest bytes afterwards, via the kernelWRITE_FIXED, so it cannot hold the claim across the transition.The per-block
lock_write_rangedoes not cover this either — it is taken byBlockHandler::writeonly, not bypre_write/zc_prepare_write,trim, orwrite_zeroes. So the "already serialized by the write lock" reasoning holds for USER_COPY and not for the ZC path.The fix: give every claim a
ClaimTokenPromoteClaimBitmapbecomes a sparse claim registry (HashMap<usize, ClaimSlot>— same one-entry-per-in-flight-claim cost as theHashSetit replaces) carrying a monotonic token plus a valid bit.CacheInner::set_present— the oneNOT_PRESENT → CLEANthat bypasses the claim — invalidates the live claim before flipping the state, so a holder that reads CLEAN and then finds its token valid is guaranteed to be looking at its own claim.write_materializedtakes theMaterializationClaiminstead of a bare block index, and checks state + token in one critical section (if_validholds the claim map across the pwrite; thedata_fileWRITE lock is already held there, which excludes everything else the map guards).MaterializationClaim::unclaimis claim-checked too: an aborting materializer must not knock a later writer's CLEAN back toNOT_PRESENTand strand its in-flight data write.Cost
Steady state is unchanged:
set_presentearly-returns onis_presentbefore touching the claim map, so an overwrite of an already-present block never takes the mutex. Only a genuineNOT_PRESENT → CLEANpays a lock — on a block that is about to do an S3 GET.cargo bench --bench workloads(random 4/16/64 KB + sequential 1/10 MB), with vs. without the change: every delta inside run-to-run noise (p > 0.05 on 4 of 5; the one "significant" result had the without-fix run slower).Verification
write_materialized_aborts_when_block_was_reclaimedwalks the full cycle (claim → steal → flush → evict → re-claim → guest bytes land) and asserts the stale pre-image does not land. It fails without the invalidation.fio_verify_random_cold_wakethrough a real ublk device: CRC32C verification passed.stateright-model(17 tests) still green — it is a standalone crate. Note it cannot reach this bug:faithful.rscollapses the claim-first materialization into one atomic step reading the lives3[b]("nothing can change it while CLEAN" — the exact assumption the ABA violates), and has no separate "ZCpre_writemarks present, data lands later" transition. Extending the model is a follow-up.🤖 Generated with Claude Code