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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:27:57 +02:00
1 parent b87ded090c
commit 33e7f20736
2 files changed
+50 -1

No files matched your search

@@ -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<DispatchFn>(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 })],
+23 -1
View File
@@ -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: {