Skip to content

Commit 9411deb

Browse files
authored
fix(db): throttle autovacuum on the largest tables so vacuum WAL bursts stop stalling commits (#9087)
Vacuuming these tables writes WAL, mostly full-page images, faster than the synchronous standbys flush it, so every write commit on the primary waits for a standby until the vacuum ends. A per-table autovacuum cost delay and limit, on each table and its TOAST relation, cap that rate. The ALTERs are catalog-only under SHARE UPDATE EXCLUSIVE: no rewrite, and reads and writes never wait on them, even while one is queued behind a running vacuum. An integration test applies the migration and checks the lock mode, that writes proceed, and that the settings land on both relations.
1 parent e309463 commit 9411deb

4 files changed

Lines changed: 31592 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { applyMigration } from '@sim/db/scripts/migration-fixture'
3+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
4+
import { sleep } from '@sim/utils/helpers'
5+
import { generateId } from '@sim/utils/id'
6+
import postgres, { type Sql } from 'postgres'
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
8+
9+
const databaseUrl = readTestDatabaseUrl()
10+
const TABLES = ['embedding', 'embedding_search', 'workflow_execution_logs'] as const
11+
const THROTTLE = ['autovacuum_vacuum_cost_delay=10', 'autovacuum_vacuum_cost_limit=200']
12+
13+
/**
14+
* Applies 0431 to stand-in tables, each with a TOAST relation, and checks the settings land on
15+
* both relations without the statement ever holding a lock that reads or writes wait on.
16+
*/
17+
describe('large table vacuum throttle migration in PostgreSQL', () => {
18+
let admin: Sql
19+
let migrator: Sql
20+
let writer: Sql
21+
let migration: string
22+
let statements: string[]
23+
const schema = `vacuum_throttle_${generateId().replaceAll('-', '')}`
24+
const rel = (table: string) => `"${schema}"."${table}"`
25+
26+
/** The writer gives up quickly, so a write queued behind the migration fails instead of hanging. */
27+
const connect = (lockTimeoutMs?: number) =>
28+
postgres(databaseUrl, {
29+
max: 1,
30+
connection: {
31+
search_path: schema,
32+
...(lockTimeoutMs ? { lock_timeout: lockTimeoutMs } : {}),
33+
},
34+
onnotice: () => undefined,
35+
})
36+
37+
beforeAll(async () => {
38+
admin = postgres(databaseUrl, { max: 1, onnotice: () => undefined })
39+
await admin.unsafe(
40+
[
41+
`CREATE SCHEMA "${schema}"`,
42+
...TABLES.map((table) => `CREATE TABLE ${rel(table)} (id text PRIMARY KEY, body text)`),
43+
].join(';')
44+
)
45+
migrator = connect()
46+
writer = connect(300)
47+
migration = await readFile(
48+
new URL('./migrations/0431_large_table_vacuum_throttle.sql', import.meta.url),
49+
'utf8'
50+
)
51+
statements = migration
52+
.split('--> statement-breakpoint')
53+
.map((statement) => statement.trim())
54+
.filter(Boolean)
55+
})
56+
57+
afterAll(async () => {
58+
await migrator?.end()
59+
await writer?.end()
60+
if (admin) {
61+
await admin.unsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`)
62+
await admin.end()
63+
}
64+
})
65+
66+
it('takes only SHARE UPDATE EXCLUSIVE and rewrites nothing, so reads and writes proceed', async () => {
67+
const relfilenode = async () =>
68+
(
69+
await admin<{ node: string }[]>`
70+
SELECT relfilenode::text AS node FROM pg_class WHERE oid = ${rel('embedding')}::regclass`
71+
)[0]?.node
72+
const before = await relfilenode()
73+
74+
await migrator.begin(async (tx) => {
75+
await tx.unsafe(statements[0])
76+
const locks = await tx<{ mode: string }[]>`
77+
SELECT mode FROM pg_locks
78+
WHERE pid = pg_backend_pid() AND locktype = 'relation'
79+
AND relation = ${rel('embedding')}::regclass`
80+
expect(locks.map((lock) => lock.mode)).toEqual(['ShareUpdateExclusiveLock'])
81+
82+
// Another session reads and writes the table while the migration transaction holds it.
83+
await writer`INSERT INTO embedding (id, body) VALUES ('during', 'x')`
84+
expect(await writer`SELECT id FROM embedding`).toHaveLength(1)
85+
})
86+
87+
expect(await relfilenode()).toBe(before)
88+
})
89+
90+
it('does not queue writers behind it while it waits for a conflicting holder', async () => {
91+
const holder = connect()
92+
let releaseHolder = () => {}
93+
const released = new Promise<void>((resolve) => {
94+
releaseHolder = resolve
95+
})
96+
try {
97+
let holderLocked = () => {}
98+
const locked = new Promise<void>((resolve) => {
99+
holderLocked = resolve
100+
})
101+
const holding = holder.begin(async (tx) => {
102+
await tx.unsafe('LOCK TABLE embedding_search IN SHARE UPDATE EXCLUSIVE MODE')
103+
holderLocked()
104+
await released
105+
})
106+
await locked
107+
108+
// postgres.js sends a query only once it is awaited or executed.
109+
const waiting = migrator.unsafe(statements[1]).execute()
110+
let queued: { mode: string }[] = []
111+
for (let attempt = 0; attempt < 100 && queued.length === 0; attempt++) {
112+
queued = await admin<{ mode: string }[]>`
113+
SELECT mode FROM pg_locks
114+
WHERE NOT granted AND locktype = 'relation'
115+
AND relation = ${rel('embedding_search')}::regclass`
116+
if (queued.length === 0) await sleep(10)
117+
}
118+
expect(queued.map((lock) => lock.mode)).toEqual(['ShareUpdateExclusiveLock'])
119+
120+
await writer`INSERT INTO embedding_search (id, body) VALUES ('queued', 'x')`
121+
122+
releaseHolder()
123+
await holding
124+
await waiting
125+
} finally {
126+
releaseHolder()
127+
await holder.end()
128+
}
129+
})
130+
131+
it('sets the throttle on every table and its TOAST relation', async () => {
132+
// The whole file, so this holds on its own; reapplying the same settings is a no-op.
133+
await applyMigration(migrator, migration)
134+
const rows = await admin<{ heap: string[] | null; toast: string[] | null }[]>`
135+
SELECT c.reloptions AS heap, t.reloptions AS toast
136+
FROM pg_class c JOIN pg_class t ON t.oid = c.reltoastrelid
137+
WHERE c.oid = ANY(${TABLES.map(rel)}::regclass[])`
138+
expect(rows).toHaveLength(TABLES.length)
139+
for (const row of rows) {
140+
expect(row.heap).toEqual(expect.arrayContaining(THROTTLE))
141+
expect(row.toast).toEqual(expect.arrayContaining(THROTTLE))
142+
}
143+
})
144+
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- migration-safe: table-local maintenance settings only; a catalog update under SHARE UPDATE EXCLUSIVE, which reads and writes do not wait on, with no row rewrite.
2+
-- Vacuuming these tables writes WAL, mostly full-page images, faster than the synchronous standbys
3+
-- flush it, so every write commit on the primary waits on a standby for as long as the vacuum runs.
4+
-- A per-table cost delay and limit cap that rate. This reverses 0362's choice for these tables
5+
-- only: they leave cross-worker I/O balancing and vacuum more slowly, the lesser cost here. The
6+
-- TOAST values would be inherited, but are set explicitly because the TOAST vacuums are the largest.
7+
ALTER TABLE "embedding" SET (autovacuum_vacuum_cost_delay = 10, autovacuum_vacuum_cost_limit = 200, toast.autovacuum_vacuum_cost_delay = 10, toast.autovacuum_vacuum_cost_limit = 200);--> statement-breakpoint
8+
ALTER TABLE "embedding_search" SET (autovacuum_vacuum_cost_delay = 10, autovacuum_vacuum_cost_limit = 200, toast.autovacuum_vacuum_cost_delay = 10, toast.autovacuum_vacuum_cost_limit = 200);--> statement-breakpoint
9+
ALTER TABLE "workflow_execution_logs" SET (autovacuum_vacuum_cost_delay = 10, autovacuum_vacuum_cost_limit = 200, toast.autovacuum_vacuum_cost_delay = 10, toast.autovacuum_vacuum_cost_limit = 200);

0 commit comments

Comments
 (0)