S3: Commit + Read path: advance commit_lsn, serve reads, watchdog - #175
Conversation
f101cf5 to
3f4a438
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new read/write paths assume single-iovec sg_lists but don’t fully validate/enforce buffer shape/capacity, which can lead to checksum mismatches and potential out-of-bounds writes on the wire-reachable read path.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Implements the CRAFT S3 “commit + read path” for CraftReplDev, including advancing commit_lsn by applying journal entries to the index, serving reads from an overlay+index merge (with CRC validation), and adding a client-liveness watchdog plus configuration knobs to bound request sizes.
Changes:
- Add commit/apply machinery (including
VolumeIndexTable::delete_lba_range) and overlay rebuild on restart, with read-path overlay/index resolution and CRC verification. - Introduce watchdog timer behavior and new settings (
craft_watchdog_timeout_ms,craft_max_io_len_mb) with corresponding test coverage. - Expand/adjust CRAFT API and test build wiring to support the new behaviors (including new unit/integration tests).
File summaries
| File | Description |
|---|---|
| src/lib/volume/index_fixed_table.hpp | Adds delete_lba_range to support unmap/all-zero apply via range remove and blkid reclamation. |
| src/lib/home_blks_config.fbs | Adds watchdog timeout and max I/O length configuration keys. |
| src/lib/craft/tests/test_craft_write.cpp | Updates write tests for new checksum behavior, argument changes, and max-IO enforcement. |
| src/lib/craft/tests/test_craft_watchdog.cpp | New watchdog timer test binary with minimal iomgr bring-up. |
| src/lib/craft/tests/test_craft_truncate.cpp | Updates mocks/constructors and settings initialization for new config usage. |
| src/lib/craft/tests/test_craft_peer_exchange.cpp | Updates mocks/constructors and settings initialization for new journal API shape. |
| src/lib/craft/tests/test_craft_journal_slot_wire.cpp | Adjusts wire-compat assertions now that JournalSlot is a superset internally. |
| src/lib/craft/tests/test_craft_homestore_backend.cpp | Extends HomeStore backend coverage (read_slot parsing/validation; updated backend ctor). |
| src/lib/craft/tests/test_craft_concurrency.cpp | New multi-threaded tests for internal locking, overlay/read behavior, and commit serialization. |
| src/lib/craft/tests/test_craft_commit.cpp | New comprehensive unit tests for commit/read/rebuild overlay behavior and error paths. |
| src/lib/craft/tests/test_craft_commit_hs.cpp | New heavy integration tests against a real VolumeIndexTable + real HomeStore data. |
| src/lib/craft/tests/CMakeLists.txt | Wires new test targets and ensures config bindump is linked into “light” tests. |
| src/lib/craft/craft_repl_dev.hpp | Extends interfaces/state (checksums, read_data, overlay, watchdog, commit/read seams). |
| src/lib/craft/craft_repl_dev.cpp | Implements on-disk slot format changes, commit/read/overlay logic, watchdog, and recovery gate. |
| src/lib/craft/craft_api.cpp | Updates async_write signature to match “empty data means unmap” semantics. |
| src/include/homeblks/home_blocks.hpp | Updates public API documentation/signature for CRAFT write/unmap semantics. |
| conanfile.py | Bumps package version to 6.0.7. |
Review details
- Files reviewed: 17/17 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev/v6.x #175 +/- ##
===========================================
Coverage ? 48.84%
===========================================
Files ? 19
Lines ? 1208
Branches ? 527
===========================================
Hits ? 590
Misses ? 267
Partials ? 351 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🟡 Changes recommended
Horizon reads, checksum verification, buffer validation, recovery compatibility, and peer data fetching contain correctness issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
src/lib/craft/craft_repl_dev.cpp:945
- Checking only that an iovec exists does not establish that the flat destination has
lenwritable bytes. A malformed public call withdest.size < len, a short first iovec, or a null base reaches thememset/async_readbelow and writes past or through the supplied buffer. Validate the destination exactly as the write path validates its source before dereferencing it.
if (dest.iovs.empty()) {
LOGW("read rejected: dest sg_list has no iovecs (size={})", dest.size);
co_return std::unexpected(make_error_condition(std::errc::invalid_argument));
}
auto* dest_buf = static_cast< uint8_t* >(dest.iovs[0].iov_base);
src/lib/craft/craft_repl_dev.cpp:240
- After deserialization there is no structural check that a data slot's blkid covers exactly
hdr.len / lba_size_blocks (or that a zero slot has an empty blkid). If it covers fewer blocks,commit_impl()builds fewerBlockInfovalues but still callswrite_to_index()for the full header range, which inserts default/invalid mappings for the missing LBAs and advancescommit_lsn. Validate alignment and exact block coverage here before returning the slot.
sisl::blob blkid_blob{buf.bytes() + blkid_off, buf.size() - blkid_off};
slot.blkid.deserialize(blkid_blob, /* copy = */ true);
- Files reviewed: 17/17 changed files
- Comments generated: 8
- Review effort level: Balanced
szmyd
left a comment
There was a problem hiding this comment.
Blocking on one finding, inline at read()'s piggyback commit.
Most of what I would otherwise raise is already on this thread -- the overlay's single-version limitation and the read_impl/commit_impl interleaving in particular. What follows is a separate half of the first of those, and the thing worth saying about it is that the fix proposed there does not close it.
Two smaller items I looked at and did not file, so they are at least on the record: the blk_count_t narrowing in read_impl's contiguous-run merge (line 965 -- run_nlbas is bounded only by craft_max_io_len_mb / lba_size, which exceeds 65535 at a 512-byte page_size under the default cap), and commit_impl's is_empty branch skipping overlay retirement alongside the apply. Say the word if either is worth writing up properly.
Squashed from 11 incremental commits (checksum-array blob format, real read_slot(), VolumeIndexTable::delete_lba_range, index handle + journal-tail overlay, commit() wired into write()/keep_alive(), CraftReplDev::read(), client-liveness watchdog, heavy integration + concurrency test coverage, overlay rebuild on restart, and two rounds of robustness-gap fixes) into one commit for a clean rebase onto the updated dev/v6.x base (PR eBay#176 / S5 landed upstream). Implements S3 (SDSTOR-22733): CraftReplDev::commit() advances commit_lsn by applying journaled entries to the index in strict dLSN order, stalling (not erroring) at the first gap; keep_alive() and write() both piggyback it. CraftReplDev::read() serves [addr, addr+len) as of a client-supplied horizon from the index or the journal-tail overlay (never fetches from a peer), CRC-verifying every LBA and collapsing all-zero content to holes at read time. A per-partition watchdog timer proposes a SyncRSCommitLSN entry when a client goes quiet. rebuild_overlay() repopulates the overlay from the journal on restart. Full unit + HomeStore-integration + concurrency test coverage across test_craft_commit.cpp, test_craft_commit_hs.cpp, test_craft_concurrency.cpp, test_craft_homestore_backend.cpp, and test_craft_watchdog.cpp.
…k leaks, validation) Addresses sbinmalek/Copilot/szmyd/independent-agent review findings on PR eBay#175, all build-verified (100% tests passing, 12/12 suites). szmyd (blocking) — read()'s piggyback commit can advance commit_lsn past its own read_lsn before read_impl() snapshots it, serving a too-new value with no error. Added volume_error::HORIZON_STALE; read_impl() rejects read_lsn < commit_lsn_snapshot instead of silently serving the too-new value. Residual TOCTOU in read_impl — the horizon check above only guards the snapshot taken BEFORE the unlocked index query; a concurrent commit_impl can still advance commit_lsn while that query runs. Re-snapshot and re-check after the index read closes this second instance of the same class of bug. Copilot — old_blkid == new_blkid frees a live index block on crash-replay of an already-applied slot (index already holds the exact blkid the replay reports as "old"). Skip the free when they match. Copilot — read_slot() never cross-checked the on-disk record's self-describing hdr.lsn against the seq_num actually requested; a corrupt/misplaced record was silently trusted. Now rejected. sbinmalek/Copilot — the all-zero read-time collapse ran before the CRC check, so a bit-flip that zeroed real data was reported as a hole instead of CRC_MISMATCH. CRC is now computed unconditionally before the collapse decision. Copilot — read_slot() derived nlbas from hdr.len without validating alignment/non-zero, letting a corrupt record silently misalign the csum-array and blkid offsets that follow. Validated up front. Copilot — write()'s ack snapshotted state_ BEFORE the piggyback commit() call, so a successful write's own commit progress was never reflected in its own response. Re-snapshot after. Independent-agent finding — hdr.all_committed_lsn (client-controlled) had no validation; a malformed negative value (not the -1 "unset" sentinel) could corrupt this long-lived floor. Now ignored. An unbounded-positive-value poisoning vector remains -- no valid per-call bound exists without breaking the legitimate lagging-replica catch-up signal; documented as a TODO(S8) on the field itself, since only S8's real reclaim implementation has the context to validate it. sbinmalek/Copilot — dest iovec capacity in the read path only checked for emptiness, not whether iov_len actually covered the requested range; an undersized buffer would be written past its end. Copilot — write() allowed a multi-iovec sg_list whose iovs[0] alone happened to satisfy the size/alignment checks, carrying dead trailing iovecs. Rejected as hygiene (the CRC-corruption scenario Copilot originally described was already closed by the pre-existing iov_len check). szmyd (unfiled) — a single contiguous read run could exceed blk_count_t's max (65535 LBAs) at small lba_size/large craft_max_io_len_mb, silently truncating in the run_blkid construction. Capped the run-extension loop at that limit; larger runs now split into multiple read_data batches (invisible to the caller -- adjacent same-type extents still merge). szmyd (unfiled) — commit_impl's is_empty branch skipped overlay retirement entirely, including the case where THIS replica already journaled real data for an lsn the cluster-wide resolution still verdicts Empty ("Empty beats data" reconciliation). Left a live overlay entry that would keep being served on reads, contradicting the verdict. Now attempts a best-effort read_slot() purely to discover the LBA range to retire, even when skipping the index apply. Independent-agent finding (S4 scope, not S3, bundled here per review discussion) — truncate() never freed the data blocks referenced by journal entries it rolled back, permanently leaking them on every login that drops a stale tail. Reads each dropped entry's blkid before the rollback destroys the record, frees it after. Also: two review comments acked but never actually implemented -- explicit is_hole(nlbas, false) initialization (style, sbinmalek) and a shortened TODO-style comment on the logout/watchdog interaction (sbinmalek's "leave a TODO instead of a large comment" request). Deferred to a follow-up PR (design discussion in progress with szmyd): the overlay's single-version-per-LBA limitation, a read_impl/commit_impl atomicity gap distinct from the two fixed above, and asymmetric failure handling for a corrupt journal record hit live vs. at restart.
3f4a438 to
3885dd8
Compare
S5 landed independently on dev/v6.x with no visibility into S3's own changes (and vice versa), so several integration points compiled/linked/ran incorrectly even after the rebase's textual conflicts were resolved -- none of these were merge conflicts, they were semantic mismatches between the two branches' independent designs: - HomeStoreCraftJournalBackend::free_slot (S5) computed the blkid offset assuming the pre-S3 [header][blkid] blob layout; S3 inserted a csum array in between ([header][csums][blkid]). Fixed to skip past the csum array via blkid_offset(nlbas), same as read_slot() already does. - apply_sync_rs_commit_lsn's (S5) own write_slot call site never passed csums (added by S3 after S5 branched) -- now passes the fetched slot's own slot.csums. - CraftReplDev moved from a public constructor to a private constructor + create() factory (S5, for shared_from_this()) with S3's lba_size/indx_tbl parameters folded in. Four S3-only test files never conflicted during the rebase (S5 never touched them) but still called the old make_unique<CraftReplDev> pattern directly: test_craft_watchdog.cpp, test_craft_commit.cpp (two call sites), test_craft_concurrency.cpp, test_craft_commit_hs.cpp. All switched to CraftReplDev::create(), with dev_ members changed from unique_ptr to shared_ptr to match create()'s return type. - Three MockCraftJournalBackend mocks (test_craft_commit/concurrency/watchdog) never implemented S5's new free_slot pure virtual, making them abstract classes; added stub overrides. test_craft_raft_entries.cpp's own mock was also missing S3's read_data pure virtual and had a stale 6-arg write_slot override (no csums parameter) -- both fixed. - test_craft_raft_entries's CMakeLists.txt target was missing the generated HB_CONFIG_BINDUMP source and its core-library dependency, causing an undefined reference to home_blks_config_fbs at link time (every other light CRAFT test target already needed this before S5's own tests ever required HB_DYNAMIC_CONFIG). - test_craft_raft_entries.cpp's main() never called SISL_OPTIONS_LOAD -- harmless before S3's watchdog work made CraftReplDev's constructor unconditionally read HB_DYNAMIC_CONFIG(craft_watchdog_timeout_ms), which segfaults against an uninitialized settings registry. Added SISL_OPTIONS_LOAD plus craft_watchdog_timeout_ms=0 (this suite never starts iomgr, so a live recurring timer would crash it regardless). All 13 test suites pass post-fix (100%), including S5's own CraftRaftEntriesTest (28 tests) running for the first time alongside every S3 fix from this review pass.
3885dd8 to
101e3e8
Compare
CI's GccAddressSanitize job runs a formatting check against origin/dev/v6.x for PR-modified files and failed on this one line -- pre-existing from the rebase's auto-merge (never touched by any of this session's actual edits to this file, just caught in the same formatting-check scope since the file was modified). Joins the JournalSlot initializer onto one line, matching clang-format -style=file's output.
23cbb40 to
a53d82b
Compare
szmyd
left a comment
There was a problem hiding this comment.
Let's go ahead and merge this; follow-up fixes or additions in new prs.
Summary
Implements the S3 story:
commit(),keep_alive(), andread()onCraftReplDev— the machinery that advancescommit_lsn, applies journal entries to the index, and serves client reads from the journal-tail overlay and committed index without fetching from a peer.Update: rebased onto the latest
dev/v6.x(now includes S5'sapply_sync_rs_commit_lsn/InternalLogin, PR #176) and addressed the full review pass from sbinmalek, Copilot, szmyd, and an independent review pass — see "Review fixes" below. All 13 addressable findings are fixed and build-verified; 3 findings are real but need more design discussion and are explicitly deferred to a follow-up PR (see "Deferred to follow-up PR").What's implemented (across 11 commits, squashed)
CraftJournalEntryblob format — per-LBA checksum array appended after the header;write()computes onecrc16_t10difper LBA at append timeHomeStoreCraftJournalBackend::read_slot()— real log-store read: validate, parse header, deserialize blkid, extract csumsVolumeIndexTable::delete_lba_range()— new method forall_zerosunmap on applystd::unordered_map<lba_t, OverlayEntry>covering(commit_lsn, last_append_lsn]; highest-dLSN-wins per LBA; populated onwrite(), rebuilt on restart, pruned ontruncate()and commitcommit(upto_lsn)— walks the journal in order, stalls at the first missing gap (never skips holes), applies each slot to the index (write_to_indexfor data,delete_lba_rangeforall_zeros), retires overlay entries only when their recorded LSN exactly matches the applied slotkeep_alive()— term check → watchdog reset →commit(hdr.commit_lsn)→ returns{commit_lsn, last_append_lsn}read()— term check → piggyback commit → snapshot watermarks → per-LBA resolution: overlay (horizon-clamped toread_lsn) wins over index; absent-from-both is a hole; read-time all-zero collapse; CRC verification; adjacent same-type extents mergedrebuild_overlay()— walks(commit_lsn, last_append_lsn]on restart, skips missing/empty LSNs (not a stop condition), populates overlay with the same highest-dLSN-wins rulewrite()usesiomgrtimer; reset on every successfulwrite()/keep_alive(); fires intoappend()when the session goes quiet;craft_watchdog_timeout_msconfig key (default 30 s, 0 = disabled)craft_max_io_len_mbconfig key (default 128 MiB) — enforced in bothwrite()andread()to bound unbounded-len attacks from malformed framesReview fixes (this update)
All build-verified (100% tests passing, 13/13 suites, including S5's own
CraftRaftEntriesTest).read()'s piggyback commit can advancecommit_lsnpast its ownread_lsnbefore the snapshot is taken, serving a too-new value with no errorvolume_error::HORIZON_STALE;read_impl()rejectsread_lsn < commit_lsn_snapshotcommit_implcan still advancecommit_lsnwhile that query runsold_blkid == new_blkid, freeing a block the index still referencesread_slot()never cross-checked the on-disk record'shdr.lsnagainst the seq_num actually requestedCRC_MISMATCHread_slot()derivednlbasfromhdr.lenwithout validating alignment/non-zerowrite()'s ack snapshottedstate_before the piggybackcommit()callhdr.all_committed_lsn(client-controlled) had no validation-1sentinel) are ignored;TODO(S8)documents that the unbounded-positive-poisoning vector needs S8's real reclaim implementation to close, since no valid per-call bound exists without breaking the legitimate lagging-replica catch-up signaliov_len >= nlbas * lba_size_write()allowed a multi-iovec sg_list with dead trailing iovecsblk_count_t's max (65535 LBAs)read_databatches, invisible to the callercommit_impl'sis_emptybranch skipped overlay retirement entirely, including when this replica already journaled real data for an LSN the cluster verdicts Empty ("Empty beats data")read_slot()to discover the LBA range and retire it, even when skipping the index applytruncate()never freed the data blocks referenced by rolled-back journal entries, leaking them on every login that drops a stale tailAlso: two review asks that were acked in-thread but never actually implemented, now done — explicit
is_hole(nlbas, false)initialization (style), and a shortened// TODO:comment on the logout/watchdog interaction (was a large explanatory paragraph).Declined:
k_journal_versionstays at1(no legacy on-disk records exist in practice; not worth the churn this round).Deferred to follow-up PR
Three findings are real, confirmed, and distinct from everything fixed above, but need a design decision rather than a quick diff:
read_impl/commit_implatomicity gap (sbinmalek, via an independent review pass) —read_impl's index-read and overlay-lookup, andcommit_impl's index-write and overlay-retire, are each two separate non-atomic steps. A read straddling a commit can land on data older than what's already fully committed, with no error. Distinct from item 1 above (fixed) — that one is about seeing data too new; this is seeing data too old.recovery_faulted_); the same fault hit live just logs and retries forever with no latch and no observability.szmyd's review explicitly frames (1) and (2) as the two things he'd have raised himself if they weren't already on the thread. A design writeup for both has been shared with the team; will land together in the next PR once we settle on an approach. (3) needs a scope decision (in-scope for S3, or a tracked follow-up ticket) before a fix is written.
Unit test coverage
All tests in
test_craft_commit.cpp(light, no HomeStore) andtest_craft_commit_hs.cpp(real HomeStore) pass. Coverage maps to AC requirements:InOrderApply+WriteCommitReadRoundTrip(hs)ThreeWayMixedExtentReadInOrderApplyAfterHoleFillsMatchesNoGapRunRestartRecoveryGatesClientIoUntilOverlayRebuilt,RebuildOverlaySkipsNlbasZeroSlottest_craft_watchdog.cppAllZerosOverlayIsHole,AllZerosUnmapReclaimsRealBlock(hs)DataWriteOfAllZeroBytesCollapsesAtReadTimeNotWriteTimeCommitCsumShortArrayAbortsRebuildOverlaySkipsNlbasZeroSlotWriteCapturesAllCommittedLsn,ReadCapturesAllCommittedLsnReadDestEmptyIovsRejectedReadMaxIoLenEnforced,MaxIoLenEnforced(write)HorizonClampServesIndexNotOverlayAboveReadLsnOverlayWinsOverCommittedIndexForSameLbaCrcMismatchFailsTruncateRemovesOverlayEntriesAboveLsncommit_running_ConcurrentCommitIsNoOp,ConcurrentKeepAliveSerializesCommitAgainstRealIndex(hs)ReadRejectsStaleHorizon,ReadRejectsHorizonAdvancedDuringIndexQuery(item 2)CrashReplayOfAppliedSlotDoesNotFreeLiveBlockReadSlotLsnMismatchFailsAllZeroCorruptionFailsCrcInsteadOfCollapsingReadSlotMisalignedLenFailsWriteAckReflectsPostCommitSnapshotKeepAliveIgnoresMalformedNegativeAllCommittedLsnReadDestUndersizedIovLenRejectedMultiIovecRejectedEvenWhenFirstIovAloneSufficesLargeContiguousRunSplitsAcrossBlkCountTLimitEmptyVerdictRetiresOverlayEvenWhenThisReplicaHadTheDataTruncateFreesBlocksForDroppedDataEntries,TruncateDoesNotFreeEntriesAtOrBelowLsn,TruncateSkipsFreeForAllZerosEntries,TruncateSkipsFreeForGapsAboveLsnNot in scope (future stories)
all_committed_lsn(captured here; reclaim action itself is S8)CraftPartitionStatesuperblock recovery (overlay rebuild walk is empty until state recovery is wired)