998e81b3d8
Per-substrate transition history now survives restart. Each entry's
steady_clock timestamp is written as a system_clock-millis snapshot;
on replay the steady_clock time_point is reconstructed relative to
the current (steady_now, system_now) pair, so inter-event spacing
is preserved across restarts even if the FSM is in a different
process. Absolute wall-clock accuracy degrades by any NTP step
that happened between write and read; that's a documented caveat.
Record format goes v1 → v2. v1 (history-less) records still load,
just with empty history.
Test updates:
- the old "history is NOT journaled" test is REPLACED with one
that asserts every axis + event + label round-trips.
- hand-crafted v1 record on disk still loads (proves backwards
compat).
- 15 ms-spaced events restore with their spacing intact (±slop
for scheduler jitter).
Closes the "substrate history persistence" caveat from the post-#1-13
status writeup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
262 lines
9.5 KiB
C++
262 lines
9.5 KiB
C++
// 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 <chrono>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <random>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <variant>
|
|
|
|
#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: v2 history survives restart with axis + event preserved") {
|
|
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));
|
|
REQUIRE(s.fire_processing_event("W-H",
|
|
SubstrateProcessingEvent::EndProcessing));
|
|
CHECK(s.history("W-H")->size() == 3);
|
|
}
|
|
{
|
|
SubstrateStore s;
|
|
s.enable_persistence(dir);
|
|
REQUIRE(s.has("W-H"));
|
|
const auto* hist = s.history("W-H");
|
|
REQUIRE(hist);
|
|
REQUIRE(hist->size() == 3);
|
|
|
|
// Entry 0: location-axis Acquire, processing-axis NoState.
|
|
CHECK((*hist)[0].location_to == SubstrateState::AtWork);
|
|
CHECK((*hist)[0].processing_to == SubstrateProcessingState::NoState);
|
|
CHECK(std::holds_alternative<SubstrateEvent>((*hist)[0].event));
|
|
CHECK(std::get<SubstrateEvent>((*hist)[0].event) == SubstrateEvent::Acquire);
|
|
CHECK((*hist)[0].location_label == "MOD");
|
|
|
|
// Entry 1: processing-axis StartProcessing.
|
|
CHECK((*hist)[1].processing_to == SubstrateProcessingState::InProcess);
|
|
CHECK(std::holds_alternative<SubstrateProcessingEvent>((*hist)[1].event));
|
|
CHECK(std::get<SubstrateProcessingEvent>((*hist)[1].event) ==
|
|
SubstrateProcessingEvent::StartProcessing);
|
|
|
|
// Entry 2: processing-axis EndProcessing.
|
|
CHECK((*hist)[2].processing_to == SubstrateProcessingState::Processed);
|
|
CHECK(std::get<SubstrateProcessingEvent>((*hist)[2].event) ==
|
|
SubstrateProcessingEvent::EndProcessing);
|
|
}
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
TEST_CASE("SubstrateStore: v1 record on disk still loads (empty history)") {
|
|
auto dir = scratch_dir("history-v1");
|
|
{
|
|
std::ofstream f(dir / "0000000001.sub", std::ios::binary);
|
|
// v1: magic 0xC6, version 1, loc=0 proc=0 id=0 slot=0
|
|
const uint8_t header[6] = {0xC6, 0x01, 0x00, 0x00, 0x00, 0x00};
|
|
f.write(reinterpret_cast<const char*>(header), 6);
|
|
auto write_str = [&](const std::string& s) {
|
|
uint16_t n = static_cast<uint16_t>(s.size());
|
|
uint8_t lb[2] = {static_cast<uint8_t>(n >> 8), static_cast<uint8_t>(n)};
|
|
f.write(reinterpret_cast<const char*>(lb), 2);
|
|
f.write(s.data(), n);
|
|
};
|
|
write_str("W-LEGACY");
|
|
write_str("");
|
|
write_str("");
|
|
}
|
|
SubstrateStore s;
|
|
s.enable_persistence(dir);
|
|
REQUIRE(s.has("W-LEGACY"));
|
|
CHECK(s.history("W-LEGACY")->empty());
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
TEST_CASE("SubstrateStore: history timestamps preserve inter-event spacing") {
|
|
// Two events fired ~10 ms apart should land ~10 ms apart in the
|
|
// restored history regardless of how long the "restart" gap is.
|
|
auto dir = scratch_dir("history-spacing");
|
|
using namespace std::chrono_literals;
|
|
{
|
|
SubstrateStore s;
|
|
s.enable_persistence(dir);
|
|
REQUIRE(s.create("W-T") == SubstrateStore::CreateResult::Created);
|
|
REQUIRE(s.fire_location_event("W-T", SubstrateEvent::Acquire));
|
|
std::this_thread::sleep_for(15ms);
|
|
REQUIRE(s.fire_processing_event("W-T",
|
|
SubstrateProcessingEvent::StartProcessing));
|
|
}
|
|
std::this_thread::sleep_for(20ms);
|
|
{
|
|
SubstrateStore s;
|
|
s.enable_persistence(dir);
|
|
const auto* hist = s.history("W-T");
|
|
REQUIRE(hist);
|
|
REQUIRE(hist->size() == 2);
|
|
const auto delta = (*hist)[1].at - (*hist)[0].at;
|
|
// ~15 ms spacing should survive the persist/restore round-trip;
|
|
// ±10 ms slop for scheduling jitter.
|
|
CHECK(delta >= 5ms);
|
|
CHECK(delta <= 60ms);
|
|
}
|
|
fs::remove_all(dir);
|
|
}
|