Skip to content
26 changes: 14 additions & 12 deletions app/routes/_app+/recipients+/__editor.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 },
Expand Down
33 changes: 30 additions & 3 deletions app/routes/_app+/recipients+/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
}
Expand Down
35 changes: 31 additions & 4 deletions app/utils/cron.server.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -18,6 +18,7 @@ test('does not send to unverified recipients', async () => {
await prisma.user.create({
data: {
...createUser(),
stripeId: faker.string.uuid(),
recipients: {
create: [
{
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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)

Comment thread
cursor[bot] marked this conversation as resolved.
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,
},
},
},
],
Expand Down
66 changes: 36 additions & 30 deletions app/utils/cron.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}
Expand Down
31 changes: 31 additions & 0 deletions app/utils/schedule-constants.server.ts
Original file line number Diff line number Diff line change
@@ -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
24 changes: 15 additions & 9 deletions app/utils/text.server.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -96,33 +100,35 @@ 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,
recipient.timeZone,
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({
select: { id: true },
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,
},
})
}
Expand Down
21 changes: 21 additions & 0 deletions prisma/migrations/20260202140000_optimize_cron_query/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading