Files
accounted/app/api/sandbox/cleanup/cron/route.ts
T
MattssonandClaude Fable 5 526f0315d0 fix(sandbox): loop small cleanup batches (8s cap is real), clear last FK blockers (#1452)
* 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>
2026-08-07 15:18:59 +02:00

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 })
})