morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
morph::offline::IReplayLedger Struct Referenceabstract

Interface for an op-id-keyed exactly-once replay ledger. More...

#include <replay_ledger.hpp>

Inheritance diagram for morph::offline::IReplayLedger:
morph::offline::InMemoryReplayLedger

Public Member Functions

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.
::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 delivered on replyExec.

Protected Member Functions

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, for an implementation with no owner; one with an owner looks up there.
virtual std::optional< std::string > doLookup (std::string_view scope, std::string_view opId) const =0
 Storage hook for lookup().
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).

Detailed Description

Interface for an op-id-keyed exactly-once replay ledger.

Answers one question — "has this operation id already been applied?" — for a host that must dedup a retried write against one it already committed. One framework seam in place of a near-identical table each host would otherwise hand-write. A host either stores a result and replays it verbatim on a hit ("response-replay"), or stores nothing and reports only that the op was seen ("skip-only", as examples/bookmarks' import path does). Both are the same mechanism with a different caller-side interpretation of the stored payload — see lookup()'s own doc comment — so there is exactly one interface here, not two.

Why the framework cannot supply the storage
Every existing occurrence stores its ledger row in the same database and the same transaction as the write it guards, so the check-then-set commits atomically with the operation's effect — see record()'s doc comment. include/morph has no dependency on any SQL library, so it cannot open that connection or join that transaction. What it can supply is the contract: the shape every implementation must have, and a conformance suite (tests/replay_ledger_conformance.hpp) any implementation can be run against. The table itself stays app-side, per rung, exactly as before.
No base class for consumers
A model holds (or is handed) an IReplayLedger&; it never derives from one. Every existing occurrence is a free function or a plain member on the model, not a base class — a design that required inheriting from this interface would be un-adoptable by every one of them.

Definition at line 47 of file replay_ledger.hpp.

Member Function Documentation

◆ lookup() [1/2]

std::optional< std::string > morph::offline::IReplayLedger::lookup ( std::string_view scope,
std::string_view opId ) const
inlinenodiscard

Reports whether opId within scope has already been decided.

What a hit means is the caller's decision, not this interface's
The returned string is whatever record() stored alongside the id. A response-replay consumer (a move or a money transfer whose retry must see the original answer) stores its result's serialised form and, on a hit, returns that string verbatim instead of re-running the operation. A skip-only consumer (bookmarks' import path, or an at-most-once replay of queued captures) stores an empty payload and, on a hit, reports "already applied, nothing to return" without ever looking at the string. Both read exactly the same lookup() — the value type reports the fact, and the layer that calls this decides what a hit means.
scope
An opaque caller-chosen partition — the three key shapes the seven occurrences used ((entity_id, op_id), (owner_principal, op_id), a bare op_id) are all this one string plus the id, with the bare-key case simply passing an empty scope. Two calls with the same opId but different scope never collide.
Empty opId
Never reported as decided, in every conforming implementation — see record(). An empty id is not an identity, and reporting one "already applied" would make every unkeyed call after the first a silent no-op.
Parameters
scopeCaller-chosen partition; empty is a valid, distinct scope.
opIdThe operation id to look up. Empty always reports std::nullopt, regardless of what has been recorded.
Returns
The payload recorded with opId (possibly empty, for a skip-only caller), or std::nullopt if it has not been recorded — or opId is empty.

Definition at line 82 of file replay_ledger.hpp.

◆ record()

void morph::offline::IReplayLedger::record ( std::string_view scope,
std::string_view opId,
std::string payload )
inline

Records that opId within scope has been decided, with payload attached.

Atomicity is the caller's responsibility, not this interface's
The framework cannot enforce this — IReplayLedger has no database connection of its own — so it is stated here instead: a conforming implementation MUST let its caller commit this call in the same transaction as the write it guards. An implementation backed by a SQL table does this by operating through the caller's own already-open connection/transaction handle (passed to the implementation at construction), never by opening a connection of its own. Recording outside that transaction reintroduces exactly the defect this ledger exists to prevent: a crash between the write and the record redelivers the operation and it is re-applied.
Idempotent: first-write-wins
Recording an opId within scope that is already decided is a no-op — the existing payload is kept, payload here is discarded, and no error is raised. This is the same contract morph::journal::IActionLog::append()'s idempotency-key dedup already ships (journal/action_log.hpp's own doc comment: "if an entry with the same key was already recorded, treat the call as a no-op"): a redelivered retry that reaches record() a second time (e.g. because a reply was lost after the first commit) must not overwrite a payload a caller may already have replayed to someone else.
Empty opId
A no-op — there is no identity to record against, so lookup() for an empty id must keep reporting std::nullopt regardless of how many times record() is called with one.
Parameters
scopeCaller-chosen partition; must match the scope a later lookup() uses to find this entry.
opIdThe operation id being recorded. A no-op if empty.
payloadOpaque payload to associate with opId; empty for a skip-only caller that only cares whether a hit occurred.

Definition at line 126 of file replay_ledger.hpp.

◆ lookup() [2/2]

::morph::async::Completion< std::optional< std::string > > morph::offline::IReplayLedger::lookup ( ::morph::exec::IExecutor & replyExec,
std::string_view scope,
std::string_view opId ) const
inlinenodiscard

Reports whether opId within scope has already been decided, looked up on the ledger's owner and delivered on replyExec.

The form of lookup() for a caller that is not on the ledger's owner. A lookup answered elsewhere cannot be atomic with the record() that follows it; a caller that needs check-then-set atomicity runs both on the owner.

Parameters
replyExecWhere the answer is delivered, and where the caller attaches to it. Borrowed: it must outlive the completion.
scopeCaller-chosen partition; empty is a valid, distinct scope.
opIdThe operation id to look up. Empty always answers std::nullopt.
Returns
What lookup(scope, opId) would have returned.

Definition at line 148 of file replay_ledger.hpp.

◆ askLookup()

virtual ::morph::async::Completion< std::optional< std::string > > morph::offline::IReplayLedger::askLookup ( ::morph::exec::IExecutor & replyExec,
std::string scope,
std::string opId ) const
inlinenodiscardprotectedvirtual

What lookup(replyExec, scope, opId) asks. The default looks up where it is called, for an implementation with no owner; one with an owner looks up there.

Parameters
replyExecWhere the answer is delivered.
scopeCaller-chosen partition.
opIdThe operation id to look up; never empty here.
Returns
The payload recorded with opId, or std::nullopt.

Reimplemented in morph::offline::InMemoryReplayLedger.

Definition at line 167 of file replay_ledger.hpp.

◆ doLookup()

virtual std::optional< std::string > morph::offline::IReplayLedger::doLookup ( std::string_view scope,
std::string_view opId ) const
nodiscardprotectedpure virtual

Storage hook for lookup().

Parameters
scopeCaller-chosen partition, forwarded verbatim from lookup().
opIdThe operation id to look up; never empty here – the public wrapper already handled that case.
Returns
The payload recorded with opId, or std::nullopt if none.

Implemented in morph::offline::InMemoryReplayLedger.

◆ doRecord()

virtual void morph::offline::IReplayLedger::doRecord ( std::string_view scope,
std::string_view opId,
std::string payload )
protectedpure virtual

Storage hook for record(). Must be a no-op if scope/opId is already recorded (first-write-wins).

Parameters
scopeCaller-chosen partition, forwarded verbatim from record().
opIdThe operation id being recorded; never empty here – the public wrapper already handled that case.
payloadOpaque payload to associate with opId.

Implemented in morph::offline::InMemoryReplayLedger.


The documentation for this struct was generated from the following file: