From 33e7f2073652236159158ddc09532889f8b51e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:27:57 +0200 Subject: [PATCH] perf(reminders): bound the Vorsorge cron scan to due reminders The measurement-reminder tick loaded every enabled reminder across all tenants every 15 minutes and gated due-ness in Node, so the shipped measurement_reminders_user_id_next_due_at_idx went unused. Add a nextDueAt bound (not null, lte now plus one tick of slack) to the query so Postgres scans only rows that could plausibly fire. nextDueAt is stamped at the notify-hour boundary, so anything due is already past; non-recurring reminders carry a null nextDueAt and can never fire, which the in-Node predicate already short-circuits. A reminder satisfied early re-anchors once it crosses due, before any nudge, so dropping future rows is safe. The in-Node hour gate stays the authoritative fire decision. --- .../__tests__/measurement-reminder.test.ts | 27 +++++++++++++++++++ src/lib/jobs/measurement-reminder.ts | 24 ++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 5c6ad36d0..d64cd34aa 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -268,6 +268,33 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); }); + it("bounds the scan with a nextDueAt filter so the index is used", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + const calls = prisma.measurementReminder.findMany.mock + .calls as unknown as unknown[][]; + const args = calls[0][0] as { + where: { + deletedAt: null; + enabled: boolean; + nextDueAt: { not: null; lte: Date }; + }; + }; + expect(args.where.enabled).toBe(true); + expect(args.where.deletedAt).toBeNull(); + expect(args.where.nextDueAt.not).toBeNull(); + // Floor is now + one tick of slack; anything due (<= floor) is included. + expect(args.where.nextDueAt.lte.getTime()).toBe( + NINE_LOCAL.getTime() + 15 * 60_000, + ); + }); + it("skips a reminder outside its notify-hour window", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 7223d76ec..2afa31687 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -41,6 +41,14 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; +/** + * Slack added to `now` when scanning for due reminders, so a `nextDueAt` + * stamped just ahead of a tick still falls inside the same hour window. + * One tick interval (15 min) — small enough that the in-Node hour gate stays + * the authoritative fire decision. + */ +const DUE_QUERY_SLACK_MS = 15 * 60_000; + export interface MeasurementReminderSummary { candidatesScanned: number; inWindow: number; @@ -141,8 +149,22 @@ export async function runMeasurementReminderTick( failed: 0, }; + // Bound the scan to reminders that could plausibly fire this tick so Postgres + // uses `measurement_reminders_user_id_next_due_at_idx` instead of loading the + // whole cross-tenant enabled set 4×/hour. `nextDueAt` is stamped at the + // notify-hour boundary, so anything due is already <= now; the small slack + // (one tick interval) covers a stamp landing just ahead of a :00 tick. A + // null `nextDueAt` is a non-recurring reminder that can never fire — the + // in-Node `evaluateMeasurementReminderDue` short-circuits it anyway, so + // excluding it here is parity. Reminders satisfied early re-anchor once they + // cross due, before any nudge fires, so dropping future rows is safe. + const dueFloor = new Date(now.getTime() + DUE_QUERY_SLACK_MS); const reminders = await prisma.measurementReminder.findMany({ - where: { deletedAt: null, enabled: true }, + where: { + deletedAt: null, + enabled: true, + nextDueAt: { not: null, lte: dueFloor }, + }, include: { user: { select: {