craft: S2 write path — HS_DATA_LINKED journal append with full correctness hardening#171
craft: S2 write path — HS_DATA_LINKED journal append with full correctness hardening#171shosseinimotlagh wants to merge 7 commits into
Conversation
- CraftJournalBackend::write_slot: replace sisl::sg_list with
homestore::multi_blk_id blkid + bool all_zeros (HS_DATA_LINKED scheme;
payload never enters the journal buffer)
- CraftReplDev::write: add bool all_zeros param; empty-verdict rejection
(EMPTY_SLOT) checked under missing_mu_ before pre-insert; stubbed blkid{}
passed to write_slot (real block allocation wired in commit 2)
- volume_error: add EMPTY_SLOT enum value
- Tests: MockCraftJournalBackend updated to new signature in all three test
TUs; three new cases: AllZerosWrite, WriteSlotFails_LsnRemainsInMissing,
EmptySlotRejectsWrite
…nconsistency Three correctness fixes found by adversarial review of the S2 write path: 1. Move state_.term check inside missing_mu_ lock. state_.term is mutated by apply_internal_login (S5) under the same mutex; reading it outside the lock is a data race (UB) once S5 lands. 2. Reject dlsn < 0 before acquiring any lock. A negative dlsn (initial last_append_lsn=-1 edge case) could bypass the pre-insert condition and call write_slot unguarded, violating the invariant that every written dlsn is pre-inserted into missing_lsns_. 3. Cap out-of-order gap at k_max_ooo_gap (1 000 000). A single write with a large dlsn caused O(gap) synchronous allocations into missing_lsns_ under the mutex — an OOM / latency-spike risk. One additional fix: seed_empty (and future apply_sync_rs_commit_lsn) must also erase the verdicted LSNs from missing_lsns_. An LSN already present in missing_lsns_ when the Empty verdict fires would stay there forever, permanently stalling commit advancement. Tests added: EmptyVerdictClearsMissingEntry, NegativeDlsnRejected, ExcessiveGapRejected, OutOfOrderWritesLargerGap.
… filter, post-flight term check Four bugs found by adversarial review; all fixed and test-covered. B1 — signed overflow in gap cap check: dlsn near INT64_MAX caused dlsn - last_append_lsn to wrap, silently bypassing the cap and looping ~2^63 times → OOM. Fix: Guard 1 rejects dlsn > INT64_MAX - k_max_ooo_gap before the subtraction in Guard 2. M1 — duplicate write bypasses pre-insert invariant: after dlsn=N succeeds and is erased from missing_lsns_, a retry evaluated (N>N)=false && !contains(N)=true → no pre-insert → write_slot called with N absent from missing. Fix: return current snapshot idempotently when dlsn ≤ last_append_lsn and not in missing_lsns_. M2 — gap loop re-introduces Empty-verdicted LSNs: the fill loop inserted all gaps unconditionally, undoing the Empty verdict and stalling commit advancement. Fix: skip lsns in empty_lsns_ inside the loop body. M3 — ghost entry after login-truncate race: write_slot completing after a new login+truncate produced a journal entry that survived past the truncation point. Fix: re-validate hdr.term in the second lock region and discard the result with STALE_TERM on mismatch. Also: fix seed_empty() header comment (said "does not affect missing_lsns_", which is now wrong); add tests for all four fixes including gap-cap fencepost from non-initial state and Guard 1 at INT64_MAX.
…document known risks Two block-leak bugs fixed, three known risks documented, and a full subtask coverage review against SDSTOR-22732. ── Fixes ──────────────────────────────────────────────────────────────── Finding 1 — block leak when write_slot fails After alloc_write_data succeeds, if write_slot subsequently returns an error the allocated blocks were silently dropped with no free path. Fix: after a write_slot failure, call journal_->free_data(blkid) before propagating the error. A free failure is logged but non-fatal. Finding 2 — block leak on post-flight stale-term discard After write_slot succeeds the code rechecks the term under missing_mu_. If the term changed it returned STALE_TERM — but this path had no free call, and you cannot co_await while holding a std::lock_guard (undefined behaviour / potential deadlock). Fix: introduce a stale_post_flight bool, capture the check result under the lock, release the lock, then call free_data without holding missing_mu_. Implementation: added CraftJournalBackend::free_data() pure virtual method (backed by homestore::data_service().async_free_blk in production; stub co_return ok() in all three test mocks). ── Known risks documented (inline comments in craft_repl_dev.cpp) ────── Finding 3 — shutdown correctness: write_async skips the callback when HomeStore is stopping (log_store.cpp:71 returns 0), leaving the LogstoreWriteAwaitable coroutine permanently suspended until process exit. This is a design decision that requires a drain protocol: all in-flight writes must complete before HomeStore shutdown begins. Owners of the shutdown path need to define that protocol explicitly. Finding 4 — silent I/O error loss: write_async fires its callback with the same signature for both success and I/O failure, with no status argument. await_resume cannot distinguish them, so write_slot always returns ok() even when the underlying I/O failed. Fixing this requires a HomeStore API extension. A HomeStore expert should confirm whether write_async can actually call back on I/O error or always terminates the process — if the latter, this risk does not exist in practice. Finding 5 — all_zeros=false with empty data produces a malformed journal entry (all_zeros=0, default-constructed blkid). The client wire rejects this combination, but internal callers and test stubs that pass an empty sg_list without setting all_zeros=true will trigger it. This is a gap to close at a convenient time; a runtime assertion or static_assert on the pre-condition would prevent it. ── Subtask coverage review (SDSTOR-22732) ────────────────────────────── | Ticket | Summary | Status in S2 code | |------------|--------------------------------------|-------------------------------| | SDSTOR-22869 | Term validation before journal append | Done — pre-flight check under missing_mu_ | | SDSTOR-22870 | HS_DATA_LINKED journal append | Done — alloc_write_data + write_slot | | SDSTOR-22871 | Advance last_append_lsn on success | Done — see note below | | SDSTOR-22872 | Out-of-order slot arrivals | Done — missing_lsns_ overlay | | SDSTOR-22873 | Zero-copy data path | Done — sg_list passed through | | SDSTOR-22874 | Quorum-ack; no LBA index update | Done — server ACKs after local journal commit; no LBA index touched | | SDSTOR-22904 | all_zeros WRITE_ZEROES path | Done — skips alloc, journals metadata-only slot | Flags for reviewers: SDSTOR-22870 note — the ticket description states "a crash between the data-service write and journal append leaves only uncommitted blocks, which HomeStore recovery reclaims automatically." If this auto-reclaim is real, the free_data calls added here are defensive but not strictly necessary for crash-safety. If it is aspirational or incorrect, they are the only reclaim path. This should be confirmed with the HomeStore team before closing SDSTOR-22870. SDSTOR-22871 note — the ticket says "after a successful append". This implementation advances last_append_lsn BEFORE write_slot (in the pre-insert block) and does NOT roll it back on failure. The failing dlsn stays in missing_lsns_ so the gap is tracked correctly. This is an intentional design choice; reviewers should confirm it is acceptable or request a post-success update with rollback on failure. S3 design note — when apply_internal_login is implemented, it must update state_.term under missing_mu_ to avoid a data race with write()'s pre-flight and post-flight term checks.
e753bee to
395fd40
Compare
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev/v6.x #171 +/- ##
===========================================
Coverage ? 44.62%
===========================================
Files ? 18
Lines ? 1033
Branches ? 449
===========================================
Hits ? 461
Misses ? 281
Partials ? 291 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Implements the CRAFT S2 write path for HomeBlocks replicas using an HS_DATA_LINKED scheme (payload written via HomeStore data service, journal stores metadata + block reference), adds correctness hardening around term/idempotency/gap handling, and extends unit tests to cover key write-path behaviors.
Changes:
- Introduces HS_DATA_LINKED journaling by splitting payload handling into
alloc_write_data(...)+ metadata-onlywrite_slot(...), plusfree_data(...)for cleanup on failures/discards. - Hardens
CraftReplDev::write()with term fencing under mutex, idempotent retry handling, out-of-order gap capping, and Empty-slot rejection semantics. - Expands CRAFT unit tests for all_zeros writes, missing-set invariants, Empty-slot interactions, and gap-cap/overflow guards; bumps package version and test dependency.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/craft/tests/test_craft_write.cpp | Adds write-path tests and updates the journal mock for the new backend interface. |
| src/lib/craft/tests/test_craft_truncate.cpp | Updates truncate tests’ journal mock to satisfy the new backend interface. |
| src/lib/craft/tests/test_craft_peer_exchange.cpp | Updates peer-exchange tests’ journal mock to satisfy the new backend interface. |
| src/lib/craft/craft_repl_dev.hpp | Updates the journal backend abstraction and CraftReplDev::write() signature to support HS_DATA_LINKED + all_zeros. |
| src/lib/craft/craft_repl_dev.cpp | Implements HS_DATA_LINKED write_slot for the HomeStore backend and wires the S2 write path logic in CraftReplDev::write(). |
| src/include/homeblks/home_blocks.hpp | Adds volume_error::EMPTY_SLOT for Empty-verdicted slot rejection. |
| conanfile.py | Bumps HomeBlocks version and updates a test dependency constraint. |
Comments suppressed due to low confidence (2)
src/lib/craft/craft_repl_dev.cpp:269
- Rejecting an out-of-range dLSN is input validation; consider returning
std::errc::invalid_argument(or another appropriatestd::errc) instead ofvolume_error::INTERNAL_ERROR, per the error-surface guidance in home_blocks.hpp.
if (dlsn > INT64_MAX - k_max_ooo_gap) {
LOGW("write rejected: dlsn={} exceeds safe LSN range", dlsn);
co_return std::unexpected(make_error_condition(volume_error::INTERNAL_ERROR));
}
src/lib/craft/craft_repl_dev.cpp:274
- Rejecting a write that exceeds the out-of-order gap cap is input validation; consider returning
std::errc::invalid_argument(orstd::errc::value_too_large) instead ofvolume_error::INTERNAL_ERROR, per the error-surface guidance in home_blocks.hpp.
if (dlsn - state_.last_append_lsn > k_max_ooo_gap) {
LOGW("write rejected: dlsn={} too far ahead of last_append_lsn={}", dlsn, state_.last_append_lsn);
co_return std::unexpected(make_error_condition(volume_error::INTERNAL_ERROR));
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…c path tests
Comment 2 (lines 246, 266, 271): wrong error code for input validation.
home_blocks.hpp lines 68-70 document that anything with a standard
equivalent is returned as std::make_error_condition(std::errc::*) rather
than a volume_error. Three sites were returning INTERNAL_ERROR for
rejected client input, which makes it impossible for a caller to
distinguish "bad request" from "server internal fault":
- dlsn < 0 → std::errc::invalid_argument
- dlsn > INT64_MAX-cap → std::errc::invalid_argument
- dlsn - last > cap → std::errc::value_too_large
Comment 3 (test line 87): alloc_write_data path not exercised.
do_write() passes an empty sg_list (data.size==0), so the production
guard (!all_zeros && data.size > 0) always skips allocation. This
meant blkid_allocated was always false and the Finding 1 fix
(free_data after write_slot failure) was untested. Changes:
- MockCraftJournalBackend: add fail_alloc flag and free_data_calls counter
- Add do_write_with_data() helper (data.size=4096, all_zeros=false)
- Add NonZeroWriteCallsAllocWriteData: verifies alloc_write_data called once
- Add AllocWriteDataFails_WriteRejected: alloc failure, no write_slot call,
no free_data call (nothing was allocated), lsn stays in missing set
- Add WriteSlotFailsWithData_BlocksFreed: verifies free_data_calls==1
when write_slot fails after a successful alloc (Finding 1 fix coverage)
Comment 1 (line 302): identified Finding 5 (all_zeros=false + empty data
produces malformed journal entry). This gap was pre-documented in the
prior commit. The suggested fix (auto-derive all_zeros from data.size==0)
is not applied — it would silently coerce protocol violations into zero
writes. The correct closure is a precondition assertion at write() entry,
deferred to a follow-up.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/lib/craft/tests/test_craft_write.cpp:92
do_write()passes an emptysg_listbut defaultsall_zerostofalse, which exercises the explicitly documented malformed-case (all_zeros=0 + empty data + default blkid). Since this helper is the default path for most tests, it’s better to defaultall_zerostotrue(or make callers choose explicitly) so tests don’t encode a caller-protocol violation.
auto do_write(uint64_t term, int64_t lsn, bool all_zeros = false) {
return homeblocks::detail::sync_get(
dev_->write(craft::client_hdr{term, -1, -1}, lsn, 0, 4096, sisl::sg_list{}, all_zeros));
…, include Comment 1 (craft_repl_dev.hpp): stale docstring said 'An EMPTY data is a zero write' — predated the all_zeros flag. Replaced with the correct semantics: all_zeros=true is the zero-write signal; all_zeros=false requires non-empty data. Also promotes Finding 5 from a comment to an enforced precondition. craft_repl_dev.cpp: add RELEASE_ASSERT(all_zeros || data.size > 0) after the dlsn guard. This is the Finding 5 gap called out in the commit message; it catches all_zeros=false with empty data at the call site rather than silently journalling a zero blkid that replay cannot resolve. tests/test_craft_write.cpp: do_write() helper defaulted all_zeros=false with an empty sg_list, which now violates the precondition. Changed default to all_zeros=true (all state-management tests use do_write() to exercise missing-set, gap-loop, and term-check logic — not the data path — so treating them as zero-writes is semantically correct). Added #include <set> (Comment 3: std::set used by fail_lsns but the TU relied on transitive inclusion from craft_repl_dev.hpp). Comment 2 (craft_repl_dev.cpp line 302) — no change: the bytes-vs-LBAs concern is not a bug. alloc_write_data ignores the len parameter entirely (HomeStoreCraftJournalBackend marks it /* len */ and derives size from the sg_list). CraftJournalEntry stores raw wire byte addr/len by design: hb_internal.hpp lines 64-68 documents 'lba_t identical to the retired craft:: aliases, so an LBA meeting a byte range interops seamlessly.' The byte-to-block conversion is deferred to S3's apply_sync_rs_commit_lsn.
Add two cross-type idempotent scenarios to the existing test: - data write first, then all_zeros retry at same dLSN: retry discarded, slot type (data) preserved, alloc_write_data not called again - all_zeros write first, then data retry at same dLSN: retry discarded, slot type (zero) preserved, idempotent path skips alloc_write_data Covers the AC requirement that the original slot is immutable once written, regardless of the retry's all_zeros flag.
277bbed to
1b94959
Compare
Summary
Implements SDSTOR-22732 S2: the CRAFT write path on the HomeBlocks replica.
A client-assigned dLSN arrives, blocks are allocated via the HS_DATA_LINKED
pattern (payload written directly to data-service chunks, never into the RAFT
log), a metadata-only journal slot is appended, and the achieved watermarks
{commit_lsn, last_append_lsn} are returned to the client. Zero writes
(all_zeros=true / WRITE_ZEROES) skip block allocation entirely.
Four rounds of adversarial review were run on the implementation before this
PR was raised. Eight bugs were found and fixed; three known risks are documented
in-source for future owners.
What changed
Commit 1 — S2 write path core (
51ffc3a)CraftJournalBackend::write_slotsignature changed:sisl::sg_listreplacedby
homestore::multi_blk_id blkid + bool all_zeros(HS_DATA_LINKED scheme —the payload never enters the journal buffer; only the block reference does).
CraftReplDev::writewired: pre-flight term check, Empty-verdict rejection(
EMPTY_SLOT), pre-insert intomissing_lsns_, gap fill loop, journalappend via
write_slot, post-success state update.volume_error::EMPTY_SLOTadded.AllZerosWrite,WriteSlotFails_LsnRemainsInMissing,EmptySlotRejectsWrite.Commit 2 — round-1 hardening (
8eb1066)Three correctness bugs found by adversarial review:
state_.term— read outsidemissing_mu_is UB onceapply_internal_login(S3) lands. Fixed: term check moved inside the lock.dlsn < 0skipped the pre-insert condition andcalled
write_slotwithout an entry inmissing_lsns_. Fixed: rejectdlsn < 0before acquiring any lock.insertions into
missing_lsns_under the mutex. Fixed: cap out-of-order gapat
k_max_ooo_gap = 1 000 000.Additional fix:
seed_empty(and futureapply_sync_rs_commit_lsn) must eraseverdicted LSNs from
missing_lsns_; an Empty-verdicted LSN left in the missingset would permanently stall commit advancement.
Tests:
EmptyVerdictClearsMissingEntry,NegativeDlsnRejected,ExcessiveGapRejected,OutOfOrderWritesLargerGap.Commit 3 — round-2 hardening (
6204d41)Four more bugs found by a second adversarial review pass:
dlsnnearINT64_MAXcauseddlsn - last_append_lsnto wrap, silently bypassing the cap and looping ~2^63times → OOM. Fixed: Guard 1 rejects
dlsn > INT64_MAX - k_max_ooo_gapbeforethe subtraction.
previously-written dlsn evaluated
(N > N) = false && !contains(N) = true,skipping pre-insert and calling
write_slotwithout the dlsn inmissing_lsns_. Fixed: return the current watermark snapshot idempotentlywhen
dlsn ≤ last_append_lsnand not missing.gaps unconditionally, undoing Empty verdicts and stalling commit advancement.
Fixed: skip LSNs in
empty_lsns_inside the loop body.write_slotthat completesafter a new login+truncate produced a journal entry that survived past the
truncation point. Fixed: re-validate
hdr.termin the second lock region;discard with
STALE_TERMon mismatch.Tests:
DuplicateWriteIsIdempotent,GapLoopSkipsEmptyVerdicts,GapCapFenceposts(includingINT64_MAXGuard 1 case).Commit 4 — block-leak fixes and risk documentation (
e753bee)Two block-leak bugs fixed, three known risks documented in-source.
Finding 1 — block leak when
write_slotfailsAfter
alloc_write_datasucceeds, a subsequentwrite_slotfailure silentlydropped the allocated blocks with no free path. Fixed: call
journal_->free_data(blkid)before propagating the error.Finding 2 — block leak on post-flight stale-term discard
After
write_slotsucceeds, the code re-checks the term undermissing_mu_.If the term had changed it returned
STALE_TERM— but with no free call, andyou cannot
co_awaitwhile holding astd::lock_guard(UB / potentialdeadlock). Fixed: introduce
bool stale_post_flight, capture the check resultunder the lock, release the lock, then call
free_datawithout holdingmissing_mu_.Implementation:
CraftJournalBackend::free_data()pure virtual added (backedby
homestore::data_service().async_free_blkin production; stubco_return ok()in all three test mocks).Known risks documented (inline comments in
craft_repl_dev.cpp):write_asyncskips its callback when HomeStoreis stopping (
log_store.cpp:71), leaving theLogstoreWriteAwaitablecoroutine permanently suspended. This requires a drain protocol: all in-flight
writes must complete before HomeStore shutdown begins. Owners of the shutdown
path need to define that protocol explicitly.
write_asyncfires the same callbackfor success and I/O failure with no status argument;
write_slotalwaysreturns
ok(). Fixing this needs a HomeStore API extension. A HomeStore expertshould confirm whether
write_asynccan call back on I/O error or alwayscrashes the process — if the latter, this risk does not exist in practice.
Subtask coverage (SDSTOR-22732)
missing_mu_alloc_write_data+write_slotlast_append_lsnon successmissing_lsns_overlay + gap loopsg_listpassed through uncopiedall_zerosWRITE_ZEROES pathSDSTOR-22870 flag — the ticket states "a crash between the data-service
write and journal append leaves only uncommitted blocks, which HomeStore recovery
reclaims automatically." If that auto-reclaim is real, the
free_datacallsadded here are defensive but not strictly necessary for crash-safety. If it is
aspirational or incorrect, they are the only reclaim path. Please confirm with
added here are defensive but not strictly necessary for crash-safety. If it is
aspirational or incorrect, they are the only reclaim path. Please confirm with
the HomeStore team before closing this ticket.
SDSTOR-22871 flag — the ticket says "after a successful append." This
implementation advances
last_append_lsnbeforewrite_slot(in thepre-insert block) and does not roll it back on failure. The failing dlsn stays
in
missing_lsns_so the gap is correctly tracked. This is an intentionaldesign choice — please confirm it is acceptable or request a post-success update
with rollback on failure.
Design notes for future stories
apply_internal_login) must updatestate_.termundermissing_mu_to avoid a data race with
write()'s pre-flight and post-flight term checks.from the local replica only; quorum counting is the client's responsibility in
CRAFT and is a separate future story.
Test summary
All 6 test binaries pass (
test_craft_write,test_craft_truncate,test_craft_peer_exchange, and others). 22 unit tests cover the write pathincluding: in-order, out-of-order, large gaps, idempotent retry, term rejection,
all_zeros, Empty-verdict rejection and interaction with the gap loop, negativedlsn, overflow guard, post-flight stale-term discard, and write_slot failure
with block-free verification.