morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
outbox.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <cstddef>
5#include <functional>
6#include <memory>
7#include <span>
8#include <stdexcept>
9#include <vector>
10
11#include "../core/logger.hpp"
12#include "action_log.hpp"
13
14namespace morph::journal {
15
16/// @brief Thrown by `OutboxRelay::relay()` when `sink` is null.
17///
18/// A null `drainOutbox`/`markRelayed` already throws a catchable
19/// `std::bad_function_call` (invoking a null `std::function`); this makes a
20/// null `sink` consistent with that instead of a raw null-`shared_ptr`
21/// dereference, which is real undefined behavior and not portably catchable.
22struct NullSinkError : std::runtime_error {
23 using std::runtime_error::runtime_error;
24};
25
26/// @brief Outcome of one `OutboxRelay::relay()` call.
28 /// @brief Outbox rows drained, appended to `OutboxRelay::sink`, and marked
29 /// relayed in this call. Includes rows the sink silently deduped via
30 /// `LogEntry::idempotencyKey` — a row is still marked relayed exactly
31 /// once even when the sink treats its append as a no-op.
32 std::size_t relayed = 0;
33};
34
35/// @brief Moves committed-but-unrelayed rows from a model's own outbox table to
36/// a durable `IActionLog`, then marks them relayed in the model's own
37/// store.
38///
39/// Closes the "two independent writes" gap between a model's own transactional
40/// store and the action log: a store-backed model writes its business tables
41/// *and* an outbox row (shaped like `LogEntry`, including a stable
42/// `idempotencyKey`) in one local transaction — see
43/// `IModelHolder::setOutboxManaged` — and `OutboxRelay` is the separate,
44/// asynchronous step that moves that row into the real durable sink.
45///
46/// `drainOutbox`/`markRelayed` are injected callables against the model's own
47/// store, exactly as `morph::offline::ReconnectCoordinator::Deps` and
48/// `morph::offline::SyncWorker::ReplayFunction` inject their side effects —
49/// `morph::journal` never touches the model's database. Mirroring
50/// `ReconnectCoordinator::Deps`, a null member is logged (via
51/// `morph::log::logError`) at the start of every `relay()` call but is not
52/// rejected — invoking a null member still throws (`std::bad_function_call`) or
53/// crashes as usual.
54///
55/// @par Crash safety
56/// `relay()` is idempotent and safe to call repeatedly: it appends every row
57/// `drainOutbox()` currently reports to `sink`, flushes `sink`, and only then
58/// calls `markRelayed` with the whole batch. A crash between the sink append and
59/// `markRelayed` committing simply means the next `relay()` call sees the same
60/// row again from `drainOutbox()` — appending it again is a safe no-op as long
61/// as `sink` dedups by `LogEntry::idempotencyKey` (`InMemoryActionLog` and
62/// `FileActionLog` do this out of the box — see their class docs; `SessionLog`
63/// deliberately does not, since its contract is full fidelity with nothing
64/// dropped). This is at-least-once-plus-dedup, not a two-phase commit: `sink`
65/// and the model's own store are never committed as a single distributed
66/// transaction.
68 /// @brief Pulls committed-but-unrelayed rows from the model's own store.
69 /// Must be non-destructive: the same rows are re-drained until
70 /// `markRelayed` records them, including across process restarts.
71 std::function<std::vector<LogEntry>()> drainOutbox;
72
73 /// @brief Marks @p rows relayed in the model's own store (by identity or
74 /// `LogEntry::idempotencyKey`) so a later `drainOutbox()` call no
75 /// longer returns them.
76 std::function<void(std::span<const LogEntry>)> markRelayed;
77
78 /// @brief Durable audit sink rows are forwarded to.
79 std::shared_ptr<IActionLog> sink;
80
81 /// @brief Moves every row `drainOutbox()` currently reports to `sink`, then
82 /// marks the whole batch relayed via `markRelayed`.
83 ///
84 /// A no-op (returns `{.relayed = 0}` without touching `sink` or calling
85 /// `markRelayed`) if `drainOutbox()` returns no rows.
86 ///
87 /// `markRelayed` runs only after `sink->flush()` returns normally. An
88 /// `IActionLog` that cannot make the batch durable throws (see
89 /// `IActionLog::flush`), which propagates out of here *before* the rows are
90 /// marked — so they stay in the outbox and a later `relay()` retries them.
91 /// This is what keeps the relay at-least-once instead of at-most-once: were
92 /// the failure swallowed, the rows would be recorded as relayed while
93 /// nothing reached the sink, and nothing would ever surface the loss.
94 ///
95 /// Runs on `sink`'s owner (see `IActionLog::owner()`): `sink->flush()`
96 /// answers only there, which is what lets its failure reach this call.
97 ///
98 /// @return The number of rows relayed in this call.
99 /// @throws NullSinkError if `sink` is null and `drainOutbox()` reports at
100 /// least one row — thrown before `sink` is ever dereferenced, so
101 /// no row is lost or marked relayed. A null `sink` with nothing
102 /// to relay is still a no-op, exactly like an empty outbox with a
103 /// real `sink`: nothing would touch `sink` either way.
104 /// @throws std::exception propagated from `sink->append()` or `sink->flush()`;
105 /// the batch is left unmarked and therefore retryable.
107 logIfAnyDepNull();
108 auto rows = drainOutbox();
109 if (rows.empty()) {
110 return {};
111 }
112 if (!sink) {
113 throw NullSinkError("journal::OutboxRelay::relay: sink is null");
114 }
115 for (const auto& row : rows) {
116 sink->append(row);
117 }
118 sink->flush();
119 markRelayed(rows);
120 return OutboxRelayResult{.relayed = rows.size()};
121 }
122
123private:
124 // Logs any null member, mirroring ReconnectCoordinator::assertDepsNonNull —
125 // a diagnostic, not a rejection; construction/assignment of OutboxRelay
126 // itself is never intercepted (it is a plain aggregate), so the check runs
127 // at the start of every relay() call instead.
128 void logIfAnyDepNull() const {
129 if (!drainOutbox) {
130 ::morph::log::logError("[journal::OutboxRelay] null drainOutbox");
131 }
132 if (!markRelayed) {
133 ::morph::log::logError("[journal::OutboxRelay] null markRelayed");
134 }
135 if (!sink) {
136 ::morph::log::logError("[journal::OutboxRelay] null sink");
137 }
138 // This branch itself is unit-tested (test_outbox.cpp asserts the
139 // warning fires). relay() throws NullSinkError right after this call
140 // if sink is null and there is at least one row to relay -- a null
141 // drainOutbox/markRelayed still throws std::bad_function_call as
142 // usual, invoking a null std::function.
143 }
144};
145
146} // namespace morph::journal
Thrown by OutboxRelay::relay() when sink is null.
Definition outbox.hpp:22
Outcome of one OutboxRelay::relay() call.
Definition outbox.hpp:27
std::size_t relayed
Outbox rows drained, appended to OutboxRelay::sink, and marked relayed in this call....
Definition outbox.hpp:32
Moves committed-but-unrelayed rows from a model's own outbox table to a durable IActionLog,...
Definition outbox.hpp:67
OutboxRelayResult relay()
Moves every row drainOutbox() currently reports to sink, then marks the whole batch relayed via markR...
Definition outbox.hpp:106
std::function< std::vector< LogEntry >()> drainOutbox
Pulls committed-but-unrelayed rows from the model's own store. Must be non-destructive: the same rows...
Definition outbox.hpp:71
std::function< void(std::span< const LogEntry >)> markRelayed
Marks rows relayed in the model's own store (by identity or LogEntry::idempotencyKey) so a later drai...
Definition outbox.hpp:76
std::shared_ptr< IActionLog > sink
Durable audit sink rows are forwarded to.
Definition outbox.hpp:79