1548b49afd
Same pattern as carriers: per-substrate binary record (.sub) with atomic .tmp+rename, replay on enable, delete on remove. Records current state across all three E90 axes (location / processing / ID-status), plus substid / carrierid / slot / free-form location label. History is deliberately NOT journaled — it's an in-memory ring buffer and rebuilding from replayed state would mislead. Five new tests cover full-axis replay, every terminal processing state, remove-deletes-journal, corrupt-record drop, and the history-is-transient invariant. Closes #2 in the test-gap backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
309 lines
11 KiB
C++
309 lines
11 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 ----------------------------------------------------
|
||
// Records *current state* per substrate. History is not journaled —
|
||
// it's a transient in-memory ring buffer and rebuilding it from
|
||
// replayed state would be misleading.
|
||
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;
|
||
if (seq >= next_seq_) next_seq_ = seq + 1;
|
||
}
|
||
}
|
||
|
||
bool persistence_enabled() const { return persistent_; }
|
||
std::filesystem::path journal_dir() const { return journal_dir_; }
|
||
|
||
private:
|
||
struct Record {
|
||
std::string substid;
|
||
std::string carrierid;
|
||
uint8_t slot;
|
||
std::string location;
|
||
SubstrateState loc_state;
|
||
SubstrateProcessingState proc_state;
|
||
SubstrateIDStatus id_state;
|
||
};
|
||
|
||
// Substrate record:
|
||
// [u8 magic = 0xC6][u8 version = 1]
|
||
// [u8 loc_state][u8 proc_state][u8 id_state][u8 slot]
|
||
// [u16 substid_len][substid_len × u8]
|
||
// [u16 carrierid_len][carrierid_len × u8]
|
||
// [u16 location_len][location_len × u8]
|
||
static constexpr uint8_t kMagic = 0xC6;
|
||
static constexpr uint8_t kVersion = 0x01;
|
||
|
||
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);
|
||
out.flush();
|
||
out.close();
|
||
std::error_code ec;
|
||
fs::rename(tmp, s->journal_path, ec);
|
||
if (ec) fs::remove(tmp, ec);
|
||
}
|
||
|
||
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 || header[1] != kVersion) return std::nullopt;
|
||
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;
|
||
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
|