PostgreSQL WAL Streaming Validation
Continuous archiving only protects you if the archived write-ahead log is complete and replayable, so this page builds — as a concrete instance of async batching for large datasets — a Python pipeline that validates a stream of archived PostgreSQL WAL segments in bounded async batches: it enumerates segments, proves the LSN timeline is contiguous with no gap, checksums each segment against its archive manifest exactly as the surrounding checksum validation pipeline expects, and replay-tests a sample so a corrupt record is caught before a real recovery needs it. Concurrency is bounded with an asyncio.Semaphore so a multi-terabyte archive is verified without exhausting file handles or memory, the continuity check is what guarantees a point-in-time restore can reach the recovery target set by your RTO/RPO mapping, and every fault it finds — a missing segment, a checksum divergence, a failed replay — is emitted with a reason that feeds the error categorization framework.
Architecture and Execution Model
Figure. Archived WAL segments are enumerated, checked for an unbroken LSN timeline, checksummed under bounded concurrency, sampled for replay, and reduced to a gating exit code and a coverage metric.
WAL validation is unlike hashing an opaque blob because the segments carry a strict order that the filename itself encodes. A WAL filename is 24 hex characters — an 8-digit timeline id, then the logical and segment portions of the log sequence number — so continuity is checkable purely from the sorted names before a single byte is read: the successor of ...000000A2 on a 16 MB segment size is ...000000A3, and any break is a hole in the recoverable timeline. The pipeline exploits that by doing the cheap continuity pass first and failing fast on a gap, then spending the expensive checksum-and-replay work only on an archive already known to be contiguous. The asyncio.Semaphore caps how many segments are open and hashing at once, so peak resource use is a function of the concurrency limit, not the thousands of segments a busy database emits between base backups; timeline history files (.history) are read separately to confirm which timeline the segments belong to after any promotion. That staging matters for cost as much as correctness: hashing every 16 MB segment of an archive that spans days of activity is gigabytes of I/O, and there is no reason to pay it for an archive a filename scan has already proven unrecoverable. The design also assumes the archiver has finished writing each segment — PostgreSQL renames a segment into place only once it is fully archived, so the validator treats a partially written current segment as out of scope and works exclusively from completed files.
Prerequisites
- Python 3.10+ — the pipeline uses
asyncio.to_threadand structuredasyncio.gather; both are stable from 3.9/3.10 onward. - The archived WAL directory and a manifest: a JSON map of
segment_name -> sha256captured when each segment was archived. Without it the pipeline runs in continuity-only mode. - For the replay test: either a scratch PostgreSQL instance reachable via
pip install asyncpg==0.29.*, or thepg_waldumpbinary onPATHfor a decode-only check that reads each record without applying it. - Read access to the archive (local path, or a mounted object-store bucket). The validator never touches the primary; it works entirely from the archived segments and their manifest.
- The configured segment size (default 16 MB). A PostgreSQL cluster initialized with a non-default
--wal-segsizechanges the LSN arithmetic, so pass it explicitly.
Production Implementation
The pipeline enumerates segments, runs the continuity check synchronously (it is cheap and gating), then fans out checksum verification under a semaphore, and finally replay-tests a sample. It exits 0 when the archive is contiguous and every checked segment is valid, 1 on any gap, checksum divergence, or replay failure, and 2 on a usage or manifest error. It runs as-is under python3 wal_validate.py <archive_dir>.
#!/usr/bin/env python3
"""wal_validate.py — validate archived PostgreSQL WAL segments in async batches.
Enumerate segments, prove the LSN timeline is contiguous, checksum each segment
against the archive manifest under bounded concurrency, and replay-test a
sample. Exit codes gate the DR drill:
0 contiguous timeline and every checked segment valid -> archive is sound
1 gap, checksum divergence, or replay failure -> halt recovery
2 usage / unreadable manifest or archive -> abort
"""
import argparse
import asyncio
import hashlib
import json
import logging
import os
import sys
from typing import Dict, List, Optional, Tuple
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_USAGE = 2
SEGMENT_NAME_LEN = 24 # 8 hex timeline + 8 hex logical + 8 hex segment
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
logger = logging.getLogger("wal_validate")
def is_wal_segment(name: str) -> bool:
"""A WAL segment filename is exactly 24 hex characters, no extension."""
return len(name) == SEGMENT_NAME_LEN and all(c in "0123456789ABCDEF" for c in name)
def segment_index(name: str, seg_per_logical: int) -> Tuple[int, int]:
"""Decompose a segment name into (timeline, absolute_segment_index)."""
timeline = int(name[0:8], 16)
logical = int(name[8:16], 16)
seg = int(name[16:24], 16)
return timeline, logical * seg_per_logical + seg
def find_continuity_gaps(names: List[str], seg_per_logical: int) -> List[str]:
"""Return a human-readable gap description for every break in the timeline."""
gaps: List[str] = []
ordered = sorted(names)
for prev, curr in zip(ordered, ordered[1:]):
tl_p, idx_p = segment_index(prev, seg_per_logical)
tl_c, idx_c = segment_index(curr, seg_per_logical)
if tl_c != tl_p:
# A timeline change is legitimate only at a promotion boundary;
# flag it so the operator confirms against the .history file.
gaps.append(f"timeline change {prev} ({tl_p}) -> {curr} ({tl_c})")
elif idx_c != idx_p + 1:
missing = idx_c - idx_p - 1
gaps.append(f"gap of {missing} segment(s) between {prev} and {curr}")
return gaps
async def checksum_segment(path: str, expected: Optional[str],
sem: asyncio.Semaphore) -> Tuple[str, str]:
"""Hash one segment off the event loop, bounded by the semaphore."""
name = os.path.basename(path)
def _hash() -> str:
digest = hashlib.sha256()
with open(path, "rb", buffering=0) as fh:
for block in iter(lambda: fh.read(4 * 1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
async with sem:
try:
live = await asyncio.to_thread(_hash)
except OSError as exc:
logger.error("read failed for %s: %s", name, exc)
return name, "UNREADABLE"
if expected is None:
return name, "UNKNOWN"
return name, ("VALID" if live == expected else "DIGEST_MISMATCH")
async def replay_test(path: str, sem: asyncio.Semaphore) -> Tuple[str, str]:
"""Decode a segment's records with pg_waldump; a clean decode proves the
segment is structurally replayable without mutating any cluster."""
name = os.path.basename(path)
async with sem:
proc = await asyncio.create_subprocess_exec(
"pg_waldump", path,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode == 0:
return name, "REPLAYABLE"
logger.error("replay decode failed for %s: %s", name, stderr.decode(errors="replace").strip())
return name, "REPLAY_FAILED"
async def run(args: argparse.Namespace) -> int:
try:
entries = [e for e in os.listdir(args.archive_dir) if is_wal_segment(e)]
except OSError as exc:
logger.error("cannot list archive %s: %s", args.archive_dir, exc)
return EXIT_USAGE
if not entries:
logger.error("no WAL segments found in %s", args.archive_dir)
return EXIT_USAGE
manifest: Dict[str, str] = {}
if args.manifest:
try:
with open(args.manifest, "r", encoding="utf-8") as fh:
manifest = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
logger.error("cannot load manifest %s: %s", args.manifest, exc)
return EXIT_USAGE
seg_per_logical = (0x100000000 // args.segsize)
gaps = find_continuity_gaps(entries, seg_per_logical)
if gaps:
for gap in gaps:
logger.error("CONTINUITY %s", gap)
logger.error("%d timeline break(s); archive is not continuously recoverable", len(gaps))
return EXIT_FAILED
logger.info("timeline contiguous across %d segments", len(entries))
sem = asyncio.Semaphore(args.concurrency)
checksum_tasks = [
checksum_segment(os.path.join(args.archive_dir, name), manifest.get(name), sem)
for name in sorted(entries)
]
results = await asyncio.gather(*checksum_tasks)
bad = [(n, s) for n, s in results if s in {"DIGEST_MISMATCH", "UNREADABLE"}]
sample = sorted(entries)[:: max(1, len(entries) // args.sample)] if args.sample else []
replay_results: List[Tuple[str, str]] = []
if sample and not args.no_replay:
replay_tasks = [replay_test(os.path.join(args.archive_dir, n), sem) for n in sample]
replay_results = await asyncio.gather(*replay_tasks)
bad += [(n, s) for n, s in replay_results if s == "REPLAY_FAILED"]
coverage = 100.0 * sum(1 for _, s in results if s == "VALID") / len(results)
logger.info("checksum coverage %.1f%% | replay-tested %d segment(s)",
coverage, len(replay_results))
if bad:
for name, status in bad:
logger.error("FAULT %s %s", status, name)
return EXIT_FAILED
return EXIT_OK
def main() -> int:
parser = argparse.ArgumentParser(description="Validate archived PostgreSQL WAL segments")
parser.add_argument("archive_dir", help="directory of archived WAL segments")
parser.add_argument("--manifest", help="JSON map of segment_name -> sha256")
parser.add_argument("--segsize", type=int, default=16 * 1024 * 1024,
help="WAL segment size in bytes (default 16MiB)")
parser.add_argument("--concurrency", type=int, default=8,
help="max segments open/hashing at once")
parser.add_argument("--sample", type=int, default=10,
help="number of segments to replay-test (0 disables)")
parser.add_argument("--no-replay", action="store_true",
help="skip the pg_waldump replay test")
args = parser.parse_args()
if args.concurrency < 1 or args.segsize < 1:
logger.error("--concurrency and --segsize must be positive")
return EXIT_USAGE
try:
return asyncio.run(run(args))
except KeyboardInterrupt:
logger.error("interrupted")
return EXIT_FAILED
if __name__ == "__main__":
sys.exit(main())
The continuity check gates everything else on purpose: find_continuity_gaps decodes each 24-character name into an absolute segment index and asserts each successor is exactly one higher, so a single missing .done segment surfaces before any expensive hashing runs. A timeline change is not automatically a failure — after a promotion the archive legitimately jumps timelines — but it is flagged so an operator reconciles it against the .history file rather than trusting a silent renumbering. The replay stage samples rather than exhaustively decodes because pg_waldump on every segment of a large archive is prohibitively slow; sampling one segment per stride gives strong probabilistic assurance that records are structurally intact while keeping the whole validation inside the drill window.
Step-by-Step Execution Walkthrough
- Point the validator at the archive.
python3 wal_validate.py /var/lib/pgsql/archive --manifest wal_manifest.json. The continuity pass runs first and exits1immediately on any gap. - Match the segment size. If the source cluster was initialized with a non-default
--wal-segsize, pass--segsizein bytes so the LSN arithmetic is correct; a wrong size fabricates phantom gaps. - Size concurrency to the archive tier. Raise
--concurrencyon fast local storage, lower it against a rate-limited object store so the semaphore keeps open handles bounded. - Tune the replay sample.
--sample 20decodes more segments for higher assurance;--no-replayskips decoding entirely whenpg_waldumpis unavailable and you only need continuity plus checksums. - Branch on the exit code. The orchestrator reads
$?:0marks the archive recoverable,1halts the drill on a gap or fault,2aborts on a missing archive or unreadable manifest.
Verification and Expected Output
A sound archive logs the continuity result, the coverage percentage, and exits 0:
2026-07-05 03:02:11 | INFO | timeline contiguous across 4096 segments
2026-07-05 03:02:19 | INFO | checksum coverage 100.0% | replay-tested 10 segment(s)
$ echo $?
0
A gap in the timeline fails fast, before any hashing, and exits 1:
2026-07-05 03:05:44 | ERROR | CONTINUITY gap of 1 segment(s) between 000000010000000000000A2 and 000000010000000000000A4
2026-07-05 03:05:44 | ERROR | 1 timeline break(s); archive is not continuously recoverable
$ echo $?
1
Success means an exit of 0, a “timeline contiguous” line covering every segment, and 100% checksum coverage with no REPLAY_FAILED faults. Any FAULT DIGEST_MISMATCH or CONTINUITY gap line is a hard stop — the archive cannot guarantee recovery to an arbitrary point in time.
Failure Modes and Troubleshooting
| Symptom | Cause | Remediation |
|---|---|---|
| Phantom gaps on a valid archive | --segsize does not match the source cluster’s --wal-segsize |
Pass the real segment size; the LSN-to-index arithmetic depends on it |
| Every timeline boundary flagged as a break | Archive legitimately spans a promotion | Confirm the .history file explains each timeline change; the flag is informational there |
pg_waldump: command not found |
Binary absent from the validator image | Install the matching PostgreSQL client package, or run with --no-replay |
Many UNKNOWN statuses |
Manifest missing entries for archived segments | Regenerate the manifest so every archived segment has a recorded digest |
| Too many open files mid-run | --concurrency above the process file-handle limit |
Lower --concurrency or raise ulimit -n; the semaphore bounds handles to that value |
| Replay fails only on the newest segment | Partially archived, still being written | Exclude the current segment; validate only fully archived .done segments |
Integration Notes
Wire the validator into orchestration by branching on its exit code, and treat it as the gate that must pass before a point-in-time restore is attempted. In Airflow, a BashOperator fails the DAG on exit 1 so a discontinuous archive never feeds a doomed recovery; a scheduled nightly run captures a coverage metric you trend to catch an archiver that has silently started dropping segments. Route each FAULT and CONTINUITY line into the error categorization framework so a genuine gap escalates while a transient unreadable-segment error is retried, and reconcile the checksum stage against the same checksum validation pipeline that governs your base backups so WAL and base data share one integrity verdict. For the authoritative behavior of continuous archiving and the WAL file naming this pipeline decodes, see the PostgreSQL documentation on continuous archiving; for the concurrency primitives the pipeline relies on, see the Python asyncio documentation.
Frequently Asked Questions
Why check LSN continuity before checksumming the segments?
Continuity is decidable from the sorted filenames alone, at almost no cost, whereas checksumming and replaying thousands of 16 MB segments is expensive. A single missing segment makes the archive non-recoverable regardless of whether every other segment checksums cleanly, so failing fast on a gap avoids wasting the drill window hashing an archive that already cannot support a point-in-time restore.
Is a timeline change in the archive always a failure?
No. After a standby is promoted the archive legitimately advances to a new timeline, and the .history file records the switch LSN. The validator flags every timeline change so an operator can confirm it against that history file, but a change that matches a known promotion is expected. What is never acceptable is a gap within a single timeline, which is an unrecoverable hole.
Why replay-test only a sample instead of every segment?
Decoding every segment of a large archive with pg_waldump is too slow to fit a drill window. Sampling one segment per stride gives strong probabilistic assurance that records are structurally intact and decodable, which combined with a full checksum pass over every segment covers both bit-level corruption and structural damage without an exhaustive replay.
Does the semaphore actually bound memory on a huge archive?
Yes. The asyncio.Semaphore caps how many segments are open and hashing concurrently, so peak file handles and buffer memory are a function of the concurrency limit rather than the total segment count. An archive with tens of thousands of segments validates with the same resource ceiling as a small one, which is the whole point of bounded async batching.
Related
- Async Batching for Large Datasets — the parent workflow this WAL pipeline instantiates for streaming archives.
- MongoDB Oplog Batch Validation — the sibling pipeline that applies the same bounded-batch pattern to an oplog window.
- Checksum Validation Pipelines — the integrity layer the per-segment checksum stage reconciles against.
- Continuous Replication Validation — the broader replication-health context that WAL continuity feeds.
This page is one component of the broader Async Batching for Large Datasets workflow; from there you can move up to the Automated Backup Integrity Check Implementation overview.