persistence: substrate history journaling in v2 record

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>
This commit is contained in:
2026-06-09 11:34:54 +02:00
parent d9f23d6db8
commit 998e81b3d8
2 changed files with 229 additions and 18 deletions
+82 -7
View File
@@ -5,10 +5,13 @@
#include <doctest/doctest.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#include <thread>
#include <variant>
#include "secsgem/gem/store/substrates.hpp"
@@ -161,7 +164,7 @@ TEST_CASE("SubstrateStore persistence: corrupt record dropped, others survive")
fs::remove_all(dir);
}
TEST_CASE("SubstrateStore: history is NOT journaled (transient by design)") {
TEST_CASE("SubstrateStore: v2 history survives restart with axis + event preserved") {
auto dir = scratch_dir("history");
{
SubstrateStore s;
@@ -170,17 +173,89 @@ TEST_CASE("SubstrateStore: history is NOT journaled (transient by design)") {
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);
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"));
// 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);
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);
}