Skip to content

Commit 7044cb3

Browse files
authored
Merge pull request choiyounggi#5 from choiyounggi/wiki/postgres-migration-and-vacuum
ingest(databases): online schema changes + autovacuum/wraparound
2 parents a8f2dc4 + fa69035 commit 7044cb3

6 files changed

Lines changed: 125 additions & 2 deletions

File tree

log.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ Append-only. Format: `## [YYYY-MM-DD] <ingest|revise|lint|gap|contradiction|drif
3333
## [2026-07-12] ingest | databases +1 (new category sqlite): concurrent-access-for-a-read-api — WAL + busy_timeout for a read-heavy API with a background writer; set journal_mode once (per-connection pragma cost ~0.4ms measured vs 0.04ms busy_timeout), single writer, run the batch writer as a separate process (interpreter-lock isolation), scale reads with worker processes not threads. Derived from load-testing korea-data-suite (measured connect/query/pragma costs, GIL-bound concurrency, separate-sync-process deployment). Sources: SQLite WAL/pragma/FAQ docs.
3434
## [2026-07-12] revise | security/secrets-in-code +1 edge case: third-party HTTP client (httpx/requests) logs the full request URL — including a query-param API key — at INFO, so root/DEBUG logging leaks it; keep the client logger above INFO. Found when a standalone sync process set logging.basicConfig(INFO) and httpx wrote the data.go.kr serviceKey to the log file. last_verified bumped to 2026-07-12.
3535
## [2026-07-13] ingest | databases +1 (query-optimization): streaming-large-result-sets — memory-bounded export of a huge single-query result. Client-side cursor pulls the whole set to libpq on execute (fetchmany caps only the Python-list explosion); only a server-side/named cursor truly streams but needs a transaction, so it fails under autocommit or a proxy that blocks BEGIN → fall back to client-side fetchmany + disk spool + openpyxl write_only (measured 300k rows 838MB→38MB). Derived from RNR-3440 (potential-listing weekly extract memory peak); QueryPie BEGIN-block generalized to "read-only access proxy", field-tested. Sources: psycopg2 usage/cursor docs (named-cursor WITHOUT HOLD + autocommit exception), openpyxl optimized-modes (write-only near-constant memory, lxml=speed-not-memory).
36+
## [2026-07-23] ingest | databases +2: schema-design/online-schema-changes (ACCESS EXCLUSIVE lock avoidance — non-volatile default fast path, ADD CONSTRAINT NOT VALID + VALIDATE at SHARE UPDATE EXCLUSIVE, CHECK-NOT-NULL trick, CREATE INDEX CONCURRENTLY, expand-and-contract to decouple DB migration from app deploy, lock_timeout for lock-queue pile-up) + operations/autovacuum-and-wraparound (NEW category operations: per-table scale_factor/cost_limit tuning for hot tables, age(datfrozenxid)/relfrozenxid + n_dead_tup monitoring, wraparound read-only cliff and superuser VACUUM recovery, VACUUM FULL vs pg_repack). Derived from the Hatchet "Postgres survival guide"; both cross-checked against PostgreSQL official docs (sql-altertable, routine-vacuuming).

wiki/databases/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ Match your situation to a "load when" line; load only matching pages.
3737
| [column-data-types](schema-design/column-data-types.md) | Picking column types: money, time, text, enums, JSON, binary; changing a type on a live table |
3838
| [nullability-and-defaults](schema-design/nullability-and-defaults.md) | Declaring column nullability/defaults; queries dropping rows around NULLs |
3939
| [soft-delete](schema-design/soft-delete.md) | Deleted records themselves must be restorable or kept (deleted_at schemas); deciding what a parent's deletion does to children that must survive (for who-changed-what history → requirements-to-tables) |
40+
| [online-schema-changes](schema-design/online-schema-changes.md) | Running ALTER TABLE / CREATE INDEX on a large table under live traffic; a migration blocks reads/writes (ACCESS EXCLUSIVE); adding a column/constraint/NOT NULL/index/type change safely; expand-and-contract to decouple DB migration from app deploy |
41+
42+
## operations
43+
44+
| Page | Load when |
45+
|------|-----------|
46+
| [autovacuum-and-wraparound](operations/autovacuum-and-wraparound.md) | A write-heavy table bloats or slows over time; tuning autovacuum for a hot table; monitoring/preventing transaction-ID wraparound (age(datfrozenxid)); the database starts refusing writes to avoid wraparound; deciding VACUUM vs VACUUM FULL vs pg_repack |
4047

4148
## sqlite
4249

wiki/databases/indexing/index-write-cost.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ sources:
88
- https://www.postgresql.org/docs/current/indexes.html
99
- https://use-the-index-luke.com/
1010
last_verified: 2026-07-10
11-
related: [databases-indexing-index-selection, databases-indexing-covering-indexes]
11+
related: [databases-indexing-index-selection, databases-indexing-covering-indexes, databases-schema-design-online-schema-changes, databases-operations-autovacuum-and-wraparound]
1212
---
1313

1414
# Budgeting Index Maintenance Cost
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
id: databases-operations-autovacuum-and-wraparound
3+
domain: databases
4+
category: operations
5+
applies_to: [postgresql]
6+
confidence: verified
7+
sources:
8+
- https://www.postgresql.org/docs/current/routine-vacuuming.html
9+
last_verified: 2026-07-23
10+
related: [databases-indexing-index-write-cost, databases-query-optimization-reading-execution-plans, databases-schema-design-online-schema-changes]
11+
---
12+
13+
# Keeping Autovacuum Ahead of a Write-Heavy Table (and Off the Wraparound Cliff)
14+
15+
## When this applies
16+
17+
A table takes sustained `UPDATE`/`DELETE`/`INSERT` traffic in production. Two
18+
failure modes grow silently: **bloat** (dead tuples autovacuum hasn't reclaimed,
19+
so scans and indexes slow down) and **transaction-ID wraparound** (unfrozen old
20+
rows approaching the 32-bit XID limit). Wraparound is the severe one: at the
21+
cliff, Postgres refuses all writes cluster-wide until you vacuum. Default
22+
autovacuum is tuned for average tables and can fall behind a hot one.
23+
24+
## Do this
25+
26+
1. **Monitor both clocks on a schedule** (weekly is enough until a table is hot):
27+
28+
| Signal | Query | Act when |
29+
|--------|-------|----------|
30+
| Wraparound age | `SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;` | Investigate above ~150M; `autovacuum_freeze_max_age` default is 200M, and aggressive autovacuum should hold it well below that |
31+
| Per-table freeze age | `age(relfrozenxid)` from `pg_stat_user_tables` | A single table climbing while others are flat means autovacuum can't finish it (long txn holding it back, or cost limits too low) |
32+
| Bloat | `n_dead_tup`, `last_autovacuum` from `pg_stat_user_tables` | Dead ratio stays high and `last_autovacuum` is stale → autovacuum isn't triggering often enough |
33+
34+
2. **Tune the hot table specifically, not the cluster.** Autovacuum triggers at
35+
`threshold + scale_factor × rows`; the default `autovacuum_vacuum_scale_factor`
36+
of 0.2 means a 1M-row table must reach ~200k dead tuples first. Lower it
37+
per-table so it fires earlier:
38+
`ALTER TABLE t SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_cost_limit = 2000);`
39+
The default cost limit (200) with a 2ms delay throttles I/O and is often why
40+
autovacuum can't keep up on a busy table.
41+
42+
3. **Set `log_autovacuum_min_duration = 0`** so every autovacuum is logged — you
43+
need the record to see whether it's running and how long it takes.
44+
45+
## Edge cases
46+
47+
| Case | Then |
48+
|------|------|
49+
| An autovacuum on one table runs for over an hour or never seems to finish | Its cost limits are too low for the table's churn, or a long-running transaction / idle-in-transaction session is holding `xmin` back so nothing can be frozen — find it in `pg_stat_activity` and end it |
50+
| Insert-only table (append log) never gets vacuumed, then triggers a huge aggressive freeze | Insert-triggered autovacuum (PG13+) helps, but also lower the table's `autovacuum_freeze_min_age` so ordinary vacuums freeze rows early and spread the work |
51+
| Wraparound warning already in the log ("must be vacuumed within N transactions") | Run a plain database-wide `VACUUM` (not `VACUUM FULL` — it needs an XID and fails; not `VACUUM FREEZE` — it does more than needed). First clear what pins `xmin`: long transactions, orphaned prepared transactions (`pg_prepared_xacts`), and stale replication slots (`pg_replication_slots`) |
52+
| Database already refusing writes (read-only, ~3M XIDs left) | Only `VACUUM` and reads work. Vacuum in single-user mode if needed; this is an incident, so the real fix is the monitoring above so you never reach it |
53+
| `VACUUM FULL` proposed to reclaim space on a live table | It takes `ACCESS EXCLUSIVE` and rewrites the whole table — an outage. Prefer `pg_repack` for online bloat reclamation; reserve `VACUUM FULL` for a maintenance window |
54+
55+
## Sources
56+
57+
- https://www.postgresql.org/docs/current/routine-vacuuming.html — vacuum's four jobs, wraparound mechanics and thresholds, autovacuum trigger formula and cost settings, recovery steps and the VACUUM FULL/FREEZE caveats

wiki/databases/schema-design/column-data-types.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ sources:
99
- https://wiki.postgresql.org/wiki/Don%27t_Do_This
1010
- https://dev.mysql.com/doc/refman/8.0/en/data-types.html
1111
last_verified: 2026-07-10
12-
related: [databases-schema-design-primary-key-choice, databases-schema-design-nullability-and-defaults, databases-schema-design-requirements-to-tables]
12+
related: [databases-schema-design-primary-key-choice, databases-schema-design-nullability-and-defaults, databases-schema-design-requirements-to-tables, databases-schema-design-online-schema-changes]
1313
---
1414

1515
# Choosing Column Data Types
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
id: databases-schema-design-online-schema-changes
3+
domain: databases
4+
category: schema-design
5+
applies_to: [postgresql]
6+
confidence: verified
7+
sources:
8+
- https://www.postgresql.org/docs/current/sql-altertable.html
9+
last_verified: 2026-07-23
10+
related: [databases-schema-design-column-data-types, databases-schema-design-foreign-keys-and-referential-actions, databases-indexing-index-write-cost, databases-schema-design-nullability-and-defaults]
11+
---
12+
13+
# Applying Schema Changes to a Live Table Without Blocking Writes
14+
15+
## When this applies
16+
17+
Running `ALTER TABLE` / `CREATE INDEX` against a table that takes concurrent
18+
production traffic, on a table large enough that a full scan or rewrite is not
19+
instant. Most `ALTER TABLE` forms take an `ACCESS EXCLUSIVE` lock — it blocks
20+
reads *and* writes for the whole operation, so a multi-second rewrite is a
21+
multi-second outage. The goal: make each change either instant-under-lock or
22+
lock-light.
23+
24+
## Do this
25+
26+
Split every risky change into an instant metadata step plus a concurrent/validated
27+
step. Pick the row for your change:
28+
29+
| Change | Do |
30+
|--------|----|
31+
| Add a column with a default | Postgres 11+ stores a **non-volatile** default as metadata — instant, no rewrite. Keep the default constant (`DEFAULT now()` is fine; `DEFAULT clock_timestamp()`, a stored generated column, or an identity column force a full rewrite) |
32+
| Add a `CHECK` or `FOREIGN KEY` constraint | Add it `NOT VALID` (skips the scan, takes the lock only briefly), then `ALTER TABLE ... VALIDATE CONSTRAINT` in a separate statement — validation takes only `SHARE UPDATE EXCLUSIVE`, so writes continue |
33+
| Make a column `NOT NULL` | First add `CHECK (col IS NOT NULL) NOT VALID`, `VALIDATE` it, then `SET NOT NULL` — a valid CHECK proving no NULLs lets `SET NOT NULL` skip its table scan |
34+
| Add an index | `CREATE INDEX CONCURRENTLY` — builds without an `ACCESS EXCLUSIVE` lock ([databases-indexing-index-write-cost]). Never inside a transaction block |
35+
| Add a foreign key | `ADD FOREIGN KEY` needs only `SHARE ROW EXCLUSIVE` (not ACCESS EXCLUSIVE), but still add it `NOT VALID` + `VALIDATE` to avoid the reference scan under lock |
36+
| Rename/drop a column, or change a type | These force `ACCESS EXCLUSIVE` (and a type change rewrites the whole table by default — see the binary-coercible exception in Edge cases). Use expand-and-contract instead of an in-place change (see below) |
37+
38+
**Expand and contract** — for renames, type changes, and column splits, never
39+
mutate in place under load. Expand: add the new column/table (instant steps
40+
above) and dual-write from the app. Migrate: backfill old rows in batches.
41+
Contract: switch reads to the new shape, then drop the old column in a final
42+
quick `ACCESS EXCLUSIVE` step. This also decouples the deploy: the app tolerates
43+
both shapes across the window, so DB migration and app release need not be atomic.
44+
45+
## Edge cases
46+
47+
| Case | Then |
48+
|------|------|
49+
| Type change where old type is binary-coercible to new (e.g. `text``varchar`, no collation change) | No rewrite, and indexes are not rebuilt — the fast path. Verify against your exact types before assuming instant |
50+
| `CREATE INDEX CONCURRENTLY` fails midway | It leaves an `INVALID` index that still costs writes; `DROP INDEX` it and retry, don't leave it |
51+
| The brief `ACCESS EXCLUSIVE` step queues behind a long-running query | The `ALTER` waits for the lock *and every query that arrives behind it also waits* — a lock queue pile-up. Set a short `lock_timeout` on the DDL and retry, so it backs off instead of freezing traffic |
52+
| Batched backfill during expand | Keep each batch a short transaction and throttle — a single `UPDATE` over the whole table holds row locks and generates dead tuples faster than autovacuum clears them ([databases-operations-autovacuum-and-wraparound]) |
53+
| Adding a volatile default / generated / identity column is unavoidable | It rewrites the table under `ACCESS EXCLUSIVE`; schedule it as a maintenance-window operation, not a live deploy |
54+
55+
## Sources
56+
57+
- https://www.postgresql.org/docs/current/sql-altertable.html — lock levels per ALTER form, NOT VALID / VALIDATE, non-volatile default fast path, type-change rewrite rules
58+
- https://www.postgresql.org/docs/current/sql-createindex.html — CREATE INDEX CONCURRENTLY lock behavior

0 commit comments

Comments
 (0)