Files
secs-gem/include/secsgem/gem/store/spool.hpp
T
raphael e3765a5176 persistence: multi-version reads across every store
ProcessJobStore and SubstrateStore already implemented the
loader-accepts-any-version-in-[1, kVersion] pattern.  The other five
stores (ControlJobStore, CarrierStore, LoadPortStore, ExceptionStore,
SpoolStore) used strict `header[1] != kVersion` rejection, meaning
a future kVersion bump there would silently nuke every persisted
record on first replay.  That's a footgun the test_persistence_upgrade
test already flagged as a tripwire.

This commit flips the strict checks to `< 1 || > kVersion`, mirroring
PJ + Substrate.  No format change (kVersion stays at 1 across the
five stores), but:

- Future v2 of any store now Just Works: add fields at the end of
  write_record_, bump kVersion to 2, gate the new reads behind
  `if (version >= 2)`.  Old v1 records on disk continue to replay
  with the new fields defaulted.
- Future versions beyond kVersion still get rejected (downgrade
  protection — older code can't try to decode trailers it doesn't
  understand).

Comment blocks on each kVersion declaration now describe the upgrade
discipline so the next contributor doesn't reinvent it.

Test additions:
- Positive test that v1 ControlJob records load on current code
  (will continue to pass when kVersion bumps to 2, proving v1 is
  still readable)
- ExceptionStore rejects a v9 (future) record, matching CJ + Carrier
- The existing tripwire tests get retitled from "rejects unknown
  version" to "rejects a future version" to reflect the new contract

README §6 gets honest: every store is now multi-version-aware, not
just PJ + Substrate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 14:53:05 +02:00

278 lines
9.3 KiB
C++

#pragma once
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <filesystem>
#include <fstream>
#include <optional>
#include <set>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
#include "secsgem/secs2/codec.hpp"
#include "secsgem/secs2/message.hpp"
// E30 §6.22 Spooling.
//
// When the equipment can't deliver a primary message to the host (because
// the link is down, the host hasn't selected, or — for the demo — a
// test-force flag is set), it queues the message in the spool instead of
// dropping it. When the host later sends S6F23 with RSDC=0, the equipment
// transmits everything in the queue. RSDC=1 purges.
//
// The set of streams the equipment is allowed to spool is configured via
// S2F43 (Reset Spooling). This implementation supports the simple form
// (per-stream enable list, no per-message-type granularity) which covers
// the common cases (stream 5 alarms + stream 6 event reports).
//
// Persistence (opt-in): when `enable_persistence(dir)` is called, each
// enqueued message is also written as a single file `<seq>.spool` in
// the configured directory; on restart, `enable_persistence` replays
// the directory back into the in-memory queue. Drain and clear delete
// the corresponding files. Writes are atomic: write to `.tmp`, fsync,
// rename.
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
// S2F44 RSPACK — ack for S2F43.
enum class ResetSpoolAck : uint8_t {
Accept = 0,
Denied_Busy = 1,
Denied_NotAllowed = 2,
Denied_NoSuchStream = 3,
};
// S6F24 RSDA — ack for S6F23 (Request Spooled Data).
enum class SpoolRequestAck : uint8_t {
Accept = 0,
Denied = 1,
};
// S6F23 RSDC — host's request code in the Spool Request.
enum class SpoolRequestCode : uint8_t {
Transmit = 0,
Purge = 1,
};
class SpoolStore {
public:
enum class EnqueueResult {
Queued,
Dropped_NotSpoolable, // stream not in the configured spoolable set
Dropped_Full, // queue at max_size, oldest-message overflow policy
};
// Spool config (set by S2F43 + loader).
void set_spoolable_streams(std::vector<uint8_t> streams) {
spoolable_.clear();
for (auto s : streams) spoolable_.insert(s);
}
bool is_stream_spoolable(uint8_t stream) const {
return spoolable_.count(stream) > 0;
}
std::vector<uint8_t> spoolable_streams() const {
return {spoolable_.begin(), spoolable_.end()};
}
void set_max_size(std::size_t n) { max_size_ = n; }
std::size_t max_size() const { return max_size_; }
// Equipment may force-enable spool mode (e.g. while the host is offline,
// or via a test command in the demo). Independent of the spoolable
// stream list — if `force_spool` is true *and* the stream is spoolable,
// enqueue() returns Queued. If `force_spool` is false, enqueue() still
// returns Dropped_NotSpoolable (so the caller can send live instead).
void set_force_spool(bool on) { force_spool_ = on; }
bool force_spool() const { return force_spool_; }
// ---- Persistence ----------------------------------------------------
// Enable file-backed spooling at `dir`. Creates the directory if it
// doesn't exist; scans existing `*.spool` files and replays them
// back into the queue (sorted by seq) before returning. Subsequent
// enqueue / drain / clear calls keep the directory in sync. Errors
// (permission, malformed records) are silently skipped per file so a
// single bad record can't poison the whole spool — the worst case is
// dropping that one message.
void enable_persistence(std::filesystem::path dir) {
namespace fs = std::filesystem;
journal_dir_ = std::move(dir);
persistent_ = true;
std::error_code ec;
fs::create_directories(journal_dir_, ec);
// Replay: collect all .spool files, sort by integer seq prefix.
std::vector<std::pair<uint64_t, fs::path>> existing;
for (auto& entry : fs::directory_iterator(journal_dir_, ec)) {
if (!entry.is_regular_file()) continue;
const auto& p = entry.path();
if (p.extension() != ".spool") continue;
uint64_t seq = 0;
try { seq = std::stoull(p.stem().string()); }
catch (...) { continue; }
existing.emplace_back(seq, p);
}
std::sort(existing.begin(), existing.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& [seq, path] : existing) {
auto msg = load_record(path);
if (!msg) {
// unreadable / malformed; drop the file so it doesn't linger.
fs::remove(path, ec);
continue;
}
queue_.push_back({std::move(*msg), path});
if (seq >= next_seq_) next_seq_ = seq + 1;
}
}
bool persistence_enabled() const { return persistent_; }
std::filesystem::path journal_dir() const { return journal_dir_; }
EnqueueResult enqueue(s2::Message msg) {
if (!is_stream_spoolable(msg.stream)) return EnqueueResult::Dropped_NotSpoolable;
if (queue_.size() >= max_size_) {
// Overflow policy: drop the oldest (FIFO eviction).
if (persistent_ && !queue_.front().path.empty()) {
std::error_code ec;
std::filesystem::remove(queue_.front().path, ec);
}
queue_.pop_front();
}
std::filesystem::path path;
if (persistent_) {
path = write_record(msg);
}
queue_.push_back({std::move(msg), std::move(path)});
return EnqueueResult::Queued;
}
// Drain everything currently in the queue (FIFO order). Also deletes
// the matching journal files when persistence is enabled.
std::vector<s2::Message> drain() {
std::vector<s2::Message> out;
out.reserve(queue_.size());
std::error_code ec;
while (!queue_.empty()) {
auto& slot = queue_.front();
out.push_back(std::move(slot.msg));
if (persistent_ && !slot.path.empty()) {
std::filesystem::remove(slot.path, ec);
}
queue_.pop_front();
}
return out;
}
void clear() {
if (persistent_) {
std::error_code ec;
for (auto& slot : queue_) {
if (!slot.path.empty()) std::filesystem::remove(slot.path, ec);
}
}
queue_.clear();
}
std::size_t size() const { return queue_.size(); }
bool empty() const { return queue_.empty(); }
private:
struct Slot {
s2::Message msg;
std::filesystem::path path; // empty when persistence is disabled
};
// Journal record (v1) layout (little-endian-free; all bytes explicit):
// [u8 magic = 0xE5]
// [u8 version]
// [u8 stream]
// [u8 function]
// [u8 reply_expected]
// [u32 body_length, big-endian]
// [body_length bytes]
//
// Upgrade discipline: loader accepts any version in [1, kVersion];
// future fields append behind an `if (version >= N)` gate.
static constexpr uint8_t kMagic = 0xE5;
static constexpr uint8_t kVersion = 0x01;
std::filesystem::path write_record(const s2::Message& msg) {
namespace fs = std::filesystem;
auto seq = next_seq_++;
char namebuf[32];
std::snprintf(namebuf, sizeof(namebuf), "%010llu.spool",
static_cast<unsigned long long>(seq));
fs::path final_path = journal_dir_ / namebuf;
fs::path tmp_path = final_path;
tmp_path += ".tmp";
auto body_bytes = msg.body ? secs2::encode(*msg.body) : std::vector<uint8_t>{};
const uint32_t blen = static_cast<uint32_t>(body_bytes.size());
std::ofstream out(tmp_path, std::ios::binary | std::ios::trunc);
if (!out) return {};
uint8_t hdr[5] = {kMagic, kVersion, msg.stream, msg.function,
static_cast<uint8_t>(msg.reply_expected ? 1 : 0)};
out.write(reinterpret_cast<const char*>(hdr), sizeof(hdr));
uint8_t blen_be[4] = {
static_cast<uint8_t>((blen >> 24) & 0xFF),
static_cast<uint8_t>((blen >> 16) & 0xFF),
static_cast<uint8_t>((blen >> 8) & 0xFF),
static_cast<uint8_t>(blen & 0xFF)};
out.write(reinterpret_cast<const char*>(blen_be), sizeof(blen_be));
if (blen) out.write(reinterpret_cast<const char*>(body_bytes.data()), blen);
out.flush();
out.close();
std::error_code ec;
fs::rename(tmp_path, final_path, ec);
if (ec) {
fs::remove(tmp_path, ec);
return {};
}
return final_path;
}
static std::optional<s2::Message> load_record(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return std::nullopt;
uint8_t hdr[5];
in.read(reinterpret_cast<char*>(hdr), sizeof(hdr));
if (!in || hdr[0] != kMagic ||
hdr[1] < 1 || hdr[1] > kVersion) return std::nullopt;
uint8_t blen_be[4];
in.read(reinterpret_cast<char*>(blen_be), sizeof(blen_be));
if (!in) return std::nullopt;
const uint32_t blen = (uint32_t{blen_be[0]} << 24) |
(uint32_t{blen_be[1]} << 16) |
(uint32_t{blen_be[2]} << 8) |
uint32_t{blen_be[3]};
std::vector<uint8_t> body(blen);
if (blen) {
in.read(reinterpret_cast<char*>(body.data()), blen);
if (!in) return std::nullopt;
}
try {
return s2::Message::from_body(hdr[2], hdr[3], hdr[4] != 0, body);
} catch (...) {
return std::nullopt;
}
}
std::set<uint8_t> spoolable_;
std::deque<Slot> queue_;
std::size_t max_size_ = 100;
bool force_spool_ = false;
bool persistent_ = false;
std::filesystem::path journal_dir_;
uint64_t next_seq_ = 0;
};
} // namespace secsgem::gem