From 50f1bc550108335ca6b5b7e7cb8fd7c1e754cba1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 14:14:31 +0000 Subject: [PATCH 1/9] Add schedule sentinel date constant and migration for optimized cron query - Add SCHEDULE_SENTINEL_DATE constant (9999-12-31) to replace NULL nextScheduledAt - Create migration to: - Drop redundant indexes - Update existing NULL values to sentinel date - Create optimized composite index for cron query - Create partial index on User for stripeId IS NOT NULL Co-authored-by: me --- app/utils/schedule-constants.server.ts | 22 +++++++++++++++++++ .../migration.sql | 19 ++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 app/utils/schedule-constants.server.ts create mode 100644 prisma/migrations/20260202140000_optimize_cron_query/migration.sql diff --git a/app/utils/schedule-constants.server.ts b/app/utils/schedule-constants.server.ts new file mode 100644 index 00000000..6e7bccdd --- /dev/null +++ b/app/utils/schedule-constants.server.ts @@ -0,0 +1,22 @@ +/** + * Sentinel date used when nextScheduledAt cannot be computed (invalid cron, etc.) + * Using a far-future date instead of NULL allows SQLite to use indexes efficiently + * and eliminates the OR ... IS NULL pattern that defeats index usage. + */ +export const SCHEDULE_SENTINEL_DATE = new Date('9999-12-31T23:59:59.999Z') + +/** + * Check if a date is the sentinel value (meaning no valid schedule) + */ +export function isScheduleSentinel(date: Date | null): boolean { + if (!date) return false + return date.getTime() === SCHEDULE_SENTINEL_DATE.getTime() +} + +/** + * Get the display value for a schedule date, returning null if it's the sentinel + */ +export function getScheduleDisplayDate(date: Date | null): Date | null { + if (!date || isScheduleSentinel(date)) return null + return date +} 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..2d5dc334 --- /dev/null +++ b/prisma/migrations/20260202140000_optimize_cron_query/migration.sql @@ -0,0 +1,19 @@ +-- 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 nextScheduledAt values to sentinel date (9999-12-31) +-- 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" = '9999-12-31T23:59:59.999Z' 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; From 8604ae3e4489736f6533856f983314584a84e6ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 14:14:39 +0000 Subject: [PATCH 2/9] Optimize cron query with raw SQL and sentinel date pattern - Replace Prisma findMany with optimized raw SQL using INNER JOIN - Remove OR ... IS NULL condition that defeated index usage - Add sentinel date check to schedule window validation - Update SQL file with optimized query for TypedSQL - Update schema to reflect new composite index Co-authored-by: me --- app/utils/cron.server.ts | 94 ++++++++++++++++++++--------- prisma/schema.prisma | 5 +- prisma/sql/getrecipientsforcron.sql | 46 +++++++------- 3 files changed, 87 insertions(+), 58 deletions(-) diff --git a/app/utils/cron.server.ts b/app/utils/cron.server.ts index c25f4e65..afb6287e 100644 --- a/app/utils/cron.server.ts +++ b/app/utils/cron.server.ts @@ -10,6 +10,7 @@ import { setIntervalAsync, } from 'set-interval-async/dynamic' import { prisma } from './db.server.ts' +import { SCHEDULE_SENTINEL_DATE } from './schedule-constants.server.ts' import { sendText, sendTextToRecipient } from './text.server.ts' export class CronParseError extends Error { @@ -63,52 +64,77 @@ export async function init() { ) } +/** + * Type for recipients returned from the optimized cron query + */ +type CronRecipient = { + id: string + name: string + scheduleCron: string + timeZone: string + prevScheduledAt: Date | null + nextScheduledAt: Date | null + lastRemindedAt: Date | null + lastSentAt: Date | null + userPhoneNumber: string + userName: string | null +} + export async function sendNextTexts() { const now = new Date() 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 raw SQL query that: + // 1. Uses EXISTS instead of JOIN (we only filter by User, don't select from it) + // 2. Uses the composite index on (verified, disabled, nextScheduledAt, userId) + // 3. Handles both regular scheduled recipients and those with sentinel dates + // Note: SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff, + // so recipients with invalid schedules won't be included unless we explicitly fetch them + const recipients = await prisma.$queryRaw` + SELECT + 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 <= ${reminderCutoff} + ORDER BY r.nextScheduledAt ASC + ` + + // 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() !== SCHEDULE_SENTINEL_DATE.getTime() ) { scheduleWindow = { prevScheduledAt: recipient.prevScheduledAt, @@ -126,6 +152,14 @@ export async function sendNextTexts() { `Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`, error instanceof Error ? error.message : error, ) + // Update with sentinel to prevent repeated processing + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { + prevScheduledAt: SCHEDULE_SENTINEL_DATE, + nextScheduledAt: SCHEDULE_SENTINEL_DATE, + }, + }) continue } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a596a102..cdcab30a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -131,9 +131,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..95cae71d 100644 --- a/prisma/sql/getrecipientsforcron.sql +++ b/prisma/sql/getrecipientsforcron.sql @@ -1,26 +1,22 @@ +-- Optimized cron query for fetching recipients due for reminders/messages +-- Uses INNER JOIN instead of LEFT JOIN, and filters by nextScheduledAt range +-- The composite index Recipient_cron_query_idx covers this query pattern +-- @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; From 904480cde0836a8706833d5faa3d816d8eb1df1c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 14:14:46 +0000 Subject: [PATCH 3/9] Use sentinel date instead of NULL for invalid schedules - Update text.server.ts to set sentinel date when schedule can't be computed - Update __editor.server.tsx to use sentinel date for invalid cron expressions - Update _layout.tsx to handle sentinel dates in schedule display Co-authored-by: me --- .../_app+/recipients+/__editor.server.tsx | 23 ++++++++-------- app/routes/_app+/recipients+/_layout.tsx | 26 ++++++++++++++++--- app/utils/text.server.ts | 18 ++++++------- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/app/routes/_app+/recipients+/__editor.server.tsx b/app/routes/_app+/recipients+/__editor.server.tsx index cf5b8a05..04257675 100644 --- a/app/routes/_app+/recipients+/__editor.server.tsx +++ b/app/routes/_app+/recipients+/__editor.server.tsx @@ -4,6 +4,7 @@ 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 { 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 +155,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 date when schedule can't be computed + scheduleData = { + prevScheduledAt: SCHEDULE_SENTINEL_DATE, + nextScheduledAt: 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..ff54670a 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -8,6 +8,7 @@ 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 { 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 +60,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() === 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 +97,21 @@ export async function loader({ request }: LoaderFunctionArgs) { cronError: null as string | null, } } catch (error) { + // Use sentinel date for invalid schedules, update if needed + const needsSentinelUpdate = + !recipient.nextScheduledAt || + recipient.nextScheduledAt.getTime() !== SCHEDULE_SENTINEL_DATE.getTime() + if (needsSentinelUpdate) { + scheduleUpdates.push({ + id: recipient.id, + prevScheduledAt: SCHEDULE_SENTINEL_DATE, + nextScheduledAt: SCHEDULE_SENTINEL_DATE, + }) + } return { ...recipient, - prevScheduledAt, - nextScheduledAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // Far future date for sorting + prevScheduledAt: prevScheduledAt ?? SCHEDULE_SENTINEL_DATE, + nextScheduledAt: SCHEDULE_SENTINEL_DATE, // Sentinel date for sorting cronError: error instanceof CronParseError ? error.message : 'Invalid cron', } diff --git a/app/utils/text.server.ts b/app/utils/text.server.ts index 918917b9..79513458 100644 --- a/app/utils/text.server.ts +++ b/app/utils/text.server.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { getScheduleWindow } from './cron.server.ts' import { prisma } from './db.server.ts' +import { SCHEDULE_SENTINEL_DATE } from './schedule-constants.server.ts' import { getCustomerProducts } from './stripe.server.ts' const { TWILIO_SID, TWILIO_TOKEN } = process.env @@ -96,8 +97,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 +105,11 @@ export async function sendTextToRecipient({ sentAt, ) } catch { - scheduleData = null + // Use sentinel date when schedule can't be computed + scheduleData = { + prevScheduledAt: SCHEDULE_SENTINEL_DATE, + nextScheduledAt: SCHEDULE_SENTINEL_DATE, + } } await prisma.message.update({ @@ -117,12 +121,8 @@ export async function sendTextToRecipient({ where: { id: recipientId }, data: { lastSentAt: sentAt, - ...(scheduleData - ? { - prevScheduledAt: scheduleData.prevScheduledAt, - nextScheduledAt: scheduleData.nextScheduledAt, - } - : {}), + prevScheduledAt: scheduleData.prevScheduledAt, + nextScheduledAt: scheduleData.nextScheduledAt, }, }) } From ab4865614d3d7388eef4921272608385b5bf89b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 14:14:51 +0000 Subject: [PATCH 4/9] Update scripts to use sentinel date for invalid schedules - Update backfill script to set sentinel date instead of NULL - Update benchmark script to use optimized raw SQL query - Both scripts now handle sentinel dates correctly Co-authored-by: me --- scripts/backfill-recipient-schedules.ts | 17 +++++-- scripts/benchmark-performance.ts | 65 ++++++++++++++++--------- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/scripts/backfill-recipient-schedules.ts b/scripts/backfill-recipient-schedules.ts index 56554bee..486af1b5 100644 --- a/scripts/backfill-recipient-schedules.ts +++ b/scripts/backfill-recipient-schedules.ts @@ -3,6 +3,7 @@ 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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts' type BackfillOptions = { batchSize: number @@ -61,15 +62,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() === + 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 +89,9 @@ async function run() { data.prevScheduledAt = scheduleWindow.prevScheduledAt data.nextScheduledAt = scheduleWindow.nextScheduledAt } catch { - data.prevScheduledAt = null - data.nextScheduledAt = null + // Use sentinel date when schedule can't be computed + data.prevScheduledAt = SCHEDULE_SENTINEL_DATE + data.nextScheduledAt = SCHEDULE_SENTINEL_DATE } } diff --git a/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index ef17fad1..6f63666c 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -3,6 +3,7 @@ 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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts' const MESSAGES_PER_PAGE = 100 @@ -128,10 +129,14 @@ async function benchmarkRecipientsList( const sortedRecipients = recipients .map((recipient) => { try { + const isSentinel = + recipient.nextScheduledAt?.getTime() === + SCHEDULE_SENTINEL_DATE.getTime() const scheduleWindow = recipient.nextScheduledAt && recipient.prevScheduledAt && - recipient.nextScheduledAt > now + recipient.nextScheduledAt > now && + !isSentinel ? { nextScheduledAt: recipient.nextScheduledAt, prevScheduledAt: recipient.prevScheduledAt, @@ -262,6 +267,17 @@ async function benchmarkPastMessages( } } +type CronBenchRecipient = { + id: string + name: string + scheduleCron: string + timeZone: string + prevScheduledAt: Date | null + nextScheduledAt: Date | null + lastRemindedAt: Date | null + lastSentAt: Date | null +} + async function benchmarkCron(iterations: number): Promise { const querySamples: number[] = [] const computeSamples: number[] = [] @@ -272,27 +288,26 @@ 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 raw SQL query (same as production cron.server.ts) + const rawRecipients = await prisma.$queryRaw` + SELECT + r.id, + r.name, + r.scheduleCron, + r.timeZone, + r.prevScheduledAt, + r.nextScheduledAt, + r.lastRemindedAt, + r.lastSentAt + 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 <= ${reminderCutoff} + ORDER BY r.nextScheduledAt ASC + ` const queryMs = performance.now() - queryStart const computeStart = performance.now() @@ -301,10 +316,14 @@ async function benchmarkCron(iterations: number): Promise { let errors = 0 for (const recipient of rawRecipients) { try { + const isSentinel = + recipient.nextScheduledAt?.getTime() === + SCHEDULE_SENTINEL_DATE.getTime() const scheduleWindow = recipient.nextScheduledAt && recipient.prevScheduledAt && - recipient.nextScheduledAt > now + recipient.nextScheduledAt > now && + !isSentinel ? { nextScheduledAt: recipient.nextScheduledAt, prevScheduledAt: recipient.prevScheduledAt, From a5105ee3e35644fdf794217a66001fcdbf9fd2f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 14:14:57 +0000 Subject: [PATCH 5/9] Update tests to compute schedule fields for recipients - Update createRecipient to compute prevScheduledAt/nextScheduledAt - Update cron tests to use consistent schedule data - Ensures tests work with optimized query that requires schedule fields Co-authored-by: me --- app/utils/cron.server.test.ts | 33 ++++++++++++++++++++++++++++++--- tests/db-utils.ts | 23 ++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/app/utils/cron.server.test.ts b/app/utils/cron.server.test.ts index d4bebc88..2fb78698 100644 --- a/app/utils/cron.server.test.ts +++ b/app/utils/cron.server.test.ts @@ -1,8 +1,9 @@ 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' +import { SCHEDULE_SENTINEL_DATE } from './schedule-constants.server.ts' test('does not send any texts if there are none to be sent', async () => { await prisma.sourceNumber.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,6 +88,20 @@ 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 - too overdue to send + const scheduleCron = '* * 1 * *' + const timeZone = 'America/Denver' + let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } + try { + scheduleData = getScheduleWindow(scheduleCron, timeZone) + } catch { + scheduleData = { + prevScheduledAt: SCHEDULE_SENTINEL_DATE, + nextScheduledAt: SCHEDULE_SENTINEL_DATE, + } + } + await prisma.user.create({ data: { ...createUser(), @@ -86,7 +110,10 @@ test(`does not send a text if it is too overdue`, async () => { { ...createRecipient(), verified: true, - scheduleCron: '* * 1 * *', + scheduleCron, + timeZone, + prevScheduledAt: scheduleData.prevScheduledAt, + nextScheduledAt: scheduleData.nextScheduledAt, messages: { create: { ...createMessage(), sentAt: null }, }, diff --git a/tests/db-utils.ts b/tests/db-utils.ts index 56720040..18b7989c 100644 --- a/tests/db-utils.ts +++ b/tests/db-utils.ts @@ -1,7 +1,9 @@ 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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts' const uniqueUsernameEnforcer = new UniqueEnforcer() const uniquePhoneEnforcer = new UniqueEnforcer() @@ -58,13 +60,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: SCHEDULE_SENTINEL_DATE, + nextScheduledAt: 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, } } From b20519c7c32e76072f5747a671d7ee1859d81ffa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 15:07:55 +0000 Subject: [PATCH 6/9] Use Prisma TypedSQL and separate sentinel dates for prev/next schedule - Replace $queryRaw with $queryRawTyped using getrecipientsforcron TypedSQL - Add PREV_SCHEDULE_SENTINEL_DATE (1970-01-01) for far-past sentinel - Add NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31) for far-future sentinel - Update migration to use correct sentinel dates for each field - Update all code to use appropriate sentinel dates Co-authored-by: me --- .../_app+/recipients+/__editor.server.tsx | 11 ++-- app/routes/_app+/recipients+/_layout.tsx | 20 +++--- app/utils/cron.server.test.ts | 9 ++- app/utils/cron.server.ts | 62 +++++-------------- app/utils/schedule-constants.server.ts | 28 ++++++++- app/utils/text.server.ts | 11 ++-- .../migration.sql | 6 +- scripts/backfill-recipient-schedules.ts | 13 ++-- scripts/benchmark-performance.ts | 41 +++--------- tests/db-utils.ts | 9 ++- 10 files changed, 100 insertions(+), 110 deletions(-) diff --git a/app/routes/_app+/recipients+/__editor.server.tsx b/app/routes/_app+/recipients+/__editor.server.tsx index 04257675..f091f7c5 100644 --- a/app/routes/_app+/recipients+/__editor.server.tsx +++ b/app/routes/_app+/recipients+/__editor.server.tsx @@ -4,7 +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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.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 { @@ -159,10 +162,10 @@ export async function usertRecipientAction({ try { scheduleData = getScheduleWindow(scheduleCron, timeZone) } catch { - // Use sentinel date when schedule can't be computed + // Use sentinel dates when schedule can't be computed scheduleData = { - prevScheduledAt: SCHEDULE_SENTINEL_DATE, - nextScheduledAt: SCHEDULE_SENTINEL_DATE, + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, } } diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index ff54670a..43306aa2 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -8,7 +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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.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) { @@ -62,7 +65,7 @@ export async function loader({ request }: LoaderFunctionArgs) { let prevScheduledAt = recipient.prevScheduledAt // Check if current value is the sentinel date (invalid schedule) const isSentinel = - nextScheduledAt?.getTime() === SCHEDULE_SENTINEL_DATE.getTime() + nextScheduledAt?.getTime() === NEXT_SCHEDULE_SENTINEL_DATE.getTime() try { if ( !nextScheduledAt || @@ -97,21 +100,22 @@ export async function loader({ request }: LoaderFunctionArgs) { cronError: null as string | null, } } catch (error) { - // Use sentinel date for invalid schedules, update if needed + // Use sentinel dates for invalid schedules, update if needed const needsSentinelUpdate = !recipient.nextScheduledAt || - recipient.nextScheduledAt.getTime() !== SCHEDULE_SENTINEL_DATE.getTime() + recipient.nextScheduledAt.getTime() !== + NEXT_SCHEDULE_SENTINEL_DATE.getTime() if (needsSentinelUpdate) { scheduleUpdates.push({ id: recipient.id, - prevScheduledAt: SCHEDULE_SENTINEL_DATE, - nextScheduledAt: SCHEDULE_SENTINEL_DATE, + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, }) } return { ...recipient, - prevScheduledAt: prevScheduledAt ?? SCHEDULE_SENTINEL_DATE, - nextScheduledAt: SCHEDULE_SENTINEL_DATE, // Sentinel 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 2fb78698..de8b74a9 100644 --- a/app/utils/cron.server.test.ts +++ b/app/utils/cron.server.test.ts @@ -3,7 +3,10 @@ import { test, expect } from 'vitest' import { createMessage, createRecipient, createUser } from '#tests/db-utils.ts' import { getScheduleWindow, sendNextTexts } from './cron.server.ts' import { prisma } from './db.server.ts' -import { SCHEDULE_SENTINEL_DATE } from './schedule-constants.server.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from './schedule-constants.server.ts' test('does not send any texts if there are none to be sent', async () => { await prisma.sourceNumber.create({ @@ -97,8 +100,8 @@ test(`does not send a text if it is too overdue`, async () => { scheduleData = getScheduleWindow(scheduleCron, timeZone) } catch { scheduleData = { - prevScheduledAt: SCHEDULE_SENTINEL_DATE, - nextScheduledAt: SCHEDULE_SENTINEL_DATE, + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, } } diff --git a/app/utils/cron.server.ts b/app/utils/cron.server.ts index afb6287e..deee633c 100644 --- a/app/utils/cron.server.ts +++ b/app/utils/cron.server.ts @@ -10,7 +10,11 @@ import { setIntervalAsync, } from 'set-interval-async/dynamic' import { prisma } from './db.server.ts' -import { SCHEDULE_SENTINEL_DATE } from './schedule-constants.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 { @@ -64,53 +68,20 @@ export async function init() { ) } -/** - * Type for recipients returned from the optimized cron query - */ -type CronRecipient = { - id: string - name: string - scheduleCron: string - timeZone: string - prevScheduledAt: Date | null - nextScheduledAt: Date | null - lastRemindedAt: Date | null - lastSentAt: Date | null - userPhoneNumber: string - userName: string | null -} - export async function sendNextTexts() { const now = new Date() const reminderWindowMs = 1000 * 60 * 30 const reminderCutoff = new Date(now.getTime() + reminderWindowMs) - // Optimized raw SQL query that: - // 1. Uses EXISTS instead of JOIN (we only filter by User, don't select from it) + // 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: SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff, - // so recipients with invalid schedules won't be included unless we explicitly fetch them - const recipients = await prisma.$queryRaw` - SELECT - 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 <= ${reminderCutoff} - ORDER BY r.nextScheduledAt ASC - ` + // 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) => ({ @@ -134,7 +105,8 @@ export async function sendNextTexts() { recipient.prevScheduledAt && recipient.nextScheduledAt && recipient.nextScheduledAt > now && - recipient.nextScheduledAt.getTime() !== SCHEDULE_SENTINEL_DATE.getTime() + recipient.nextScheduledAt.getTime() !== + NEXT_SCHEDULE_SENTINEL_DATE.getTime() ) { scheduleWindow = { prevScheduledAt: recipient.prevScheduledAt, @@ -152,12 +124,12 @@ export async function sendNextTexts() { `Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`, error instanceof Error ? error.message : error, ) - // Update with sentinel to prevent repeated processing + // Update with sentinel dates to prevent repeated processing await prisma.recipient.update({ where: { id: recipient.id }, data: { - prevScheduledAt: SCHEDULE_SENTINEL_DATE, - nextScheduledAt: SCHEDULE_SENTINEL_DATE, + 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 index 6e7bccdd..be9410fc 100644 --- a/app/utils/schedule-constants.server.ts +++ b/app/utils/schedule-constants.server.ts @@ -3,14 +3,36 @@ * Using a far-future date instead of NULL allows SQLite to use indexes efficiently * and eliminates the OR ... IS NULL pattern that defeats index usage. */ -export const SCHEDULE_SENTINEL_DATE = new Date('9999-12-31T23:59:59.999Z') +export const NEXT_SCHEDULE_SENTINEL_DATE = new Date('9999-12-31T23:59:59.999Z') /** - * Check if a date is the sentinel value (meaning no valid schedule) + * Sentinel date used when prevScheduledAt cannot be computed (invalid cron, etc.) + * Using a far-past date 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 + +/** + * Check if a date is a sentinel value (meaning no valid schedule) */ export function isScheduleSentinel(date: Date | null): boolean { if (!date) return false - return date.getTime() === SCHEDULE_SENTINEL_DATE.getTime() + return ( + date.getTime() === NEXT_SCHEDULE_SENTINEL_DATE.getTime() || + date.getTime() === PREV_SCHEDULE_SENTINEL_DATE.getTime() + ) +} + +/** + * Check if a nextScheduledAt date is the sentinel value + */ +export function isNextScheduleSentinel(date: Date | null): boolean { + if (!date) return false + return date.getTime() === NEXT_SCHEDULE_SENTINEL_DATE.getTime() } /** diff --git a/app/utils/text.server.ts b/app/utils/text.server.ts index 79513458..e1c1ff0a 100644 --- a/app/utils/text.server.ts +++ b/app/utils/text.server.ts @@ -1,7 +1,10 @@ import { z } from 'zod' import { getScheduleWindow } from './cron.server.ts' import { prisma } from './db.server.ts' -import { SCHEDULE_SENTINEL_DATE } from './schedule-constants.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 @@ -105,10 +108,10 @@ export async function sendTextToRecipient({ sentAt, ) } catch { - // Use sentinel date when schedule can't be computed + // Use sentinel dates when schedule can't be computed scheduleData = { - prevScheduledAt: SCHEDULE_SENTINEL_DATE, - nextScheduledAt: SCHEDULE_SENTINEL_DATE, + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, } } diff --git a/prisma/migrations/20260202140000_optimize_cron_query/migration.sql b/prisma/migrations/20260202140000_optimize_cron_query/migration.sql index 2d5dc334..ce15e211 100644 --- a/prisma/migrations/20260202140000_optimize_cron_query/migration.sql +++ b/prisma/migrations/20260202140000_optimize_cron_query/migration.sql @@ -6,10 +6,12 @@ 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 nextScheduledAt values to sentinel date (9999-12-31) +-- 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" = '9999-12-31T23:59:59.999Z' WHERE "prevScheduledAt" 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) diff --git a/scripts/backfill-recipient-schedules.ts b/scripts/backfill-recipient-schedules.ts index 486af1b5..ce283f42 100644 --- a/scripts/backfill-recipient-schedules.ts +++ b/scripts/backfill-recipient-schedules.ts @@ -3,7 +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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts' +import { + NEXT_SCHEDULE_SENTINEL_DATE, + PREV_SCHEDULE_SENTINEL_DATE, +} from '#app/utils/schedule-constants.server.ts' type BackfillOptions = { batchSize: number @@ -65,7 +68,7 @@ async function run() { // Check if current value is the sentinel date const isSentinel = recipient.nextScheduledAt?.getTime() === - SCHEDULE_SENTINEL_DATE.getTime() + NEXT_SCHEDULE_SENTINEL_DATE.getTime() const shouldUpdateSchedule = !options.onlyMissing || !recipient.prevScheduledAt || @@ -89,9 +92,9 @@ async function run() { data.prevScheduledAt = scheduleWindow.prevScheduledAt data.nextScheduledAt = scheduleWindow.nextScheduledAt } catch { - // Use sentinel date when schedule can't be computed - data.prevScheduledAt = SCHEDULE_SENTINEL_DATE - data.nextScheduledAt = SCHEDULE_SENTINEL_DATE + // 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 6f63666c..1ad5949c 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -3,7 +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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.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 @@ -131,7 +132,7 @@ async function benchmarkRecipientsList( try { const isSentinel = recipient.nextScheduledAt?.getTime() === - SCHEDULE_SENTINEL_DATE.getTime() + NEXT_SCHEDULE_SENTINEL_DATE.getTime() const scheduleWindow = recipient.nextScheduledAt && recipient.prevScheduledAt && @@ -267,17 +268,6 @@ async function benchmarkPastMessages( } } -type CronBenchRecipient = { - id: string - name: string - scheduleCron: string - timeZone: string - prevScheduledAt: Date | null - nextScheduledAt: Date | null - lastRemindedAt: Date | null - lastSentAt: Date | null -} - async function benchmarkCron(iterations: number): Promise { const querySamples: number[] = [] const computeSamples: number[] = [] @@ -289,25 +279,10 @@ async function benchmarkCron(iterations: number): Promise { const reminderCutoff = new Date(now.getTime() + reminderWindowMs) const queryStart = performance.now() - // Use optimized raw SQL query (same as production cron.server.ts) - const rawRecipients = await prisma.$queryRaw` - SELECT - r.id, - r.name, - r.scheduleCron, - r.timeZone, - r.prevScheduledAt, - r.nextScheduledAt, - r.lastRemindedAt, - r.lastSentAt - 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 <= ${reminderCutoff} - ORDER BY r.nextScheduledAt ASC - ` + // 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() @@ -318,7 +293,7 @@ async function benchmarkCron(iterations: number): Promise { try { const isSentinel = recipient.nextScheduledAt?.getTime() === - SCHEDULE_SENTINEL_DATE.getTime() + NEXT_SCHEDULE_SENTINEL_DATE.getTime() const scheduleWindow = recipient.nextScheduledAt && recipient.prevScheduledAt && diff --git a/tests/db-utils.ts b/tests/db-utils.ts index 18b7989c..7780a4a0 100644 --- a/tests/db-utils.ts +++ b/tests/db-utils.ts @@ -3,7 +3,10 @@ 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 { SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.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() @@ -69,8 +72,8 @@ export function createRecipient() { scheduleData = getScheduleWindow(scheduleCron, timeZone) } catch { scheduleData = { - prevScheduledAt: SCHEDULE_SENTINEL_DATE, - nextScheduledAt: SCHEDULE_SENTINEL_DATE, + prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, + nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, } } From c40d4101bd380f8e5a1e8c747278f3656c939d4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 15:10:19 +0000 Subject: [PATCH 7/9] Fix sentinel updates and cron test Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 5 ++++- app/utils/cron.server.test.ts | 2 ++ app/utils/schedule-constants.server.ts | 27 ------------------------ 3 files changed, 6 insertions(+), 28 deletions(-) diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 43306aa2..e8003a54 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -103,8 +103,11 @@ export async function loader({ request }: LoaderFunctionArgs) { // Use sentinel dates for invalid schedules, update if needed const needsSentinelUpdate = !recipient.nextScheduledAt || + !recipient.prevScheduledAt || recipient.nextScheduledAt.getTime() !== - NEXT_SCHEDULE_SENTINEL_DATE.getTime() + NEXT_SCHEDULE_SENTINEL_DATE.getTime() || + recipient.prevScheduledAt.getTime() !== + PREV_SCHEDULE_SENTINEL_DATE.getTime() if (needsSentinelUpdate) { scheduleUpdates.push({ id: recipient.id, diff --git a/app/utils/cron.server.test.ts b/app/utils/cron.server.test.ts index de8b74a9..71305544 100644 --- a/app/utils/cron.server.test.ts +++ b/app/utils/cron.server.test.ts @@ -22,6 +22,7 @@ test('does not send to unverified recipients', async () => { await prisma.user.create({ data: { ...createUser(), + stripeId: faker.string.uuid(), recipients: { create: [ { @@ -108,6 +109,7 @@ test(`does not send a text if it is too overdue`, async () => { await prisma.user.create({ data: { ...createUser(), + stripeId: faker.string.uuid(), recipients: { create: [ { diff --git a/app/utils/schedule-constants.server.ts b/app/utils/schedule-constants.server.ts index be9410fc..776a40fa 100644 --- a/app/utils/schedule-constants.server.ts +++ b/app/utils/schedule-constants.server.ts @@ -15,30 +15,3 @@ 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 - -/** - * Check if a date is a sentinel value (meaning no valid schedule) - */ -export function isScheduleSentinel(date: Date | null): boolean { - if (!date) return false - return ( - date.getTime() === NEXT_SCHEDULE_SENTINEL_DATE.getTime() || - date.getTime() === PREV_SCHEDULE_SENTINEL_DATE.getTime() - ) -} - -/** - * Check if a nextScheduledAt date is the sentinel value - */ -export function isNextScheduleSentinel(date: Date | null): boolean { - if (!date) return false - return date.getTime() === NEXT_SCHEDULE_SENTINEL_DATE.getTime() -} - -/** - * Get the display value for a schedule date, returning null if it's the sentinel - */ -export function getScheduleDisplayDate(date: Date | null): Date | null { - if (!date || isScheduleSentinel(date)) return null - return date -} From eca49fc5dcd540646478550dc66d6f57cd6d371d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 15:55:10 +0000 Subject: [PATCH 8/9] Add documentation explaining denormalized fields and sentinel dates - Document lastSentAt denormalization in schema (avoids slow MAX() aggregate) - Document sentinel dates in schema (avoids OR ... IS NULL pattern) - Add comprehensive performance notes to getrecipientsforcron.sql - Add comments in text.server.ts and schedule-constants.server.ts - These comments prevent future developers from accidentally undoing optimizations Co-authored-by: me --- app/utils/schedule-constants.server.ts | 20 +++++++++++++++++--- app/utils/text.server.ts | 3 +++ prisma/schema.prisma | 16 ++++++++++++++++ prisma/sql/getrecipientsforcron.sql | 21 +++++++++++++++++++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/app/utils/schedule-constants.server.ts b/app/utils/schedule-constants.server.ts index 776a40fa..aad1bb04 100644 --- a/app/utils/schedule-constants.server.ts +++ b/app/utils/schedule-constants.server.ts @@ -1,13 +1,27 @@ +/** + * 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 instead of NULL allows SQLite to use indexes efficiently - * and eliminates the OR ... IS NULL pattern that defeats index usage. + * 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 indicates there was never a valid previous schedule. + * 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') diff --git a/app/utils/text.server.ts b/app/utils/text.server.ts index e1c1ff0a..7d1ce6e5 100644 --- a/app/utils/text.server.ts +++ b/app/utils/text.server.ts @@ -120,6 +120,9 @@ 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: { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cdcab30a..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()) diff --git a/prisma/sql/getrecipientsforcron.sql b/prisma/sql/getrecipientsforcron.sql index 95cae71d..dcad2919 100644 --- a/prisma/sql/getrecipientsforcron.sql +++ b/prisma/sql/getrecipientsforcron.sql @@ -1,6 +1,23 @@ -- Optimized cron query for fetching recipients due for reminders/messages --- Uses INNER JOIN instead of LEFT JOIN, and filters by nextScheduledAt range --- The composite index Recipient_cron_query_idx covers this query pattern +-- +-- 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 r.id, From d5a246cd472fafd2db59bad95689d1f7a7004cf7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 16:13:04 +0000 Subject: [PATCH 9/9] Fix overdue cron test coverage Co-authored-by: me --- app/utils/cron.server.test.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/app/utils/cron.server.test.ts b/app/utils/cron.server.test.ts index 71305544..a69e21c1 100644 --- a/app/utils/cron.server.test.ts +++ b/app/utils/cron.server.test.ts @@ -3,10 +3,6 @@ import { test, expect } from 'vitest' import { createMessage, createRecipient, createUser } from '#tests/db-utils.ts' import { getScheduleWindow, sendNextTexts } from './cron.server.ts' import { prisma } from './db.server.ts' -import { - NEXT_SCHEDULE_SENTINEL_DATE, - PREV_SCHEDULE_SENTINEL_DATE, -} from './schedule-constants.server.ts' test('does not send any texts if there are none to be sent', async () => { await prisma.sourceNumber.create({ @@ -93,18 +89,12 @@ test(`does not send a text if it is too overdue`, async () => { data: { phoneNumber: faker.phone.number() }, }) - // Use a cron that's monthly - too overdue to send + // Use a cron that's monthly, but force an overdue window within the reminder cutoff const scheduleCron = '* * 1 * *' const timeZone = 'America/Denver' - let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } - try { - scheduleData = getScheduleWindow(scheduleCron, timeZone) - } catch { - scheduleData = { - prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, - nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, - } - } + 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: { @@ -117,10 +107,15 @@ test(`does not send a text if it is too overdue`, async () => { verified: true, scheduleCron, timeZone, - prevScheduledAt: scheduleData.prevScheduledAt, - nextScheduledAt: scheduleData.nextScheduledAt, + 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, + }, }, }, ],