Files
secs-gem/include/secsgem/gem/store/substrates.hpp
T
raphael 998e81b3d8 persistence: substrate history journaling in v2 record
Per-substrate transition history now survives restart.  Each entry's
steady_clock timestamp is written as a system_clock-millis snapshot;
on replay the steady_clock time_point is reconstructed relative to
the current (steady_now, system_now) pair, so inter-event spacing
is preserved across restarts even if the FSM is in a different
process.  Absolute wall-clock accuracy degrades by any NTP step
that happened between write and read; that's a documented caveat.

Record format goes v1 → v2.  v1 (history-less) records still load,
just with empty history.

Test updates:
  - the old "history is NOT journaled" test is REPLACED with one
    that asserts every axis + event + label round-trips.
  - hand-crafted v1 record on disk still loads (proves backwards
    compat).
  - 15 ms-spaced events restore with their spacing intact (±slop
    for scheduler jitter).

Closes the "substrate history persistence" caveat from the post-#1-13
status writeup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:34:54 +02:00

445 lines
17 KiB
C++

#pragma once
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <system_error>
#include <utility>
#include <variant>
#include <vector>
#include "secsgem/gem/substrate_state.hpp"
namespace secsgem::gem {
// One entry in a substrate's per-axis transition log. Holds the
// post-transition state on one axis (the other stays at NoState),
// the triggering event encoded back into the SubstrateEvent or
// SubstrateProcessingEvent enum (held discriminated by `is_processing`),
// the location string at the moment, and a steady_clock timestamp.
struct SubstrateHistoryEntry {
std::chrono::steady_clock::time_point at;
SubstrateState location_to{SubstrateState::NoState};
SubstrateProcessingState processing_to{SubstrateProcessingState::NoState};
std::variant<SubstrateEvent, SubstrateProcessingEvent> event;
std::string location_label; // free-form location at the time of the event
};
struct Substrate {
std::string substid; // SUBSTID — E90 wafer identifier
std::string carrierid; // CARRIERID where this wafer originated
uint8_t slot = 0; // 1-based slot in the source carrier
std::string location; // free-form: equipment-defined module name
std::vector<SubstrateHistoryEntry> history;
std::unique_ptr<SubstrateStateMachine> fsm;
std::filesystem::path journal_path; // empty when persistence disabled
};
class SubstrateStore {
public:
using LocationChangeHandler =
std::function<void(const std::string& substid, SubstrateState from,
SubstrateState to, SubstrateEvent)>;
using ProcessingChangeHandler =
std::function<void(const std::string& substid,
SubstrateProcessingState from,
SubstrateProcessingState to,
SubstrateProcessingEvent)>;
SubstrateStore() = default;
SubstrateStore(const SubstrateStore&) = delete;
SubstrateStore& operator=(const SubstrateStore&) = delete;
SubstrateStore(SubstrateStore&&) = delete;
SubstrateStore& operator=(SubstrateStore&&) = delete;
void set_location_handler(LocationChangeHandler h) { on_loc_ = std::move(h); }
void set_processing_handler(ProcessingChangeHandler h) { on_proc_ = std::move(h); }
enum class CreateResult { Created, Denied_AlreadyExists };
// Cap per-substrate history retention to keep memory bounded. 0 ==
// unlimited. Default cap is 256 entries which comfortably covers a
// typical wafer's lifecycle.
void set_history_limit(std::size_t n) { history_limit_ = n; }
std::size_t history_limit() const { return history_limit_; }
CreateResult create(std::string substid, std::string carrierid = "",
uint8_t slot = 0) {
if (subs_.count(substid)) return CreateResult::Denied_AlreadyExists;
install_(substid, carrierid, slot, "", std::nullopt, std::nullopt,
std::nullopt);
if (persistent_) write_record_(substid);
return CreateResult::Created;
}
bool has(const std::string& id) const { return subs_.count(id) > 0; }
const Substrate* get(const std::string& id) const {
auto it = subs_.find(id);
return it == subs_.end() ? nullptr : &it->second;
}
Substrate* get(const std::string& id) {
auto it = subs_.find(id);
return it == subs_.end() ? nullptr : &it->second;
}
bool fire_location_event(const std::string& id, SubstrateEvent e,
std::string new_location = "") {
auto* s = get(id);
if (!s) return false;
if (!new_location.empty()) s->location = std::move(new_location);
bool ok = s->fsm->on_location_event(e);
if (persistent_) write_record_(id);
return ok;
}
bool fire_processing_event(const std::string& id, SubstrateProcessingEvent e) {
auto* s = get(id);
if (!(s && s->fsm->on_processing_event(e))) return false;
if (persistent_) write_record_(id);
return true;
}
bool remove(const std::string& id) {
auto it = subs_.find(id);
if (it == subs_.end()) return false;
if (persistent_ && !it->second.journal_path.empty()) {
std::error_code ec;
std::filesystem::remove(it->second.journal_path, ec);
}
subs_.erase(it);
return true;
}
std::size_t size() const { return subs_.size(); }
std::vector<std::string> ids() const {
std::vector<std::string> out;
out.reserve(subs_.size());
for (const auto& kv : subs_) out.push_back(kv.first);
return out;
}
const std::vector<SubstrateHistoryEntry>* history(const std::string& id) const {
const auto* s = get(id);
return s ? &s->history : nullptr;
}
// ---- Persistence ----------------------------------------------------
// v2 records also journal the per-substrate transition history.
// Each entry's `at` field is written as a milliseconds-since-UNIX
// epoch wall-clock timestamp; on replay the steady_clock time_point
// is reconstructed relative to the *current* steady_now, so the
// delta between two replayed entries matches their original spacing.
// Absolute wall-clock accuracy depends on system_clock not having
// been NTP-stepped between write and read.
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);
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() != ".sub") 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 rec = load_record_(path);
if (!rec) { fs::remove(path, ec); continue; }
install_(rec->substid, rec->carrierid, rec->slot, rec->location,
rec->loc_state, rec->proc_state, rec->id_state);
auto* s = get(rec->substid);
if (s) {
s->journal_path = path;
s->history = restore_history_(rec->history);
}
if (seq >= next_seq_) next_seq_ = seq + 1;
}
}
bool persistence_enabled() const { return persistent_; }
std::filesystem::path journal_dir() const { return journal_dir_; }
private:
// Persisted history entry — system_clock millis at write time, plus
// axis bytes and a small variant discriminator.
struct PersistedHistoryEntry {
int64_t system_ms_at_write;
SubstrateState location_to;
SubstrateProcessingState processing_to;
uint8_t event_kind; // 0 = SubstrateEvent, 1 = SubstrateProcessingEvent
uint8_t event_value; // raw enum value
std::string location_label;
};
struct Record {
std::string substid;
std::string carrierid;
uint8_t slot;
std::string location;
SubstrateState loc_state;
SubstrateProcessingState proc_state;
SubstrateIDStatus id_state;
std::vector<PersistedHistoryEntry> history;
};
// Substrate record (v2):
// v1 header (kept) + v2 trailer:
// [u8 magic = 0xC6][u8 version]
// [u8 loc_state][u8 proc_state][u8 id_state][u8 slot]
// [u16 substid_len][substid]
// [u16 carrierid_len][carrierid]
// [u16 location_len][location]
// --- v2 trailer ---
// [u32 history_count]
// [repeat: i64 system_ms, u8 loc_to, u8 proc_to, u8 kind, u8 event,
// u16 label_len, label]
// v1 records accepted; history comes back empty.
static constexpr uint8_t kMagic = 0xC6;
static constexpr uint8_t kVersion = 0x02;
void install_(const std::string& substid, const std::string& carrierid,
uint8_t slot, const std::string& location,
std::optional<SubstrateState> loc,
std::optional<SubstrateProcessingState> proc,
std::optional<SubstrateIDStatus> id) {
auto fsm = std::make_unique<SubstrateStateMachine>();
if (loc || proc || id) {
fsm->restore_state(loc.value_or(SubstrateState::AtSource),
proc.value_or(SubstrateProcessingState::NeedsProcessing),
id.value_or(SubstrateIDStatus::NotConfirmed));
}
const std::string keyed = substid;
fsm->set_location_handler(
[this, keyed](SubstrateState f, SubstrateState t, SubstrateEvent e) {
record_history(keyed, t, SubstrateProcessingState::NoState, e);
if (on_loc_) on_loc_(keyed, f, t, e);
});
fsm->set_processing_handler(
[this, keyed](SubstrateProcessingState f, SubstrateProcessingState t,
SubstrateProcessingEvent e) {
record_history(keyed, SubstrateState::NoState, t, e);
if (on_proc_) on_proc_(keyed, f, t, e);
});
subs_.emplace(substid,
Substrate{substid, carrierid, slot, location, {},
std::move(fsm), {}});
}
void write_record_(const std::string& substid) {
namespace fs = std::filesystem;
auto* s = get(substid);
if (!s) return;
if (s->journal_path.empty()) {
char namebuf[32];
std::snprintf(namebuf, sizeof(namebuf), "%010llu.sub",
static_cast<unsigned long long>(next_seq_++));
s->journal_path = journal_dir_ / namebuf;
}
fs::path tmp = s->journal_path;
tmp += ".tmp";
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return;
uint8_t header[6] = {
kMagic, kVersion,
static_cast<uint8_t>(s->fsm->location_state()),
static_cast<uint8_t>(s->fsm->processing_state()),
static_cast<uint8_t>(s->fsm->id_state()),
s->slot};
out.write(reinterpret_cast<const char*>(header), sizeof(header));
auto write_str = [&](const std::string& str) {
uint16_t n = static_cast<uint16_t>(str.size());
uint8_t lb[2] = {static_cast<uint8_t>(n >> 8), static_cast<uint8_t>(n)};
out.write(reinterpret_cast<const char*>(lb), 2);
out.write(str.data(), static_cast<std::streamsize>(n));
};
write_str(s->substid);
write_str(s->carrierid);
write_str(s->location);
// --- v2 history trailer ---
auto write_be32 = [&](uint32_t v) {
uint8_t b[4] = {static_cast<uint8_t>((v >> 24) & 0xFF),
static_cast<uint8_t>((v >> 16) & 0xFF),
static_cast<uint8_t>((v >> 8) & 0xFF),
static_cast<uint8_t>(v & 0xFF)};
out.write(reinterpret_cast<const char*>(b), 4);
};
auto write_be64 = [&](int64_t v) {
uint64_t u = static_cast<uint64_t>(v);
for (int i = 7; i >= 0; --i) {
uint8_t byte = static_cast<uint8_t>((u >> (i * 8)) & 0xFF);
out.put(static_cast<char>(byte));
}
};
using clk = std::chrono::steady_clock;
using sclk = std::chrono::system_clock;
const auto steady_now = clk::now();
const auto system_now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
sclk::now().time_since_epoch()).count();
write_be32(static_cast<uint32_t>(s->history.size()));
for (const auto& h : s->history) {
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
h.at - steady_now).count();
const int64_t system_ms_for_entry = system_now_ms + elapsed_ms;
write_be64(system_ms_for_entry);
out.put(static_cast<char>(h.location_to));
out.put(static_cast<char>(h.processing_to));
const uint8_t kind = std::holds_alternative<SubstrateEvent>(h.event) ? 0 : 1;
out.put(static_cast<char>(kind));
uint8_t ev_val = 0;
if (kind == 0) {
ev_val = static_cast<uint8_t>(std::get<SubstrateEvent>(h.event));
} else {
ev_val = static_cast<uint8_t>(std::get<SubstrateProcessingEvent>(h.event));
}
out.put(static_cast<char>(ev_val));
uint16_t lab_len = static_cast<uint16_t>(h.location_label.size());
uint8_t lb[2] = {static_cast<uint8_t>(lab_len >> 8),
static_cast<uint8_t>(lab_len)};
out.write(reinterpret_cast<const char*>(lb), 2);
out.write(h.location_label.data(),
static_cast<std::streamsize>(lab_len));
}
out.flush();
out.close();
std::error_code ec;
fs::rename(tmp, s->journal_path, ec);
if (ec) fs::remove(tmp, ec);
}
// Reconstruct steady_clock::time_point from persisted system_clock millis
// by anchoring to the current (now_steady, now_system) pair. Wall-clock
// jumps between write and read will skew absolute values; deltas stay
// accurate.
static std::vector<SubstrateHistoryEntry> restore_history_(
const std::vector<PersistedHistoryEntry>& src) {
std::vector<SubstrateHistoryEntry> out;
out.reserve(src.size());
using clk = std::chrono::steady_clock;
using sclk = std::chrono::system_clock;
const auto steady_now = clk::now();
const auto system_now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
sclk::now().time_since_epoch()).count();
for (const auto& p : src) {
SubstrateHistoryEntry e;
const int64_t delta_ms = p.system_ms_at_write - system_now_ms;
e.at = steady_now + std::chrono::milliseconds(delta_ms);
e.location_to = p.location_to;
e.processing_to = p.processing_to;
e.location_label = p.location_label;
if (p.event_kind == 0) {
e.event = static_cast<SubstrateEvent>(p.event_value);
} else {
e.event = static_cast<SubstrateProcessingEvent>(p.event_value);
}
out.push_back(std::move(e));
}
return out;
}
static std::optional<Record> load_record_(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return std::nullopt;
uint8_t header[6];
in.read(reinterpret_cast<char*>(header), sizeof(header));
if (!in || header[0] != kMagic) return std::nullopt;
if (header[1] != 0x01 && header[1] != 0x02) return std::nullopt;
const uint8_t version = header[1];
Record r;
r.loc_state = static_cast<SubstrateState>(header[2]);
r.proc_state = static_cast<SubstrateProcessingState>(header[3]);
r.id_state = static_cast<SubstrateIDStatus>(header[4]);
r.slot = header[5];
auto read_str = [&](std::string& out) {
uint8_t lb[2];
in.read(reinterpret_cast<char*>(lb), 2);
if (!in) return false;
uint16_t n = static_cast<uint16_t>((uint16_t(lb[0]) << 8) | lb[1]);
out.resize(n);
in.read(out.data(), n);
return static_cast<bool>(in) || n == 0;
};
if (!read_str(r.substid)) return std::nullopt;
if (!read_str(r.carrierid)) return std::nullopt;
if (!read_str(r.location)) return std::nullopt;
if (version >= 2) {
auto read_be32 = [&](uint32_t& v) {
uint8_t b[4];
in.read(reinterpret_cast<char*>(b), 4);
if (!in) return false;
v = (uint32_t(b[0]) << 24) | (uint32_t(b[1]) << 16) |
(uint32_t(b[2]) << 8) | uint32_t(b[3]);
return true;
};
auto read_be64 = [&](int64_t& v) {
uint8_t b[8];
in.read(reinterpret_cast<char*>(b), 8);
if (!in) return false;
uint64_t u = 0;
for (int i = 0; i < 8; ++i) u = (u << 8) | b[i];
v = static_cast<int64_t>(u);
return true;
};
uint32_t count = 0;
if (!read_be32(count)) return std::nullopt;
r.history.resize(count);
for (auto& h : r.history) {
if (!read_be64(h.system_ms_at_write)) return std::nullopt;
int b0 = in.get(); if (b0 == EOF) return std::nullopt;
int b1 = in.get(); if (b1 == EOF) return std::nullopt;
int b2 = in.get(); if (b2 == EOF) return std::nullopt;
int b3 = in.get(); if (b3 == EOF) return std::nullopt;
h.location_to = static_cast<SubstrateState>(b0);
h.processing_to = static_cast<SubstrateProcessingState>(b1);
h.event_kind = static_cast<uint8_t>(b2);
h.event_value = static_cast<uint8_t>(b3);
if (!read_str(h.location_label)) return std::nullopt;
}
}
return r;
}
template <typename Event>
void record_history(const std::string& id, SubstrateState loc_to,
SubstrateProcessingState proc_to, Event ev) {
auto* s = get(id);
if (!s) return;
SubstrateHistoryEntry entry;
entry.at = std::chrono::steady_clock::now();
entry.location_to = loc_to;
entry.processing_to = proc_to;
entry.event = ev;
entry.location_label = s->location;
s->history.push_back(std::move(entry));
if (history_limit_ != 0 && s->history.size() > history_limit_) {
// Drop the oldest entry; vector shift is fine for the small caps
// we expect (default 256, typically a few dozen per substrate).
s->history.erase(s->history.begin());
}
}
std::map<std::string, Substrate> subs_;
LocationChangeHandler on_loc_;
ProcessingChangeHandler on_proc_;
std::size_t history_limit_ = 256;
bool persistent_ = false;
std::filesystem::path journal_dir_;
uint64_t next_seq_ = 0;
};
} // namespace secsgem::gem