diff --git a/app/api/admin/ama/bookings/[bookingId]/route.ts b/app/api/admin/ama/bookings/[bookingId]/route.ts index 6b93dcd61..c93e03eb2 100644 --- a/app/api/admin/ama/bookings/[bookingId]/route.ts +++ b/app/api/admin/ama/bookings/[bookingId]/route.ts @@ -3,6 +3,9 @@ import { getAmaAdminServices, ownerRequestAuthenticator, } from '~/lib/ama/admin/server' +import { kickAmaOperations } from '~/lib/ama/booking/server' + +export const maxDuration = 300 export async function POST( request: Request, @@ -10,10 +13,12 @@ export async function POST( ) { const { bookingId } = await params const { bookingAdmin, security, baseUrl } = getAmaAdminServices() - return createAdminBookingActionHandler({ + const response = await createAdminBookingActionHandler({ authenticator: ownerRequestAuthenticator, service: bookingAdmin, security, baseUrl, })(request, bookingId) + if (response.ok) kickAmaOperations() + return response } diff --git a/app/api/admin/ama/operations/[operationId]/route.ts b/app/api/admin/ama/operations/[operationId]/route.ts index 16a8b77f1..c604dc3cd 100644 --- a/app/api/admin/ama/operations/[operationId]/route.ts +++ b/app/api/admin/ama/operations/[operationId]/route.ts @@ -3,6 +3,9 @@ import { getAmaAdminServices, ownerRequestAuthenticator, } from '~/lib/ama/admin/server' +import { kickAmaOperations } from '~/lib/ama/booking/server' + +export const maxDuration = 300 export async function POST( request: Request, @@ -10,10 +13,12 @@ export async function POST( ) { const { operationId } = await params const { bookingAdmin, security, baseUrl } = getAmaAdminServices() - return createAdminOperationActionHandler({ + const response = await createAdminOperationActionHandler({ authenticator: ownerRequestAuthenticator, service: bookingAdmin, security, baseUrl, })(request, operationId) + if (response.ok) kickAmaOperations() + return response } diff --git a/app/api/ama/manage/[token]/cancel/route.ts b/app/api/ama/manage/[token]/cancel/route.ts index 2a9a196ac..82ea00cec 100644 --- a/app/api/ama/manage/[token]/cancel/route.ts +++ b/app/api/ama/manage/[token]/cancel/route.ts @@ -1,7 +1,12 @@ import { createManageCancelHandler } from '~/lib/ama/booking/http' -import { getAmaBookingServices } from '~/lib/ama/booking/server' +import { + getAmaBookingServices, + kickAmaOperations, +} from '~/lib/ama/booking/server' import { protectAmaLaunchBoundary } from '~/lib/ama/security/launch-boundary-server' +export const maxDuration = 300 + export async function POST( request: Request, { params }: { params: Promise<{ token: string }> }, @@ -13,5 +18,10 @@ export async function POST( if (blocked) return blocked const { token } = await params const { manage, guard } = getAmaBookingServices() - return createManageCancelHandler({ manage, guard })(request, token) + const response = await createManageCancelHandler({ manage, guard })( + request, + token, + ) + if (response.ok) kickAmaOperations() + return response } diff --git a/app/api/ama/manage/[token]/reschedule/route.ts b/app/api/ama/manage/[token]/reschedule/route.ts index 7d0b51cff..d99109984 100644 --- a/app/api/ama/manage/[token]/reschedule/route.ts +++ b/app/api/ama/manage/[token]/reschedule/route.ts @@ -1,7 +1,12 @@ import { createManageRescheduleHandler } from '~/lib/ama/booking/http' -import { getAmaBookingServices } from '~/lib/ama/booking/server' +import { + getAmaBookingServices, + kickAmaOperations, +} from '~/lib/ama/booking/server' import { protectAmaLaunchBoundary } from '~/lib/ama/security/launch-boundary-server' +export const maxDuration = 300 + export async function POST( request: Request, { params }: { params: Promise<{ token: string }> }, @@ -13,5 +18,10 @@ export async function POST( if (blocked) return blocked const { token } = await params const { manage, guard } = getAmaBookingServices() - return createManageRescheduleHandler({ manage, guard })(request, token) + const response = await createManageRescheduleHandler({ manage, guard })( + request, + token, + ) + if (response.ok) kickAmaOperations() + return response } diff --git a/app/api/ama/stripe/webhook/route.ts b/app/api/ama/stripe/webhook/route.ts index 269c7457b..6467d881e 100644 --- a/app/api/ama/stripe/webhook/route.ts +++ b/app/api/ama/stripe/webhook/route.ts @@ -1,7 +1,12 @@ import { createStripeWebhookHandler, json } from '~/lib/ama/booking/http' -import { getAmaBookingServices } from '~/lib/ama/booking/server' +import { + getAmaBookingServices, + kickAmaOperations, +} from '~/lib/ama/booking/server' import { protectAmaLaunchBoundary } from '~/lib/ama/security/launch-boundary-server' +export const maxDuration = 300 + export async function POST(request: Request) { const blocked = protectAmaLaunchBoundary(request, ['payments']) if (blocked) return blocked @@ -10,5 +15,10 @@ export async function POST(request: Request) { return createStripeWebhookHandler({ service: booking, signingSecret: stripeWebhookSecret, + // Only booking creation enqueues durable work; duplicate, ignored, + // orphaned, and hold-release deliveries return 200 without any. + onOutcome: (outcome) => { + if (outcome === 'booking_created') kickAmaOperations() + }, })(request) } diff --git a/lib/ama/booking/http.ts b/lib/ama/booking/http.ts index 7bbc88521..605a9d4b1 100644 --- a/lib/ama/booking/http.ts +++ b/lib/ama/booking/http.ts @@ -9,7 +9,7 @@ import { import type { SecurityRateLimiter } from '../security/service' import { verifyStripeWebhook } from '../stripe/webhook' import type { ManageService } from './manage' -import type { BookingService } from './service' +import type { BookingService, WebhookOutcome } from './service' const MAX_JSON_BODY_BYTES = 32 * 1024 @@ -253,12 +253,18 @@ type WebhookDependencies = { service: Pick signingSecret: string clock?: { now(): Date } + /** + * Observes the processed outcome so callers can react to the ones that + * enqueued durable work without re-parsing the response body. + */ + onOutcome?: (outcome: WebhookOutcome) => void } export function createStripeWebhookHandler({ service, signingSecret, clock = { now: () => new Date() }, + onOutcome, }: WebhookDependencies) { return async function POST(request: Request) { let payload: string @@ -276,6 +282,7 @@ export function createStripeWebhookHandler({ if (!event) return json(400, { error: 'invalid_signature' }) try { const outcome = await service.processWebhookEvent(event) + onOutcome?.(outcome) return json(200, { received: true, outcome }) } catch { // Signal Stripe to redeliver; the persisted provider event makes the diff --git a/lib/ama/booking/server.ts b/lib/ama/booking/server.ts index ab03dec9b..96a07fe3b 100644 --- a/lib/ama/booking/server.ts +++ b/lib/ama/booking/server.ts @@ -1,5 +1,7 @@ import 'server-only' +import { waitUntil } from '@vercel/functions' + import { createRateLimiter } from '~/lib/rate-limit/server' import { availabilityRepository } from '../availability/repository' @@ -30,6 +32,12 @@ import { bookingRepository } from './repository' import { createBookingService } from './service' const PROVIDER_REQUEST_TIMEOUT_MS = 8_000 +const OPERATIONS_BATCH_SIZE = 10 +// Budget for starting drain passes. The mutating routes set maxDuration to +// 300s; 240s here plus one worst-case 45s pass still finishes inside that +// ceiling, and covers enough passes that a backlog cannot starve the +// operation the triggering mutation enqueued. +const INLINE_DRAIN_BUDGET_MS = 240_000 let services: ReturnType | undefined @@ -186,6 +194,7 @@ function createServices() { clock, }) const runner = createOperationsRunner({ + batchSize: OPERATIONS_BATCH_SIZE, operations: durableOperationsRepository, handler: createOperationHandlers({ repository: bookingRepository, @@ -227,3 +236,32 @@ export function getAmaBookingServices() { services ??= createServices() return services } + +/** + * Starts a background drain of due durable operations inside the current + * invocation, so work enqueued by a mutation (booking emails, Finalizing + * Booking recovery, refunds) begins immediately instead of waiting for the + * next scheduled sweep. The scheduled endpoint remains the sole driver for + * reminders, retry backoff, and expired Slot Hold release. + */ +export function kickAmaOperations() { + const { runner } = getAmaBookingServices() + waitUntil( + (async () => { + // A full batch means older due work may have crowded out the operation + // this mutation enqueued, so keep draining until a partial batch shows + // the due queue is empty or the inline budget is spent. Anything left + // over stays with the scheduled sweep. + const startedAtMs = Date.now() + let result = await runner.run() + while ( + result.claimed >= OPERATIONS_BATCH_SIZE && + Date.now() - startedAtMs < INLINE_DRAIN_BUDGET_MS + ) { + result = await runner.run() + } + })().catch((error) => { + console.error('ama: inline operations drain failed', error) + }), + ) +} diff --git a/vercel.json b/vercel.json index b1aac97bb..9452d405b 100644 --- a/vercel.json +++ b/vercel.json @@ -6,11 +6,11 @@ "crons": [ { "path": "/api/internal/media/reconcile", - "schedule": "*/15 * * * *" + "schedule": "0 * * * *" }, { "path": "/api/internal/ama/work", - "schedule": "*/5 * * * *" + "schedule": "*/30 * * * *" } ] }