persistence: SubstrateStore enable_persistence(dir)
Same pattern as carriers: per-substrate binary record (.sub) with atomic .tmp+rename, replay on enable, delete on remove. Records current state across all three E90 axes (location / processing / ID-status), plus substid / carrierid / slot / free-form location label. History is deliberately NOT journaled — it's an in-memory ring buffer and rebuilding from replayed state would mislead. Five new tests cover full-axis replay, every terminal processing state, remove-deletes-journal, corrupt-record drop, and the history-is-transient invariant. Closes #2 in the test-gap backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
@@ -34,6 +39,7 @@ struct Substrate {
|
||||
std::string location; // free-form: equipment-defined module name
|
||||
std::vector<SubstrateHistoryEntry> history;
|
||||
std::unique_ptr<SubstrateStateMachine> 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<SubstrateStateMachine>();
|
||||
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<std::string> ids() const {
|
||||
std::vector<std::string> 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<std::pair<uint64_t, fs::path>> 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<SubstrateState> loc,
|
||||
std::optional<SubstrateProcessingState> proc,
|
||||
std::optional<SubstrateIDStatus> id) {
|
||||
auto fsm = std::make_unique<SubstrateStateMachine>();
|
||||
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<unsigned long long>(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<uint8_t>(s->fsm->location_state()),
|
||||
static_cast<uint8_t>(s->fsm->processing_state()),
|
||||
static_cast<uint8_t>(s->fsm->id_state()),
|
||||
s->slot};
|
||||
out.write(reinterpret_cast<const char*>(header), sizeof(header));
|
||||
auto write_str = [&](const std::string& str) {
|
||||
uint16_t n = static_cast<uint16_t>(str.size());
|
||||
uint8_t lb[2] = {static_cast<uint8_t>(n >> 8), static_cast<uint8_t>(n)};
|
||||
out.write(reinterpret_cast<const char*>(lb), 2);
|
||||
out.write(str.data(), static_cast<std::streamsize>(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<Record> 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<char*>(header), sizeof(header));
|
||||
if (!in || header[0] != kMagic || header[1] != kVersion) return std::nullopt;
|
||||
Record r;
|
||||
r.loc_state = static_cast<SubstrateState>(header[2]);
|
||||
r.proc_state = static_cast<SubstrateProcessingState>(header[3]);
|
||||
r.id_state = static_cast<SubstrateIDStatus>(header[4]);
|
||||
r.slot = header[5];
|
||||
auto read_str = [&](std::string& out) {
|
||||
uint8_t lb[2];
|
||||
in.read(reinterpret_cast<char*>(lb), 2);
|
||||
if (!in) return false;
|
||||
uint16_t n = static_cast<uint16_t>((uint16_t(lb[0]) << 8) | lb[1]);
|
||||
out.resize(n);
|
||||
in.read(out.data(), n);
|
||||
return static_cast<bool>(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 <typename Event>
|
||||
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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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 <doctest/doctest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#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);
|
||||
}
|
||||
Reference in New Issue
Block a user