|
NGen
|
Engine-agnostic abstractions for record-based state checkpointing. This document captures the design rationale, the rules every concrete backend must satisfy, and the recommended consumer call patterns.
The audience for this document is developers writing or reviewing a RecordBackend implementation or library-internal code. Model authors and end-user operators have separate docs (see below).
v0.1.0 — initial release. Version constants live in version.hpp; consumers depending on a specific release's features can static_assert on them.
The 0.X series makes no API-compatibility guarantee. The abstract RecordBackend surface, BackendError::Kind, and the IdPredicate kit are all subject to revision as concrete backends inform the design. Pin to an exact 0.X.Y release and expect to update per release. At 1.0.0, the API will be stable, and MAJOR / MINOR / PATCH will carry their conventional Semantic Versioning meanings from that point on.
The public API is the four headers in this directory:
record.hpp — the Record value type + parse_timestamp helper.record_backend.hpp — RecordBackend, nested Writer / Reader, BackendError, Durability, plus the scoped with_writer / with_reader wrappers. Method-level docstrings live in the header; this README covers the rationale and the cross-cutting design.id_predicates.hpp — IdPredicate typeversion.hpp — library version constants and versioning-policy doc.../../../doc/NGEN_SERIALIZATION.md — how ngen drives this library through the BMI serialization protocol (configuration, engine integration points, scaling considerations). Audience: ngen operators and contributors.../../../doc/BMI_SERIALIZATION_PROTOCOL.md — the BMI model-author contract that produces the records this library stores. Audience: model authors writing BMI extensions for any engine.Does: define the engine-agnostic abstractions through which checkpoint records are produced, persisted, and retrieved. The library provides:
Record value type — the unit of state interchange.RecordBackend interface — the storage abstraction.BackendError — categorized error type for polymorphic error handling across backends.IdPredicate and a kit of helpers — the application's seam for id-string interpretation.Durability enum — the toggle that distinguishes fsync/commit-blocking from best-effort flush.with_writer / with_reader.Out of scope, always:
Not in v0.1:
CheckpointSchedule SDK type (with EveryN, Calendar, EndOfRun, Manual, and composite implementations) is under consideration.Snapshot / SnapshotCoordinator layer is under consideration.All fallible operations return expected<T, BackendError>. The library never throws on its own; underlying stream / OS / model code may throw, and concrete backends catch at the seam and convert to the error arm.
A record's identity for restore lookup is its Record::id string, exactly as the producer composed it. Backends address records by that string verbatim — they do not strip, normalize, or augment it from out-of-band sources (rank, hostname, process id).
This transparency is what enables N-to-M rank restart: a producer that wrote "cat-1:cfe" restores it at any rank count without the backend silently entangling it with the writing rank's identity.
Implication for backends: never derive the lookup key from anything other than the bytes the producer placed in Record::id. Backends MAY consult rank/host for file-level aggregation (per-rank file naming via path templates) but that information never enters the record's storage key.
The expected<> error arm carries a BackendError:
Each concrete backend translates its native error sources into this small vocabulary. A polymorphic caller branches on kind without parsing the message:
Kind | When to use it | Typical caller response |
|---|---|---|
NotFound | The query criteria don't match any record (no entry for id, or no entry for the given step/timestamp, or id is outside the open-time scope predicate). | Warn-and-continue; treat restore-for-this-id as a fresh start. |
Corrupted | A record exists but its bytes don't parse: bad magic, unsupported wire_version, oversized payload, file truncated mid-record. | Abort. Operator action required. |
IOError | Everything else: file open failed, permission denied, network blip, stream went bad mid-write, single-in-flight rule violation, backend not yet open. | Backend-specific — retry, surface to operator, fall back to a different backend. The message field carries the detail. |
The extension rule: add a new Kind value only when a real caller wants to branch on it differently. Speculative additions are costly because every concrete backend must update its translation. Today's intentionally minimal set sweeps several distinguishable situations into IOError (permission denied, transient network, backend-not-open, etc.); split them only when a real consumer appears that would act differently on each.
The full extension-rule docstring lives at BackendError's declaration in record_backend.hpp.
A sub-handle is a Reader or Writer obtained from backend->reader(...) / backend->writer(...). Two guarantees apply to every sub-handle, regardless of which concrete backend produced it:
std::unique_ptr<...> inside the value arm. Lifetime is bound to that unique_ptr; destruction tears the sub-handle down.Concrete backends satisfy guarantee (2) one of two ways:
The sub-handle owns everything it needs at construction. The backend object itself is not referenced after construction and may be destroyed independently.
The Reader holds no *this or shared_ptr<MyBackend> — its captured resources are all it needs. The backend's ~MyBackend() may run while Readers are still alive; the Readers keep working because they captured their state by value or via independent shared_ptrs.
Fits backends where per-handle resources are cheap to acquire and don't require cross-handle coordination. Typical for Reader-side in a file-based backend (each Reader opens its own ifstream).
The sub-handle holds a shared_ptr to its parent backend, typically obtained via std::enable_shared_from_this. The backend stays alive while any sub-handle does.
Required for backends with expensive global state (open HDF5 files, S3 connection pools, TileDB contexts, remote sessions) and required for all Writers regardless of backend, because the single-in-flight rule and deferred-error accounting both need backend coordination.
The choice is per-backend, per-sub-handle. A file-based backend's Reader is naturally snapshot (cheap ifstream); its Writer is naturally shared-state (single-in-flight tracking). An HDF5 backend would use shared-state for both Reader and Writer (the open file handle is shared).
The abstract guarantee is: the sub-handle stays valid for its lifetime. How the backend ensures that is an implementation detail.
A backend MUST refuse to issue a second Writer while the previous one is still alive on the same backend instance. The intent: prevent intra-process same-epoch collisions by construction. The mechanism: a flag (typically bool writer_in_flight_), set on writer(), cleared in the Writer's destructor or on commit().
The error returned when the rule is violated is a BackendError with Kind::IOError and a message identifying the rule. (The rule violation is genuinely an I/O-shaped failure from the caller's perspective — the storage is unavailable for a new event.)
The single-in-flight rule is the call site's responsibility unless the call site uses with_writer, which makes the rule a syntactic property of the closure scope.
reader(IdPredicate scope) constructs a Reader that will only return records whose id satisfies scope. The predicate is both a hint and a contract:
NotFound from every find_* call against this Reader, even if the underlying storage contains them. Callers that want a different scope construct a fresh Reader.The empty predicate (default-constructed std::function) means "no
scope" — the Reader is willing to find any record. This is the common case for tooling and exploratory restores; production restore typically supplies a hydrofabric-derived scope to bound index memory.
Writer::commit() makes accumulated writes durable, with the durability level the caller supplied to writer(epoch, durability):
Durability::strict — commit() blocks until bytes are durably stored (fsync, collective barrier, ack from a remote object store, etc.) and errors surface synchronously on the expected<> arm.Durability::relaxed — best-effort flush. Errors may be deferred to the parent backend's finalize() call.If a Writer is destroyed without commit() having been called, the destructor performs best-effort cleanup. Errors during destruction cannot be surfaced (destructors must not throw); they are logged and propagated to the next RecordBackend::finalize() call. Callers who care about commit errors call commit() explicitly and inspect its return — or use with_writer, which calls commit() automatically on the success path.
Reader::close()Reader exposes a close() method analogous to Writer::commit() for backends with fallible read-side teardown (release of leased remote resources, graceful disconnect from a streaming source, final telemetry flush). The default implementation is a no-op success; backends that don't need fallible teardown inherit it. The destructor is the best-effort fallback if close() isn't called explicitly.
with_reader calls close() automatically on the success path — so callers using the recommended scoped pattern get teardown-error surfacing for free, regardless of whether the backend overrides close().
Backends MUST support multiple Reader sub-handles alive concurrently on the same RecordBackend instance, including from different threads. Writers do not compose concurrently — §3.4's single-in-flight rule is the mechanism.
Use with_writer and with_reader. They bind the sub-handle to a closure scope, guarantee commit() / close() runs on success, and handle destructor cleanup on exception:
Both wrappers return expected<void, BackendError> so the caller has a single error-handling branch covering factory failure, in-closure errors, and commit/close failures.
The lower-level writer(...) / reader(...) factories are still public for cases where the sub-handle genuinely needs to outlive a single function scope: async writes during a long event, multi-phase restore drivers, gradual snapshotting across iterations.
When using the low-level form, the call site owns the single-in-flight rule. A second writer(...) call on the same backend while w is still alive returns an error. Calling commit() (or letting w go out of scope) clears the flag.
The whole point of BackendError::Kind is that callers branch without parsing the message:
The IdPredicate type is std::function<bool(const std::string&)>. The library invokes it during the index walk and treats the returned bool as authoritative. The library never parses, splits, or interprets the id itself — id-string semantics are exclusively the application's business.
Stock helpers cover the common composition schemes:
| Helper | Matches |
|---|---|
exact_id(s) | id == s |
primary_prefix(s) | First :/.-delimited segment equals s |
contains_segment(s) | s appears as any :/.-delimited segment |
at_position(n, s) | nth segment equals s |
starts_with(s) | Substring prefix |
all_of({...}) | All sub-predicates true (short-circuiting) |
any_of({...}) | Any sub-predicate true (short-circuiting) |
not_fn(p) | !p(id) |
Custom application closures are first-class. Anything that meets the closure requirements — pure-ish, cheap-ish, stable for the Reader's lifetime — is fair game:
RecordBackendChecklist for adding a concrete backend (e.g., HDF5Backend, S3Backend):
BackendError::Kind your native errors map to. Write a small translation function inside the backend's translation unit. Don't ship a backend that uses IOError for everything if you can meaningfully distinguish NotFound / Corrupted — categorization is what backends owe their callers.writer() and reader() factories return expected<unique_ptr<...>, BackendError>; both can fail (file open / network setup / single-in-flight violation / etc.).commit() and the Reader's close(). commit() is fallible and MUST surface errors on the expected<> arm. close() may inherit the default no-op success if the Reader has no fallible teardown.writer(). Use a bool member protected by whatever concurrency mechanism your backend uses (mutex for thread-safe backends; nothing for single-threaded). Return Kind::IOError with a clear message when the rule is violated.test/utils/serialization/. The existing mock-based tests exercise the abstract surface. Adding your backend to that suite either passes (you satisfy the rules) or fails (you don't). New backend-specific tests cover the backend's own surface area (e.g. HDF5 file layout assertions) on top of the shared interface tests.