* fix(sandbox): loop small cleanup batches (8s cap is real), clear last FK blockers Draining the prod backlog exposed two final issues: - The function-level statement_timeout shipped in 20260807150000 does NOT lift authenticator's 8s cap: the timer arms when the top-level statement starts (verified empirically on prod: SET LOCAL 2s canceled the RPC despite its 290s proconfig; matches the 2026-08-04 SIE-import finding). The route now loops batches of 10 (~220ms/user with the account_id index, so ~2.2s per batch), each rpc() call being its own statement with its own 8s window. The loop stops when a batch makes no progress or the 240s time budget nears; capacity is 250 users/night. - processing_history.company_id and invoice_deliveries.company_id are plain NO ACTION FKs, so sandboxes whose visitor produced AI telemetry or sent a demo invoice could never be deleted (7 of ~510 backlog users). A data-driven sweep of every NO ACTION FK into companies confirms these two plus the already-handled audit_log are the only such tables with sandbox rows. cleanup_sandbox_user (migration 20260807160000) deletes them explicitly; the pg fixture now seeds a processing_history row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): use valid processing_history aggregate_type/event_type in sandbox fixture aggregate_type is CHECK-constrained and event_type is an FK to the seeded processing_event_types lookup; the guessed values failed all five fixture-dependent pg tests in CI. Validated against staging: Document/DocumentIngested inserts and tears down cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sandbox): bypass invoice-delivery delete guard in teardown, cover both blocker tables in pg fixture CodeRabbit's fixture ask exposed a real gap: enforce_invoice_delivery_immutability silently swallows DELETEs (RETURN NULL plus a SECURITY_EVENT audit row) for terminal rows, so the explicit invoice_deliveries delete was a no-op and the companies FK still blocked teardown for sandboxes that sent a demo invoice. The trigger's DELETE branch now honors the gnubok.sandbox_cleanup flag with the same per-row sandbox re-verification as every other guard; base definition 20260803224000, all other branches untouched. The pg fixture seeds an invoice plus a marked_sent manual delivery, and a new test pins the zero-settings refusal path the Swedish review asked about. Validated on staging end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
89 lines
3.3 KiB
TypeScript
89 lines
3.3 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).
|
|
*
|
|
* Every PostgREST statement runs under authenticator's statement_timeout of
|
|
* 8s, and a function-level SET statement_timeout does NOT lift it (the timer
|
|
* arms when the top-level statement starts; verified empirically on prod
|
|
* 2026-08-07, same finding as the SIE import RPCs). One teardown costs
|
|
* ~220ms with the account_id index, so the run loops SMALL batches: each
|
|
* rpc() call is its own statement with its own 8s window, and the loop
|
|
* stops when a batch makes no progress (nothing left, or only failing
|
|
* users remain) or the route's time budget nears. Capacity per night is
|
|
* MAX_BATCHES * BATCH_LIMIT users; the nightly intake is a small fraction
|
|
* of that.
|
|
*/
|
|
export const maxDuration = 300
|
|
|
|
const BATCH_LIMIT = 10
|
|
const MAX_BATCHES = 25
|
|
const TIME_BUDGET_MS = 240_000
|
|
|
|
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 started = Date.now()
|
|
const totals = { cleaned: 0, failed: 0, orphans_removed: 0, batches: 0 }
|
|
|
|
for (let i = 0; i < MAX_BATCHES; i++) {
|
|
if (Date.now() - started > TIME_BUDGET_MS) break
|
|
|
|
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, ...totals })
|
|
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 batch =
|
|
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),
|
|
}
|
|
|
|
totals.cleaned += batch.cleaned
|
|
totals.failed += batch.failed
|
|
totals.orphans_removed += batch.orphans_removed
|
|
totals.batches += 1
|
|
|
|
// No progress means only permanently-failing users (retried nightly and
|
|
// reported below) or an empty backlog: looping further would spin on the
|
|
// same rows.
|
|
if (batch.cleaned + batch.orphans_removed === 0) break
|
|
}
|
|
|
|
// 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 (totals.failed > 0) {
|
|
ctx.log.error('sandbox cleanup completed with failures', totals)
|
|
} else {
|
|
ctx.log.info('sandbox cleanup summary', totals)
|
|
}
|
|
|
|
return NextResponse.json({ success: true, ...totals })
|
|
})
|