Skip to content

craft: S2 write path — HS_DATA_LINKED journal append with full correctness hardening - #171

Merged
shosseinimotlagh merged 27 commits into
eBay:dev/v6.xfrom
shosseinimotlagh:S1_Write_path
Aug 30, 2026
Merged

craft: S2 write path — HS_DATA_LINKED journal append with full correctness hardening#171
shosseinimotlagh merged 27 commits into
eBay:dev/v6.xfrom
shosseinimotlagh:S1_Write_path

Conversation

@shosseinimotlagh

@shosseinimotlagh shosseinimotlagh commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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 through the RAFT/journal 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.

What's implemented

Write path (CraftReplDev::write)

  • Term fencing under missing_mu_ (rejects with STALE_TERM on mismatch).
  • Empty-verdict tombstone: a write into a slot already verdicted Empty is rejected with EMPTY_SLOT (new volume_error value).
  • Out-of-order arrival via missing_lsns_: gaps are tracked and cleared as they fill; retries of an already-written dLSN return the current watermark idempotently instead of re-appending.
  • Two independent caps against unbounded growth: a per-write gap cap (bounds one write's own contribution) and a cumulative cap on missing_lsns_'s total size (bounds sustained forward-walking that stays within the per-write cap on every individual call).
  • all_zeros/data-size validated symmetrically in both directions (a data write requires a payload; a zero write requires none) — a malformed combination is rejected, never silently accepted or dropped.

On-disk journal format (CraftJournalEntry)

  • {magic, version, term, lsn, lba, len, all_zeros}, packed and size-locked with a static_assert so a layout change is a compile error, not a silent recovery bug.
  • No CRC field: HomeStore's log-group header already checksums the entire record body on every append and verifies it on read/recovery replay (verified directly against HomeStore's dev/v8.x source) — a CRAFT-level CRC would be redundant.
  • lba/len are explicitly byte-addressed (not block units); this backend's own JournalSlot fields are named lba_off_bytes/len_bytes to keep that visible at the type level, since craft_client's equivalent struct uses the same field names for block units.

HomeStoreCraftJournalBackend (the production backend)

  • write_slot bridges HomeStore's callback-based write_async to a coroutine via a lock-free value_awaitable, safe under both of HomeStore's real completion paths: synchronous completion under LogDev::m_flush_mtx (the production configuration) and INLINE completion firing before await_suspend returns (used by solo_repl_dev).
  • Guards the one HomeStore-side failure that does carry a return-value signal (log store/logdev shutting down) and fails the write cleanly instead of hanging forever; the other failure trigger (a journal I/O error) has no such signal today and is tracked as a HomeStore-side gap (see Known risks).
  • Defensive DEBUG_ASSERT_EQ on the serialized multi_blk_id size before the memcpy that packs it into the journal blob.

Test coverage

  • test_craft_write.cpp (24 tests): in-order/out-of-order writes, term rejection, idempotent retry, both gap-cap guards including exact fenceposts and the corner cases that expose why a naive implementation of each guard is wrong (a strictly in-order write must stay exempt from the cumulative cap; a gap-filling write must too), Empty-verdict interaction with the gap loop, all_zeros validation in both directions, zero-copy verification (a real buffer's pointer, not a copy, must reach alloc_write_data), and the on-disk term round-trip.
  • test_craft_homestore_backend.cpp (3 tests, new): drives HomeStoreCraftJournalBackend directly against a real HomeStore log store — the only place in this suite that exercises the production backend instead of a mock. Covers write_slot's completion bridge under INLINE flush mode, truncate_to's rollback against a real logdev, and alloc_write_data's failure path.
  • test_craft_truncate.cpp (8 tests) and test_craft_peer_exchange.cpp (13 tests): mocks updated to the current write_slot signature (term + multi_blk_id + all_zeros).

All CRAFT test binaries pass on the remote build server (Debug build).

Bugs found and fixed along the way

  • Block leak when write_slot fails after alloc_write_data succeeds — free_data now called before propagating the error.
  • Several correctness issues from adversarial review: a state_.term data race outside the lock, a negative-dLSN bypass of the pre-insert invariant, signed overflow in the gap-cap subtraction near INT64_MAX, a duplicate-write path that could call write_slot without the dLSN present in missing_lsns_, the gap-fill loop re-inserting already-Empty-verdicted LSNs (which would permanently stall commit advancement), and a ghost journal entry surviving a login-truncate race (closed by removing the unreachable free-on-stale-term path entirely rather than patching around it).
  • write_async's return value was being misread as "non-positive means shutting down" — HomeStore's log-dev actually returns its internal monotonic index on success, which legitimately starts at 0 for the first write to a fresh logdev. Found by the new real-backend test (which exercises an actual fresh logdev); fixed to only treat a negative return as the shutdown signal.
  • VolumeChunkSelector::select_chunk dereferenced a null chunk-info slot for an unregistered volume ordinal with no null check — a real, previously-latent crash in shared volume infrastructure, unrelated to CRAFT, found by the same new test and fixed with a one-line guard (volume_chunk_selector.cpp).

Known risks / tracked follow-ups

  • write_async completion can be permanently lost. Two independent triggers collapse to the same failure mode (the coroutine never resumes): the log store/logdev shutting down (now guarded here — the write fails cleanly instead of hanging) and a journal I/O error (HomeStore's own flush-failure path returns without ever invoking the completion callback — no return-value signal exists for this one). The needed HomeStore-side fix is to propagate the error into the completion path itself, not add a status argument to a callback that currently isn't even invoked on this path. Tracked; an interim timeout on the co_await is the mitigation until that lands.
  • EMPTY_SLOT has no wire representation. craft_client's craft_error enum has no Empty-slot-equivalent value, so this can't survive translation at the CraftConnector boundary yet. Cross-repo, out of scope for this story; tracked as a sub-task under the CraftConnector story (S9).
  • last_append_lsn is advanced before write_slot runs and not rolled back on failure. Intentional, documented in-source and grounded in CRAFT-Design's own recovery-watermark argument (the design already tolerates false-include of a non-durable LSN into the login watermark as benign, reserving false-exclude as the only catastrophic case). To decide in a future PR.

@codecov-commenter

codecov-commenter commented Jul 23, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 50.00000% with 9 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (dev/v6.x@9fce458). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/lib/craft/craft_repl_dev.cpp 56.25% 1 Missing and 6 partials ⚠️
src/include/homeblks/home_blocks.hpp 0.00% 1 Missing ⚠️
src/lib/volume/volume_chunk_selector.cpp 0.00% 0 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@             Coverage Diff             @@
##             dev/v6.x     #171   +/-   ##
===========================================
  Coverage            ?   45.60%           
===========================================
  Files               ?       18           
  Lines               ?     1035           
  Branches            ?      451           
===========================================
  Hits                ?      472           
  Misses              ?      262           
  Partials            ?      301           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI 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.

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-only write_slot(...), plus free_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 appropriate std::errc) instead of volume_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 (or std::errc::value_too_large) instead of volume_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.

Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
Comment thread src/lib/craft/craft_repl_dev.cpp
Comment thread src/lib/craft/tests/test_craft_write.cpp Outdated

Copilot AI 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.

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 empty sg_list but defaults all_zeros to false, 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 default all_zeros to true (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));

Comment thread src/lib/craft/craft_repl_dev.hpp
Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
Comment thread src/lib/craft/tests/test_craft_write.cpp Outdated
Comment thread src/lib/craft/tests/test_craft_write.cpp Outdated
Comment thread src/lib/craft/craft_repl_dev.hpp Outdated

@szmyd szmyd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the S2 write path against the CRAFT wiki (CRAFT-Design, CRAFT-on-HomeBlocks).

The in-memory state machine is careful and well tested: the missing/empty interaction (Empty resolves a gap, the gap loop skips verdicts, Empty beats data) matches the design's reconciliation rule, the error codes now follow the home_blocks.hpp guidance, and no co_await happens under missing_mu_. The HS_DATA_LINKED shape is right too -- payload never enters the journal buffer, the ack awaits append completion, no LBA index write on the write path. Advancing last_append_lsn before the append is defensible as flagged: the design models it as the highest appended dLSN with Missing tracking the holes, so a failed write left in missing_lsns_ is the consistent representation.

The five inline comments are the items I consider defects rather than gaps, and that are cheaper to fix now than later. Two of them (units, missing term) are urgent only because this PR freezes the first on-disk CRAFT format.

Deferrable to a follow-up

all_zeros end-to-end: the client does not emit zero writes yet, so this is a real gap but not urgent. Two notes to carry forward. The RELEASE_ASSERT at line 247 should become std::errc::invalid_argument before any client can reach it -- a server should not abort on client input, and this one fires before the term check. And craft_api.cpp:57 plus the public async_write need the flag plumbed through, since home_blocks.hpp:177 still documents the empty-buffer convention while home_blocks.hpp:152 already promises the flag. As it stands all_zeros has no caller that can set it.

Also for later:

  • write() drops hdr.commit_lsn, so every ack returns commit_lsn = -1, which also pins the all_committed_lsn reclaim floor at -1. The design makes write the commit carrier ("there is no standalone commit verb; every IO is its carrier").
  • The 1M gap cap will permanently lock out a far-behind member: last_append_lsn only advances via a write, so once the gap exceeds the cap every subsequent write is rejected forever. The design's re-admission path keeps the client broadcasting to that member while reachable. Bounding against the reclaim floor / startLSN, or representing Missing as an interval set, removes the need for the cap entirely.
  • missing_lsns_ materializing every LSN individually means one write can insert 1M std::set nodes under missing_mu_, stalling the whole write path.
  • The M1 idempotency fix covers only sequential retries. Two concurrent writes at the same dLSN both find it in missing_lsns_, both allocate, and both call write_slot at the same seq_num, orphaning one blkid. Worth either an in-flight set or an explicit note that the connector must dedup by request_id.

Base drift

This is on dev/v6.x@7c7fa4f. The craft_client split (#168) and the reworked replica/peer API (#169) have since rewritten craft_repl_dev.* and removed src/lib/craft/tests/, and still carry the old write_slot(..., sisl::sg_list) signature. Worth settling which line is trunk before this lands.

One note on coverage: make_homestore_journal_backend has no call site anywhere in the tree, so every new production line here is unreachable and the 22 tests exercise only MockCraftJournalBackend. Fine for a staged story, but it does mean the first two comments below were structurally uncatchable by this suite.

Comment on lines +33 to +37
struct CraftJournalEntry {
lba_t lba;
lba_count_t len;
uint8_t all_zeros;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The on-disk entry omits term, and has no version field.

CRAFT-on-HomeBlocks ("Journal backing and write-once-by-reference") specifies the slot as {term, lsn, lba, len, blkid}. This records {lba, len, all_zeros} plus the serialized blkid.

Without the term, the design's self-healing rule is not implementable: "A member that missed the login truncates itself: applying the login entries from the RAFT log reveals a stale-term tail above the synced watermark, which it drops before anything else." There is no way to recognize a stale-term tail. It is also what would let recovery identify the ghost entry flagged at line 331.

Same deadline argument as the units issue: this PR freezes the first on-disk CRAFT format, and there is no magic/version byte either. Adding both costs a few bytes now and a migration later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. CraftJournalEntry is now 34 bytes (#pragma pack(1)):

struct CraftJournalEntry {
    uint32_t    magic;     // always k_journal_magic (0xC4AF5AFE)
    uint8_t     version;   // always k_journal_version (1); bump when layout changes
    uint64_t    term;      // session term at write time — lets recovery skip stale-tail entries
    int64_t     lsn;       // dLSN — self-describing: cross-checks slot index on recovery
    lba_t       lba;       // BYTES
    lba_count_t len;       // BYTES
    uint8_t     all_zeros;
};

magic leads so recovery can reject non-CRAFT or corrupted slots before reading anything else. version follows so future layout changes can be detected without breaking the magic check. lsn is added per spec ({term, lsn, lba, len, blkid}) and enables self-describing recovery.

WriteSlotReceivesCorrectTerm verifies that term reaches the backend write path correctly.

Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
Comment on lines +57 to +72
struct LogstoreWriteAwaitable {
homestore::home_log_store* store_;
homestore::logstore_seq_num_t seq_;
sisl::io_blob_safe blob_;

bool await_ready() const noexcept { return false; }

template < typename H >
void await_suspend(H h) noexcept {
store_->write_async(
seq_, blob_, nullptr,
[h](homestore::logstore_seq_num_t, sisl::io_blob&, homestore::logdev_key, void*) mutable { h.resume(); });
}

void await_resume() const noexcept {}
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resumes the coroutine inside the logdev flush mutex.

Verified against homestore dev/v8.x:

  • log_dev.cpp:596-610: if (hs()->has_repl_data_service()) { callback_lambda(); } invokes the user completion synchronously. HomeBlocks always has the repl data service (homeblks_impl.cpp:310,317 call .with_repl_data_service), so this is the production path, not the UT path. The comment on the other branch names the hazard directly: "the callback will schedule a new write and try to acquire the flush lock again causing a deadlock."
  • That runs from flush() -> on_flush_completion(), and flush() executes under flush_guard() == std::unique_lock(m_flush_mtx) (log_dev.hpp:718), a non-recursive mutex.

h.resume() therefore runs the entire remainder of CraftReplDev::write -- and the continuation of whoever awaits it -- while holding m_flush_mtx. LogDev::read(), rollback(), and LogDev::truncate() all take flush_guard() unconditionally, so any journal op issued from that continuation self-deadlocks. free_data on the error paths submits block I/O from the flush thread for the same reason.

Second issue in the same struct: write_async is called from inside await_suspend, and append_async does if (allow_inline_flush()) flush_if_necessary(); (log_dev.cpp:297). If the CRAFT logdev is opened TIMER | INLINE (what solo_repl_dev uses), the completion fires inline and h.resume() destroys the coroutine frame -- including this awaitable and its blob_ -- before await_suspend returns.

sisl/async/value_awaitable.hpp handles exactly this handshake (completion-before-suspend, cross-thread publish). Use it, and reschedule onto an iomgr reactor rather than resuming in place.

@shosseinimotlagh shosseinimotlagh Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. LogstoreWriteAwaitable is removed entirely and replaced with sisl::async::value_awaitable<bool> + iomanager.run_on_forget(reactor_regex::least_busy_io, ...):

auto va = std::make_shared<sisl::async::value_awaitable<bool>>();
logstore_->write_async(..., [va](...) mutable {
    iomanager.run_on_forget(iomgr::reactor_regex::least_busy_io,
                            [va = std::move(va)]() mutable { va->complete(true); });
});
co_await *va;

The run_on_forget decouples coroutine resume from m_flush_mtx — the callback posts the completion to an iomgr reactor and returns; the mutex is released before the continuation runs. value_awaitable::await_suspend atomically detects the completion-before-suspend case (INLINE log-dev mode used by solo_repl_dev) and returns false, so the frame is never destroyed inside the callback.

Both bugs — the deadlock and the INLINE UB — are fixed in place rather than deferred to S3. The comment in the code documents the production code path (homeblks_impl.cpp:310,317, log_dev.hpp:718) and the two residual known limitations (shutdown drain and no I/O-error surface from the callback).

std::memcpy(blob.bytes() + sizeof(CraftJournalEntry), blkid_blob.cbytes(), blkid_sz);
co_await LogstoreWriteAwaitable{logstore_.get(), static_cast< homestore::logstore_seq_num_t >(lsn),
std::move(blob)};
co_return ok();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

write_slot returns ok() unconditionally.

Understood that write_async's callback carries no status and that fixing this properly needs a HomeStore API change. But as written this acks data that may never have reached media, which is a direct violation of the one contract the design states in absolute terms: "quorum-ack implies quorum-durable implies an all-replicas restart cannot lose an acked write."

I am not asking for the HomeStore change in this PR, but this should not merge as an in-source comment alone. File the HomeStore issue now and make it a blocker on wiring make_homestore_journal_backend into volume.cpp, so the path cannot go live silently acking unwritten data. Same for the is_stopping() hang (log_store.cpp:71), which needs a drain protocol before shutdown is implemented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged and documented as KNOWN GAP in the awaitable comment block. The log_write_comp_cb_t signature is void(logstore_seq_num_t, sisl::io_blob&, logdev_key, void*) — the callback carries no status argument, so await_resume() has nothing to surface. Fixing this requires a HomeStore API change (a status out-parameter or a separate error callback on the completion). Noted as a future item tied to that HomeStore extension.

Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
Comment on lines +303 to +304
auto res = co_await journal_->write_slot(dlsn, static_cast< lba_t >(addr), static_cast< lba_count_t >(len), blkid,
all_zeros);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Byte values are persisted into block-unit fields.

static_cast< lba_t >(addr) and static_cast< lba_count_t >(len) carry the wire's byte offset/length into CraftJournalEntry hdr{lba, len, ...} (line 102). lba_t/lba_count_t are the index's block units (hb_internal.hpp:64-68), and JournalSlot's own header comment says this is "this backend's OWN state, in its own BLOCK units (lba_t / lba_count_t)". The hb_internal.hpp sentence cited in the earlier reply is about type identity (uint64_t/uint32_t), not units.

Two concrete consequences today, not in S3:

  1. lba_count_t is uint32_t, so a byte length is silently truncated at 4 GiB with no guard.
  2. fetch_data() returns JournalSlot{lba, len} to peers as block units per its contract, while write_slot now persists bytes into the same fields. The two halves of the same struct disagree.

Deferring the byte->block conversion to S3 is a reasonable call; writing the record to disk in the meantime is not, because it makes S3 a format migration instead of a code change. Either convert here (and validate alignment/range, which home_blocks.hpp:141 requires to return std::errc::invalid_argument), or rename the fields to byte_addr/byte_len so the persisted format says what it means.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming mismatch is real — lba_t/lba_count_t names suggest block indices but the stored values are bytes. The byte-to-block conversion belongs inside the data service (it uses the volume's lba_size), so the journal stores raw bytes and the field types remain as-is by convention.

Fixed with explicit // BYTES annotations at every site:

  • CraftJournalEntry.lba / .len struct fields
  • JournalSlot.lba / .len struct fields
  • the write() call-site comment

A full field rename is tracked for S3 when the unit boundary will be enforced uniformly across the LBA index.

Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
Comment on lines +331 to +337
if (stale_post_flight) {
if (blkid_allocated) {
if (auto fr = co_await journal_->free_data(blkid); !fr)
LOGE("free_data failed after post-flight stale-term discard dlsn={}: {}", dlsn, fr.error().message());
}
co_return std::unexpected(make_error_condition(volume_error::STALE_TERM));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The post-flight free_data creates a dangling journal reference.

By the time this runs, write_slot has already appended the record at dlsn; nothing removes it. free_data(blkid) therefore leaves a journal slot pointing at freed, re-allocatable blocks.

Note what this means for the two orderings of the race the comment above describes:

  • truncate() runs after write_slot lands: the entry is dropped by the truncate anyway, so the free was unnecessary but harmless.
  • truncate() ran before write_slot landed (the ghost-entry case this code exists to fix): the entry survives above the new tail, and now it dangles.

So the fix is a no-op in the case where it is safe, and actively harmful in the case it targets.

There is a second inconsistency in the same block: on the stale path missing_lsns_.erase(dlsn) is skipped, so in-memory state says "I do not hold dlsn" while the journal does hold a record for it. Recovery rebuilding state from the journal will disagree with the overlay.

Per CRAFT-Design ("Truncation is login-only"), this is meant to be resolved by ordering, not post-hoc detection: applying InternalLogin is simultaneously the term bump and the truncate, which is what makes "stale-session and new-session data can never coexist in a slot" hold. CraftReplDev::truncate()'s own docstring already asserts that ordering exists ("Called only during login (quiesced -- no concurrent writes)"). Both cannot be true. Either the quiesce is real, and this whole post-flight path is dead code that should be dropped, or it is not, and truncate() is unsafe as documented. Whichever way it resolves, do not free blocks whose reference survives.

@shosseinimotlagh shosseinimotlagh Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: free_data is removed from the post-flight stale-term path entirely.

Your two-orderings analysis is correct in the code comment:

  • truncate ran after write_slot landed — the entry is dropped by truncate anyway; free_data was unnecessary.
  • truncate ran before write_slot landed (ghost-entry case) — the journal entry survives above the new tail and still references blkid; free_data here leaves a dangling block reference. This is the case the code was trying to fix but actively harmed.

blkid is intentionally not freed in either ordering. CraftJournalEntry.term lets recovery skip the stale entry; the next login truncates it durably. There is no deferred fix — the call is gone.

@shosseinimotlagh
shosseinimotlagh force-pushed the S1_Write_path branch 2 times, most recently from 61a078a to 7f60fc2 Compare August 7, 2026 20:32

@szmyd szmyd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against CRAFT-Design / CRAFT-on-HomeBlocks. The guard ordering in write() is right, the idempotent early-return closes a real invariant hole, and the free-on-write_slot-failure fix is correct. Inline comments cover the substantive items; smaller things below.

Nothing here is blocking from my side. The strongest items are the gap cap and the last_append_lsn ordering, plus the two cross-repo consistency questions against craft_client#2.

Production backend has no test coverage

All three mocks stub write_slot, so HomeStoreCraftJournalBackend is never executed by any of the 22 tests. That's unfortunate placement — the value_awaitable / run_on_forget / INLINE-safety bridge is the subtlest code in the PR and it's exactly where the hang discussed inline lives. The three comment blocks explaining why it's correct are doing work a test should do.

Related: do_write's default is all_zeros=true, so most of the 22 tests exercise the zero path. Only four use do_write_with_data, and that helper sets data.size = 4096 with empty iovs, so nothing ever passes a real sg_list through alloc_write_data — the zero-copy requirement (SDSTOR-22873) is asserted in the ticket table but not covered by a test.

PR description contradicts the code

Commit 4 / Finding 2 says the fix is to "release the lock, then call free_data without holding missing_mu_." The code does not call free_data on the post-flight stale-term path, and the inline comment explains why not (the durable journal entry references the blkid; freeing would leave it dangling). The code's reasoning is the correct one — please fix the description so a reviewer doesn't come away believing blocks are reclaimed there.

On-disk format isn't locked down

CraftJournalEntry is a persisted format but has no static_assert on sizeof(). craft_client asserts the size of every wire struct precisely so a silent layout change becomes a compile error instead of a recovery bug; worth matching here. Two related points:

  • #pragma pack(1) / #pragma pack() resets to the default rather than restoring the previous value. push/pop is the safer idiom.
  • magic + version catches a garbage or non-CRAFT slot but not a bit flip in term / lsn / lba / the serialized blkid. Does the logstore already checksum the record body? If so a note saying so would settle it; if not, this format probably wants a CRC.

Smaller items

  • all_zeros=true with a non-empty payload isn't rejected. The header comment says "data must be empty in that case" but the code silently ignores the payload — the guard only covers the inverse (!all_zeros && data.size == 0).
  • write_slot memcpys blkid_sz bytes out of blkid_blob without checking blkid_blob.size() == blkid_sz. If serialize() ever returns a shorter view that's an overread.
  • run_on_forget(least_busy_io, ...) resumes the coroutine on an arbitrary io reactor, which then blocks on missing_mu_. Correct, but it puts a reactor thread behind a mutex held across another write's critical section — worth a thought for tail latency.
  • seed_empty's "apply_sync_rs_commit_lsn (S5) must do the same" relies on a future author reading a comment. Factoring the erase into one helper both call would make it structural instead of advisory.
  • The ublkpp ^0.35 -> ^0.36 bump isn't mentioned in the description; intentional?

Wiki follow-up (not this PR)

CRAFT-on-HomeBlocks.md:168-173 traces the durability argument through append_async -> flush() -> sync_pwritev -> on_flush_completion -> on_write_completion and concludes the completion "fires only after the bytes are synchronously written." True, but it never covers the failure branch, where no completion fires at all. I'll add that to the wiki — noting it here because this PR inherited the omission, which is most likely how the risk got characterized as silent loss rather than a hang.

Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
// (HomeStore/src/lib/logstore/log_store.cpp:71); complete() is never called and the coroutine
// stays suspended. Callers must drain in-flight writes before HomeStore shutdown.
//
// KNOWN GAP (I/O errors): write_async fires the same callback for success and failure with no

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This risk is real but misdiagnosed, and it's the same defect as the shutdown risk documented just above — not a second one. Checked against homestore dev/v8.x (a6936340):

// log_dev.cpp:531-539
// TODO:: add logic to handle this error in upper layer
auto error = m_vdev_jd->sync_pwritev(...);
if (error) {
    THIS_LOGDEV_LOG(ERROR, "Fail to sync write to journal vde , error code {} : {}", ...);
    return false;                    // returns WITHOUT calling on_flush_completion
}
on_flush_completion(lg);             // this is what raises each append's completion callback

On a journal I/O error the callback is never invoked at all, so va->complete() never fires and co_await *va suspends permanently. write_slot never reaches co_return ok() — so the write is not "acked despite failing," it's never acked. The comment's framing ("write_slot always returns ok() regardless of the I/O result") would send a future owner looking for the wrong bug, and the open question in the PR body — whether write_async calls back on I/O error or crashes the process — has a third answer: neither.

So shutdown (log_store.cpp:71, if (is_stopping()) return 0;) and journal I/O error (log_dev.cpp:533) both terminate in one failure mode: no callback, leaked value_awaitable, coroutine suspended forever. One mitigation covers both.

Not asking you to fix it in this PR. What would help:

  1. Correct the two comments to describe the actual failure (lost completion -> permanent suspension), since they're the only record a future owner gets.
  2. write_async's return value is a usable signal and is currently ignored — it returns 0 when the log store is stopping, and LogDev::append_async returns -1 when the logdev is stopping (log_dev.cpp:290). A <= 0 check turns the shutdown trigger into an error today with no HomeStore change.
  3. A ticket for the rest: a timeout on the await to bound the I/O-error case, and the upstream // TODO:: add logic to handle this error in upper layer at log_dev.cpp:531. That TODO is homestore confirming no error propagation exists, so the API extension the PR body asks for is error propagation into the completion callback, not a status argument on a callback that fires.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Both comments now describe the actual failure (lost completion → permanent suspension) with both triggers explained precisely, matching your log_dev.cpp/log_store.cpp citations.

On the return-value check: implemented your suggested <= 0 guard first, but a new test that exercises write_slot against a real HomeStore log store caught that this was wrong — LogDev::append_async returns its internal m_log_idx on success, which legitimately starts at 0 for the first-ever write to a fresh logdev, not the seq_num passed in. A <= 0 check would have rejected every legitimate first write as if the log store were stopping. Corrected to < 0 (only is_stopping() returns negative).

Amended SDSTOR-24993 with the corrected root cause (the flush-failure path never reaches the completion callback at all, not "fires without status") and the correct fix shape you identified — error propagation into the completion path itself, not a status argument bolted onto a callback that won't fire. Added the timeout-on-await as a tracked interim mitigation in the same ticket.

co_return std::unexpected(make_error_condition(std::errc::invalid_argument));
}
// Guard 2: cap the gap to prevent unbounded per-write allocation in missing_lsns_.
if (dlsn - state_.last_append_lsn > k_max_ooo_gap) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caps a single write's gap, but not cumulative growth of missing_lsns_. A client can walk the watermark forward 1M at a time — write 1'000'000, then 2'000'000, then 3'000'000 — and every write passes both guards while the set grows without bound. GapCapFenceposts demonstrates the walk incidentally: it seeds at 1'000'000 and then writes 1'000'002 successfully.

The DoS is narrowed rather than closed. A bound on missing_lsns_.size() (reject once the set exceeds some limit, independent of per-write distance) is what actually closes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added Guard 3: rejects a gap-creating write once missing_lsns_.size() already reached a cap, independent of this write's own gap distance — the size bound you asked for.

First implementation scoped it to dlsn > last_append_lsn, intending to exempt gap-fills. That was wrong: a strictly in-order write (dlsn == last_append_lsn + 1) also satisfies dlsn > last_append_lsn but creates zero new gap entries — so once the set hit the cap from unrelated OOO activity, every subsequent write including healthy sequential ones would have been permanently rejected. Corrected to dlsn > last_append_lsn + 1 (a real gap of at least one entry).

Four new tests: the sustained walk you described (three legal per-write jumps, fourth rejected once cumulative size crosses the cap), the exact >= fencepost boundary, a gap-fill succeeding at cap (proving the set can still drain), and an in-order write succeeding at cap (the case that exposed the scoping bug above).

if (!empty_lsns_.contains(gap)) missing_lsns_.insert(gap);
}
if ((dlsn > state_.last_append_lsn) || missing_lsns_.contains(dlsn)) missing_lsns_.insert(dlsn);
state_.last_append_lsn = std::max(state_.last_append_lsn, dlsn);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagged in the description as an intentional choice (SDSTOR-22871), but I'd push back a little.

CRAFT-Design's glossary defines last_append_lsn as "Highest LSN a replica has appended (present in its journal)". Advancing it here, before write_slot, and not rolling back on failure means the replica reports a watermark for a slot that is not in its journal. That value is not just informational — login computes rs_commit_lsn = max(quorum.last_append) from it, so a replica whose append failed still inflates the recovery watermark, and Phase 1b then has to resolve a slot nobody holds and declare it Empty. Correct-by-construction if the Empty machinery works, but it manufactures avoidable Empty verdicts out of local write failures.

WriteSlotFails_LsnRemainsInMissing pins the behavior with an explicit assertion (last_append_lsn() == 0 after a failed write), so if this is the intended semantics it's worth stating the reasoning in-source next to the assignment rather than only in the PR body — and confirming with the design that an inflated last_append is acceptable input to the login watermark.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the in-source reasoning directly above the assignment, grounded in CRAFT-Design's "the recovery watermark is forced" argument (false-include tolerated as benign, false-exclude the only catastrophic case) rather than just this backend's Missing bookkeeping. Also corrected SDSTOR-22871, which previously said "on successful append" and didn't match the shipped behavior.

// std::make_error_condition(std::errc::*) directly rather than duplicated here.
ENUM(volume_error, uint16_t, UNKNOWN_VOLUME = 1, CRC_MISMATCH, INDEX_ERROR, INTERNAL_ERROR, OFFLINE, STALE_TERM);
ENUM(volume_error, uint16_t, UNKNOWN_VOLUME = 1, CRC_MISMATCH, INDEX_ERROR, INTERNAL_ERROR, OFFLINE, STALE_TERM,
EMPTY_SLOT);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EMPTY_SLOT has no representation on the CRAFT wire. craft_error in craft_client is STALE_TERM, NOT_LEADER, NO_QUORUM, WRONG_TOKEN, NOT_ELIGIBLE, REPLICA_DOWN (+ INTERNAL in szmyd/craft_client#2), and the wire status byte mirrors 1-6, so this condition can't survive to_wire_status — a client writing into an Empty-verdicted slot gets a generic failure instead of the specific one.

Worth adding the matching value to craft_error and the wire status table in the same cycle as craft_client#2, otherwise the specificity added here is lost at the transport boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified directly against the craft_client headers — confirmed craft_error/to_wire_status only cover the six values, no Empty-slot equivalent.

Posted a review comment on craft_client#2 directly (it's still open, and already touching this exact enum for INTERNAL/NOT_IMPLEMENTED), asking for an Empty-slot value to be added there while that window is open, rather than deferring to S9. Kept SDSTOR-25280 as a backstop sub-task in case craft_client#2 merges without it.

Comment thread src/lib/craft/craft_repl_dev.hpp Outdated
bool all_zeros{false};
lba_t lba{0};
lba_count_t len{0};
lba_t lba{0}; // BYTES, not block index — mirrors CraftJournalEntry.lba semantics

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This redefines the units of an existing struct without touching its types, and it now disagrees with craft_client. craft::JournalSlot there has the same name and the same lba_t lba / lba_count_t len fields, but the in-memory reference model populates them as block units (slot.lba = addr / page_size_, src/mem/replica.cpp:249).

Two same-named structs with identically-typed fields carrying opposite units across the two repos is a unit bug waiting to happen, especially once S9 CraftConnector bridges them. Storing a byte count in something called lba_count_t is also a readability trap independent of the cross-repo issue.

Since both PRs are in flight, worth settling now — either convert at the boundary and keep JournalSlot in block units, or rename the fields here (lba_off_bytes / len_bytes) so the difference is visible at every use site rather than in a comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to lba_off_bytes/len_bytes, confined to this backend's own JournalSlot (craft_repl_dev.hpp) — doesn't touch craft_client or CraftJournalEntry, since the byte↔block conversion boundary is still deferred to S3 per api.md.

- 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.
…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.
…, 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.
Two occurrences in MockCraftJournalBackend: fail_lsns lookup in write_slot
and the has_slot predicate. Both are C++20 std::set / std::map members.
szmyd (PR eBay#171 review 4900497567): the "KNOWN GAP (I/O errors)" comment was
wrong — write_async does not fire the callback with a missing status on a
journal I/O error, it never fires the callback at all (log_dev.cpp:531-539
returns before calling on_flush_completion). Same failure shape as the
shutdown trigger: lost completion, coroutine suspended forever.

Rewrite the comment to describe the actual single failure mode with both
triggers, and add a write_ret <= 0 guard on write_async's return value so
the shutdown/logdev-stopping trigger fails fast today instead of hanging
(write_async returns non-positive when the log store or logdev is
stopping, per log_store.cpp:71 and log_dev.cpp:290). The journal I/O error
trigger has no such return-value signal and remains tracked in
SDSTOR-24993, now amended with the corrected root cause and an interim
timeout-on-await mitigation.
szmyd (PR eBay#171 review 4900497567): Guard 2 only bounds a single write's
gap contribution. A client walking the watermark forward in cap-sized
increments (e.g. +1,000,000 repeatedly) never trips Guard 2 individually,
yet missing_lsns_ grows without bound across writes — the DoS was
narrowed, not closed.

Add Guard 3: reject a gap-creating write (dlsn > last_append_lsn) once
missing_lsns_.size() already reached a cap, independent of this write's
own gap distance. Gap-filling writes (dlsn <= last_append_lsn, already in
missing_lsns_) are exempt since they shrink the set and must not be
blocked by this cap, or the set could never drain once at capacity.

New test CumulativeMissingCapRejectsSustainedWalk reproduces the walk he
described (three ~1,000,000-gap writes each individually legal, fourth
rejected once cumulative size crosses the cap).
…e UTs

Guard 3 (cumulative missing_lsns_ cap) used dlsn > last_append_lsn to scope
which writes it applies to, intending to exempt gap-fills. That condition
is also true for a strictly in-order write (dlsn == last_append_lsn + 1),
which creates zero new gap entries -- so once missing_lsns_ hit the cap
from unrelated OOO activity, every subsequent write including healthy
sequential ones would be permanently rejected.

Correct condition: dlsn > last_append_lsn + 1, i.e. a real gap of at least
one entry. Add three corner-case tests: the exact >= fencepost boundary,
a gap-fill succeeding at cap (proving the set can still drain), and an
in-order write succeeding at cap (the case that exposed the bug).
…ighten guard comments

szmyd (PR eBay#171 review 4900497567): the eager, non-rolled-back advance of
last_append_lsn was flagged in the PR description only, not in-source, and
he asked whether an inflated last_append is acceptable login-watermark
input.

Ground the in-source comment in CRAFT-Design's own recovery-watermark
argument ("max(quorum.last_append) is forced"): login already tolerates
false-include (an LSN counted that turns out not quorum-durable) as
benign, reserving false-exclude as the only catastrophic case. A failed
local append is indistinguishable, to login, from a write that never
reached quorum -- Phase 1b resolves it via FetchData + Empty verdict
either way, at the cost of one avoidable Empty verdict, never data loss.

Also tighten the Guard 3 and write_slot lost-completion comments added
earlier this session for concision.

Open: this in-source grounding is not the same as design-owner sign-off:
whether an inflated last_append is acceptable login input per szmyd's
literal ask still needs his confirmation. SDSTOR-22871's title ("...on
successful journal append") also does not match the accepted behavior
(advance is unconditional) -- flagged, not corrected here.
szmyd (PR eBay#171 review 4900497567): craft::JournalSlot in craft_client has
the same name and identically-typed lba_t/lba_count_t fields, but
populates them in block units (src/mem/replica.cpp). This backend's
JournalSlot stores bytes in the same-named fields -- a unit bug waiting
to happen once S9 CraftConnector bridges the two, and a comment alone
wasn't enough (per his follow-up: two same-named cross-repo structs
disagreeing needs to be visible at every use site, not just where it's
declared).

Rename to lba_off_bytes/len_bytes so the byte unit is part of the name.
Confined to this backend's own JournalSlot; does not touch craft_client
or CraftJournalEntry (no cross-repo name collision there).
write_ret <= 0 was wrong: LogDev::append_async returns its internal
m_log_idx on success (log_dev.cpp:292-302), which starts at 0 for the
first-ever write to a fresh logdev -- not the lsn/dLSN passed in. Only
is_stopping() returns -1. The <= 0 guard rejected every legitimate first
write to a fresh logdev as if the log store were stopping.

Caught by the new HomeStoreCraftJournalBackendTest (following commit)
running against a real HomeStore log store -- the first CRAFT test to
exercise write_async's actual return semantics rather than a mock.

Guard corrected to write_ret < 0.
szmyd (PR eBay#171 review 4900497567): "the value_awaitable/run_on_forget/
INLINE-safety bridge is the subtlest code in the PR ... the three comment
blocks explaining why it's correct are doing work a test should do."
make_homestore_journal_backend has no production call site yet
(S8/SDSTOR-22745 is first), so no test previously exercised it -- all
existing CRAFT tests stub write_slot via MockCraftJournalBackend.

Brings up a real HomeStore instance via test_common.hpp's HBTestHelper
(same fixture test_volume/test_volume_io already use), creates a real
logdev/log_store directly via homestore::logstore_service(), and drives
HomeStoreCraftJournalBackend::write_slot through it with TIMER | INLINE
flush mode -- the same configuration solo_repl_dev uses, and the mode
where write_async's completion can fire before await_suspend returns.

This is the heaviest CRAFT test in the suite (needs the full HomeBlocks/
HomeStore bring-up, ~5s) and links the full homeblocks library rather
than compiling craft_repl_dev.cpp directly like the other CRAFT tests.
Kept in its own commit: discard independently if the approach doesn't
hold up, without touching the off-by-one fix in the prior commit (which
this test is what surfaced).
…hunk

m_volume_chunks is pre-sized to MAX_NUM_VOLUMES in the constructor, so an
unregistered ordinal is in-bounds but maps to a null shared_ptr slot.
select_chunk indexed it and immediately dereferenced (volc->m_chunks)
with no null check, unlike its sibling get_chunks() which already guards
the same case. Any caller passing an application_hint for a volume that
was never assigned chunks (e.g. no volume created yet) crashes the whole
process instead of failing the allocation.

Found via a new CRAFT test (HomeStoreCraftJournalBackend::alloc_write_data
against a real data service, no volume created) that segfaulted here.
Return nullptr for both the out-of-bounds and the unregistered-but-in-bounds
case, matching the existing early return for a missing application_hint.
… coverage

Adds two more HomeStoreCraftJournalBackend tests against the real
HomeStore fixture from the prior commit:

- TruncateToRollsBackRealLogStore: verifies rollback drops entries above
  a given lsn, checked via the real log_store's own tail_lsn() (read_slot
  isn't implemented yet, so this is the only available verification).

- AllocWriteDataFailsCleanlyForUnregisteredOrdinal: a regression test for
  the VolumeChunkSelector null-deref just fixed. No volume exists in this
  test (make_homestore_journal_backend has no production call site yet),
  so vol_ordinal=0 is unregistered by construction -- this asserts
  alloc_write_data now fails cleanly instead of crashing the process.
  A true success-path test (real chunks allocated) would require driving
  the full volume-creation path, which is out of scope for a backend-level
  unit test; VolumeChunkSelector has no external accessor to register
  chunks for an ordinal directly.
szmyd (PR eBay#171 review 4900497567): do_write_with_data sets data.size
without real iovs, so no existing test ever passes a real sg_list
through alloc_write_data -- the zero-copy requirement (SDSTOR-22873) is
claimed Resolved but wasn't actually verified.

MockCraftJournalBackend now captures the iov_base seen by
alloc_write_data. New test builds a real buffer and asserts the same
pointer survives the full write() -> alloc_write_data path, which is
the only way a hidden copy would be detectable.
szmyd (PR eBay#171 review 4900497567): a persisted format with no
static_assert on sizeof() lets a layout change silently become a
recovery bug instead of a compile error; craft_client already does this
for every wire struct.

- static_assert(sizeof(CraftJournalEntry) == 34, ...)
- #pragma pack(push, 1) / pack(pop) instead of pack(1) / pack(), which
  resets to the default rather than restoring whatever packing was
  previously in effect
- CRC: checked against HomeStore's actual log-dev source
  (log_group.cpp:168-176, log_stream.cpp:134-145, log_dev.cpp:367-372).
  LogGroup::compute_crc() already checksums the entire record body on
  every append and verifies it on read/recovery replay, hard failure on
  mismatch -- a CRAFT-level CRC would be redundant. Documented in-source
  so this isn't re-litigated later.
szmyd (PR eBay#171 review 4900497567): the guard only covered
!all_zeros && data.size == 0; the inverse bad combination
(all_zeros=true with a non-empty sg_list) was silently accepted and the
payload dropped. WRITE_ZEROES/unmap names a range and must not also
carry data.

Both bad combinations now share one symmetric check:
all_zeros == (data.size > 0). New test covers the previously-unguarded
direction.
szmyd (PR eBay#171 review 4900497567): write_slot memcpys blkid_sz bytes out
of blkid_blob without checking blkid_blob.size() == blkid_sz. If
serialize() ever returns a shorter view than serialized_size() promised,
that's an overread.

DEBUG_ASSERT_EQ, matching this file's existing convention for internal,
non-client-reachable invariants (cf. the truncate-below-commit_lsn guard)
-- this is a HomeStore-API contract, not attacker-controlled input.
Comments should stand on their own technical merit, not attribute
rationale to a reviewer or point at a ticket number that will drift out
of date. No behavior change.
Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
…nction

The CRAFT async_write() free function (home_blocks.hpp/craft_api.cpp) never
exposed CraftReplDev::write()'s all_zeros parameter, always forwarding the
default (false). Its doc comment still described an older "empty data means
zero write" convention that write() itself no longer implements -- write()
requires the flag and data emptiness to agree, rejecting a mismatch outright.
Net effect: nothing calling the public API could ever perform a zero write,
and an empty-data write attempt (following the stale doc's convention) would
be hard-rejected rather than treated as a zero write. Nothing in this repo
currently calls this free function's CRAFT overload (all existing callers use
the legacy 3-arg async_write()), so this closes a real gap before anything
does.
Comment thread src/lib/craft/craft_repl_dev.cpp Outdated
sbinmalek
sbinmalek previously approved these changes Aug 29, 2026

@sbinmalek sbinmalek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Comment thread conanfile.py
The earlier all_zeros/data.size consistency check already proves that
!all_zeros implies data.size > 0, making the redundant && data.size > 0
sub-condition unnecessary.

@sbinmalek sbinmalek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@shosseinimotlagh
shosseinimotlagh merged commit 1181da7 into eBay:dev/v6.x Aug 30, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants