diff --git a/CMakeLists.txt b/CMakeLists.txt index fb1e8f4..0a762dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,7 @@ add_executable(secsgem_tests tests/test_exceptions.cpp tests/test_carrier_state.cpp tests/test_carriers.cpp + tests/test_carrier_persistence.cpp tests/test_substrates.cpp tests/test_ept.cpp tests/test_cem_objects.cpp diff --git a/include/secsgem/gem/carrier_state.hpp b/include/secsgem/gem/carrier_state.hpp index 55c0864..47ec708 100644 --- a/include/secsgem/gem/carrier_state.hpp +++ b/include/secsgem/gem/carrier_state.hpp @@ -144,6 +144,17 @@ class CarrierStateMachine { bool on_slot_map_event(SlotMapEvent e); bool on_access_event(CarrierAccessEvent e); + // Direct state-restore (persistence replay only). Bypasses the + // transition tables and does NOT fire change handlers — a restored + // state isn't a transition, it's a checkpoint. Callers must ensure + // the (id, sm, acc) triple was a legal state at write time. + void restore_state(CarrierIDStatus id, SlotMapStatus sm, + CarrierAccessStatus acc) { + id_state_ = id; + sm_state_ = sm; + acc_state_ = acc; + } + private: CarrierIDTable id_table_; SlotMapTable sm_table_; diff --git a/include/secsgem/gem/load_port_state.hpp b/include/secsgem/gem/load_port_state.hpp index 68ad12f..57121cb 100644 --- a/include/secsgem/gem/load_port_state.hpp +++ b/include/secsgem/gem/load_port_state.hpp @@ -104,6 +104,14 @@ class LoadPortStateMachine { bool on_reservation_event(LoadPortReservationEvent e); bool on_association_event(LoadPortAssociationEvent e); + // Direct state-restore (persistence replay only). See carrier_state.hpp. + void restore_state(LoadPortTransferState tx, LoadPortReservationStatus rs, + LoadPortAssociationStatus as) { + tx_state_ = tx; + rs_state_ = rs; + as_state_ = as; + } + private: LoadPortTransferTable tx_table_; LoadPortReservationTable rs_table_; diff --git a/include/secsgem/gem/store/carriers.hpp b/include/secsgem/gem/store/carriers.hpp index 071b7ea..6a8f96d 100644 --- a/include/secsgem/gem/store/carriers.hpp +++ b/include/secsgem/gem/store/carriers.hpp @@ -1,11 +1,16 @@ #pragma once +#include #include +#include +#include +#include #include #include #include #include #include +#include #include #include @@ -27,12 +32,14 @@ struct Carrier { uint8_t port_id = 0; // 0 = unbound; ports are 1-based std::vector slots; // length == capacity (typ. 25) std::unique_ptr fsm; + std::filesystem::path journal_path; // empty when persistence disabled }; struct LoadPort { uint8_t port_id; std::string carrierid; // empty when nothing associated std::unique_ptr fsm; + std::filesystem::path journal_path; // empty when persistence disabled }; // Both stores are non-movable for the same reason ProcessJobStore is — @@ -64,23 +71,9 @@ class CarrierStore { CreateResult create(std::string carrierid, uint8_t port_id = 0, std::size_t capacity = 25) { if (carriers_.count(carrierid)) return CreateResult::Denied_AlreadyExists; - auto fsm = std::make_unique(); - const std::string id = carrierid; - fsm->set_id_handler([this, id](CarrierIDStatus f, CarrierIDStatus t, - CarrierIDEvent e) { - if (on_id_) on_id_(id, f, t, e); - }); - fsm->set_slot_map_handler([this, id](SlotMapStatus f, SlotMapStatus t, - SlotMapEvent e) { - if (on_sm_) on_sm_(id, f, t, e); - }); - fsm->set_access_handler([this, id](CarrierAccessStatus f, CarrierAccessStatus t, - CarrierAccessEvent e) { - if (on_acc_) on_acc_(id, f, t, e); - }); - carriers_.emplace(carrierid, Carrier{carrierid, port_id, - std::vector(capacity), - std::move(fsm)}); + install_(carrierid, port_id, std::vector(capacity), + /*restore_id=*/std::nullopt, std::nullopt, std::nullopt); + if (persistent_) write_record_(carrierid); return CreateResult::Created; } @@ -96,18 +89,43 @@ class CarrierStore { bool fire_id_event(const std::string& cid, CarrierIDEvent e) { auto* c = get(cid); - return c && c->fsm->on_id_event(e); + if (!(c && c->fsm->on_id_event(e))) return false; + if (persistent_) write_record_(cid); + return true; } bool fire_slot_map_event(const std::string& cid, SlotMapEvent e) { auto* c = get(cid); - return c && c->fsm->on_slot_map_event(e); + if (!(c && c->fsm->on_slot_map_event(e))) return false; + if (persistent_) write_record_(cid); + return true; } bool fire_access_event(const std::string& cid, CarrierAccessEvent e) { auto* c = get(cid); - return c && c->fsm->on_access_event(e); + if (!(c && c->fsm->on_access_event(e))) return false; + if (persistent_) write_record_(cid); + return true; } - bool remove(const std::string& cid) { return carriers_.erase(cid) > 0; } + // Application sets slots[i].state directly; persistence sync is opt-in + // through this helper so the journal records the new slot map. + void set_slot_state(const std::string& cid, std::size_t slot_idx, + uint8_t state) { + auto* c = get(cid); + if (!c || slot_idx >= c->slots.size()) return; + c->slots[slot_idx].state = state; + if (persistent_) write_record_(cid); + } + + bool remove(const std::string& cid) { + auto it = carriers_.find(cid); + if (it == carriers_.end()) return false; + if (persistent_ && !it->second.journal_path.empty()) { + std::error_code ec; + std::filesystem::remove(it->second.journal_path, ec); + } + carriers_.erase(it); + return true; + } std::size_t size() const { return carriers_.size(); } std::vector ids() const { std::vector out; @@ -116,11 +134,174 @@ class CarrierStore { return out; } + // ---- Persistence ---------------------------------------------------- + // Mirrors SpoolStore::enable_persistence: scan `dir` for *.car records, + // replay them into the in-memory map, then keep the directory in sync + // on subsequent create / fire_* / remove calls. Atomic writes via + // .tmp + rename. Records that fail magic/version are silently dropped + // so a single bad file can't poison the whole store. + void enable_persistence(std::filesystem::path dir) { + namespace fs = std::filesystem; + journal_dir_ = std::move(dir); + persistent_ = true; + std::error_code ec; + fs::create_directories(journal_dir_, ec); + + std::vector> existing; + for (auto& entry : fs::directory_iterator(journal_dir_, ec)) { + if (!entry.is_regular_file()) continue; + const auto& p = entry.path(); + if (p.extension() != ".car") continue; + uint64_t seq = 0; + try { seq = std::stoull(p.stem().string()); } + catch (...) { continue; } + existing.emplace_back(seq, p); + } + std::sort(existing.begin(), existing.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + + for (auto& [seq, path] : existing) { + auto rec = load_record_(path); + if (!rec) { fs::remove(path, ec); continue; } + install_(rec->carrierid, rec->port_id, std::move(rec->slots), + rec->id_state, rec->sm_state, rec->acc_state); + auto* c = get(rec->carrierid); + if (c) c->journal_path = path; + if (seq >= next_seq_) next_seq_ = seq + 1; + } + } + + bool persistence_enabled() const { return persistent_; } + std::filesystem::path journal_dir() const { return journal_dir_; } + private: + struct Record { + std::string carrierid; + uint8_t port_id; + std::vector slots; + CarrierIDStatus id_state; + SlotMapStatus sm_state; + CarrierAccessStatus acc_state; + }; + + // Carrier record layout, big-endian throughout: + // [u8 magic = 0xC4][u8 version = 1] + // [u8 id_state][u8 sm_state][u8 acc_state][u8 port_id] + // [u16 slot_count][slot_count × u8 slot.state] + // [u16 cid_len][cid_len × u8 carrierid] + static constexpr uint8_t kMagic = 0xC4; + static constexpr uint8_t kVersion = 0x01; + + void install_(const std::string& carrierid, uint8_t port_id, + std::vector slots, + std::optional id_state, + std::optional sm_state, + std::optional acc_state) { + auto fsm = std::make_unique(); + if (id_state || sm_state || acc_state) { + fsm->restore_state(id_state.value_or(CarrierIDStatus::NotConfirmed), + sm_state.value_or(SlotMapStatus::NotRead), + acc_state.value_or(CarrierAccessStatus::NotAccessed)); + } + const std::string id = carrierid; + fsm->set_id_handler([this, id](CarrierIDStatus f, CarrierIDStatus t, + CarrierIDEvent e) { + if (on_id_) on_id_(id, f, t, e); + }); + fsm->set_slot_map_handler([this, id](SlotMapStatus f, SlotMapStatus t, + SlotMapEvent e) { + if (on_sm_) on_sm_(id, f, t, e); + }); + fsm->set_access_handler([this, id](CarrierAccessStatus f, CarrierAccessStatus t, + CarrierAccessEvent e) { + if (on_acc_) on_acc_(id, f, t, e); + }); + carriers_.emplace(carrierid, Carrier{carrierid, port_id, std::move(slots), + std::move(fsm), {}}); + } + + void write_record_(const std::string& carrierid) { + namespace fs = std::filesystem; + auto* c = get(carrierid); + if (!c) return; + fs::path target = c->journal_path; + if (target.empty()) { + char namebuf[32]; + std::snprintf(namebuf, sizeof(namebuf), "%010llu.car", + static_cast(next_seq_++)); + target = journal_dir_ / namebuf; + c->journal_path = target; + } + fs::path tmp = target; + tmp += ".tmp"; + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) return; + uint8_t header[6] = { + kMagic, kVersion, + static_cast(c->fsm->id_status()), + static_cast(c->fsm->slot_map_status()), + static_cast(c->fsm->access_status()), + c->port_id}; + out.write(reinterpret_cast(header), sizeof(header)); + auto write_be16 = [&](uint16_t v) { + uint8_t b[2] = {static_cast(v >> 8), static_cast(v)}; + out.write(reinterpret_cast(b), 2); + }; + write_be16(static_cast(c->slots.size())); + for (const auto& s : c->slots) { + out.put(static_cast(s.state)); + } + write_be16(static_cast(c->carrierid.size())); + out.write(c->carrierid.data(), + static_cast(c->carrierid.size())); + out.flush(); + out.close(); + std::error_code ec; + fs::rename(tmp, target, ec); + if (ec) fs::remove(tmp, ec); + } + + static std::optional load_record_(const std::filesystem::path& p) { + std::ifstream in(p, std::ios::binary); + if (!in) return std::nullopt; + uint8_t header[6]; + in.read(reinterpret_cast(header), sizeof(header)); + if (!in || header[0] != kMagic || header[1] != kVersion) return std::nullopt; + Record r; + r.id_state = static_cast(header[2]); + r.sm_state = static_cast(header[3]); + r.acc_state = static_cast(header[4]); + r.port_id = header[5]; + auto read_be16 = [&](uint16_t& v) { + uint8_t b[2]; + in.read(reinterpret_cast(b), 2); + if (!in) return false; + v = static_cast((uint16_t(b[0]) << 8) | b[1]); + return true; + }; + uint16_t slot_count = 0; + if (!read_be16(slot_count)) return std::nullopt; + r.slots.resize(slot_count); + for (auto& s : r.slots) { + int c = in.get(); + if (c == std::char_traits::eof()) return std::nullopt; + s.state = static_cast(c); + } + uint16_t cid_len = 0; + if (!read_be16(cid_len)) return std::nullopt; + r.carrierid.resize(cid_len); + in.read(r.carrierid.data(), cid_len); + if (!in) return std::nullopt; + return r; + } + std::map carriers_; IDChangeHandler on_id_; SlotMapChangeHandler on_sm_; AccessChangeHandler on_acc_; + bool persistent_ = false; + std::filesystem::path journal_dir_; + uint64_t next_seq_ = 0; }; class LoadPortStore { @@ -148,24 +329,8 @@ class LoadPortStore { // Idempotent: re-create a port_id is a no-op. bool create(uint8_t port_id) { if (ports_.count(port_id)) return false; - auto fsm = std::make_unique(); - const uint8_t pid = port_id; - fsm->set_transfer_handler([this, pid](LoadPortTransferState f, - LoadPortTransferState t, - LoadPortTransferEvent e) { - if (on_tx_) on_tx_(pid, f, t, e); - }); - fsm->set_reservation_handler([this, pid](LoadPortReservationStatus f, - LoadPortReservationStatus t, - LoadPortReservationEvent e) { - if (on_rs_) on_rs_(pid, f, t, e); - }); - fsm->set_association_handler([this, pid](LoadPortAssociationStatus f, - LoadPortAssociationStatus t, - LoadPortAssociationEvent e) { - if (on_as_) on_as_(pid, f, t, e); - }); - ports_.emplace(port_id, LoadPort{port_id, "", std::move(fsm)}); + install_(port_id, "", std::nullopt, std::nullopt, std::nullopt); + if (persistent_) write_record_(port_id); return true; } @@ -183,23 +348,31 @@ class LoadPortStore { auto* p = get(pid); if (!p) return false; p->carrierid = std::move(carrierid); - return p->fsm->on_association_event(LoadPortAssociationEvent::Associate); + bool ok = p->fsm->on_association_event(LoadPortAssociationEvent::Associate); + if (persistent_) write_record_(pid); + return ok; } bool disassociate(uint8_t pid) { auto* p = get(pid); if (!p) return false; p->carrierid.clear(); - return p->fsm->on_association_event(LoadPortAssociationEvent::Disassociate); + bool ok = p->fsm->on_association_event(LoadPortAssociationEvent::Disassociate); + if (persistent_) write_record_(pid); + return ok; } bool fire_transfer_event(uint8_t pid, LoadPortTransferEvent e) { auto* p = get(pid); - return p && p->fsm->on_transfer_event(e); + if (!(p && p->fsm->on_transfer_event(e))) return false; + if (persistent_) write_record_(pid); + return true; } bool fire_reservation_event(uint8_t pid, LoadPortReservationEvent e) { auto* p = get(pid); - return p && p->fsm->on_reservation_event(e); + if (!(p && p->fsm->on_reservation_event(e))) return false; + if (persistent_) write_record_(pid); + return true; } std::size_t size() const { return ports_.size(); } @@ -210,11 +383,137 @@ class LoadPortStore { return out; } + // ---- Persistence ---------------------------------------------------- + // One file per port: `.lp`. Port IDs are 1–255 so + // we can use the port id directly as the filename stem. + void enable_persistence(std::filesystem::path dir) { + namespace fs = std::filesystem; + journal_dir_ = std::move(dir); + persistent_ = true; + std::error_code ec; + fs::create_directories(journal_dir_, ec); + + for (auto& entry : fs::directory_iterator(journal_dir_, ec)) { + if (!entry.is_regular_file()) continue; + const auto& p = entry.path(); + if (p.extension() != ".lp") continue; + auto rec = load_record_(p); + if (!rec) { fs::remove(p, ec); continue; } + install_(rec->port_id, rec->carrierid, rec->tx_state, rec->rs_state, + rec->as_state); + auto* lp = get(rec->port_id); + if (lp) lp->journal_path = p; + } + } + + bool persistence_enabled() const { return persistent_; } + std::filesystem::path journal_dir() const { return journal_dir_; } + private: + struct Record { + uint8_t port_id; + std::string carrierid; + LoadPortTransferState tx_state; + LoadPortReservationStatus rs_state; + LoadPortAssociationStatus as_state; + }; + + // Load-port record: + // [u8 magic = 0xC5][u8 version = 1] + // [u8 port_id][u8 tx_state][u8 rs_state][u8 as_state] + // [u16 cid_len][cid_len × u8 carrierid] + static constexpr uint8_t kMagic = 0xC5; + static constexpr uint8_t kVersion = 0x01; + + void install_(uint8_t port_id, std::string carrierid, + std::optional tx, + std::optional rs, + std::optional as) { + auto fsm = std::make_unique(); + if (tx || rs || as) { + fsm->restore_state(tx.value_or(LoadPortTransferState::InService), + rs.value_or(LoadPortReservationStatus::NotReserved), + as.value_or(LoadPortAssociationStatus::NotAssociated)); + } + const uint8_t pid = port_id; + fsm->set_transfer_handler([this, pid](LoadPortTransferState f, + LoadPortTransferState t, + LoadPortTransferEvent e) { + if (on_tx_) on_tx_(pid, f, t, e); + }); + fsm->set_reservation_handler([this, pid](LoadPortReservationStatus f, + LoadPortReservationStatus t, + LoadPortReservationEvent e) { + if (on_rs_) on_rs_(pid, f, t, e); + }); + fsm->set_association_handler([this, pid](LoadPortAssociationStatus f, + LoadPortAssociationStatus t, + LoadPortAssociationEvent e) { + if (on_as_) on_as_(pid, f, t, e); + }); + ports_.emplace(port_id, LoadPort{port_id, std::move(carrierid), + std::move(fsm), {}}); + } + + void write_record_(uint8_t pid) { + namespace fs = std::filesystem; + auto* lp = get(pid); + if (!lp) return; + if (lp->journal_path.empty()) { + char namebuf[16]; + std::snprintf(namebuf, sizeof(namebuf), "%03u.lp", unsigned(pid)); + lp->journal_path = journal_dir_ / namebuf; + } + fs::path tmp = lp->journal_path; + tmp += ".tmp"; + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) return; + uint8_t header[6] = { + kMagic, kVersion, pid, + static_cast(lp->fsm->transfer_state()), + static_cast(lp->fsm->reservation_state()), + static_cast(lp->fsm->association_state())}; + out.write(reinterpret_cast(header), sizeof(header)); + uint16_t cid_len = static_cast(lp->carrierid.size()); + uint8_t lb[2] = {static_cast(cid_len >> 8), + static_cast(cid_len)}; + out.write(reinterpret_cast(lb), 2); + out.write(lp->carrierid.data(), + static_cast(lp->carrierid.size())); + out.flush(); + out.close(); + std::error_code ec; + fs::rename(tmp, lp->journal_path, ec); + if (ec) fs::remove(tmp, ec); + } + + static std::optional load_record_(const std::filesystem::path& p) { + std::ifstream in(p, std::ios::binary); + if (!in) return std::nullopt; + uint8_t header[6]; + in.read(reinterpret_cast(header), sizeof(header)); + if (!in || header[0] != kMagic || header[1] != kVersion) return std::nullopt; + Record r; + r.port_id = header[2]; + r.tx_state = static_cast(header[3]); + r.rs_state = static_cast(header[4]); + r.as_state = static_cast(header[5]); + uint8_t lb[2]; + in.read(reinterpret_cast(lb), 2); + if (!in) return std::nullopt; + uint16_t cid_len = static_cast((uint16_t(lb[0]) << 8) | lb[1]); + r.carrierid.resize(cid_len); + in.read(r.carrierid.data(), cid_len); + if (cid_len && !in) return std::nullopt; + return r; + } + std::map ports_; TransferChangeHandler on_tx_; ReservationChangeHandler on_rs_; AssociationChangeHandler on_as_; + bool persistent_ = false; + std::filesystem::path journal_dir_; }; } // namespace secsgem::gem diff --git a/tests/test_carrier_persistence.cpp b/tests/test_carrier_persistence.cpp new file mode 100644 index 0000000..319cad4 --- /dev/null +++ b/tests/test_carrier_persistence.cpp @@ -0,0 +1,209 @@ +// Persistence tests for CarrierStore + LoadPortStore. +// +// Mirrors the pattern from test_data_model.cpp:spool-persistence: enable +// a journal directory, mutate state, "restart" by constructing a fresh +// store pointed at the same directory, and assert replay reproduces the +// in-memory state. Plus negative paths: partial-write files are +// dropped, remove deletes the file, persistence-disabled is a no-op. + +#include + +#include +#include +#include +#include + +#include "secsgem/gem/store/carriers.hpp" + +using namespace secsgem::gem; +namespace fs = std::filesystem; + +namespace { + +fs::path scratch_dir(const char* tag) { + std::random_device rd; + auto p = fs::temp_directory_path() / + (std::string("secsgem-carrier-") + tag + "-" + + std::to_string(rd())); + fs::remove_all(p); + fs::create_directories(p); + return p; +} + +std::size_t count_with_ext(const fs::path& dir, const std::string& ext) { + std::size_t n = 0; + std::error_code ec; + for (auto& e : fs::directory_iterator(dir, ec)) { + if (e.is_regular_file() && e.path().extension() == ext) ++n; + } + return n; +} + +} // namespace + +TEST_CASE("CarrierStore persistence: write + restart + replay") { + auto dir = scratch_dir("write-replay"); + { + CarrierStore s; + s.enable_persistence(dir); + REQUIRE(s.create("CAR-1", /*port=*/2, /*capacity=*/4) == + CarrierStore::CreateResult::Created); + REQUIRE(s.create("CAR-2", /*port=*/3, /*capacity=*/4) == + CarrierStore::CreateResult::Created); + + // Drive each FSM axis through a transition so we can prove every + // byte makes it through the journal. + REQUIRE(s.fire_id_event("CAR-1", CarrierIDEvent::ProceedWithCarrier)); + REQUIRE(s.fire_slot_map_event("CAR-1", SlotMapEvent::Read)); + REQUIRE(s.fire_access_event("CAR-1", CarrierAccessEvent::BeginAccess)); + s.set_slot_state("CAR-1", 0, 1); // slot 1 occupied + s.set_slot_state("CAR-1", 1, 0); // slot 2 empty + + CHECK(count_with_ext(dir, ".car") == 2); + } + + // Restart: fresh store, same dir. + { + CarrierStore s; + s.enable_persistence(dir); + REQUIRE(s.has("CAR-1")); + REQUIRE(s.has("CAR-2")); + + const auto* c1 = s.get("CAR-1"); + REQUIRE(c1); + CHECK(c1->port_id == 2); + CHECK(c1->slots.size() == 4); + CHECK(c1->slots[0].state == 1); + CHECK(c1->slots[1].state == 0); + CHECK(c1->fsm->id_status() == CarrierIDStatus::Confirmed); + CHECK(c1->fsm->slot_map_status() == SlotMapStatus::Read); + CHECK(c1->fsm->access_status() == CarrierAccessStatus::InAccess); + + const auto* c2 = s.get("CAR-2"); + REQUIRE(c2); + CHECK(c2->port_id == 3); + CHECK(c2->fsm->id_status() == CarrierIDStatus::NotConfirmed); + + // Continued mutation after replay still writes through. + REQUIRE(s.fire_id_event("CAR-2", CarrierIDEvent::Bind)); + } + + // Second restart shows the post-replay mutation is durable. + { + CarrierStore s; + s.enable_persistence(dir); + REQUIRE(s.has("CAR-2")); + CHECK(s.get("CAR-2")->fsm->id_status() == CarrierIDStatus::Confirmed); + } + fs::remove_all(dir); +} + +TEST_CASE("CarrierStore persistence: remove deletes the journal file") { + auto dir = scratch_dir("remove"); + CarrierStore s; + s.enable_persistence(dir); + REQUIRE(s.create("CAR-A") == CarrierStore::CreateResult::Created); + REQUIRE(s.create("CAR-B") == CarrierStore::CreateResult::Created); + CHECK(count_with_ext(dir, ".car") == 2); + + REQUIRE(s.remove("CAR-A")); + CHECK(count_with_ext(dir, ".car") == 1); + + // Restart and confirm CAR-A is gone, CAR-B remains. + CarrierStore s2; + s2.enable_persistence(dir); + CHECK_FALSE(s2.has("CAR-A")); + CHECK(s2.has("CAR-B")); + fs::remove_all(dir); +} + +TEST_CASE("CarrierStore persistence: malformed file is dropped, not poisonous") { + auto dir = scratch_dir("malformed"); + { + CarrierStore s; + s.enable_persistence(dir); + REQUIRE(s.create("CAR-OK") == CarrierStore::CreateResult::Created); + } + + // Inject a corrupt record (wrong magic byte at offset 0). + { + std::ofstream f(dir / "9999999999.car", std::ios::binary); + const char garbage[] = {0x00, 0x01, 0x02, 0x03}; + f.write(garbage, sizeof(garbage)); + } + // And a truncated record (magic+version only). + { + std::ofstream f(dir / "9999999998.car", std::ios::binary); + const char hdr[] = {static_cast(0xC4), 0x01}; + f.write(hdr, sizeof(hdr)); + } + + CHECK(count_with_ext(dir, ".car") == 3); + + CarrierStore s; + s.enable_persistence(dir); + CHECK(s.has("CAR-OK")); + CHECK(s.size() == 1); + // The two malformed files were removed during replay. + CHECK(count_with_ext(dir, ".car") == 1); + fs::remove_all(dir); +} + +TEST_CASE("CarrierStore: persistence disabled is a no-op") { + auto dir = scratch_dir("disabled"); + CarrierStore s; + // Note: enable_persistence NOT called. + REQUIRE(s.create("CAR-X") == CarrierStore::CreateResult::Created); + REQUIRE(s.fire_id_event("CAR-X", CarrierIDEvent::ProceedWithCarrier)); + CHECK_FALSE(s.persistence_enabled()); + + // No journal file should exist anywhere — but to be safe also check + // that pointing a fresh store at `dir` doesn't see CAR-X (since we + // never wrote it). + CarrierStore s2; + s2.enable_persistence(dir); + CHECK_FALSE(s2.has("CAR-X")); + CHECK(s2.size() == 0); + fs::remove_all(dir); +} + +TEST_CASE("LoadPortStore persistence: write + restart + replay") { + auto dir = scratch_dir("lp-write-replay"); + { + LoadPortStore lp; + lp.enable_persistence(dir); + REQUIRE(lp.create(1)); + REQUIRE(lp.create(2)); + REQUIRE(lp.associate(1, "CAR-1")); + REQUIRE(lp.fire_transfer_event(1, LoadPortTransferEvent::StartLoading)); + REQUIRE(lp.fire_reservation_event(2, LoadPortReservationEvent::Reserve)); + CHECK(count_with_ext(dir, ".lp") == 2); + } + { + LoadPortStore lp; + lp.enable_persistence(dir); + REQUIRE(lp.has(1)); + REQUIRE(lp.has(2)); + const auto* p1 = lp.get(1); + CHECK(p1->carrierid == "CAR-1"); + CHECK(p1->fsm->transfer_state() == LoadPortTransferState::Loading); + CHECK(p1->fsm->association_state() == LoadPortAssociationStatus::Associated); + const auto* p2 = lp.get(2); + CHECK(p2->fsm->reservation_state() == LoadPortReservationStatus::Reserved); + } + fs::remove_all(dir); +} + +TEST_CASE("LoadPortStore persistence: malformed file dropped") { + auto dir = scratch_dir("lp-malformed"); + { + std::ofstream f(dir / "001.lp", std::ios::binary); + const char junk[] = {0x00, 0x01, 0x02}; + f.write(junk, sizeof(junk)); + } + LoadPortStore lp; + lp.enable_persistence(dir); + CHECK(lp.size() == 0); + CHECK(count_with_ext(dir, ".lp") == 0); + fs::remove_all(dir); +}