Skip to content

docs(design): webhook fan-out → River migration plan#398

Merged
jiashuoz merged 1 commit into
mainfrom
docs/webhook-fanout-river
Jul 9, 2026
Merged

docs(design): webhook fan-out → River migration plan#398
jiashuoz merged 1 commit into
mainfrom
docs/webhook-fanout-river

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Jul 9, 2026

Copy link
Copy Markdown
Member

Scopes the last River migration: moving the webhookpub.OutboxWorker (the hand-rolled loop that fans webhook_eventswebhook_subscriber_deliveries, Layer 1 → Layer 2) onto River, completing the webhook triad after the delivery migration.

Design in one line

Same three-layer, in-tx-enqueue pattern as inbound (#391) and delivery: the trigger writes the webhook_events fact row and enqueues a webhook_fan_out River job (event_id) in one tx; a FanOutWorker re-reads, matches subscriptions, inserts deliveries + enqueues the existing delivery jobs, marks the event terminal. The enqueue seam is the two Outbox methods (PublishTx/PublishBestEffortTx) — both already hold the caller's tx — so the ~10 trigger call sites don't change.

Why (and why it's low priority)

It's the sole remaining bespoke background loop doing durable work, hand-rolling what River already gives: leasing, lease-expiry reclaim, retry/backoff, LISTEN-driven low-latency pickup, multi-replica safety. It is not a durability gapwebhook_events is durable + retry-forever and the fallback poll covers missed NOTIFYs — so this is a consistency/code-health cleanup that deletes ~200 lines, not a fix.

Slices (one PR each)

  • 0fanout_job_id column + reconciler partial index (CONCURRENTLY). Additive.
  • 1internal/webhookfanout worker + registrar + reconciler behind E2A_WEBHOOK_FANOUT_MODE (default legacy), dormant.
  • 2 — wire the in-tx enqueue into the two Outbox methods + startup/periodic reconciler; live e2e in river mode.
  • 3 — flip the flag in e2a-ops (one-line rollback).
  • 4 — delete OutboxWorker (LISTEN/reconnect, poll ticker, manual lease), the webhook_events_new NOTIFY trigger, and the flag.

Preserved

(event_id, webhook_id) dedup + WHERE status='pending' guard (kept verbatim → idempotent under at-least-once, safe across cutover), processed/no_match states, the delivery-history API, redelivery/replay (doesn't flow through fan-out — untouched), and sub-second latency (River pickup is itself LISTEN/NOTIFY-driven).

Open decisions (recommendations in doc §4.3, §8)

  1. Reuse QueueWebhook (recommended) vs. a dedicated QueueWebhookFanout lane.
  2. Delete the mode flag in Slice 4 (recommended, River unconditional) vs. keep it.

🤖 Generated with Claude Code

Scopes moving the last hand-rolled durable background loop — the
webhookpub OutboxWorker that fans webhook_events → subscriber_deliveries
(Layer 1 → Layer 2) — onto River, completing the webhook triad after the
delivery (Layer 2 → 3) migration.

Same three-layer in-tx-enqueue pattern as inbound (#391): the trigger
writes the webhook_events fact row AND enqueues a webhook_fan_out River
job in one tx; a FanOutWorker re-reads, matches, inserts deliveries +
enqueues the existing delivery jobs, marks the event terminal. The
enqueue seam is the two Outbox methods (PublishTx/PublishBestEffortTx) —
so the ~10 trigger sites don't change.

Flag-gated (E2A_WEBHOOK_FANOUT_MODE, default legacy) + reconciler, 5
slices, deleting ~200 lines of LISTEN/reconnect/lease/poll code at the
end. Not a durability fix (the current path is durable + correct) — a
consistency/code-health cleanup, the lowest-priority of the River
migrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfAXBRMJ3KPuU3UEgwqjhS
@jiashuoz jiashuoz merged commit 5ff502d into main Jul 9, 2026
15 checks passed
@jiashuoz jiashuoz deleted the docs/webhook-fanout-river branch July 9, 2026 18:44
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