scripts/reclaim_deleted_version_manifests.ts
Rama main · réplica de lectura del repositorio original.
/**
* Requeue legacy deleted versions whose manifest rows have a zero counter.
* Database only: no object listing, trash, deletion or restoration.
* After deploying the handler and draining old workers, run once before the
* retirement migration. Rerun with the same checkpoint to resume interruptions.
*
* SUPABASE_DB_URL=... bun scripts/reclaim_deleted_version_manifests.ts
*/
import type { PoolClient } from 'pg'
import { createHash } from 'node:crypto'
import { readFile, rename, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { env } from 'node:process'
import { Client } from 'pg'
export interface ManifestRequeueCursor {
phase: 'dated' | 'undated' | 'done'
deletedAt: string
id: string
}
export const INITIAL_MANIFEST_REQUEUE_CURSOR: ManifestRequeueCursor = { phase: 'dated', deletedAt: '-infinity', id: '0' }
export async function requeueDeletedManifestBatch(client: Pick<PoolClient, 'query'>, cursor: ManifestRequeueCursor) {
if (cursor.phase === 'done')
return { cursor, scanned: 0, requeued: 0 }
await client.query('BEGIN')
let versionIds: string[] = []
try {
await client.query('SET LOCAL statement_timeout = \'5s\'')
await client.query('SET LOCAL lock_timeout = \'2s\'')
// Both phases use idx_app_versions_deleted_at_id. Preserve timestamp
// microseconds and bigint IDs as text only in output, never in key filters.
const rows = cursor.phase === 'dated'
? await client.query<{ id: string, deleted_at: string }>(`
SELECT av.id::text, av.deleted_at::text FROM public.app_versions AS av
WHERE av.deleted = true AND av.deleted_at IS NOT NULL
AND (av.deleted_at, av.id) > ($1::timestamptz, $2::bigint)
ORDER BY av.deleted_at, av.id LIMIT 50`, [cursor.deletedAt, cursor.id])
: await client.query<{ id: string, deleted_at: null }>(`
SELECT av.id::text, av.deleted_at::text FROM public.app_versions AS av
WHERE av.deleted = true AND av.deleted_at IS NULL AND av.id > $1::bigint
ORDER BY av.deleted_at, av.id LIMIT 50`, [cursor.id])
if (rows.rows.length === 0) {
await client.query('COMMIT')
return { cursor: { phase: cursor.phase === 'dated' ? 'undated' : 'done', deletedAt: '-infinity', id: '0' } as ManifestRequeueCursor, scanned: 0, requeued: 0 }
}
const ids = rows.rows.map(row => row.id)
versionIds = ids
// Share the handler/persistence version lock before rechecking references.
// At most 50 locks, with no per-file work or extra database connections.
await client.query(`SELECT pg_catalog.pg_advisory_xact_lock(id)
FROM (SELECT id FROM pg_catalog.unnest($1::bigint[]) AS ids(id) ORDER BY id) AS ordered`, [ids])
const updated = await client.query(`UPDATE public.app_versions AS av
SET manifest_count = 1, updated_at = pg_catalog.now()
WHERE av.id = ANY($1::bigint[]) AND av.deleted = true AND av.manifest_count = 0
AND EXISTS (SELECT 1 FROM public.manifest AS m WHERE m.app_version_id = av.id)`, [ids])
await client.query('COMMIT')
const last = rows.rows.at(-1)!
return { cursor: { phase: cursor.phase, deletedAt: last.deleted_at ?? '-infinity', id: last.id }, scanned: rows.rows.length, requeued: updated.rowCount ?? 0 }
}
catch (error) {
await client.query('ROLLBACK')
throw Object.assign(error as Error, { versionIds })
}
}
async function main() {
const databaseUrl = env.SUPABASE_DB_URL
if (!databaseUrl)
throw new Error('Set SUPABASE_DB_URL explicitly')
const database = createHash('sha256').update(databaseUrl).digest('hex')
const checkpointPath = env.MANIFEST_REQUEUE_CHECKPOINT ?? join(tmpdir(), `manifest-requeue-${database.slice(0, 12)}.json`)
let cursor = INITIAL_MANIFEST_REQUEUE_CURSOR
try {
const saved = JSON.parse(await readFile(checkpointPath, 'utf8'))
if (saved.database !== database)
throw new Error('Checkpoint belongs to another database')
if (!['dated', 'undated', 'done'].includes(saved.cursor?.phase)
|| typeof saved.cursor.deletedAt !== 'string'
|| typeof saved.cursor.id !== 'string' || !/^\d+$/.test(saved.cursor.id)) {
throw new Error('Invalid checkpoint cursor')
}
cursor = saved.cursor
}
catch (error: any) {
if (error.code !== 'ENOENT')
throw error
}
const client = new Client({ connectionString: databaseUrl })
await client.connect()
try {
while (cursor.phase !== 'done') {
const progress = await requeueDeletedManifestBatch(client, cursor)
cursor = progress.cursor
await writeFile(`${checkpointPath}.tmp`, JSON.stringify({ database, cursor }), { mode: 0o600 })
await rename(`${checkpointPath}.tmp`, checkpointPath)
console.log(JSON.stringify(progress))
}
}
catch (error: any) {
// Keep the previous committed checkpoint; report the failed key range so
// an operator can inspect it before resuming. Do not swallow failed work.
const report = JSON.stringify({ cursor, versionIds: error.versionIds, code: error.code, message: error.message })
await writeFile(`${checkpointPath}.failed.json`, report, { mode: 0o600 })
.catch(writeError => console.error('Failed to write failure report', writeError, report))
throw error
}
finally {
await client.end()
}
}
if (import.meta.main)
await main()