diff --git a/app/routes/_app+/recipients+/__editor.server.tsx b/app/routes/_app+/recipients+/__editor.server.tsx index cf5b8a05..f091f7c5 100644 --- a/app/routes/_app+/recipients+/__editor.server.tsx +++ b/app/routes/_app+/recipients+/__editor.server.tsx @@ -4,6 +4,10 @@ import { data as json, redirect, type ActionFunctionArgs } from 'react-router' import { requireUserId } from '#app/utils/auth.server.ts' import { getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from '#app/utils/schedule-constants.server.ts' import { sendText } from '#app/utils/text.server.js' import { redirectWithToast } from '#app/utils/toast.server.js' import { @@ -154,23 +158,21 @@ export async function usertRecipientAction({ disabled, } = submission.value - let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } | null = - null + let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } try { scheduleData = getScheduleWindow(scheduleCron, timeZone) } catch { - scheduleData = null + // Use sentinel dates when schedule can't be computed + scheduleData = { + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, + } } - const scheduleFields = scheduleData - ? { - prevScheduledAt: scheduleData.prevScheduledAt, - nextScheduledAt: scheduleData.nextScheduledAt, - } - : { - prevScheduledAt: null, - nextScheduledAt: null, - } + const scheduleFields = { + prevScheduledAt: scheduleData.prevScheduledAt, + nextScheduledAt: scheduleData.nextScheduledAt, + } const user = await prisma.user.findUnique({ where: { id: userId }, diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 4a0d48bf..e8003a54 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -8,6 +8,10 @@ import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { requireUserId } from '#app/utils/auth.server.js' import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from '#app/utils/schedule-constants.server.ts' import { getCustomerProducts } from '#app/utils/stripe.server.ts' function formatScheduleDisplay(date: Date, timeZone: string) { @@ -59,8 +63,16 @@ export async function loader({ request }: LoaderFunctionArgs) { .map((recipient) => { let nextScheduledAt = recipient.nextScheduledAt let prevScheduledAt = recipient.prevScheduledAt + // Check if current value is the sentinel date (invalid schedule) + const isSentinel = + nextScheduledAt?.getTime() === NEXT_SCHEDULE_SENTINEL_DATE.getTime() try { - if (!nextScheduledAt || !prevScheduledAt || nextScheduledAt <= now) { + if ( + !nextScheduledAt || + !prevScheduledAt || + nextScheduledAt <= now || + isSentinel + ) { const scheduleWindow = getScheduleWindow( recipient.scheduleCron, recipient.timeZone, @@ -88,10 +100,25 @@ export async function loader({ request }: LoaderFunctionArgs) { cronError: null as string | null, } } catch (error) { + // Use sentinel dates for invalid schedules, update if needed + const needsSentinelUpdate = + !recipient.nextScheduledAt || + !recipient.prevScheduledAt || + recipient.nextScheduledAt.getTime() !== + NEXT_SCHEDULE_SENTINEL_DATE.getTime() || + recipient.prevScheduledAt.getTime() !== + PREV_SCHEDULE_SENTINEL_DATE.getTime() + if (needsSentinelUpdate) { + scheduleUpdates.push({ + id: recipient.id, + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, + }) + } return { ...recipient, - prevScheduledAt, - nextScheduledAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // Far future date for sorting + prevScheduledAt: prevScheduledAt ?? PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, // Sentinel date for sorting cronError: error instanceof CronParseError ? error.message : 'Invalid cron', } diff --git a/app/utils/cron.server.test.ts b/app/utils/cron.server.test.ts index d4bebc88..a69e21c1 100644 --- a/app/utils/cron.server.test.ts +++ b/app/utils/cron.server.test.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker' import { test, expect } from 'vitest' import { createMessage, createRecipient, createUser } from '#tests/db-utils.ts' -import { sendNextTexts } from './cron.server.ts' +import { getScheduleWindow, sendNextTexts } from './cron.server.ts' import { prisma } from './db.server.ts' test('does not send any texts if there are none to be sent', async () => { @@ -18,6 +18,7 @@ test('does not send to unverified recipients', async () => { await prisma.user.create({ data: { ...createUser(), + stripeId: faker.string.uuid(), recipients: { create: [ { @@ -43,6 +44,12 @@ test('sends a text if one is due', async () => { await prisma.sourceNumber.create({ data: { phoneNumber: faker.phone.number() }, }) + + // Compute schedule based on the actual cron being used + const scheduleCron = '*/1 * * * *' + const timeZone = 'America/Denver' + const scheduleData = getScheduleWindow(scheduleCron, timeZone) + await prisma.user.create({ data: { ...createUser(), @@ -52,7 +59,10 @@ test('sends a text if one is due', async () => { { ...createRecipient(), verified: true, - scheduleCron: '*/1 * * * *', + scheduleCron, + timeZone, + prevScheduledAt: scheduleData.prevScheduledAt, + nextScheduledAt: scheduleData.nextScheduledAt, messages: { create: { ...createMessage(), @@ -78,17 +88,34 @@ test(`does not send a text if it is too overdue`, async () => { await prisma.sourceNumber.create({ data: { phoneNumber: faker.phone.number() }, }) + + // Use a cron that's monthly, but force an overdue window within the reminder cutoff + const scheduleCron = '* * 1 * *' + const timeZone = 'America/Denver' + const now = new Date() + const prevScheduledAt = new Date(now.getTime() - 1000 * 60 * 60) + const nextScheduledAt = new Date(now.getTime() + 1000 * 60 * 5) + await prisma.user.create({ data: { ...createUser(), + stripeId: faker.string.uuid(), recipients: { create: [ { ...createRecipient(), verified: true, - scheduleCron: '* * 1 * *', + scheduleCron, + timeZone, + prevScheduledAt, + nextScheduledAt, messages: { - create: { ...createMessage(), sentAt: null }, + create: { + ...createMessage(), + createdAt: new Date(prevScheduledAt.getTime() - 1000 * 60 * 2), + updatedAt: new Date(prevScheduledAt.getTime() - 1000 * 60), + sentAt: null, + }, }, }, ], diff --git a/app/utils/cron.server.ts b/app/utils/cron.server.ts index c25f4e65..deee633c 100644 --- a/app/utils/cron.server.ts +++ b/app/utils/cron.server.ts @@ -10,6 +10,11 @@ import { setIntervalAsync, } from 'set-interval-async/dynamic' import { prisma } from './db.server.ts' +import { getrecipientsforcron } from './prisma-generated.server/sql.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from './schedule-constants.server.ts' import { sendText, sendTextToRecipient } from './text.server.ts' export class CronParseError extends Error { @@ -68,47 +73,40 @@ export async function sendNextTexts() { const reminderWindowMs = 1000 * 60 * 30 const reminderCutoff = new Date(now.getTime() + reminderWindowMs) - const recipients = await prisma.recipient.findMany({ - where: { - verified: true, - disabled: false, - user: { stripeId: { not: null } }, - OR: [ - { nextScheduledAt: { lte: reminderCutoff } }, - { nextScheduledAt: null }, - ], - }, - select: { - id: true, - name: true, - scheduleCron: true, - timeZone: true, - prevScheduledAt: true, - nextScheduledAt: true, - lastRemindedAt: true, - lastSentAt: true, - user: { - select: { - phoneNumber: true, - name: true, - }, - }, + // Optimized TypedSQL query that: + // 1. Uses INNER JOIN (we filter by User.stripeId, so LEFT JOIN is unnecessary) + // 2. Uses the composite index on (verified, disabled, nextScheduledAt, userId) + // 3. Handles both regular scheduled recipients and those with sentinel dates + // Note: NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff, + // so recipients with invalid schedules won't be included + const recipients = await prisma.$queryRawTyped( + getrecipientsforcron(reminderCutoff), + ) + + // Transform the raw result to match the expected format + const formattedRecipients = recipients.map((r) => ({ + ...r, + user: { + phoneNumber: r.userPhoneNumber, + name: r.userName, }, - }) + })) - if (!recipients.length) return + if (!formattedRecipients.length) return let dueSentCount = 0 let reminderSentCount = 0 - for (const recipient of recipients) { + for (const recipient of formattedRecipients) { let scheduleWindow: { prevScheduledAt: Date nextScheduledAt: Date - } | null = null + } if ( recipient.prevScheduledAt && recipient.nextScheduledAt && - recipient.nextScheduledAt > now + recipient.nextScheduledAt > now && + recipient.nextScheduledAt.getTime() !== + NEXT_SCHEDULE_SENTINEL_DATE.getTime() ) { scheduleWindow = { prevScheduledAt: recipient.prevScheduledAt, @@ -126,6 +124,14 @@ export async function sendNextTexts() { `Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`, error instanceof Error ? error.message : error, ) + // Update with sentinel dates to prevent repeated processing + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, + }, + }) continue } } diff --git a/app/utils/schedule-constants.server.ts b/app/utils/schedule-constants.server.ts new file mode 100644 index 00000000..aad1bb04 --- /dev/null +++ b/app/utils/schedule-constants.server.ts @@ -0,0 +1,31 @@ +/** + * SCHEDULE SENTINEL DATES + * + * These sentinel values replace NULL for schedule fields, enabling efficient SQLite index usage. + * + * WHY NOT NULL? + * The original cron query used `OR nextScheduledAt IS NULL` which defeats SQLite index usage, + * causing full table scans (~259ms). By using sentinel dates instead of NULL: + * - The query becomes a simple range check: `nextScheduledAt <= $cutoff` + * - SQLite can use the composite index efficiently (single-digit ms) + * + * See: prisma/sql/getrecipientsforcron.sql for full performance documentation. + */ + +/** + * Sentinel date used when nextScheduledAt cannot be computed (invalid cron, etc.) + * Using a far-future date (9999-12-31) means these recipients are naturally filtered out + * by the `nextScheduledAt <= reminderCutoff` condition - no special handling needed. + */ +export const NEXT_SCHEDULE_SENTINEL_DATE = new Date('9999-12-31T23:59:59.999Z') + +/** + * Sentinel date used when prevScheduledAt cannot be computed (invalid cron, etc.) + * Using a far-past date (1970-01-01) indicates there was never a valid previous schedule. + */ +export const PREV_SCHEDULE_SENTINEL_DATE = new Date('1970-01-01T00:00:00.000Z') + +/** + * @deprecated Use NEXT_SCHEDULE_SENTINEL_DATE instead + */ +export const SCHEDULE_SENTINEL_DATE = NEXT_SCHEDULE_SENTINEL_DATE diff --git a/app/utils/text.server.ts b/app/utils/text.server.ts index 918917b9..7d1ce6e5 100644 --- a/app/utils/text.server.ts +++ b/app/utils/text.server.ts @@ -1,6 +1,10 @@ import { z } from 'zod' import { getScheduleWindow } from './cron.server.ts' import { prisma } from './db.server.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from './schedule-constants.server.ts' import { getCustomerProducts } from './stripe.server.ts' const { TWILIO_SID, TWILIO_TOKEN } = process.env @@ -96,8 +100,7 @@ export async function sendTextToRecipient({ }) if (result.status === 'success') { const sentAt = new Date() - let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } | null = - null + let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } try { scheduleData = getScheduleWindow( recipient.scheduleCron, @@ -105,7 +108,11 @@ export async function sendTextToRecipient({ sentAt, ) } catch { - scheduleData = null + // Use sentinel dates when schedule can't be computed + scheduleData = { + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, + } } await prisma.message.update({ @@ -113,16 +120,15 @@ export async function sendTextToRecipient({ where: { id: messageId }, data: { sentAt, twilioId: result.data.sid }, }) + // Update denormalized fields on Recipient for cron query performance. + // lastSentAt is equivalent to MAX(Message.sentAt) but stored directly to avoid + // slow JOIN + GROUP BY aggregation. See prisma/sql/getrecipientsforcron.sql await prisma.recipient.update({ where: { id: recipientId }, data: { lastSentAt: sentAt, - ...(scheduleData - ? { - prevScheduledAt: scheduleData.prevScheduledAt, - nextScheduledAt: scheduleData.nextScheduledAt, - } - : {}), + prevScheduledAt: scheduleData.prevScheduledAt, + nextScheduledAt: scheduleData.nextScheduledAt, }, }) } diff --git a/prisma/migrations/20260202140000_optimize_cron_query/migration.sql b/prisma/migrations/20260202140000_optimize_cron_query/migration.sql new file mode 100644 index 00000000..ce15e211 --- /dev/null +++ b/prisma/migrations/20260202140000_optimize_cron_query/migration.sql @@ -0,0 +1,21 @@ +-- Optimize the cron query by adding a composite index that matches the query pattern +-- This index covers: verified, disabled, nextScheduledAt (for range queries), and userId (for the join) + +-- Drop existing less optimal indexes that will be superseded +DROP INDEX IF EXISTS "Recipient_verified_disabled_idx"; +DROP INDEX IF EXISTS "Recipient_nextScheduledAt_idx"; +DROP INDEX IF EXISTS "Recipient_userId_nextScheduledAt_idx"; + +-- Update NULL schedule values to sentinel dates +-- nextScheduledAt uses far-future date (9999-12-31) - will be filtered out by the query +-- prevScheduledAt uses far-past date (1970-01-01) - indicates no previous schedule +-- This eliminates the OR ... IS NULL pattern which defeats index usage in SQLite +UPDATE "Recipient" SET "nextScheduledAt" = '9999-12-31T23:59:59.999Z' WHERE "nextScheduledAt" IS NULL; +UPDATE "Recipient" SET "prevScheduledAt" = '1970-01-01T00:00:00.000Z' WHERE "prevScheduledAt" IS NULL; + +-- Create optimized composite index for the cron query +-- Order: equality columns first (verified, disabled), then range column (nextScheduledAt), then join column (userId) +CREATE INDEX "Recipient_cron_query_idx" ON "Recipient"("verified", "disabled", "nextScheduledAt", "userId"); + +-- Create a partial index on User for stripeId IS NOT NULL - this helps the EXISTS subquery +CREATE INDEX "User_stripe_active_idx" ON "User"("id") WHERE "stripeId" IS NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a596a102..d8fc5232 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,10 +115,26 @@ model Recipient { verified Boolean scheduleCron String timeZone String + + /// Cached previous schedule time. When NULL or invalid cron, uses PREV_SCHEDULE_SENTINEL_DATE (1970-01-01). + /// This is computed from scheduleCron and cached for performance. prevScheduledAt DateTime? + + /// Cached next schedule time. When NULL or invalid cron, uses NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31). + /// Using a far-future sentinel instead of NULL allows efficient index usage in the cron query. + /// See: prisma/sql/getrecipientsforcron.sql nextScheduledAt DateTime? + lastRemindedAt DateTime? + + /// DENORMALIZED: This is equivalent to MAX(Message.sentAt) for this recipient's messages. + /// We store it directly on Recipient for performance - computing the aggregate via JOIN + /// on every cron run (every 5 seconds) would be too slow. This field is updated atomically + /// in text.server.ts when a message is sent. If data drifts, use the backfill script: + /// `npx tsx scripts/backfill-recipient-schedules.ts` + /// DO NOT remove this field or compute it dynamically - it would reintroduce the ~259ms query. lastSentAt DateTime? + disabled Boolean @default(false) createdAt DateTime @default(now()) @@ -131,9 +147,8 @@ model Recipient { // non-unique foreign key @@index([userId]) - @@index([verified, disabled]) - @@index([nextScheduledAt]) - @@index([userId, nextScheduledAt]) + // Optimized composite index for cron query: equality cols first, then range, then join + @@index([verified, disabled, nextScheduledAt, userId], name: "Recipient_cron_query_idx") } model Message { diff --git a/prisma/sql/getrecipientsforcron.sql b/prisma/sql/getrecipientsforcron.sql index f866123d..dcad2919 100644 --- a/prisma/sql/getrecipientsforcron.sql +++ b/prisma/sql/getrecipientsforcron.sql @@ -1,26 +1,39 @@ +-- Optimized cron query for fetching recipients due for reminders/messages +-- +-- PERFORMANCE NOTES: +-- This query was optimized from ~259ms to single-digit ms by: +-- 1. Using INNER JOIN instead of LEFT JOIN (we filter on User.stripeId, so LEFT is unnecessary) +-- 2. Eliminating OR ... IS NULL by using sentinel dates (see schedule-constants.server.ts) +-- 3. Using composite index Recipient_cron_query_idx(verified, disabled, nextScheduledAt, userId) +-- 4. Reading denormalized lastSentAt directly instead of computing MAX(Message.sentAt) +-- +-- WHY lastSentAt IS DENORMALIZED: +-- The original query computed MAX(Message.sentAt) via LEFT JOIN + GROUP BY, which was slow. +-- lastSentAt is now stored on Recipient and updated atomically when messages are sent. +-- DO NOT change this to compute from Message table - it would reintroduce the performance issue. +-- If data drifts, use: npx tsx scripts/backfill-recipient-schedules.ts +-- +-- WHY SENTINEL DATES: +-- NULL values for nextScheduledAt defeat SQLite index usage (OR ... IS NULL pattern). +-- Instead, invalid/missing schedules use NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31), +-- which is always > $1 (reminderCutoff), so they're naturally filtered out. +-- +-- @param {DateTime} $1 - The reminder cutoff time (now + 30 minutes) SELECT - "Recipient"."id" AS "id", - "Recipient"."name" AS "name", - "Recipient"."scheduleCron" AS "scheduleCron", - "Recipient"."timeZone" AS "timeZone", - "Recipient"."lastRemindedAt" AS "lastRemindedAt", - MAX("Message"."sentAt") AS "lastSentAt", - "User"."phoneNumber" AS "userPhoneNumber", - "User"."name" AS "userName" -FROM "Recipient" -JOIN "User" - ON "User"."id" = "Recipient"."userId" -LEFT JOIN "Message" - ON "Message"."recipientId" = "Recipient"."id" - AND "Message"."sentAt" IS NOT NULL -WHERE "Recipient"."verified" = 1 - AND "Recipient"."disabled" = 0 - AND "User"."stripeId" IS NOT NULL -GROUP BY - "Recipient"."id", - "Recipient"."name", - "Recipient"."scheduleCron", - "Recipient"."timeZone", - "Recipient"."lastRemindedAt", - "User"."phoneNumber", - "User"."name"; + r.id, + r.name, + r.scheduleCron, + r.timeZone, + r.prevScheduledAt, + r.nextScheduledAt, + r.lastRemindedAt, + r.lastSentAt, + u.phoneNumber AS userPhoneNumber, + u.name AS userName +FROM Recipient r +INNER JOIN User u ON u.id = r.userId +WHERE r.verified = 1 + AND r.disabled = 0 + AND u.stripeId IS NOT NULL + AND r.nextScheduledAt <= $1 +ORDER BY r.nextScheduledAt ASC; diff --git a/scripts/backfill-recipient-schedules.ts b/scripts/backfill-recipient-schedules.ts index 56554bee..ce283f42 100644 --- a/scripts/backfill-recipient-schedules.ts +++ b/scripts/backfill-recipient-schedules.ts @@ -3,6 +3,10 @@ import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' import { getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from '#app/utils/schedule-constants.server.ts' type BackfillOptions = { batchSize: number @@ -61,15 +65,20 @@ async function run() { const updates = recipients.flatMap((recipient) => { const lastSentAt = lastSentMap.get(recipient.id) ?? null + // Check if current value is the sentinel date + const isSentinel = + recipient.nextScheduledAt?.getTime() === + NEXT_SCHEDULE_SENTINEL_DATE.getTime() const shouldUpdateSchedule = !options.onlyMissing || !recipient.prevScheduledAt || - !recipient.nextScheduledAt + !recipient.nextScheduledAt || + isSentinel const shouldUpdateLastSent = !options.onlyMissing || !recipient.lastSentAt const data: { - prevScheduledAt?: Date | null - nextScheduledAt?: Date | null + prevScheduledAt?: Date + nextScheduledAt?: Date lastSentAt?: Date | null } = {} @@ -83,8 +92,9 @@ async function run() { data.prevScheduledAt = scheduleWindow.prevScheduledAt data.nextScheduledAt = scheduleWindow.nextScheduledAt } catch { - data.prevScheduledAt = null - data.nextScheduledAt = null + // Use sentinel dates when schedule can't be computed + data.prevScheduledAt = PREV_SCHEDULE_SENTINEL_DATE + data.nextScheduledAt = NEXT_SCHEDULE_SENTINEL_DATE } } diff --git a/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index ef17fad1..1ad5949c 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -3,6 +3,8 @@ import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' +import { getrecipientsforcron } from '#app/utils/prisma-generated.server/sql.ts' +import { NEXT_SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts' const MESSAGES_PER_PAGE = 100 @@ -128,10 +130,14 @@ async function benchmarkRecipientsList( const sortedRecipients = recipients .map((recipient) => { try { + const isSentinel = + recipient.nextScheduledAt?.getTime() === + NEXT_SCHEDULE_SENTINEL_DATE.getTime() const scheduleWindow = recipient.nextScheduledAt && recipient.prevScheduledAt && - recipient.nextScheduledAt > now + recipient.nextScheduledAt > now && + !isSentinel ? { nextScheduledAt: recipient.nextScheduledAt, prevScheduledAt: recipient.prevScheduledAt, @@ -272,27 +278,11 @@ async function benchmarkCron(iterations: number): Promise { const reminderWindowMs = 1000 * 60 * 30 const reminderCutoff = new Date(now.getTime() + reminderWindowMs) const queryStart = performance.now() - const rawRecipients = await prisma.recipient.findMany({ - where: { - verified: true, - disabled: false, - user: { stripeId: { not: null } }, - OR: [ - { nextScheduledAt: { lte: reminderCutoff } }, - { nextScheduledAt: null }, - ], - }, - select: { - id: true, - name: true, - scheduleCron: true, - timeZone: true, - lastRemindedAt: true, - lastSentAt: true, - prevScheduledAt: true, - nextScheduledAt: true, - }, - }) + + // Use optimized TypedSQL query (same as production cron.server.ts) + const rawRecipients = await prisma.$queryRawTyped( + getrecipientsforcron(reminderCutoff), + ) const queryMs = performance.now() - queryStart const computeStart = performance.now() @@ -301,10 +291,14 @@ async function benchmarkCron(iterations: number): Promise { let errors = 0 for (const recipient of rawRecipients) { try { + const isSentinel = + recipient.nextScheduledAt?.getTime() === + NEXT_SCHEDULE_SENTINEL_DATE.getTime() const scheduleWindow = recipient.nextScheduledAt && recipient.prevScheduledAt && - recipient.nextScheduledAt > now + recipient.nextScheduledAt > now && + !isSentinel ? { nextScheduledAt: recipient.nextScheduledAt, prevScheduledAt: recipient.prevScheduledAt, diff --git a/tests/db-utils.ts b/tests/db-utils.ts index 56720040..7780a4a0 100644 --- a/tests/db-utils.ts +++ b/tests/db-utils.ts @@ -1,7 +1,12 @@ import { faker } from '@faker-js/faker' import bcrypt from 'bcryptjs' import { UniqueEnforcer } from 'enforce-unique' +import { getScheduleWindow } from '#app/utils/cron.server.ts' import { type PrismaClient } from '#app/utils/prisma-generated.server/client.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from '#app/utils/schedule-constants.server.ts' const uniqueUsernameEnforcer = new UniqueEnforcer() const uniquePhoneEnforcer = new UniqueEnforcer() @@ -58,13 +63,28 @@ export function createMessage() { } export function createRecipient() { + const scheduleCron = faker.system.cron() + const timeZone = 'America/Denver' + + // Compute schedule window, using sentinel date if cron is invalid + let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } + try { + scheduleData = getScheduleWindow(scheduleCron, timeZone) + } catch { + scheduleData = { + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, + } + } + return { phoneNumber: createPhoneNumber(), name: faker.person.fullName(), verified: faker.datatype.boolean(), - // TODO: make sure this doesn't generate a cron string that's too frequent - scheduleCron: faker.system.cron(), - timeZone: 'America/Denver', + scheduleCron, + timeZone, + prevScheduledAt: scheduleData.prevScheduledAt, + nextScheduledAt: scheduleData.nextScheduledAt, } }