Implements the largest functional gap from the compliance audit. The
equipment now queues events the host can't immediately receive (either
because there's no SELECTED session or because the demo's force-spool
flag is on) and transmits the queue on host request.
What's new
include/secsgem/gem/store/spool.hpp
SpoolStore: a deque queue with a configurable per-stream whitelist
(so only streams 5+6 spool by default), a max_size cap with FIFO
eviction on overflow, and a `force_spool` test flag. Enqueue
returns one of Queued / Dropped_NotSpoolable / Dropped_Full so the
caller can fall back to live delivery when appropriate. Drain
pops the entire queue in FIFO order. Two new ack enums:
ResetSpoolAck (S2F44 RSPACK) and SpoolRequestAck (S6F24 RSDA), plus
SpoolRequestCode (S6F23 RSDC, Transmit/Purge).
data/messages.yaml + auto-regenerated messages.hpp
S2F43 W <L,n <B stream>> Reset Spooling
S2F44 <L,2 <B RSPACK> <L,a ...>> Reset Spooling Ack
S6F23 W <B RSDC> Request Spooled Data
S6F24 <B RSDA> Request Spooled Data Ack
data/equipment.yaml
`spool:` section: max_size + spoolable_streams list. Two new host
commands SPOOL_ON / SPOOL_OFF that flip the force-spool flag (these
stand in for "host link down" in the demo without dropping TCP).
include/secsgem/gem/store/host_commands.hpp
Spec/Result gain an optional<bool> force_spool field. S2F41
dispatch returns the result, the server applies it after S2F42 is
queued.
src/config/loader.cpp
Reads `spool:` from equipment.yaml; reads `force_spool` from each
host_commands entry; populates SpoolStore + CommandSpec.
apps/secs_server.cpp
New `deliver_or_spool(msg, what)` helper. emit_event and
emit_alarm_set funnel through it: if force_spool is on (or there's
no active session), msg.stream is checked against the spoolable
list and the message is enqueued; otherwise it's sent live.
Two new handlers:
S2F43 parses the stream list, updates SpoolStore, replies S2F44
S6F23 RSDC=Transmit drains and re-sends each as a fresh primary
(posted on the executor so the S6F24 ack flushes first);
RSDC=Purge clears the queue and acks.
The S2F41 handler now also propagates result.force_spool into the
SpoolStore.
apps/secs_client.cpp
Demo extended with 4 new steps after the FAULT branch:
SPOOL_ON -> S2F42 Accept
START -> S2F42 Accept; CEID 300 emission spooled (no live S6F11)
SPOOL_OFF -> S2F42 Accept; queue still has the message
S6F23(Transmit) -> S6F24 Accept; spooled S6F11 arrives next
Then the existing S7F19/S7F5/S10F1/S1F15/Separate flow continues.
tests/test_data_model.cpp
Four new TEST_CASEs for SpoolStore (whitelist, FIFO eviction at
max_size, drain ordering, force flag).
tests/test_loader.cpp
Confirms equipment.yaml's `spool:` section populates the store and
`force_spool: true/false` flows through to dispatch results.
COMPLIANCE.md
Spooling moves from ⬜ to 🟡. Adds S2F43/F44 + S6F23/F24 as ✅ in
the message coverage matrix; calls out what's still missing
(S6F25/F26 notification, automatic activation on HSMS NOT-SELECTED,
persistent on-disk spool).
Verified
- Tests: 73 cases / 383 assertions pass (+4 spool cases).
- Demo (docker compose up server client) walks the full happy path
and the spool path, observed in the server log as:
spool: force_spool=true (depth=0)
spool: S6F11 CEID=300 queued (depth=1)
spool: force_spool=false (depth=1)
S6F23 transmit: draining 1 messages
and on the host side as the queued S6F11 arriving in the correct
order after S6F24.
Known limitations (logged in COMPLIANCE.md)
- Spool activation is manual via SPOOL_ON/OFF rather than
automatically triggered by HSMS NOT-SELECTED.
- No S6F25/F26 spooled-data-ready notification on re-SELECT.
- In-memory only; an equipment restart loses queued events.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -186,7 +186,7 @@ TEST_CASE("recipe CRUD") {
|
||||
|
||||
// ---- Composite EquipmentDataModel ---------------------------------------
|
||||
|
||||
TEST_CASE("EquipmentDataModel composes the seven stores") {
|
||||
TEST_CASE("EquipmentDataModel composes the eight stores") {
|
||||
EquipmentDataModel m;
|
||||
m.svids.add({1, "Clock", "", s2::Item::ascii("")});
|
||||
m.dvids.add({2, "Temp", "C", s2::Item::u4(uint32_t{25})});
|
||||
@@ -195,3 +195,50 @@ TEST_CASE("EquipmentDataModel composes the seven stores") {
|
||||
CHECK_FALSE(m.vid_exists(3));
|
||||
CHECK(m.vid_value(2) == s2::Item::u4(uint32_t{25}));
|
||||
}
|
||||
|
||||
// ---- Spool ---------------------------------------------------------------
|
||||
|
||||
TEST_CASE("spool enqueue respects spoolable_streams whitelist") {
|
||||
SpoolStore s;
|
||||
s.set_spoolable_streams({5, 6});
|
||||
CHECK(s.enqueue(s2::Message(6, 11, false)) == SpoolStore::EnqueueResult::Queued);
|
||||
CHECK(s.enqueue(s2::Message(5, 1, false)) == SpoolStore::EnqueueResult::Queued);
|
||||
CHECK(s.enqueue(s2::Message(1, 2, false)) == SpoolStore::EnqueueResult::Dropped_NotSpoolable);
|
||||
CHECK(s.size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("spool FIFO eviction when max_size reached") {
|
||||
SpoolStore s;
|
||||
s.set_spoolable_streams({6});
|
||||
s.set_max_size(3);
|
||||
for (int i = 0; i < 5; ++i)
|
||||
s.enqueue(s2::Message(6, 11, false, s2::Item::u4(static_cast<uint32_t>(i))));
|
||||
CHECK(s.size() == 3);
|
||||
// Oldest two (0, 1) were evicted; remaining are 2, 3, 4.
|
||||
auto drained = s.drain();
|
||||
REQUIRE(drained.size() == 3);
|
||||
auto first_u4 = [](const s2::Message& m) -> uint32_t {
|
||||
return std::get<std::vector<uint32_t>>(m.body->storage()).front();
|
||||
};
|
||||
CHECK(first_u4(drained[0]) == 2);
|
||||
CHECK(first_u4(drained[2]) == 4);
|
||||
}
|
||||
|
||||
TEST_CASE("spool drain returns FIFO order and empties the queue") {
|
||||
SpoolStore s;
|
||||
s.set_spoolable_streams({6});
|
||||
s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{1})));
|
||||
s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{2})));
|
||||
auto out = s.drain();
|
||||
CHECK(out.size() == 2);
|
||||
CHECK(s.empty());
|
||||
CHECK(s.drain().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("spool force flag controls whether enqueue is taken") {
|
||||
SpoolStore s;
|
||||
s.set_spoolable_streams({6});
|
||||
CHECK_FALSE(s.force_spool());
|
||||
s.set_force_spool(true);
|
||||
CHECK(s.force_spool());
|
||||
}
|
||||
|
||||
@@ -64,6 +64,20 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman
|
||||
CHECK(start.emit_ceid.value_or(0) == 300);
|
||||
auto fault = m.commands.dispatch("FAULT", {});
|
||||
CHECK(fault.set_alarm.value_or(0) == 1);
|
||||
|
||||
// Spool config loaded from YAML.
|
||||
CHECK(m.spool.is_stream_spoolable(5));
|
||||
CHECK(m.spool.is_stream_spoolable(6));
|
||||
CHECK_FALSE(m.spool.is_stream_spoolable(1));
|
||||
CHECK(m.spool.max_size() == 100);
|
||||
|
||||
// force_spool flag plumbed from YAML through CommandSpec.
|
||||
auto on = m.commands.dispatch("SPOOL_ON", {});
|
||||
auto off = m.commands.dispatch("SPOOL_OFF", {});
|
||||
REQUIRE(on.force_spool.has_value());
|
||||
CHECK(*on.force_spool == true);
|
||||
REQUIRE(off.force_spool.has_value());
|
||||
CHECK(*off.force_spool == false);
|
||||
}
|
||||
|
||||
TEST_CASE("loader surfaces YAML errors with file path") {
|
||||
|
||||
Reference in New Issue
Block a user