morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
file_offline_queue.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <cstdint>
6#include <cstdio>
7#include <filesystem>
8#include <fstream>
9#include <glaze/glaze.hpp>
10#include <map>
11#include <memory>
12#include <optional>
13#include <stdexcept>
14#include <string>
15#include <string_view>
16#include <system_error>
17#include <utility>
18#include <vector>
19
20#include "../attributes.hpp"
21#include "../core/completion.hpp"
23#include "../core/executor.hpp"
24#include "../core/file_io_ops.hpp"
25#include "../core/logger.hpp"
26#include "../core/observability.hpp"
27#include "../core/profiler.hpp"
28#include "offline_queue.hpp"
29
30#ifdef _WIN32
31#include <io.h>
32#else
33#include <unistd.h>
34#endif
35
36namespace morph::offline {
37
38/// @brief Thrown by `FileOfflineQueue` when a non-trailing line of its on-disk
39/// NDJSON is malformed (via `detail::throwOnGlazeError`). Note the
40/// "cannot be opened" paths throw plain `std::runtime_error`, not this
41/// type — see the constructor's own exception documentation.
42struct FileOfflineQueueError : std::runtime_error {
43 using std::runtime_error::runtime_error;
44};
45
46namespace detail {
47
48/// @brief One NDJSON line of `FileOfflineQueue`'s on-disk log.
49///
50/// `op == "put"` upserts `id`'s current `(payload, idempotencyKey, attempts)`;
51/// `op == "done"` tombstones `id`. Replaying every line in file order and
52/// applying each in turn (a later "put" overwrites an earlier one for the same
53/// `id`; a "done" removes it) reconstructs the live item set — the
54/// last-write-wins-per-id compaction `docs/spec/offline/offline.md` describes
55/// for this variant.
56struct FileQueueRecord {
57 std::string op;
58 uint64_t id{};
59 std::string payload;
60 std::string idempotencyKey;
61 uint32_t attempts{0};
62};
63
64inline void throwOnGlazeError(const glz::error_ctx& errCode, std::string_view context) {
65 if (errCode) {
66 throw FileOfflineQueueError{glz::format_error(errCode, context)};
67 }
68}
69
70/// @brief Write options that escape ASCII control bytes as `\\uXXXX` sequences.
71///
72/// glaze 7.4 leaves control bytes (0x00-0x1F) unescaped by default, which
73/// breaks a `FileQueueRecord` carrying one in `payload`/`idempotencyKey` two
74/// ways: RFC 8259 requires those bytes escaped, so the raw byte alone yields
75/// JSON `fromJson`'s `glz::read` throws on for any non-trailing line (a
76/// permanent `FileOfflineQueueError` on every later `open()`, per this file's
77/// torn-trailing-line tolerance); worse, once the same string also contains an
78/// escaped `\` or `"`, glaze's chunked writer path silently rewrites the
79/// control byte as two 0x00 bytes, corrupting the payload before it ever
80/// reaches disk. Mirrors `morph::wire::detail::EscapingWriteOpts` (`core/wire.hpp`)
81/// exactly; duplicated here (rather than shared) so this header does not pull in
82/// `core/wire.hpp`'s envelope machinery for a four-line options struct (it
83/// already depends on `core/file_io_ops.hpp`, `core/logger.hpp` and
84/// `core/observability.hpp`, so it is this one header that is being avoided, not
85/// `core/` as such). Escaping is lossless, so any such byte still
86/// round-trips through `fromJson` unchanged.
87struct EscapingWriteOpts : glz::opts {
88 /// @brief Emit control bytes as `\\uXXXX` rather than raw.
89 // NOLINTNEXTLINE(readability-identifier-naming) — glaze's option name, matched by name.
90 bool escape_control_characters = true;
91};
92
93/// @brief Encodes @p record as JSON, escaping control bytes in `payload`/`idempotencyKey`.
94inline std::string toJson(const FileQueueRecord& record) {
95 std::string out;
96 throwOnGlazeError(glz::write<EscapingWriteOpts{}>(record, out), out);
97 return out;
98}
99
100inline FileQueueRecord fromJson(std::string_view json) {
101 FileQueueRecord record{};
102 // null_terminated=false: json is a caller-supplied view with no guaranteed
103 // trailing '\0' — see the identical fix + rationale on morph::wire::decode
104 // (wire.hpp), whose fuzz harness found the resulting heap-buffer-overflow in
105 // glaze's skip_ws. glz::read_json (used elsewhere in this file) hardcodes
106 // glz::opts{} and offers no way to override this, hence the explicit glz::read<>.
107 static constexpr glz::opts kUnpadded{.null_terminated = false};
108 throwOnGlazeError(glz::read<kUnpadded>(record, json), json);
109 return record;
110}
111
112} // namespace detail
113
114/// @brief Reference append-only, NDJSON-backed `IOfflineQueue` that persists
115/// `payload`, `idempotencyKey`, and `attempts` across process restarts
116/// with no third-party dependency.
117///
118/// Each mutation (`enqueue`, `markDone`, `setAttempts`, `setIdempotencyKey`)
119/// appends one JSON line and immediately `fflush`+`fsync`s it, so it is a
120/// committed transaction before the call returns. On construction, the file
121/// is replayed line by line (last-write-wins per id — a later "put" overwrites
122/// an earlier one, a "done" tombstones it) and then rewritten in compacted
123/// form (one "put" line per surviving item), which both bounds file growth and
124/// heals a torn trailing line left by a crash mid-write — the same tolerance
125/// `FileActionLog::entries()` (`include/morph/journal/file_action_log.hpp`)
126/// gives a malformed *trailing* line: it is logged and skipped, not thrown: a
127/// malformed line anywhere else is genuine corruption and is rethrown.
128///
129/// New ids resume from the highest id ever seen in the file (including
130/// tombstoned ones), so a fresh item can never collide with an old tombstone
131/// — unlike `FileActionLog`'s process-local `seq`, which does not need this
132/// property because it never reuses/removes entries.
133///
134/// @par One owner
135/// The file handle and the pending items belong to the executor given at
136/// construction (see `IOfflineQueue`, "One owner"): each mutation's write and
137/// fsync run there, one at a time. Not safe for multiple processes to open the
138/// same path concurrently — same restriction as `FileActionLog`.
139///
140/// @par Idempotency-key dedup
141/// A keyed `enqueue` does a linear scan over the currently-pending items to
142/// look for a matching `idempotencyKey` — O(pending items) per call. That is
143/// fine at the queue depths this reference implementation targets; a host
144/// with high-volume keyed enqueues should prefer `SqliteOfflineQueue`, whose
145/// dedup is index-backed.
147public:
149 using IOfflineQueue::enqueue; // keep the two-arg and completion overloads visible
151
152 /// @brief Opens (or creates) the queue log at @p path, replaying and
153 /// compacting whatever is already there; the queue belongs to @p owner.
154 ///
155 /// Runs on the constructing thread, before anything else can reach the queue.
156 /// @param owner The executor every access runs on; must run one task at a
157 /// time. Borrowed: it must outlive this queue and run what it posts.
158 /// @param path NDJSON file to store queue state in.
159 /// @param ioOps Injectable file-I/O primitives; defaults to the real
160 /// syscalls. Test-only seam — see `morph::core::FileIoOps`'s own
161 /// docs — for forcing the failure branches that need a real
162 /// OS-level I/O error to reach.
163 /// @param maxDepth Maximum number of pending items `enqueue()` will admit
164 /// before throwing `OfflineQueueFullError`; `std::nullopt` (the
165 /// default) means unbounded. Not persisted in the file itself — a
166 /// per-construction parameter, so a reopen must pass it again to
167 /// keep the same cap enforced.
168 /// @throws FileOfflineQueueError if @p path exists but contains a
169 /// malformed non-trailing line.
170 /// @throws std::runtime_error if @p path cannot be opened/rewritten.
171 FileOfflineQueue(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND, std::filesystem::path path,
172 ::morph::core::FileIoOps ioOps = {}, std::optional<std::size_t> maxDepth = std::nullopt)
173 : _maxDepth{maxDepth}, _owned{owner, std::make_shared<State>(std::move(path), std::move(ioOps))} {}
174
175 ~FileOfflineQueue() override = default;
176
177 FileOfflineQueue(const FileOfflineQueue&) = delete;
178 FileOfflineQueue& operator=(const FileOfflineQueue&) = delete;
180 FileOfflineQueue& operator=(FileOfflineQueue&&) = delete;
181
182 /// @brief Appends @p payload with no idempotency key. On the owner.
183 /// @param payload Serialised action to persist.
184 /// @return A stable id that can be passed to `markDone()`.
185 [[nodiscard]] uint64_t enqueue(std::string payload) override { return enqueue(std::move(payload), {}); }
186
187 /// @brief Appends @p payload carrying @p idempotencyKey. On the owner. A
188 /// non-empty key already present on a pending item is
189 /// deduplicated: the existing item's id is returned and nothing new
190 /// is written.
191 /// @param payload Serialised action to persist.
192 /// @param idempotencyKey Stable dedup token; empty means "no dedup".
193 /// @return The new item's id, or the existing item's id on a dedup hit.
194 /// @throws OfflineQueueFullError if the queue is already at `maxDepth()`
195 /// (a dedup hit above bypasses this check and always succeeds).
196 [[nodiscard]] uint64_t enqueue(std::string payload, std::string idempotencyKey) override {
197 return _owned.read("FileOfflineQueue::enqueue")
198 .enqueue(std::move(payload), std::move(idempotencyKey), _maxDepth);
199 }
200
201 /// @brief Returns all pending items in ascending-id (enqueue) order. On the owner.
202 /// @return Snapshot of all pending items; the file itself is unchanged.
203 [[nodiscard]] std::vector<QueueItem> drain() const override {
204 return _owned.read("FileOfflineQueue::drain").drain();
205 }
206
207 /// @brief Returns the number of pending items. On the owner.
208 /// @return Current pending item count.
209 [[nodiscard]] std::size_t size() const override { return _owned.read("FileOfflineQueue::size").size(); }
210
211 /// @brief Returns the configured maximum depth, or `std::nullopt` if
212 /// unbounded. Callable anywhere: fixed at construction.
213 /// @return The capacity `enqueue()` enforces, or `std::nullopt` if none.
214 [[nodiscard]] std::optional<std::size_t> maxDepth() const override { return _maxDepth; }
215
216 /// @brief Tombstones @p itemId, on the owner. No-op if not found.
217 ///
218 /// Called off the owner, the tombstone is posted there and this returns
219 /// at once; a write that fails there is logged, and the item stays queued.
220 /// @param itemId Id returned by the corresponding `enqueue()` call.
221 void markDone(uint64_t itemId) override {
222 _owned.apply("FileOfflineQueue::markDone", [itemId](State& state) { state.markDone(itemId); });
223 }
224
225 /// @brief Persists an updated attempt count for @p itemId, on the owner.
226 /// No-op if not found.
227 /// @param itemId Id of the item whose count changed.
228 /// @param attempts New cumulative attempt count to store.
229 void setAttempts(uint64_t itemId, Attempts attempts) override {
230 _owned.apply("FileOfflineQueue::setAttempts",
231 [itemId, attempts](State& state) { state.setAttempts(itemId, attempts); });
232 }
233
234protected:
235 /// @brief Stamps an idempotency key onto an already-enqueued item, on the
236 /// owner. No-op if @p itemId is not found. Reachable only if a
237 /// caller invokes the base `IOfflineQueue::enqueue(payload, key)`
238 /// default through an `IOfflineQueue&` — this class's own
239 /// `enqueue(payload, key)` override above stamps the key inline instead.
240 /// @param itemId Id of the item to stamp.
241 /// @param idempotencyKey Key to store.
242 void setIdempotencyKey(uint64_t itemId, std::string idempotencyKey) override {
243 _owned.apply("FileOfflineQueue::setIdempotencyKey",
244 [itemId, idempotencyKey = std::move(idempotencyKey)](State& state) mutable {
245 state.setIdempotencyKey(itemId, std::move(idempotencyKey));
246 });
247 }
248
249 /// @brief Enqueues on the owner, answered on @p replyExec.
250 /// @param replyExec Where the answer is delivered.
251 /// @param payload Serialised action to persist.
252 /// @param idempotencyKey Stable dedup token; may be empty.
253 /// @return The item's id, or the enqueue's error.
255 std::string payload,
256 std::string idempotencyKey) override {
257 return _owned.ask<uint64_t>("FileOfflineQueue::enqueue", replyExec,
258 [maxDepth = _maxDepth, payload = std::move(payload),
259 idempotencyKey = std::move(idempotencyKey)](State& state) mutable {
260 return state.enqueue(std::move(payload), std::move(idempotencyKey), maxDepth);
261 });
262 }
263
264 /// @brief Snapshots on the owner, answered on @p replyExec.
265 /// @param replyExec Where the answer is delivered.
266 /// @return The pending items.
268 ::morph::exec::IExecutor& replyExec) const override {
269 return _owned.ask<std::vector<QueueItem>>("FileOfflineQueue::drain", replyExec,
270 [](State& state) { return state.drain(); });
271 }
272
273 /// @brief Counts on the owner, answered on @p replyExec.
274 /// @param replyExec Where the answer is delivered.
275 /// @return The pending item count.
277 return _owned.ask<std::size_t>("FileOfflineQueue::size", replyExec, [](State& state) { return state.size(); });
278 }
279
280private:
281 /// The file, the pending items and the id high-water mark; touched only on
282 /// the owner once the constructor has returned. Closes the file when the
283 /// last task that holds it, or the queue, lets go.
284 class State {
285 public:
286 State(std::filesystem::path path, ::morph::core::FileIoOps ioOps)
287 : _path{std::move(path)}, _io{std::move(ioOps)} {
288 // No constructor-time repairTornTail() here, deliberately. Calling it
289 // before load() would not heal an "interior merge from a doubled-up
290 // short write": repairTornTail only trims bytes after the final
291 // newline, and says so itself -- "Complete records, including a
292 // malformed *interior* line, are left exactly as they are."
293 //
294 // It would also break two things. It would be the constructor's only
295 // file mutation that can run *before* load() throws, costing the
296 // guarantee that a failed construction leaves the queue file
297 // byte-identical (a file with both a malformed interior line and a torn
298 // tail would come back truncated *and* throw). And it discards a
299 // complete final record whose only missing byte is the trailing newline
300 // -- which load() decodes perfectly well -- wiping the file outright
301 // when that is the only line.
302 //
303 // What prevents the doubled-up short write is the rollback in
304 // writeLine() below, which leaves no partial bytes for a later write to
305 // merge with; load()+compact() heal an ordinary torn tail.
306 // FileActionLog calls repairTornTail at construction because its own
307 // file shape makes that safe there.
308 load();
309 compact();
310 _file = _io.fopen(_path.string(), "a");
311 ::morph::core::positionAtEnd(_file);
312 if (_file == nullptr) {
313 throw std::runtime_error("FileOfflineQueue: failed to open " + _path.string());
314 }
315 }
316
317 ~State() {
318 if (_file != nullptr) {
319 // Destructor context: there is nothing to propagate a failure to.
320 // NOLINTNEXTLINE(cert-err33-c, cppcoreguidelines-owning-memory)
321 std::fclose(_file);
322 }
323 }
324
325 State(const State&) = delete;
326 State& operator=(const State&) = delete;
327 State(State&&) = delete;
328 State& operator=(State&&) = delete;
329
330 uint64_t enqueue(std::string payload, std::string idempotencyKey, std::optional<std::size_t> maxDepth) {
331 MORPH_ZONE("FileOfflineQueue::enqueue");
332 if (!idempotencyKey.empty()) {
333 for (const auto& [existingId, item] : _items) {
334 if (item.idempotencyKey == idempotencyKey) {
335 return existingId;
336 }
337 }
338 }
339 if (maxDepth && _items.size() >= *maxDepth) {
340 ::morph::observe::detail::emitMetric(::morph::observe::Metric::queueOverflow,
341 static_cast<double>(_items.size()));
342 throw OfflineQueueFullError{*maxDepth, _items.size()};
343 }
344 uint64_t const itemId = ++_nextId;
345 QueueItem item{.id = itemId, .payload = std::move(payload), .idempotencyKey = std::move(idempotencyKey)};
346 appendPut(item);
347 _items.emplace(itemId, std::move(item));
348 return itemId;
349 }
350
351 [[nodiscard]] std::vector<QueueItem> drain() const {
352 std::vector<QueueItem> out;
353 out.reserve(_items.size());
354 for (const auto& entry : _items) {
355 out.push_back(entry.second);
356 }
357 return out;
358 }
359
360 [[nodiscard]] std::size_t size() const { return _items.size(); }
361
362 void markDone(uint64_t itemId) {
363 auto iter = _items.find(itemId);
364 if (iter == _items.end()) {
365 return;
366 }
367 // Durable first, then in-memory -- the same order `enqueue()` uses, and
368 // for the mirror-image reason. `appendDone` throws on a short write, a
369 // failed fflush or a failed fsync; erasing before it means this process
370 // would never replay the item again while no tombstone reached disk, so
371 // a restart resurrects it and applies it a second time. Erasing after
372 // means a failure leaves the item live in both places, which replays
373 // once too often at worst -- and `idempotencyKey` exists to absorb that.
374 appendDone(itemId);
375 _items.erase(iter);
376 }
377
378 void setAttempts(uint64_t itemId, Attempts attempts) {
379 auto iter = _items.find(itemId);
380 if (iter == _items.end()) {
381 return;
382 }
383 // Durable first, for the same reason as `markDone` above: a throwing
384 // `appendPut` must not leave memory claiming a count that never reached
385 // disk. Write from a copy so `_items` is only updated once the record is
386 // durable.
387 auto updated = iter->second;
388 updated.attempts = attempts.value();
389 appendPut(updated);
390 iter->second.attempts = attempts.value();
391 }
392
393 void setIdempotencyKey(uint64_t itemId, std::string idempotencyKey) {
394 auto iter = _items.find(itemId);
395 if (iter == _items.end()) {
396 return;
397 }
398 iter->second.idempotencyKey = std::move(idempotencyKey);
399 appendPut(iter->second);
400 }
401
402 private:
403 void appendPut(const QueueItem& item) {
404 detail::FileQueueRecord const record{.op = "put",
405 .id = item.id,
406 .payload = item.payload,
407 .idempotencyKey = item.idempotencyKey,
408 .attempts = item.attempts};
409 writeLine(detail::toJson(record));
410 }
411
412 void appendDone(uint64_t itemId) {
413 detail::FileQueueRecord const record{
414 .op = "done", .id = itemId, .payload = {}, .idempotencyKey = {}, .attempts = 0};
415 writeLine(detail::toJson(record));
416 }
417
418 /// Every mutation is documented as a committed transaction by the time the
419 /// call returns, so a failure to get the bytes down has to be raised rather
420 /// than swallowed: a caller told an item was enqueued, or marked done, must
421 /// not have that silently be untrue after a restart.
422 void writeLine(const std::string& json) {
423 if (_tornTail) {
424 throw std::runtime_error(
425 "FileOfflineQueue: refusing to write to " + _path.string() +
426 " after a short write that could not be rolled back; reopen the queue to have it repaired");
427 }
428 std::string line = json;
429 line.push_back('\n');
430 long long const offsetBeforeWrite = ::morph::core::wideFtell(_file);
431 // The rollback below has to cover the *flush* too, not only a short
432 // fwrite. A queue record is a few hundred bytes -- far under BUFSIZ --
433 // so fwrite is a memcpy into the stdio buffer and returns the full
434 // count even on a full disk; the write(2) that actually fails happens
435 // inside syncFile's fflush. Wired to the short-write branch alone, an
436 // ENOSPC there would throw with a truncated line already on disk, at
437 // exactly the offset the next writeLine resumes from and with no
438 // separating newline -- the identical merge the rollback exists to
439 // prevent, and the *common* manifestation of a full disk rather than
440 // an exotic one.
441 auto const rollBackAndThrow = [&](const std::string& what) {
442 if (::morph::core::rollBackShortWrite(_io, _file, _path, offsetBeforeWrite) ==
443 ::morph::core::RollBack::torn) {
444 // The rollback could not truncate, so partial bytes may still be
445 // at the end of the file. `load()` tolerates that only while it
446 // is the *trailing* line; one more successful writeLine on this
447 // handle would concatenate onto it and push it into an interior
448 // position, where the merged line makes the next open throw a
449 // parse error and takes the entire backlog with it. Refusing
450 // every later write keeps the damage trailing and therefore
451 // recoverable -- the next open's compact() rewrites the file
452 // without it.
453 _tornTail = true;
454 }
455 throw std::runtime_error("FileOfflineQueue: " + what + " " + _path.string());
456 };
457 if (_io.fwrite(line.data(), line.size(), _file) != line.size()) {
458 // The file is opened "a" (append), so a short write's partial bytes
459 // sit right where the *next* writeLine would otherwise resume, with
460 // no separating newline -- merging into one line load() can only
461 // tolerate while it stays the trailing line, and stops being able
462 // to the moment a further write pushes it into an interior position
463 // at all. Roll the file back to its pre-write length instead,
464 // so a failed write leaves no trace at all for the next one to
465 // merge with. Best-effort: this is already the failure path, and
466 // when the rollback's own flush cannot complete (the disk that made
467 // the write short is still full) it deliberately truncates nothing
468 // -- load()'s tolerance of a torn *trailing* line, plus the
469 // rewrite compact() performs on the next open, is what heals it
470 // then. See `rollBackShortWrite`'s own doc comment for why the
471 // flush has to succeed before anything is truncated, and why it
472 // resyncs `_file`'s stdio position afterwards.
473 rollBackAndThrow("short write to");
474 }
475 if (_io.fflush(_file) != 0) {
476 rollBackAndThrow("failed to flush");
477 }
478 if (_io.fsync(_file) != 0) {
479 // fsync failing after a successful flush means the bytes are in the
480 // page cache but may not reach the platter. They are a *complete*
481 // record, so rolling back is still the right call: this mutation is
482 // documented as committed once it returns, and a caller told the
483 // enqueue failed must not find it replayed after a restart.
484 rollBackAndThrow("failed to fsync");
485 }
486 }
487
488 void syncFile(std::FILE* file, const std::string& what) const {
489 if (_io.fflush(file) != 0) {
490 throw std::runtime_error("FileOfflineQueue: failed to flush " + what);
491 }
492 if (_io.fsync(file) != 0) {
493 throw std::runtime_error("FileOfflineQueue: failed to fsync " + what);
494 }
495 }
496
497 /// @brief Reads whatever is on disk and replays it into `_items`/`_nextId`.
498 void load() {
499 if (!std::filesystem::exists(_path)) {
500 return;
501 }
502 std::ifstream input{_path};
503 if (!input) {
504 // The file exists (checked above) but cannot be read. Returning an
505 // empty `_items` here is not "an empty queue": the constructor calls
506 // compact() straight after load(), which would rewrite `_path` from
507 // that empty set and destroy every pending item. Throw so the caller
508 // learns the queue could not be opened, instead of being handed one
509 // that silently reports no work.
510 throw std::runtime_error("FileOfflineQueue: cannot read " + _path.string());
511 }
512 std::vector<std::string> lines;
513 std::string line;
514 while (std::getline(input, line)) {
515 if (!line.empty()) {
516 lines.push_back(line);
517 }
518 }
519 uint64_t highestId = 0;
520 if (input.bad()) {
521 // A read error mid-file, not end-of-file: `lines` is a prefix of the
522 // queue, and compact() would commit that prefix over the whole file.
523 throw std::runtime_error("FileOfflineQueue: read error on " + _path.string());
524 }
525 for (std::size_t i = 0; i < lines.size(); ++i) {
526 detail::FileQueueRecord record;
527 try {
528 record = detail::fromJson(lines.at(i));
529 } catch (const std::exception& exc) {
530 if (i + 1 == lines.size()) {
531 ::morph::log::logWarn("FileOfflineQueue: skipping malformed trailing line in " +
532 _path.string() + ": " + std::string{exc.what()});
533 break;
534 }
535 throw;
536 }
537 highestId = std::max(highestId, record.id);
538 if (record.op == "done") {
539 _items.erase(record.id);
540 } else {
541 _items[record.id] = QueueItem{.id = record.id,
542 .payload = record.payload,
543 .idempotencyKey = record.idempotencyKey,
544 .attempts = record.attempts};
545 }
546 }
547 _nextId = highestId;
548 }
549
550 /// @brief Rewrites the file with exactly one "put" line per surviving
551 /// item, collapsing whatever history `load()` just replayed.
552 /// Called once from the constructor, after `load()` and before the
553 /// append-mode `_file` handle is opened for new writes.
554 void compact() {
555 std::string const tmp = _path.string() + ".compact-tmp";
556 std::FILE* out = _io.fopen(tmp, "w");
557 if (out == nullptr) {
558 throw std::runtime_error("FileOfflineQueue: failed to open " + tmp + " for compaction");
559 }
560
561 // Every exit below this point closes `out` and, unless the rename
562 // committed, removes `tmp`. A throw from `syncFile(out, tmp)` leaves
563 // compact() through here too, so it neither leaks the handle nor
564 // orphans the temp file; the fault-injection test "a failing fflush()
565 // during construction-time compaction throws" drives that path. An
566 // open handle would also block the unlink on Windows.
567 class TempFileGuard {
568 public:
569 TempFileGuard(std::FILE* file, std::string path) : _file{file}, _path{std::move(path)} {}
570
571 ~TempFileGuard() {
572 if (_file != nullptr) {
573 // Unwinding already; there is nothing to report a close
574 // failure to.
575 // NOLINTNEXTLINE(cert-err33-c, cppcoreguidelines-owning-memory)
576 std::fclose(_file);
577 }
578 if (!_committed) {
579 std::error_code errorCode;
580 std::filesystem::remove(_path, errorCode);
581 }
582 }
583
584 TempFileGuard(const TempFileGuard&) = delete;
585 TempFileGuard& operator=(const TempFileGuard&) = delete;
586 TempFileGuard(TempFileGuard&&) = delete;
587 TempFileGuard& operator=(TempFileGuard&&) = delete;
588
589 /// @brief Hands the handle back to the caller, which closes it.
590 void releaseHandle() noexcept { _file = nullptr; }
591 /// @brief Marks the temp file as renamed away, so it is not removed.
592 void commit() noexcept { _committed = true; }
593
594 private:
595 std::FILE* _file;
596 std::string _path;
597 bool _committed = false;
598 };
599 TempFileGuard guard{out, tmp};
600
601 auto writeRecord = [&](const detail::FileQueueRecord& record) {
602 std::string outLine = detail::toJson(record);
603 outLine.push_back('\n');
604 if (_io.fwrite(outLine.data(), outLine.size(), out) != outLine.size()) {
605 throw std::runtime_error("FileOfflineQueue: short write during compaction of " + _path.string());
606 }
607 };
608
609 for (const auto& [entryId, item] : _items) {
610 writeRecord(detail::FileQueueRecord{.op = "put",
611 .id = item.id,
612 .payload = item.payload,
613 .idempotencyKey = item.idempotencyKey,
614 .attempts = item.attempts});
615 }
616
617 // Carry the id high-water mark across the rewrite. `load()` derives
618 // _nextId from the ids it sees, and compaction drops every tombstone, so
619 // without this the mark silently regresses to the highest *surviving*
620 // id: enqueue 1 and 2, markDone(2), restart (compacts to just id 1),
621 // restart again -> _nextId == 1 and the next enqueue reissues id 2, the
622 // id of an item that was completed and acknowledged. That breaks the
623 // "new ids never collide with an old tombstone" invariant this class
624 // documents, and a stale in-flight reference to the old id 2 would then
625 // silently address a different item.
626 //
627 // Recorded as a "done" for the mark itself rather than a new record
628 // type: `load()` already raises highestId for every id it reads and
629 // erasing an id that is not present is a no-op, so this needs no reader
630 // change and stays readable by an older build. Emitted only when the
631 // mark exceeds every surviving id -- writing "done" for an id that a
632 // "put" line above just restored would delete it on the next load.
633 uint64_t const maxSurviving = _items.empty() ? 0 : _items.rbegin()->first;
634 if (_nextId > maxSurviving) {
635 writeRecord(detail::FileQueueRecord{
636 .op = "done", .id = _nextId, .payload = {}, .idempotencyKey = {}, .attempts = 0});
637 }
638
639 syncFile(out, tmp);
640 // NOLINTNEXTLINE(cert-err33-c, cppcoreguidelines-owning-memory) — the data is already fsynced above
641 std::fclose(out);
642 guard.releaseHandle(); // closed here; the rename needs the handle gone on Windows
643 std::filesystem::rename(tmp, _path);
644 guard.commit(); // `tmp` no longer exists under that name
645 // The rename is a directory mutation, not a file-content one -- fsync
646 // on `out` above made the compacted *data* durable, but not the
647 // directory entry that now names it `_path` instead of the tmp name
648 // durable. Surfaced rather than swallowed, same as every other
649 // fsync failure in this class; safe to throw here, since compact()
650 // always runs before `_file` is opened -- nothing left dangling.
651 auto const dirSync = ::morph::core::classifyDirectorySync(_io.syncPath(_path.parent_path()));
652 if (dirSync == ::morph::core::DirectorySync::failed) {
653 throw std::runtime_error("FileOfflineQueue: failed to fsync directory after compacting " +
654 _path.string());
655 }
656 if (dirSync == ::morph::core::DirectorySync::unsupported) {
657 ::morph::log::logWarn(
658 "FileOfflineQueue: cannot fsync the directory containing {}; the queue's contents are still "
659 "fsynced, "
660 "but its directory entry is only as durable as this filesystem makes it",
661 _path.string());
662 }
663 }
664
665 std::filesystem::path _path;
666 ::morph::core::FileIoOps _io;
667 std::FILE* _file = nullptr;
668 // Set when a failed write could not be rolled back, so a partial record may
669 // still sit at the end of `_path`. Every later write on this object is
670 // refused, which is what keeps that partial record the trailing line -- the
671 // only position `load()` can heal it from. Cleared by construction, since
672 // the constructor's compact() rewrites the file without it.
673 bool _tornTail = false;
674 std::map<uint64_t, QueueItem> _items;
675 uint64_t _nextId{0};
676 };
677
678 std::optional<std::size_t> _maxDepth;
679 ::morph::exec::detail::OwnedState<State> _owned;
680};
681
682} // namespace morph::offline
Move-only handle representing the eventual result of an asynchronous operation.
A replay attempt count, as a type distinct from a queue item id.
uint64_t enqueue(std::string payload) override
Appends payload with no idempotency key. On the owner.
std::optional< std::size_t > maxDepth() const override
Returns the configured maximum depth, or std::nullopt if unbounded. Callable anywhere: fixed at const...
void markDone(uint64_t itemId) override
Tombstones itemId, on the owner. No-op if not found.
::morph::async::Completion< std::size_t > askSize(::morph::exec::IExecutor &replyExec) const override
Counts on the owner, answered on replyExec.
::morph::async::Completion< std::vector< QueueItem > > askDrain(::morph::exec::IExecutor &replyExec) const override
Snapshots on the owner, answered on replyExec.
void setAttempts(uint64_t itemId, Attempts attempts) override
Persists an updated attempt count for itemId, on the owner. No-op if not found.
::morph::async::Completion< uint64_t > askEnqueue(::morph::exec::IExecutor &replyExec, std::string payload, std::string idempotencyKey) override
Enqueues on the owner, answered on replyExec.
std::vector< QueueItem > drain() const override
Returns all pending items in ascending-id (enqueue) order. On the owner.
FileOfflineQueue(::morph::exec::IExecutor &owner, std::filesystem::path path, ::morph::core::FileIoOps ioOps={}, std::optional< std::size_t > maxDepth=std::nullopt)
Opens (or creates) the queue log at path, replaying and compacting whatever is already there; the que...
void setIdempotencyKey(uint64_t itemId, std::string idempotencyKey) override
Stamps an idempotency key onto an already-enqueued item, on the owner. No-op if itemId is not found....
std::size_t size() const override
Returns the number of pending items. On the owner.
uint64_t enqueue(std::string payload, std::string idempotencyKey) override
Appends payload carrying idempotencyKey. On the owner. A non-empty key already present on a pending i...
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...
std::function< std::FILE *(const std::string &path, const char *mode)> fopen
Opens path in mode mode. Mirrors std::fopen.
Abstract executor interface.
Definition executor.hpp:70
Thrown by FileOfflineQueue when a non-trailing line of its on-disk NDJSON is malformed (via detail::t...
Interface for durable storage of actions that could not be delivered.
virtual uint64_t enqueue(std::string payload)=0
Appends payload to the queue with no idempotency key.
virtual std::size_t size() const
Returns the number of pending items without removing them.
virtual std::vector< QueueItem > drain() const =0
Returns all pending items in enqueue order without removing them.