offline: bound IOfflineQueue depth with a reject-newest overflow policy - #123
Merged
Merged
Conversation
…cy (morph#112) IOfflineQueue previously had no depth bound or overflow policy -- an unbounded producer (a host stuck offline for a long time) could grow InMemoryOfflineQueue/FileOfflineQueue/SqliteOfflineQueue without limit. Adds an opt-in, per-instance maxDepth (std::optional<std::size_t>, default std::nullopt/unbounded) as the last constructor parameter on all three shipped implementations, enforced with a reject-newest policy: once a bounded queue holds maxDepth() items, enqueue() throws the new OfflineQueueFullError (a std::runtime_error carrying maxDepth and currentSize) instead of evicting an older item or invoking an app-defined eviction callback. Evict-oldest would silently destroy data the caller believes durable and break replay ordering with no error raised anywhere; IOfflineQueue's existing virtual interface is already the seam for a host that wants different eviction semantics (subclass it directly), so no second overlapping policy mechanism was added. Each enqueue() rejection emits the new Metric::queueOverflow counter (observability.hpp) with the rejection-time size, immediately before throwing. Per-implementation enforcement: - InMemoryOfflineQueue: capacity checked under the existing lock, before push_back. No idempotency-key dedup exists here, so there is no dedup-vs-capacity ordering question. - FileOfflineQueue: capacity checked after the existing keyed-dedup scan, before appendPut -- a dedup hit (re-enqueue of an already- pending key) always succeeds even on a full queue, since it inserts nothing new. - SqliteOfflineQueue: capacity checked (SELECT COUNT(*), via a new countLocked() helper) before both INSERT paths, including the keyed ON CONFLICT ... DO NOTHING path. This means a call that would have resolved to a dedup hit can still be rejected if the queue is full at that moment -- a deliberate, documented conservatism rather than a second round-trip to special-case it. drain() moves from non-const to const across IOfflineQueue and all three implementations, since none of their bodies mutate instance state beyond taking a lock; this lets the new size() default (drain().size()) be const too, and every shipped implementation overrides size() with a direct O(1)/indexed count instead. SqliteOfflineQueue needed `mutable sqlite3* _db` (plus its existing mutex made mutable) since SQLite's C API takes a non-const handle throughout and prepare()/bindText()/bindInt64()/stepOrThrow() all needed to become callable from const context -- the idiomatic fix for a logically-const method wrapping a C API with no const overloads. Two test-only IOfflineQueue subclasses (MinimalQueue in test_offline_queue.cpp, NonDurableQueue in test_sync_worker.cpp) updated their drain() overrides to const to match. docs/spec/offline/offline.md's "Offline queue" section documents size()/maxDepth(), OfflineQueueFullError, the reject-newest policy and its rationale (with a per-implementation enforcement-order note), the queueOverflow metric, and that maxDepth is a per-construction parameter (not persisted on disk -- a reopened FileOfflineQueue/ SqliteOfflineQueue must pass the same value again). Verified every existing enqueue()/constructor call site under include/, examples/, and tests/ still compiles unchanged (the new maxDepth parameter defaults to std::nullopt everywhere) -- confirmed via a full default build (examples/concepts included). 18 new tests added across test_offline_queue.cpp (7), test_file_offline_queue.cpp (5), test_sqlite_offline_queue.cpp (5), and test_sync_worker.cpp (1), all passing. Full suite: 1049 -> 1067 test cases (morph_tests, MSVC/Ninja Debug), all passing, no regressions. morph_offline_sqlite_tests: 5 -> 10 test cases; all 5 new overflow tests pass. 2 pre-existing, unrelated failures on that binary (Windows file-lock on an unscoped SqliteOfflineQueue local still holding its WAL file open when the test tries to remove() it) reproduce identically on unmodified master. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rename the constructor parameters (maxDepth/currentSize) to maxDepthValue/currentSizeValue -- clang's -Wshadow-field-in-constructor (enabled under -Weverything -Werror on the Linux clang-tidy-diff and Windows clangcl-release CI legs) correctly flagged the original parameter names as shadowing the identically-named public fields they initialize. MSVC (this worktree's local build toolchain) doesn't enable this warning, so it wasn't caught before pushing. No functional change; member field names and the exception's public API are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The Header <-> spec sync check flagged this PR's include/morph/core/ observability.hpp change (the new Metric::queueOverflow enumerator) with no matching docs/spec/core/** update -- docs/spec/offline/ offline.md was updated (correctly, from the offline-queue side) but the Metric enum's own spec, which explicitly enumerated "eight observation kinds" in two places, was not. Adds queueOverflow to both the enumerator table and the call-site table (fired by all three IOfflineQueue::enqueue implementations), and updates both enumerator-count references from eight to nine. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tch miss Confirmed via llvm-cov's own per-line HTML report (ground truth, not codecov's report which has an unrelated pre-existing per-instantiation line-record issue tracked in morph#92): FileOfflineQueue::maxDepth() was the only genuinely uncovered line in this diff. Its InMemoryOfflineQueue and SqliteOfflineQueue counterparts both have a direct maxDepth() assertion in their own test files; FileOfflineQueue's test file added the throw/reopen/dedup/size()/metric coverage but never called maxDepth() itself. Adds the missing assertion to the existing "enqueue at maxDepth throws" test (mirrors test_offline_queue.cpp's pattern) plus a dedicated "maxDepth() is std::nullopt when unbounded" case, mirroring what InMemoryOfflineQueue's own test file already covers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #112.
IOfflineQueueand all three shipped implementations (InMemoryOfflineQueue,FileOfflineQueue,SqliteOfflineQueue) previously grew without bound — no capacity parameter, no depth cap, no overflow signal. A client stuck offline indefinitely (or whoseSyncWorkerreplay keeps failing) had no way to stop the queue growing, and no way to learn it had crossed any threshold. This surfaced during the kanban rung 4 work (examples/kanban/README.md's "Expected strain points": "Offline queue growth is unbounded... define an overflow policy [framework gap]").Design: reject-newest, opt-in, backward-compatible
maxDepth()items,enqueue()throws the newOfflineQueueFullErrorinstead of silently evicting an older item or invoking an app-defined eviction callback. Evict-oldest would silently destroy data the caller believes durable and break replay ordering with no error raised anywhere.IOfflineQueue's existing virtual interface is already the seam for a host that wants different eviction semantics (subclass it directly) — no second, overlapping policy mechanism was added.maxDepthis a newstd::optional<std::size_t>parameter appended last on every constructor, defaulting tostd::nullopt(unbounded) — every existing call site keeps compiling and keeps growing unbounded exactly as before. No numeric default is proposed; a host that wants a cap passes one explicitly, since the right number depends entirely on app write rate and payload size.IOfflineQueuesubclass is forced to change):size()(defaults todrain().size()) andmaxDepth()(defaults tostd::nullopt).Metric::queueOverflowcounter fires immediately before each rejection, with the rejection-time size as its value.Per-implementation enforcement
InMemoryOfflineQueue: capacity checked under the existing lock, beforepush_back. No idempotency-key dedup exists here, so there's no dedup-vs-capacity ordering question.FileOfflineQueue: capacity checked after the existing keyed-dedup scan — a dedup hit (re-enqueue of an already-pending key) always succeeds even on a full queue, since it inserts nothing new.SqliteOfflineQueue: capacity checked (SELECT COUNT(*), via a newcountLocked()helper) before both INSERT paths, including the keyedON CONFLICT ... DO NOTHINGpath. This means a call that would have resolved to a dedup hit can also be rejected if the queue happens to be full at that moment — a deliberate, documented conservatism rather than an extra round trip to special-case it.A structural side effect:
drain()becomesconstdrain()moves from non-const to const acrossIOfflineQueueand all three implementations, since none of their bodies mutate instance state beyond taking a lock — this lets the newsize()default (drain().size()) be const too, and every shipped implementation overridessize()with a direct O(1)/indexed count instead of relying on the default.SqliteOfflineQueueneededmutable sqlite3* _db(plus its existing mutex mademutable) since SQLite's C API takes a non-const handle throughout — the idiomatic fix for a logically-const method wrapping a C API with no const overloads.Backward compatibility
Every existing
enqueue()/constructor call site acrossinclude/,examples/, andtests/compiles unchanged — confirmed via a full default build (examples/conceptsincluded) plus a targeted grep. The only source-level break is for a hypothetical out-of-treeIOfflineQueuesubclass that predates this change and overridesdrain()as non-const — mechanical fix (addconst). This is otherwise a strictly additive, opt-in change; adding two non-pure virtuals to a public interface is an ABI break for prebuilt binaries linking against the old vtable shape, acceptable pre-1.0 and no different in kind from prior additions to this same interface (setAttempts).Spec update
docs/spec/offline/offline.md's "Offline queue" section now documentssize()/maxDepth(),OfflineQueueFullError, the reject-newest policy and its rationale (with a per-implementation enforcement-order note), thequeueOverflowmetric, and thatmaxDepthis a per-construction parameter, not persisted on disk — a reopenedFileOfflineQueue/SqliteOfflineQueuemust pass the same value again to keep the same cap enforced.Testing
18 new tests across
test_offline_queue.cpp(7),test_file_offline_queue.cpp(5),test_sqlite_offline_queue.cpp(5), andtest_sync_worker.cpp(1) — covering: below/at/over capacity,markDonefreeing capacity, unbounded-by-default, thesize()default delegating todrain().size(),OfflineQueueFullError's fields, per-implementationqueueOverflowmetric emission,maxDepthsurviving a destroy/reopen cycle over the same file, the documented dedup-hit-vs-capacity conservatism (locked in explicitly for bothFileOfflineQueue, where it never fires, andSqliteOfflineQueue, where it deliberately does), andSyncWorker::run()over an already-full queue draining/replaying normally.Verified independently (not just trusting the implementer's own report) — rebuilt and ran the full suite myself on this exact tree:
(The remaining 5 of the 18 new tests live in
morph_offline_sqlite_tests, a separate binary gated behindMORPH_BUILD_OFFLINE_SQLITEoff by default with no vcpkg entry in this environment — I could not independently re-run it here due to a missingsqlite3.dllonPATH, a local environment gap, not a code issue. Reviewed those 5 tests by inspection instead; they follow the same pattern as the reviewed-and-passingInMemoryOfflineQueue/FileOfflineQueuetests. The implementer's own report notes 2 pre-existing, unrelated failures on that binary — a Windows file-lock/WAL-handle issue on an unscopedSqliteOfflineQueuelocal — confirmed viagit stashto reproduce identically on unmodifiedmaster, i.e. not introduced by this change.)