Dispatching an event to the listener previously happened from an un-awaited goroutine, with only a log line on failure - once processed_at was set, a dropped or failed notification was permanently and silently lost, with no way to tell it had happened. Replaces the processed/processed_successfully booleans with a notified_at/processed_at pair (migration 000031): the dispatcher sets notified_at and hands the event to MockEventListener.Notify, which now also receives an ack callback the listener calls whenever it's truly done, synchronously or arbitrarily later. Anything still "notified" but unacked past notifyRetryAfter (a tunable field, not a stored per-row timestamp) gets notified again on the dispatcher's normal poll/reactive loop - no new retry mechanism needed. ack is idempotent, since a late ack from an earlier attempt and one from a retry can both eventually fire for the same event. Dispatch is deferred until after the transaction that recorded notified_at has actually committed, so ack's independent write can't race a still-open transaction it implicitly depends on being visible. The real SSE listener (server/sse/db_event_publisher.go) acks inline, since its work is synchronous. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
5.1 KiB
Work Summary — 2026-08-05 20:17
Task
Insight #2 from the domains/amazon design review: fire-and-forget listener notifications meant a dropped/failed SSE push was silently permanent, with the underlying event already marked processed. Fixed per a design worked out collaboratively with the user (not unilaterally chosen - this one had real tradeoffs).
Design (agreed with user before implementing)
Non-blocking dispatch is a hard constraint - events come from external systems whose APIs may change unexpectedly, so the dispatcher must never wait on the listener's real work. The agreed shape:
- Three states, driven by two nullable timestamps rather than a stored
notify_again_at:unprocessed(notified_at IS NULL) →notified(notified_atset,processed_atstill null) →processed(processed_atset). - The dispatcher sets
notified_atand calls the listener; it never setsprocessed_atitself. - The listener signals completion via a callback passed into
Notifyitself:Notify(ctx, e, ack func(context.Context) error) error.ackcan be called synchronously (fast listeners, like the real SSE one) or arbitrarily later from elsewhere (slow/async listeners) - exactly once is the contract, and extra calls are safe (see below). - If an event sits in
notifiedpast a retry threshold without being acked, the dispatcher's normal loop re-notifies it - reusing the existing poll/reactive machinery, no new loop. The threshold (notifyRetryAfter) is a Go-side field with a sensible default, not a stored per-row timestamp, so the cadence can change without touching any row (explicit user preference over storingnotify_again_at).
Changes
database_migrations/000031_amazon_event_notify_ack.{up,down}.sql: dropsprocessed/processed_successfullybooleans onmock.shop_amazon_events, adds nullablenotified_at/processed_attimestamps, updates the partial index accordingly. Applied to both the test DB and the real dev DB.domains/amazon/mock.go:MockEventListener.Notifygained theackparameter.Mocksgained anotifyRetryAfterfield (default 30s) andWithNotifyRetryAfter(d)builder for tuning/tests.processUnprocessedEvents's query now selects events that are either brand new or notified-but-stale (processed_at IS NULL AND (notified_at IS NULL OR notified_at < retry_after)).- Restructured so the async dispatch to the listener happens after the transaction that recorded
notified_athas committed, not from inside it -ack's own independent write can never race a still-open transaction it implicitly depends on being visible. ackis idempotent (... AND processed_at IS NULLin its UPDATE), since a late ack from an earlier notification and a fresh one from a retry can both eventually fire for the same event.
server/sse/db_event_publisher.go: the real listener now acks inline right after a successful SSE publish - its work is synchronous, so there's no reason to defer completion.domains/amazon/mock_test.go: updatednotifySpyfor the new signature (records ack callbacks, supports disabling auto-ack to simulate a listener that doesn't finish), updatedisProcessed/addedisNotifiedto check the new columns, and addedTestProcessUnprocessedEvents_RetriesUnackedNotification- the core new-behavior test: a non-acking listener gets re-notified after the retry window (not before), and once any recorded ack for that event fires (including a stale one from an earlier attempt, not just the latest), the event settles intoprocessedand stops being retried.
A test bug caught and fixed along the way (not production code)
First run: TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches failed (8/150 events still unprocessed) and the new retry test hung/timed out. Both were bugs in my own notifySpy, not the production code:
- It signaled "notified" before calling the auto-ack, so a test could observe "all N notified" before all N acks had actually landed in the DB.
waitForCountdrained a fixed number of channel signals per call instead of tracking a cumulative total, so calling it twice (once for 1, once for 2) double-counted and hung waiting for a signal that would never come.
Fixed by replacing the channel with a cumulative completedCount incremented only after any auto-ack attempt returns, and made waitForCount poll that count (safe to call repeatedly with increasing thresholds).
Verification
go build ./.../go vet ./...clean.go test ./domains/amazon/... -v -race: all 5 tests pass (4 existing + 1 new).- 10x repeated runs (
-count=1 -race) with no flakes, ~1.3-1.5s each. make testandmake test-against-dev-db: full suite green both ways; confirmed zero leftover rows in the dev DB afterward.- Migration applied cleanly to both the test DB and the real dev DB; verified
\d mock.shop_amazon_eventsmatches the intended shape on both.
Follow-ups / not done here
- Insights #3 (hardcoded 1-minute poll interval) and #4 (a LISTEN-connection error is fatal to the entire application, most consequential) remain open - continuing one at a time per the user's request.