Skip to content

fix: stop duplicate notification emails#237

Merged
Makisuo merged 1 commit into
mainfrom
fix/duplicate-notification-emails
Jul 20, 2026
Merged

fix: stop duplicate notification emails#237
Makisuo merged 1 commit into
mainfrom
fix/duplicate-notification-emails

Conversation

@Makisuo

@Makisuo Makisuo commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

What

Users reported being spammed by the same email over and over. This traces and fixes four compounding causes, verified against production telemetry (internal-org alerting logs via the Maple MCP):

  1. EMAIL binding is now production-only (apps/api/alchemy.run.ts, apps/alerting/alchemy.run.ts). PR-preview and stg workers run the same onboarding/digest/alert crons against branched real data and had a live binding — telemetry showed five PR previews each sending "Welcome to Maple" within 250 ms of each other, so a new user got one copy from prod plus one per live preview. EmailService also gains a runtime guard: sends require MAPLE_ENVIRONMENT=production (escape hatch: MAPLE_EMAIL_ALLOW_NONPROD=true), covering any binding that still reaches a non-prod worker (wrangler dev, manual deploys).
  2. Flap suppression (AlertsService). Confirmed live on 07-16: a rule oscillating around its threshold opened/resolved short-lived incidents all day, emailing identical trigger/resolve pairs every few minutes. Incidents reopening within the renotify interval of the previously notified incident now open silently, inherit its notify anchor (so the renotify gate enforces one email per interval while flapping persists), and resolve silently.
  3. Renotify gate closes at queue time, not delivery success. Previously a failing destination re-queued a fresh renotify chain every minute (the delivery key embeds scheduledAt, so the (deliveryKey, attemptNumber) unique index never collapsed cross-tick re-queues) and the entire backlog fired once the destination recovered. The per-event retry chain (5 attempts, backoff) already covers transient failures.
  4. Email fan-out partial failure (AlertDeliveryDispatch). One member failing used to fail the whole event; the retry re-emailed members who already received it. Partial success now succeeds with the failures annotated in providerMessage; only all-members-failed is retried (first member error preserved verbatim so timeout classification survives). This also flows through NotificationDispatcher, fixing the EscalationService re-page on partially-delivered email destinations.
  5. Error-issue notification idempotency (ErrorsService). Incident open/resolve dispatch immediately (no outbox) and were guarded only by a read-then-act check; overlapping ticks — including stg's crons, which currently share the prod DB — could double-dispatch. Both transitions are now CAS-guarded (onConflictDoUpdate + setWhere + .returning() for open; conditional status='open' update for resolve); only the winner notifies.

Reviewer notes

  • Semantics shift: alert_incidents.lastNotifiedAt now means "last notification queued" until a delivery success overwrites it with delivery time. All other readers are display-only (audited: web collections, incident-timeline MCP tool, v2 serializer).
  • Suppressed flaps are identified at resolve time by lastDeliveredEventType == null && lastNotifiedAt != null — an incident that renotified successfully mid-flap still gets its resolve email.
  • CAS-loss path in ErrorsService deliberately skips occurrence bumps (the winner already counted the same scan window).
  • No schema changes. New tests: flap suppression, renotify-gate-under-failing-deliveries, partial-member email dispatch, overlapping-ticks-dispatch-once.
  • Out of scope (running as a separate task): tearing down orphaned PR-preview workers and the pr-branch issue_escalations grant gap — those are preview-only hygiene; prod was healthy.

Testing

  • bun typecheck — 29/29 clean
  • bun run test — full monorepo green (api 935, web 719, ingest 38)
  • Post-deploy verification: preview envs should disappear from "Email sent successfully" logs; flapping episodes log "Skipping trigger notification for flapping incident" instead of repeated deliveries; at most one attempt-1 renotify event per interval per incident.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Open in Devin Review

Users reported receiving the same email repeatedly. Production telemetry
traced it to four compounding causes, fixed here:

- Gate the EMAIL binding to the prd stage only (api + alerting alchemy
  configs): PR-preview and stg workers ran the same onboarding/digest/
  alert crons against branched real data with a live binding, so every
  live stage sent its own copy of each email to real users. EmailService
  also gains a runtime guard (MAPLE_ENVIRONMENT must be "production",
  override via MAPLE_EMAIL_ALLOW_NONPROD) for bindings that reach
  non-prod workers anyway.
- Flap suppression (AlertsService): incidents reopening within the
  renotify interval of the previously notified incident open silently
  and inherit its notify anchor; their resolves are silent too. A rule
  oscillating around its threshold no longer emails an identical
  trigger/resolve pair every few minutes.
- Renotify gate closes at queue time, not delivery success: a failing
  destination used to re-queue a fresh renotify chain every minute
  (deliveryKey embeds scheduledAt, so the unique index never collapsed
  them) and the whole backlog fired on recovery.
- Email fan-out counts partial member success as success (failed members
  are reported, not retried): retrying the event re-emailed members who
  already received it.
- Error-issue incident open/resolve transitions are CAS-guarded
  (setWhere + returning), so overlapping error ticks can no longer
  double-dispatch the immediate-path notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@Makisuo
Makisuo merged commit e4a859c into main Jul 20, 2026
6 of 8 checks passed
@Makisuo
Makisuo deleted the fix/duplicate-notification-emails branch July 20, 2026 11:46

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

Makisuo added a commit that referenced this pull request Jul 20, 2026
…gate non-prod alerting crons (#239)

Follow-up to the Jul 20 error sweep (#235/#237). Those fixes verifiably
worked (prod errors ~110k/day -> ~220/10h); this resolves the steady error
sources that remained:

- query-engine: classify Tinybird's "invalid authentication token.
  Workspace not found" as WarehouseAuthError (it previously fell through
  to the generic WarehouseQueryError and looked retryable).
- alerting ticks: new warehouse-org-quarantine (edge-cache bucket, 6h TTL)
  parks an org whose warehouse fails with an auth/config-class error and
  skips it in the errors/anomaly/digest ticks — three prod orgs with dead
  Tinybird workspace tokens were failing every 1-5min tick forever
  (~75 errors/10h prod, ~3k/10h across stages, plus secondary
  *PersistenceError cascades). TTL expiry retries automatically once the
  org's config is repaired. DigestService gains an EdgeCacheService dep.
- oauth: new oauth_connections.revoked_at (migration 0016), stamped in the
  shared refresh path and at the Cloudflare analytics poller's revoked
  sites (a mid-poll 401 never reaches the refresh path). pollAllOrgs
  filters revoked connections, so an org whose grant was revoked stops
  re-erroring every 5 minutes; reconnect (upsertConnection) and
  resetOrgState clear the stamp.
- alerting worker: skip all crons on non-prod stages unless
  MAPLE_ALERTING_ALLOW_NONPROD=1 — stg shares the prod DB, so its crons
  iterated real orgs with stage-local Tinybird/Clerk credentials
  (~10k errors/10h; same class of leak as the #237 email gating).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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