d9f23d6db8
Closes the v1 caveat: the optional E40-0705 trailers on S16F11 —
recipe variables (RcpVar) and process parameters (ProcessParam),
each carrying a secs2::Item value of arbitrary type — now survive
restart.
Record format bumps to v2:
v2 header = v1 header
+ [u16 rcpvar_count][repeat: u16 name_len, name, u32 enc_len,
secs2::encode(value)]
+ [u16 ppparam_count][...same shape]
v1 records are still accepted by load_record_ (no extras come back).
Two new tests:
- round-trip mixed F4 / ASCII / U4 / nested-list values through
rcpvars + prprocessparams
- hand-crafted v1 record on disk still loads cleanly, just with
empty extras (proves backwards compat)
Closes the "PJ rcpvars / prprocessparams persistence" caveat from
the post-#1-13 status writeup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
243 lines
8.0 KiB
C++
243 lines
8.0 KiB
C++
// 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("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<RcpVar> 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<ProcessParam> 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<const char*>(header), 6);
|
|
auto write_str = [&](const std::string& s) {
|
|
uint16_t n = static_cast<uint16_t>(s.size());
|
|
uint8_t lb[2] = {static_cast<uint8_t>(n >> 8), static_cast<uint8_t>(n)};
|
|
f.write(reinterpret_cast<const char*>(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<const char*>(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);
|
|
}
|