docs: chapters 30–36 — the codebase (Part 3 complete)

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>
This commit is contained in:
2026-06-09 20:23:05 +02:00
parent 40df3067a4
commit cae98d9a7d
7 changed files with 2127 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
# 36 — Persistence, validation, metrics
← [35 State machines and dispatch](35_state_machines_and_dispatch.md) | [Back to index](00_index.md) | Next: [40 Building, running, the demo](40_building_running_demo.md) →
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
```cpp
// 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)`:
1. Creates `dir` if needed.
2. Iterates files in `dir`.
3. For each file, reads + parses + adds the record to the store.
4. 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:
```cpp
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`](../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:
1. Doesn't bind the port.
2. Tries to load every YAML.
3. Accumulates *every* issue (across files).
4. Prints them all.
5. Exits 0 or 1.
```bash
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`](../include/secsgem/config/validate.hpp):
```cpp
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:
1. Loads the YAML (catching parse errors as one issue).
2. Walks every record, applying structural + referential checks.
3. Adds each problem as an `Issue{path, line, severity, message}`.
Tests:
[`tests/test_config_validate.cpp`](../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_ceid` must reference a CEID defined in
`equipment.yaml::ceids`.
- `events.default_reports[].vids` must reference SVIDs or DVIDs
defined elsewhere.
- `control_state.yaml::transitions` `from`/`to` must 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`](../include/secsgem/metrics/prometheus.hpp))
with two parts:
- **Registry** — accumulates `Counter` and `Gauge` series with
labels.
- **Server** — exposes them on a configurable HTTP port at
`/metrics`.
Typical wiring:
```cpp
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`](../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`](../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`](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.
Next: [→ 40 Building, running, the demo](40_building_running_demo.md)