Skip to content

v2.3.0

Choose a tag to compare

@github-actions github-actions released this 02 Jul 22:57
· 81 commits to main since this release

What's changed in v2.3.0

  • feat: outbox dedup + perf cleanup (remove OutboxWorker stack, batch settlement) (#106) (by @patrickleet)

    • remove vestigial OutboxWorker publish stack

    Delete the parallel outbox publish pipeline (OutboxWorker, DrainResult,
    ProcessOneResult, OutboxPublisher, LogPublisher, LocalEmitterPublisher):
    a complete second claim -> publish -> settle state machine that mutated
    loaded rows in memory without durable settle, duplicating the production
    drain path in OutboxDispatcher::dispatch_claimed with its own degenerate
    message shape (event_type + raw bytes + metadata map instead of the
    canonical bus Message).

    Its only consumers were the todos integration tests, now ported to
    OutboxDispatcher with a recording MessagePublisher. LocalEmitterPublisher
    had no other users (the emitter feature's real path is src/emitter
    enqueue), so it is not recreated. Pre-release: no deprecation shims.

    Co-Authored-By: Claude Fable 5 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n

    • perf: convert outbox rows to transport messages by value

    The immediate-publish path cloned each staged row into the publish hook,
    then From<&OutboxMessage> for Message cloned the payload bytes and every
    metadata string a second time. Every dispatch path (dispatch_claimed,
    publish hook, outbox source) owns its row by the time it maps, so the
    conversion is now From (by value) and the payload,
    event type, id, and metadata strings move instead of being re-cloned.

    Co-Authored-By: Claude Fable 5 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n

    • refactor: share one publish-then-settle path for dispatcher and hook

    OutboxDispatcher::dispatch_claimed and BusOutboxPublishHook carried the
    same publish -> complete-or-record_failure sequence in two places.
    Extract it as publish_and_settle, used by both. The hook still never
    re-claims: it is handed rows already claimed inside the commit
    transaction, and publish_and_settle only settles claims it is given.

    OutboxPublishHook::publish_claimed now takes the commit's claimed rows
    as one batch (callers looped per row anyway), which also sets up
    batched settlement for the after-commit path.

    Co-Authored-By: Claude Fable 5 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n

    • perf: batch outbox settlement and bound publish concurrency

    dispatch_claimed settled strictly serially: one publish round trip plus
    one complete UPDATE round trip per message.

    • Add OutboxStore::complete_many (default: serial loop). Postgres settles
      the batch in one UPDATE ... FROM unnest(ids, workers, attempts)
      statement with the same per-claim predicate as complete, diagnosing
      unapplied claims to the same NotFound/InvalidState errors; SQLite runs
      the per-claim UPDATEs in one transaction (one commit/fsync per batch;
      an IN-list cannot carry the per-claim worker/attempt predicate); the
      hashmap store settles under a single write lock. Additive only in the
      repo backends: new fns, no restructuring.
    • publish_and_settle now publishes with bounded concurrency
      (buffer_unordered) and settles all successes in one complete_many call;
      publish failures remain settled individually via record_failure.
    • OutboxDispatcher::with_publish_concurrency(NonZeroUsize) exposes the
      publish window. It defaults to 1 because outbox claim order is
      created-at order and consumers may rely on it: 1 preserves strict
      ordering on the wire; higher values overlap publish round trips but may
      deliver out of order. The after-commit hook always uses 1 — a commit's
      rows are one aggregate's events, where relative order matters.
    • If the batched complete fails, published-but-unsettled rows stay in
      flight and are re-published after lease expiry — the same at-least-once
      window a crash between publish and settle always leaves.
    • Adds futures-util (no default features) to the core crate for
      buffer_unordered; the runtime-agnostic core stays executor-free.
    • Conformance: worker_completes_claims_in_one_batch runs complete_many
      against hashmap, Postgres, and SQLite.

    Co-Authored-By: Claude Fable 5 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n

    • refactor: share claim_order_key between outbox store ordering helpers

    sort_by_claim_order and claim_order_ids each restated the claim ordering
    (created-at, then message id). One claim_order_key fn now defines it and
    both helpers sort by it, so the two orderings cannot drift apart.

    Co-Authored-By: Claude Fable 5 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n

    • chore: consolidate outbox_worker test block_on into one test util

    The busy-poll test executor was copy-pasted into three outbox_worker
    test modules (two more copies died with the OutboxWorker stack). One
    #[cfg(test)] testing module now owns it; src/bus copies are untouched.

    Co-Authored-By: Claude Fable 5 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n

    • fix: preserve sub-second precision in outbox lease deadlines

    lease_deadline_secs truncated now + lease to whole seconds (.as_secs()),
    and claim() rebuilt the SystemTime from that u64 — so a sub-second lease
    produced a deadline at or before now (expired at birth), and every lease
    lost up to a second. claim() now takes the deadline as a SystemTime and
    lease_deadline computes now + lease at full precision, keeping the
    overflow and before-epoch validation. The stores already persist
    leased_until with sub-second precision (f64 epoch / nanosecond storage),
    so only the in-memory computation was lossy.

    Unit test covers a sub-second lease: deadline is exactly now + lease and
    is not expired until the lease elapses.

    Co-Authored-By: Claude Fable 5 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n


    Co-authored-by: Claude Fable 5 noreply@anthropic.com

See full diff: v2.2.4...v2.3.0