morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
file_action_log.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <cstdio>
5#include <filesystem>
6#include <fstream>
7#include <memory>
8#include <stdexcept>
9#include <string>
10#include <string_view>
11#include <system_error>
12#include <unordered_set>
13#include <utility>
14#include <vector>
15
16#include "../attributes.hpp"
17#include "../core/completion.hpp"
19#include "../core/executor.hpp"
20#include "../core/file_io_ops.hpp"
21#include "../core/logger.hpp"
22#include "../core/profiler.hpp"
23#include "action_log.hpp"
24#include "action_log_json.hpp"
25
26#ifdef _WIN32
27#include <io.h>
28#else
29#include <unistd.h>
30#endif
31
32namespace morph::journal {
33
34/// @brief Append-only, newline-delimited-JSON `IActionLog` backed by a local file.
35///
36/// Each entry is written as one `journal::toJson`-encoded line. `flush()` flushes
37/// the C stdio buffer and then issues a real fsync (POSIX `fsync` / Windows
38/// `_commit`), so a crash immediately after `flush()` returns cannot lose data —
39/// this is the "local file" sink from the original request, and the natural
40/// target for `SessionLog::checkpoint()` at a "Save" action. Dedups `append()` on
41/// a non-empty `LogEntry::idempotencyKey`, rebuilding the seen-key set from disk
42/// at open time — see `IActionLog`'s class docs and the constructor's docs.
43///
44/// @par Process-local `seq`
45/// Like `InMemoryActionLog`, `seq` is assigned fresh per process instance — it
46/// does not resume from the highest `seq` already on disk if the file is
47/// reopened. Entries remain correctly ordered on disk regardless (append-only),
48/// but `seq` alone is not a cross-restart unique key; use `entries()`' natural
49/// file order for that.
50///
51/// @par One owner
52/// The file handle and the dedup state belong to the executor given at
53/// construction, which must run one task at a time. `append()` runs there —
54/// at once when called on it, posted from anywhere else (a model's strand) —
55/// so appends from many threads reach the file one at a time, in the order
56/// the owner runs them. `flush()`, `entries()` and `rotate()` run only there;
57/// a caller elsewhere uses `flush(replyExec)` and `entries(replyExec, key)`.
58/// Not safe for multiple processes to append to the same path concurrently.
59/// See `docs/spec/journal/journal.md`, "One owner".
60///
61/// @par Rotation
62/// `rotate()` seals the active file (rename to a host-chosen path) and
63/// reopens a fresh, empty one at the original path — the seam a host uses to
64/// implement its own retention policy. See `rotate()`'s own docs.
65class FileActionLog : public IActionLog {
66public:
69
70 /// @brief Opens (creating if necessary) @p path for appending, owned by @p owner.
71 ///
72 /// Also rebuilds the `idempotencyKey` dedup set (see `append()`) from
73 /// whatever is already on disk at @p path — an O(n) scan of the existing
74 /// file's contents, paid once here, not on every `append()`. Runs on the
75 /// constructing thread, before anything else can reach the log.
76 /// @param owner The executor every access runs on; must run one task at a
77 /// time. Borrowed: it must outlive this log and run what it posts.
78 /// @param path File to append entries to.
79 /// @param ioOps Injectable file-I/O primitives; defaults to the real
80 /// syscalls. Test-only seam — see `morph::core::FileIoOps`'s own
81 /// docs — for forcing the failure branches that need a real
82 /// OS-level I/O error to reach.
83 /// @throws std::runtime_error if the file cannot be opened.
84 /// @throws SerializationError if an existing file at @p path has a malformed
85 /// *interior* line (a malformed trailing line is tolerated — see
86 /// `entries()`).
87 FileActionLog(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND, std::filesystem::path path,
88 ::morph::core::FileIoOps ioOps = {})
89 : _owned{owner, std::make_shared<State>(std::move(path), std::move(ioOps))} {}
90
91 ~FileActionLog() override = default;
92
93 FileActionLog(const FileActionLog&) = delete;
94 FileActionLog& operator=(const FileActionLog&) = delete;
95 FileActionLog(FileActionLog&&) = delete;
96 FileActionLog& operator=(FileActionLog&&) = delete;
97
98 /// @brief Appends @p entry as one JSON line, on the owner. Buffered until `flush()`.
99 ///
100 /// The entry's `idempotencyKey` is only remembered as *seen* once `flush()`
101 /// confirms it reached the disk (see `flush()`), so a write that fails is
102 /// still retryable. Recording the key up front — before the bytes were
103 /// durable — silently deduplicated the retry of a row that was never
104 /// written, turning a transient I/O error into permanent data loss.
105 ///
106 /// Called off the owner, the write is posted there and this returns at
107 /// once. A posted write that fails is logged, and the next `flush()`
108 /// throws for it: the entry did not reach the backend.
109 ///
110 /// @param entry Entry to append; `seq` is overwritten regardless of the input value.
111 /// @throws std::runtime_error, on the owner, if the write fails, or if the
112 /// log has no open file (only reachable after a `rotate()` that
113 /// failed to reopen).
114 void append(LogEntry entry) override {
115 if (_owned.here()) {
116 _owned.read("FileActionLog::append").append(std::move(entry));
117 return;
118 }
119 _owned.apply("FileActionLog::append", [entry = std::move(entry)](State& state) mutable {
120 try {
121 state.append(std::move(entry));
122 } catch (const std::exception& exc) {
123 state.notePostedFailure(exc.what());
124 throw;
125 }
126 });
127 }
128
129 /// @brief Flushes stdio's buffer, then fsyncs the file descriptor. On the owner.
130 ///
131 /// Throws rather than returning a status because the interface is
132 /// `void`-returning and the failure is not one a caller may ignore:
133 /// `OutboxRelay::relay()` calls `markRelayed()` immediately after this, and
134 /// on a silent failure would record rows as relayed in the model's own store
135 /// while nothing reached the durable sink — the rows are then gone from the
136 /// outbox and absent from the log. Throwing aborts `relay()` before
137 /// `markRelayed`, leaving the batch to be retried.
138 ///
139 /// On failure the keys buffered since the last successful flush are
140 /// forgotten, so a retry writes them again instead of being deduplicated
141 /// away. A duplicated audit row is recoverable; a dropped one is not.
142 ///
143 /// A write posted from another thread that failed since the last flush is
144 /// reported here too, after the rest is made durable: that entry is not on
145 /// disk.
146 ///
147 /// @throws std::runtime_error if the buffer flush or the fsync fails, if a
148 /// posted append failed since the last flush, or if the log has no
149 /// open file.
150 void flush() override { _owned.read("FileActionLog::flush").flush(); }
151
152 /// @brief Re-reads the file from disk and decodes every line. On the owner.
153 ///
154 /// Reads whatever is currently on disk, including anything written but not
155 /// yet `flush()`ed if the platform's stdio buffering has already handed it
156 /// to the OS — callers that need a guaranteed-durable view should `flush()`
157 /// first.
158 /// @param entityKey If non-empty, restricts the result to that entity's entries.
159 /// @return Matching entries, in on-disk (append) order.
160 [[nodiscard]] std::vector<LogEntry> entries(std::string_view entityKey = {}) const override {
161 return _owned.read("FileActionLog::entries").entries(entityKey);
162 }
163
164 /// @brief The executor given at construction.
165 /// @return The owner.
166 [[nodiscard]] ::morph::exec::IExecutor* owner() const noexcept override { return &_owned.owner(); }
167
168 /// @brief Seals the active file and reopens a fresh, empty one at the same
169 /// path. On the owner.
170 ///
171 /// Flushes (`fflush` + `fsync`/`_commit`) and closes the current active
172 /// file, renames it to @p sealedPath, then reopens a fresh, empty active
173 /// file at the original path. It runs on the owner, as every `append()`
174 /// does, so no append is ever split across the sealed and the new active
175 /// file. `entries()` keeps reading only the (post-rotation, empty) active
176 /// file; composing full history across a rotation is a host-side recipe
177 /// (concatenate each sealed segment's `entries()` oldest-to-newest, then the
178 /// active file's), not a new API.
179 ///
180 /// @par Crash safety
181 /// The rename is a single atomic filesystem operation. A crash before it
182 /// completes leaves the pre-rotation active file untouched, as if
183 /// `rotate()` was never called; a crash after leaves the sealed file plus
184 /// a freshly recreated, empty active file. Either way no line is ever torn
185 /// across the two files, so the existing torn-line rule keeps applying
186 /// independently per file.
187 ///
188 /// @param sealedPath Destination path for the sealed segment. Platform
189 /// `rename` semantics decide what happens if a filesystem entry
190 /// already exists there (POSIX silently replaces it) — pass a path
191 /// that does not collide with an existing segment.
192 /// @throws std::runtime_error if the pre-rotation flush/fsync fails (raised
193 /// before anything is closed or renamed, so no segment is ever
194 /// sealed around entries that never reached the disk); if renaming
195 /// to @p sealedPath fails — in that case the *original* active file
196 /// is reopened in place (still holding every entry recorded before
197 /// the call, so no data is lost; the rotation simply did not
198 /// happen) — or if reopening the active path afterward fails
199 /// outright (only possible if the rename itself succeeded and the
200 /// original path's directory then became unwritable). After that
201 /// last case the log has no open file: `append()`, `flush()`, and a
202 /// further `rotate()` all throw with that diagnosis rather than
203 /// dereferencing a null handle, and destruction is still safe.
204 void rotate(const std::filesystem::path& sealedPath) { _owned.read("FileActionLog::rotate").rotate(sealedPath); }
205
206protected:
207 /// @brief Reads the file on the owner, delivered on @p replyExec.
208 /// @param replyExec Where the answer is delivered.
209 /// @param entityKey If non-empty, restricts the result to that entity's entries.
210 /// @return The matching entries, or the read's error.
212 std::string entityKey) const override {
213 return _owned.ask<std::vector<LogEntry>>(
214 "FileActionLog::entries", replyExec,
215 [entityKey = std::move(entityKey)](State& state) { return state.entries(entityKey); });
216 }
217
218 /// @brief Flushes on the owner, answered on @p replyExec.
219 /// @param replyExec Where the answer is delivered.
220 /// @return `true` once durable, or the flush's error.
222 return _owned.ask<bool>("FileActionLog::flush", replyExec, [](State& state) {
223 state.flush();
224 return true;
225 });
226 }
227
228private:
229 /// The file and everything kept about it; touched only on the owner once
230 /// the constructor has returned. Closes the file when the last task that
231 /// holds it, or the log, lets go.
232 class State {
233 public:
234 State(std::filesystem::path path, ::morph::core::FileIoOps ioOps)
235 : _path{std::move(path)}, _io{std::move(ioOps)} {
236 // Discard a torn trailing record before anything else touches the file.
237 // The file is opened "a", so the next append() would otherwise start
238 // writing at the exact byte the truncated JSON stopped at, with no
239 // separating newline: the two would merge into one line that swallows
240 // the new entry, and once a *further* append pushes that merged line out
241 // of trailing position, entries()' tolerance no longer applies and it
242 // throws -- from this very constructor, leaving the journal permanently
243 // unopenable. FileOfflineQueue heals the same damage in compact(); this
244 // is FileActionLog's equivalent.
245 ::morph::core::repairTornTail(_io, _path, "FileActionLog");
246
247 // Rebuild the idempotencyKey dedup set from whatever is already durably on
248 // disk, so a re-relayed outbox row is recognised even after this process
249 // restarts (not just within one FileActionLog instance's lifetime). Reuses
250 // entries()'s existing torn-trailing-line tolerance; a malformed *interior*
251 // line still throws SerializationError here, same as calling entries()
252 // directly would (see entries()'s docs).
253 //
254 // Done before _file is opened: entries() reads through its own
255 // std::ifstream, independent of _file, so a throw here leaves no file
256 // handle open. Opening _file first and rebuilding the dedup set after
257 // would leak it on this path -- the constructor never completes, so
258 // the destructor never runs to close what fopen() already opened.
259 for (const auto& existing : entries({})) {
260 if (!existing.idempotencyKey.empty()) {
261 _seenIdempotencyKeys.insert(existing.idempotencyKey);
262 }
263 }
264 _file = _io.fopen(_path.string(), "a");
265 ::morph::core::positionAtEnd(_file);
266 if (_file == nullptr) {
267 throw std::runtime_error("FileActionLog: failed to open " + _path.string());
268 }
269 // "a" mode creates the file if it did not already exist -- a fresh
270 // directory entry that `_file`'s own later fsyncs never make durable.
271 // Unconditional: harmless when the file already existed,
272 // since syncing an unchanged directory is a cheap no-op: a failure
273 // here is surfaced rather than swallowed, the same discipline
274 // `flush()`/`rotate()` already apply to the file-content fsync.
275 // `_file` is closed first -- this constructor never completes, so
276 // ~State() never runs to close what fopen() already opened.
277 // A directory fsync needs a *read* handle on the directory, a strictly
278 // stronger permission than writing a file inside it, and several mounts
279 // cannot do it at all -- see classifyDirectorySync(). Neither is a
280 // durability failure, and neither is worth refusing to open over.
281 auto const dirSync = ::morph::core::classifyDirectorySync(_io.syncPath(_path.parent_path()));
282 if (dirSync == ::morph::core::DirectorySync::failed) {
283 // NOLINTNEXTLINE(cert-err33-c, cppcoreguidelines-owning-memory) — about to rethrow, nothing to report a close failure to
284 std::fclose(_file);
285 _file = nullptr;
286 throw std::runtime_error("FileActionLog: failed to fsync directory after creating " + _path.string());
287 }
288 if (dirSync == ::morph::core::DirectorySync::unsupported) {
289 ::morph::log::logWarn(
290 "FileActionLog: cannot fsync the directory containing {}; the log's contents are still fsynced, "
291 "but its directory entry is only as durable as this filesystem makes it",
292 _path.string());
293 }
294 }
295
296 /// `_file` is normally non-null: the constructor either finishes with a
297 /// valid handle or throws before completing, and copy/move are
298 /// deleted. The one exception is a `rotate()` whose reopen failed after
299 /// the rename succeeded — it throws with the handle left null rather
300 /// than dangling, so the null check here is load-bearing.
301 ~State() {
302 if (_file != nullptr) {
303 // Destructor context: there is nothing to propagate a failure to.
304 // NOLINTNEXTLINE(cert-err33-c, cppcoreguidelines-owning-memory)
305 std::fclose(_file);
306 }
307 }
308
309 State(const State&) = delete;
310 State& operator=(const State&) = delete;
311 State(State&&) = delete;
312 State& operator=(State&&) = delete;
313
314 void append(LogEntry entry) {
315 MORPH_ZONE("FileActionLog::append");
316 requireOpen("append");
317 if (_tornTail) {
318 throw std::runtime_error("FileActionLog::append: refusing to append to " + _path.string() +
319 " after a short write that could not be rolled back; reopen the log to have "
320 "it repaired");
321 }
322 if (!entry.idempotencyKey.empty() && (_seenIdempotencyKeys.contains(entry.idempotencyKey) ||
323 _unflushedIdempotencyKeys.contains(entry.idempotencyKey))) {
324 return; // already recorded; a re-relayed duplicate is a safe no-op
325 }
326 entry.seq = ++_nextSeq;
327 auto line = toJson(entry);
328 line.push_back('\n');
329 long long const offsetBeforeWrite = ::morph::core::wideFtell(_file);
330 if (_io.fwrite(line.data(), line.size(), _file) != line.size()) {
331 // See FileOfflineQueue::writeLine's identical comment:
332 // "a"-mode means a short write's partial bytes sit exactly where the
333 // next append() would resume, merging into one line repairTornTail()
334 // can only heal at construction, before this can happen. Roll the
335 // file back to its pre-write length instead, so the failed write
336 // leaves nothing for a later append to merge with. Best-effort --
337 // already the failure path. See `rollBackShortWrite()`'s own docs
338 // for why it also resyncs `_file`'s stdio position, not just the
339 // on-disk length: without that, a second consecutive short write
340 // (e.g. `OutboxRelay::relay()` retrying `append()` on this same
341 // long-lived sink while disk space stays exhausted) would roll back
342 // to a stale offset and pad the file with NUL bytes instead of
343 // truncating it.
344 if (::morph::core::rollBackShortWrite(_io, _file, _path, offsetBeforeWrite) ==
345 ::morph::core::RollBack::torn) {
346 // The rollback could not truncate, so partial bytes may remain at
347 // the end of the file. `repairTornTail()` heals that at the next
348 // construction, but only while it is still the *trailing* line --
349 // one more successful append on this handle would concatenate
350 // onto it and move the damage into an interior position, which
351 // nothing heals. Refuse every later append instead, so the file
352 // stays in the one shape the next open can repair.
353 _tornTail = true;
354 }
355 throw std::runtime_error("FileActionLog::append: short write to " + _path.string());
356 }
357 if (!entry.idempotencyKey.empty()) {
358 _unflushedIdempotencyKeys.insert(std::move(entry.idempotencyKey));
359 }
360 }
361
362 /// Keeps the first failure of a posted append, for the next `flush()`.
363 void notePostedFailure(std::string_view what) {
364 if (_postedFailure.empty()) {
365 _postedFailure = what;
366 }
367 }
368
369 void flush() {
370 requireOpen("flush");
371 if (_io.fflush(_file) != 0) {
372 _unflushedIdempotencyKeys.clear();
373 throw std::runtime_error("FileActionLog::flush: failed to flush " + _path.string());
374 }
375 if (_io.fsync(_file) != 0) {
376 _unflushedIdempotencyKeys.clear();
377 throw std::runtime_error("FileActionLog::flush: failed to fsync " + _path.string());
378 }
379 // Durable now: promote this window's keys so they survive as dedup state.
380 for (const auto& key : _unflushedIdempotencyKeys) {
381 _seenIdempotencyKeys.insert(key);
382 }
383 _unflushedIdempotencyKeys.clear();
384 if (!_postedFailure.empty()) {
385 std::string const what = std::exchange(_postedFailure, {});
386 throw std::runtime_error(
387 "FileActionLog::flush: an append posted from another thread was not written: " + what);
388 }
389 }
390
391 [[nodiscard]] std::vector<LogEntry> entries(std::string_view entityKey) const {
392 std::ifstream input{_path};
393 if (!input && std::filesystem::exists(_path)) {
394 // Distinguish "no journal yet" (absent: legitimately empty, and the
395 // constructor's dedup rebuild depends on that) from "journal present
396 // but unreadable". Returning {} for the second silently empties the
397 // idempotencyKey dedup set OutboxRelay relies on, turning
398 // at-least-once-plus-dedup into duplicates with no diagnostic.
399 throw std::runtime_error("FileActionLog: cannot read " + _path.string());
400 }
401 std::vector<std::string> lines;
402 std::string line;
403 while (std::getline(input, line)) {
404 if (!line.empty()) {
405 lines.push_back(line);
406 }
407 }
408 std::vector<LogEntry> out;
409 for (std::size_t i = 0; i < lines.size(); ++i) {
410 // .at() outside the try, not lines[i] inside it: the loop condition
411 // already bounds i, so the bounds check cannot fire, but if it ever
412 // could its std::out_of_range would be swallowed by the catch below
413 // and mis-reported as a malformed journal line.
414 std::string const& rawLine = lines.at(i);
415 LogEntry entry;
416 try {
417 entry = fromJson(rawLine);
418 } catch (const std::exception& exc) {
419 // A crash between `append`'s `fwrite` and the next flush can leave
420 // a truncated final line. Tolerate exactly that — skip a malformed
421 // *trailing* line so the rest of the log stays readable — but a
422 // malformed line mid-file is genuine corruption and is re-thrown.
423 if (i + 1 == lines.size()) {
424 ::morph::log::logWarn("FileActionLog: skipping malformed trailing line in " + _path.string() +
425 ": " + std::string{exc.what()});
426 break;
427 }
428 throw;
429 }
430 if (entityKey.empty() || entry.entityKey == entityKey) {
431 out.push_back(std::move(entry));
432 }
433 }
434 return out;
435 }
436
437 void rotate(const std::filesystem::path& sealedPath) {
438 requireOpen("rotate");
439 // Same durability steps as flush(), without its posted-failure
440 // report: a failure is raised before the file is closed and
441 // renamed, so a segment is never sealed around entries that never
442 // reached the disk.
443 if (_io.fflush(_file) != 0) {
444 throw std::runtime_error("FileActionLog::rotate: failed to flush " + _path.string());
445 }
446 if (_io.fsync(_file) != 0) {
447 throw std::runtime_error("FileActionLog::rotate: failed to fsync " + _path.string());
448 }
449 // Everything buffered is now durable in the segment about to be sealed.
450 for (const auto& key : _unflushedIdempotencyKeys) {
451 _seenIdempotencyKeys.insert(key);
452 }
453 _unflushedIdempotencyKeys.clear();
454 // NOLINTNEXTLINE(cert-err33-c, cppcoreguidelines-owning-memory)
455 std::fclose(_file); // the data is already fsynced above
456 _file = nullptr;
457
458 std::error_code renameError;
459 std::filesystem::rename(_path, sealedPath, renameError);
460
461 // Reopen the active path regardless of the rename's outcome: on
462 // success this creates a fresh empty file; on failure it reopens the
463 // same pre-rotation file (still holding every prior entry), so a
464 // failed rotation never leaves the log unusable.
465 // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) — mirrors std::fopen's own raw-owning-pointer return; closed in the destructor
466 _file = _io.fopen(_path.string(), "a");
467 ::morph::core::positionAtEnd(_file);
468
469 // Two directory mutations just happened -- the seal rename and, on
470 // success, a brand-new active file -- and neither is durable until
471 // its directory entry is fsynced. Run regardless of what
472 // failed above, so a rotation that is about to throw still leaves
473 // whatever succeeded as durable as it can be made; the failure is
474 // surfaced below rather than swallowed, same as the pre-rotation
475 // fsync above.
476 // `unsupported` is not a failure here either -- same reasoning as the
477 // constructor's own directory fsync -- but it is not silent either:
478 // the contract is that it warns, so an operator knows the rotated
479 // names are only as durable as the filesystem makes them. Collapsing
480 // the tri-state to a bool here would drop that warning on the floor for
481 // both directories, leaving `rotate()` quieter than the construction
482 // path that documents the same classification.
483 auto const classifyAndWarn = [this](const std::filesystem::path& dir) {
484 auto const outcome = ::morph::core::classifyDirectorySync(_io.syncPath(dir));
485 if (outcome == ::morph::core::DirectorySync::unsupported) {
486 ::morph::log::logWarn(
487 "FileActionLog::rotate: cannot fsync the directory {}; the log's contents are still fsynced, "
488 "but the rotated directory entries are only as durable as this filesystem makes them",
489 dir.string());
490 }
491 return outcome;
492 };
493 bool const dirSyncFailed = classifyAndWarn(_path.parent_path()) == ::morph::core::DirectorySync::failed;
494 bool const sealedDirSyncFailed =
495 sealedPath.parent_path() != _path.parent_path() &&
496 classifyAndWarn(sealedPath.parent_path()) == ::morph::core::DirectorySync::failed;
497
498 if (_file == nullptr) {
499 throw std::runtime_error("FileActionLog::rotate: failed to reopen " + _path.string() + " after " +
500 (renameError ? "a failed" : "a successful") + " rename to " +
501 sealedPath.string());
502 }
503 if (renameError) {
504 throw std::runtime_error("FileActionLog::rotate: failed to rename " + _path.string() + " to " +
505 sealedPath.string() + ": " + renameError.message());
506 }
507 if (dirSyncFailed || sealedDirSyncFailed) {
508 throw std::runtime_error("FileActionLog::rotate: failed to fsync the directory after rotating " +
509 _path.string() + " to " + sealedPath.string());
510 }
511 }
512
513 private:
514 /// Throws if no file handle is open. Reachable only after a `rotate()` whose
515 /// reopen failed: that path deliberately leaves `_file` null rather than
516 /// dangling, so every entry point has to say so instead of dereferencing it.
517 void requireOpen(std::string_view what) const {
518 if (_file == nullptr) {
519 throw std::runtime_error("FileActionLog::" + std::string{what} + ": " + _path.string() +
520 " is not open (a previous rotate() failed to reopen it)");
521 }
522 }
523
524 std::filesystem::path _path;
525 ::morph::core::FileIoOps _io;
526 std::FILE* _file = nullptr;
527 // Set when a failed write could not be rolled back, so a partial record may
528 // still sit at the end of `_path`. Every later append is refused, which is
529 // what keeps that partial record the trailing line -- the only position
530 // `repairTornTail()` can heal it from at the next construction.
531 bool _tornTail = false;
532 uint64_t _nextSeq{0};
533 /// Keys confirmed durable by a successful `flush()`.
534 std::unordered_set<std::string> _seenIdempotencyKeys;
535 /// Keys written since the last successful `flush()`. Promoted into
536 /// `_seenIdempotencyKeys` on success, discarded on failure so the
537 /// corresponding rows stay retryable.
538 std::unordered_set<std::string> _unflushedIdempotencyKeys;
539 /// The first failure of a posted append since the last `flush()`.
540 std::string _postedFailure;
541 };
542
543 ::morph::exec::detail::OwnedState<State> _owned;
544};
545
546} // namespace morph::journal
The LogEntry JSON codec, kept apart from action_log.hpp.
LogEntry fromJson(std::string_view json)
Decodes json into a LogEntry.
Move-only handle representing the eventual result of an asynchronous operation.
::morph::async::Completion< bool > askFlush(::morph::exec::IExecutor &replyExec) override
Flushes on the owner, answered on replyExec.
::morph::exec::IExecutor * owner() const noexcept override
The executor given at construction.
FileActionLog(::morph::exec::IExecutor &owner, std::filesystem::path path, ::morph::core::FileIoOps ioOps={})
Opens (creating if necessary) path for appending, owned by owner.
void rotate(const std::filesystem::path &sealedPath)
Seals the active file and reopens a fresh, empty one at the same path. On the owner.
void flush() override
Flushes stdio's buffer, then fsyncs the file descriptor. On the owner.
::morph::async::Completion< std::vector< LogEntry > > askEntries(::morph::exec::IExecutor &replyExec, std::string entityKey) const override
Reads the file on the owner, delivered on replyExec.
void append(LogEntry entry) override
Appends entry as one JSON line, on the owner. Buffered until flush().
std::vector< LogEntry > entries(std::string_view entityKey={}) const override
Re-reads the file from disk and decodes every line. On the owner.
OwnedState: a component's state, and the one executor it is touched on.
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_ZONE(name)
Opens a profiler zone named name that ends with the enclosing scope. At most one per scope.
Definition profiler.hpp:118
The raw file-I/O primitives morph::journal::FileActionLog and morph::offline::FileOfflineQueue both c...
Abstract executor interface.
Definition executor.hpp:70
Interface for durable storage of executed-action entries.
virtual void flush()=0
Pushes any buffered entries to the durable backend. No-op for sinks with nothing to buffer (e....
virtual std::vector< LogEntry > entries(std::string_view entityKey={}) const =0
Returns recorded entries in append order.
One recorded execution of an action against a model instance.