diff --git a/conanfile.py b/conanfile.py index 1e89fce..7adca3b 100644 --- a/conanfile.py +++ b/conanfile.py @@ -10,7 +10,7 @@ class HomeBlocksConan(ConanFile): name = "homeblocks" - version = "6.0.2" + version = "6.0.3" homepage = "https://github.com/eBay/HomeBlocks" description = "Block Store built on HomeStore" @@ -48,7 +48,7 @@ def configure(self): def build_requirements(self): self.test_requires("gtest/[^1.17]") - self.test_requires("ublkpp/[^0.34]@oss/main") + self.test_requires("ublkpp/[^0.35]@oss/main") def requirements(self): self.requires("homestore/[^8.0]@oss/dev", transitive_headers=True) diff --git a/src/lib/craft/CMakeLists.txt b/src/lib/craft/CMakeLists.txt index e74bc3b..824c4fc 100644 --- a/src/lib/craft/CMakeLists.txt +++ b/src/lib/craft/CMakeLists.txt @@ -13,3 +13,5 @@ target_link_libraries(${PROJECT_NAME}_craft # (the volume_handle free functions) and craft_repl_dev.cpp is the engine behind it. The CRAFT client, the wire # codec, the in-memory reference model and the reference server all live in the standalone craft_client package; # the ublk driver lives in ublkpp. None of them are built or linked here. + +add_subdirectory(tests) diff --git a/src/lib/craft/craft_repl_dev.cpp b/src/lib/craft/craft_repl_dev.cpp index 6d8b7ef..23905bb 100644 --- a/src/lib/craft/craft_repl_dev.cpp +++ b/src/lib/craft/craft_repl_dev.cpp @@ -15,25 +15,127 @@ #include "craft_repl_dev.hpp" +#include // home_log_store, logstore_seq_num_t + namespace homeblocks { +// ─── HomeStore journal backend ──────────────────────────────────────────────── +// +// Production backend: wraps a HomeStore home_log_store. write_slot / read_slot +// are stubs implemented in S2 (PR #165). truncate_to() is implemented here (S4). + +class HomeStoreCraftJournalBackend : public CraftJournalBackend { +public: + explicit HomeStoreCraftJournalBackend(shared< homestore::home_log_store > logstore) : + logstore_{std::move(logstore)} {} + + async_status write_slot(int64_t lsn, lba_t /* lba */, lba_count_t /* len */, sisl::sg_list /* data */) override { + LOGW("HomeStoreCraftJournalBackend::write_slot lsn={} not yet implemented", lsn); + co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); + } + + async_result< JournalSlot > read_slot(int64_t lsn) override { + LOGW("HomeStoreCraftJournalBackend::read_slot lsn={} not yet implemented", lsn); + co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); + } + + // Drop all journal entries with seq_num > lsn; lsn becomes the new tail. + // home_log_store::rollback(to_lsn) removes everything ABOVE to_lsn, which is exactly what we need. + async_status truncate_to(int64_t lsn) override { + if (!logstore_->rollback(static_cast< homestore::logstore_seq_num_t >(lsn))) + co_return std::unexpected(std::make_error_condition(std::errc::io_error)); + co_return ok(); + } + +private: + shared< homestore::home_log_store > logstore_; +}; + +unique< CraftJournalBackend > make_homestore_journal_backend(shared< homestore::home_log_store > logstore) { + return std::make_unique< HomeStoreCraftJournalBackend >(std::move(logstore)); +} + // ─── constructor ────────────────────────────────────────────────────────────── CraftReplDev::CraftReplDev(volume_id_t vol_id, unique< CraftJournalBackend > journal) : vol_id_{vol_id}, journal_{std::move(journal)}, raft_listener_{this} {} // ─── get_lsns / get_rs_commit_lsn ──────────────────────────────────────────── -// These are real: they just snapshot the in-memory partition state. +// Snapshot the in-memory partition state under missing_mu_ for consistency with +// write() which updates state_ under the same lock. async_result< craft::lsn_pair > CraftReplDev::get_lsns(volume_id_t /* vol_id */) { - co_return craft::lsn_pair{state_.commit_lsn, state_.last_append_lsn}; + craft::lsn_pair pair{}; + { + std::lock_guard lk{missing_mu_}; + pair = {state_.commit_lsn, state_.last_append_lsn}; + } + co_return pair; } async_result< craft::lsn_pair > CraftReplDev::get_rs_commit_lsn() { - co_return craft::lsn_pair{state_.commit_lsn, state_.last_append_lsn}; + craft::lsn_pair pair{}; + { + std::lock_guard lk{missing_mu_}; + pair = {state_.commit_lsn, state_.last_append_lsn}; + } + co_return pair; +} + +// ─── truncate (S4) ──────────────────────────────────────────────────────────── +// +// Called only during the login sequence, while no writes are in-flight (the +// CRAFT write path is quiesced by login serialisation). Three atomic steps: +// 1. Journal rollback: drop all entries with dLSN > lsn (tail truncation). +// 2. Clamp last_append_lsn to lsn if it is higher. +// 3. Erase all missing-set entries above lsn. +// commit_lsn is not touched: the new rs_commit_lsn passed by the caller is the +// dLSN up to which RAFT consensus has RESOLVED entries. Entries above that are +// the ones being dropped. The missing set tracks gaps in [commit_lsn+1, +// last_append_lsn]; after truncation every entry > lsn is gone from both the +// journal and the missing set. + +async_status CraftReplDev::truncate(int64_t lsn) { + // Guard before touching the journal: truncating below commit_lsn would drop + // committed entries and break the commit_lsn <= last_append_lsn invariant. + { + std::lock_guard lk{missing_mu_}; + DEBUG_ASSERT_GE(lsn, state_.commit_lsn, "truncate below committed prefix"); + } + + // Step 1: journal rollback — synchronous; fails fast on I/O error. + if (auto r = co_await journal_->truncate_to(lsn); !r) co_return r; + + // Steps 2 + 3 under the same mutex write() uses. + { + std::lock_guard lk{missing_mu_}; + if (state_.last_append_lsn > lsn) state_.last_append_lsn = lsn; + missing_lsns_.erase(missing_lsns_.upper_bound(lsn), missing_lsns_.end()); + } + co_return ok(); +} + +#ifdef _PRERELEASE +void CraftReplDev::seed_lsns(int64_t last_append, std::initializer_list< int64_t > missing) { + std::lock_guard lk{missing_mu_}; + state_.last_append_lsn = last_append; + missing_lsns_.clear(); + missing_lsns_.insert(missing); } -// ─── stubs (S2–S7 will implement these) ────────────────────────────────────── +void CraftReplDev::seed_commit_lsn(int64_t commit) { + std::lock_guard lk{missing_mu_}; + state_.commit_lsn = commit; +} + +void CraftReplDev::seed_empty(std::initializer_list< int64_t > empty) { + std::lock_guard lk{missing_mu_}; + empty_lsns_.clear(); + empty_lsns_.insert(empty); +} +#endif + +// ─── stubs (S1/S2/S3/S5/S6/S7 implement these) ─────────────────────────────── async_result< craft::LoginResult > CraftReplDev::login(uint64_t /* client_token */) { LOGW("CraftReplDev::login not yet implemented"); @@ -69,19 +171,59 @@ async_result< craft::resolution_result > CraftReplDev::request_resolution(craft: co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); } -async_status CraftReplDev::truncate(int64_t /* lsn */) { - LOGW("CraftReplDev::truncate not yet implemented"); - co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); -} - async_status CraftReplDev::append(int64_t /* sync_to */, uint64_t /* client_token */) { LOGW("CraftReplDev::append not yet implemented"); co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); } -async_result< std::vector< JournalSlot > > CraftReplDev::fetch_data(std::vector< int64_t > /* lsns */) { - LOGW("CraftReplDev::fetch_data not yet implemented"); - co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); +// ─── fetch_data (S6) ────────────────────────────────────────────────────────── +// +// Four-way response per requested LSN (in request order; omitted slots absent): +// is_empty=true : slot positively verdicted Empty by a prior SyncRSCommitLSN (S5); Empty beats data +// present+data : slot in journal, all_zeros=false; data payload present +// present+zero : slot in journal, all_zeros=true; no data payload (WRITE_ZEROES / range-unmap) +// omitted : slot not locally held — in missing_lsns_ or above last_append_lsn +// +// The Empty verdict is leader-only (S5 pre-resolution); this responder only reports state. +// empty_lsns_ is checked first: a slot in both empty_lsns_ and the journal returns is_empty=true +// (Empty beats data, the reconciliation invariant from S5). +// +// The missing_mu_ lock is dropped before each co_await read_slot() call to avoid holding a mutex +// across a suspension point. Callers are serialised by the login sequence (no concurrent writes +// while fetch_data runs), so the snapshot taken under the lock is stable. +// +// A read_slot() I/O error aborts the batch immediately (fail-fast); the partial result is discarded. + +async_result< std::vector< JournalSlot > > CraftReplDev::fetch_data(std::vector< int64_t > lsns) { + std::vector< JournalSlot > result; + result.reserve(lsns.size()); + + for (int64_t lsn : lsns) { + enum class SlotKind { Empty, Present, Absent }; + SlotKind kind; + { + std::lock_guard lk{missing_mu_}; + if (empty_lsns_.contains(lsn)) { + kind = SlotKind::Empty; + } else if (lsn >= 0 && lsn <= state_.last_append_lsn && !missing_lsns_.contains(lsn)) { + kind = SlotKind::Present; + } else { + kind = SlotKind::Absent; + } + } + + if (kind == SlotKind::Empty) { + result.push_back(JournalSlot{.lsn = lsn, .is_empty = true}); + } else if (kind == SlotKind::Present) { + auto slot_r = co_await journal_->read_slot(lsn); + if (!slot_r) co_return std::unexpected(slot_r.error()); + slot_r->lsn = lsn; + result.push_back(std::move(*slot_r)); + } + // Absent: omit from result (not-present-here) + } + + co_return result; } // ─── RAFT listener ──────────────────────────────────────────────────────────── @@ -95,17 +237,14 @@ void CraftReplDev::CraftRaftListener::on_commit(int64_t lsn, sisl::blob const& / LOGD("CraftRaftListener::on_commit lsn={} (entry dispatch not yet implemented)", lsn); } -// ─── RAFT apply helpers (S5 will implement) ─────────────────────────────────── +// ─── RAFT apply helpers (S5 implements) ────────────────────────────────────── void CraftReplDev::apply_sync_rs_commit_lsn(int64_t rs_commit_lsn, uint64_t /* client_token */) { - // Advance commit_lsn to rs_commit_lsn, calling fetch_data() for any missing slots. LOGD("apply_sync_rs_commit_lsn rs_commit_lsn={} (not yet implemented)", rs_commit_lsn); } void CraftReplDev::apply_internal_login(uint64_t client_token, uint64_t term) { - // Update state_.client_token and state_.term; subsequent IOs with a different - // term will be rejected with ETERM. LOGD("apply_internal_login client_token={} term={} (not yet implemented)", client_token, term); } -} // namespace homeblocks \ No newline at end of file +} // namespace homeblocks diff --git a/src/lib/craft/craft_repl_dev.hpp b/src/lib/craft/craft_repl_dev.hpp index 27b4a8b..bc6f508 100644 --- a/src/lib/craft/craft_repl_dev.hpp +++ b/src/lib/craft/craft_repl_dev.hpp @@ -18,10 +18,15 @@ #include #include +#include #include #include #include +namespace homestore { +class home_log_store; +} + namespace homeblocks { // ─── CRAFT vocabulary vs. this backend's own state ─────────────────────────── @@ -67,14 +72,20 @@ class CraftJournalBackend { public: virtual async_status write_slot(int64_t lsn, lba_t lba, lba_count_t len, sisl::sg_list data) = 0; virtual async_result< JournalSlot > read_slot(int64_t lsn) = 0; + // Drop all entries with seq_num > lsn; lsn becomes the new journal tail. virtual async_status truncate_to(int64_t lsn) = 0; virtual ~CraftJournalBackend() = default; }; +// Factory that wraps a HomeStore log store. Used by volume.cpp when creating a CRAFT-mode volume. +// Tests inject MockCraftJournalBackend directly. +unique< CraftJournalBackend > make_homestore_journal_backend(shared< homestore::home_log_store > logstore); + // ─── CraftReplDev ───────────────────────────────────────────────────────────── // -// Parallel to HomeStore's ReplDisk. Each CRAFT-mode volume owns one instance -// instead of the solo repl_dev. Non-CRAFT volumes are unaffected. +// One instance per CRAFT-mode volume. Implements the full CRAFT data plane +// (write, read, login, truncate, ...) on top of a HomeStore log store and +// index. Non-CRAFT volumes are unaffected. class CraftReplDev { public: @@ -140,7 +151,8 @@ class CraftReplDev { // Alias of get_lsns exposed to peer servers during GetRSCommitLSN broadcast. async_result< craft::lsn_pair > get_rs_commit_lsn(); - // Drop all journal entries with dLSN > lsn; clear missing-set entries above lsn. + // Drop all journal entries with dLSN > lsn; clear missing-set entries above lsn; clamp last_append_lsn. + // Called only during login (quiesced -- no concurrent writes). commit_lsn is NOT changed. async_status truncate(int64_t lsn); // Propose a SyncRSCommitLSN RAFT entry (called by watchdog or leader during login). @@ -150,6 +162,42 @@ class CraftReplDev { // JournalSlot{.is_empty=true} rather than an error. async_result< std::vector< JournalSlot > > fetch_data(std::vector< int64_t > lsns); + // ── observability ───────────────────────────────────────────────────── + + size_t missing_count() const { + std::lock_guard lk{missing_mu_}; + return missing_lsns_.size(); + } + + bool is_missing(int64_t lsn) const { + std::lock_guard lk{missing_mu_}; + return missing_lsns_.contains(lsn); + } + + bool is_empty_slot(int64_t lsn) const { + std::lock_guard lk{missing_mu_}; + return empty_lsns_.contains(lsn); + } + + int64_t last_append_lsn() const { + std::lock_guard lk{missing_mu_}; + return state_.last_append_lsn; + } + int64_t commit_lsn() const { + std::lock_guard lk{missing_mu_}; + return state_.commit_lsn; + } + +#ifdef _PRERELEASE + // Seeds partition watermarks and the missing set directly, bypassing write(). + // Only compiled when _PRERELEASE is defined; never present in production binaries. + void seed_lsns(int64_t last_append, std::initializer_list< int64_t > missing = {}); + // Seeds commit_lsn independently of seed_lsns (which only touches last_append + missing). + void seed_commit_lsn(int64_t commit); + // Seeds the Empty-verdict set; does not affect missing_lsns_. Clears any prior seeded empties. + void seed_empty(std::initializer_list< int64_t > empty); +#endif + private: // ── RAFT listener ────────────────────────────────────────────────────── // @@ -215,10 +263,11 @@ class CraftReplDev { unique< CraftJournalBackend > journal_; CraftPartitionState state_; std::set< int64_t > missing_lsns_; // gaps between commit_lsn and last_append_lsn - std::mutex missing_mu_; + std::set< int64_t > empty_lsns_; // slots positively verdicted Empty by a prior SyncRSCommitLSN (S5) + mutable std::mutex missing_mu_; // guards state_, missing_lsns_, and empty_lsns_ bool login_in_progress_{false}; std::mutex login_mu_; CraftRaftListener raft_listener_; }; -} // namespace homeblocks \ No newline at end of file +} // namespace homeblocks diff --git a/src/lib/craft/tests/CMakeLists.txt b/src/lib/craft/tests/CMakeLists.txt new file mode 100644 index 0000000..a43aeb2 --- /dev/null +++ b/src/lib/craft/tests/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.11) + +# Unit test for CraftReplDev::truncate (S4). Compiles craft_repl_dev.cpp directly to avoid +# dragging in craft_api.cpp → volume.hpp (heavy HomeStore volume plumbing not needed here). +add_executable(test_craft_truncate) +target_sources(test_craft_truncate PRIVATE + test_craft_truncate.cpp + ../craft_repl_dev.cpp +) +target_compile_definitions(test_craft_truncate PRIVATE _PRERELEASE) +target_link_libraries(test_craft_truncate + ${COMMON_TEST_DEPS} + -rdynamic +) + +add_test(NAME CraftTruncateTest COMMAND test_craft_truncate) + +# Unit tests for CraftReplDev::get_lsns(), get_rs_commit_lsn(), and fetch_data() (S6). +# Same pattern as test_craft_truncate: compile craft_repl_dev.cpp directly. +add_executable(test_craft_peer_exchange) +target_sources(test_craft_peer_exchange PRIVATE + test_craft_peer_exchange.cpp + ../craft_repl_dev.cpp +) +target_compile_definitions(test_craft_peer_exchange PRIVATE _PRERELEASE) +target_link_libraries(test_craft_peer_exchange + ${COMMON_TEST_DEPS} + -rdynamic +) + +add_test(NAME CraftPeerExchangeTest COMMAND test_craft_peer_exchange) diff --git a/src/lib/craft/tests/test_craft_peer_exchange.cpp b/src/lib/craft/tests/test_craft_peer_exchange.cpp new file mode 100644 index 0000000..6cdcbf3 --- /dev/null +++ b/src/lib/craft/tests/test_craft_peer_exchange.cpp @@ -0,0 +1,266 @@ +/********************************************************************************* + * Modifications Copyright 2026 eBay Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + *********************************************************************************/ + +// Unit tests for CraftReplDev::get_lsns(), get_rs_commit_lsn(), and fetch_data() +// (S6: Peer Data Exchange APIs). +// +// fetch_data() implements a four-way response per slot: +// present+data : slot in journal, all_zeros=false +// present+zero : slot in journal, all_zeros=true (WRITE_ZEROES / range-unmap) +// omitted : slot not locally held (in missing_lsns_ or above last_append_lsn) +// is_empty : slot positively verdicted Empty via a prior S5 SyncRSCommitLSN apply +// +// The Empty verdict is leader-only (S5 pre-resolution); fetch_data responders +// report state only — they never declare a slot Empty themselves. +// +// This TU defines SISL_LOGGING_DEF for the homeblocks module because it compiles +// craft_repl_dev.cpp directly (same pattern as test_craft_truncate.cpp). + +#include +#include +#include +#include + +#include "craft/craft_repl_dev.hpp" +#include "coro_helpers.hpp" + +SISL_LOGGING_DEF(HOMEBLOCKS_LOG_MODS) +SISL_LOGGING_INIT(HOMEBLOCKS_LOG_MODS) + +namespace homeblocks { +namespace { + +// ── journal mock ────────────────────────────────────────────────────────────── +// +// Backed by a std::map so tests can seed exact JournalSlot values per LSN. +// fail_on_read optionally injects an I/O error for a specific LSN to exercise +// the error-propagation path of fetch_data. + +class MockCraftJournalBackend : public CraftJournalBackend { +public: + std::map< int64_t, JournalSlot > slots; + std::optional< int64_t > fail_on_read; // if set, read_slot for that LSN returns io_error + + async_status write_slot(int64_t, lba_t, lba_count_t, sisl::sg_list) override { + co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); + } + + async_result< JournalSlot > read_slot(int64_t lsn) override { + if (fail_on_read && *fail_on_read == lsn) + co_return std::unexpected(std::make_error_condition(std::errc::io_error)); + auto it = slots.find(lsn); + if (it == slots.end()) + co_return std::unexpected(std::make_error_condition(std::errc::no_such_file_or_directory)); + co_return it->second; + } + + async_status truncate_to(int64_t) override { co_return ok(); } +}; + +// ── test fixture ───────────────────────────────────────────────────────────── + +class CraftPeerExchangeTest : public ::testing::Test { +protected: + void SetUp() override { + auto mock = std::make_unique< MockCraftJournalBackend >(); + journal_ = mock.get(); + dev_ = std::make_unique< CraftReplDev >(volume_id_t{}, std::move(mock)); + } + + auto do_get_lsns() { return homeblocks::detail::sync_get(dev_->get_lsns(volume_id_t{})); } + auto do_get_rs_commit_lsn() { return homeblocks::detail::sync_get(dev_->get_rs_commit_lsn()); } + auto do_fetch_data(std::vector< int64_t > lsns) { + return homeblocks::detail::sync_get(dev_->fetch_data(std::move(lsns))); + } + + // Seed a data slot into the mock journal. lba and len default to small non-zero values. + void add_slot(int64_t lsn, lba_t lba = 0, lba_count_t len = 4, bool all_zeros = false) { + journal_->slots[lsn] = JournalSlot{.lsn = lsn, .all_zeros = all_zeros, .lba = lba, .len = len}; + } + + MockCraftJournalBackend* journal_{nullptr}; + std::unique_ptr< CraftReplDev > dev_; +}; + +// ── get_lsns / get_rs_commit_lsn ───────────────────────────────────────────── + +// Fresh device: both watermarks default to -1 (uninitialized sentinel). +TEST_F(CraftPeerExchangeTest, GetLsnsDefaultState) { + auto r = do_get_lsns(); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->commit_lsn, -1); + EXPECT_EQ(r->last_append_lsn, -1); +} + +// After seeding, get_lsns reflects both watermarks correctly. +TEST_F(CraftPeerExchangeTest, GetLsnsAfterSeed) { + dev_->seed_lsns(50, {30, 40}); + dev_->seed_commit_lsn(25); + auto r = do_get_lsns(); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->commit_lsn, 25); + EXPECT_EQ(r->last_append_lsn, 50); +} + +// get_rs_commit_lsn is an alias of get_lsns; both must return the same snapshot. +TEST_F(CraftPeerExchangeTest, GetRsCommitLsnMatchesGetLsns) { + dev_->seed_lsns(100, {}); + dev_->seed_commit_lsn(80); + auto lsns_r = do_get_lsns(); + auto rs_r = do_get_rs_commit_lsn(); + ASSERT_TRUE(lsns_r.has_value()); + ASSERT_TRUE(rs_r.has_value()); + EXPECT_EQ(lsns_r->commit_lsn, rs_r->commit_lsn); + EXPECT_EQ(lsns_r->last_append_lsn, rs_r->last_append_lsn); +} + +// ── fetch_data ──────────────────────────────────────────────────────────────── + +// Requesting zero LSNs returns an empty result vector, not an error. +TEST_F(CraftPeerExchangeTest, FetchDataEmptyRequest) { + auto r = do_fetch_data({}); + ASSERT_TRUE(r.has_value()); + EXPECT_TRUE(r->empty()); +} + +// A slot present in the journal (not missing, within last_append) returns with its data fields. +TEST_F(CraftPeerExchangeTest, FetchDataPresentData) { + add_slot(5, /*lba=*/10, /*len=*/4); + dev_->seed_lsns(5, {}); + + auto r = do_fetch_data({5}); + ASSERT_TRUE(r.has_value()); + ASSERT_EQ(r->size(), 1u); + EXPECT_EQ((*r)[0].lsn, 5); + EXPECT_FALSE((*r)[0].is_empty); + EXPECT_FALSE((*r)[0].all_zeros); + EXPECT_EQ((*r)[0].lba, 10u); + EXPECT_EQ((*r)[0].len, 4u); +} + +// A zero-write slot (all_zeros=true) is returned with that flag set and no data payload. +TEST_F(CraftPeerExchangeTest, FetchDataPresentZeroWrite) { + add_slot(7, /*lba=*/20, /*len=*/8, /*all_zeros=*/true); + dev_->seed_lsns(7, {}); + + auto r = do_fetch_data({7}); + ASSERT_TRUE(r.has_value()); + ASSERT_EQ(r->size(), 1u); + EXPECT_EQ((*r)[0].lsn, 7); + EXPECT_FALSE((*r)[0].is_empty); + EXPECT_TRUE((*r)[0].all_zeros); +} + +// Slots both at or below commit_lsn (committed) and above it (appended-only) are returned; +// fetch_data does not gate on the commit boundary — it returns any locally-held slot. +TEST_F(CraftPeerExchangeTest, FetchDataAcrossCommitBoundary) { + add_slot(3, 0, 4); // lsn <= commit_lsn=5: committed and applied to index + add_slot(7, 0, 4); // lsn in (5, 10]: appended, not yet committed + add_slot(10, 0, 4); + dev_->seed_commit_lsn(5); + dev_->seed_lsns(10, {}); + + auto r = do_fetch_data({3, 7, 10}); + ASSERT_TRUE(r.has_value()); + ASSERT_EQ(r->size(), 3u); + EXPECT_EQ((*r)[0].lsn, 3); + EXPECT_EQ((*r)[1].lsn, 7); + EXPECT_EQ((*r)[2].lsn, 10); +} + +// A slot in missing_lsns_ is not locally held; it must be omitted from the response. +TEST_F(CraftPeerExchangeTest, FetchDataMissingOmitted) { + dev_->seed_lsns(10, {6}); // lsn 6 is in the missing set + + auto r = do_fetch_data({6}); + ASSERT_TRUE(r.has_value()); + EXPECT_TRUE(r->empty()); // not-present-here: omitted +} + +// A requested LSN above last_append_lsn is not locally held; it must be omitted. +TEST_F(CraftPeerExchangeTest, FetchDataAboveLastAppendOmitted) { + dev_->seed_lsns(5, {}); + + auto r = do_fetch_data({10}); // 10 > last_append_lsn=5 + ASSERT_TRUE(r.has_value()); + EXPECT_TRUE(r->empty()); +} + +// A slot positively verdicted Empty (by a prior S5 apply) is returned with is_empty=true, +// even when the slot is also in missing_lsns_ (the peer never received the write data). +TEST_F(CraftPeerExchangeTest, FetchDataEmptySlot) { + dev_->seed_lsns(10, {8}); // lsn 8 is missing (write never arrived) + dev_->seed_empty({8}); // S5 also applied an Empty verdict for lsn 8 + + auto r = do_fetch_data({8}); + ASSERT_TRUE(r.has_value()); + ASSERT_EQ(r->size(), 1u); + EXPECT_EQ((*r)[0].lsn, 8); + EXPECT_TRUE((*r)[0].is_empty); +} + +// Empty beats data: if a slot is in both empty_lsns_ and the journal, is_empty=true is returned. +// This is the "Empty wins" reconciliation invariant from S5 pre-resolution. +TEST_F(CraftPeerExchangeTest, FetchDataEmptyBeatsPresent) { + add_slot(5, 0, 4); + dev_->seed_lsns(5, {}); + dev_->seed_empty({5}); // S5 applied an Empty verdict over an existing data slot + + auto r = do_fetch_data({5}); + ASSERT_TRUE(r.has_value()); + ASSERT_EQ(r->size(), 1u); + EXPECT_TRUE((*r)[0].is_empty); + EXPECT_EQ((*r)[0].lsn, 5); +} + +// Mixed request: present, missing, Empty, and above-last-append — each classified correctly. +// Only present and Empty slots appear in the result; missing and above-last-append are omitted. +TEST_F(CraftPeerExchangeTest, FetchDataMixedRequest) { + add_slot(2, 0, 4); + add_slot(4, 0, 4); + dev_->seed_lsns(10, {3}); // lsn 3 is missing + dev_->seed_empty({6}); // lsn 6 is Empty-verdicted + // lsn 15 > last_append_lsn=10 → absent + + auto r = do_fetch_data({2, 3, 4, 6, 15}); + ASSERT_TRUE(r.has_value()); + // 3 (missing) and 15 (above last_append) are omitted → 3 results in request order + ASSERT_EQ(r->size(), 3u); + EXPECT_EQ((*r)[0].lsn, 2); + EXPECT_FALSE((*r)[0].is_empty); + EXPECT_EQ((*r)[1].lsn, 4); + EXPECT_FALSE((*r)[1].is_empty); + EXPECT_EQ((*r)[2].lsn, 6); + EXPECT_TRUE((*r)[2].is_empty); +} + +// A read_slot() I/O error aborts fetch_data immediately; subsequent LSNs are not processed. +TEST_F(CraftPeerExchangeTest, FetchDataReadErrorPropagates) { + add_slot(1, 0, 4); + add_slot(2, 0, 4); + dev_->seed_lsns(3, {}); + journal_->fail_on_read = 2; // reading lsn=2 returns io_error + + auto r = do_fetch_data({1, 2, 3}); + EXPECT_FALSE(r.has_value()); // error propagated; partial result discarded +} + +} // namespace +} // namespace homeblocks + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/lib/craft/tests/test_craft_truncate.cpp b/src/lib/craft/tests/test_craft_truncate.cpp new file mode 100644 index 0000000..8470e2a --- /dev/null +++ b/src/lib/craft/tests/test_craft_truncate.cpp @@ -0,0 +1,165 @@ +/********************************************************************************* + * Modifications Copyright 2026 eBay Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + *********************************************************************************/ + +// Unit tests for CraftReplDev::truncate (S4: Truncate Path). +// +// Scope: truncate() drops journal entries above lsn, clamps last_append_lsn, and erases +// missing-set entries above lsn, without touching commit_lsn. A journal I/O failure must +// leave partition state unmodified. +// +// This TU defines SISL_LOGGING_DEF for the homeblocks module because it compiles +// craft_repl_dev.cpp directly (not via the ${PROJECT_NAME}_craft OBJECT lib which is linked +// into the main library together with homeblks_impl.cpp that owns the definition normally). + +#include +#include + +#include "craft/craft_repl_dev.hpp" +#include "coro_helpers.hpp" + +SISL_LOGGING_DEF(HOMEBLOCKS_LOG_MODS) +SISL_LOGGING_INIT(HOMEBLOCKS_LOG_MODS) + +namespace homeblocks { +namespace { + +// ── minimal journal mock ────────────────────────────────────────────────────── +// +// Captures the lsn passed to truncate_to() and can be armed to return an I/O error +// so tests can verify the error-path invariant (state unchanged on failure). + +class MockCraftJournalBackend : public CraftJournalBackend { +public: + bool should_fail{false}; + int64_t truncated_to{INT64_MIN}; + + async_status write_slot(int64_t, lba_t, lba_count_t, sisl::sg_list) override { + co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); + } + + async_result< JournalSlot > read_slot(int64_t) override { + co_return std::unexpected(std::make_error_condition(std::errc::not_supported)); + } + + async_status truncate_to(int64_t lsn) override { + truncated_to = lsn; + if (should_fail) co_return std::unexpected(std::make_error_condition(std::errc::io_error)); + co_return ok(); + } +}; + +// ── test fixture ───────────────────────────────────────────────────────────── + +class CraftTruncateTest : public ::testing::Test { +protected: + void SetUp() override { + auto mock = std::make_unique< MockCraftJournalBackend >(); + journal_ = mock.get(); + dev_ = std::make_unique< CraftReplDev >(volume_id_t{}, std::move(mock)); + } + + auto do_truncate(int64_t lsn) { return homeblocks::detail::sync_get(dev_->truncate(lsn)); } + + MockCraftJournalBackend* journal_{nullptr}; + std::unique_ptr< CraftReplDev > dev_; +}; + +// ── tests ───────────────────────────────────────────────────────────────────── + +// lsn < last_append: last_append is clamped to lsn; entries above lsn are gone. +TEST_F(CraftTruncateTest, TruncateClampedLastAppend) { + dev_->seed_lsns(100, {85, 92}); + auto r = do_truncate(80); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(journal_->truncated_to, 80); + EXPECT_EQ(dev_->last_append_lsn(), 80); + EXPECT_EQ(dev_->missing_count(), 0u); +} + +// lsn == last_append: nothing to clamp; journal is still called. +TEST_F(CraftTruncateTest, TruncateAtLastAppend) { + dev_->seed_lsns(100, {}); + auto r = do_truncate(100); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(journal_->truncated_to, 100); + EXPECT_EQ(dev_->last_append_lsn(), 100); +} + +// lsn > last_append: last_append is already below; it must NOT be raised. +TEST_F(CraftTruncateTest, TruncateAboveLastAppend) { + dev_->seed_lsns(80, {}); + auto r = do_truncate(100); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(journal_->truncated_to, 100); + EXPECT_EQ(dev_->last_append_lsn(), 80); // unchanged +} + +// Missing entries strictly above the truncation point are removed; those at or below survive. +TEST_F(CraftTruncateTest, MissingSetPartiallyErased) { + dev_->seed_lsns(100, {70, 80, 91, 95}); + auto r = do_truncate(90); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(dev_->last_append_lsn(), 90); + EXPECT_EQ(dev_->missing_count(), 2u); // 70 and 80 survive + EXPECT_TRUE(dev_->is_missing(70)); + EXPECT_TRUE(dev_->is_missing(80)); + EXPECT_FALSE(dev_->is_missing(91)); + EXPECT_FALSE(dev_->is_missing(95)); +} + +// All missing entries are above the truncation point → missing set fully cleared. +TEST_F(CraftTruncateTest, MissingSetFullyErased) { + dev_->seed_lsns(100, {91, 95, 99}); + auto r = do_truncate(90); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(dev_->last_append_lsn(), 90); + EXPECT_EQ(dev_->missing_count(), 0u); +} + +// A missing entry exactly at the truncation point is kept: upper_bound(lsn) erases > lsn only. +TEST_F(CraftTruncateTest, MissingEntryAtTruncationPointKept) { + dev_->seed_lsns(100, {80, 90, 95}); + auto r = do_truncate(90); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(dev_->missing_count(), 2u); // 80 and 90 survive; 95 erased + EXPECT_TRUE(dev_->is_missing(90)); + EXPECT_FALSE(dev_->is_missing(95)); +} + +// commit_lsn is invariant: truncate() must not alter it. +TEST_F(CraftTruncateTest, CommitLsnNotTouched) { + dev_->seed_lsns(50, {}); + auto r = do_truncate(30); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(dev_->commit_lsn(), -1); // default; truncate must not touch it +} + +// A journal I/O error propagates out and leaves partition state completely unmodified. +TEST_F(CraftTruncateTest, JournalErrorShieldsState) { + journal_->should_fail = true; + dev_->seed_lsns(100, {80, 90}); + auto r = do_truncate(70); + ASSERT_FALSE(r.has_value()); + EXPECT_EQ(dev_->last_append_lsn(), 100); // unchanged + EXPECT_EQ(dev_->missing_count(), 2u); // unchanged +} + +} // namespace +} // namespace homeblocks + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}