Debugging Corruption
WAL corruption is every DBA’s nightmare: the log that guarantees recovery itself is damaged. This page covers systematic diagnosis with PostgreSQL tools, recovery procedures, and a detailed case study of the SQLite WAL-Reset bug — a race condition latent for 16 years that caused 19 production incidents at Tailscale.
Detecting WAL Corruption
Section titled “Detecting WAL Corruption”pg_waldump
Section titled “pg_waldump”The primary offline WAL inspection tool:
# Decode WAL records from a segmentpg_waldump 000000010000000000000001
# Verify checksums on all recordspg_waldump --verify-checksums 000000010000000000000001
# Stop at first error (don't flood output)pg_waldump --verify-checksums --stop-on-error pg_wal/
# Filter by resource managerpg_waldump --rmgr=Heap2 pg_wal/000000010000000000000001
# Show records after a specific LSNpg_waldump --start=0/3000000 pg_wal/000000010000000000000001Sample corruption output:
pg_waldump: error: invalid record length at 0/1A2B3C0: wanted 256, got 0pg_waldump: error: record with incorrect prev-link at 0/1A2B3C8pg_waldump: error: checksum mismatch at 0/1A2B3D0: calculated 0xABCD1234, recorded 0xDEADBEEFpg_walinspect (SQL Functions)
Section titled “pg_walinspect (SQL Functions)”PostgreSQL 15+ provides SQL-accessible WAL inspection:
-- List records in a WAL rangeSELECT start_lsn, end_lsn, prev_lsn, xid, resource_manager, record_typeFROM pg_wal_summary( '0/1000000'::pg_lsn, '0/2000000'::pg_lsn);
-- Decode a specific recordSELECT * FROM pg_get_wal_records_info( '0/1500000'::pg_lsn, '0/1600000'::pg_lsn);
-- Find the last valid record before corruptionSELECT start_lsn, end_lsn, resource_manager, record_typeFROM pg_get_wal_records_info('0/0'::pg_lsn, pg_current_wal_lsn())ORDER BY start_lsn DESCLIMIT 10;Corruption Recovery Procedure
Section titled “Corruption Recovery Procedure”flowchart TD
A[WAL Corruption Detected] --> B[Stop PostgreSQL]
B --> C[Identify last valid LSN<br/>pg_waldump --stop-on-error]
C --> D{Have base backup<br/>+ archived WAL?}
D -->|Yes| E[PITR to last valid LSN]
D -->|No| F{Corruption in<br/>current segment?}
F -->|Yes| G[Truncate WAL after<br/>last valid record]
F -->|No| H[pg_resetwal<br/>LAST RESORT]
E --> I[Start & verify]
G --> I
H --> I
Step 1: Identify Last Valid LSN
Section titled “Step 1: Identify Last Valid LSN”# Find the corruption pointpg_waldump --verify-checksums --stop-on-error pg_wal/ > /tmp/wal_dump.txt 2>&1
# Last valid record LSN is in the output before the errortail -20 /tmp/wal_dump.txt# rmgr: Heap2 len (rec/tot): 64/ 64 tx: 1234 lsn: 0/1A2B3C0# pg_waldump: error: invalid record length at 0/1A2B3D0# → Last valid LSN: 0/1A2B3C0Step 2: PITR Recovery (Preferred)
Section titled “Step 2: PITR Recovery (Preferred)”# Restore base backuptar xzf base_backup.tar.gz -C $PGDATA
# Configure recovery to stop at last valid LSNcat >> $PGDATA/postgresql.conf <<EOFrestore_command = 'cp /archive/%f %p'recovery_target_lsn = '0/1A2B3C0'recovery_target_action = 'promote'EOF
touch $PGDATA/recovery.signalpg_ctl startStep 3: pg_resetwal (Last Resort)
Section titled “Step 3: pg_resetwal (Last Resort)”# Dry run — see what would happenpg_resetwal -n $PGDATA
# Force reset (requires PostgreSQL stopped)pg_resetwal -f $PGDATA
# Then start and immediately take a new base backuppg_ctl startpg_basebackup -D /backup/post-reset -Fp -XsCase Study: The SQLite WAL-Reset Bug (2026)
Section titled “Case Study: The SQLite WAL-Reset Bug (2026)”One of the most significant WAL bugs discovered in recent years — a race condition in SQLite’s WAL mode that was latent for 16 years before causing production incidents.
The Bug
Section titled “The Bug”SQLite WAL mode uses a -wal file alongside the main database. During checkpoint, SQLite:
- Copies WAL frames back to the database file
- Resets the WAL file (truncates to zero)
- Updates the WAL header
The race condition occurs when a concurrent writer appends to the WAL between steps 2 and 3:
Timeline (buggy):Writer A: BEGIN → write frame 1 to WALCheckpoint: copy frame 1 to DB → RESET WAL (truncate to 0)Writer B: BEGIN → write frame 1 to WAL (at offset 0 — looks valid!)Checkpoint: update WAL header (frame count = 0, but frame 1 exists!)Writer B: COMMIT → frame 1 appears committed but checkpoint already ranResult: Frame 1 is INVISIBLE — data silently lostsequenceDiagram
participant WA as Writer A
participant CP as Checkpoint
participant WB as Writer B
participant DB as Database File
WA->>DB: Write frame to WAL
CP->>DB: Copy WAL frames to DB
CP->>DB: RESET WAL (truncate)
Note over CP: WAL header: frame count = 0
WB->>DB: Write frame to WAL (offset 0)
CP->>DB: Update WAL header
Note over WB: Frame exists but count = 0
WB->>DB: COMMIT
Note over DB: Data silently lost!
Impact
Section titled “Impact”| Metric | Value |
|---|---|
| Bug latent since | SQLite 3.7.0 (2010) — WAL mode introduction |
| Discovered by | Antithesis (deterministic testing) |
| Repro time | 15 minutes (Antithesis) |
| Tailscale incidents | 19 production data loss events |
| Reproducer | Phil Eaton published minimal C reproducer |
| Fix | SQLite 3.x.x — serialize WAL reset with writer lock |
Tailscale’s Experience
Section titled “Tailscale’s Experience”Tailscale uses SQLite (via gorm/modernc.org/sqlite) for coordination state. The bug manifested as:
- Nodes losing registration state after checkpoint
- Intermittent — depends on write/checkpoint timing
- No error returned — silent data loss
- Difficult to diagnose because WAL file looks structurally valid
Antithesis Discovery
Section titled “Antithesis Discovery”Antithesis runs distributed systems in a deterministic simulator, injecting faults (crash, partition, delay). Their approach:
- Run SQLite with concurrent writers + aggressive checkpointing
- Inject thread scheduling variations
- Compare database state after recovery against expected state
- Found divergence in 15 minutes — the WAL-Reset race
Phil Eaton’s Reproducer
Section titled “Phil Eaton’s Reproducer”A minimal C program demonstrating the bug:
// Simplified reproducer logic (Phil Eaton, 2026)// Thread 1: continuous writeswhile (running) { sqlite3_exec(db, "INSERT INTO t VALUES (?)", ...); sqlite3_exec(db, "COMMIT", ...);}
// Thread 2: aggressive checkpointingwhile (running) { sqlite3_wal_checkpoint_v2(db, NULL, SQLITE_CHECKPOINT_RESTART, ...); usleep(100); // checkpoint every 100µs}
// After N iterations: SELECT count(*) != expected count// Data silently lost — no error, no corruption detectedLessons Learned
Section titled “Lessons Learned”- WAL reset is a critical section — must be serialized with all writers
- Silent data loss is worse than corruption — checksums can’t detect logically missing records
- 16 years latent — concurrency bugs in WAL paths require deterministic testing to find
- Aggressive checkpointing increases risk — production checkpoint schedules matter
- Test your PITR — if Tailscale had tested recovery, they might have found state divergence earlier
- Deterministic simulation > fuzzing for WAL race conditions — Antithesis found it in 15 minutes
Corruption Prevention Checklist
Section titled “Corruption Prevention Checklist”| Practice | Protects Against |
|---|---|
full_page_writes = on |
Torn pages |
| WAL checksums (PG 11+) | Bit rot, partial writes |
wal_compression |
Reduced I/O volume (fewer corruption opportunities) |
| Separate WAL disk | I/O interference corruption |
| Regular PITR testing | Undetected silent loss |
Monitor pg_stat_wal |
Early warning of anomalous generation |
Avoid fsync=off |
Buffer cache corruption on crash |
Key Takeaways
Section titled “Key Takeaways”- pg_waldump –verify-checksums –stop-on-error is your first diagnostic tool
- PITR to last valid LSN is the preferred recovery — pg_resetwal is last resort
- The SQLite WAL-Reset bug was a 16-year latent race between checkpoint reset and concurrent writers
- Silent data loss (no error, no checksum failure) is the hardest WAL bug class to detect
- Deterministic testing (Antithesis) found the bug in 15 minutes; production fuzzing took 16 years
Quick Quiz: Debugging Corruption
-
What pg_waldump flags verify WAL integrity? → –verify-checksums checks CRC on each record; –stop-on-error halts at the first corruption.
-
What is the preferred recovery procedure for WAL corruption? → PITR: restore base backup, replay archived WAL up to the last valid LSN, then promote.
-
When should you use pg_resetwal? → Only as a last resort when PITR is impossible and accepting potential data loss. It creates a fresh WAL timeline.
-
Describe the SQLite WAL-Reset race condition. → A concurrent writer appends to the WAL after checkpoint truncates it but before the header is updated with frame count = 0, making the frame invisible.
-
Why was the WAL-Reset bug so hard to detect in production? → Silent data loss — no error returned, WAL file structurally valid, checksums pass. Data is simply missing.
-
How did Antithesis find the bug so quickly? → Deterministic simulation with controlled thread scheduling, concurrent writes, and aggressive checkpointing — comparing expected vs actual state after recovery.