// Persistence tests for ProcessJobStore + ControlJobStore. #include #include #include #include #include #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{"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{"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{"PJ-1", "PJ-2"}); CHECK(s.get("CJ-B")->fsm->state() == ControlJobState::Queued); } fs::remove_all(dir); } TEST_CASE("ProcessJobStore persistence: rcpvars + prprocessparams survive restart") { auto dir = scratch_dir("pj-extras"); { ProcessJobStore s; s.enable_persistence(dir); REQUIRE(s.create("PJ-X", "RCP-1", {"W1"}) == ProcessJobStore::CreateResult::Created); std::vector rcpvars = { {"setpoint_c", secsgem::secs2::Item::f4(350.5f)}, {"chamber_name", secsgem::secs2::Item::ascii("CHAMBER-A")}, {"flags", secsgem::secs2::Item::u4(uint32_t{0xDEADBEEFu})}, }; std::vector params = { {"recipe_var", secsgem::secs2::Item::list({ secsgem::secs2::Item::ascii("nested"), secsgem::secs2::Item::u2(uint16_t{42})})}, }; REQUIRE(s.set_e40_extras("PJ-X", MaterialFlag::Substrate, ProcessRecipeMethod::RecipeOnly, rcpvars, params)); } { ProcessJobStore s; s.enable_persistence(dir); const auto* pj = s.get("PJ-X"); REQUIRE(pj); REQUIRE(pj->rcpvars.size() == 3); CHECK(pj->rcpvars[0].name == "setpoint_c"); CHECK(pj->rcpvars[0].value.format() == secsgem::secs2::Format::F4); CHECK(pj->rcpvars[1].value.as_ascii() == "CHAMBER-A"); CHECK(pj->rcpvars[2].name == "flags"); REQUIRE(pj->prprocessparams.size() == 1); CHECK(pj->prprocessparams[0].name == "recipe_var"); CHECK(pj->prprocessparams[0].value.is_list()); CHECK(pj->prprocessparams[0].value.as_list().size() == 2); CHECK(pj->prprocessparams[0].value.as_list()[0].as_ascii() == "nested"); } fs::remove_all(dir); } TEST_CASE("ProcessJobStore persistence: v1 record (no extras) still loads") { auto dir = scratch_dir("pj-v1"); // Hand-craft a v1 record on disk. { std::ofstream f(dir / "0000000001.pj", std::ios::binary); // [magic=0xC7][version=1][state=Queued=0][alert=1][mf=0][prrecipemethod=0] const uint8_t header[6] = {0xC7, 0x01, 0x00, 0x01, 0x00, 0x00}; f.write(reinterpret_cast(header), 6); auto write_str = [&](const std::string& s) { uint16_t n = static_cast(s.size()); uint8_t lb[2] = {static_cast(n >> 8), static_cast(n)}; f.write(reinterpret_cast(lb), 2); f.write(s.data(), n); }; write_str("PJ-LEGACY"); // prjobid write_str("RCP-OLD"); // ppid const uint8_t mc[2] = {0x00, 0x00}; f.write(reinterpret_cast(mc), 2); // No v2 trailer. } ProcessJobStore s; s.enable_persistence(dir); REQUIRE(s.has("PJ-LEGACY")); CHECK(s.get("PJ-LEGACY")->ppid == "RCP-OLD"); CHECK(s.get("PJ-LEGACY")->rcpvars.empty()); CHECK(s.get("PJ-LEGACY")->prprocessparams.empty()); 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); }