morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
sync_worker.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <atomic>
6#include <cstdint>
7#include <functional>
8#include <memory>
9#include <string>
10#include <unordered_map>
11#include <utility>
12
13#include "../attributes.hpp"
14#include "../core/completion.hpp"
16#include "../core/executor.hpp"
17#include "../core/logger.hpp"
18#include "../core/observability.hpp"
19#include "../core/profiler.hpp"
20#include "offline_queue.hpp"
21
22namespace morph::offline {
23
24/// @brief What one replay attempt actually achieved.
25///
26/// The distinction that matters is **delivery**, not success: a replay the
27/// server refused and a replay the server never saw both leave the item
28/// queued, but only the first is evidence about the payload. Charging both to
29/// the same retry budget means a run of reconnect flaps dead-letters work that
30/// was never offered to anyone — reported to the user as a change that could
31/// not be applied, and dropped. See `SyncWorker`'s retry documentation and
32/// docs/spec/offline/offline.md.
33enum class ReplayOutcome : std::uint8_t {
34 /// @brief Delivered and accepted. The item is removed from the queue.
35 Succeeded,
36
37 /// @brief Delivered, and the peer refused it. Charges one attempt against
38 /// the retry budget; dead-letters the item once the budget is spent.
39 /// This is what `false` means on the boolean `ReplayFunction`.
40 Rejected,
41
42 /// @brief Never reached the peer (the connection was down, or dropped
43 /// before anything was committed). Charges **nothing**: the item
44 /// stays queued with its attempt count untouched, in memory and on
45 /// disk alike. Report this only when the payload demonstrably was
46 /// not acted on — an outcome that is really "I don't know" must be
47 /// `Rejected`, or a genuinely poisonous payload is retried forever.
48 Undelivered,
49};
50
51/// @brief Aggregated result returned by `SyncWorker::run()`.
52struct SyncResult {
53 /// @brief Number of items successfully replayed and removed from the queue.
54 int successful = 0;
55
56 /// @brief Number of items that were delivered, refused, and left in the
57 /// queue for retry, each having spent one attempt.
58 int failed = 0;
59
60 /// @brief Number of items whose replay never reached the peer. They remain
61 /// in the queue with their attempt count unchanged, so they are
62 /// neither progress nor a step towards being dead-lettered. Items
63 /// left queued by this run are `failed + undelivered`.
64 int undelivered = 0;
65
66 /// @brief Number of items that exhausted their retry budget and were dropped
67 /// from the queue (handed to the `DeadLetterSink` if one is set,
68 /// otherwise logged via `morph::log::logError`).
69 int deadLettered = 0;
70};
71
72/// @brief Replays queued actions from an `IOfflineQueue` on reconnect.
73///
74/// `SyncWorker` drains the queue and calls a caller-supplied replay function
75/// for each item. The framework has no knowledge of what "replay" means —
76/// that is entirely the caller's domain. Retry semantics are baked in with
77/// hard-coded sensible defaults; there are intentionally no setters.
78///
79/// @par Retry & dead-letter (built-in defaults)
80/// - Each item is retried up to **5 cumulative attempts**. The count is seeded
81/// from the larger of the drained `QueueItem::attempts` (durable, if the
82/// queue persists it) and this worker's own in-memory count, so a queue that
83/// overrides `IOfflineQueue::setAttempts()` makes the budget survive a
84/// process restart; a queue that does not (the default no-op) keeps the
85/// count purely in-memory, exactly as before.
86/// - **Only a *delivered* failure spends an attempt.** A replay reporting
87/// `ReplayOutcome::Undelivered` leaves the item queued with its count
88/// untouched, in memory and on disk alike, so a run of reconnect flaps
89/// cannot walk queued work to the dead-letter sink. The boolean
90/// `ReplayFunction` cannot express that outcome; a caller that can tell the
91/// two apart should use `DetailedReplayFunction`.
92/// - After every *charged* failure, the new count is written back through
93/// `IOfflineQueue::setAttempts()` (a no-op unless the queue overrides it) so
94/// a persisting queue's next `drain()` — this run or after a restart — sees
95/// the updated value.
96/// - Items that fail their 5th cumulative attempt are dropped from the queue.
97/// If a `DeadLetterSink` is set, it is invoked with the exhausted item
98/// instead of the default log line; if unset, the item is logged at
99/// `morph::log::LogLevel::error` (the payload appears in the log line) —
100/// today's behavior, unchanged.
101/// - Items that succeed reset their attempt counter implicitly (they're removed).
102/// - There is no public knob on the retry cap — if your application needs
103/// different retry math, wrap or replace `SyncWorker`. The framework's
104/// promise is "obvious, safe defaults that the GUI never has to think about."
105///
106/// @par Replay contract
107/// `DetailedReplayFunction` (three outcomes — prefer it):
108/// - `ReplayOutcome::Succeeded` → removed from the queue.
109/// - `ReplayOutcome::Rejected` → delivered and refused; attempt counter
110/// incremented; left in queue if under the
111/// cap, otherwise dropped (dead-lettered).
112/// - `ReplayOutcome::Undelivered` → never reached the peer; **nothing is
113/// charged** and the item stays queued.
114///
115/// `ReplayFunction` (two outcomes — unchanged for existing callers):
116/// - Return `true` → same as `Succeeded`.
117/// - Return `false` → same as `Rejected`. It is deliberately *not* read as
118/// `Undelivered`: every caller written against the boolean
119/// form means "this attempt failed", and silently treating
120/// that as undelivered would retry a poison payload forever.
121///
122/// Either form:
123/// - Throw → treated as `Rejected`. A throw reports failure but not delivery,
124/// and an unknown failure has to be charged for the same reason.
125///
126/// @par One owner
127/// A worker belongs to the executor it is given, which must run one task at a
128/// time: the coordinator's offline strand (`ReconnectCoordinator::strand()`),
129/// or any other strand. Its queue drain, attempt counts and dead-lettering run
130/// only in that owner's tasks. `run()` is callable from any thread: on the
131/// owner it drains at once; elsewhere it posts the drain to the owner, so two
132/// runs never overlap. Its answer is delivered on the executor the caller
133/// names, so a caller attaches to it where it already runs. `stop()` is the
134/// one cross-thread signal, an atomic.
135///
136/// The drain calls the queue's synchronous verbs, which answer only on the
137/// queue's own owner (see `IOfflineQueue`, "One owner"): give the queue the
138/// worker's owner, or the executor that strand runs over.
140public:
141 /// @brief Callable that attempts to replay a single queued item, reporting
142 /// only success or failure.
143 ///
144 /// Retained unchanged for existing callers, and `false` keeps meaning
145 /// exactly what it always did — `ReplayOutcome::Rejected`, i.e. one attempt
146 /// spent. A caller that can tell an undelivered replay from a refused one
147 /// should use `DetailedReplayFunction` instead; this overload cannot
148 /// express the difference and so cannot avoid the dead-lettering it causes.
149 using ReplayFunction = std::function<bool(const std::string& payload)>;
150
151 /// @brief Callable that attempts to replay a single queued item and reports
152 /// what it achieved.
153 ///
154 /// The three-outcome counterpart of `ReplayFunction`: it is the only form
155 /// that can report `ReplayOutcome::Undelivered` and so keep a reconnect
156 /// flap from spending the item's retry budget.
157 using DetailedReplayFunction = std::function<ReplayOutcome(const std::string& payload)>;
158
159 /// @brief Callable invoked when an item exhausts its retry budget, just
160 /// before it is removed from the queue.
161 ///
162 /// Receives the exhausted item (payload, idempotencyKey, and its final
163 /// cumulative attempt count). If unset, `SyncWorker` keeps the default
164 /// behavior: log at `morph::log::LogLevel::error` and drop. A throwing
165 /// sink is caught and logged — the item is still removed.
166 using DeadLetterSink = std::function<void(const QueueItem& poisoned)>;
167
168 /// @brief Constructs a worker that drains @p queue using @p replay, on
169 /// @p owner.
170 /// @param owner The executor every drain runs on; must run one task
171 /// at a time (a strand). Borrowed: it must outlive this
172 /// worker and keep running tasks until it is destroyed.
173 /// @param queue Queue to drain on each `run()` call, belonging to
174 /// @p owner (or to the executor it runs over).
175 /// Borrowed, not owned: it must outlive this worker.
176 /// @param replay Function called for each pending item. Stored and
177 /// invoked for this worker's whole lifetime, so
178 /// anything the callable refers to must outlive it.
179 /// @param deadLetterSink Optional hook invoked with the exhausted item
180 /// instead of the default log-and-drop path when an
181 /// item exhausts its retry budget. Default: unset.
182 /// Retained on the same terms as @p replay.
183 SyncWorker(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND, IOfflineQueue& queue MORPH_LIFETIMEBOUND,
184 ReplayFunction replay MORPH_LIFETIMEBOUND, DeadLetterSink deadLetterSink MORPH_LIFETIMEBOUND = nullptr)
185 : SyncWorker{owner, queue,
186 [replay = std::move(replay)](const std::string& payload) {
187 return replay(payload) ? ReplayOutcome::Succeeded : ReplayOutcome::Rejected;
188 },
189 std::move(deadLetterSink)} {}
190
191 /// @brief Constructs a worker that drains @p queue using @p replay, which
192 /// distinguishes an undelivered replay from a rejected one.
193 ///
194 /// Identical to the overload above except for the replay callable's return
195 /// type. Only `ReplayOutcome::Rejected` spends an attempt, so a reconnect
196 /// flap that never reached the peer no longer walks queued work towards the
197 /// dead-letter sink.
198 ///
199 /// The two overloads are unambiguous: `ReplayOutcome` is a scoped enum, so
200 /// neither return type implicitly converts to the other.
201 ///
202 /// @param owner The executor every drain runs on; must run one task
203 /// at a time (a strand). Borrowed: it must outlive this
204 /// worker and keep running tasks until it is destroyed.
205 /// @param queue Queue to drain on each `run()` call, belonging to
206 /// @p owner (or to the executor it runs over).
207 /// Borrowed, not owned: it must outlive this worker.
208 /// @param replay Function called for each pending item. Stored and
209 /// invoked for this worker's whole lifetime, so
210 /// anything the callable refers to must outlive it.
211 /// @param deadLetterSink Optional hook invoked with the exhausted item
212 /// instead of the default log-and-drop path when an
213 /// item exhausts its retry budget. Default: unset.
214 /// Retained on the same terms as @p replay.
215 SyncWorker(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND, IOfflineQueue& queue MORPH_LIFETIMEBOUND,
216 DetailedReplayFunction replay MORPH_LIFETIMEBOUND,
217 DeadLetterSink deadLetterSink MORPH_LIFETIMEBOUND = nullptr)
218 : _owner{owner}, _queue{queue}, _replay{std::move(replay)}, _deadLetterSink{std::move(deadLetterSink)} {}
219
220 /// @brief Drains the queue, replaying each item via the replay function,
221 /// on the owner.
222 ///
223 /// On the owner the drain runs before this returns; from any other thread
224 /// it is posted there, after any drain posted before it. It processes every
225 /// pending item, or stops early once `stop()` is signalled.
226 ///
227 /// If `stop()` was called before the drain starts, the drain does nothing,
228 /// resets the stop flag and answers an empty result.
229 ///
230 /// A posted drain refers to this worker, so the worker must outlive every
231 /// drain it has posted: destroy it once every `run()` has settled, or
232 /// after its owner is closed.
233 ///
234 /// @param replyExec Executor the answer is delivered on and the caller
235 /// attaches its callbacks on: the caller's own. Borrowed: it must
236 /// outlive the returned `Completion`.
237 /// @return A `Completion` settled on the owner with the counts of
238 /// successful / failed / dead-lettered replays, delivered on
239 /// @p replyExec.
241 auto settleable = ::morph::async::Completion<SyncResult>::makeSettleable(&replyExec);
242 if (::morph::exec::runningOn(_owner)) {
243 settleable.second.resolve(drainHere());
244 return std::move(settleable.first);
245 }
246 _owner.post([this, promise = std::make_shared<::morph::async::Completion<SyncResult>::Promise>(
247 std::move(settleable.second))] { promise->resolve(drainHere()); });
248 return std::move(settleable.first);
249 }
250
251 /// @brief Signals an in-progress drain to stop after the current item.
252 ///
253 /// Callable from any thread. The drain clears the flag at its start, so
254 /// stopping is one-shot — but a `stop()` that lands *during* a drain leaves
255 /// the flag set on return, so the next drain takes its early-out and drains
256 /// nothing; work resumes only on the drain after that.
257 void stop() { _stopped.store(true); }
258
259private:
260 /// @brief The drain itself. On the owner.
261 /// @return Counts of successful / failed / dead-lettered replays.
262 SyncResult drainHere() {
263 MORPH_ZONE("SyncWorker::drain");
264 ::morph::exec::detail::noteOwner("SyncWorker::run", _owner.coreExecutor(), ::morph::exec::runningOn(_owner));
265 bool const wasStoppedBeforeRun = _stopped.exchange(false);
266 SyncResult result;
267 if (wasStoppedBeforeRun) {
268 return result;
269 }
270
271 auto items = _queue.drain();
272 ::morph::observe::detail::emitMetric(::morph::observe::Metric::queueDepth, static_cast<double>(items.size()));
273 for (auto& item : items) {
274 if (_stopped.load()) {
275 break;
276 }
277 MORPH_ZONE("SyncWorker::replay");
278 ReplayOutcome outcome = ReplayOutcome::Rejected;
279 try {
280 outcome = _replay(item.payload);
281 } catch (...) {
282 // A throw says the replay failed but not whether it was
283 // delivered, and "I don't know" has to be charged: treating an
284 // unknown failure as undelivered would retry a genuinely
285 // poisonous payload forever. Same reading `false` has always
286 // had on the boolean overload.
287 outcome = ReplayOutcome::Rejected;
288 }
289 if (outcome == ReplayOutcome::Succeeded) {
290 _queue.markDone(item.id);
291 _attempts.erase(item.id);
292 ++result.successful;
293 continue;
294 }
295 if (outcome == ReplayOutcome::Undelivered) {
296 // Nothing reached the peer, so there is no evidence about this
297 // payload and nothing to charge. Deliberately touches neither
298 // `_attempts` nor `setAttempts()`: the budget is durable, so
299 // leaving the in-memory count alone while advancing the
300 // persisted one would still walk the item towards the
301 // dead-letter sink across a restart.
302 ++result.undelivered;
303 continue;
304 }
305 // The effective count is the larger of the persisted item.attempts
306 // (0 unless the queue overrides setAttempts) and this worker's own
307 // in-memory count, so a durable queue's cross-restart count is
308 // never regressed by a fresh (empty) in-memory map.
309 auto& counter = _attempts[item.id];
310 counter = std::max(counter, item.attempts);
311 ++counter;
312 _queue.setAttempts(item.id, counter);
313 if (counter >= kMaxAttempts) {
314 if (_deadLetterSink) {
315 QueueItem poisoned = item;
316 poisoned.attempts = counter;
317 try {
318 _deadLetterSink(poisoned);
319 } catch (...) {
320 ::morph::log::logError("[sync_worker] dead-letter sink threw while handling payload: " +
321 item.payload);
322 }
323 } else {
324 ::morph::log::logError("[sync_worker] dropping payload after " + std::to_string(kMaxAttempts) +
325 " failed attempts: " + item.payload);
326 }
327 _queue.markDone(item.id);
328 _attempts.erase(item.id);
329 ++result.deadLettered;
330 } else {
331 ++result.failed;
332 }
333 }
334 return result;
335 }
336
337 /// @brief Cap on per-item cumulative retry attempts. Intentionally
338 /// hard-coded — see class docs.
339 static constexpr uint32_t kMaxAttempts = 5;
340
341 ::morph::exec::IExecutor& _owner;
342 IOfflineQueue& _queue;
343 // Always the three-outcome form: the boolean constructor adapts into it, so
344 // `run()` has exactly one contract to implement rather than two.
345 DetailedReplayFunction _replay;
346 DeadLetterSink _deadLetterSink;
347 std::atomic<bool> _stopped{false};
348 std::unordered_map<uint64_t, uint32_t> _attempts;
349};
350
351} // namespace morph::offline
Producer-side handle paired with a Completion<T> by makeSettleable().
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.
std::function< void(const QueueItem &poisoned)> DeadLetterSink
Callable invoked when an item exhausts its retry budget, just before it is removed from the queue.
std::function< bool(const std::string &payload)> ReplayFunction
Callable that attempts to replay a single queued item, reporting only success or failure.
SyncWorker(::morph::exec::IExecutor &owner, IOfflineQueue &queue, DetailedReplayFunction replay, DeadLetterSink deadLetterSink=nullptr)
Constructs a worker that drains queue using replay, which distinguishes an undelivered replay from a ...
std::function< ReplayOutcome(const std::string &payload)> DetailedReplayFunction
Callable that attempts to replay a single queued item and reports what it achieved.
::morph::async::Completion< SyncResult > run(::morph::exec::IExecutor &replyExec)
Drains the queue, replaying each item via the replay function, on the owner.
void stop()
Signals an in-progress drain to stop after the current item.
SyncWorker(::morph::exec::IExecutor &owner, IOfflineQueue &queue, ReplayFunction replay, DeadLetterSink deadLetterSink=nullptr)
Constructs a worker that drains queue using replay, on owner.
The check every loop-owned body makes on entry: "am I on my owner?".
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_ZONE(name)
Opens a profiler zone named name that ends with the enclosing scope. At most one per scope.
Definition profiler.hpp:118
Abstract executor interface.
Definition executor.hpp:70
Interface for durable storage of actions that could not be delivered.
An item stored in the offline queue.
Aggregated result returned by SyncWorker::run().
int undelivered
Number of items whose replay never reached the peer. They remain in the queue with their attempt coun...
int deadLettered
Number of items that exhausted their retry budget and were dropped from the queue (handed to the Dead...
int successful
Number of items successfully replayed and removed from the queue.
int failed
Number of items that were delivered, refused, and left in the queue for retry, each having spent one ...