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:
@@ -111,6 +111,7 @@ add_executable(secsgem_tests
|
|||||||
tests/test_exceptions.cpp
|
tests/test_exceptions.cpp
|
||||||
tests/test_carrier_state.cpp
|
tests/test_carrier_state.cpp
|
||||||
tests/test_carriers.cpp
|
tests/test_carriers.cpp
|
||||||
|
tests/test_carrier_persistence.cpp
|
||||||
tests/test_substrates.cpp
|
tests/test_substrates.cpp
|
||||||
tests/test_ept.cpp
|
tests/test_ept.cpp
|
||||||
tests/test_cem_objects.cpp
|
tests/test_cem_objects.cpp
|
||||||
|
|||||||
@@ -144,6 +144,17 @@ class CarrierStateMachine {
|
|||||||
bool on_slot_map_event(SlotMapEvent e);
|
bool on_slot_map_event(SlotMapEvent e);
|
||||||
bool on_access_event(CarrierAccessEvent e);
|
bool on_access_event(CarrierAccessEvent e);
|
||||||
|
|
||||||
|
// Direct state-restore (persistence replay only). Bypasses the
|
||||||
|
// transition tables and does NOT fire change handlers — a restored
|
||||||
|
// state isn't a transition, it's a checkpoint. Callers must ensure
|
||||||
|
// the (id, sm, acc) triple was a legal state at write time.
|
||||||
|
void restore_state(CarrierIDStatus id, SlotMapStatus sm,
|
||||||
|
CarrierAccessStatus acc) {
|
||||||
|
id_state_ = id;
|
||||||
|
sm_state_ = sm;
|
||||||
|
acc_state_ = acc;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
CarrierIDTable id_table_;
|
CarrierIDTable id_table_;
|
||||||
SlotMapTable sm_table_;
|
SlotMapTable sm_table_;
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ class LoadPortStateMachine {
|
|||||||
bool on_reservation_event(LoadPortReservationEvent e);
|
bool on_reservation_event(LoadPortReservationEvent e);
|
||||||
bool on_association_event(LoadPortAssociationEvent e);
|
bool on_association_event(LoadPortAssociationEvent e);
|
||||||
|
|
||||||
|
// Direct state-restore (persistence replay only). See carrier_state.hpp.
|
||||||
|
void restore_state(LoadPortTransferState tx, LoadPortReservationStatus rs,
|
||||||
|
LoadPortAssociationStatus as) {
|
||||||
|
tx_state_ = tx;
|
||||||
|
rs_state_ = rs;
|
||||||
|
as_state_ = as;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
LoadPortTransferTable tx_table_;
|
LoadPortTransferTable tx_table_;
|
||||||
LoadPortReservationTable rs_table_;
|
LoadPortReservationTable rs_table_;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <system_error>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -27,12 +32,14 @@ struct Carrier {
|
|||||||
uint8_t port_id = 0; // 0 = unbound; ports are 1-based
|
uint8_t port_id = 0; // 0 = unbound; ports are 1-based
|
||||||
std::vector<CarrierSlot> slots; // length == capacity (typ. 25)
|
std::vector<CarrierSlot> slots; // length == capacity (typ. 25)
|
||||||
std::unique_ptr<CarrierStateMachine> fsm;
|
std::unique_ptr<CarrierStateMachine> fsm;
|
||||||
|
std::filesystem::path journal_path; // empty when persistence disabled
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LoadPort {
|
struct LoadPort {
|
||||||
uint8_t port_id;
|
uint8_t port_id;
|
||||||
std::string carrierid; // empty when nothing associated
|
std::string carrierid; // empty when nothing associated
|
||||||
std::unique_ptr<LoadPortStateMachine> fsm;
|
std::unique_ptr<LoadPortStateMachine> fsm;
|
||||||
|
std::filesystem::path journal_path; // empty when persistence disabled
|
||||||
};
|
};
|
||||||
|
|
||||||
// Both stores are non-movable for the same reason ProcessJobStore is —
|
// Both stores are non-movable for the same reason ProcessJobStore is —
|
||||||
@@ -64,23 +71,9 @@ class CarrierStore {
|
|||||||
CreateResult create(std::string carrierid, uint8_t port_id = 0,
|
CreateResult create(std::string carrierid, uint8_t port_id = 0,
|
||||||
std::size_t capacity = 25) {
|
std::size_t capacity = 25) {
|
||||||
if (carriers_.count(carrierid)) return CreateResult::Denied_AlreadyExists;
|
if (carriers_.count(carrierid)) return CreateResult::Denied_AlreadyExists;
|
||||||
auto fsm = std::make_unique<CarrierStateMachine>();
|
install_(carrierid, port_id, std::vector<CarrierSlot>(capacity),
|
||||||
const std::string id = carrierid;
|
/*restore_id=*/std::nullopt, std::nullopt, std::nullopt);
|
||||||
fsm->set_id_handler([this, id](CarrierIDStatus f, CarrierIDStatus t,
|
if (persistent_) write_record_(carrierid);
|
||||||
CarrierIDEvent e) {
|
|
||||||
if (on_id_) on_id_(id, f, t, e);
|
|
||||||
});
|
|
||||||
fsm->set_slot_map_handler([this, id](SlotMapStatus f, SlotMapStatus t,
|
|
||||||
SlotMapEvent e) {
|
|
||||||
if (on_sm_) on_sm_(id, f, t, e);
|
|
||||||
});
|
|
||||||
fsm->set_access_handler([this, id](CarrierAccessStatus f, CarrierAccessStatus t,
|
|
||||||
CarrierAccessEvent e) {
|
|
||||||
if (on_acc_) on_acc_(id, f, t, e);
|
|
||||||
});
|
|
||||||
carriers_.emplace(carrierid, Carrier{carrierid, port_id,
|
|
||||||
std::vector<CarrierSlot>(capacity),
|
|
||||||
std::move(fsm)});
|
|
||||||
return CreateResult::Created;
|
return CreateResult::Created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,18 +89,43 @@ class CarrierStore {
|
|||||||
|
|
||||||
bool fire_id_event(const std::string& cid, CarrierIDEvent e) {
|
bool fire_id_event(const std::string& cid, CarrierIDEvent e) {
|
||||||
auto* c = get(cid);
|
auto* c = get(cid);
|
||||||
return c && c->fsm->on_id_event(e);
|
if (!(c && c->fsm->on_id_event(e))) return false;
|
||||||
|
if (persistent_) write_record_(cid);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool fire_slot_map_event(const std::string& cid, SlotMapEvent e) {
|
bool fire_slot_map_event(const std::string& cid, SlotMapEvent e) {
|
||||||
auto* c = get(cid);
|
auto* c = get(cid);
|
||||||
return c && c->fsm->on_slot_map_event(e);
|
if (!(c && c->fsm->on_slot_map_event(e))) return false;
|
||||||
|
if (persistent_) write_record_(cid);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool fire_access_event(const std::string& cid, CarrierAccessEvent e) {
|
bool fire_access_event(const std::string& cid, CarrierAccessEvent e) {
|
||||||
auto* c = get(cid);
|
auto* c = get(cid);
|
||||||
return c && c->fsm->on_access_event(e);
|
if (!(c && c->fsm->on_access_event(e))) return false;
|
||||||
|
if (persistent_) write_record_(cid);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool remove(const std::string& cid) { return carriers_.erase(cid) > 0; }
|
// Application sets slots[i].state directly; persistence sync is opt-in
|
||||||
|
// through this helper so the journal records the new slot map.
|
||||||
|
void set_slot_state(const std::string& cid, std::size_t slot_idx,
|
||||||
|
uint8_t state) {
|
||||||
|
auto* c = get(cid);
|
||||||
|
if (!c || slot_idx >= c->slots.size()) return;
|
||||||
|
c->slots[slot_idx].state = state;
|
||||||
|
if (persistent_) write_record_(cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool remove(const std::string& cid) {
|
||||||
|
auto it = carriers_.find(cid);
|
||||||
|
if (it == carriers_.end()) return false;
|
||||||
|
if (persistent_ && !it->second.journal_path.empty()) {
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove(it->second.journal_path, ec);
|
||||||
|
}
|
||||||
|
carriers_.erase(it);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
std::size_t size() const { return carriers_.size(); }
|
std::size_t size() const { return carriers_.size(); }
|
||||||
std::vector<std::string> ids() const {
|
std::vector<std::string> ids() const {
|
||||||
std::vector<std::string> out;
|
std::vector<std::string> out;
|
||||||
@@ -116,11 +134,174 @@ class CarrierStore {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Persistence ----------------------------------------------------
|
||||||
|
// Mirrors SpoolStore::enable_persistence: scan `dir` for *.car records,
|
||||||
|
// replay them into the in-memory map, then keep the directory in sync
|
||||||
|
// on subsequent create / fire_* / remove calls. Atomic writes via
|
||||||
|
// .tmp + rename. Records that fail magic/version are silently dropped
|
||||||
|
// so a single bad file can't poison the whole store.
|
||||||
|
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() != ".car") 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->carrierid, rec->port_id, std::move(rec->slots),
|
||||||
|
rec->id_state, rec->sm_state, rec->acc_state);
|
||||||
|
auto* c = get(rec->carrierid);
|
||||||
|
if (c) c->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:
|
private:
|
||||||
|
struct Record {
|
||||||
|
std::string carrierid;
|
||||||
|
uint8_t port_id;
|
||||||
|
std::vector<CarrierSlot> slots;
|
||||||
|
CarrierIDStatus id_state;
|
||||||
|
SlotMapStatus sm_state;
|
||||||
|
CarrierAccessStatus acc_state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Carrier record layout, big-endian throughout:
|
||||||
|
// [u8 magic = 0xC4][u8 version = 1]
|
||||||
|
// [u8 id_state][u8 sm_state][u8 acc_state][u8 port_id]
|
||||||
|
// [u16 slot_count][slot_count × u8 slot.state]
|
||||||
|
// [u16 cid_len][cid_len × u8 carrierid]
|
||||||
|
static constexpr uint8_t kMagic = 0xC4;
|
||||||
|
static constexpr uint8_t kVersion = 0x01;
|
||||||
|
|
||||||
|
void install_(const std::string& carrierid, uint8_t port_id,
|
||||||
|
std::vector<CarrierSlot> slots,
|
||||||
|
std::optional<CarrierIDStatus> id_state,
|
||||||
|
std::optional<SlotMapStatus> sm_state,
|
||||||
|
std::optional<CarrierAccessStatus> acc_state) {
|
||||||
|
auto fsm = std::make_unique<CarrierStateMachine>();
|
||||||
|
if (id_state || sm_state || acc_state) {
|
||||||
|
fsm->restore_state(id_state.value_or(CarrierIDStatus::NotConfirmed),
|
||||||
|
sm_state.value_or(SlotMapStatus::NotRead),
|
||||||
|
acc_state.value_or(CarrierAccessStatus::NotAccessed));
|
||||||
|
}
|
||||||
|
const std::string id = carrierid;
|
||||||
|
fsm->set_id_handler([this, id](CarrierIDStatus f, CarrierIDStatus t,
|
||||||
|
CarrierIDEvent e) {
|
||||||
|
if (on_id_) on_id_(id, f, t, e);
|
||||||
|
});
|
||||||
|
fsm->set_slot_map_handler([this, id](SlotMapStatus f, SlotMapStatus t,
|
||||||
|
SlotMapEvent e) {
|
||||||
|
if (on_sm_) on_sm_(id, f, t, e);
|
||||||
|
});
|
||||||
|
fsm->set_access_handler([this, id](CarrierAccessStatus f, CarrierAccessStatus t,
|
||||||
|
CarrierAccessEvent e) {
|
||||||
|
if (on_acc_) on_acc_(id, f, t, e);
|
||||||
|
});
|
||||||
|
carriers_.emplace(carrierid, Carrier{carrierid, port_id, std::move(slots),
|
||||||
|
std::move(fsm), {}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_record_(const std::string& carrierid) {
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
auto* c = get(carrierid);
|
||||||
|
if (!c) return;
|
||||||
|
fs::path target = c->journal_path;
|
||||||
|
if (target.empty()) {
|
||||||
|
char namebuf[32];
|
||||||
|
std::snprintf(namebuf, sizeof(namebuf), "%010llu.car",
|
||||||
|
static_cast<unsigned long long>(next_seq_++));
|
||||||
|
target = journal_dir_ / namebuf;
|
||||||
|
c->journal_path = target;
|
||||||
|
}
|
||||||
|
fs::path tmp = target;
|
||||||
|
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>(c->fsm->id_status()),
|
||||||
|
static_cast<uint8_t>(c->fsm->slot_map_status()),
|
||||||
|
static_cast<uint8_t>(c->fsm->access_status()),
|
||||||
|
c->port_id};
|
||||||
|
out.write(reinterpret_cast<const char*>(header), sizeof(header));
|
||||||
|
auto write_be16 = [&](uint16_t v) {
|
||||||
|
uint8_t b[2] = {static_cast<uint8_t>(v >> 8), static_cast<uint8_t>(v)};
|
||||||
|
out.write(reinterpret_cast<const char*>(b), 2);
|
||||||
|
};
|
||||||
|
write_be16(static_cast<uint16_t>(c->slots.size()));
|
||||||
|
for (const auto& s : c->slots) {
|
||||||
|
out.put(static_cast<char>(s.state));
|
||||||
|
}
|
||||||
|
write_be16(static_cast<uint16_t>(c->carrierid.size()));
|
||||||
|
out.write(c->carrierid.data(),
|
||||||
|
static_cast<std::streamsize>(c->carrierid.size()));
|
||||||
|
out.flush();
|
||||||
|
out.close();
|
||||||
|
std::error_code ec;
|
||||||
|
fs::rename(tmp, target, 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.id_state = static_cast<CarrierIDStatus>(header[2]);
|
||||||
|
r.sm_state = static_cast<SlotMapStatus>(header[3]);
|
||||||
|
r.acc_state = static_cast<CarrierAccessStatus>(header[4]);
|
||||||
|
r.port_id = header[5];
|
||||||
|
auto read_be16 = [&](uint16_t& v) {
|
||||||
|
uint8_t b[2];
|
||||||
|
in.read(reinterpret_cast<char*>(b), 2);
|
||||||
|
if (!in) return false;
|
||||||
|
v = static_cast<uint16_t>((uint16_t(b[0]) << 8) | b[1]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
uint16_t slot_count = 0;
|
||||||
|
if (!read_be16(slot_count)) return std::nullopt;
|
||||||
|
r.slots.resize(slot_count);
|
||||||
|
for (auto& s : r.slots) {
|
||||||
|
int c = in.get();
|
||||||
|
if (c == std::char_traits<char>::eof()) return std::nullopt;
|
||||||
|
s.state = static_cast<uint8_t>(c);
|
||||||
|
}
|
||||||
|
uint16_t cid_len = 0;
|
||||||
|
if (!read_be16(cid_len)) return std::nullopt;
|
||||||
|
r.carrierid.resize(cid_len);
|
||||||
|
in.read(r.carrierid.data(), cid_len);
|
||||||
|
if (!in) return std::nullopt;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
std::map<std::string, Carrier> carriers_;
|
std::map<std::string, Carrier> carriers_;
|
||||||
IDChangeHandler on_id_;
|
IDChangeHandler on_id_;
|
||||||
SlotMapChangeHandler on_sm_;
|
SlotMapChangeHandler on_sm_;
|
||||||
AccessChangeHandler on_acc_;
|
AccessChangeHandler on_acc_;
|
||||||
|
bool persistent_ = false;
|
||||||
|
std::filesystem::path journal_dir_;
|
||||||
|
uint64_t next_seq_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
class LoadPortStore {
|
class LoadPortStore {
|
||||||
@@ -148,24 +329,8 @@ class LoadPortStore {
|
|||||||
// Idempotent: re-create a port_id is a no-op.
|
// Idempotent: re-create a port_id is a no-op.
|
||||||
bool create(uint8_t port_id) {
|
bool create(uint8_t port_id) {
|
||||||
if (ports_.count(port_id)) return false;
|
if (ports_.count(port_id)) return false;
|
||||||
auto fsm = std::make_unique<LoadPortStateMachine>();
|
install_(port_id, "", std::nullopt, std::nullopt, std::nullopt);
|
||||||
const uint8_t pid = port_id;
|
if (persistent_) write_record_(port_id);
|
||||||
fsm->set_transfer_handler([this, pid](LoadPortTransferState f,
|
|
||||||
LoadPortTransferState t,
|
|
||||||
LoadPortTransferEvent e) {
|
|
||||||
if (on_tx_) on_tx_(pid, f, t, e);
|
|
||||||
});
|
|
||||||
fsm->set_reservation_handler([this, pid](LoadPortReservationStatus f,
|
|
||||||
LoadPortReservationStatus t,
|
|
||||||
LoadPortReservationEvent e) {
|
|
||||||
if (on_rs_) on_rs_(pid, f, t, e);
|
|
||||||
});
|
|
||||||
fsm->set_association_handler([this, pid](LoadPortAssociationStatus f,
|
|
||||||
LoadPortAssociationStatus t,
|
|
||||||
LoadPortAssociationEvent e) {
|
|
||||||
if (on_as_) on_as_(pid, f, t, e);
|
|
||||||
});
|
|
||||||
ports_.emplace(port_id, LoadPort{port_id, "", std::move(fsm)});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,23 +348,31 @@ class LoadPortStore {
|
|||||||
auto* p = get(pid);
|
auto* p = get(pid);
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
p->carrierid = std::move(carrierid);
|
p->carrierid = std::move(carrierid);
|
||||||
return p->fsm->on_association_event(LoadPortAssociationEvent::Associate);
|
bool ok = p->fsm->on_association_event(LoadPortAssociationEvent::Associate);
|
||||||
|
if (persistent_) write_record_(pid);
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool disassociate(uint8_t pid) {
|
bool disassociate(uint8_t pid) {
|
||||||
auto* p = get(pid);
|
auto* p = get(pid);
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
p->carrierid.clear();
|
p->carrierid.clear();
|
||||||
return p->fsm->on_association_event(LoadPortAssociationEvent::Disassociate);
|
bool ok = p->fsm->on_association_event(LoadPortAssociationEvent::Disassociate);
|
||||||
|
if (persistent_) write_record_(pid);
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool fire_transfer_event(uint8_t pid, LoadPortTransferEvent e) {
|
bool fire_transfer_event(uint8_t pid, LoadPortTransferEvent e) {
|
||||||
auto* p = get(pid);
|
auto* p = get(pid);
|
||||||
return p && p->fsm->on_transfer_event(e);
|
if (!(p && p->fsm->on_transfer_event(e))) return false;
|
||||||
|
if (persistent_) write_record_(pid);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool fire_reservation_event(uint8_t pid, LoadPortReservationEvent e) {
|
bool fire_reservation_event(uint8_t pid, LoadPortReservationEvent e) {
|
||||||
auto* p = get(pid);
|
auto* p = get(pid);
|
||||||
return p && p->fsm->on_reservation_event(e);
|
if (!(p && p->fsm->on_reservation_event(e))) return false;
|
||||||
|
if (persistent_) write_record_(pid);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::size_t size() const { return ports_.size(); }
|
std::size_t size() const { return ports_.size(); }
|
||||||
@@ -210,11 +383,137 @@ class LoadPortStore {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Persistence ----------------------------------------------------
|
||||||
|
// One file per port: `<port_id 0-padded>.lp`. Port IDs are 1–255 so
|
||||||
|
// we can use the port id directly as the filename stem.
|
||||||
|
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);
|
||||||
|
|
||||||
|
for (auto& entry : fs::directory_iterator(journal_dir_, ec)) {
|
||||||
|
if (!entry.is_regular_file()) continue;
|
||||||
|
const auto& p = entry.path();
|
||||||
|
if (p.extension() != ".lp") continue;
|
||||||
|
auto rec = load_record_(p);
|
||||||
|
if (!rec) { fs::remove(p, ec); continue; }
|
||||||
|
install_(rec->port_id, rec->carrierid, rec->tx_state, rec->rs_state,
|
||||||
|
rec->as_state);
|
||||||
|
auto* lp = get(rec->port_id);
|
||||||
|
if (lp) lp->journal_path = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool persistence_enabled() const { return persistent_; }
|
||||||
|
std::filesystem::path journal_dir() const { return journal_dir_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct Record {
|
||||||
|
uint8_t port_id;
|
||||||
|
std::string carrierid;
|
||||||
|
LoadPortTransferState tx_state;
|
||||||
|
LoadPortReservationStatus rs_state;
|
||||||
|
LoadPortAssociationStatus as_state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load-port record:
|
||||||
|
// [u8 magic = 0xC5][u8 version = 1]
|
||||||
|
// [u8 port_id][u8 tx_state][u8 rs_state][u8 as_state]
|
||||||
|
// [u16 cid_len][cid_len × u8 carrierid]
|
||||||
|
static constexpr uint8_t kMagic = 0xC5;
|
||||||
|
static constexpr uint8_t kVersion = 0x01;
|
||||||
|
|
||||||
|
void install_(uint8_t port_id, std::string carrierid,
|
||||||
|
std::optional<LoadPortTransferState> tx,
|
||||||
|
std::optional<LoadPortReservationStatus> rs,
|
||||||
|
std::optional<LoadPortAssociationStatus> as) {
|
||||||
|
auto fsm = std::make_unique<LoadPortStateMachine>();
|
||||||
|
if (tx || rs || as) {
|
||||||
|
fsm->restore_state(tx.value_or(LoadPortTransferState::InService),
|
||||||
|
rs.value_or(LoadPortReservationStatus::NotReserved),
|
||||||
|
as.value_or(LoadPortAssociationStatus::NotAssociated));
|
||||||
|
}
|
||||||
|
const uint8_t pid = port_id;
|
||||||
|
fsm->set_transfer_handler([this, pid](LoadPortTransferState f,
|
||||||
|
LoadPortTransferState t,
|
||||||
|
LoadPortTransferEvent e) {
|
||||||
|
if (on_tx_) on_tx_(pid, f, t, e);
|
||||||
|
});
|
||||||
|
fsm->set_reservation_handler([this, pid](LoadPortReservationStatus f,
|
||||||
|
LoadPortReservationStatus t,
|
||||||
|
LoadPortReservationEvent e) {
|
||||||
|
if (on_rs_) on_rs_(pid, f, t, e);
|
||||||
|
});
|
||||||
|
fsm->set_association_handler([this, pid](LoadPortAssociationStatus f,
|
||||||
|
LoadPortAssociationStatus t,
|
||||||
|
LoadPortAssociationEvent e) {
|
||||||
|
if (on_as_) on_as_(pid, f, t, e);
|
||||||
|
});
|
||||||
|
ports_.emplace(port_id, LoadPort{port_id, std::move(carrierid),
|
||||||
|
std::move(fsm), {}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_record_(uint8_t pid) {
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
auto* lp = get(pid);
|
||||||
|
if (!lp) return;
|
||||||
|
if (lp->journal_path.empty()) {
|
||||||
|
char namebuf[16];
|
||||||
|
std::snprintf(namebuf, sizeof(namebuf), "%03u.lp", unsigned(pid));
|
||||||
|
lp->journal_path = journal_dir_ / namebuf;
|
||||||
|
}
|
||||||
|
fs::path tmp = lp->journal_path;
|
||||||
|
tmp += ".tmp";
|
||||||
|
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out) return;
|
||||||
|
uint8_t header[6] = {
|
||||||
|
kMagic, kVersion, pid,
|
||||||
|
static_cast<uint8_t>(lp->fsm->transfer_state()),
|
||||||
|
static_cast<uint8_t>(lp->fsm->reservation_state()),
|
||||||
|
static_cast<uint8_t>(lp->fsm->association_state())};
|
||||||
|
out.write(reinterpret_cast<const char*>(header), sizeof(header));
|
||||||
|
uint16_t cid_len = static_cast<uint16_t>(lp->carrierid.size());
|
||||||
|
uint8_t lb[2] = {static_cast<uint8_t>(cid_len >> 8),
|
||||||
|
static_cast<uint8_t>(cid_len)};
|
||||||
|
out.write(reinterpret_cast<const char*>(lb), 2);
|
||||||
|
out.write(lp->carrierid.data(),
|
||||||
|
static_cast<std::streamsize>(lp->carrierid.size()));
|
||||||
|
out.flush();
|
||||||
|
out.close();
|
||||||
|
std::error_code ec;
|
||||||
|
fs::rename(tmp, lp->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.port_id = header[2];
|
||||||
|
r.tx_state = static_cast<LoadPortTransferState>(header[3]);
|
||||||
|
r.rs_state = static_cast<LoadPortReservationStatus>(header[4]);
|
||||||
|
r.as_state = static_cast<LoadPortAssociationStatus>(header[5]);
|
||||||
|
uint8_t lb[2];
|
||||||
|
in.read(reinterpret_cast<char*>(lb), 2);
|
||||||
|
if (!in) return std::nullopt;
|
||||||
|
uint16_t cid_len = static_cast<uint16_t>((uint16_t(lb[0]) << 8) | lb[1]);
|
||||||
|
r.carrierid.resize(cid_len);
|
||||||
|
in.read(r.carrierid.data(), cid_len);
|
||||||
|
if (cid_len && !in) return std::nullopt;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
std::map<uint8_t, LoadPort> ports_;
|
std::map<uint8_t, LoadPort> ports_;
|
||||||
TransferChangeHandler on_tx_;
|
TransferChangeHandler on_tx_;
|
||||||
ReservationChangeHandler on_rs_;
|
ReservationChangeHandler on_rs_;
|
||||||
AssociationChangeHandler on_as_;
|
AssociationChangeHandler on_as_;
|
||||||
|
bool persistent_ = false;
|
||||||
|
std::filesystem::path journal_dir_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace secsgem::gem
|
} // namespace secsgem::gem
|
||||||
|
|||||||
@@ -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