* fix(sandbox): fit the nightly cleanup inside PostgREST's 8s session cap Follow-up to #1449. Profiling the repaired teardown on prod puts one sandbox user at ~3s (the auth.users delete fans out over ~250 FK triggers; FK indexes were tried inside an aborted transaction and do not help), while every PostgREST session inherits authenticator's statement_timeout = 8s. The nightly RPC call therefore times out and ROLLS BACK wholesale: a second silent-failure mode for the same cron. - cleanup_expired_sandbox_users gets a function-local statement_timeout of 290s (same sanctioned pattern as undo_sie_import, 20260702154500) via migration 20260807150000. - The cron route bounds each night to 60 users (~180s), exports maxDuration = 300, and the backlog drains over a few nights. - Tests: route asserts the bounded rpc call and the maxDuration budget; the pg suite pins proconfig containing statement_timeout=290s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): index journal_entry_lines.account_id (chart-account cascade seq-scans 730k rows) Caught live during the backlog purge: DELETE FROM auth.users cascades chart_of_accounts deletion, whose ON DELETE SET NULL fires an unindexed UPDATE over journal_entry_lines per account (~37 per sandbox company). This is the bulk of the ~3s per-user teardown cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
66 lines
2.4 KiB
TypeScript
66 lines
2.4 KiB
TypeScript
import { createClient } from '@supabase/supabase-js'
|
|
import { NextResponse } from 'next/server'
|
|
import { withCronContext } from '@/lib/api/with-cron-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
|
|
/**
|
|
* GET /api/sandbox/cleanup/cron: daily 04:00 UTC.
|
|
* Removes expired sandbox users (>24h old).
|
|
*
|
|
* One teardown costs ~3s on prod (the auth.users delete fans out over ~250
|
|
* FK triggers), so the run is bounded: BATCH_LIMIT users per night, sized to
|
|
* finish inside both the RPC's 290s statement_timeout (migration
|
|
* 20260807150000) and this route's maxDuration. The nightly intake is a
|
|
* fraction of this; a backlog drains over a few nights instead of timing
|
|
* out and rolling back wholesale.
|
|
*/
|
|
export const maxDuration = 300
|
|
|
|
const BATCH_LIMIT = 60
|
|
|
|
export const GET = withCronContext('cron.sandbox_cleanup', async (_request, ctx) => {
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !supabaseServiceKey) {
|
|
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { reason: 'Missing Supabase configuration' },
|
|
})
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
|
|
|
const { data, error } = await supabase.rpc('cleanup_expired_sandbox_users', {
|
|
p_max_age_hours: 24,
|
|
p_limit: BATCH_LIMIT,
|
|
})
|
|
|
|
if (error) {
|
|
ctx.log.error('sandbox cleanup rpc failed', error)
|
|
return errorResponse(error, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
|
|
// Migration 20260807130000 changed the RPC's return from a bare integer to
|
|
// a {cleaned, failed, orphans_removed} summary; accept both shapes so
|
|
// deploy/migration ordering cannot break the cron.
|
|
const summary =
|
|
typeof data === 'number'
|
|
? { cleaned: data, failed: 0, orphans_removed: 0 }
|
|
: {
|
|
cleaned: Number(data?.cleaned ?? 0),
|
|
failed: Number(data?.failed ?? 0),
|
|
orphans_removed: Number(data?.orphans_removed ?? 0),
|
|
}
|
|
|
|
// Per-user failures used to be swallowed as Postgres WARNINGs, which is how
|
|
// the cleanup sat broken for months; surface them at error level instead.
|
|
if (summary.failed > 0) {
|
|
ctx.log.error('sandbox cleanup completed with failures', summary)
|
|
} else {
|
|
ctx.log.info('sandbox cleanup summary', summary)
|
|
}
|
|
|
|
return NextResponse.json({ success: true, ...summary })
|
|
})
|