persistence: CarrierStore + LoadPortStore enable_persistence(dir)
Mirrors SpoolStore: per-record file with atomic .tmp+rename, magic+ version-prefixed binary layout, replay on enable, delete on remove. FSMs gain a restore_state() that bypasses the transition table and handlers since a replay isn't a transition. Six new tests cover write+restart+replay across every CIDS/CSMS/CAS axis, remove-deletes-journal, malformed-record drop-not-poison, and the persistence-disabled no-op path. Closes #1 in the test-gap backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <doctest/doctest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#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<char>(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);
|
||||
}
|
||||
Reference in New Issue
Block a user