morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
action_log.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <cstdint>
5#include <exception>
6#include <memory>
7#include <mutex>
8#include <stdexcept>
9#include <string>
10#include <string_view>
11#include <unordered_set>
12#include <utility>
13#include <vector>
14
15#include "../attributes.hpp"
16#include "../core/completion.hpp"
18#include "../core/executor.hpp"
19
20namespace morph::journal {
21
22/// @brief Current line-format version stamped on every newly-written
23/// `LogEntry` (`LogEntry::v`'s default).
24///
25/// Bumped only on a **breaking** change to `LogEntry`'s on-disk/on-wire shape;
26/// an additive key (tolerated by `fromJson`'s lenient decode) does not bump
27/// it. A reader refuses to decode a line whose `v` exceeds this constant —
28/// see `fromJson`.
29inline constexpr std::uint32_t kLogFormatVersion = 1;
30
31/// @brief Discriminates a recorded `LogEntry` as a successful execution or one
32/// rejected by the validator / thrown by `Model::execute`.
33///
34/// A failed entry has an empty `result` (there was none) and a non-empty
35/// `error`; a succeeded entry is the reverse. Serialises as the string
36/// `"Succeeded"`/`"Failed"` (see the `glz::meta` specialisation below) so the
37/// audit trail stays human-readable without cross-referencing the enum.
38enum class Outcome : std::uint8_t { Succeeded, Failed };
39
40/// @brief One recorded execution of an action against a model instance.
41///
42/// Normally produced automatically by
43/// `morph::model::detail::IModelHolder::recordIfAttached`. Application code may
44/// also construct and append one directly — that is what an outbox row is, and
45/// what `causalParentId` is set by; see `morph::journal::OutboxRelay`.
46struct LogEntry {
47 /// @brief Monotonic order assigned by the sink on `append()`. Callers pass `0`.
48 uint64_t seq = 0;
49
50 /// @brief String type-id of the model the action ran against (`ModelTraits<M>::typeId()`).
51 std::string modelType;
52
53 /// @brief Stable identity of the model instance (e.g. an account id), stamped
54 /// from the value passed to `attachActionLog()`. Empty if none was set.
55 std::string entityKey;
56
57 /// @brief String type-id of the executed action (`ActionTraits<A>::typeId()`).
58 std::string actionType;
59
60 /// @brief JSON-encoded request (`ActionTraits<A>::toJson`).
61 std::string payload;
62
63 /// @brief Structural fingerprint of the action payload's shape at the
64 /// moment `payload` was encoded (`morph::model::payloadFingerprint`).
65 ///
66 /// `v` above versions the *line format*; this versions the *payload*. Without
67 /// it, replaying an entry written by an older build against a renamed field
68 /// is indistinguishable from replaying an entry that genuinely omitted the
69 /// field: the lenient decode drops the unknown key, default-constructs the
70 /// new one, and reconstruction reports a state that was never recorded. With
71 /// it, `replay()` compares this against the fingerprint the *current* build
72 /// computes for the same action and refuses rather than guessing — see
73 /// `journal::replay()` and `docs/spec/journal/journal.md`, "Payload schema
74 /// fingerprint".
75 ///
76 /// Stamped automatically by the two sites that execute an action
77 /// (`ActionDispatcher::registerAction`'s runner and `Bridge::executeVia`'s
78 /// local op). **Empty is a meaningful value**: it marks an *unstamped*
79 /// entry — one written before this field existed, or appended directly by
80 /// application code — whose payload shape is unknown and therefore
81 /// unverifiable. See `UnstampedPayloadPolicy` for what replay does with one.
82 std::string schema;
83
84 /// @brief JSON-encoded result (`ActionTraits<A>::resultToJson`), captured after
85 /// successful execution. Empty when `outcome == Outcome::Failed`.
86 std::string result;
87
88 /// @brief Whether this execution succeeded or was rejected/threw. Defaults to
89 /// `Succeeded` so a pre-existing on-disk entry (written before this
90 /// field existed) decodes unchanged under the lenient reader — an
91 /// absent key is indistinguishable from an explicit `Succeeded`.
92 Outcome outcome = Outcome::Succeeded;
93
94 /// @brief `std::exception::what()` from the exception that rejected the
95 /// action. Empty unless `outcome == Outcome::Failed`.
96 std::string error;
97
98 /// @brief Auth principal from `morph::session::current()`, if any. Empty if unset.
99 std::string principal;
100
101 /// @brief Wall-clock time of execution, milliseconds since the Unix epoch.
102 int64_t timestampMs = 0;
103
104 /// @brief Optional dedup token for outbox-relayed entries. Empty by default;
105 /// ordinary auto-appended entries (from `ActionDispatcher`'s runner or
106 /// `Bridge::executeVia`'s local op) never set it. Mirrors
107 /// `morph::offline::QueueItem::idempotencyKey`'s exact contract: opaque,
108 /// stored verbatim, stable across restarts for one logical outbox row.
109 /// See `journal::OutboxRelay` (`outbox.hpp`) for how it's used.
110 std::string idempotencyKey;
111
112 /// @brief Line-format version this entry was written at.
113 ///
114 /// Defaults to `kLogFormatVersion`, so every freshly-constructed entry
115 /// already carries the current version with no separate stamping step. A
116 /// legacy line (written before this field existed) has no `v` key; under
117 /// `fromJson`'s lenient decode that is just an absent key, so it decodes
118 /// with this same default — i.e. legacy data reads as `v == 1`, which is
119 /// correct: v1 is today's shape, `kLogFormatVersion` merely names it.
120 std::uint32_t v = kLogFormatVersion;
121
122 /// @brief Identity of the "trigger" entry that caused this entry to be
123 /// recorded, or empty (the sentinel) if this entry was not caused
124 /// by another one.
125 ///
126 /// Set by application code that journals a cascaded mutation — e.g. an
127 /// automation rule that reacts to one recorded action by executing a
128 /// further one — to the triggering entry's own stable identity, so
129 /// `replay()` and an activity view can both recover "what caused this."
130 /// Empty by default: an ordinary, non-cascaded entry never sets it.
131 ///
132 /// @warning **Must not be a `LogEntry::seq` value.** `seq` is sink-local
133 /// and re-stamped by every sink's `append()` (and again by
134 /// `SessionLog::checkpoint()` when forwarding) — it is not a stable,
135 /// cross-sink or cross-restart identifier (see the Invariants section of
136 /// `docs/spec/journal/journal.md`). A `causalParentId` wired to a raw
137 /// `seq` would stop matching anything the moment the trigger entry is
138 /// forwarded to another sink or the process restarts. Application code
139 /// must instead mint its own opaque/UUID-style identity for the trigger
140 /// entry at the point it is created, independent of whatever `seq` any
141 /// sink later assigns it, and reuse that same identity as every cascaded
142 /// entry's `causalParentId`.
143 std::string causalParentId;
144};
145
146/// @brief Thrown by `toJson`/`fromJson` when `LogEntry` (de)serialisation fails.
147///
148/// Declared here rather than beside the codec in `action_log_json.hpp`: a
149/// caller catching it needs only `<stdexcept>`, and making that catch drag in
150/// glaze would put the compile surcharge back on exactly the consumers the
151/// split exists to spare.
152struct SerializationError : std::runtime_error {
153 using std::runtime_error::runtime_error;
154};
155
156/// @brief Interface for durable storage of executed-action entries.
157///
158/// Entries are never removed by the framework — this is a permanent, append-only
159/// record, unlike `morph::offline::IOfflineQueue` (whose `markDone()` deletes
160/// items once retried successfully). Implementations range from in-memory
161/// (`InMemoryActionLog`) to file, SQL, or network-backed stores supplied by the
162/// host application.
163///
164/// @par Idempotency-key dedup (optional)
165/// An implementation MAY treat a non-empty `LogEntry::idempotencyKey` as a dedup
166/// key on `append()`: if an entry with the same key was already recorded, treat
167/// the call as a no-op. This is not required by the interface, but
168/// `InMemoryActionLog` and `FileActionLog` both do it, which is what makes them
169/// safe choices for `journal::OutboxRelay::sink` (see `outbox.hpp`) — a
170/// re-relayed row after a crash between `append()` and marking it relayed lands
171/// here twice but is stored once. An entry with an empty `idempotencyKey` is
172/// never deduped.
173// NOLINTBEGIN(cppcoreguidelines-special-member-functions)
175 virtual ~IActionLog() = default;
176
177 /// @brief Appends @p entry. Implementations assign `entry.seq`.
178 ///
179 /// An implementation that can fail to record the entry must throw. Returning
180 /// normally is the sink's promise that the entry is recorded (or, for a
181 /// buffering sink, that it will be by the next successful `flush()`); see
182 /// `flush()` for why silence is not an option here.
183 ///
184 /// @param entry Entry to append.
185 /// @throws std::exception (implementation-defined) if the entry could not be recorded.
186 virtual void append(LogEntry entry) = 0;
187
188 /// @brief Pushes any buffered entries to the durable backend. No-op for sinks
189 /// with nothing to buffer (e.g. `InMemoryActionLog`).
190 ///
191 /// **Must throw if the data did not reach the backend.** The return type is
192 /// `void`, so throwing is the only channel an implementation has, and
193 /// callers rely on it: `OutboxRelay::relay()` calls `markRelayed()` directly
194 /// after this, and a silently-failed flush would mark rows relayed in the
195 /// model's own store while nothing was durably written — dropping them from
196 /// the outbox *and* from the log, with no error anywhere. An implementation
197 /// that cannot fail (nothing to buffer) simply never throws.
198 ///
199 /// @throws std::exception (implementation-defined) if buffered entries could
200 /// not be made durable.
201 virtual void flush() = 0;
202
203 /// @brief Returns recorded entries in append order.
204 ///
205 /// An implementation with an owner answers only there (see `owner()`); a
206 /// caller elsewhere asks through `entries(replyExec, entityKey)`.
207 /// @param entityKey If non-empty, restricts the result to that entity's entries.
208 /// @return Matching entries, in append order.
209 [[nodiscard]] virtual std::vector<LogEntry> entries(std::string_view entityKey = {}) const = 0;
210
211 /// @brief The executor this log's state belongs to, or null for an
212 /// implementation that has none.
213 ///
214 /// Every log morph ships has one: its `append()` runs there (posted from
215 /// anywhere else), and its `entries()`/`flush()` answer only there. What a
216 /// caller on a model's strand names as the reply executor of
217 /// `entries(replyExec, …)` or `flush(replyExec)`.
218 /// @return The owner, or `nullptr`.
219 [[nodiscard]] virtual ::morph::exec::IExecutor* owner() const noexcept { return nullptr; }
220
221 /// @brief Returns recorded entries in append order, read on the log's
222 /// owner and delivered on @p replyExec.
223 ///
224 /// The form for a caller that is not on the owner: a model's strand, an
225 /// application thread the log does not belong to. The read is ordered
226 /// after every `append()` posted to the owner before it.
227 /// @param replyExec Where the answer is delivered, and where the caller
228 /// attaches to it. Borrowed: it must outlive the completion.
229 /// @param entityKey If non-empty, restricts the result to that entity's entries.
230 /// @return The matching entries, in append order.
232 ::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND, std::string entityKey = {}) const {
233 return askEntries(replyExec, std::move(entityKey));
234 }
235
236 /// @brief Pushes buffered entries to the durable backend on the log's
237 /// owner; the answer is delivered on @p replyExec.
238 ///
239 /// The form of `flush()` for a caller that is not on the owner. It is
240 /// ordered after every `append()` posted to the owner before it.
241 /// @param replyExec Where the answer is delivered. Borrowed: it must
242 /// outlive the completion.
243 /// @return `true` once the entries are durable; rejected with the error
244 /// `flush()` would have thrown.
245 [[nodiscard]] ::morph::async::Completion<bool> flush(::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND) {
246 return askFlush(replyExec);
247 }
248
249protected:
250 /// @brief What `entries(replyExec, entityKey)` asks. The default reads
251 /// `entries(entityKey)` where it is called, for an implementation
252 /// with no owner; one with an owner reads there.
253 /// @param replyExec Where the answer is delivered.
254 /// @param entityKey If non-empty, restricts the result to that entity's entries.
255 /// @return The matching entries.
256 [[nodiscard]] virtual ::morph::async::Completion<std::vector<LogEntry>> askEntries(
257 ::morph::exec::IExecutor& replyExec,
258 // NOLINTNEXTLINE(performance-unnecessary-value-param): the base only reads it; overrides take it by value and move it.
259 std::string entityKey) const {
260 auto settleable = ::morph::async::Completion<std::vector<LogEntry>>::makeSettleable(&replyExec);
261 try {
262 settleable.second.resolve(entries(entityKey));
263 } catch (...) {
264 settleable.second.reject(std::current_exception());
265 }
266 return std::move(settleable.first);
267 }
268
269 /// @brief What `flush(replyExec)` asks. The default flushes where it is
270 /// called, for an implementation with no owner; one with an owner
271 /// flushes there.
272 /// @param replyExec Where the answer is delivered.
273 /// @return `true` once durable, or the flush's error.
274 [[nodiscard]] virtual ::morph::async::Completion<bool> askFlush(::morph::exec::IExecutor& replyExec) {
275 auto settleable = ::morph::async::Completion<bool>::makeSettleable(&replyExec);
276 try {
277 flush();
278 settleable.second.resolve(true);
279 } catch (...) {
280 settleable.second.reject(std::current_exception());
281 }
282 return std::move(settleable.first);
283 }
284};
285// NOLINTEND(cppcoreguidelines-special-member-functions)
286
287namespace detail {
288
289/// @brief The entries of @p all that belong to @p entityKey, or all of them
290/// when it is empty.
291/// @param all Entries in append order.
292/// @param entityKey Entity to keep, or empty for every entity.
293/// @return The matching entries, in append order.
294[[nodiscard]] inline std::vector<LogEntry> entriesFor(const std::vector<LogEntry>& all, std::string_view entityKey) {
295 if (entityKey.empty()) {
296 return all;
297 }
298 std::vector<LogEntry> out;
299 for (const auto& entry : all) {
300 if (entry.entityKey == entityKey) {
301 out.push_back(entry);
302 }
303 }
304 return out;
305}
306
307} // namespace detail
308
309/// @brief In-memory implementation of `IActionLog`, owned by one executor.
310///
311/// Suitable for testing and for applications that do not need cross-process
312/// durability. Mirrors `morph::offline::InMemoryOfflineQueue`'s shape. Dedups
313/// `append()` on a non-empty `LogEntry::idempotencyKey` — see `IActionLog`'s
314/// class docs.
315///
316/// @par One owner
317/// The entries belong to the executor given at construction, which must run
318/// one task at a time. `append()` runs there: at once when called on it,
319/// posted from anywhere else (a model's strand). `entries()` answers only on
320/// it; a caller elsewhere uses `entries(replyExec, entityKey)`. See
321/// `docs/spec/journal/journal.md`, "One owner".
323public:
325 using IActionLog::flush;
326
327 /// @brief Constructs an empty log belonging to @p owner.
328 /// @param owner The executor every access runs on; must run one task at a
329 /// time. Borrowed: it must outlive this log and run what it posts.
330 explicit InMemoryActionLog(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND)
331 : _owned{owner, std::make_shared<State>()} {}
332
333 /// @brief Appends @p entry, assigning a monotonically increasing `seq`, on
334 /// the owner.
335 /// @param entry Entry to append; `seq` is overwritten regardless of the input value.
336 void append(LogEntry entry) override {
337 _owned.apply("InMemoryActionLog::append", [entry = std::move(entry)](State& state) mutable {
338 if (!entry.idempotencyKey.empty() && !state.seenIdempotencyKeys.insert(entry.idempotencyKey).second) {
339 return; // already recorded once; a re-relayed duplicate is a safe no-op
340 }
341 entry.seq = ++state.nextSeq;
342 state.entries.push_back(std::move(entry));
343 });
344 }
345
346 /// @brief No-op — there is no external backend to flush to. Callable anywhere.
347 void flush() override {}
348
349 /// @brief Returns a snapshot of matching entries in append order. On the owner.
350 /// @param entityKey If non-empty, restricts the result to that entity's entries.
351 /// @return Matching entries, in append order.
352 [[nodiscard]] std::vector<LogEntry> entries(std::string_view entityKey = {}) const override {
353 return detail::entriesFor(_owned.read("InMemoryActionLog::entries").entries, entityKey);
354 }
355
356 /// @brief The executor given at construction.
357 /// @return The owner.
358 [[nodiscard]] ::morph::exec::IExecutor* owner() const noexcept override { return &_owned.owner(); }
359
360protected:
361 /// @brief Reads the entries on the owner, delivered on @p replyExec.
362 /// @param replyExec Where the answer is delivered.
363 /// @param entityKey If non-empty, restricts the result to that entity's entries.
364 /// @return The matching entries.
366 std::string entityKey) const override {
367 return _owned.ask<std::vector<LogEntry>>(
368 "InMemoryActionLog::entries", replyExec,
369 [entityKey = std::move(entityKey)](State& state) { return detail::entriesFor(state.entries, entityKey); });
370 }
371
372private:
373 /// Everything the log holds; touched only on the owner.
374 struct State {
375 std::vector<LogEntry> entries;
376 uint64_t nextSeq{0};
377 std::unordered_set<std::string> seenIdempotencyKeys;
378 };
379
380 ::morph::exec::detail::OwnedState<State> _owned;
381};
382
383namespace detail {
384
385/// @brief Process-wide slot holding the default action log, plus the mutex
386/// guarding it. A function-local static, not a namespace-scope global,
387/// so it's safe regardless of translation-unit init order.
388inline std::pair<std::mutex, std::shared_ptr<IActionLog>>& defaultActionLogState() {
389 static std::pair<std::mutex, std::shared_ptr<IActionLog>> state;
390 return state;
391}
392
393} // namespace detail
394
395/// @brief Installs @p log as the process-wide default action log.
396///
397/// Every model instance created via `morph::model::detail::ModelFactory::create<Model>()`
398/// — which is every model registered the ordinary way, whether the active
399/// backend ends up being local or remote — automatically gets @p log attached
400/// (with an empty `entityKey`) from that point on. Call this once at startup;
401/// no per-model or per-handler wiring is needed for the common case.
402///
403/// Application code that needs a specific instance identity (e.g. per-account
404/// auditing) can still call `IModelHolder::attachActionLog` explicitly on that
405/// instance afterward — an explicit call always overrides whatever the
406/// default attached. Thread-safe.
407///
408/// @param log Sink to attach automatically, or `nullptr` to stop auto-attaching
409/// (existing instances keep whatever they already have).
410inline void setActionLog(std::shared_ptr<IActionLog> log) {
411 auto& [mtx, slot] = detail::defaultActionLogState();
412 std::scoped_lock const lock{mtx};
413 slot = std::move(log);
414}
415
416/// @brief Returns the currently installed default action log, or `nullptr`
417/// if none has been set. Thread-safe.
418[[nodiscard]] inline std::shared_ptr<IActionLog> defaultActionLog() {
419 auto& [mtx, slot] = detail::defaultActionLogState();
420 std::scoped_lock const lock{mtx};
421 return slot;
422}
423
424/// @brief RAII helper that installs a default action log for its lifetime and
425/// restores the previous one on destruction.
426///
427/// Mirrors `morph::log::ScopedLoggerOverride`. Intended for tests (so one test
428/// case's sink never leaks into the next) and for applications that need to
429/// temporarily redirect auto-attached logging within a scope.
430///
431/// @code
432/// {
433/// morph::journal::ScopedActionLog guard{std::make_shared<morph::journal::InMemoryActionLog>(owner)};
434/// // ... models created in this scope auto-attach guard's log ...
435/// } // previous default restored here
436/// @endcode
438public:
439 /// @brief Installs @p log as the default, saving whatever was there before.
440 /// @param log New default for the lifetime of this object.
441 explicit ScopedActionLog(std::shared_ptr<IActionLog> log) : _previous{defaultActionLog()} {
442 setActionLog(std::move(log));
443 }
444
445 /// @brief Restores the saved default.
446 ~ScopedActionLog() { setActionLog(std::move(_previous)); }
447
448 ScopedActionLog(const ScopedActionLog&) = delete;
449 ScopedActionLog& operator=(const ScopedActionLog&) = delete;
451 ScopedActionLog& operator=(ScopedActionLog&&) = delete;
452
453private:
454 std::shared_ptr<IActionLog> _previous;
455};
456
457} // namespace morph::journal
Move-only handle representing the eventual result of an asynchronous operation.
static std::pair< Completion< T >, Promise > makeSettleable(::morph::exec::IExecutor *execPtr)
Constructs a Completion<T>/Promise<T> pair sharing one settleable state.
void flush() override
No-op — there is no external backend to flush to. Callable anywhere.
void append(LogEntry entry) override
Appends entry, assigning a monotonically increasing seq, on the owner.
::morph::exec::IExecutor * owner() const noexcept override
The executor given at construction.
std::vector< LogEntry > entries(std::string_view entityKey={}) const override
Returns a snapshot of matching entries in append order. On the owner.
InMemoryActionLog(::morph::exec::IExecutor &owner)
Constructs an empty log belonging to owner.
::morph::async::Completion< std::vector< LogEntry > > askEntries(::morph::exec::IExecutor &replyExec, std::string entityKey) const override
Reads the entries on the owner, delivered on replyExec.
RAII helper that installs a default action log for its lifetime and restores the previous one on dest...
ScopedActionLog(std::shared_ptr< IActionLog > log)
Installs log as the default, saving whatever was there before.
~ScopedActionLog()
Restores the saved default.
OwnedState: a component's state, and the one executor it is touched on.
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....
::morph::async::Completion< std::vector< LogEntry > > entries(::morph::exec::IExecutor &replyExec, std::string entityKey={}) const
Returns recorded entries in append order, read on the log's owner and delivered on replyExec.
virtual ::morph::async::Completion< bool > askFlush(::morph::exec::IExecutor &replyExec)
What flush(replyExec) asks. The default flushes where it is called, for an implementation with no own...
::morph::async::Completion< bool > flush(::morph::exec::IExecutor &replyExec)
Pushes buffered entries to the durable backend on the log's owner; the answer is delivered on replyEx...
virtual ::morph::exec::IExecutor * owner() const noexcept
The executor this log's state belongs to, or null for an implementation that has none.
virtual std::vector< LogEntry > entries(std::string_view entityKey={}) const =0
Returns recorded entries in append order.
virtual void append(LogEntry entry)=0
Appends entry. Implementations assign entry.seq.
virtual ::morph::async::Completion< std::vector< LogEntry > > askEntries(::morph::exec::IExecutor &replyExec, std::string entityKey) const
What entries(replyExec, entityKey) asks. The default reads entries(entityKey) where it is called,...
One recorded execution of an action against a model instance.
std::string principal
Auth principal from morph::session::current(), if any. Empty if unset.
std::string entityKey
Stable identity of the model instance (e.g. an account id), stamped from the value passed to attachAc...
std::string payload
JSON-encoded request (ActionTraits<A>::toJson).
std::string modelType
String type-id of the model the action ran against (ModelTraits<M>::typeId()).
std::string causalParentId
Identity of the "trigger" entry that caused this entry to be recorded, or empty (the sentinel) if thi...
std::string idempotencyKey
Optional dedup token for outbox-relayed entries. Empty by default; ordinary auto-appended entries (fr...
std::string schema
Structural fingerprint of the action payload's shape at the moment payload was encoded (morph::model:...
std::string error
std::exception::what() from the exception that rejected the action. Empty unless outcome == Outcome::...
std::string actionType
String type-id of the executed action (ActionTraits<A>::typeId()).
Outcome outcome
Whether this execution succeeded or was rejected/threw. Defaults to Succeeded so a pre-existing on-di...
std::uint32_t v
Line-format version this entry was written at.
int64_t timestampMs
Wall-clock time of execution, milliseconds since the Unix epoch.
std::string result
JSON-encoded result (ActionTraits<A>::resultToJson), captured after successful execution....
uint64_t seq
Monotonic order assigned by the sink on append(). Callers pass 0.
Thrown by toJson/fromJson when LogEntry (de)serialisation fails.
SlotBinding< std::decay_t< F > > slot(SlotId slotId, F &&read)
A slot with an id.
Definition view.hpp:63