morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
offline_queue.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <concepts>
6#include <cstdint>
7#include <deque>
8#include <exception>
9#include <memory>
10#include <optional>
11#include <stdexcept>
12#include <string>
13#include <type_traits>
14#include <vector>
15
16#include "../attributes.hpp"
17#include "../core/completion.hpp"
19#include "../core/executor.hpp"
20#include "../core/observability.hpp"
21#include "../core/profiler.hpp"
22
23namespace morph::offline {
24
25/// @brief A replay attempt count, as a type distinct from a queue item id.
26///
27/// `setAttempts(itemId, attempts)` takes two integers that mean entirely
28/// different things, and before this type existed both were plain unsigned
29/// integers, mutually convertible in either direction. Transposing them
30/// compiled, and — because `setAttempts()` on an unknown id is a documented
31/// no-op — it also *ran*: the real item's count never advanced, nothing threw,
32/// and the defect surfaced much later as a retry budget that never exhausts.
33///
34/// `Attempts` removes the hazard rather than suppressing the warning about it.
35/// It is implicitly constructible from a narrow integer, so `setAttempts(id, 3)`
36/// reads exactly as it did before, and **deliberately not constructible from a
37/// 64-bit integer**, which is what a `QueueItem::id` is — so the transposed
38/// call does not compile. A genuinely 64-bit count is still expressible, with
39/// the narrowing spelled out at the call site:
40/// `setAttempts(id, static_cast<std::uint32_t>(count))`.
41class Attempts {
42public:
43 /// @brief Constructs a zero attempt count.
44 constexpr Attempts() noexcept = default;
45
46 /// @brief Implicitly wraps a narrow integral attempt count.
47 ///
48 /// Constrained to integral types **strictly narrower than a queue item
49 /// id**, which is the whole point of the class: `uint64_t` (and any other
50 /// 64-bit integer) is rejected, so passing an item id where an attempt
51 /// count belongs is a compile error rather than a silent no-op. `bool` is
52 /// excluded because a boolean is never an attempt count.
53 ///
54 /// @tparam Count Integral type of the incoming count.
55 /// @param count The attempt count to wrap.
56 template <typename Count>
57 requires(std::integral<Count> && !std::same_as<std::remove_cv_t<Count>, bool> &&
58 sizeof(Count) < sizeof(std::uint64_t))
59 constexpr Attempts(Count count) noexcept : _value{static_cast<std::uint32_t>(count)} {}
60
61 /// @brief Returns the wrapped count.
62 /// @return The attempt count as a plain `uint32_t`.
63 [[nodiscard]] constexpr std::uint32_t value() const noexcept { return _value; }
64
65 /// @brief Compares two attempt counts.
66 /// @param other The count to compare against.
67 /// @return `true` when both wrap the same value.
68 [[nodiscard]] constexpr bool operator==(const Attempts& other) const noexcept = default;
69
70private:
71 std::uint32_t _value{0};
72};
73
74static_assert(!std::is_constructible_v<Attempts, std::uint64_t>,
75 "Attempts must not be constructible from a queue item id, or setAttempts()'s two "
76 "parameters become mutually convertible again and a transposed call compiles.");
77static_assert(std::is_convertible_v<std::uint32_t, Attempts>,
78 "Attempts must stay implicitly constructible from a narrow count, so existing "
79 "setAttempts(id, n) call sites keep reading the way they did.");
80
81/// @brief An item stored in the offline queue.
82///
83/// The payload is an opaque string — the caller controls the serialisation
84/// format (JSON, binary-hex, plain text, etc.).
85struct QueueItem {
86 /// @brief Stable identifier assigned at enqueue time.
87 ///
88 /// Local to *this* queue instance; it is **not** a cross-subsystem
89 /// idempotency key (it is queue-local and the journal never sees it; both
90 /// shipped durable queues re-present the *stored* id after a restart). Use
91 /// `idempotencyKey` to dedup a replay against already-applied ops.
92 uint64_t id{};
93
94 /// @brief Opaque serialised representation of the queued action.
95 std::string payload;
96
97 /// @brief Optional caller-supplied idempotency key that is **stable across
98 /// subsystems and process restarts** for one logical operation.
99 ///
100 /// Empty by default. When set, it is the shared dedup token that lets a
101 /// replay consumer recognise an op the journal (or a previous replay) has
102 /// already applied and skip it, instead of double-applying. The offline
103 /// queue and the journal replay have no shared identity otherwise — the
104 /// queue's `id` is queue-local and the journal's `seq` is journal-local —
105 /// so a host that replays through both paths must wire this key (see
106 /// `docs/spec/offline/offline.md`, "`idempotencyKey`: deduping against the journal").
107 /// The value is opaque to the queue; a good choice is a stable content hash
108 /// or a client-minted operation id (e.g. a UUID) reused if the same op is
109 /// re-enqueued.
110 ///
111 /// The queue never *interprets* the key, and the interface does not
112 /// **require** an implementation to enforce uniqueness on it — a replay
113 /// consumer must therefore always dedup on the key itself, because a
114 /// conforming queue may hand it the same key twice. An implementation is
115 /// nonetheless **permitted** to dedup a non-empty key at enqueue time as a
116 /// strengthening of that floor; see `IOfflineQueue::enqueue(std::string,
117 /// std::string)` for exactly what a dedup hit does.
118 std::string idempotencyKey;
119
120 /// @brief Durable retry count for this item, authoritative when the queue
121 /// persists it.
122 ///
123 /// Defaults to `0`. A queue that overrides `IOfflineQueue::setAttempts()`
124 /// to store this value makes the retry budget survive a process restart:
125 /// `SyncWorker` seeds its own attempt counter from the larger of this
126 /// field and its in-memory count, and writes the updated count back via
127 /// `setAttempts()` after every failed replay. A queue that leaves
128 /// `setAttempts()` as the default no-op never advances this field, so
129 /// `SyncWorker`'s in-memory counter stays authoritative — today's
130 /// process-local retry behavior, unchanged.
131 uint32_t attempts{0};
132};
133
134/// @brief Thrown by `enqueue()` when the queue is at its configured `maxDepth()`.
135///
136/// `IOfflineQueue` enforces a reject-newest overflow policy: once the queue
137/// holds `maxDepth()` items, a further `enqueue()` throws instead of silently
138/// evicting an older item or invoking an app-defined callback. Evicting the
139/// oldest item would destroy data the caller believes durable and break
140/// replay ordering with no error raised anywhere; a host that genuinely wants
141/// different eviction semantics already has the seam for it — subclass
142/// `IOfflineQueue` directly rather than layering a second policy mechanism on
143/// top of this one.
144struct OfflineQueueFullError : std::runtime_error {
145 /// @param maxDepthValue The configured capacity that was reached.
146 /// @param currentSizeValue Number of pending items at the time of rejection
147 /// (equal to maxDepth for a well-behaved implementation).
148 OfflineQueueFullError(std::size_t maxDepthValue, std::size_t currentSizeValue)
149 : std::runtime_error("IOfflineQueue: enqueue rejected, queue is at capacity (" +
150 std::to_string(currentSizeValue) + "/" + std::to_string(maxDepthValue) + ")"),
151 maxDepth{maxDepthValue},
152 currentSize{currentSizeValue} {}
153
154 /// @brief The configured capacity that was reached.
155 std::size_t maxDepth;
156 /// @brief Number of pending items at the time of rejection.
157 std::size_t currentSize;
158};
159
160// ── Interface ─────────────────────────────────────────────────────────────────
161
162/// @brief Interface for durable storage of actions that could not be delivered.
163///
164/// Items accumulate while the system is offline and are replayed by `SyncWorker`
165/// on reconnect. The interface is intentionally minimal so that implementations
166/// can range from in-memory (`InMemoryOfflineQueue`) to SQLite or file-backed stores.
167///
168/// @par One owner
169/// Every queue morph ships belongs to the executor it is given at
170/// construction. Its synchronous verbs answer only there; its writes
171/// (`markDone`, `setAttempts`) run there, posted from anywhere else; a caller
172/// that is not on the owner asks through the completion overloads
173/// (`enqueue(replyExec, …)`, `drain(replyExec)`, `size(replyExec)`). A
174/// `SyncWorker` draining the queue runs on the queue's owner. See
175/// `docs/spec/offline/offline.md`, "One owner".
176// NOLINTBEGIN(cppcoreguidelines-special-member-functions)
178 virtual ~IOfflineQueue() = default;
179
180 /// @brief Appends @p payload to the queue with no idempotency key.
181 ///
182 /// @param payload Serialised action to persist.
183 /// @return A stable id that can be passed to `markDone()`.
184 [[nodiscard]] virtual uint64_t enqueue(std::string payload) = 0;
185
186 /// @brief Appends @p payload carrying the cross-subsystem @p idempotencyKey.
187 ///
188 /// The key is stored on the resulting `QueueItem::idempotencyKey` so a
189 /// replay consumer can dedup this op against ones the journal (or a prior
190 /// replay) already applied (see `QueueItem::idempotencyKey` and
191 /// `docs/spec/offline/offline.md`).
192 ///
193 /// @par Uniqueness is a floor, not a prohibition
194 /// The queue never interprets the key, and this interface does not
195 /// *require* uniqueness enforcement: a conforming implementation may store
196 /// the same non-empty key twice, so a replay consumer must dedup on the key
197 /// regardless. Enqueue-time dedup is an allowed **strengthening**, not a
198 /// contract violation. Of the implementations morph ships,
199 /// `InMemoryOfflineQueue` never dedups, while `FileOfflineQueue` (linear
200 /// scan) and `SqliteOfflineQueue` (partial unique index) both do.
201 ///
202 /// @par What a dedup hit does, in every implementation that dedups
203 /// - It applies only to a **non-empty** key already carried by a *pending*
204 /// item. An empty key is never a dedup token — two empty-key enqueues
205 /// always produce two distinct items, in every implementation.
206 /// - The call **succeeds** and returns the **existing** item's id rather
207 /// than a fresh one, so the return value is not a reliable signal that
208 /// anything was stored.
209 /// - It is **first-write-wins with silent payload loss**: @p payload is
210 /// discarded, the pending item keeps the payload it already had, and no
211 /// error is raised and nothing is reported to the caller. A caller that
212 /// re-enqueues a *corrected* payload under an unchanged key therefore
213 /// loses the correction — mint a new key when the payload changes.
214 /// - `markDone()` releases the key: once the pending item is gone, the same
215 /// key enqueues normally again.
216 /// - It survives a restart in a durable queue — re-enqueuing a key that a
217 /// pending persisted item still carries is a hit after a reopen.
218 ///
219 /// These guarantees are pinned for every shipped implementation by
220 /// `tests/offline_queue_conformance.hpp`, which is told which policy each
221 /// implementation has and asserts it.
222 ///
223 /// The default implementation delegates to `enqueue(std::move(payload))`
224 /// and then stamps the key via `setIdempotencyKey`, so existing
225 /// `IOfflineQueue` implementations keep working without overriding it.
226 ///
227 /// @par Non-atomicity of this default (implementors, read this)
228 /// This default makes **two separate virtual calls**
229 /// (`enqueue(payload)`, then `setIdempotencyKey(itemId, key)`), and nothing
230 /// ties them together. In an implementation reachable from more than one
231 /// thread, a `markDone(itemId)` landing between them could erase the item
232 /// before the key is stamped (silently dropping the key rather than
233 /// throwing), and a `drain()` in that same window can observe the item
234 /// with an *empty* idempotency key. A subclass that stores keys and cares
235 /// about atomicity **must override the two-argument `enqueue`** and stamp
236 /// the key in the same step that inserts the item, exactly as every
237 /// implementation morph ships already does (`FileOfflineQueue`,
238 /// `InMemoryOfflineQueue`, `SqliteOfflineQueue` all override it inline) —
239 /// this base default exists only so a subclass that has not been updated
240 /// yet keeps compiling and working for the non-concurrent case, not as an
241 /// atomicity guarantee.
242 /// @param payload Serialised action to persist.
243 /// @param idempotencyKey Stable dedup token for the logical op; may be empty.
244 /// @return A stable id that can be passed to `markDone()`.
245#pragma GCC diagnostic push
246#pragma GCC diagnostic ignored "-Woverloaded-virtual"
247 [[nodiscard]] virtual uint64_t enqueue(std::string payload, std::string idempotencyKey) {
248 const uint64_t itemId = enqueue(std::move(payload));
249 setIdempotencyKey(itemId, std::move(idempotencyKey));
250 return itemId;
251 }
252#pragma GCC diagnostic pop
253
254 /// @brief Returns all pending items in enqueue order without removing them.
255 ///
256 /// Items remain in the queue until `markDone()` is called. It is safe to
257 /// call `drain()` multiple times — items survive a crash between `drain()`
258 /// and the corresponding `markDone()` call.
259 ///
260 /// @par Enqueue order is the implementation's to keep; the id does not imply it
261 /// "Enqueue order" is a property this method **requires** and that
262 /// `QueueItem::id` does not supply. Every implementation morph ships mints
263 /// ids that happen to increase with insertion — an in-memory counter, a
264 /// file offset, SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT` — so each of
265 /// them satisfies this method by presenting rows ordered by id. That is a
266 /// property of those three stores, not of the type: a `uint64_t` neither
267 /// promises monotonicity nor rules out an id minted from a GUID, a content
268 /// hash, or a sharded sequence, and a store that reuses the id of a removed
269 /// row does not order correctly either. An implementation over such a store
270 /// must carry its own insertion sequence and order on *that*; ordering by
271 /// the id would compile, survive a casual smoke test, and replay a user's
272 /// actions out of order. `tests/offline_queue_conformance.hpp` asserts the
273 /// ordering, so an implementation that gets this wrong fails there.
274 /// @return Snapshot of all pending items.
275 [[nodiscard]] virtual std::vector<QueueItem> drain() const = 0;
276
277 /// @brief Removes the item identified by @p itemId.
278 ///
279 /// No-op if @p itemId is not found.
280 /// @param itemId Id returned by the corresponding `enqueue()` call.
281 virtual void markDone(uint64_t itemId) = 0;
282
283 /// @brief Returns the number of pending items without removing them.
284 ///
285 /// Default implementation calls `drain().size()` — correct but O(n) and
286 /// allocates a full snapshot vector to answer a size query. Override for
287 /// an O(1) or index-backed answer.
288 /// @return Current pending item count.
289 [[nodiscard]] virtual std::size_t size() const { return drain().size(); }
290
291 /// @brief Returns the configured maximum depth, or `std::nullopt` if unbounded.
292 ///
293 /// Default: `std::nullopt` (unbounded) — preserves current behavior for any
294 /// `IOfflineQueue` subclass that predates this method.
295 /// @return The capacity `enqueue()` enforces, or `std::nullopt` if none.
296 [[nodiscard]] virtual std::optional<std::size_t> maxDepth() const { return std::nullopt; }
297
298 /// @brief Persists an updated attempt count for an item. Default: no-op.
299 ///
300 /// A durable queue overrides this to store the count so the retry budget
301 /// survives a restart — `SyncWorker` calls it after every failed replay,
302 /// and reads the persisted value back through `QueueItem::attempts` on
303 /// the next `drain()` (this run, or after a restart). `InMemoryOfflineQueue`
304 /// overrides it to update the in-deque item; a queue that leaves it as
305 /// the default no-op keeps the pre-existing process-local retry behavior
306 /// (`SyncWorker`'s own in-memory counter is then always authoritative,
307 /// since `QueueItem::attempts` never advances).
308 /// @param itemId Id of the item whose attempt count changed.
309 /// @param attempts New cumulative attempt count to persist. Typed, not a
310 /// bare integer, so a transposed call does not compile — see
311 /// `Attempts` for what that silently did before.
312 virtual void setAttempts([[maybe_unused]] uint64_t itemId, [[maybe_unused]] Attempts attempts) {}
313
314 /// @brief Appends @p payload carrying @p idempotencyKey on the queue's
315 /// owner; the new item's id is delivered on @p replyExec.
316 ///
317 /// The form of `enqueue()` for a caller that is not on the queue's owner.
318 /// A full queue rejects the completion with `OfflineQueueFullError`.
319 /// @param replyExec Where the answer is delivered, and where the
320 /// caller attaches to it. Borrowed: it must outlive the completion.
321 /// @param payload Serialised action to persist.
322 /// @param idempotencyKey Stable dedup token for the logical op; may be empty.
323 /// @return The id `enqueue(payload, idempotencyKey)` would have returned,
324 /// or the error it would have thrown.
325 [[nodiscard]] ::morph::async::Completion<uint64_t> enqueue(::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND,
326 std::string payload, std::string idempotencyKey = {}) {
327 return askEnqueue(replyExec, std::move(payload), std::move(idempotencyKey));
328 }
329
330 /// @brief Snapshots the pending items on the queue's owner; delivered on
331 /// @p replyExec.
332 /// @param replyExec Where the answer is delivered. Borrowed: it must
333 /// outlive the completion.
334 /// @return What `drain()` would have returned.
336 ::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND) const {
337 return askDrain(replyExec);
338 }
339
340 /// @brief Counts the pending items on the queue's owner; delivered on
341 /// @p replyExec.
342 /// @param replyExec Where the answer is delivered. Borrowed: it must
343 /// outlive the completion.
344 /// @return What `size()` would have returned.
346 ::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND) const {
347 return askSize(replyExec);
348 }
349
350protected:
351 /// @brief What `enqueue(replyExec, payload, key)` asks. The default
352 /// enqueues where it is called, for an implementation with no
353 /// owner; one with an owner enqueues there.
354 /// @param replyExec Where the answer is delivered.
355 /// @param payload Serialised action to persist.
356 /// @param idempotencyKey Stable dedup token; may be empty.
357 /// @return The new item's id, or the enqueue's error.
358 [[nodiscard]] virtual ::morph::async::Completion<uint64_t> askEnqueue(::morph::exec::IExecutor& replyExec,
359 std::string payload,
360 std::string idempotencyKey) {
361 return answerHere<uint64_t>(replyExec, [&] { return enqueue(std::move(payload), std::move(idempotencyKey)); });
362 }
363
364 /// @brief What `drain(replyExec)` asks. The default drains where it is
365 /// called; one with an owner drains there.
366 /// @param replyExec Where the answer is delivered.
367 /// @return The pending items.
368 [[nodiscard]] virtual ::morph::async::Completion<std::vector<QueueItem>> askDrain(
369 ::morph::exec::IExecutor& replyExec) const {
370 return answerHere<std::vector<QueueItem>>(replyExec, [&] { return drain(); });
371 }
372
373 /// @brief What `size(replyExec)` asks. The default counts where it is
374 /// called; one with an owner counts there.
375 /// @param replyExec Where the answer is delivered.
376 /// @return The pending item count.
377 [[nodiscard]] virtual ::morph::async::Completion<std::size_t> askSize(::morph::exec::IExecutor& replyExec) const {
378 return answerHere<std::size_t>(replyExec, [&] { return size(); });
379 }
380
381 /// @brief Stamps an idempotency key onto an already-enqueued item.
382 ///
383 /// Called by the default `enqueue(payload, key)` overload so implementations
384 /// that only override the single-argument `enqueue` still support keys. The
385 /// default is a no-op (an implementation with no per-item key storage simply
386 /// drops it); `InMemoryOfflineQueue` overrides it to record the key.
387 /// @param itemId Id of the item to stamp.
388 /// @param idempotencyKey Key to store; ignored by the default no-op.
389 virtual void setIdempotencyKey([[maybe_unused]] uint64_t itemId, [[maybe_unused]] std::string idempotencyKey) {}
390
391private:
392 /// Answers @p body's result now, delivered on @p replyExec.
393 template <typename T, typename Body>
394 static ::morph::async::Completion<T> answerHere(::morph::exec::IExecutor& replyExec, Body&& body) {
395 auto settleable = ::morph::async::Completion<T>::makeSettleable(&replyExec);
396 try {
397 settleable.second.resolve(std::forward<Body>(body)());
398 } catch (...) {
399 settleable.second.reject(std::current_exception());
400 }
401 return std::move(settleable.first);
402 }
403};
404// NOLINTEND(cppcoreguidelines-special-member-functions)
405
406// The point of `Attempts`, stated as something the compiler checks rather than
407// as a comment. The second assertion is what keeps the first from being
408// vacuous: if `Attempts` were an alias for `uint32_t` the transposed call would
409// compile and the first assertion would fail, and if it were made explicit the
410// ordinary call would stop compiling and the second would fail. Both directions
411// have to hold.
412static_assert(!std::is_invocable_v<decltype(&IOfflineQueue::setAttempts), IOfflineQueue&, uint32_t, uint64_t>,
413 "setAttempts's parameters must not be transposable: writing an item id into an attempt count is "
414 "silent, because setAttempts on an unknown id is a documented no-op.");
415static_assert(std::is_invocable_v<decltype(&IOfflineQueue::setAttempts), IOfflineQueue&, uint64_t, uint32_t>,
416 "setAttempts must still accept a plain count in the right order.");
417
418// ── In-memory implementation ──────────────────────────────────────────────────
419
420/// @brief In-memory implementation of `IOfflineQueue`, owned by one executor.
421///
422/// Suitable for testing and for applications that do not require persistence
423/// across process restarts. Items are stored in a `std::deque` that belongs to
424/// the executor given at construction (see `IOfflineQueue`, "One owner").
426public:
428 using IOfflineQueue::enqueue; // keep the two-arg and completion overloads visible
430
431 /// @brief Constructs an in-memory queue belonging to @p owner, optionally bounded.
432 /// @param owner The executor every access runs on; must run one task at a
433 /// time. Borrowed: it must outlive this queue and run what it posts.
434 /// @param maxDepth Maximum number of pending items `enqueue()` will admit
435 /// before throwing `OfflineQueueFullError`; `std::nullopt` (the
436 /// default) means unbounded.
437 explicit InMemoryOfflineQueue(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND,
438 std::optional<std::size_t> maxDepth = std::nullopt)
439 : _maxDepth{maxDepth}, _owned{owner, std::make_shared<State>()} {}
440
441 /// @brief Appends @p payload and returns a monotonically increasing id. On the owner.
442 /// @param payload Serialised action to store.
443 /// @return Unique id for this item.
444 [[nodiscard]] uint64_t enqueue(std::string payload) override { return enqueue(std::move(payload), {}); }
445
446 /// @brief Appends @p payload carrying @p idempotencyKey and returns a
447 /// monotonically increasing id. On the owner.
448 /// @param payload Serialised action to store.
449 /// @param idempotencyKey Stable dedup token; stored verbatim on the item.
450 /// @return Unique id for this item.
451 /// @throws OfflineQueueFullError if the queue is already at `maxDepth()`.
452 [[nodiscard]] uint64_t enqueue(std::string payload, std::string idempotencyKey) override {
453 return insert(_owned.read("InMemoryOfflineQueue::enqueue"), _maxDepth, std::move(payload),
454 std::move(idempotencyKey));
455 }
456
457 /// @brief Returns a snapshot of all pending items. On the owner.
458 /// @return Copy of all items in insertion order.
459 [[nodiscard]] std::vector<QueueItem> drain() const override {
460 auto const& items = _owned.read("InMemoryOfflineQueue::drain").items;
461 return std::vector<QueueItem>{items.begin(), items.end()};
462 }
463
464 /// @brief Returns the number of pending items. On the owner.
465 /// @return Current pending item count.
466 [[nodiscard]] std::size_t size() const override { return _owned.read("InMemoryOfflineQueue::size").items.size(); }
467
468 /// @brief Returns the configured maximum depth, or `std::nullopt` if
469 /// unbounded. Callable anywhere: fixed at construction.
470 /// @return The capacity `enqueue()` enforces, or `std::nullopt` if none.
471 [[nodiscard]] std::optional<std::size_t> maxDepth() const override { return _maxDepth; }
472
473 /// @brief Removes the item with @p itemId from the queue, on the owner.
474 ///
475 /// No-op if @p itemId is not found.
476 /// @param itemId Id of the item to remove.
477 void markDone(uint64_t itemId) override {
478 _owned.apply("InMemoryOfflineQueue::markDone", [itemId](State& state) {
479 auto iter =
480 std::ranges::find_if(state.items, [itemId](const QueueItem& item) { return item.id == itemId; });
481 if (iter != state.items.end()) {
482 state.items.erase(iter);
483 }
484 });
485 }
486
487 /// @brief Updates the persisted attempt count on the in-deque item with
488 /// @p itemId, on the owner. No-op if @p itemId is not found.
489 ///
490 /// `InMemoryOfflineQueue` does not itself survive a process restart, but
491 /// overriding this hook lets a *fresh* `SyncWorker` constructed over the
492 /// same instance observe a durable-style cumulative attempt count — used
493 /// to simulate cross-restart dead-lettering in tests.
494 /// @param itemId Id of the item to update.
495 /// @param attempts New attempt count to store.
496 void setAttempts(uint64_t itemId, Attempts attempts) override {
497 _owned.apply("InMemoryOfflineQueue::setAttempts", [itemId, attempts](State& state) {
498 auto iter =
499 std::ranges::find_if(state.items, [itemId](const QueueItem& item) { return item.id == itemId; });
500 if (iter != state.items.end()) {
501 iter->attempts = attempts.value();
502 }
503 });
504 }
505
506protected:
507 /// @brief Enqueues on the owner, answered on @p replyExec.
508 /// @param replyExec Where the answer is delivered.
509 /// @param payload Serialised action to store.
510 /// @param idempotencyKey Stable dedup token; may be empty.
511 /// @return The new item's id, or `OfflineQueueFullError`.
513 std::string payload,
514 std::string idempotencyKey) override {
515 return _owned.ask<uint64_t>("InMemoryOfflineQueue::enqueue", replyExec,
516 [maxDepth = _maxDepth, payload = std::move(payload),
517 idempotencyKey = std::move(idempotencyKey)](State& state) mutable {
518 return insert(state, maxDepth, std::move(payload), std::move(idempotencyKey));
519 });
520 }
521
522 /// @brief Snapshots on the owner, answered on @p replyExec.
523 /// @param replyExec Where the answer is delivered.
524 /// @return The pending items.
526 ::morph::exec::IExecutor& replyExec) const override {
527 return _owned.ask<std::vector<QueueItem>>("InMemoryOfflineQueue::drain", replyExec, [](State& state) {
528 return std::vector<QueueItem>{state.items.begin(), state.items.end()};
529 });
530 }
531
532 /// @brief Counts on the owner, answered on @p replyExec.
533 /// @param replyExec Where the answer is delivered.
534 /// @return The pending item count.
536 return _owned.ask<std::size_t>("InMemoryOfflineQueue::size", replyExec,
537 [](State& state) { return state.items.size(); });
538 }
539
540private:
541 /// Everything the queue holds; touched only on the owner.
542 struct State {
543 std::deque<QueueItem> items;
544 uint64_t nextId{0};
545 };
546
547 /// Inserts one item into @p state, refusing past @p maxDepth.
548 static uint64_t insert(State& state, std::optional<std::size_t> maxDepth, std::string payload,
549 std::string idempotencyKey) {
550 MORPH_ZONE("InMemoryOfflineQueue::enqueue");
551 if (maxDepth && state.items.size() >= *maxDepth) {
552 ::morph::observe::detail::emitMetric(::morph::observe::Metric::queueOverflow,
553 static_cast<double>(state.items.size()));
554 throw OfflineQueueFullError{*maxDepth, state.items.size()};
555 }
556 uint64_t const itemId = ++state.nextId;
557 state.items.push_back(
558 QueueItem{.id = itemId, .payload = std::move(payload), .idempotencyKey = std::move(idempotencyKey)});
559 return itemId;
560 }
561
562 std::optional<std::size_t> _maxDepth;
563 ::morph::exec::detail::OwnedState<State> _owned;
564};
565
566} // namespace morph::offline
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.
A replay attempt count, as a type distinct from a queue item id.
constexpr Attempts() noexcept=default
Constructs a zero attempt count.
constexpr bool operator==(const Attempts &other) const noexcept=default
Compares two attempt counts.
constexpr std::uint32_t value() const noexcept
Returns the wrapped count.
uint64_t enqueue(std::string payload, std::string idempotencyKey) override
Appends payload carrying idempotencyKey and returns a monotonically increasing id....
::morph::async::Completion< uint64_t > askEnqueue(::morph::exec::IExecutor &replyExec, std::string payload, std::string idempotencyKey) override
Enqueues on the owner, answered on replyExec.
std::vector< QueueItem > drain() const override
Returns a snapshot of all pending items. On the owner.
std::size_t size() const override
Returns the number of pending items. On the owner.
InMemoryOfflineQueue(::morph::exec::IExecutor &owner, std::optional< std::size_t > maxDepth=std::nullopt)
Constructs an in-memory queue belonging to owner, optionally bounded.
::morph::async::Completion< std::size_t > askSize(::morph::exec::IExecutor &replyExec) const override
Counts on the owner, answered on replyExec.
void setAttempts(uint64_t itemId, Attempts attempts) override
Updates the persisted attempt count on the in-deque item with itemId, on the owner....
void markDone(uint64_t itemId) override
Removes the item with itemId from the queue, on the owner.
uint64_t enqueue(std::string payload) override
Appends payload and returns a monotonically increasing id. On the owner.
::morph::async::Completion< std::vector< QueueItem > > askDrain(::morph::exec::IExecutor &replyExec) const override
Snapshots on the owner, answered on replyExec.
std::optional< std::size_t > maxDepth() const override
Returns the configured maximum depth, or std::nullopt if unbounded. Callable anywhere: fixed at const...
OwnedState: a component's state, and the one executor it is touched on.
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.
virtual void markDone(uint64_t itemId)=0
Removes the item identified by itemId.
virtual ::morph::async::Completion< std::vector< QueueItem > > askDrain(::morph::exec::IExecutor &replyExec) const
What drain(replyExec) asks. The default drains where it is called; one with an owner drains there.
::morph::async::Completion< std::vector< QueueItem > > drain(::morph::exec::IExecutor &replyExec) const
Snapshots the pending items on the queue's owner; delivered on replyExec.
virtual uint64_t enqueue(std::string payload)=0
Appends payload to the queue with no idempotency key.
virtual std::optional< std::size_t > maxDepth() const
Returns the configured maximum depth, or std::nullopt if unbounded.
::morph::async::Completion< uint64_t > enqueue(::morph::exec::IExecutor &replyExec, std::string payload, std::string idempotencyKey={})
Appends payload carrying idempotencyKey on the queue's owner; the new item's id is delivered on reply...
::morph::async::Completion< std::size_t > size(::morph::exec::IExecutor &replyExec) const
Counts the pending items on the queue's owner; delivered on replyExec.
virtual ::morph::async::Completion< std::size_t > askSize(::morph::exec::IExecutor &replyExec) const
What size(replyExec) asks. The default counts where it is called; one with an owner counts there.
virtual ::morph::async::Completion< uint64_t > askEnqueue(::morph::exec::IExecutor &replyExec, std::string payload, std::string idempotencyKey)
What enqueue(replyExec, payload, key) asks. The default enqueues where it is called,...
virtual void setIdempotencyKey(uint64_t itemId, std::string idempotencyKey)
Stamps an idempotency key onto an already-enqueued item.
virtual std::size_t size() const
Returns the number of pending items without removing them.
virtual std::vector< QueueItem > drain() const =0
Returns all pending items in enqueue order without removing them.
virtual uint64_t enqueue(std::string payload, std::string idempotencyKey)
Appends payload carrying the cross-subsystem idempotencyKey.
virtual void setAttempts(uint64_t itemId, Attempts attempts)
Persists an updated attempt count for an item. Default: no-op.
OfflineQueueFullError(std::size_t maxDepthValue, std::size_t currentSizeValue)
std::size_t currentSize
Number of pending items at the time of rejection.
std::size_t maxDepth
The configured capacity that was reached.
An item stored in the offline queue.
uint32_t attempts
Durable retry count for this item, authoritative when the queue persists it.
std::string payload
Opaque serialised representation of the queued action.
std::string idempotencyKey
Optional caller-supplied idempotency key that is stable across subsystems and process restarts for on...