Skip to content

fix(cron): make event reminders idempotent with per-RSVP reminderSentAt#249

Merged
thpr merged 2 commits into
mainfrom
claude/adoring-mclaren-66e417
Jun 14, 2026
Merged

fix(cron): make event reminders idempotent with per-RSVP reminderSentAt#249
thpr merged 2 commits into
mainfrom
claude/adoring-mclaren-66e417

Conversation

@thpr

@thpr thpr commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

What

GET /api/cron/reminders selected events in a [now+2h, now+2h15m] window with no per-RSVP dedup. On a 15-minute-interval cron, an event sitting near a window boundary matched on two consecutive runs → EVENT_STARTING_SOON double-sent to the same ATTENDING RSVPs.

Fix

  • Schema: add nullable reminderSentAt DateTime? to RSVP (@sensitivity:internal) + migration 20260611000000_add_rsvp_reminder_sent_at.
  • Route: query now filters reminderSentAt: null; the timestamp is stamped only after sendNotification resolves — a send failure leaves the RSVP eligible next run instead of silently dropping the reminder.
  • Test: new cronRemindersEdge.integration.test.ts — boundary event (start +2h5m) run through GET twice asserts run 1 notifies once, run 2 is a no-op, RSVP notified exactly once across both runs.

Reviewer notes

  • The characterization test referenced in the original task did not exist in the tree, history, or any branch — built the end-state idempotency test directly instead.
  • ⚠️ Deploy: prod must run prisma migrate deploy before the cron picks up the new reminderSentAt: null filter (column must exist).
  • Verified locally: throwaway Postgres + prisma db push, full integration suite 327 passed / 54 suites (both cron suites green). Pre-commit hook was bypassed with --no-verify (user-authorized) — it false-fails in worktrees per the documented testPathIgnorePatterns rootDir issue, not a real test failure.

🤖 Generated with Claude Code

@dkaygithub dkaygithub left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clean, well-scoped idempotency fix — the reminderSentAt: null filter + stamp-after-send is the right shape, and the failure handling is actually a strength: any send/stamp rejection makes Promise.all reject → 500 → cron retry, and the already-stamped RSVPs are excluded on the retry, so the stamp is what makes retries safe rather than duplicative. Verified the schema dependency (@@id([eventId, participantId]) makes the eventId_participantId update key valid) and that classifications.ts is generator output matching schema order. Three non-blocking notes below.

participantId: rsvp.participantId
}
},
data: { reminderSentAt: new Date() }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Worth naming the guarantee explicitly: this is at-least-once with best-effort dedup, not exactly-once. The one residual double-send window is send-succeeds-then-stamp-fails — if sendNotification resolves but this rSVP.update throws (DB hiccup), the notification went out but reminderSentAt stays null, so the next run re-sends. That's fundamental (you can't transactionally bind an external send to a DB write) and a huge improvement over the old every-overlapping-run behavior, so I'd accept it — just good to acknowledge it's not airtight. Same applies if two cron executions ever overlap (both read null before either stamps); fine as long as the platform doesn't run the job concurrently with itself.

where: { status: 'ATTENDING' }
// Only RSVPs that haven't been reminded yet — the window can overlap
// across runs, so reminderSentAt is what makes this idempotent.
where: { status: 'ATTENDING', reminderSentAt: null }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

reminderSentAt is never cleared, so the dedup key is per-RSVP for all time, not per-scheduled-time. Two consequences worth a conscious decision: (1) if an event is rescheduled later, already-reminded attendees won't get a fresh 2h reminder for the new time; (2) an ATTENDING → NOT_ATTENDING → ATTENDING toggle won't re-remind. Both are probably acceptable for this feature, but flagging so it's intentional rather than incidental.

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RSVP" ADD COLUMN "reminderSentAt" TIMESTAMP(3);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deploy ordering (you flagged this in the PR body — confirming the state): dev is covered, deploy-dev.yml runs prisma migrate deploy (with retries) before the app comes up, and CI runs migrate deploy + migrate diff --exit-code so a mismatched migration is caught. Prod has no deploy workflow yet — that's #218 (still open), so whoever lands prod deploy needs to ensure migrate deploy runs before the new code, otherwise the reminderSentAt: null filter hits a missing column and the cron 500s. Not blocking this PR, just a cross-PR dependency to track.

@dkaygithub dkaygithub self-requested a review June 12, 2026 15:43
thpr pushed a commit that referenced this pull request Jun 12, 2026
…ery guarantee

Addresses non-blocking review notes on #249.

- editTime now resets RSVP.reminderSentAt to null when an event's START
  moves (single event and applyToFuture series), so rescheduled events
  re-qualify for a fresh 2h reminder. End-only edits and no-op start
  edits leave reminder state untouched. Cancel paths already deleteMany
  the RSVPs, so no reminder leak there.
- Document the cron reminder delivery guarantee as at-least-once with
  best-effort dedup (not exactly-once): the residual double-send window
  is send-succeeds-then-stamp-fails, and the route assumes the platform
  never runs the job concurrently with itself.
- New eventRescheduleClearsReminder.integration.test.ts: start-shift
  clears + cron re-notifies once; end-only and same-start edits do not
  clear; series shift clears across the group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tom Parker and others added 2 commits June 13, 2026 18:34
GET /api/cron/reminders selected events in a [now+2h, now+2h15m] window
with no dedup. On a 15m-interval cron, an event near a window boundary
matched on two consecutive runs, double-sending EVENT_STARTING_SOON to
the same ATTENDING RSVPs.

Add a nullable reminderSentAt timestamp to RSVP. The query now excludes
RSVPs that already have it set, and the route stamps it only after
sendNotification resolves — so a send failure leaves the RSVP eligible
on the next run rather than silently dropping the reminder.

Add cronRemindersEdge.integration.test.ts: a boundary event (start +2h5m)
run through GET twice asserts the first run notifies once, the second is
a no-op, and the RSVP is notified exactly once across both runs.

Note: prod must run `prisma migrate deploy` before the cron picks up the
new reminderSentAt: null filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ery guarantee

Addresses non-blocking review notes on #249.

- editTime now resets RSVP.reminderSentAt to null when an event's START
  moves (single event and applyToFuture series), so rescheduled events
  re-qualify for a fresh 2h reminder. End-only edits and no-op start
  edits leave reminder state untouched. Cancel paths already deleteMany
  the RSVPs, so no reminder leak there.
- Document the cron reminder delivery guarantee as at-least-once with
  best-effort dedup (not exactly-once): the residual double-send window
  is send-succeeds-then-stamp-fails, and the route assumes the platform
  never runs the job concurrently with itself.
- New eventRescheduleClearsReminder.integration.test.ts: start-shift
  clears + cron re-notifies once; end-only and same-start edits do not
  clear; series shift clears across the group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thpr thpr force-pushed the claude/adoring-mclaren-66e417 branch from e6730db to 10bd3fc Compare June 13, 2026 23:35
@thpr thpr added this pull request to the merge queue Jun 14, 2026
Merged via the queue into main with commit eb03509 Jun 14, 2026
3 checks passed
@github-actions

Copy link
Copy Markdown

✅ Deployed to dev

Commit eb03509 is live on https://ops-dev.innovationtreehouse.org.

  • Image: ghcr.io/innovationtreehouse/checkin-dev:eb03509
  • Migrations: applied · Service: stable
  • Deploy run

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.

2 participants