fix(sandbox): fit the nightly cleanup inside PostgREST's 8s session cap (#1451)

* 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>
This commit is contained in:
Mattsson
2026-08-07 14:37:28 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 7411a0171b
commit bd7a423b86
5 changed files with 142 additions and 1 deletions
@@ -27,13 +27,19 @@ vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(() => ({ rpc: h.rpc })),
}))
import { GET } from '../route'
import { GET, maxDuration } from '../route'
function cronRequest(): Request {
return new Request('http://localhost:3000/api/sandbox/cleanup/cron')
}
describe('GET /api/sandbox/cleanup/cron', () => {
it('reserves enough function time for a full batch', () => {
// 60 users at ~3s each must fit inside the route budget and the RPC's
// 290s statement_timeout (migration 20260807150000).
expect(maxDuration).toBe(300)
})
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'
@@ -51,6 +57,7 @@ describe('GET /api/sandbox/cleanup/cron', () => {
expect(h.rpc).toHaveBeenCalledWith('cleanup_expired_sandbox_users', {
p_max_age_hours: 24,
p_limit: 60,
})
expect(res.status).toBe(200)
expect(body).toEqual({ success: true, cleaned: 3, failed: 0, orphans_removed: 2 })
+12
View File
@@ -6,7 +6,18 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
/**
* 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
@@ -22,6 +33,7 @@ export const GET = withCronContext('cron.sandbox_cleanup', async (_request, ctx)
const { data, error } = await supabase.rpc('cleanup_expired_sandbox_users', {
p_max_age_hours: 24,
p_limit: BATCH_LIMIT,
})
if (error) {