persistence: ProcessJobStore + ControlJobStore enable_persistence(dir)

Per-job binary record (.pj / .cj) with magic+version, atomic
.tmp+rename. PJ store additionally writes an order.idx index file
that preserves HOQ-aware queue position across restarts.

Rcpvars / prprocessparams (secs2::Item variants) are intentionally
out of scope for v1 — they're optional E40 trailers and need a body
codec round-trip; callers re-populate via set_e40_extras() after
restart.

Five new tests cover full lifecycle replay (Processing mid-run +
HOQ-reordered queue), dequeue-deletes-file, corrupt-record drop,
CJ state + PJ-list replay, and CJ remove cleanup.

Closes #3 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:35:26 +02:00
parent 1548b49afd
commit 1189ffc994
4 changed files with 570 additions and 41 deletions
+172
View File
@@ -0,0 +1,172 @@
// Persistence tests for ProcessJobStore + ControlJobStore.
#include <doctest/doctest.h>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#include "secsgem/gem/store/control_jobs.hpp"
#include "secsgem/gem/store/process_jobs.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-job-") + 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("ProcessJobStore persistence: state + ordering replay") {
auto dir = scratch_dir("pj-replay");
{
ProcessJobStore s;
s.enable_persistence(dir);
REQUIRE(s.create("PJ-A", "RCP-1", {"W1", "W2"}) ==
ProcessJobStore::CreateResult::Created);
REQUIRE(s.create("PJ-B", "RCP-1", {"W3"}) ==
ProcessJobStore::CreateResult::Created);
REQUIRE(s.create("PJ-C", "RCP-2", {"W4", "W5"}) ==
ProcessJobStore::CreateResult::Created);
// Drive PJ-A into Processing.
REQUIRE(s.fire_internal("PJ-A", ProcessJobEvent::Select));
REQUIRE(s.fire_internal("PJ-A", ProcessJobEvent::SetupComplete));
REQUIRE(s.on_host_command("PJ-A", ProcessJobEvent::Start) ==
HostCmdAck::Accept);
// HOQ-promote PJ-C to head.
REQUIRE(s.on_host_command("PJ-C", ProcessJobEvent::HeadOfQueue) ==
HostCmdAck::Accept);
REQUIRE(s.set_alert("PJ-B", false));
CHECK(count_with_ext(dir, ".pj") == 3);
CHECK(s.position("PJ-C") == 0);
CHECK(s.position("PJ-A") == 1);
CHECK(s.position("PJ-B") == 2);
}
{
ProcessJobStore s;
s.enable_persistence(dir);
REQUIRE(s.has("PJ-A"));
REQUIRE(s.has("PJ-B"));
REQUIRE(s.has("PJ-C"));
// State survived.
CHECK(s.get("PJ-A")->fsm->state() == ProcessJobState::Processing);
CHECK(s.get("PJ-B")->fsm->state() == ProcessJobState::Queued);
CHECK(s.get("PJ-C")->fsm->state() == ProcessJobState::Queued);
// Recipe + materials survived.
CHECK(s.get("PJ-A")->ppid == "RCP-1");
CHECK(s.get("PJ-A")->mtrloutspec == std::vector<std::string>{"W1", "W2"});
// Alert flag survived.
CHECK_FALSE(s.get("PJ-B")->alert_enabled);
// HOQ ordering survived.
CHECK(s.position("PJ-C") == 0);
CHECK(s.position("PJ-A") == 1);
CHECK(s.position("PJ-B") == 2);
}
fs::remove_all(dir);
}
TEST_CASE("ProcessJobStore persistence: dequeue removes journal + index entry") {
auto dir = scratch_dir("pj-dequeue");
{
ProcessJobStore s;
s.enable_persistence(dir);
REQUIRE(s.create("PJ-1", "R", {}) == ProcessJobStore::CreateResult::Created);
REQUIRE(s.create("PJ-2", "R", {}) == ProcessJobStore::CreateResult::Created);
CHECK(count_with_ext(dir, ".pj") == 2);
REQUIRE(s.dequeue("PJ-1") == HostCmdAck::Accept);
CHECK(count_with_ext(dir, ".pj") == 1);
}
{
ProcessJobStore s;
s.enable_persistence(dir);
CHECK_FALSE(s.has("PJ-1"));
CHECK(s.has("PJ-2"));
CHECK(s.ids() == std::vector<std::string>{"PJ-2"});
}
fs::remove_all(dir);
}
TEST_CASE("ProcessJobStore persistence: corrupt record dropped, others survive") {
auto dir = scratch_dir("pj-corrupt");
{
ProcessJobStore s;
s.enable_persistence(dir);
REQUIRE(s.create("PJ-OK", "R", {}) == ProcessJobStore::CreateResult::Created);
}
{
std::ofstream f(dir / "9999999999.pj", std::ios::binary);
const char junk[] = {0x00, 0x01};
f.write(junk, sizeof(junk));
}
ProcessJobStore s;
s.enable_persistence(dir);
CHECK(s.has("PJ-OK"));
CHECK(s.size() == 1);
fs::remove_all(dir);
}
TEST_CASE("ControlJobStore persistence: state + prjob list replay") {
auto dir = scratch_dir("cj-replay");
{
ControlJobStore s;
s.enable_persistence(dir);
REQUIRE(s.create("CJ-A", {"PJ-1", "PJ-2"}) ==
ControlJobStore::CreateResult::Created);
REQUIRE(s.create("CJ-B", {"PJ-3"}) ==
ControlJobStore::CreateResult::Created);
REQUIRE(s.fire_internal("CJ-A", ControlJobEvent::Select));
REQUIRE(s.fire_internal("CJ-A", ControlJobEvent::SetupComplete));
REQUIRE(s.on_host_command("CJ-A", ControlJobEvent::Start) ==
HostCmdAck::Accept);
CHECK(count_with_ext(dir, ".cj") == 2);
}
{
ControlJobStore s;
s.enable_persistence(dir);
REQUIRE(s.has("CJ-A"));
REQUIRE(s.has("CJ-B"));
CHECK(s.get("CJ-A")->fsm->state() == ControlJobState::Executing);
CHECK(s.get("CJ-A")->prjobids == std::vector<std::string>{"PJ-1", "PJ-2"});
CHECK(s.get("CJ-B")->fsm->state() == ControlJobState::Queued);
}
fs::remove_all(dir);
}
TEST_CASE("ControlJobStore persistence: remove deletes journal file") {
auto dir = scratch_dir("cj-remove");
ControlJobStore s;
s.enable_persistence(dir);
REQUIRE(s.create("CJ-X", {"PJ-1"}) ==
ControlJobStore::CreateResult::Created);
REQUIRE(s.create("CJ-Y", {"PJ-2"}) ==
ControlJobStore::CreateResult::Created);
CHECK(count_with_ext(dir, ".cj") == 2);
REQUIRE(s.remove("CJ-X"));
CHECK(count_with_ext(dir, ".cj") == 1);
fs::remove_all(dir);
}