diff --git a/CMakeLists.txt b/CMakeLists.txt index 0a762dd..53cd7b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,6 +113,7 @@ add_executable(secsgem_tests tests/test_carriers.cpp tests/test_carrier_persistence.cpp tests/test_substrates.cpp + tests/test_substrate_persistence.cpp tests/test_ept.cpp tests/test_cem_objects.cpp tests/test_modules.cpp diff --git a/include/secsgem/gem/store/substrates.hpp b/include/secsgem/gem/store/substrates.hpp index 8dbb074..16bad54 100644 --- a/include/secsgem/gem/store/substrates.hpp +++ b/include/secsgem/gem/store/substrates.hpp @@ -2,10 +2,15 @@ #include #include +#include +#include +#include #include #include #include +#include #include +#include #include #include #include @@ -34,6 +39,7 @@ struct Substrate { std::string location; // free-form: equipment-defined module name std::vector history; std::unique_ptr fsm; + std::filesystem::path journal_path; // empty when persistence disabled }; class SubstrateStore { @@ -67,22 +73,9 @@ class SubstrateStore { CreateResult create(std::string substid, std::string carrierid = "", uint8_t slot = 0) { if (subs_.count(substid)) return CreateResult::Denied_AlreadyExists; - auto fsm = std::make_unique(); - const std::string id = substid; - fsm->set_location_handler( - [this, id](SubstrateState f, SubstrateState t, SubstrateEvent e) { - record_history(id, t, SubstrateProcessingState::NoState, e); - if (on_loc_) on_loc_(id, f, t, e); - }); - fsm->set_processing_handler( - [this, id](SubstrateProcessingState f, SubstrateProcessingState t, - SubstrateProcessingEvent e) { - record_history(id, SubstrateState::NoState, t, e); - if (on_proc_) on_proc_(id, f, t, e); - }); - subs_.emplace(substid, - Substrate{substid, std::move(carrierid), slot, "", - {}, std::move(fsm)}); + install_(substid, carrierid, slot, "", std::nullopt, std::nullopt, + std::nullopt); + if (persistent_) write_record_(substid); return CreateResult::Created; } @@ -101,14 +94,27 @@ class SubstrateStore { auto* s = get(id); if (!s) return false; if (!new_location.empty()) s->location = std::move(new_location); - return s->fsm->on_location_event(e); + bool ok = s->fsm->on_location_event(e); + if (persistent_) write_record_(id); + return ok; } bool fire_processing_event(const std::string& id, SubstrateProcessingEvent e) { auto* s = get(id); - return s && s->fsm->on_processing_event(e); + if (!(s && s->fsm->on_processing_event(e))) return false; + if (persistent_) write_record_(id); + return true; } - bool remove(const std::string& id) { return subs_.erase(id) > 0; } + bool remove(const std::string& id) { + auto it = subs_.find(id); + if (it == subs_.end()) return false; + if (persistent_ && !it->second.journal_path.empty()) { + std::error_code ec; + std::filesystem::remove(it->second.journal_path, ec); + } + subs_.erase(it); + return true; + } std::size_t size() const { return subs_.size(); } std::vector ids() const { std::vector out; @@ -122,7 +128,155 @@ class SubstrateStore { return s ? &s->history : nullptr; } + // ---- Persistence ---------------------------------------------------- + // Records *current state* per substrate. History is not journaled — + // it's a transient in-memory ring buffer and rebuilding it from + // replayed state would be misleading. + 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() != ".sub") 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->substid, rec->carrierid, rec->slot, rec->location, + rec->loc_state, rec->proc_state, rec->id_state); + auto* s = get(rec->substid); + if (s) s->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 substid; + std::string carrierid; + uint8_t slot; + std::string location; + SubstrateState loc_state; + SubstrateProcessingState proc_state; + SubstrateIDStatus id_state; + }; + + // Substrate record: + // [u8 magic = 0xC6][u8 version = 1] + // [u8 loc_state][u8 proc_state][u8 id_state][u8 slot] + // [u16 substid_len][substid_len × u8] + // [u16 carrierid_len][carrierid_len × u8] + // [u16 location_len][location_len × u8] + static constexpr uint8_t kMagic = 0xC6; + static constexpr uint8_t kVersion = 0x01; + + void install_(const std::string& substid, const std::string& carrierid, + uint8_t slot, const std::string& location, + std::optional loc, + std::optional proc, + std::optional id) { + auto fsm = std::make_unique(); + if (loc || proc || id) { + fsm->restore_state(loc.value_or(SubstrateState::AtSource), + proc.value_or(SubstrateProcessingState::NeedsProcessing), + id.value_or(SubstrateIDStatus::NotConfirmed)); + } + const std::string keyed = substid; + fsm->set_location_handler( + [this, keyed](SubstrateState f, SubstrateState t, SubstrateEvent e) { + record_history(keyed, t, SubstrateProcessingState::NoState, e); + if (on_loc_) on_loc_(keyed, f, t, e); + }); + fsm->set_processing_handler( + [this, keyed](SubstrateProcessingState f, SubstrateProcessingState t, + SubstrateProcessingEvent e) { + record_history(keyed, SubstrateState::NoState, t, e); + if (on_proc_) on_proc_(keyed, f, t, e); + }); + subs_.emplace(substid, + Substrate{substid, carrierid, slot, location, {}, + std::move(fsm), {}}); + } + + void write_record_(const std::string& substid) { + namespace fs = std::filesystem; + auto* s = get(substid); + if (!s) return; + if (s->journal_path.empty()) { + char namebuf[32]; + std::snprintf(namebuf, sizeof(namebuf), "%010llu.sub", + static_cast(next_seq_++)); + s->journal_path = journal_dir_ / namebuf; + } + fs::path tmp = s->journal_path; + tmp += ".tmp"; + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) return; + uint8_t header[6] = { + kMagic, kVersion, + static_cast(s->fsm->location_state()), + static_cast(s->fsm->processing_state()), + static_cast(s->fsm->id_state()), + s->slot}; + out.write(reinterpret_cast(header), sizeof(header)); + auto write_str = [&](const std::string& str) { + uint16_t n = static_cast(str.size()); + uint8_t lb[2] = {static_cast(n >> 8), static_cast(n)}; + out.write(reinterpret_cast(lb), 2); + out.write(str.data(), static_cast(n)); + }; + write_str(s->substid); + write_str(s->carrierid); + write_str(s->location); + out.flush(); + out.close(); + std::error_code ec; + fs::rename(tmp, s->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.loc_state = static_cast(header[2]); + r.proc_state = static_cast(header[3]); + r.id_state = static_cast(header[4]); + r.slot = header[5]; + auto read_str = [&](std::string& out) { + uint8_t lb[2]; + in.read(reinterpret_cast(lb), 2); + if (!in) return false; + uint16_t n = static_cast((uint16_t(lb[0]) << 8) | lb[1]); + out.resize(n); + in.read(out.data(), n); + return static_cast(in) || n == 0; + }; + if (!read_str(r.substid)) return std::nullopt; + if (!read_str(r.carrierid)) return std::nullopt; + if (!read_str(r.location)) return std::nullopt; + return r; + } + template void record_history(const std::string& id, SubstrateState loc_to, SubstrateProcessingState proc_to, Event ev) { @@ -146,6 +300,9 @@ class SubstrateStore { LocationChangeHandler on_loc_; ProcessingChangeHandler on_proc_; std::size_t history_limit_ = 256; + bool persistent_ = false; + std::filesystem::path journal_dir_; + uint64_t next_seq_ = 0; }; } // namespace secsgem::gem diff --git a/include/secsgem/gem/substrate_state.hpp b/include/secsgem/gem/substrate_state.hpp index 1f0fa02..ef8bd4c 100644 --- a/include/secsgem/gem/substrate_state.hpp +++ b/include/secsgem/gem/substrate_state.hpp @@ -127,6 +127,15 @@ class SubstrateStateMachine { bool on_processing_event(SubstrateProcessingEvent e); bool on_id_event(SubstrateIDEvent e); + // Direct state-restore (persistence replay only). Bypasses the + // transition tables and does NOT fire change handlers. + void restore_state(SubstrateState loc, SubstrateProcessingState proc, + SubstrateIDStatus id) { + loc_ = loc; + proc_ = proc; + id_ = id; + } + private: SubstrateTable loc_table_; SubstrateProcessingTable proc_table_; diff --git a/tests/test_substrate_persistence.cpp b/tests/test_substrate_persistence.cpp new file mode 100644 index 0000000..8ff25d6 --- /dev/null +++ b/tests/test_substrate_persistence.cpp @@ -0,0 +1,186 @@ +// Persistence tests for SubstrateStore. Mirrors test_carrier_persistence: +// enable a journal directory, drive every FSM axis, "restart" by +// constructing a fresh store pointed at the same directory, assert +// replay reproduces in-memory state. + +#include + +#include +#include +#include +#include + +#include "secsgem/gem/store/substrates.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-substrate-") + 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("SubstrateStore persistence: replay reproduces all three axes") { + auto dir = scratch_dir("replay"); + { + SubstrateStore s; + s.enable_persistence(dir); + REQUIRE(s.create("W-1", "CAR-A", 1) == + SubstrateStore::CreateResult::Created); + REQUIRE(s.create("W-2", "CAR-A", 2) == + SubstrateStore::CreateResult::Created); + + // Drive each axis for W-1: location, processing, id. + REQUIRE(s.fire_location_event("W-1", SubstrateEvent::Acquire, "MOD-A")); + REQUIRE(s.fire_processing_event("W-1", + SubstrateProcessingEvent::StartProcessing)); + // The store has no fire_id_event helper, but mutating the FSM does + // trigger handlers; persistence is only refreshed via the store + // entrypoints — for this test we use the FSM directly and then call + // a location event to force a journal rewrite. + REQUIRE(s.get("W-1")->fsm->on_id_event(SubstrateIDEvent::Read)); + REQUIRE(s.get("W-1")->fsm->on_id_event(SubstrateIDEvent::Confirm)); + REQUIRE(s.fire_location_event("W-1", SubstrateEvent::Release, "DEST")); + REQUIRE(s.fire_processing_event("W-1", + SubstrateProcessingEvent::EndProcessing)); + + CHECK(count_with_ext(dir, ".sub") == 2); + } + { + SubstrateStore s; + s.enable_persistence(dir); + REQUIRE(s.has("W-1")); + REQUIRE(s.has("W-2")); + const auto* w1 = s.get("W-1"); + CHECK(w1->carrierid == "CAR-A"); + CHECK(w1->slot == 1); + CHECK(w1->location == "DEST"); + CHECK(w1->fsm->location_state() == SubstrateState::AtDestination); + CHECK(w1->fsm->processing_state() == SubstrateProcessingState::Processed); + CHECK(w1->fsm->id_state() == SubstrateIDStatus::Confirmed); + const auto* w2 = s.get("W-2"); + CHECK(w2->fsm->location_state() == SubstrateState::AtSource); + CHECK(w2->fsm->processing_state() == SubstrateProcessingState::NeedsProcessing); + } + fs::remove_all(dir); +} + +TEST_CASE("SubstrateStore persistence: each terminal processing state round-trips") { + auto dir = scratch_dir("terminal"); + { + SubstrateStore s; + s.enable_persistence(dir); + // Abort/Stop/Reject are only legal from InProcess per E90 §6.4.4, + // so drive Start first for those three. + auto drive_into = [&](const std::string& id, uint8_t slot, + SubstrateProcessingEvent terminal) { + REQUIRE(s.create(id, "CAR", slot) == + SubstrateStore::CreateResult::Created); + REQUIRE(s.fire_processing_event(id, + SubstrateProcessingEvent::StartProcessing)); + REQUIRE(s.fire_processing_event(id, terminal)); + }; + drive_into("W-AB", 1, SubstrateProcessingEvent::Abort); + drive_into("W-ST", 2, SubstrateProcessingEvent::Stop); + drive_into("W-RJ", 3, SubstrateProcessingEvent::Reject); + + // Lost and Skip are reachable from NeedsProcessing. + REQUIRE(s.create("W-LO", "CAR", 4) == SubstrateStore::CreateResult::Created); + REQUIRE(s.fire_processing_event("W-LO", SubstrateProcessingEvent::ReportLost)); + + REQUIRE(s.create("W-SK", "CAR", 5) == SubstrateStore::CreateResult::Created); + REQUIRE(s.fire_processing_event("W-SK", SubstrateProcessingEvent::Skip)); + } + { + SubstrateStore s; + s.enable_persistence(dir); + CHECK(s.get("W-AB")->fsm->processing_state() == + SubstrateProcessingState::Aborted); + CHECK(s.get("W-ST")->fsm->processing_state() == + SubstrateProcessingState::Stopped); + CHECK(s.get("W-RJ")->fsm->processing_state() == + SubstrateProcessingState::Rejected); + CHECK(s.get("W-LO")->fsm->processing_state() == + SubstrateProcessingState::Lost); + CHECK(s.get("W-SK")->fsm->processing_state() == + SubstrateProcessingState::Skipped); + } + fs::remove_all(dir); +} + +TEST_CASE("SubstrateStore persistence: remove deletes journal file") { + auto dir = scratch_dir("remove"); + SubstrateStore s; + s.enable_persistence(dir); + REQUIRE(s.create("W-A") == SubstrateStore::CreateResult::Created); + REQUIRE(s.create("W-B") == SubstrateStore::CreateResult::Created); + CHECK(count_with_ext(dir, ".sub") == 2); + REQUIRE(s.remove("W-A")); + CHECK(count_with_ext(dir, ".sub") == 1); + + SubstrateStore s2; + s2.enable_persistence(dir); + CHECK_FALSE(s2.has("W-A")); + CHECK(s2.has("W-B")); + fs::remove_all(dir); +} + +TEST_CASE("SubstrateStore persistence: corrupt record dropped, others survive") { + auto dir = scratch_dir("corrupt"); + { + SubstrateStore s; + s.enable_persistence(dir); + REQUIRE(s.create("W-OK") == SubstrateStore::CreateResult::Created); + } + // Wrong magic. + { + std::ofstream f(dir / "9999999999.sub", std::ios::binary); + const char garbage[] = {0x00, 0x00, 0x00}; + f.write(garbage, sizeof(garbage)); + } + SubstrateStore s; + s.enable_persistence(dir); + CHECK(s.has("W-OK")); + CHECK(s.size() == 1); + CHECK(count_with_ext(dir, ".sub") == 1); + fs::remove_all(dir); +} + +TEST_CASE("SubstrateStore: history is NOT journaled (transient by design)") { + auto dir = scratch_dir("history"); + { + SubstrateStore s; + s.enable_persistence(dir); + REQUIRE(s.create("W-H") == SubstrateStore::CreateResult::Created); + REQUIRE(s.fire_location_event("W-H", SubstrateEvent::Acquire, "MOD")); + REQUIRE(s.fire_processing_event("W-H", + SubstrateProcessingEvent::StartProcessing)); + CHECK(s.history("W-H")->size() == 2); + } + { + SubstrateStore s; + s.enable_persistence(dir); + REQUIRE(s.has("W-H")); + // State survived but history is empty post-replay — current state + // is what's durable, not the journey to it. + CHECK(s.history("W-H")->size() == 0); + CHECK(s.get("W-H")->fsm->processing_state() == + SubstrateProcessingState::InProcess); + } + fs::remove_all(dir); +}