persistence: ExceptionStore enable_persistence(dir)
Per-EXID binary record (.ex), magic + version + atomic .tmp+rename. Records full E5 §9 lifecycle: state, EXID, EXTYPE, EXMESSAGE, and the candidate EXRECVRA list. Cleared exceptions are terminal — the FSM transitions through Cleared remove the in-memory entry AND delete the journal file (matching the existing in-memory semantics). Recovering / RecoverFailed states survive restart: the application can decide on replay whether to retry recovery or abort. Five new tests cover post+replay, Recovering-survives-restart, autonomous-clear cleanup, RecoverFailed retry post-restart, and corrupt-record drop. This completes #12 in the test-gap backlog (persistence for the four in-memory stores beyond Spool). Closes #4 in the test-gap backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Persistence tests for ExceptionStore.
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include "secsgem/gem/store/exceptions.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-ex-") + 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("ExceptionStore persistence: post + replay reproduces full record") {
|
||||
auto dir = scratch_dir("post-replay");
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
REQUIRE(s.post(42, "VACUUM", "lost vacuum in chamber A",
|
||||
{"PURGE", "RECOVER", "ABORT"}) ==
|
||||
ExceptionStore::PostResult::Posted);
|
||||
REQUIRE(s.post(99, "OVERTEMP", "process tube > 850C",
|
||||
{"COOL"}) ==
|
||||
ExceptionStore::PostResult::Posted);
|
||||
CHECK(count_with_ext(dir, ".ex") == 2);
|
||||
}
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
REQUIRE(s.has(42));
|
||||
REQUIRE(s.has(99));
|
||||
const auto* e42 = s.get(42);
|
||||
CHECK(e42->extype == "VACUUM");
|
||||
CHECK(e42->exmessage == "lost vacuum in chamber A");
|
||||
CHECK(e42->exrecvra == std::vector<std::string>{"PURGE", "RECOVER", "ABORT"});
|
||||
CHECK(e42->fsm->state() == ExceptionState::Posted);
|
||||
|
||||
const auto* e99 = s.get(99);
|
||||
CHECK(e99->exrecvra.size() == 1);
|
||||
CHECK(e99->exrecvra[0] == "COOL");
|
||||
}
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
TEST_CASE("ExceptionStore persistence: Recovering state survives restart") {
|
||||
auto dir = scratch_dir("recover");
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
REQUIRE(s.post(1, "GAS", "leak", {"PURGE"}) ==
|
||||
ExceptionStore::PostResult::Posted);
|
||||
CHECK(s.on_recover(1, "PURGE") == AlarmAck::Accept);
|
||||
CHECK(s.state(1) == ExceptionState::Recovering);
|
||||
}
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
REQUIRE(s.has(1));
|
||||
CHECK(s.state(1) == ExceptionState::Recovering);
|
||||
|
||||
// Application decides to complete recovery — state moves to Cleared,
|
||||
// which terminally removes the entry + deletes the journal file.
|
||||
REQUIRE(s.fire_internal(1, ExceptionEvent::RecoveryComplete));
|
||||
CHECK_FALSE(s.has(1));
|
||||
CHECK(count_with_ext(dir, ".ex") == 0);
|
||||
}
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
TEST_CASE("ExceptionStore persistence: autonomous Clear deletes journal") {
|
||||
auto dir = scratch_dir("autoclear");
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
REQUIRE(s.post(7, "RF", "rf fault", {"RESET"}) ==
|
||||
ExceptionStore::PostResult::Posted);
|
||||
CHECK(count_with_ext(dir, ".ex") == 1);
|
||||
REQUIRE(s.fire_internal(7, ExceptionEvent::Clear));
|
||||
CHECK_FALSE(s.has(7));
|
||||
CHECK(count_with_ext(dir, ".ex") == 0);
|
||||
}
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
CHECK(s.size() == 0);
|
||||
}
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
TEST_CASE("ExceptionStore persistence: RecoverFailed survives restart and retry works") {
|
||||
auto dir = scratch_dir("retry");
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
REQUIRE(s.post(3, "FLOW", "MFC stuck", {"RETRY", "ABORT"}) ==
|
||||
ExceptionStore::PostResult::Posted);
|
||||
CHECK(s.on_recover(3, "RETRY") == AlarmAck::Accept);
|
||||
REQUIRE(s.fire_internal(3, ExceptionEvent::RecoveryFailed));
|
||||
CHECK(s.state(3) == ExceptionState::RecoverFailed);
|
||||
}
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
CHECK(s.state(3) == ExceptionState::RecoverFailed);
|
||||
// Recovery from RecoverFailed is legal — host can retry.
|
||||
CHECK(s.on_recover(3, "RETRY") == AlarmAck::Accept);
|
||||
CHECK(s.state(3) == ExceptionState::Recovering);
|
||||
}
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
TEST_CASE("ExceptionStore persistence: corrupt file dropped, others survive") {
|
||||
auto dir = scratch_dir("corrupt");
|
||||
{
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
REQUIRE(s.post(11, "T", "M", {"R"}) == ExceptionStore::PostResult::Posted);
|
||||
}
|
||||
{
|
||||
std::ofstream f(dir / "9999999999.ex", std::ios::binary);
|
||||
const char junk[] = {0x00, 0x01, 0x02};
|
||||
f.write(junk, sizeof(junk));
|
||||
}
|
||||
ExceptionStore s;
|
||||
s.enable_persistence(dir);
|
||||
CHECK(s.has(11));
|
||||
CHECK(s.size() == 1);
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
Reference in New Issue
Block a user