CC1: persistent file-backed spool

Adds opt-in disk persistence to SpoolStore.  `enable_persistence(dir)`
turns every enqueue into a single `<seq>.spool` file alongside the
in-memory queue; drain and clear delete the matching files; restart
replays the directory sorted by seq.

Writes are atomic: serialize the message via the SECS-II codec, write
to `.tmp`, then `std::filesystem::rename` to the final name.  Malformed
records are dropped silently so a single bad file can't poison the
whole spool.

`secs_server --spool-dir <path>` enables persistence at startup.
Without the flag the behaviour is identical to before (in-memory only).

Two new tests: enqueue → restart → replay → drain restores the wire
order, and clear deletes the journal files.

Test suite: 291 cases / 1515 assertions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:51:46 +02:00
parent cfa2d1e531
commit d69f26b415
3 changed files with 250 additions and 5 deletions
+77
View File
@@ -1,6 +1,10 @@
#include <doctest/doctest.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <sys/types.h>
#include <unistd.h>
#include "secsgem/gem/data_model.hpp"
@@ -300,3 +304,76 @@ TEST_CASE("spool force flag controls whether enqueue is taken") {
s.set_force_spool(true);
CHECK(s.force_spool());
}
TEST_CASE("spool persistence: write, restart, replay") {
namespace fs = std::filesystem;
// Unique temp dir per test run.
auto dir = fs::temp_directory_path() /
("spool_test_" + std::to_string(::getpid()) + "_" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::remove_all(dir);
{
SpoolStore s;
s.set_spoolable_streams({5, 6});
s.enable_persistence(dir);
CHECK(s.empty());
s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{42})));
s.enqueue(s2::Message(5, 1, false, s2::Item::ascii("ALARM")));
CHECK(s.size() == 2);
// Two journal files written.
std::size_t count = 0;
for (auto& e : fs::directory_iterator(dir)) {
if (e.path().extension() == ".spool") ++count;
}
CHECK(count == 2);
}
// Simulate restart: fresh store rehydrates from the same dir.
{
SpoolStore s;
s.set_spoolable_streams({5, 6});
s.enable_persistence(dir);
REQUIRE(s.size() == 2);
auto drained = s.drain();
REQUIRE(drained.size() == 2);
CHECK(drained[0].stream == 6);
CHECK(drained[1].stream == 5);
// FIFO: first-enqueued comes out first.
REQUIRE(drained[0].body.has_value());
CHECK(std::get<std::vector<uint32_t>>(drained[0].body->storage()).front() == 42);
CHECK(drained[1].body->as_ascii() == "ALARM");
// Files removed after drain.
std::size_t count = 0;
for (auto& e : fs::directory_iterator(dir)) {
if (e.path().extension() == ".spool") ++count;
}
CHECK(count == 0);
}
fs::remove_all(dir);
}
TEST_CASE("spool persistence: clear deletes files") {
namespace fs = std::filesystem;
auto dir = fs::temp_directory_path() /
("spool_clear_" + std::to_string(::getpid()) + "_" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count() + 1));
fs::remove_all(dir);
SpoolStore s;
s.set_spoolable_streams({6});
s.enable_persistence(dir);
s.enqueue(s2::Message(6, 11, false));
s.enqueue(s2::Message(6, 11, false));
CHECK(s.size() == 2);
s.clear();
CHECK(s.empty());
std::size_t count = 0;
for (auto& e : fs::directory_iterator(dir)) {
if (e.path().extension() == ".spool") ++count;
}
CHECK(count == 0);
fs::remove_all(dir);
}