|
| 1 | +import { Pool } from 'pg' |
| 2 | + |
| 3 | +const TOAST_THRESHOLD_BYTES = 1_000_000_000 // 1 GB |
| 4 | +const CHECK_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes |
| 5 | + |
| 6 | +interface ToastInfo { |
| 7 | + schema_name: string |
| 8 | + table_name: string |
| 9 | + toast_table: string |
| 10 | + toast_size: string |
| 11 | + toast_bytes: number |
| 12 | +} |
| 13 | + |
| 14 | +async function checkAndVacuumToast(pool: Pool): Promise<void> { |
| 15 | + const client = await pool.connect() |
| 16 | + try { |
| 17 | + const result = await client.query<ToastInfo>(` |
| 18 | + SELECT |
| 19 | + n.nspname AS schema_name, |
| 20 | + c.relname AS table_name, |
| 21 | + t.relname AS toast_table, |
| 22 | + pg_size_pretty(pg_relation_size(t.oid)) AS toast_size, |
| 23 | + pg_relation_size(t.oid) AS toast_bytes |
| 24 | + FROM pg_class c |
| 25 | + JOIN pg_class t ON t.oid = c.reltoastrelid |
| 26 | + JOIN pg_namespace n ON n.oid = c.relnamespace |
| 27 | + WHERE t.relkind = 't' |
| 28 | + ORDER BY pg_relation_size(t.oid) DESC |
| 29 | + LIMIT 20 |
| 30 | + `) |
| 31 | + |
| 32 | + for (const row of result.rows) { |
| 33 | + console.log(`[toast-vacuum] ${row.schema_name}.${row.table_name}: TOAST ${row.toast_size}`) |
| 34 | + } |
| 35 | + |
| 36 | + const bloated = result.rows.filter((r) => r.toast_bytes > TOAST_THRESHOLD_BYTES) |
| 37 | + for (const row of bloated) { |
| 38 | + console.log( |
| 39 | + `[toast-vacuum] TOAST bloat detected on ${row.schema_name}.${row.table_name} (${row.toast_size}), running VACUUM FULL...`, |
| 40 | + ) |
| 41 | + const start = Date.now() |
| 42 | + await client.query(`VACUUM FULL VERBOSE "${row.schema_name}"."${row.table_name}"`) |
| 43 | + const elapsed = ((Date.now() - start) / 1000).toFixed(1) |
| 44 | + console.log(`[toast-vacuum] VACUUM FULL on ${row.schema_name}.${row.table_name} completed in ${elapsed}s`) |
| 45 | + } |
| 46 | + |
| 47 | + if (bloated.length === 0) { |
| 48 | + console.log('[toast-vacuum] No TOAST bloat detected.') |
| 49 | + } |
| 50 | + } catch (err) { |
| 51 | + console.error('[toast-vacuum] Error during TOAST check:', err) |
| 52 | + } finally { |
| 53 | + client.release() |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +async function configureSessionTimeouts(pool: Pool): Promise<void> { |
| 58 | + const client = await pool.connect() |
| 59 | + try { |
| 60 | + const dbName = process.env.DB_NAME || 'squid' |
| 61 | + await client.query(`ALTER DATABASE "${dbName}" SET idle_in_transaction_session_timeout = '2min'`) |
| 62 | + await client.query(`ALTER DATABASE "${dbName}" SET idle_session_timeout = '5min'`) |
| 63 | + console.log('[toast-vacuum] Configured database session timeouts (idle_in_transaction=2min, idle_session=5min)') |
| 64 | + } catch (err) { |
| 65 | + console.error('[toast-vacuum] Error configuring session timeouts:', err) |
| 66 | + } finally { |
| 67 | + client.release() |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +export function startToastVacuumMonitor(): void { |
| 72 | + const pool = new Pool({ |
| 73 | + host: process.env.DB_HOST, |
| 74 | + port: parseInt(process.env.DB_PORT!), |
| 75 | + database: process.env.DB_NAME, |
| 76 | + user: process.env.DB_USER, |
| 77 | + password: process.env.DB_PASS, |
| 78 | + connectionTimeoutMillis: 60000, |
| 79 | + idleTimeoutMillis: 600000, |
| 80 | + query_timeout: 3600000, // 1 hour — VACUUM FULL can take a while |
| 81 | + statement_timeout: 3600000, |
| 82 | + }) |
| 83 | + |
| 84 | + // Configure database-level session timeouts to prevent idle transactions from blocking VACUUM |
| 85 | + configureSessionTimeouts(pool).then(() => checkAndVacuumToast(pool)).then(() => { |
| 86 | + // Then repeat every 30 minutes |
| 87 | + setInterval(() => { |
| 88 | + checkAndVacuumToast(pool) |
| 89 | + }, CHECK_INTERVAL_MS) |
| 90 | + }) |
| 91 | +} |
0 commit comments