morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
replay_ledger.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <exception>
5#include <map>
6#include <memory>
7#include <optional>
8#include <string>
9#include <string_view>
10#include <utility>
11
12#include "../attributes.hpp"
13#include "../core/completion.hpp"
15#include "../core/executor.hpp"
16
17namespace morph::offline {
18
19/// @brief Interface for an op-id-keyed exactly-once replay ledger.
20///
21/// Answers one question — "has this operation id already been applied?" —
22/// for a host that must dedup a retried write against one it already
23/// committed. One framework seam in place of a near-identical table each host
24/// would otherwise hand-write. A host either stores a result and replays it
25/// verbatim on a hit ("response-replay"), or stores nothing and reports only
26/// that the op was seen ("skip-only", as `examples/bookmarks`' import path
27/// does). Both are the same mechanism with a different
28/// caller-side interpretation of the stored payload — see `lookup()`'s own
29/// doc comment — so there is exactly one interface here, not two.
30///
31/// @par Why the framework cannot supply the storage
32/// Every existing occurrence stores its ledger row in the *same database and
33/// the same transaction* as the write it guards, so the check-then-set commits
34/// atomically with the operation's effect — see `record()`'s doc comment.
35/// `include/morph` has no dependency on any SQL library, so it cannot open
36/// that connection or join that transaction. What it can supply is the
37/// *contract*: the shape every implementation must have, and a conformance
38/// suite (`tests/replay_ledger_conformance.hpp`) any implementation can be
39/// run against. The table itself stays app-side, per rung, exactly as before.
40///
41/// @par No base class for consumers
42/// A model holds (or is handed) an `IReplayLedger&`; it never derives from
43/// one. Every existing occurrence is a free function or a plain member on the
44/// model, not a base class — a design that required inheriting from this
45/// interface would be un-adoptable by every one of them.
46// NOLINTBEGIN(cppcoreguidelines-special-member-functions)
48 virtual ~IReplayLedger() = default;
49
50 /// @brief Reports whether @p opId within @p scope has already been decided.
51 ///
52 /// @par What a hit means is the caller's decision, not this interface's
53 /// The returned string is whatever `record()` stored alongside the id. A
54 /// response-replay consumer (a move or a money transfer whose retry must
55 /// see the original answer) stores its result's serialised form and, on a
56 /// hit, returns that string verbatim instead of re-running the operation. A
57 /// skip-only consumer (bookmarks' import path, or an at-most-once replay
58 /// of queued captures) stores an empty payload and, on a hit, reports "already applied,
59 /// nothing to return" without ever looking at the string. Both read
60 /// exactly the same `lookup()` — the value type reports the fact, and the
61 /// layer that calls this decides what a hit means.
62 ///
63 /// @par `scope`
64 /// An opaque caller-chosen partition — the three key shapes the seven
65 /// occurrences used (`(entity_id, op_id)`, `(owner_principal, op_id)`, a
66 /// bare `op_id`) are all this one string plus the id, with the bare-key
67 /// case simply passing an empty `scope`. Two calls with the same `opId`
68 /// but different `scope` never collide.
69 ///
70 /// @par Empty `opId`
71 /// Never reported as decided, in every conforming implementation — see
72 /// `record()`. An empty id is not an identity, and reporting one "already
73 /// applied" would make every unkeyed call after the first a silent no-op.
74 ///
75 /// @param scope Caller-chosen partition; empty is a valid, distinct scope.
76 /// @param opId The operation id to look up. Empty always reports
77 /// `std::nullopt`, regardless of what has been recorded.
78 /// @return The payload recorded with @p opId (possibly empty, for a
79 /// skip-only caller), or `std::nullopt` if it has not been
80 /// recorded — or @p opId is empty.
81 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
82 [[nodiscard]] std::optional<std::string> lookup(std::string_view scope, std::string_view opId) const {
83 if (opId.empty()) {
84 return std::nullopt;
85 }
86 return doLookup(scope, opId);
87 }
88
89 /// @brief Records that @p opId within @p scope has been decided, with
90 /// @p payload attached.
91 ///
92 /// @par Atomicity is the caller's responsibility, not this interface's
93 /// The framework cannot enforce this — `IReplayLedger` has no database
94 /// connection of its own — so it is stated here instead: a conforming
95 /// implementation MUST let its caller commit this call in the same
96 /// transaction as the write it guards. An implementation backed by a SQL
97 /// table does this by operating through the caller's own already-open
98 /// connection/transaction handle (passed to the implementation at
99 /// construction), never by opening a connection of its own. Recording
100 /// outside that transaction reintroduces exactly the defect this ledger
101 /// exists to prevent: a crash between the write and the record redelivers
102 /// the operation and it is re-applied.
103 ///
104 /// @par Idempotent: first-write-wins
105 /// Recording an @p opId within @p scope that is already decided is a
106 /// no-op — the existing payload is kept, `payload` here is discarded, and
107 /// no error is raised. This is the same contract
108 /// `morph::journal::IActionLog::append()`'s idempotency-key dedup already
109 /// ships (`journal/action_log.hpp`'s own doc comment: "if an entry with
110 /// the same key was already recorded, treat the call as a no-op"): a
111 /// redelivered retry that reaches `record()` a second time (e.g. because
112 /// a reply was lost after the first commit) must not overwrite a payload
113 /// a caller may already have replayed to someone else.
114 ///
115 /// @par Empty `opId`
116 /// A no-op — there is no identity to record against, so `lookup()` for an
117 /// empty id must keep reporting `std::nullopt` regardless of how many
118 /// times `record()` is called with one.
119 ///
120 /// @param scope Caller-chosen partition; must match the `scope` a later
121 /// `lookup()` uses to find this entry.
122 /// @param opId The operation id being recorded. A no-op if empty.
123 /// @param payload Opaque payload to associate with @p opId; empty for a
124 /// skip-only caller that only cares whether a hit occurred.
125 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
126 void record(std::string_view scope, std::string_view opId, std::string payload) {
127 if (opId.empty()) {
128 return;
129 }
130 doRecord(scope, opId, std::move(payload));
131 }
132
133 /// @brief Reports whether @p opId within @p scope has already been
134 /// decided, looked up on the ledger's owner and delivered on
135 /// @p replyExec.
136 ///
137 /// The form of `lookup()` for a caller that is not on the ledger's owner.
138 /// A lookup answered elsewhere cannot be atomic with the `record()` that
139 /// follows it; a caller that needs check-then-set atomicity runs both on
140 /// the owner.
141 /// @param replyExec Where the answer is delivered, and where the caller
142 /// attaches to it. Borrowed: it must outlive the completion.
143 /// @param scope Caller-chosen partition; empty is a valid, distinct scope.
144 /// @param opId The operation id to look up. Empty always answers
145 /// `std::nullopt`.
146 /// @return What `lookup(scope, opId)` would have returned.
147 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
149 ::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND, std::string_view scope, std::string_view opId) const {
150 if (opId.empty()) {
151 auto settleable = ::morph::async::Completion<std::optional<std::string>>::makeSettleable(&replyExec);
152 settleable.second.resolve(std::nullopt);
153 return std::move(settleable.first);
154 }
155 return askLookup(replyExec, std::string{scope}, std::string{opId});
156 }
157
158protected:
159 /// @brief What `lookup(replyExec, scope, opId)` asks. The default looks
160 /// up where it is called, for an implementation with no owner; one
161 /// with an owner looks up there.
162 /// @param replyExec Where the answer is delivered.
163 /// @param scope Caller-chosen partition.
164 /// @param opId The operation id to look up; never empty here.
165 /// @return The payload recorded with @p opId, or `std::nullopt`.
166 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
167 [[nodiscard]] virtual ::morph::async::Completion<std::optional<std::string>> askLookup(
168 ::morph::exec::IExecutor& replyExec,
169 // NOLINTNEXTLINE(performance-unnecessary-value-param): the base only reads them; overrides take them by value and move them.
170 std::string scope, std::string opId) const {
171 auto settleable = ::morph::async::Completion<std::optional<std::string>>::makeSettleable(&replyExec);
172 try {
173 settleable.second.resolve(doLookup(scope, opId));
174 } catch (...) {
175 settleable.second.reject(std::current_exception());
176 }
177 return std::move(settleable.first);
178 }
179
180 /// @brief Storage hook for `lookup()`.
181 /// @param scope Caller-chosen partition, forwarded verbatim from `lookup()`.
182 /// @param opId The operation id to look up; never empty here -- the
183 /// public wrapper already handled that case.
184 /// @return The payload recorded with @p opId, or `std::nullopt` if none.
185 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
186 [[nodiscard]] virtual std::optional<std::string> doLookup(std::string_view scope, std::string_view opId) const = 0;
187
188 /// @brief Storage hook for `record()`. Must be a no-op if @p scope/@p opId
189 /// is already recorded (first-write-wins).
190 /// @param scope Caller-chosen partition, forwarded verbatim from `record()`.
191 /// @param opId The operation id being recorded; never empty here -- the
192 /// public wrapper already handled that case.
193 /// @param payload Opaque payload to associate with @p opId.
194 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
195 virtual void doRecord(std::string_view scope, std::string_view opId, std::string payload) = 0;
196};
197// NOLINTEND(cppcoreguidelines-special-member-functions)
198
199// ── In-memory implementation ──────────────────────────────────────────────────
200
201/// @brief In-memory implementation of `IReplayLedger`, owned by one executor.
202///
203/// Suitable for testing and for a host with no durability requirement.
204/// Mirrors `morph::offline::InMemoryOfflineQueue`'s shape. Not durable across
205/// a process restart, and — since it is not backed by any real transaction —
206/// not a stand-in for `record()`'s atomicity requirement: a real
207/// implementation must be backed by the same store and transaction as the
208/// write it guards.
209///
210/// @par One owner
211/// The entries belong to the executor given at construction, which must run
212/// one task at a time — the executor the guarded writes run on, so a
213/// `lookup()` and the `record()` after it are one check-then-set. `lookup()`
214/// answers only there; `record()` runs there, posted from anywhere else; a
215/// caller elsewhere looks up through `lookup(replyExec, scope, opId)`.
217public:
218 /// @brief Constructs an empty ledger belonging to @p owner.
219 /// @param owner The executor every access runs on; must run one task at a
220 /// time. Borrowed: it must outlive this ledger and run what it posts.
221 explicit InMemoryReplayLedger(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND)
222 : _owned{owner, std::make_shared<State>()} {}
223
224protected:
225 /// @brief Looks up @p opId within @p scope in the in-memory map. On the owner.
226 ///
227 /// @par The two `std::string` constructions below are deliberate
228 /// They materialise the key purely to probe an ordered map that could
229 /// take a transparent comparator instead. That was profiled rather than
230 /// fixed. Measured with clang 22 `-O2`, a counting `operator new`, 2e6
231 /// iterations per row:
232 ///
233 /// @verbatim
234 /// -- allocations per lookup() --
235 /// scope=bookmarks ( 9) id=CreateSwimlane (14) : 0.00
236 /// scope=bookmarks ( 9) id=CreateSwimlane-0001 (19) : 1.00
237 /// scope=bookmarks-import-scope (22) id=CreateSwimlane-0001 (19) : 2.00
238 ///
239 /// -- ns per lookup(), single thread --
240 /// both inside libstdc++'s 15-char SSO buffer : 28.2 ns
241 /// both past it : 31.6 ns
242 /// @endverbatim
243 ///
244 /// So the cost is real and id-length-dependent. What parks it is the call
245 /// census, not the size: `InMemoryReplayLedger` is constructed in **one**
246 /// file in this tree, `tests/test_replay_ledger.cpp`, and in no shipping
247 /// code at all. The only production `IReplayLedger::lookup()` call
248 /// (`examples/bookmarks/src/models/bookmark_model.cpp:737`, once per
249 /// `ImportBookmarks`) runs against `BookmarksReplayLedger`, whose own
250 /// `doLookup` is an ODBC round-trip -- next to which 3 ns is not
251 /// measurable.
252 ///
253 /// It becomes worth fixing the moment a per-request caller of *this* class
254 /// appears; the fix is then a transparent comparator on the map,
255 /// `std::map`'s `is_transparent` flavour rather than `core/registry.hpp`'s
256 /// hash-and-equality pair (which serves an `unordered_map` and does not
257 /// apply here).
258 ///
259 /// @param scope Caller-chosen partition.
260 /// @param opId The operation id to look up; never empty here.
261 /// @return The payload recorded with @p opId, or `std::nullopt` if none.
262 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
263 [[nodiscard]] std::optional<std::string> doLookup(std::string_view scope, std::string_view opId) const override {
264 return find(_owned.read("InMemoryReplayLedger::lookup"), scope, opId);
265 }
266
267 /// @brief Records @p opId within @p scope in the in-memory map, unless
268 /// already present (first-write-wins). On the owner; posted there
269 /// from anywhere else.
270 /// @param scope Caller-chosen partition.
271 /// @param opId The operation id being recorded; never empty here.
272 /// @param payload Opaque payload to associate with @p opId.
273 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
274 void doRecord(std::string_view scope, std::string_view opId, std::string payload) override {
275 _owned.apply("InMemoryReplayLedger::record", [key = Key{std::string{scope}, std::string{opId}},
276 payload = std::move(payload)](State& state) mutable {
277 // emplace (not insert_or_assign): first-write-wins, per record()'s
278 // own doc comment -- a second record() for the same (scope, opId)
279 // must not overwrite a payload a caller may already have replayed.
280 state.entries.emplace(std::move(key), std::move(payload));
281 });
282 }
283
284 /// @brief Looks up on the owner, answered on @p replyExec.
285 /// @param replyExec Where the answer is delivered.
286 /// @param scope Caller-chosen partition.
287 /// @param opId The operation id to look up; never empty here.
288 /// @return The payload recorded with @p opId, or `std::nullopt`.
289 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
291 std::string scope,
292 std::string opId) const override {
293 return _owned.ask<std::optional<std::string>>(
294 "InMemoryReplayLedger::lookup", replyExec,
295 [scope = std::move(scope), opId = std::move(opId)](State& state) { return find(state, scope, opId); });
296 }
297
298private:
299 using Key = std::pair<std::string, std::string>; // (scope, opId)
300
301 /// Everything the ledger holds; touched only on the owner.
302 struct State {
303 std::map<Key, std::string> entries;
304 };
305
306 /// The payload recorded under (@p scope, @p opId) in @p state, if any.
307 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- scope/opId, not interchangeable
308 static std::optional<std::string> find(const State& state, std::string_view scope, std::string_view opId) {
309 auto const iter = state.entries.find(Key{std::string{scope}, std::string{opId}});
310 if (iter == state.entries.end()) {
311 return std::nullopt;
312 }
313 return iter->second;
314 }
315
316 ::morph::exec::detail::OwnedState<State> _owned;
317};
318
319} // namespace morph::offline
Move-only handle representing the eventual result of an asynchronous operation.
InMemoryReplayLedger(::morph::exec::IExecutor &owner)
Constructs an empty ledger belonging to owner.
void doRecord(std::string_view scope, std::string_view opId, std::string payload) override
Records opId within scope in the in-memory map, unless already present (first-write-wins)....
std::optional< std::string > doLookup(std::string_view scope, std::string_view opId) const override
Looks up opId within scope in the in-memory map. On the owner.
::morph::async::Completion< std::optional< std::string > > askLookup(::morph::exec::IExecutor &replyExec, std::string scope, std::string opId) const override
Looks up on the owner, answered on replyExec.
OwnedState: a component's state, and the one executor it is touched on.
Abstract executor interface.
Definition executor.hpp:70
Interface for an op-id-keyed exactly-once replay ledger.
::morph::async::Completion< std::optional< std::string > > lookup(::morph::exec::IExecutor &replyExec, std::string_view scope, std::string_view opId) const
Reports whether opId within scope has already been decided, looked up on the ledger's owner and deliv...
virtual ::morph::async::Completion< std::optional< std::string > > askLookup(::morph::exec::IExecutor &replyExec, std::string scope, std::string opId) const
What lookup(replyExec, scope, opId) asks. The default looks up where it is called,...
virtual void doRecord(std::string_view scope, std::string_view opId, std::string payload)=0
Storage hook for record(). Must be a no-op if scope/opId is already recorded (first-write-wins).
virtual std::optional< std::string > doLookup(std::string_view scope, std::string_view opId) const =0
Storage hook for lookup().
std::optional< std::string > lookup(std::string_view scope, std::string_view opId) const
Reports whether opId within scope has already been decided.
void record(std::string_view scope, std::string_view opId, std::string payload)
Records that opId within scope has been decided, with payload attached.