b3bde7f087
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>
150 lines
4.4 KiB
C++
150 lines
4.4 KiB
C++
// 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);
|
|
}
|