morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
sqlite_offline_queue.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <sqlite3.h>
5
6#include <chrono>
7#include <climits>
8#include <core/platform/Clock.hpp>
9#include <cstdint>
10#include <filesystem>
11#include <memory>
12#include <optional>
13#include <stdexcept>
14#include <string>
15#include <vector>
16
17#include "../attributes.hpp"
18#include "../core/completion.hpp"
20#include "../core/executor.hpp"
21#include "../core/file_io_ops.hpp"
22#include "../core/logger.hpp"
23#include "../core/observability.hpp"
24#include "offline_queue.hpp"
25
26namespace morph::offline {
27
28/// @brief Thrown when a SQLite operation used by `SqliteOfflineQueue` fails.
29struct SqliteOfflineQueueError : std::runtime_error {
30 using std::runtime_error::runtime_error;
31};
32
33namespace detail {
34
35/// @brief `SQLITE_TRANSIENT` (`((sqlite3_destructor_type)-1)` in `sqlite3.h`)
36/// re-expressed via `reinterpret_cast` instead of the macro, so no
37/// C-style cast token ever appears at our call sites under
38/// `-Wold-style-cast`/`-Weverything`. Tells SQLite to copy the bound
39/// string immediately, since our `std::string` arguments may be gone
40/// by the time a later `sqlite3_step` would otherwise read them.
41inline const sqlite3_destructor_type kSqliteTransient = reinterpret_cast<sqlite3_destructor_type>(-1);
42
43/// @brief RAII wrapper that finalizes a `sqlite3_stmt*` on scope exit,
44/// including when an exception unwinds past it.
45class StatementGuard {
46public:
47 /// @brief Takes over finalization of @p stmt.
48 /// @param stmt Statement to finalize on destruction. Borrowed for the
49 /// guard's whole lifetime — it is `sqlite3_finalize`d, not
50 /// copied, so it must stay valid until the guard is destroyed.
51 explicit StatementGuard(sqlite3_stmt* stmt MORPH_LIFETIMEBOUND) : _stmt{stmt} {}
52 ~StatementGuard() { sqlite3_finalize(_stmt); }
53
54 StatementGuard(const StatementGuard&) = delete;
55 StatementGuard& operator=(const StatementGuard&) = delete;
56 StatementGuard(StatementGuard&&) = delete;
57 StatementGuard& operator=(StatementGuard&&) = delete;
58
59 [[nodiscard]] sqlite3_stmt* get() const noexcept { return _stmt; }
60
61private:
62 sqlite3_stmt* _stmt;
63};
64
65} // namespace detail
66
67/// @brief Reference SQLite-backed `IOfflineQueue`: persists `payload`,
68/// `idempotencyKey`, and `attempts` across process restarts.
69///
70/// Schema (one table, `morph_offline_queue`):
71///
72/// ```sql
73/// CREATE TABLE IF NOT EXISTS morph_offline_queue (
74/// id INTEGER PRIMARY KEY AUTOINCREMENT,
75/// payload TEXT NOT NULL,
76/// idempotency_key TEXT NOT NULL DEFAULT '',
77/// attempts INTEGER NOT NULL DEFAULT 0,
78/// enqueued_at INTEGER NOT NULL
79/// );
80/// CREATE UNIQUE INDEX IF NOT EXISTS ix_queue_idem
81/// ON morph_offline_queue(idempotency_key) WHERE idempotency_key <> '';
82/// ```
83///
84/// `id` is `AUTOINCREMENT`, so ids are never reused and a re-opened queue
85/// re-presents each row under its **stored** id — stable across restarts.
86/// `QueueItem::id` remains queue-local (per `docs/spec/offline/offline.md`);
87/// cross-restart identity is carried by `idempotencyKey`, not `id`.
88///
89/// The partial unique index gives insert-time dedup for a non-empty
90/// `idempotencyKey`: a re-enqueue of the same key is a no-op that returns the
91/// existing row's id (`INSERT ... ON CONFLICT ... DO NOTHING`, then a lookup
92/// on a no-op conflict). Empty keys (the default) are exempt, so keyless
93/// items behave exactly as `InMemoryOfflineQueue` does — never deduplicated.
94///
95/// @par Crash safety
96/// `drain()` never deletes, so a crash between `drain()` and `markDone()`
97/// loses nothing. Each write (`enqueue`, `markDone`, `setAttempts`,
98/// `setIdempotencyKey`) is its own committed statement; `PRAGMA
99/// journal_mode=WAL` and `PRAGMA synchronous=FULL` (both set once, at
100/// construction, and `journal_mode` read back and verified rather than
101/// trusted -- some filesystems, e.g. NFS, silently fall back to `delete`
102/// mode) give the durability. `PRAGMA busy_timeout` is also set at
103/// construction: running every statement on one owner makes `SQLITE_BUSY`
104/// unreachable for a single instance, but the class documents no single-opener restriction.
105/// Construction also fsyncs the containing directory once, after
106/// `sqlite3_open()` and every schema-setup `PRAGMA`/`CREATE` succeed, closing
107/// the same fresh-file-creation directory-durability gap `FileIoOps::syncPath`
108/// closes for `FileActionLog`/`FileOfflineQueue`.
109///
110/// @par One owner
111/// The connection belongs to the executor given at construction (see
112/// `IOfflineQueue`, "One owner"): every statement runs there, one at a time.
113/// The application's enqueue-on-failure path and `SyncWorker`'s drain share
114/// the queue by sharing that owner.
116 class State;
117
118public:
120 using IOfflineQueue::enqueue; // keep the two-arg and completion overloads visible
122
123 /// @brief `PRAGMA busy_timeout` set at construction: how long
124 /// a statement blocks on `SQLITE_BUSY` before giving up, in
125 /// milliseconds.
126 static constexpr int kBusyTimeoutMillis = 5000;
127
128 /// @brief How hard each committed statement is pushed onto the platter.
129 enum class Synchronous : std::uint8_t {
130 /// @brief `PRAGMA synchronous=NORMAL` — the default, and SQLite's own
131 /// recommendation under WAL. A crash or power loss can lose the
132 /// most recent commits, which this queue's at-least-once
133 /// delivery plus `idempotencyKey` dedup already absorb.
135 /// @brief `PRAGMA synchronous=FULL` — fsync on every committed
136 /// statement. Measured ~18x slower per mutation (~0.08ms to
137 /// ~1.44ms on NVMe/btrfs, sqlite 3.53.4), and every mutation here
138 /// is its own commit, so a 200-item `SyncWorker` drain goes from
139 /// ~16ms to ~290ms — all of it on the queue's owner, where it
140 /// holds up the producer's `enqueue()` too. Opt in when losing the
141 /// last few commits is genuinely unacceptable.
143 };
144
145 /// @brief SQLite's numeric `PRAGMA synchronous` level for this connection,
146 /// as read back at construction.
147 ///
148 /// `0` OFF, `1` NORMAL, `2` FULL, `3` EXTRA. Reported rather than inferred
149 /// from the constructor argument: the argument is what was asked for, this
150 /// is what SQLite confirmed.
151 /// @return The level in force on this queue's own connection.
152 [[nodiscard]] int synchronousLevel() const noexcept { return _synchronousLevel; }
153
154 /// @brief The `journal_mode` this database actually ended up in.
155 ///
156 /// Normally `"wal"`. A filesystem without the shared-memory support WAL
157 /// needs reports `"delete"`; `:memory:` and the temp spellings report
158 /// `"memory"`. Construction warns rather than throws on anything but
159 /// `"wal"` (durability is carried by `PRAGMA synchronous`), so this is how
160 /// a caller that cares can tell which mode it got.
161 /// @return The mode string as SQLite reported it at construction.
162 [[nodiscard]] const std::string& journalMode() const noexcept { return _journalMode; }
163
164 /// @brief Opens (or creates) the queue database at @p path, creating the
165 /// schema if it does not already exist; the queue belongs to @p owner.
166 ///
167 /// Runs on the constructing thread, before anything else can reach the queue.
168 /// @param owner The executor every statement runs on; must run one task
169 /// at a time. Borrowed: it must outlive this queue and run what it posts.
170 /// @param path SQLite database file.
171 /// @param maxDepth Maximum number of pending rows `enqueue()` will admit
172 /// before throwing `OfflineQueueFullError`; `std::nullopt` (the
173 /// default) means unbounded. Not persisted in the database itself
174 /// — a per-construction parameter, so a reopen must pass it again
175 /// to keep the same cap enforced.
176 /// @param ioOps Injectable directory-fsync primitive (`FileIoOps::syncPath`
177 /// only -- the rest of this class talks to SQLite directly, never
178 /// through `FileIoOps`); defaults to the real syscall. Test-only
179 /// seam, mirroring `FileActionLog`/`FileOfflineQueue`'s own
180 /// `FileIoOps` parameter, for forcing the directory-fsync failure
181 /// branch below without needing a real OS-level failure.
182 /// @param synchronous `PRAGMA synchronous` level; see `Synchronous`.
183 /// Defaults to `normal`.
184 /// @param busyTimeout How long a statement waits out a lock another
185 /// connection holds before returning `SQLITE_BUSY`. Defaults to
186 /// `kBusyTimeoutMillis`. One owner running every statement makes
187 /// `SQLITE_BUSY` unreachable for a single instance, so the timeout
188 /// only matters when something else has the database open — but
189 /// when it does, the wait happens on the owner and holds up every
190 /// other caller of this instance, a Qt GUI thread included. Pass `0` to restore
191 /// fail-fast.
192 /// @param wallClock Clock the `enqueued_at` column is stamped from; the
193 /// system clock by default. Borrowed, so it must outlive the queue.
194 /// @throws SqliteOfflineQueueError if the database cannot be opened, the
195 /// schema cannot be created, or the containing directory cannot
196 /// be fsynced after `sqlite3_open()` creates a brand-new file. A
197 /// `journal_mode` that does not end up as `wal` is **warned about,
198 /// not thrown** — see the constructor body.
199 explicit SqliteOfflineQueue(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND, std::filesystem::path path,
200 std::optional<std::size_t> maxDepth = std::nullopt,
202 std::chrono::milliseconds busyTimeout = std::chrono::milliseconds{kBusyTimeoutMillis},
203 ::core::platform::WallClockRef wallClock = ::core::platform::defaultSystemWallClock())
204 : SqliteOfflineQueue{owner, std::make_shared<State>(std::move(path), maxDepth, std::move(ioOps), synchronous,
205 busyTimeout, wallClock)} {}
206
207 ~SqliteOfflineQueue() override = default;
208
209 SqliteOfflineQueue(const SqliteOfflineQueue&) = delete;
210 SqliteOfflineQueue& operator=(const SqliteOfflineQueue&) = delete;
212 SqliteOfflineQueue& operator=(SqliteOfflineQueue&&) = delete;
213
214 /// @brief Inserts @p payload with an empty idempotency key. On the owner.
215 /// @param payload Serialised action to persist.
216 /// @return The new row's id (`SELECT last_insert_rowid()`).
217 /// @throws OfflineQueueFullError if the queue is already at `maxDepth()`.
218 [[nodiscard]] uint64_t enqueue(std::string payload) override {
219 return _owned.read("SqliteOfflineQueue::enqueue").enqueue(payload);
220 }
221
222 /// @brief Inserts @p payload carrying @p idempotencyKey in one write. On
223 /// the owner. A non-empty key already present on a row is
224 /// deduplicated: the existing row's id is returned and nothing new
225 /// is inserted.
226 /// @param payload Serialised action to persist.
227 /// @param idempotencyKey Stable dedup token; empty means "no dedup".
228 /// @return The new row's id, or the existing row's id on a dedup hit.
229 /// @throws OfflineQueueFullError if the queue is already at `maxDepth()`.
230 /// Checked before the insert is attempted, so a call that would
231 /// have resolved to a dedup hit (inserting nothing) can also be
232 /// rejected when the queue happens to be full at the same time —
233 /// a documented, accepted conservatism rather than an extra
234 /// round trip to special-case it.
235 [[nodiscard]] uint64_t enqueue(std::string payload, std::string idempotencyKey) override {
236 return _owned.read("SqliteOfflineQueue::enqueue").enqueue(payload, idempotencyKey);
237 }
238
239 /// @brief Returns all pending rows in ascending-id (enqueue) order. On the owner.
240 /// @return Snapshot of all pending items; the table is unchanged.
241 [[nodiscard]] std::vector<QueueItem> drain() const override {
242 return _owned.read("SqliteOfflineQueue::drain").drain();
243 }
244
245 /// @brief Deletes the row identified by @p itemId, on the owner. No-op if absent.
246 ///
247 /// Called off the owner, the delete is posted there and this returns at
248 /// once; a statement that fails there is logged, and the row stays queued.
249 /// @param itemId Id returned by the corresponding `enqueue()` call.
250 void markDone(uint64_t itemId) override {
251 _owned.apply("SqliteOfflineQueue::markDone", [itemId](State& state) { state.markDone(itemId); });
252 }
253
254 /// @brief Persists an updated attempt count for @p itemId, on the owner.
255 /// No-op if absent.
256 /// @param itemId Id of the item whose count changed.
257 /// @param attempts New cumulative attempt count to store.
258 void setAttempts(uint64_t itemId, Attempts attempts) override {
259 _owned.apply("SqliteOfflineQueue::setAttempts",
260 [itemId, attempts](State& state) { state.setAttempts(itemId, attempts); });
261 }
262
263 /// @brief Returns the number of pending rows. On the owner.
264 /// @return Current pending item count (`COUNT(*)` against the table).
265 [[nodiscard]] std::size_t size() const override { return _owned.read("SqliteOfflineQueue::size").size(); }
266
267 /// @brief Returns the configured maximum depth, or `std::nullopt` if
268 /// unbounded. Callable anywhere: fixed at construction.
269 /// @return The capacity `enqueue()` enforces, or `std::nullopt` if none.
270 [[nodiscard]] std::optional<std::size_t> maxDepth() const override { return _maxDepth; }
271
272protected:
273 /// @brief Stamps an idempotency key onto an already-inserted row, on the
274 /// owner. No-op if @p itemId is absent. Reachable only if a caller
275 /// invokes the base `IOfflineQueue::enqueue(payload, key)` default
276 /// through an `IOfflineQueue&` -- this class's own
277 /// `enqueue(payload, key)` override above stamps the key inline in
278 /// the same INSERT instead.
279 /// @param itemId Id of the row to stamp.
280 /// @param idempotencyKey Key to store.
281 void setIdempotencyKey(uint64_t itemId, std::string idempotencyKey) override {
282 _owned.apply("SqliteOfflineQueue::setIdempotencyKey",
283 [itemId, idempotencyKey = std::move(idempotencyKey)](State& state) mutable {
284 state.setIdempotencyKey(itemId, idempotencyKey);
285 });
286 }
287
288 /// @brief Enqueues on the owner, answered on @p replyExec.
289 /// @param replyExec Where the answer is delivered.
290 /// @param payload Serialised action to persist.
291 /// @param idempotencyKey Stable dedup token; may be empty.
292 /// @return The row's id, or the enqueue's error.
294 std::string payload,
295 std::string idempotencyKey) override {
296 return _owned.ask<uint64_t>("SqliteOfflineQueue::enqueue", replyExec,
297 [payload = std::move(payload), idempotencyKey = std::move(idempotencyKey)](
298 State& state) mutable { return state.enqueue(payload, idempotencyKey); });
299 }
300
301 /// @brief Reads every pending row on the owner, answered on @p replyExec.
302 /// @param replyExec Where the answer is delivered.
303 /// @return The pending items, or the read's error.
305 ::morph::exec::IExecutor& replyExec) const override {
306 return _owned.ask<std::vector<QueueItem>>("SqliteOfflineQueue::drain", replyExec,
307 [](State& state) { return state.drain(); });
308 }
309
310 /// @brief Counts the rows on the owner, answered on @p replyExec.
311 /// @param replyExec Where the answer is delivered.
312 /// @return The pending item count.
314 return _owned.ask<std::size_t>("SqliteOfflineQueue::size", replyExec,
315 [](State& state) { return state.size(); });
316 }
317
318private:
319 /// Adopts @p state, built by the public constructor, and copies out what
320 /// is fixed at construction so it can be read from any thread.
321 SqliteOfflineQueue(::morph::exec::IExecutor& owner, std::shared_ptr<State> state)
322 : _maxDepth{state->maxDepth()},
323 _synchronousLevel{state->synchronousLevel()},
324 _journalMode{state->journalMode()},
325 _owned{owner, std::move(state)} {}
326
327 /// The connection and everything configured on it; touched only on the
328 /// owner once the constructor has returned. Closes the connection when the
329 /// last task that holds it, or the queue, lets go.
330 class State {
331 public:
332 State(std::filesystem::path path, std::optional<std::size_t> maxDepth, ::morph::core::FileIoOps ioOps,
333 Synchronous synchronous, std::chrono::milliseconds busyTimeout, ::core::platform::WallClockRef wallClock)
334 : _path{std::move(path)},
335 _maxDepth{maxDepth},
336 _io{std::move(ioOps)},
337 _synchronous{synchronous},
338 _busyTimeout{busyTimeout},
339 _wallClock{wallClock} {
340 if (sqlite3_open(_path.string().c_str(), &_db) != SQLITE_OK) {
341 std::string const msg = "SqliteOfflineQueue: failed to open " + _path.string() + ": " +
342 (_db != nullptr ? sqlite3_errmsg(_db) : "unknown error");
343 if (_db != nullptr) {
344 sqlite3_close(_db);
345 _db = nullptr;
346 }
347 throw SqliteOfflineQueueError{msg};
348 }
349 // _db is a live, open connection from here on -- if any schema-setup
350 // statement below throws, this constructor never completes, so
351 // ~State() never runs to close it. Close it here before
352 // rethrowing, the same discipline the open-failure branch above
353 // already applies to its own failure path.
354 try {
355 // FIRST, before anything that can return SQLITE_BUSY. Converting a
356 // database to WAL needs an exclusive lock, so with a second
357 // connection open `PRAGMA journal_mode=WAL` is itself a BUSY
358 // candidate -- issued after the timeout it waits, issued before it
359 // fails instantly with "database is locked" (measured: 12ms to
360 // throw without, a full 1001ms wait with a 1000ms timeout set
361 // first). Setting it last, as an earlier revision did, left the
362 // multi-opener case it was added for failing exactly as before.
363 execOrThrow(("PRAGMA busy_timeout=" + std::to_string(_busyTimeout.count()) + ";").c_str());
364
365 // Before journal_mode, and unconditional. SQLite's default for the
366 // rollback journal is already FULL; it is WAL that lowers it to
367 // NORMAL, so raising it here first means the setting holds whether
368 // or not WAL takes. Ordered the other way round, a database that
369 // fell back to the rollback journal kept whatever synchronous it
370 // happened to have.
371 execOrThrow(
372 ("PRAGMA synchronous=" + std::string{_synchronous == Synchronous::full ? "FULL" : "NORMAL"} + ";")
373 .c_str());
374 // Read back for the same reason journal_mode is: sqlite3_exec
375 // discards the row a PRAGMA returns, so a setting that did not take
376 // would otherwise be invisible. `synchronous` is a *connection*
377 // property, not a database one, so this is also the only way a
378 // caller can confirm the level it asked for is the level in force.
379 {
380 detail::StatementGuard const guard{prepare("PRAGMA synchronous;")};
381 if (sqlite3_step(guard.get()) != SQLITE_ROW) {
382 throw SqliteOfflineQueueError{"SqliteOfflineQueue: failed to read back synchronous"};
383 }
384 _synchronousLevel = sqlite3_column_int(guard.get(), 0);
385 }
386
387 execOrThrow("PRAGMA journal_mode=WAL;");
388 // execOrThrow() discards sqlite3_exec's row callback, so a silent
389 // fallback would otherwise go unnoticed. Read the
390 // pragma back through a real prepared statement rather than
391 // trusting the set.
392 //
393 // Warn, do not throw. An earlier revision made a non-`wal` result a
394 // hard construction failure, which refused configurations that were
395 // never actually less durable: `journal_mode` reports `memory` for
396 // `:memory:` and the temp/"" spellings, and `delete` on filesystems
397 // without the shared-memory WAL needs (NFS, CIFS/SMB, some overlay,
398 // 9p and Docker mounts). All of those persist correctly through the
399 // rollback journal -- whose durability is exactly what the
400 // `synchronous` pragma above now guarantees regardless of mode. A
401 // client that builds one of these from a user-supplied path would
402 // otherwise be unable to start at all from an NFS home directory.
403 {
404 detail::StatementGuard const guard{prepare("PRAGMA journal_mode;")};
405 if (sqlite3_step(guard.get()) != SQLITE_ROW) {
406 throw SqliteOfflineQueueError{"SqliteOfflineQueue: failed to read back journal_mode"};
407 }
408 _journalMode = textColumn(guard.get(), 0);
409 if (_journalMode != "wal") {
410 ::morph::log::logWarn(
411 "SqliteOfflineQueue: PRAGMA journal_mode=WAL did not take for {} (got '{}'); continuing "
412 "on "
413 "that mode -- durability is carried by PRAGMA synchronous, but concurrent readers and the "
414 "writer will not overlap as they do under WAL",
415 _path.string(), _journalMode);
416 }
417 }
418 execOrThrow(
419 "CREATE TABLE IF NOT EXISTS morph_offline_queue ("
420 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
421 " payload TEXT NOT NULL,"
422 " idempotency_key TEXT NOT NULL DEFAULT '',"
423 " attempts INTEGER NOT NULL DEFAULT 0,"
424 " enqueued_at INTEGER NOT NULL"
425 ");");
426 execOrThrow(
427 "CREATE UNIQUE INDEX IF NOT EXISTS ix_queue_idem "
428 "ON morph_offline_queue(idempotency_key) WHERE idempotency_key <> '';");
429 // sqlite3_open() above creates `_path` (and, once journal_mode=WAL
430 // took, its "-wal"/"-shm" siblings) if it did not already exist --
431 // a fresh directory entry that SQLite's own internal fsyncs of the
432 // *file's* contents never make durable -- the identical
433 // directory-vs-file-fsync gap
434 // `FileActionLog`/`FileOfflineQueue` close (see `FileIoOps::syncPath`'s
435 // own docs). Unconditional: harmless when the file already
436 // existed, since syncing an unchanged directory is a cheap no-op.
437 //
438 // Which directory, though, is SQLite's to answer rather than
439 // `_path`'s. `:memory:`, `""` and the `file::memory:` URI spellings
440 // open no file at all, and `parent_path()` is empty for them --
441 // which `FileIoOps::syncPath` resolves to `"."`, so deriving the
442 // directory from `_path` meant fsyncing the *process's current
443 // working directory* and reporting the result as this queue's: a
444 // warning naming a database that is not on disk where a CWD cannot
445 // be fsynced, and a refusal to construct an in-memory queue at all
446 // where that fsync genuinely fails. `sqlite3_db_filename` reports
447 // an empty name for exactly those spellings, so it distinguishes
448 // "no backing file, nothing to sync" from a real path without this
449 // class having to re-parse SQLite's own filename grammar.
450 const char* const backingFile = sqlite3_db_filename(_db, "main");
451 if (backingFile != nullptr && *backingFile != '\0') {
452 std::filesystem::path const backingPath{backingFile};
453 auto const dirSync = ::morph::core::classifyDirectorySync(_io.syncPath(backingPath.parent_path()));
454 if (dirSync == ::morph::core::DirectorySync::failed) {
455 throw SqliteOfflineQueueError{"SqliteOfflineQueue: failed to fsync directory after creating " +
456 backingPath.string()};
457 }
458 if (dirSync == ::morph::core::DirectorySync::unsupported) {
459 ::morph::log::logWarn(
460 "SqliteOfflineQueue: cannot fsync the directory containing {}; SQLite's own fsyncs still "
461 "cover the database contents, but its directory entry is only as durable as this "
462 "filesystem "
463 "makes it",
464 backingPath.string());
465 }
466 }
467 } catch (...) {
468 sqlite3_close(_db);
469 _db = nullptr;
470 throw;
471 }
472 }
473
474 /// @brief Closes the underlying SQLite connection.
475 ~State() {
476 if (_db != nullptr) {
477 sqlite3_close(_db);
478 }
479 }
480
481 State(const State&) = delete;
482 State& operator=(const State&) = delete;
483 State(State&&) = delete;
484 State& operator=(State&&) = delete;
485
486 /// @brief Inserts @p payload with an empty idempotency key.
487 /// @param payload Serialised action to persist.
488 /// @return The new row's id (`SELECT last_insert_rowid()`).
489 /// @throws OfflineQueueFullError if the queue is already at `maxDepth()`.
490 [[nodiscard]] uint64_t enqueue(const std::string& payload) const {
491 checkCapacity();
492 detail::StatementGuard const guard{
493 prepare("INSERT INTO morph_offline_queue (payload, idempotency_key, attempts, enqueued_at) "
494 "VALUES (?, '', 0, ?);")};
495 bindText(guard.get(), 1, payload);
496 bindInt64(guard.get(), 2, nowMillis());
497 stepOrThrow(guard.get(), "enqueue");
498 return static_cast<uint64_t>(sqlite3_last_insert_rowid(_db));
499 }
500
501 /// @brief Inserts @p payload carrying @p idempotencyKey in one write. A
502 /// non-empty key already present on a row is deduplicated: the
503 /// existing row's id is returned and nothing new is inserted.
504 /// @param payload Serialised action to persist.
505 /// @param idempotencyKey Stable dedup token; empty means "no dedup".
506 /// @return The new row's id, or the existing row's id on a dedup hit.
507 /// @throws OfflineQueueFullError if the queue is already at `maxDepth()`.
508 /// Checked before the insert is attempted, so a call that would
509 /// have resolved to a dedup hit (inserting nothing) can also be
510 /// rejected when the queue happens to be full at the same time —
511 /// a documented, accepted conservatism rather than an extra
512 /// round trip to special-case it.
513 [[nodiscard]] uint64_t enqueue(const std::string& payload, const std::string& idempotencyKey) const {
514 if (idempotencyKey.empty()) {
515 checkCapacity();
516 detail::StatementGuard const guard{
517 prepare("INSERT INTO morph_offline_queue (payload, idempotency_key, attempts, enqueued_at) "
518 "VALUES (?, '', 0, ?);")};
519 bindText(guard.get(), 1, payload);
520 bindInt64(guard.get(), 2, nowMillis());
521 stepOrThrow(guard.get(), "enqueue");
522 return static_cast<uint64_t>(sqlite3_last_insert_rowid(_db));
523 }
524
525 checkCapacity();
526 detail::StatementGuard const insertGuard{
527 prepare("INSERT INTO morph_offline_queue (payload, idempotency_key, attempts, enqueued_at) "
528 "VALUES (?, ?, 0, ?) "
529 "ON CONFLICT(idempotency_key) WHERE idempotency_key <> '' DO NOTHING;")};
530 bindText(insertGuard.get(), 1, payload);
531 bindText(insertGuard.get(), 2, idempotencyKey);
532 bindInt64(insertGuard.get(), 3, nowMillis());
533 stepOrThrow(insertGuard.get(), "enqueue");
534 if (sqlite3_changes(_db) > 0) {
535 return static_cast<uint64_t>(sqlite3_last_insert_rowid(_db));
536 }
537
538 // Conflict fired (DO NOTHING) -- a row for this key already exists.
539 detail::StatementGuard const lookupGuard{
540 prepare("SELECT id FROM morph_offline_queue WHERE idempotency_key = ?;")};
541 bindText(lookupGuard.get(), 1, idempotencyKey);
542 int const lookupResult = sqlite3_step(lookupGuard.get());
543 if (lookupResult != SQLITE_ROW) {
544 // The conflict proved a row exists, so anything but a row here is a
545 // real failure. Falling through to return `0` would hand the
546 // caller an id that matches nothing: `markDone(0)` silently
547 // deletes no row, and the item is stranded in the queue forever
548 // with no error ever reported.
549 //
550 // In production it takes a second writer deleting this exact row
551 // between the INSERT and this SELECT; a trigger that silently
552 // drops the insert reaches it deterministically in the tests.
553 throw SqliteOfflineQueueError{
554 std::string{"SqliteOfflineQueue: enqueue could not resolve the existing id "
555 "for a deduplicated idempotency key: "} +
556 sqlite3_errmsg(_db)};
557 }
558 return static_cast<uint64_t>(sqlite3_column_int64(lookupGuard.get(), 0));
559 }
560
561 /// @brief Returns all pending rows in ascending-id (enqueue) order.
562 /// @return Snapshot of all pending items; the table is unchanged.
563 [[nodiscard]] std::vector<QueueItem> drain() const {
564 detail::StatementGuard const guard{
565 prepare("SELECT id, payload, idempotency_key, attempts FROM morph_offline_queue ORDER BY id;")};
566 std::vector<QueueItem> out;
567 for (;;) {
568 int const stepResult = sqlite3_step(guard.get());
569 if (stepResult == SQLITE_DONE) {
570 break;
571 }
572 if (stepResult != SQLITE_ROW) {
573 // `while (step() == SQLITE_ROW)` treated an I/O error, a corrupt
574 // page, or SQLITE_BUSY as "no more rows", so drain() returned a
575 // silently truncated set that the caller takes for the complete
576 // list of pending work -- and SyncWorker then reports a clean
577 // pass over a queue it never fully read.
578 throw SqliteOfflineQueueError{
579 std::string{"SqliteOfflineQueue: drain failed part-way through after "} +
580 std::to_string(out.size()) + " row(s): " + sqlite3_errmsg(_db)};
581 }
582 QueueItem item;
583 item.id = static_cast<uint64_t>(sqlite3_column_int64(guard.get(), 0));
584 item.payload = textColumn(guard.get(), 1);
585 item.idempotencyKey = textColumn(guard.get(), 2);
586 item.attempts = static_cast<uint32_t>(sqlite3_column_int64(guard.get(), 3));
587 out.push_back(std::move(item));
588 }
589 return out;
590 }
591
592 /// @brief Deletes the row identified by @p itemId. No-op if absent.
593 /// @param itemId Id returned by the corresponding `enqueue()` call.
594 void markDone(uint64_t itemId) const {
595 detail::StatementGuard const guard{prepare("DELETE FROM morph_offline_queue WHERE id = ?;")};
596 bindInt64(guard.get(), 1, static_cast<std::int64_t>(itemId));
597 stepOrThrow(guard.get(), "markDone");
598 }
599
600 /// @brief Persists an updated attempt count for @p itemId. No-op if absent.
601 /// @param itemId Id of the item whose count changed.
602 /// @param attempts New cumulative attempt count to store.
603 void setAttempts(uint64_t itemId, Attempts attempts) const {
604 detail::StatementGuard const guard{prepare("UPDATE morph_offline_queue SET attempts = ? WHERE id = ?;")};
605 bindInt64(guard.get(), 1, static_cast<std::int64_t>(attempts.value()));
606 bindInt64(guard.get(), 2, static_cast<std::int64_t>(itemId));
607 stepOrThrow(guard.get(), "setAttempts");
608 }
609
610 /// @brief Returns the number of pending rows.
611 /// @return Current pending item count (`COUNT(*)` against the table).
612 [[nodiscard]] std::size_t size() const { return countRows(); }
613
614 /// @brief Returns the configured maximum depth, or `std::nullopt` if unbounded.
615 /// @return The capacity `enqueue()` enforces, or `std::nullopt` if none.
616 [[nodiscard]] std::optional<std::size_t> maxDepth() const { return _maxDepth; }
617
618 [[nodiscard]] int synchronousLevel() const noexcept { return _synchronousLevel; }
619
620 [[nodiscard]] const std::string& journalMode() const noexcept { return _journalMode; }
621
622 /// @brief Stamps an idempotency key onto an already-inserted row. No-op if
623 /// @p itemId is absent. Reachable only if a caller invokes the base
624 /// `IOfflineQueue::enqueue(payload, key)` default through an
625 /// `IOfflineQueue&` -- this class's own `enqueue(payload, key)`
626 /// override above stamps the key inline in the same INSERT instead.
627 /// @param itemId Id of the row to stamp.
628 /// @param idempotencyKey Key to store.
629 void setIdempotencyKey(uint64_t itemId, const std::string& idempotencyKey) const {
630 // `WHERE NOT EXISTS (...)` rather than a bare UPDATE: the partial
631 // unique index `ix_queue_idem` rejects stamping a non-empty key a
632 // pending row already holds, and a bare UPDATE would turn that into a
633 // thrown SqliteOfflineQueueError -- while this class's own
634 // `enqueue(payload, key)` resolves the identical conflict silently, by
635 // keeping the existing row. Same conflict, two answers, and only one of
636 // them matches the documented dedup contract.
637 //
638 // Throwing also bought nothing. This hook is called by the *base*
639 // `IOfflineQueue::enqueue(payload, key)` default, which has already
640 // inserted the row by the time it stamps; on a conflict the row exists
641 // either way, so the throw added an exception to an outcome it could
642 // not undo. Leaving the key unset is the same end state without it.
643 detail::StatementGuard const guard{
644 prepare("UPDATE morph_offline_queue SET idempotency_key = ?1 WHERE id = ?2 "
645 "AND (?1 = '' OR NOT EXISTS (SELECT 1 FROM morph_offline_queue "
646 "WHERE idempotency_key = ?1 AND id <> ?2));")};
647 bindText(guard.get(), 1, idempotencyKey);
648 bindInt64(guard.get(), 2, static_cast<std::int64_t>(itemId));
649 stepOrThrow(guard.get(), "setIdempotencyKey");
650 }
651
652 void execOrThrow(const char* sql) const {
653 char* errMsg = nullptr;
654 if (sqlite3_exec(_db, sql, nullptr, nullptr, &errMsg) != SQLITE_OK) {
655 std::string const msg = errMsg != nullptr ? errMsg : "unknown sqlite error";
656 sqlite3_free(errMsg);
657 throw SqliteOfflineQueueError{"SqliteOfflineQueue: " + msg};
658 }
659 }
660
661 sqlite3_stmt* prepare(const char* sql) const {
662 sqlite3_stmt* stmt = nullptr;
663 if (sqlite3_prepare_v2(_db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
664 throw SqliteOfflineQueueError{std::string{"SqliteOfflineQueue: prepare failed: "} +
665 sqlite3_errmsg(_db)};
666 }
667 return stmt;
668 }
669
670 // COVERAGE GAP, documented not closed: this throw's only realistic
671 // non-misuse trigger is SQLITE_TOOBIG, raised when `value` exceeds
672 // SQLite's SQLITE_LIMIT_LENGTH -- confirmed ~2 GB on this SDK's SQLite
673 // 3.51 via sqlite3_limit() (a 1,000,000,001-byte string was confirmed to
674 // still bind SQLITE_OK). This class does not expose the raw sqlite3*
675 // handle for a test to lower that limit via sqlite3_limit(), so the only
676 // way to reach this branch is allocating and binding an actual >2 GB
677 // std::string -- kSqliteTransient below means SQLite additionally copies
678 // it, so the peak footprint is >4 GB for one assertion. Evaluated and
679 // declined as not worth the memory/CI risk on an 8 GB development
680 // machine for a single low-value branch that a legitimate offline-queue
681 // payload will never approach in practice.
682 void bindText(sqlite3_stmt* stmt, int index, const std::string& value) const {
683 // An explicit length (not -1) is required so a NUL inside `value` --
684 // legitimate, since payload/idempotencyKey are opaque strings the
685 // caller controls the serialisation of -- doesn't tell
686 // SQLite to measure only up to that byte and silently truncate.
687 if (value.size() > static_cast<std::size_t>(INT_MAX)) {
688 throw SqliteOfflineQueueError{"SqliteOfflineQueue: value exceeds INT_MAX bytes"};
689 }
690 if (sqlite3_bind_text(stmt, index, value.c_str(), static_cast<int>(value.size()),
691 detail::kSqliteTransient) != SQLITE_OK) {
692 throw SqliteOfflineQueueError{std::string{"SqliteOfflineQueue: bind failed: "} + sqlite3_errmsg(_db)};
693 }
694 }
695
696 void bindInt64(sqlite3_stmt* stmt, int index, std::int64_t value) const {
697 if (sqlite3_bind_int64(stmt, index, value) != SQLITE_OK) {
698 throw SqliteOfflineQueueError{std::string{"SqliteOfflineQueue: bind failed: "} + sqlite3_errmsg(_db)};
699 }
700 }
701
702 void stepOrThrow(sqlite3_stmt* stmt, const char* what) const {
703 // Anything but SQLITE_DONE throws, so SQLITE_BUSY is not distinguished
704 // from a genuine error -- a production consumer wanting to retry on busy
705 // would need to split them. The one owner every statement runs on
706 // makes SQLITE_BUSY practically unreachable for this
707 // reference queue, which is why the distinction is not made here.
708 if (sqlite3_step(stmt) != SQLITE_DONE) {
709 throw SqliteOfflineQueueError{std::string{"SqliteOfflineQueue: "} + what +
710 " failed: " + sqlite3_errmsg(_db)};
711 }
712 }
713
714 static std::string textColumn(sqlite3_stmt* stmt, int index) {
715 // sqlite3_column_bytes() gives the real stored length; constructing a
716 // std::string from the raw `const char*` alone would stop at the
717 // first NUL and silently truncate a NUL-bearing payload or
718 // idempotency key on the way back out -- the read-side half of the
719 // same truncation bindText() above prevents on the write side.
720 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast): sqlite returns UTF-8 text as unsigned char; same object representation.
721 const auto* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, index));
722 return text != nullptr ? std::string{text, static_cast<std::size_t>(sqlite3_column_bytes(stmt, index))}
723 : std::string{};
724 }
725
726 [[nodiscard]] std::int64_t nowMillis() const noexcept {
727 return std::chrono::duration_cast<std::chrono::milliseconds>(_wallClock.now().time_since_epoch()).count();
728 }
729
730 /// @brief Returns the current row count. On the owner.
731 /// @return `COUNT(*)` against `morph_offline_queue`.
732 std::size_t countRows() const {
733 detail::StatementGuard const guard{prepare("SELECT COUNT(*) FROM morph_offline_queue;")};
734 sqlite3_step(guard.get());
735 return static_cast<std::size_t>(sqlite3_column_int64(guard.get(), 0));
736 }
737
738 /// @brief Throws `OfflineQueueFullError` if the queue is already at
739 /// `maxDepth()`. On the owner. No-op if unbounded.
740 void checkCapacity() const {
741 if (!_maxDepth) {
742 return;
743 }
744 std::size_t const current = countRows();
745 if (current >= *_maxDepth) {
746 ::morph::observe::detail::emitMetric(::morph::observe::Metric::queueOverflow,
747 static_cast<double>(current));
748 throw OfflineQueueFullError{*_maxDepth, current};
749 }
750 }
751
752 private:
753 std::filesystem::path _path;
754 mutable sqlite3* _db = nullptr;
755 std::optional<std::size_t> _maxDepth;
756 ::morph::core::FileIoOps _io;
757 Synchronous _synchronous{Synchronous::normal};
758 std::chrono::milliseconds _busyTimeout{kBusyTimeoutMillis};
759 ::core::platform::WallClockRef _wallClock;
760 // SQLite's numeric synchronous level, read back at construction.
761 int _synchronousLevel{-1};
762 // The mode the database actually ended up in, as read back at construction
763 // -- "wal" normally, something else on a filesystem that cannot support it.
764 std::string _journalMode;
765 };
766
767 std::optional<std::size_t> _maxDepth;
768 // SQLite's numeric synchronous level, read back at construction.
769 int _synchronousLevel{-1};
770 // The mode the database actually ended up in, as read back at construction
771 // -- "wal" normally, something else on a filesystem that cannot support it.
772 std::string _journalMode;
773 ::morph::exec::detail::OwnedState<State> _owned;
774};
775
776} // namespace morph::offline
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
Move-only handle representing the eventual result of an asynchronous operation.
A replay attempt count, as a type distinct from a queue item id.
void setIdempotencyKey(uint64_t itemId, std::string idempotencyKey) override
Stamps an idempotency key onto an already-inserted row, on the owner. No-op if itemId is absent....
::morph::async::Completion< uint64_t > askEnqueue(::morph::exec::IExecutor &replyExec, std::string payload, std::string idempotencyKey) override
Enqueues on the owner, answered on replyExec.
::morph::async::Completion< std::vector< QueueItem > > askDrain(::morph::exec::IExecutor &replyExec) const override
Reads every pending row on the owner, answered on replyExec.
SqliteOfflineQueue(::morph::exec::IExecutor &owner, std::filesystem::path path, std::optional< std::size_t > maxDepth=std::nullopt, ::morph::core::FileIoOps ioOps={}, Synchronous synchronous=Synchronous::normal, std::chrono::milliseconds busyTimeout=std::chrono::milliseconds{kBusyTimeoutMillis}, ::core::platform::WallClockRef wallClock=::core::platform::defaultSystemWallClock())
Opens (or creates) the queue database at path, creating the schema if it does not already exist; the ...
std::optional< std::size_t > maxDepth() const override
Returns the configured maximum depth, or std::nullopt if unbounded. Callable anywhere: fixed at const...
int synchronousLevel() const noexcept
SQLite's numeric PRAGMA synchronous level for this connection, as read back at construction.
std::size_t size() const override
Returns the number of pending rows. On the owner.
static constexpr int kBusyTimeoutMillis
PRAGMA busy_timeout set at construction: how long a statement blocks on SQLITE_BUSY before giving up,...
void setAttempts(uint64_t itemId, Attempts attempts) override
Persists an updated attempt count for itemId, on the owner. No-op if absent.
std::vector< QueueItem > drain() const override
Returns all pending rows in ascending-id (enqueue) order. On the owner.
::morph::async::Completion< std::size_t > askSize(::morph::exec::IExecutor &replyExec) const override
Counts the rows on the owner, answered on replyExec.
uint64_t enqueue(std::string payload) override
Inserts payload with an empty idempotency key. On the owner.
uint64_t enqueue(std::string payload, std::string idempotencyKey) override
Inserts payload carrying idempotencyKey in one write. On the owner. A non-empty key already present o...
Synchronous
How hard each committed statement is pushed onto the platter.
@ full
PRAGMA synchronous=FULL — fsync on every committed statement. Measured ~18x slower per mutation (~0....
@ normal
PRAGMA synchronous=NORMAL — the default, and SQLite's own recommendation under WAL....
const std::string & journalMode() const noexcept
The journal_mode this database actually ended up in.
void markDone(uint64_t itemId) override
Deletes the row identified by itemId, on the owner. No-op if absent.
OwnedState: a component's state, and the one executor it is touched on.
The raw file-I/O primitives morph::journal::FileActionLog and morph::offline::FileOfflineQueue both c...
Abstract executor interface.
Definition executor.hpp:70
Interface for durable storage of actions that could not be delivered.
virtual uint64_t enqueue(std::string payload)=0
Appends payload to the queue with no idempotency key.
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.
Thrown when a SQLite operation used by SqliteOfflineQueue fails.