morph::offline provides the building blocks for a network-aware application
that degrades gracefully when the backend is unreachable. It covers four
concerns:
- Detecting the connectivity state (
NetworkMonitor). - Queuing actions that could not be delivered (
IOfflineQueue,InMemoryOfflineQueue,FileOfflineQueue,SqliteOfflineQueue). - Replaying queued actions on reconnect, with retry and dead-letter
semantics (
SyncWorker). - Orchestrating the reconnect → activate → bind → replay sequence
(
ReconnectCoordinator,ReconnectOutcome,ReconnectCoordinatorConfig).
All types live in morph::offline.
| Type | Header | Role |
|---|---|---|
NetworkMonitor / NetworkMonitorConfig |
network_monitor.hpp |
Background probe thread + online/offline state machine. |
QueueItem, IOfflineQueue, InMemoryOfflineQueue |
offline_queue.hpp |
Passive store of undelivered actions (opaque payloads); durable retry-attempt tracking. |
FileOfflineQueue |
file_offline_queue.hpp |
Reference NDJSON-file-backed durable queue; no extra dependency, ships by default. |
SqliteOfflineQueue |
sqlite_offline_queue.hpp |
Reference SQLite-backed durable queue; opt-in (MORPH_BUILD_OFFLINE_SQLITE). |
SyncWorker / SyncResult / SyncWorker::DeadLetterSink |
sync_worker.hpp |
Drains + replays a queue with durable-attempt-aware retry/dead-letter. |
ReconnectCoordinator, ReconnectOutcome, ReconnectCoordinatorConfig, ReconnectCoordinator::Deps |
reconnect_coordinator.hpp |
Orders reconnect → activate → bind → replay, with abort checks. |
- NetworkMonitor
- NetworkMonitor callback constraint
- Offline queue
- Ownership: who enqueues
- SyncWorker
- Conflict resolution on replay
- ReconnectCoordinator
- End-to-end integration
- Failure modes
- Limitations
- Design decisions
- Cross-references
A background thread calls a user-supplied probe function at regular intervals.
The monitor starts online and transitions to offline only after
failureThreshold consecutive failures. It returns to online after
onlineThreshold consecutive successes. Callbacks fire on the probe thread.
The monitor is non-copyable and non-movable. Destroy it to stop monitoring.
| Field | Type | Default | Purpose |
|---|---|---|---|
probeInterval |
std::chrono::milliseconds |
5s |
Time between probe calls. |
failureThreshold |
int |
3 |
Consecutive failures before going offline. |
onlineThreshold |
int |
1 |
Consecutive successes before going online. |
Declared outside NetworkMonitor so its default member initialisers are fully
parsed before any constructor default argument evaluates — a nested incomplete
type breaks constructor-default-argument lookup on clang/GCC.
| Member | Signature | Notes |
|---|---|---|
ProbeFunction |
std::function<bool()> |
Returns true when the network is reachable. |
Callback |
std::function<void()> |
Called on state change. |
Config |
NetworkMonitorConfig |
Alias for the config struct. |
| ctor | NetworkMonitor(ProbeFunction, Callback onOffline, Callback onOnline, Config = {}) |
Launches the probe thread immediately. |
| dtor | ~NetworkMonitor() |
Calls stop() then spin-waits on _runExited to handle the case where stop() was called from within a probe callback (avoiding deadlock on join()). |
isOnline() |
bool isOnline() const noexcept |
Reads an atomic flag; safe from any thread. |
stop() |
void stop() |
Signals the thread to stop. Idempotent. If called from the probe thread itself, detaches instead of joining. |
Probe exceptions are swallowed — a throwing probe is treated as a failed
probe (safeProbe catches everything and returns false).
onOffline and onOnline run on the probe thread, inline inside the probe
loop. Look at run(): it waits on the condition variable for probeInterval,
calls safeProbe, then calls handleProbeResult, which invokes the callback
before the loop can circle back to wait for the next interval. There is no
executor, no second thread, and no queue between the probe result and the
callback — whatever the callback does, the probe thread does.
Consequences:
- A blocking callback stalls all probes. While the callback runs, the next
wait_forhas not started, so no further connectivity checks happen. A callback that blocks for 30s means 30s of connectivity blindness. - Running the coordinator or
SyncWorkerinline is a mistake. AReconnectCoordinator::onOnline()can spin for up tomaxAttempts * retryDelay(≈20s at defaults) of retry-and-sleep, and aSyncWorker::run()executes arbitrarily long replay work. Doing either directly inside a callback runs seconds of retry loop on the probe thread, which is exactly the thread that is supposed to be watching the network. - The safe shape is: set an atomic, or post to an executor, and return.
The callback should do O(1) work — flip a flag,
post()a lambda onto a worker executor — and let the heavy sequencing run elsewhere. This is whyReconnectCoordinator::onOnline()/onOffline()are documented as "posted onto a worker executor by the host, not called on the probe thread."
Calling stop() from within a callback is supported (it detaches rather than
joins to avoid a self-deadlock — see the dtor/stop() notes above), but it is
still a callback running on the probe thread and must not block first.
See concurrency_and_lifetimes.md for the framework-wide rule that
notification callbacks marshal work off the thread that raised them.
| Field | Type | Purpose |
|---|---|---|
id |
uint64_t |
Stable identifier assigned at enqueue time. Queue-local — not a cross-subsystem key. |
payload |
std::string |
Opaque serialised representation of the queued action. |
idempotencyKey |
std::string |
Optional caller-supplied dedup token, stable across subsystems and restarts for one logical op. Empty by default. |
attempts |
uint32_t |
Durable retry count, authoritative when the queue persists it via setAttempts(). Defaults to 0. |
The payload format is the caller's choice — JSON, binary-hex, plain text, etc.
QueueItem::id is queue-local — a durable queue re-presents the same logical
op with a fresh id after a restart, and the journal's seq is journal-local,
so the two subsystems share no identity. That is exactly the seam where an op can
be double-applied: the offline queue and the journal can each replay the same
logical operation with nothing to recognise it as already-applied.
idempotencyKey is the shared dedup token that closes it. It is a caller-minted
value — a stable content hash or a client-generated operation id (e.g. a UUID),
reused if the same op is re-enqueued — that stays constant for one logical
operation across the queue, the journal, and process restarts. A replay consumer
that has applied an op records its key and skips any later item (from either
path) carrying the same key.
Set it by enqueuing through the two-argument overload:
queue.enqueue(serialise(action), operationId(action)); // payload + idempotency keyThe queue stores the key verbatim and never interprets it. The key is opaque
to morph::offline, just like the payload.
Uniqueness enforcement is a floor, not a prohibition. The interface does not
require an implementation to enforce uniqueness, so a replay consumer must
always dedup on the key itself — a conforming queue may present the same key
twice. An implementation is nonetheless permitted to dedup at enqueue time as
a deliberate strengthening: InMemoryOfflineQueue never dedups, while
FileOfflineQueue (linear scan) and SqliteOfflineQueue (partial unique index)
both do.
Where an implementation does dedup, these are the semantics — identical in both,
and pinned for every shipped implementation by
tests/offline_queue_conformance.hpp:
- Only a non-empty key already carried by a pending item is a dedup candidate. An empty key is never a dedup token: two empty-key enqueues always produce two distinct items, in every implementation.
- The call succeeds and returns the existing item's id, so the return value does not distinguish a store from a hit.
- A hit is first-write-wins with silent payload loss — the new payload is discarded, the pending item keeps the payload it already had, and no error is raised and nothing is reported to the caller. A caller that re-enqueues a corrected payload under an unchanged key loses the correction; mint a new key when the payload changes.
markDone()releases the key: once the pending item is gone, the same key enqueues normally again.- A durable queue still recognises the key after a reopen.
The dedup contract. The offline queue replay and the journal replay must be wired so a logical op is applied at most once. There are two ways to satisfy it, and a host must pick one:
- Mutually exclusive replay — drive replay through exactly one path (the
SyncWorkerqueue path or the journal replay), never both over the same ops. This is the existing guidance (see Conflict resolution on replay and the journal cross-reference) and needs no key. - Shared idempotency key — if both paths can replay the same ops, every
enqueued item and its corresponding journal entry must carry the same
idempotencyKey, and the replay consumer must dedup on it (apply a key once, skip repeats). The framework provides the field; wiring the check into the replay/flush path is the host's responsibility (that logic lives insync_worker.hppand the app's journal-replay code, not in the queue).
Minimal interface for durable storage of undelivered actions. Accepts items
while offline; SyncWorker drains and replays them on reconnect.
| Member | Signature | Notes |
|---|---|---|
enqueue |
uint64_t enqueue(std::string payload) |
Appends payload with no idempotency key. Returns a stable (queue-local) id. |
enqueue |
uint64_t enqueue(std::string payload, std::string idempotencyKey) |
Appends payload carrying the dedup key (stored on QueueItem::idempotencyKey). Virtual with a default that delegates to the one-arg enqueue then stamps the key via the protected setIdempotencyKey, so existing implementations keep working; the key is dropped by an implementation with no per-item storage that overrides neither. |
drain |
std::vector<QueueItem> drain() |
Returns all pending items in enqueue order, without removing them. Safe to call multiple times — items survive between drain() and the corresponding markDone(). |
markDone |
void markDone(uint64_t itemId) |
Removes the item identified by itemId. No-op if not found. |
setAttempts |
void setAttempts(uint64_t itemId, uint32_t attempts) |
Persists an updated attempt count for an item. Public (unlike setIdempotencyKey) because SyncWorker calls it from outside the queue after every failed replay. Default no-op; InMemoryOfflineQueue overrides it to update the in-deque item. A queue that overrides it to store the count durably makes SyncWorker's retry budget survive a process restart. |
size |
std::size_t size() const |
Number of pending items, without removing them. Default calls drain().size() — correct but O(n) and allocates a full snapshot to answer a size query; every shipped implementation overrides it with a direct count. |
maxDepth |
std::optional<std::size_t> maxDepth() const |
The capacity enqueue() enforces, or std::nullopt if unbounded. Default: std::nullopt — preserves current behavior for any IOfflineQueue subclass written before this method existed. |
setIdempotencyKey (protected) |
void setIdempotencyKey(uint64_t itemId, std::string key) |
Hook the default two-arg enqueue uses to stamp the key onto an already-enqueued item. Default no-op; InMemoryOfflineQueue records the key directly instead. A conflicting non-empty key is skipped, never raised — an implementation that deduplicates leaves the item unkeyed rather than failing, because the default enqueue has already inserted by the time it stamps, so the row exists either way and an exception could not undo it. That path therefore yields an extra unkeyed item, not a dedup hit; a caller wanting dedup uses the virtual two-arg enqueue (see below). |
drain() is const — it takes a snapshot and mutates nothing, so size()'s
default can call it (and so can an application) without needing a non-const
reference to the queue.
IOfflineQueue has no depth bound by default — maxDepth() returns
std::nullopt and enqueue() never rejects an item on capacity grounds
unless a concrete queue is constructed with an explicit bound. Every shipped
implementation (InMemoryOfflineQueue, FileOfflineQueue,
SqliteOfflineQueue) takes an std::optional<std::size_t> maxDepth = std::nullopt as the last constructor parameter; passing a value turns on
enforcement for that instance.
Policy: reject-newest. Once a bounded queue holds maxDepth() items, a
further enqueue() throws OfflineQueueFullError instead of admitting the
new item — the queue never silently evicts an older item or invokes an
app-defined eviction callback:
/// Thrown by enqueue() when the queue is at its configured maxDepth().
struct OfflineQueueFullError : std::runtime_error {
OfflineQueueFullError(std::size_t maxDepth, std::size_t currentSize);
std::size_t maxDepth; // the configured capacity that was reached
std::size_t currentSize; // pending items at the time of rejection
};maxDepth/currentSize are equal for a well-behaved implementation; both are
carried on the exception so a caller can log or branch on the numbers without
re-querying the queue. Immediately before throwing, each implementation emits
the queueOverflow counter metric with the rejection-time size as its value
(see observability.md).
Per-implementation notes:
InMemoryOfflineQueuechecks capacity under its existing lock, before the dequepush_back. It has no idempotency-key dedup at all, so there is no dedup-hit-vs-capacity ordering question here.FileOfflineQueueruns its existing keyed-dedup scan first; the capacity check sits after it, beforeappendPut. A dedup hit (a re-enqueue of an already-pending key) therefore always succeeds and returns the existing id, even on a full queue — it inserts nothing new, so there is nothing to reject.SqliteOfflineQueuechecks capacity (SELECT COUNT(*)) before attempting either INSERT path (empty-key and keyed). For the keyed path, this means the check runs before theINSERT ... ON CONFLICT ... DO NOTHINGcan resolve to a dedup hit — a re-enqueue of an already-queued key can be rejected if the queue happens to be full at that moment, even though it would have inserted nothing. This is a deliberate, documented conservatism: avoiding it would require a second round trip (insert speculatively, then check whether it was actually a no-op conflict) purely to special-case a narrow situation (re-enqueuing an already-queued idempotency key while the queue is simultaneously full).
maxDepth is a per-construction parameter, not persisted in the file or
database — a host that reopens FileOfflineQueue/SqliteOfflineQueue over
the same path must pass the same maxDepth argument again to keep the same
cap enforced; nothing on disk remembers it.
Thread-safe in-memory implementation of IOfflineQueue. Items live in a
std::deque<QueueItem> protected by a std::mutex. Ids are monotonically
increasing. Overrides setAttempts to update the in-deque item, so the
attempt count is current for as long as the queue object lives — but it has
no persistence, so a process restart still resets it to 0. Suitable for
testing and applications that do not require persistence across restarts.
Reference append-only, NDJSON-backed IOfflineQueue (file_offline_queue.hpp)
that persists payload, idempotencyKey, and attempts across process
restarts with no extra dependency — it ships in the default morph target
alongside InMemoryOfflineQueue. Each mutation (enqueue, markDone,
setAttempts, setIdempotencyKey) appends one JSON line
({"op": "put"|"done", "id", "payload", "idempotencyKey", "attempts"}) and
immediately fflush+fsyncs it. The line is written with
detail::EscapingWriteOpts (mirroring morph::wire::detail::EscapingWriteOpts,
core/wire.hpp) so a raw ASCII control byte in payload/idempotencyKey
round-trips instead of producing invalid JSON that breaks replay on the next
open — or, alongside an escaped \/" in the same string, JSON glaze's
writer silently corrupts before it ever reaches disk. On open, the file is replayed
last-write-wins-per-id and rewritten in compacted form — this both bounds file
growth and heals a torn trailing line left by a crash mid-write, tolerating it
the same way FileActionLog does (a malformed trailing line is logged and
skipped; a malformed line anywhere else is genuine corruption and is
rethrown). New ids resume from the highest id ever seen in the file (including
tombstoned ones), so a fresh item never collides with an old tombstone — and
because compaction drops every tombstone, that high-water mark is carried across
each rewrite explicitly, as a trailing "done" record for the mark itself
(emitted only when it exceeds every surviving id, so it can never delete a row a
"put" line above just restored). Recording it as a "done" needs no reader
change: load() already raises the mark for every id it reads, and erasing an
absent id is a no-op. Without it the mark regressed to the highest surviving
id on the second restart — enqueue 1 and 2, markDone(2), restart (compacts to
just id 1), restart again, and the next enqueue() reissued id 2, the id of a
completed and acknowledged item. Mutations also raise rather than swallow I/O
failures: a short write or a failed fflush/fsync throws, since every
mutation is documented as a committed transaction by the time the call returns. A
keyed enqueue's dedup is a linear scan over pending items — fine at modest
queue depths; SqliteOfflineQueue is the index-backed alternative for
high-volume keyed enqueues. Not safe for multiple processes to open the same
path concurrently.
The constructor takes an optional second morph::core::FileIoOps parameter
(FileOfflineQueue(std::filesystem::path, morph::core::FileIoOps = {})) — the
same test-only fault-injection seam FileActionLog uses (see
docs/spec/journal/journal.md): the raw fwrite/fflush/fsync/fopen
calls this class makes, as an injectable strategy defaulting to the real
syscalls. A normal caller never passes one.
Reference SQLite-backed IOfflineQueue (sqlite_offline_queue.hpp), built
only when the host opts in via the MORPH_BUILD_OFFLINE_SQLITE CMake option
(default OFF; resolved through CMake's bundled FindSQLite3 module against a
system SQLite3 package, not through vcpkg.json, so the default build and its
dependency graph are unaffected). Backed by one table:
CREATE TABLE IF NOT EXISTS morph_offline_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
idempotency_key TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
enqueued_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_queue_idem
ON morph_offline_queue(idempotency_key) WHERE idempotency_key <> '';id is AUTOINCREMENT: ids are never reused, and a re-opened queue
re-presents each row under its stored, stable id — restarting does not
renumber existing rows. (QueueItem::id is still queue-local per the general
contract above; cross-restart identity is carried by idempotencyKey, not
id.) The partial unique index gives insert-time dedup for a non-empty
idempotencyKey — a re-enqueue of the same key is a no-op that returns the
existing row's id; empty keys are exempt and are never deduplicated, matching
InMemoryOfflineQueue. drain() never deletes, so a crash between drain()
and markDone() loses nothing; every write is its own committed statement
under PRAGMA journal_mode=WAL. All operations serialise on an internal
mutex, so the queue is safe to share between the write and drain/replay paths.
The queue is passive. IOfflineQueue exposes enqueue / drain /
markDone and nothing else — it has no notion of a backend, a transport, or a
"failed request." It never fills itself. The framework supplies no transport
layer that would notice a write failed and drop it into the queue, so
detecting an offline/failed execute() and calling enqueue() is the
application's job.
The seam is on the write path, not inside morph::offline. A host that wants
offline durability wraps its own dispatch:
// Application code — the framework does not write this for you.
void submit(const MyAction& action) {
if (!monitor.isOnline()) { // known offline: don't even try
queue.enqueue(serialise(action));
return;
}
try {
bridge.execute(action); // attempt delivery
} catch (const std::exception&) { // delivery failed at the edge
queue.enqueue(serialise(action)); // trap it into the queue
}
}That free function is app-layer by design, not by omission:
examples/IMPLEMENTATION.md rule 1 would otherwise keep this code inside a
model, and
Disposition: app-layer by design
below is the recorded carve-out that puts it here.
SyncWorker closes the loop on the read path: on reconnect it drain()s the
same queue and replays each payload. The two halves share one IOfflineQueue
instance (see End-to-end integration) — the
application owns the "enqueue on failure" half, the framework owns the "drain
and replay" half. Neither NetworkMonitor nor ReconnectCoordinator enqueues
anything; they only observe and sequence.
Because the framework never calls enqueue, the serialisation format is
entirely the caller's (QueueItem::payload is an opaque std::string), and it
is the caller's responsibility that the same format round-trips through the
SyncWorker::ReplayFunction.
The example above puts domain-adjacent code in a free function at the dispatch
site. examples/IMPLEMENTATION.md rule 1 would otherwise forbid exactly that
placement — "nothing domain-shaped may live in presenters, QML, main(), or
free functions." The placement is deliberate, and this section is its recorded
disposition (morph#197), so a reader who finds
if (!monitor.isOnline()) queue.enqueue(...) outside a model knows it is a
sanctioned exception rather than an oversight.
Rule 1 is not being overridden here; it fired. Its final clause is "If logic can't be expressed in a model, that is a finding," and that document's prime directive says the same of the framework itself. This carve-out is that finding's outcome, not an argument that the rule is wrong.
Why a model cannot host it. enqueue() is the write path's last act before
the wire, taken precisely when the wire is unavailable. In the canonical wiring
this file and ARCHITECTURE.md both show, the client keeps an in-process
LocalBackend, so a client-side model does exist and could in principle own
the decision. On a remote deployment — models behind a server, reached over
a Bridge — it does not: the one machine that must decide "queue this instead
of sending it" is the one machine with no model on it. morph offers no seam
there, and none of the framework's own offline types fills the gap: neither
NetworkMonitor nor ReconnectCoordinator enqueues, and the queue stays
passive by design (above).
What the carve-out covers, and what it does not.
| Belongs in the write-path seam | Stays in the model |
|---|---|
Probing NetworkMonitor::isOnline(), or catching a failed execute() |
Validating and authorizing the action (Context::principal) |
Minting the idempotency key, serialising the payload, calling enqueue() |
Deduping the replayed op against the journal |
| Surfacing queue depth to the UI | Classifying a stale base version as a conflict |
| Client-local bookkeeping that keeps this client's own queued items consistent — e.g. a per-entity version ledger so a second offline edit chains onto the first rather than colliding with it | Everything the payload means once it lands: replay re-dispatches it as an ordinary typed action |
The last row of the left column is the sharp edge: it is genuinely
domain-shaped, and the carve-out sanctions it only in a dedicated app-layer
write-path class — never in a presenter, a QML bridge, or main(). The
domain semantics of the queued action never move out of the model; only the
decision to queue it, and the client-local state that decision needs, live in
the seam.
Reference shapes in the ladder.
examples/lims/include/lims/offline/field_outbox.hpp— the reference for the domain-shaped half. A plain, non-Qt, app-layer class that stamps each queued capture with a base version from its own local ledger and advances that ledger on enqueue, so a client's second offline edit chains onto its own pending first edit. Replay still goes through the model (SampleModel::execute(QueuedCapture)), which owns validation, authorization and conflict classification.examples/kanban/gui_lib/board_qml_bridge.cpp— the transport-shaped half only: probe, mint an op id, serialise, enqueue, update queue depth. It lives in a presenter, which rule 1 names as forbidden for domain-shaped code; nothing there is domain-shaped, so it stands as glue under rule 2's "pure glue with no domain logic" justification. Anything with a domain invariant in it belongs in aFieldOutbox-shaped class instead.
Scope, and when to revisit. This is the "explicitly dispositioned in the
spec as app-layer by design" branch of examples/IMPLEMENTATION.md's promotion
rule, taken at two occurrences of the transport-shaped seam (kanban's
bridge, lims' outbox) and one of the domain-shaped version chaining (lims'
outbox). No framework primitive is owed yet. A third rung independently growing
its own enqueue-on-offline path is the trigger to reopen the question of a
framework-owned outbox dispatcher — a standing disposition must not become the
reason a third reinvention goes unexamined.
Replays queued actions from an IOfflineQueue on reconnect. Drains the queue
and calls a caller-supplied ReplayFunction for each item.
| Field | Type | Default | Purpose |
|---|---|---|---|
successful |
int |
0 |
Items replayed and removed from the queue. |
failed |
int |
0 |
Items that failed and remain in the queue for retry. |
deadLettered |
int |
0 |
Items that exhausted their retry budget and were dropped — handed to the DeadLetterSink if one is set, otherwise logged at morph::log::LogLevel::error. |
| Member | Signature | Notes |
|---|---|---|
ReplayFunction |
std::function<bool(const std::string&)> |
Return true → success, false → failure. Throwing is treated as failure. |
DeadLetterSink |
std::function<void(const QueueItem&)> |
Invoked with the exhausted item, just before it is removed, when an item hits the retry cap. Optional — default unset. |
| ctor | SyncWorker(IOfflineQueue&, ReplayFunction, DeadLetterSink = nullptr) |
References the queue and the replay callable; the sink is an optional third argument. |
run() |
SyncResult run() |
Drains the queue and replays each item. Concurrent calls are serialised by an internal mutex. Returns immediately if stop() was called before acquiring the lock. Emits the queueDepth metric once, with the drained item count, before replaying (see observability.md). |
stop() |
void stop() |
Signals an in-progress run() to stop after the current item. One-shot — the flag resets at the start of the next run(). |
Retry & dead-letter (hard-coded cap, durable count):
- Each item is retried up to 5 cumulative attempts. The count is seeded
from the larger of the drained
QueueItem::attemptsandSyncWorker's own in-memory count, so a queue that persistsattempts(viasetAttempts()) makes the budget survive a process restart; a queue that leavessetAttempts()as the default no-op keeps the count purely in-memory (the original behavior — it resets whenever a freshSyncWorkeris constructed). - After every failed attempt, the new cumulative count is written back
through
setAttempts()(a no-op unless the queue overrides it), so a persisting queue's nextdrain()— this run, or after a restart — sees the updated value. - Items that fail their 5th cumulative attempt are dropped. If a
DeadLetterSinkis set, it receives the exhaustedQueueItem(payload, idempotencyKey, finalattemptscount) instead of the default log line; if unset, the item is logged atmorph::log::LogLevel::error(the payload appears in the log line) — the original behavior, unchanged. A throwing sink is caught and logged; the item is still removed. - Items that succeed implicitly reset their attempt counter (they are removed).
- There are intentionally no public knobs on the retry cap itself — the framework guarantees obvious, safe defaults.
The per-item attempt counter lives in a std::unordered_map<uint64_t, uint32_t>
keyed by QueueItem::id, seeded from and written back to the queue as above.
QueueItem::attempts (durable retries) and QueueItem::idempotencyKey (dedup)
are complementary, not overlapping: idempotencyKey prevents an item from
being double-applied across the queue and journal replay paths;
attempts prevents an item from being retried forever across restarts.
Neither is enforced by the queue itself — SyncWorker and the replay
consumer act on them.
The offline path publishes no consistency guarantee stronger than the three properties below. Stating them is not flattering — comparable sync engines publish causal+ or eventual consistency — but every one is already implied by the rest of this document, and leaving them unstated invites a host to assume more.
- Per-queue FIFO, best-effort.
SyncWorker::run()drains in enqueue order and attempts every item in the batch. A failure does not stop the run: the failed item is left queued for a later attempt and the loop continues to the next one (sync_worker.hpp). So there is no head-of-line blocking — and equally no guarantee that the order items land on the server matches the order they were enqueued, once any item has failed. - No read-your-writes for a queued write. An enqueued write is invisible to a subsequent read until it actually reaches the server. The queue holds an opaque payload; nothing replays it into the local model's state or shadows the read path with pending writes.
- No cross-client visibility without a re-read.
subscribe<R>fans out only to handlers on the sameBridge(one client process), so a result another client produced does not reach this client's subscribers — it is observed on this client's next read. See the README's "Instance subscriptions are best-effort and in-process".
None of these is a defect to fix; they are the shape of a queue that stores
opaque payloads and replays them through an ordinary action path. A host needing
stronger guarantees builds them above this layer — for instance by making writes
idempotent (see idempotencyKey above) and re-reading once a replay completes.
morph::offline has no conflict-resolution machinery of its own. Neither
SyncWorker nor ReconnectCoordinator knows what a payload means, so neither
can detect that a queued write was superseded by a change the backend accepted
while the client was offline. SyncWorker's ReplayFunction sees only a
const std::string& and returns a bool — it has no channel to say "delivered,
but the server had a newer version" or "discarded as stale." Conflict
detection and merge/discard are the model's responsibility, and the seam the
framework provides for them is Model::onBackendChanged(), not SyncWorker.
There are therefore two distinct replay paths over the same IOfflineQueue,
and a host picks one:
-
SyncWorkerpath — the framework replays each payload through an opaqueReplayFunctionwith the built-in retry/dead-letter policy above. Fits a fire-and-forget queue of writes that either land or are retried; the replay function returns only success/failure. -
Model
onBackendChanged()path — when the backend switches,Bridgereconstructs each model on the new backend and firesonBackendChanged()on that fresh instance (seebridge.md). A model that holds a reference to the sharedIOfflineQueuecandrain()it insideonBackendChanged(), decide what each item becomes, andmarkDone()it. This is the path that supports clean-replay / merge / discard outcomes, because the model — not an opaqueboolcallback — decides.Two limits on this seam, both structural. They do not stop the path working, but they decide when it can run and what it can consult:
-
It fires on the switch, and only
LocalBackendimplements it.Bridge::switchBackendcallsnotifyBackendChanged()on the new backend, andLocalBackend(backend.hpp) is the onlyIBackendthat implements it —SimulatedRemoteBackend(remote.hpp),SocketBackend(net/socket_backend.hpp) andQtWebSocketBackend(qt/qt_websocket_backend.hpp) are alloverride {}. So the hook runs when a client switches to the local backend — going offline — and does not run on reconnect to a remote backend. A host that wants replay to happen on reconnect cannot get it from this hook alone as the backends stand; it drains at a moment of its own choosing, or uses theSyncWorkerpath.This also means "classify each item against the now-reachable backend" would be the wrong instruction: at the moment the hook fires, the now-reachable backend is the local one.
-
It runs with no session.
LocalBackend::notifyBackendChangedposts the call without aScopedContext, sosession::current()isnullptrinsideonBackendChanged()— see session.hpp's note that aContextexists only during a dispatch. The model cannot identify who is replaying, and no code change to this hook would give it a verified principal:LocalBackendnever consults anIAuthorizerat all, so the most it could ever carry is the client-assertedBridge::_defaultSession. Replay on this path is therefore unauthenticated by construction; a host that needs an authenticated replay must perform it through an action on a remote backend, whereRemoteServerauthenticates.
-
A model on the onBackendChanged() path typically drives each drained item
through two caller-supplied hooks (as the conflict-resolution tests do):
- A conflict checker
bool(const std::string& payload)—truemeans the backend already holds a newer version that supersedes this queued write. - A resolver
std::string(const std::string& payload), consulted only for conflicting items — a non-empty result is a merge (apply the reconciled value), an empty result is a discard (drop the stale write). Non- conflicting items are a clean replay.
Every drained item is markDone()d regardless of outcome — clean replay,
merge, and discard all remove the item from the queue. Unlike the SyncWorker
path there is no retry or dead-letter here: the model handles each item exactly
once per backend switch, so it must not leave an item in a state that needs a
later attempt. This path also inherits onBackendChanged's threading contract:
Bridge::switchBackend does not run onBackendChanged() inline on the caller's
thread — LocalBackend::notifyBackendChanged posts it onto the model's own
strand (the same per-ModelId serial queue execute uses). It therefore runs
single-threaded per model, never overlapping an execute() on that model, so a
model draining the queue and mutating its own counters there needs no locking
of its own state. Because the drain is posted (asynchronous), it completes some
time after switchBackend returns; a test or host that must observe the
drained result waits for it (the conflict-resolution tests poll a model counter)
rather than assuming it finished synchronously. See
bridge.md's switchBackend for the exact posting mechanism.
The two paths are mutually exclusive per queue — a queue drained inside
onBackendChanged() and also handed to a SyncWorker::run() would be
double-processed. Choose the model path when replay outcomes are richer than
success/failure (conflicts, merges); choose SyncWorker when they are not and
you want the built-in retry budget.
Sequences the reconnect → activate → bind → replay steps when the network comes
back. All side effects are injected via Deps; the coordinator contains only
the retry loop, the ordering guarantees, and the abort checks. It performs no
I/O and owns no thread — onOnline() / onOffline() run synchronously on the
calling thread.
| Enumerator | Meaning |
|---|---|
Reconnected |
Backend reopened, made active, context bound, queue replay invoked. |
GaveUp |
Exhausted maxAttempts without a successful reconnect; stayed offline. |
Aborted |
shouldContinue() returned false before any reconnect attempt. |
| Field | Type | Default | Purpose |
|---|---|---|---|
maxAttempts |
int |
10 |
Max reconnect attempts per onOnline() call. |
retryDelay |
std::chrono::milliseconds |
2s |
Delay between failed attempts. |
| Member | Signature | Notes |
|---|---|---|
Config |
ReconnectCoordinatorConfig |
Alias. |
Deps |
struct | Injected side-effect callbacks (see below). |
| ctor | explicit ReconnectCoordinator(Deps, Config = {}) |
Non-copyable, non-movable. Null Deps members are logged via morph::log::logError in all builds; construction still succeeds. |
| Field | std::function signature |
Purpose |
|---|---|---|
tryReconnect |
bool() |
Attempt to (re)open the primary backend. Throwing → failed attempt. |
activatePrimary |
void() |
Make the freshly-reconnected primary the active backend. Called once per successful onOnline(), after tryReconnect() succeeds. |
activateLocal |
void() |
Switch the active backend to the local/offline one. Called by onOffline(). |
bindContext |
void() |
Rebind per-connection/per-session context to the active backend. Called after every activate* step. Must not throw. |
replay |
void() |
Replay the offline queue against the now-active primary. Typically wraps SyncWorker::run(). Called last in onOnline(). |
shouldContinue |
bool() |
Return false to abort the current onOnline() early (e.g. monitor reports offline mid-retry). Polled before each attempt and once more before replay. |
sleep |
void(std::chrono::milliseconds) |
Sleep between failed attempts. Tests substitute a no-op/counter; hosts wire to std::this_thread::sleep_for. |
Within a successful onOnline(), the steps run in strict order:
tryReconnect()returnstrue.activatePrimary()— make primary the active backend.bindContext()— rebind per-connection/per-session state.replay()— drain + replay the offline queue.
Step 4 MUST NOT run before step 3, and step 3 MUST NOT run before step 2.
ReconnectOutcome onOnline();Synchronous. Runs the retry loop. For each attempt:
- Check
shouldContinue()— abort if false. - Emit the
reconnectAttemptsmetric, then calltryReconnect()— skip to sleep if false. - On success:
activatePrimary(),bindContext(), re-checkshouldContinue()beforereplay(), returnReconnected. - Sleep
retryDelay(except after the final attempt). - After
maxAttemptsfailures, log a warning and returnGaveUp.
Every return path also emits the reconnectOutcome metric once, tagged
outcome = "Reconnected" / "GaveUp" / "Aborted" (see
observability.md).
void onOffline();Calls activateLocal() then bindContext(). Idempotent — safe to call when
already local.
onOnline() and onOffline() are mutually serialised by an internal mutex.
They are intended to be posted onto a worker executor by the host, not called
directly on the probe thread.
The four types compose into one pipeline. The rule that ties them together:
the probe callback does no work of its own — it posts, and the coordinator
does the sequencing on a worker executor, and the coordinator's replay
dependency wraps SyncWorker::run() over the same queue the application
enqueues into.
morph::offline::InMemoryOfflineQueue queue; // shared by both halves
morph::exec::SomeExecutor worker; // host's worker executor
morph::offline::SyncWorker sync{
queue,
[&](const std::string& payload) { return deliver(payload); } // ReplayFunction
};
morph::offline::ReconnectCoordinator coordinator{{
.tryReconnect = [&] { return backend.reopen(); },
.activatePrimary = [&] { bridge.switchBackend(makePrimary()); },
.activateLocal = [&] { bridge.switchBackend(makeLocal()); },
.bindContext = [&] { session.rebind(); },
.replay = [&] { sync.run(); }, // <-- SyncWorker over the shared queue
.shouldContinue = [&] { return monitor.isOnline(); },
.sleep = [](std::chrono::milliseconds d) { std::this_thread::sleep_for(d); },
}};
// Callbacks run on the probe thread, so they ONLY post — never run the
// coordinator inline (see "NetworkMonitor callback constraint").
morph::offline::NetworkMonitor monitor{
[] { return tcpProbe(); }, // ProbeFunction: bool()
[&] { worker.post([&] { coordinator.onOffline(); }); }, // onOffline
[&] { worker.post([&] { coordinator.onOnline(); }); }, // onOnline
};Flow: the bool() probe drives NetworkMonitor's state machine → on a
transition the callback only posts a lambda to worker (it must not run
reconnect logic inline on the probe thread) → the worker runs
ReconnectCoordinator::onOffline() / onOnline() → a successful onOnline()
calls activatePrimary → bindContext → replay, and replay runs
SyncWorker::run(), which drains and replays the queue the application filled
on the write path (Ownership: who enqueues).
ARCHITECTURE.md shows a simpler wiring where the callbacks call
bridge.switchBackend(...) directly:
morph::offline::NetworkMonitor monitor{
myTcpProbe,
[&] { bridge.switchBackend(std::make_unique<LocalBackend>(localPool)); },
[&] { bridge.switchBackend(std::make_unique<SimulatedRemoteBackend>(server)); }
};Both are legitimate; they are different points on a spectrum:
- Direct
switchBackend— the minimal path. No retry, no ordered replay, no abort-on-flap.switchBackendis a bounded mutex operation (it is not a seconds-long retry loop), so calling it inline on the probe thread is acceptable as a minimal demo. It does not replay a queue and has noshouldContinueguard. ReconnectCoordinator— the ordered, tested path. Use it when reconnect can fail and need retries, when replay must run strictly after activate + bind, and when a mid-retry flap-back-offline must abort cleanly. This is the path with the ordering invariant and the guarantees this file documents. Its own callbacks must be posted off the probe thread precisely because the retry loop can run for seconds.
Rule of thumb: a demo or a backend switch with no pending writes can use direct
switchBackend; anything that must not lose queued writes on a flaky link uses
the coordinator, with replay wrapping SyncWorker::run().
The pipeline has several sharp edges that callers must design around. None are bugs — they are consequences of the deliberately minimal contracts.
SyncWorker::run() does not stop at the first failing item. When
_replay returns false (or throws) it increments that item's attempt counter
and continues to the next item, replaying and markDone-ing later items that
succeed. Therefore "enqueue order is preserved" holds only when every item
succeeds. If item #2 fails and item #3 succeeds, #3 is delivered and removed
while #2 stays queued for a later run() — the backend sees #3 before a
subsequent retry of #2. Callers that need strict ordering across failures must
enforce it themselves (e.g. a replay function that refuses to process #3 until
#2 lands).
QueueItem::attempts carries the durable retry count, and SyncWorker seeds
its own counter from the larger of that field and its in-memory
std::unordered_map<uint64_t,uint32_t>, writing the updated count back
through IOfflineQueue::setAttempts() after every failed replay. Whether the
budget survives a restart depends entirely on the queue: InMemoryOfflineQueue
overrides setAttempts() to update its in-deque item (so a fresh SyncWorker
over the same, still-alive instance sees the persisted count — used to
simulate a restart in tests), but it does not survive the process exiting.
setAttempts()'s default is a no-op, so a queue that does not override it
always reports attempts == 0 on drain(), and SyncWorker's in-memory
count is the only thing tracking retries — it resets whenever a fresh
SyncWorker is constructed, exactly as before this hook existed.
FileOfflineQueue and SqliteOfflineQueue both override setAttempts() to
write the count to disk, so the retry budget — and therefore dead-lettering —
genuinely survives a process restart when either is used as the
IOfflineQueue behind SyncWorker.
onOnline() returns ReconnectOutcome::Reconnected after a successful
tryReconnect + activatePrimary + bindContext, but it re-checks
shouldContinue() once more before replay(). If that final check is false
(the backend went away again during activate/bind), replay() is skipped and
the outcome is still Reconnected. Reconnected means "we reconnected and
bound," not "the queue was replayed." A caller that keys off the outcome to
decide whether the queue is drained will be wrong in this window.
NetworkMonitor::run() wait_fors probeInterval before the first probe, so
the first probe is at t = probeInterval, and failureThreshold consecutive
failures are needed to flip offline. The earliest an onOffline can fire is
probeInterval * failureThreshold — ≈15s at defaults (5s × 3). An app that is
offline from the very start still reports online for that whole window.
Separately, the monitor starts in the online state, and callbacks fire only
on transitions, so onOnline never fires at startup — there is no
online→online edge. Startup activation is the host's job (call onOnline() /
activatePrimary explicitly at boot if the backend is expected up).
ReconnectCoordinator's constructor only logs null Deps members (in all
builds, via assertDepsNonNull calling morph::log::logError); it does not
throw. A coordinator built with a null tryReconnect/replay/etc. constructs
fine and later crashes when onOnline()/onOffline() invokes the null
std::function. Treat the logged error line as the only warning you get.
onOnline() takes _mtx at entry and holds it across the whole loop —
including every sleep(retryDelay) — for up to maxAttempts * retryDelay
(≈20s at defaults). Because onOffline() shares that mutex, a
flap-back-offline cannot preempt an in-progress onOnline() by acquiring the
lock; it can only take effect through shouldContinue() returning false at
the next poll. Wire shouldContinue to the live monitor state
(monitor.isOnline()) so a flap is actually observed, rather than to a stale
snapshot.
Honest boundaries of what ships today:
- Opaque
std::stringpayload discards the typed-codec machinery. The rest of morph moves typed actions through the wire codec (wire.md); the offline queue stores an opaque blob and hands an opaque blob to the replay function. Serialisation, versioning, and type-safety across the enqueue→replay boundary are entirely on the caller — the compiler will not catch a format mismatch. - Replay must be idempotent; the queue supplies a key but not enforcement.
drain()is non-destructive andmarkDoneruns only after a successful replay, so a crash (or afalsereturn) after the side effect has committed re-invokesreplayon the same payload on the nextrun(). Retries and post-commit failures both re-run the payload.QueueItem::idempotencyKeygives a replay consumer a stable token to dedup on (including against journal replay — seeidempotencyKey: deduping against the journal), but the queue only stores it: it performs no dedup and gives no "exactly-once" guarantee itself. The replay function MUST still be idempotent — either intrinsically, or by checking the key — and the spec cannot enforce it. - Two durable reference queues ship, plus the in-memory default.
InMemoryOfflineQueueloses everything on exit and remains the default.FileOfflineQueue(NDJSON, no extra dependency, ships by default) andSqliteOfflineQueue(opt-in viaMORPH_BUILD_OFFLINE_SQLITE) persistpayload,idempotencyKey, andattemptsacross restarts. A host that needs a different store (a different SQL engine, a remote queue service) still writes its ownIOfflineQueue— the interface remains the seam. - Dead-lettering has an optional recovery hook, but no built-in dead-letter
store. A poison item that exhausts its 5 cumulative attempts is
markDone-d (dropped); if the host set aSyncWorker::DeadLetterSink, it receives the exhaustedQueueItem(payload, idempotencyKey, finalattemptscount) instead of the default log line, so it can persist, forward, or re-enqueue the item into a separate dead-letter queue of its own. With no sink set, the original log-only behavior is unchanged (written tomorph::logat error level; if the log sink drops it, it is gone). Either way, morph ships no concrete dead-letter store — the sink is the seam, not a built-in queue. - Null
Depsare not rejected at construction (see Failure modes) — a misconfigured coordinator is a latent crash, not a constructor error. onOnline()serialises the whole retry loop under one mutex, so responsiveness to a mid-retry state change is bounded only by theshouldContinue()poll cadence, not by lock hand-off.
| Decision | Choice | Why |
|---|---|---|
| Monitor probe interval | Caller-chosen, default 5s | Tunable per application; 5s is polite for most backends. |
| State transitions use thresholds | failureThreshold / onlineThreshold |
A single failed probe does not flip state — hysteresis avoids flapping on transient blips. |
| Probe exceptions | Swallowed, treated as false |
A crashing probe should not tear down the monitor; the host fixes the probe. |
| Queue interface | Minimal virtual interface (IOfflineQueue) |
Lets callers swap in SQLite, file-backed, or test queues without framework changes. |
drain is non-destructive |
Items survive between drain() and markDone() |
Crash safety: a crash after drain() but before markDone() does not lose items. |
| SyncWorker retry count | Hard-coded at 5, no public knob | The framework guarantees obvious, safe defaults; apps that need different math wrap or replace SyncWorker. |
QueueItem::attempts / setAttempts() |
Opt-in durable retry count, no-op default | Lets a durable queue make SyncWorker's retry budget survive a restart without changing InMemoryOfflineQueue's or existing SyncWorker call sites' behavior. |
DeadLetterSink |
Optional third constructor arg, replaces (not augments) the log line | Gives a host a programmatic hand-off for a poisoned item; a throwing sink is caught and logged, the item is still removed — consistent with the framework's swallow-and-continue policy. |
| Two shipped durable queues, split by dependency | FileOfflineQueue in the default target; SqliteOfflineQueue opt-in |
A host that cannot add a SQLite dependency still gets restart-durability for free; a host that wants indexed dedup and can accept the dependency opts in via MORPH_BUILD_OFFLINE_SQLITE. |
Idempotency-key dedup strengthened in SqliteOfflineQueue only |
Partial unique index on non-empty idempotency_key |
The base IOfflineQueue contract only stores the key; the SQLite reference implementation additionally enforces insert-time dedup as a deliberate strengthening, not a contract change — FileOfflineQueue mirrors the same dedup behavior (linear scan) for parity, but neither is required by the interface. |
| SyncWorker thread safety | Internal mutex serialises run() |
Second caller blocks — safe to fire from multiple executors. |
| Reconnect retry loop | Synchronous, no background thread | The host owns the executor; the coordinator is pure orchestration with no hidden threads. |
| Reconnect step ordering | Explicit in the onOnline() body |
The strict order (reconnect → activate → bind → replay) is the class's reason to exist — callers should never have to get it right themselves. |
onOnline() / onOffline() serialised |
Same internal mutex | Prevents a race where a concurrent onOffline() replays into a local backend during an in-progress onOnline(). |
shouldContinue re-checked before replay |
Second poll after bind | The backend may have gone away during activatePrimary() / bindContext() — never replay into a backend that just became unreachable. |
| No sleep after final attempt | retryDelay skipped on last iteration |
Wasting 2s after we already know we're giving up serves no purpose. |
| Conflict resolution lives in the model | SyncWorker has no conflict hook; models reconcile in onBackendChanged() |
The framework cannot know whether a payload was superseded — only the domain model can. Keeping SyncWorker's contract a plain bool avoids baking a conflict model into the framework; hosts that need merge/discard drain the queue inside onBackendChanged() instead (see Conflict resolution on replay). |
bridge.md—Bridge::switchBackendis the mechanism the coordinator'sactivatePrimary/activateLocaldependencies drive (re-registers live handlers on the new backend, firesonBackendChanged). ARCHITECTURE.md's minimal wiring callsswitchBackendstraight from the monitor callback; see Reconciling with ARCHITECTURE.md's direct wiring.onBackendChangedon the freshly-reconstructed model is also the seam for the model-driven replay path in Conflict resolution on replay — a model candrain()the sharedIOfflineQueuethere and reconcile each item, instead of (not in addition to) usingSyncWorker.journal.md— the action log is a permanent, append-only audit/replay trail;IOfflineQueueis transient (holds pending writes, deletes them on delivery). The two are distinct: the journal's ordering is authoritative and entries already in a sink are never removed by the framework (append-only), though a durable sink that throws duringcheckpoint()can permanently lose that batch (watermark-advances-first — see journal.md's failure modes). Offline replay ordering only holds when every item succeeds (see Failure modes). Do not conflate the offline queue's replay with journal replay.file_action_log.hpp—FileOfflineQueue's torn-trailing-line tolerance and fsync-per-write durability directly mirrorFileActionLog's (seedocs/spec/journal/journal.md); the two differ in thatFileOfflineQueuealso tombstones and reuses no id, since (unlike the append-only action log) its rows are removed and its retry counts are mutated in place.concurrency_and_lifetimes.md— the framework-wide rule that notification callbacks marshal work off the raising thread (the reason NetworkMonitor callbacks must only post), plus the monitor's teardown/stop()-from-callback contract.error_handling.md— how a failedexecute()surfaces to the application (the signal that drives enqueue-on-failure), and the framework's swallow-and-treat-as-failure policy that this file mirrors insafeProbe,SyncWorker's replaytry/catch,SyncWorker'sDeadLetterSinktry/catch, and the coordinator'scallTryReconnect/callShouldContinue.observability.md—SyncWorker::run()'squeueDepthgauge andReconnectCoordinator::onOnline()'sreconnectAttempts/reconnectOutcomecounters, fed through the same injectablemorph::observeseamRemoteServer/LocalBackenduse.testing_strategy.md— the soak test (tests/soak/test_soak_reconnect_churn.cpp) that drives this exactNetworkMonitor→ReconnectCoordinator→SyncWorkerpipeline through hundreds of offline/online flaps and asserts the queue always fully drains and every reconnect attempt succeeds.