Skip to content

fix(notifications): lease deliveries instead of marking sent on claim - #167

Merged
balebbae merged 3 commits into
mainfrom
feat/notification-delivery-lease
Sep 11, 2026
Merged

balebbae merged 3 commits into
mainfrom
feat/notification-delivery-lease

Conversation

@balebbae

Copy link
Copy Markdown
Collaborator

The bug

ClaimDue set sent_at = now() at claim time, so sent_at meant "a dispatcher saw this row", not "hackers were notified".

Any interruption between the claim and the actual push — SIGTERM during a deploy, OOM, a Cloud Run scale-down — left the row marked sent. It was never retried, and nobody got the notification. Silent loss, invisible in the UI, because the row looked delivered.

The fix

Migration 000051 splits that overloaded column into a revocable lease and a real delivery record:

Column Meaning
claimed_at The lease. A dispatcher that dies mid-delivery leaves the row claimable again once it elapses.
sent_at Now means only that delivery actually happened.
attempts Bounds retries so a poison row cannot loop forever.
failed_at Terminal — the dispatcher gave up.
last_error Why it gave up.

ClaimDue leases with FOR UPDATE SKIP LOCKED, filtering on attempts < maxAttempts and claimed_at IS NULL OR claimed_at < now - lease. Outcomes resolve through MarkSent / ReleaseClaim / MarkFaileda claim that is never resolved is exactly the bug this prevents.

Rows already marked sent are left alone; they can't be reclassified retroactively.

Why DISPATCHER_MAX_LATENESS_MINUTES is part of this

Making delivery reliable creates a new failure mode. Previously a notification stuck across an outage was lost; now it is faithfully retried — and would go out hours late. A "starting in 15 minutes" reminder arriving at 3am is worse than none.

DISPATCHER_MAX_LATENESS_MINUTES (default 30) bounds the retry window. Past it the row is failed rather than sent, and surfaces in a new Failed tab in the superadmin notifications table instead of vanishing. The test asserts the strong version: ListByRole is never called, so no subscription is even contacted.

Note: because the check is late > maxLateness, setting this to 0 expires every notification rather than disabling the check. .env.example calls that out.

Concurrent delivery

Delivery now fans out concurrently under a semaphore, with each subscription's outcome recorded per-index so aggregation stays deterministic. The batch is bounded by a deadline inside the lease, so one hanging push service cannot outlive the claim it was dispatched under. bytes.Clone per send — webpush appends its padding delimiter into the payload slice's spare capacity, so concurrent sends must not share one slice.

Bookkeeping runs on a context.WithoutCancel context, so a claim is still resolved when the dispatcher context is already cancelled by shutdown.

Verification

go build, go vet, gofmt, go test ./... and go test -race ./... all pass (race matters — delivery is now concurrent). Portal format:check, lint and build pass.

New coverage in dispatcher_test.go: marks sent after a real delivery; releases the claim when subscriptions cannot be listed; fails terminally once attempts are exhausted; expires past the lateness window without sending; resolves the claim even when the dispatcher context is already cancelled.

⚠️ TestIntegrationNotificationLease has not been run. It is gated behind HARP_TEST_DSN, and it is the only test that exercises the real SQL — the claim query, the partial index, the migration itself. Everything else runs on MockStore. Please run it against a live database before merging:

HARP_TEST_DSN=<dsn> go test ./internal/store/ -run TestIntegrationNotificationLease -v

Notes for review

  • swag v1.16.4 cannot parse the Go 1.27 stdlib, so task gen-docs fails on main today. docs/docs.go here was updated by hand to add the new fields; it compiles, but the generator mismatch is worth fixing separately.
  • This was developed in the hackutd/hackutd-harp fork and rebased onto main. Two fork-only dependencies were deliberately dropped rather than dragged in: the dispatcher goroutine is no longer registered with that fork's backgroundJobs drain (the lease makes it unnecessary — an unresolved claim expires and retries), and the fork's walk-in integration test was left behind.
  • Push-endpoint log redaction (pushEndpointHost / pushSendError) is preserved in the new concurrent send path; the fork predated it.

🤖 Generated with Claude Code

https://claude.ai/code/session_012CcXtZJizDrfbeYZ551eZ5

balebbae and others added 3 commits September 11, 2026 14:35
ClaimDue set sent_at = now() at claim time, so sent_at meant "a dispatcher
saw this row", not "hackers were notified". Any interruption between the
claim and the push — SIGTERM, OOM, a Cloud Run scale-down — left the row
marked sent and it was never retried. The notification was lost silently.

Split that single column into a revocable lease and a real delivery record:

- claimed_at is the lease. A dispatcher that dies mid-delivery leaves the
  row claimable again once the lease elapses, instead of burning it.
- sent_at now means only that delivery actually happened.
- attempts bounds retries so a poison row cannot loop forever.
- failed_at is terminal, with last_error recording why.

Because deliveries are now genuinely retryable, a notification stuck across
an outage would eventually go out hours late — a "starting in 15 minutes"
reminder arriving at 3am misinforms hackers. DISPATCHER_MAX_LATENESS_MINUTES
(default 30) bounds that: past the window the row is failed, not sent, and
surfaces in a new Failed tab in the superadmin notifications table.

Delivery also fans out concurrently now, capped by a semaphore, with each
subscription's outcome recorded per-index so aggregation stays deterministic.
The batch is bounded by a deadline inside the lease so a slow push service
cannot outlive the claim it was dispatched under.

Bookkeeping runs on a context.WithoutCancel context, so a claim is still
resolved when the dispatcher context is already cancelled by shutdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CcXtZJizDrfbeYZ551eZ5
Moving the claim from sent_at to claimed_at left three predicates that had
quietly relied on "a claimed row has sent_at set", plus one bookkeeping gap
in the batch loop.

GenerateFromSchedule deleted schedule-sourced rows WHERE sent_at IS NULL.
That used to skip in-flight rows; now it deleted them mid-delivery, and it
wiped every failed row — the Failed tab's entire content for reminders — on
an unrelated action. Neither could be recreated: both are already past due.
The DELETE now also requires claimed_at IS NULL AND failed_at IS NULL.
Failed rows are history, like sent ones.

Update matched WHERE sent_at IS NULL too, so an operator could edit a row a
dispatcher was delivering. The edit cleared the lease, and MarkSent then
stamped the edited content — new title, future time — as sent with the old
recipient count. Update now refuses a leased row with ErrNotificationInFlight
(wrapping ErrConflict) and the handler returns a distinct 409 so the operator
knows to simply retry.

ClaimDue charges an attempt up front, which is right for a crash but wrong
for rows the batch deadline cut off before they were tried: each deferral
cost an attempt, and after five a notification sat unclaimable in the
Scheduled tab forever, never once attempted and never failed. Deferred rows
are now handed back through ReleaseUnattempted with the attempt refunded.

truncateCause sliced last_error by byte, so a multibyte cause could end in a
partial rune. Postgres rejects that as invalid UTF-8, failing the very write
that resolves the claim. It now cuts on a rune boundary and stays within its
500-byte bound (previously 502).

TestIntegrationGenerateFromSchedulePreservesLeaseState fails on the old
predicate and passes on the new one; the lease integration test covers the
refund and the refused edit; TestDispatchBatch covers the deadline path.
@balebbae
balebbae marked this pull request as ready for review September 11, 2026 21:12
@balebbae
balebbae merged commit 8e995db into main Sep 11, 2026
3 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