Skip to content

feat(jobs): transactional outbox — opt-in atomic event dispatch - #60

Merged
moveeeax merged 1 commit into
masterfrom
feat/outbox
Aug 23, 2026
Merged

feat(jobs): transactional outbox — opt-in atomic event dispatch#60
moveeeax merged 1 commit into
masterfrom
feat/outbox

Conversation

@moveeeax

Copy link
Copy Markdown
Owner

After-response dispatch loses the event if the process dies between the DB commit and the job submit. The template now ships a transactional outbox for the flows where that's not acceptable.

  • Outbox::enqueue(txn, kind, payload) inserts in the caller's open transaction — commit makes the domain write and the event durable atomically; rollback erases both (proven by the RolledBackEnqueueIsInvisible test).
  • Outbox::drain: short claim txn (FOR UPDATE SKIP LOCKED, no Redis I/O under row locks) → Jobs::submit per row → one finalize txn (delete successes, release+count failures). A drainer dying mid-flight can't strand rows — stale claims are re-claimed after 300 s; honest at-least-once, documented (handlers must tolerate duplicates anyway due to the jobs lease path).
  • Opt-in by design: outbox.drain_interval_sec defaults to 0 (off), existing email/webhook flows untouched — behavior bit-for-bit. The decision guide (when after-response is fine vs when you need the outbox) lives in the header doc-comment and as CONVENTIONS gotcha 20, with a 3-line billing-fork example inside the wallet transaction.
  • 4 integration tests: commit→drain→job really in Redis; rollback invisibility; failed-submit attempts/last_error/re-drain; two concurrent drainers × 40 rows — exactly-once submission, queue depth +40.

Gates green incl. module-deps (63 edges — the one new jobs→database edge declared) and config-sync (141 triples). Compile + tests: this CI run.

After-response dispatch (4-arg with_repo_errors) is best-effort by design:
a process dying between the DB commit and Jobs::submit loses the event.
For must-not-lose events (money mail, reconciled webhooks) add the outbox:

- migrations/010_outbox.sql: outbox table (kind, jsonb payload, claimed_at,
  attempts, last_error) + partial index over the unclaimed backlog
- src/jobs/Outbox.hpp/.cpp: Outbox::enqueue(txn, kind, payload) INSERTs in
  the caller's open transaction (commit = event durable, rollback = gone);
  Outbox::drain(batch) claims via UPDATE ... FOR UPDATE SKIP LOCKED,
  relays to Jobs::submit, DELETEs on success, releases with attempts+1 +
  last_error on failure; stale claims (dead drainer) re-claimable after
  kStaleClaimSec — at-least-once end to end
- Core schedules the drain every outbox.drain_interval_sec seconds
  (OUTBOX_DRAIN_INTERVAL_SEC, default 0 = off — the pattern is opt-in;
  existing email/webhook flows keep their after-response path unchanged)
- docs: CONVENTIONS gotcha 20 (outbox vs after-response decision + 3-line
  billing-fork example), CONFIG.md row, module-deps edge jobs -> database
- tests/integration/test_outbox.cpp: commit->drain->job-in-Redis, rollback
  invisibility, failed-submit retry accounting, concurrent-drain
  exactly-once partition
@moveeeax
moveeeax merged commit cdd2469 into master Aug 23, 2026
11 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.

1 participant