Seven chapters walking the implementation top-to-bottom. 30 — Repository tour. Top-level layout, directory by directory. The eight built binaries. The dependency graph from TCP socket up through EquipmentDataModel. CMake's role. Test layout. 31 — Spec-as-data and codegen. Why the design choice fits SECS/ GEM specifically. The five YAML files: messages catalog, control/PJ/CJ transition tables, equipment dictionary. How tools/gen_messages.py turns messages.yaml into typed C++ at build time. The --validate-config multi-error validator. How to add a new SVID / CEID / host command / state / message without C++. 32 — Stores and the data model. What a store IS (records + API + change handler + optional persistence). Every store in the codebase mapped to the SEMI standard it serves (table of 21). EquipmentDataModel as plain composition + cross-store convenience methods (vid_value, compose_reports_for). The no-locks single- threaded contract. How to add a new store. 33 — Transport. hsms::Connection read path (length+payload async chain), write path (queue + one outstanding write), timer model (5 steady_timers + per-request T3). The asio executor / strand model and why it's the right shape. secsi::Protocol as the IO- free FSM with Action / Event variants; secsi::TcpTransport as the asio adapter. Pattern repeats for E84 + GEM comm-state. 34 — Codec and SML. The four files (170 + 30 + 52 + 32 lines of header, 229 + 220 lines of impl). Item variant storage layout (11 alternatives, 16 formats, shared storage where E5 permits). encode_into recursion; decode_at with bounds checks throwing CodecError. Message wrapper. SML printer + try_parse_sml + why SML round-trips Items but not necessarily bytes. 35 — State machines and dispatch. gem::Router as a typed (stream, function) dispatch table. How an S2F41 round-trip walks through parser → store dispatch → side-effect → CEID emission → S6F11 build → spool-aware deliver. The 11 FSMs all sharing the same three-property shape (pure data table + pure FSM + observer pattern). CEID cascading from FSM transitions to wire bytes. 36 — Persistence, validation, metrics. Which 7 stores have file journals + why the others don't. Per-record file pattern (atomic rename, partial-write safe). Schema versioning + multi-version read. Multi-error YAML validator (--validate-config) + cross-file reference checks. Prometheus registry + HTTP exporter + worked metric patterns from the PVD example. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
9.8 KiB
36 — Persistence, validation, metrics
← 35 State machines and dispatch | Back to index | Next: 40 Building, running, the demo →
Three operational concerns wrap up Part 3:
- Persistence — file-backed journals for the seven stores that survive equipment restarts.
- Validation — the multi-error YAML validator behind
--validate-config. - Metrics — the Prometheus exporter.
Each is a small slice of the codebase but load-bearing for production deployments.
Persistence
Which stores persist
Seven of the 21 stores have file-backed journals:
| Store | Survives equipment restart |
|---|---|
SpoolStore |
Queued messages waiting for host comm to come back. |
ProcessJobStore |
All in-progress PJs and their state machines. |
ControlJobStore |
All in-progress CJs. |
ExceptionStore |
Posted exceptions and their recovery state. |
CarrierStore |
Docked carriers + slot maps + access state. |
LoadPortStore |
Per-port association + reservation. |
SubstrateStore |
Per-substrate location + STS / SPS / ID status. |
The remaining 14 stores (SVIDs, ECIDs, CEIDs, alarm registry, …)
don't persist — their state is reconstructed from the YAML or
from real-time signals on restart. An ECID that the host had
changed would be lost on restart unless the EAP writes it back
to the YAML (E40-style S2F15 is rare in production for
exactly this reason).
The per-record file pattern
Every persistent store uses the same shape:
/var/lib/secsgem/<store>/
├── PJ-001 # one file per record
├── PJ-002
├── PJ-003
└── ...
One file per record, named by ID. When the store is mutated, the
file is rewritten atomically (write to .tmp + rename). When
the record is removed, the file is unlink'd.
This is partial-write safe. If the equipment power-cycles mid-write of one record, the others are untouched. At startup, the store iterates the directory, reads each file, and replays into in-memory state. A file that fails to parse (corrupted or unfinished) is dropped with a log line.
How a store enables persistence
// apps/secs_server.cpp — startup
auto model = std::make_shared<gem::EquipmentDataModel>();
if (!spool_dir.empty()) {
model->spool.enable_persistence(spool_dir);
}
if (!pj_dir.empty()) {
model->process_jobs.enable_persistence(pj_dir);
}
// ... etc per store
enable_persistence(dir):
- Creates
dirif needed. - Iterates files in
dir. - For each file, reads + parses + adds the record to the store.
- Sets up the on-disk journal for subsequent mutations.
The persistence is opt-in per store, configured via CLI flag in
apps/secs_server.cpp. Some deployments want spool persistence
but not job persistence (e.g., test rigs); the per-store toggle
makes that easy.
File format and versioning
Each record file is a small binary blob:
magic: 4 bytes "SGv1" (store-specific magic; v1 = version 1)
version: 4 bytes (uint32_t, big-endian) — schema version
length: 4 bytes (uint32_t, big-endian) — payload length
payload: N bytes — store-specific record encoding
checksum: 4 bytes (CRC-32C over header + payload)
Schema versioning is built in. Every store has a kVersion
constant. When the store reads a file:
if (file_version > kVersion)
drop the file (newer than us; can't read)
if (file_version < kVersion)
apply the upgrade path (v1 → v2 → v3 reader chain)
if (file_version == kVersion)
read directly
Multi-version reads let a new equipment release process old
on-disk records without manual migration. Tested by
tests/test_persistence_upgrade.cpp
(7 cases — every store with persistence, write v1, restart at
v2, verify replay).
Tests
| Store | Test file | Cases |
|---|---|---|
| Spool | bundled into tests/test_data_model.cpp |
— |
| Process Jobs | tests/test_job_persistence.cpp (PJ + CJ together) |
7 |
| Control Jobs | same | — |
| Exception | tests/test_exception_persistence.cpp |
5 |
| Carrier | tests/test_carrier_persistence.cpp |
6 |
| Substrate | tests/test_substrate_persistence.cpp |
7 |
| Upgrade path | tests/test_persistence_upgrade.cpp |
7 |
Each persistence test covers: write a record, restart, verify replayed; partial-write recovery (truncated file dropped); remove deletes the file; corrupted file is dropped without throwing.
Validation
Why a separate validator
YAML loaders throw on first error. That's the right behaviour at process startup — fail fast — but it's frustrating for an operator with a fresh equipment.yaml that has three typos.
--validate-config is a separate CLI flag that:
- Doesn't bind the port.
- Tries to load every YAML.
- Accumulates every issue (across files).
- Prints them all.
- Exits 0 or 1.
secs_server --validate-config \
--config data/equipment.yaml \
--state-table data/control_state.yaml \
--pj-state-table data/process_job_state.yaml \
--cj-state-table data/control_job_state.yaml
Typical output:
data/equipment.yaml:42: SVID 5 references undefined enum 'ChamberStateEnum'
data/equipment.yaml:78: alarm 3 has ALCD bit-7 cleared but alarm is declared 'active'
data/control_state.yaml:11: transition from OnlineRemote on host_request_remote has no `to` or `ack` field
data/equipment.yaml:104: host_command VENT references unknown CEID 999
4 error(s), 0 warning(s) across 4 files
Then exit 1.
How it's implemented
include/secsgem/config/validate.hpp:
class ConfigValidator {
public:
void validate_equipment(const std::string& path);
void validate_control_state(const std::string& path);
void validate_process_job_state(const std::string& path);
void validate_control_job_state(const std::string& path);
std::size_t error_count() const;
std::size_t warning_count() const;
bool has_errors() const;
const std::vector<Issue>& issues() const;
void format_issues_to(std::ostream&, FormatOptions = {}) const;
};
Each validate_* method:
- Loads the YAML (catching parse errors as one issue).
- Walks every record, applying structural + referential checks.
- Adds each problem as an
Issue{path, line, severity, message}.
Tests:
tests/test_config_validate.cpp
(8 cases — every category of issue: missing required field,
typed mismatch, dangling reference, duplicate ID, …).
Reference checks across files
Cross-file references are validated last (after all files are parsed). Examples:
host_commands[].emit_ceidmust reference a CEID defined inequipment.yaml::ceids.events.default_reports[].vidsmust reference SVIDs or DVIDs defined elsewhere.control_state.yaml::transitionsfrom/tomust reference states declared by the schema (the 5 standard control states).
This catches "I deleted the CEID but forgot to update the host_command" before runtime.
Metrics
What gets exported
The codebase ships a Prometheus exporter
(include/secsgem/metrics/prometheus.hpp)
with two parts:
- Registry — accumulates
CounterandGaugeseries with labels. - Server — exposes them on a configurable HTTP port at
/metrics.
Typical wiring:
auto registry = std::make_shared<metrics::Registry>();
registry->register_metric("secsgem_ceid_emits_total", metrics::MetricType::Counter);
registry->register_metric("secsgem_spool_depth", metrics::MetricType::Gauge);
registry->register_metric("secsgem_pj_state", metrics::MetricType::Gauge);
// ...later, in the CEID-emit handler:
registry->counter("secsgem_ceid_emits_total", {{"ceid", std::to_string(ceid)}}).inc();
// ...periodically:
registry->gauge("secsgem_spool_depth").set(model->spool.size());
// Start the HTTP server:
auto exporter = std::make_shared<metrics::PrometheusServer>(io, /*port=*/9090, registry);
The exporter is wire-compatible with Prometheus scrape (text
format). Tested by
tests/test_metrics_prometheus.cpp
(3 cases — counter increment, gauge set, HTTP scrape format).
What to expose
Common patterns from
examples/pvd_tool/main.cpp §7:
- Per-CEID counters (
secsgem_ceid_emits_total{ceid="300"}). - Per-alarm counters (
secsgem_alarm_set_total{alid="42"}). - Spool depth gauge (alarm in operations if it climbs).
- Per-state EPT durations (sample of E116 buckets).
- T3 timeout counter (alarm in operations if non-zero).
The exporter doesn't dictate which metrics to expose — the EAP
decides. See
docs/INTEGRATION.md §6.4 for the production
patterns.
End of Part 3
You now know every layer of the runtime:
- The repository layout (chapter 30).
- The spec-as-data philosophy + codegen (chapter 31).
- The stores + data model (chapter 32).
- The transport implementation (chapter 33).
- The codec + SML (chapter 34).
- Router + state machines + dispatch (chapter 35).
- Persistence + validation + metrics (this chapter).
Part 4 turns to operations — how a customer actually builds, runs, deploys, and integrates this codebase into a real fab tool.