NGen
Loading...
Searching...
No Matches
file_backend.hpp
1/*
2Author: Nels Frazier
3Copyright (C) 2025 Lynker
4------------------------------------------------------------------------
5Licensed under the Apache License, Version 2.0 (the "License");
6you may not use this file except in compliance with the License.
7You may obtain a copy of the License at
8
9 http://www.apache.org/licenses/LICENSE-2.0
10
11Unless required by applicable law or agreed to in writing, software
12distributed under the License is distributed on an "AS IS" BASIS,
13WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14See the License for the specific language governing permissions and
15limitations under the License.
16------------------------------------------------------------------------
17File-based `RecordBackend` implementation.
18
19Persists records using the v0.2 wire format defined in
20`src/utilities/bmi/wire_format.hpp`: each record is a fixed-size
21prefix + id bytes + payload bytes, written sequentially to a
22single append-mode file. A FileBackend maintains an in-memory
23index keyed by `Record::id`, built on the first `reader()` call
24by walking the file once, and services `find_*` queries via
25index lookup + per-record seek.
26
27Per-path sharing
28----------------
29`FileBackend` is **shared per path** via a process-static
30registry. `FileBackend::create(path, scope)` is a `get_or_create`
31factory: the first caller for a given path allocates the backend
32and registers the resulting `shared_ptr` under a `weak_ptr` slot;
33subsequent callers for the same path receive the same
34`shared_ptr`. The backend (and its index, and its write fd) die
35when the last caller drops their handle; the `weak_ptr` slot
36becomes recoverable and is opportunistically pruned on the next
37lookup.
38
39This pattern is what makes N independent clients on the same
40checkpoint file viable at scale: N callers that each ask the
41registry for a backend at the same path share **one** index,
42**one** write fd, and **one** mutex-serialized write critical
43section, instead of paying N times.
44
45Two-layer scope filtering
46-------------------------
47`IdPredicate` filtering happens at two distinct layers:
48
49 - **Index scope** — passed to `create(path, scope)`. Records
50 whose id does not satisfy the predicate are never added to
51 the in-memory index.
52
53 Each `create()` call registers its scope. The index is built
54 on the first `reader()` call from the union of every non-empty
55 registered scope; empty scopes drop out of the union so a
56 save-only caller can pass `{}` to mean "no opinion." With no
57 non-empty scope registered, the fallback is "index everything."
58 `create()` calls arriving after the first `reader()` still
59 record but do not retroactively broaden the built index.
60
61 - **Read scope** — passed to `reader(scope)`. Per-Reader
62 predicate applied at `find_*` time. A record is returned iff
63 *both* the index-scope predicate (already enforced by not
64 being in the index) AND the read-scope predicate accept its
65 id. Lets a single Reader narrow further within a backend
66 whose index scope was deliberately broadened.
67
68Durability semantics
69--------------------
70`RecordBackend`'s abstract contract distinguishes `Durability::strict`
71(synchronous + durable) from `Durability::relaxed` (best-effort
72durability, errors *may* be deferred to `finalize()`). FileBackend
73fills in those two parameters as follows:
74
75 - **`strict`**: `commit()` calls `::fsync()` on the owned fd
76 before returning, ensuring kernel page-cache pages reach the
77 storage device. All write- and commit-side I/O failures
78 surface synchronously through the call's `expected<>` return
79 arm. Matches the abstract contract exactly.
80
81 - **`relaxed`**: `commit()` skips `::fsync()` — bytes are
82 handed to the kernel and may sit in the page cache when
83 `commit()` returns. The OS will eventually flush, but a
84 crash between commit and OS flush can lose those bytes.
85 This is the documented "best-effort durability" the abstract
86 contract permits.
87
88 However, FileBackend's relaxed mode **still surfaces write-
89 and commit-side errors immediately** through the call's
90 `expected<>` return arm. It does NOT use the abstract
91 contract's optional deferred-error mechanism. The underlying
92 I/O is synchronous (`::write()` returns before `commit()`
93 runs), so there is no natural moment of deferred discovery
94 for FileBackend — any error is known at the originating call.
95
96 A consequence: callers of FileBackend do not need to call
97 `finalize()` to learn about per-write or per-commit failures.
98 `write().has_value()` and `commit().has_value()` are full
99 immediate signals regardless of durability mode. `finalize()`
100 returns success on a clean FileBackend by construction.
101
102 The `deferred_errors_` member and `finalize()` aggregation
103 path still exist because they're required by the abstract
104 `RecordBackend` interface; for this backend the vector
105 remains empty in practice at this time.
106
107Sub-handle lifetime patterns
108----------------------------
109See `include/utilities/serialization/README.md` for the general
110abstract-surface contract. FileBackend uses:
111
112 - **Writer** (shared-state, thin handle): holds
113 `shared_ptr<FileBackend>` so the parent backend's owned fd and
114 write mutex remain reachable. Writers do not own the fd; they
115 serialize their write+commit through the backend's
116 `io_mutex_`. Multiple Writer handles may coexist on one
117 backend — they take turns through the mutex. The single
118 `::open()`/`::close()` on the fd happens at backend lazy-open
119 / destruction, not per-Writer.
120
121 - **Reader** (snapshot): owns its own `ifstream` (independent
122 read cursor) and a `shared_ptr<const Index>` (the backend's
123 immutable indexed snapshot). Carries its own
124 `IdPredicate` for read-time filtering. Multiple Readers on one
125 backend share the Index via `shared_ptr` aliasing.
126
127Concurrency
128-----------
129Each Reader is a snapshot with an independent ifstream and shares
130the Index only via `shared_ptr` to an immutable value.
131N Readers may be alive and concurrently using the same backend
132without external synchronization.
133Writers are serialized by `io_mutex_` (single-in-flight)
134and may run concurrently with any number of Readers.
135
136FileBackend MUST be constructed via the static `create()` factory.
137The private constructor + factory enforces both the registry
138discipline (no rogue direct instantiation) and the
139`shared_ptr` management the Writer pattern depends on.
140
141Index freshness and the dirty flag
142----------------------------------
143The Index is a point-in-time snapshot. A Writer appending records
144after the Index was built does NOT update the Index — the
145snapshot stays frozen. A `dirty_` flag flipped under the
146`io_mutex_` on every write marks the backend as having had
147post-build appends; the next `reader()` call observes the flag
148and rebuilds the Index before returning the Reader. For the
149canonical "restore at init, save during run" workflow the flag
150never fires after restore-init completes (no one opens new
151Readers after that point). For mid-run restore-after-save it does
152the obvious right thing.
153
154Duplicate-key resolution
155------------------------
156When the index walk encounters two records with the same
157`(id, time_step, checkpoint_epoch)` tuple, the latest-offset
158record wins for lookup and a WARNING is emitted on stderr. This
159makes accidental cross-process collisions visible without
160forcing a policy on the protocol layer.
161*/
162#pragma once
163
164#include "utilities/serialization/record_backend.hpp"
165
166#include <chrono>
167#include <memory>
168#include <mutex>
169#include <string>
170#include <unordered_map>
171
172namespace ngen {
173namespace serialization {
174
176 : public RecordBackend
177 , public std::enable_shared_from_this<FileBackend> {
178 public:
188 [[nodiscard]]
189 static auto create(std::string path, IdPredicate index_scope = {})
190 -> std::shared_ptr<FileBackend>;
191
192 ~FileBackend() override;
193
194 // -- RecordBackend abstract surface --------------------------------
195
196 [[nodiscard]]
197 auto writer(std::chrono::system_clock::time_point epoch, Durability durability)
198 -> expected<std::unique_ptr<RecordBackend::Writer>, BackendError> override;
199
200 [[nodiscard]]
201 auto reader(IdPredicate read_scope = {})
202 -> expected<std::unique_ptr<RecordBackend::Reader>, BackendError> override;
203
204 [[nodiscard]]
205 auto finalize() -> expected<void, std::vector<BackendError>> override;
206
207 // -- Accessors (diagnostics / tests) -------------------------------
208
210 const std::string& path() const noexcept {
211 return path_;
212 }
213
214 private:
215 FileBackend(std::string path, IdPredicate initial_scope);
216
217 // No-op if @p scope is empty; else union it into
218 // `index_scope_`. Caller must hold `io_mutex_`.
219 void register_scope_(IdPredicate scope);
220
221 // Sub-handle and index types; full definitions live in
222 // file_backend.cpp. As nested classes, they have member-level
223 // access to FileBackend's private state (the C++ rule under
224 // [class.access.nest]) — no `friend` declaration needed for
225 // Writer to reach `io_mutex_`, `fd_`, `dirty_`, etc.
226 class Writer;
227 class Reader;
228 class Index;
229
230 // -- Internal helpers ---------------------------------------------
231
232 // Build (or rebuild) the index from disk applying
233 // `index_scope_`. Caller must hold `io_mutex_` (or be
234 // inside the construction path where no other reference to
235 // `this` exists yet). Sets `dirty_` to false on success.
236 auto build_index_locked_() -> expected<void, BackendError>;
237
238 // Open the write fd if not already open. Must be called with
239 // `io_mutex_` held. POSIX-only path uses `::open`; non-POSIX
240 // refuses with an IOError directing callers to use relaxed
241 // durability via stream fallback (which is handled inside the
242 // Writer's commit/write code under #ifndef NGEN_HAVE_POSIX_FSYNC).
243 auto ensure_write_fd_locked_() -> expected<void, BackendError>;
244
245 // -- State --------------------------------------------------------
246
247 const std::string path_;
248
249 // Union of non-empty registered scopes; consumed by
250 // `build_index_locked_()`. Guarded by `io_mutex_`.
252
253 // Set true once `build_index_locked_()` has run; distinct from
254 // `dirty_` (which fires on post-build writes).
255 bool index_built_ = false;
256
257 // Shared with every Reader via shared_ptr<const>.
258 std::shared_ptr<const Index> index_;
259
260 // Single fd backing all writes; opened lazily on first write
261 // request, closed in the destructor. -1 when not yet opened or
262 // not supported on this build.
263 int fd_ = -1;
264
265 // Serializes the write critical section (prefix + id + payload
266 // + optional fsync) so concurrent Writers cannot interleave a
267 // record's bytes. Also guards `fd_`, `dirty_`, and the
268 // deferred-error vector.
269 mutable std::mutex io_mutex_;
270
271 // Set true under `io_mutex_` after any successful write that
272 // mutates on-disk state. Cleared by `build_index_locked_()`. A
273 // `reader()` call that observes `dirty_ == true` rebuilds the
274 // index before returning.
275 bool dirty_ = false;
276
277 // Deferred errors stashed by Writers under
278 // `Durability::relaxed`; drained and returned by `finalize()`.
279 std::vector<BackendError> deferred_errors_;
280
281 // -- Registry -----------------------------------------------------
282
283 // Process-static path-keyed registry. weak_ptr<> entries allow
284 // automatic eviction: when the last shared_ptr<FileBackend> for
285 // a path drops, the backend is destroyed and the slot becomes
286 // recoverable. The registry is pruned opportunistically inside
287 // the critical section on every `create()` call.
288 static std::mutex registry_mutex_;
289 static std::unordered_map<std::string, std::weak_ptr<FileBackend>> registry_;
290};
291
292} // namespace serialization
293} // namespace ngen
Definition file_backend.hpp:177
auto finalize() -> expected< void, std::vector< BackendError > > override
Lifecycle hook for cross-event cleanup: close process-level open handles, drain async queues,...
Definition file_backend.cpp:645
std::mutex io_mutex_
Definition file_backend.hpp:269
void register_scope_(IdPredicate scope)
Definition file_backend.cpp:546
static std::unordered_map< std::string, std::weak_ptr< FileBackend > > registry_
Definition file_backend.hpp:289
std::shared_ptr< const Index > index_
Definition file_backend.hpp:258
bool index_built_
Definition file_backend.hpp:255
auto writer(std::chrono::system_clock::time_point epoch, Durability durability) -> expected< std::unique_ptr< RecordBackend::Writer >, BackendError > override
Construct a Writer for one checkpoint event.
Definition file_backend.cpp:602
~FileBackend() override
Definition file_backend.cpp:560
int fd_
Definition file_backend.hpp:263
auto ensure_write_fd_locked_() -> expected< void, BackendError >
Definition file_backend.cpp:580
static std::mutex registry_mutex_
Definition file_backend.hpp:288
auto build_index_locked_() -> expected< void, BackendError >
Definition file_backend.cpp:569
auto reader(IdPredicate read_scope={}) -> expected< std::unique_ptr< RecordBackend::Reader >, BackendError > override
Construct a Reader scoped to records whose id satisfies in_scope.
Definition file_backend.cpp:618
FileBackend(std::string path, IdPredicate initial_scope)
Definition file_backend.cpp:541
const std::string path_
Definition file_backend.hpp:247
const std::string & path() const noexcept
Path the backend was constructed against.
Definition file_backend.hpp:210
bool dirty_
Definition file_backend.hpp:275
static auto create(std::string path, IdPredicate index_scope={}) -> std::shared_ptr< FileBackend >
Get-or-create a shared_ptr-managed FileBackend at path.
Definition file_backend.cpp:506
IdPredicate index_scope_
Definition file_backend.hpp:251
std::vector< BackendError > deferred_errors_
Definition file_backend.hpp:279
Abstract storage interface for Records.
Definition record_backend.hpp:83
std::function< bool(const std::string &id)> IdPredicate
Eligibility predicate over a SerializationRecord's id string.
Definition id_predicates.hpp:56
Durability
Save-time durability requirement.
Definition record_backend.hpp:59
Definition DomainLayer.hpp:13
Categorized error carried on every abstract-backend method's expected<> error arm.
Definition record_backend.hpp:70