Skip to content

offline: bound IOfflineQueue depth with a reject-newest overflow policy - #123

Merged
Yaraslaut merged 4 commits into
masterfrom
offline/queue-depth-bound-and-overflow-policy
Aug 17, 2026
Merged

offline: bound IOfflineQueue depth with a reject-newest overflow policy#123
Yaraslaut merged 4 commits into
masterfrom
offline/queue-depth-bound-and-overflow-policy

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

Closes #112. IOfflineQueue and 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 whose SyncWorker replay 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

  • Policy: reject-newest. Once a bounded queue holds maxDepth() items, enqueue() throws the new OfflineQueueFullError instead 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.
  • Opt-in, unbounded by default. maxDepth is a new std::optional<std::size_t> parameter appended last on every constructor, defaulting to std::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.
  • New interface members (non-pure virtuals with behavior-preserving defaults, so no existing IOfflineQueue subclass is forced to change): size() (defaults to drain().size()) and maxDepth() (defaults to std::nullopt).
  • Observability: a new Metric::queueOverflow counter fires immediately before each rejection, with the rejection-time size as its value.

Per-implementation enforcement

  • InMemoryOfflineQueue: capacity checked under the existing lock, before push_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 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 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() becomes const

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 of relying on the default. SqliteOfflineQueue needed mutable sqlite3* _db (plus its existing mutex made mutable) 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 across include/, examples/, and tests/ compiles unchanged — confirmed via a full default build (examples/concepts included) plus a targeted grep. The only source-level break is for a hypothetical out-of-tree IOfflineQueue subclass that predates this change and overrides drain() as non-const — mechanical fix (add const). 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 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 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), and test_sync_worker.cpp (1) — covering: below/at/over capacity, markDone freeing capacity, unbounded-by-default, the size() default delegating to drain().size(), OfflineQueueFullError's fields, per-implementation queueOverflow metric emission, maxDepth surviving a destroy/reopen cycle over the same file, the documented dedup-hit-vs-capacity conservatism (locked in explicitly for both FileOfflineQueue, where it never fires, and SqliteOfflineQueue, where it deliberately does), and SyncWorker::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:

morph_tests.exe: All tests passed (20099 assertions in 1067 test cases)
morph_tests.exe "[overflow]": All tests passed (10037 assertions in 13 test cases)

(The remaining 5 of the 18 new tests live in morph_offline_sqlite_tests, a separate binary gated behind MORPH_BUILD_OFFLINE_SQLITE off by default with no vcpkg entry in this environment — I could not independently re-run it here due to a missing sqlite3.dll on PATH, a local environment gap, not a code issue. Reviewed those 5 tests by inspection instead; they follow the same pattern as the reviewed-and-passing InMemoryOfflineQueue/FileOfflineQueue tests. The implementer's own report notes 2 pre-existing, unrelated failures on that binary — a Windows file-lock/WAL-handle issue on an unscoped SqliteOfflineQueue local — confirmed via git stash to reproduce identically on unmodified master, i.e. not introduced by this change.)

Yaraslau Tamashevich and others added 2 commits August 17, 2026 14:32
…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

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Yaraslau Tamashevich and others added 2 commits August 17, 2026 15:15
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>
@Yaraslaut
Yaraslaut merged commit e6020a5 into master Aug 17, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IOfflineQueue has no depth bound or overflow policy

1 participant