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

PostgreSQL WAL streaming validation flow A vertical five-stage flow: enumerate the archived WAL segments, check LSN continuity across the timeline, checksum each segment in bounded async batches, replay-test a sample of segments, and emit a POSIX exit code together with a coverage metric. Enumerate WAL segments Check LSN continuity Async-batch checksum Replay-test sample Emit exit code + metric

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_thread and structured asyncio.gather; both are stable from 3.9/3.10 onward.
  • The archived WAL directory and a manifest: a JSON map of segment_name -> sha256 captured 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 the pg_waldump binary on PATH for 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-segsize changes 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>.

python
#!/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

  1. Point the validator at the archive. python3 wal_validate.py /var/lib/pgsql/archive --manifest wal_manifest.json. The continuity pass runs first and exits 1 immediately on any gap.
  2. Match the segment size. If the source cluster was initialized with a non-default --wal-segsize, pass --segsize in bytes so the LSN arithmetic is correct; a wrong size fabricates phantom gaps.
  3. Size concurrency to the archive tier. Raise --concurrency on fast local storage, lower it against a rate-limited object store so the semaphore keeps open handles bounded.
  4. Tune the replay sample. --sample 20 decodes more segments for higher assurance; --no-replay skips decoding entirely when pg_waldump is unavailable and you only need continuity plus checksums.
  5. Branch on the exit code. The orchestrator reads $?: 0 marks the archive recoverable, 1 halts the drill on a gap or fault, 2 aborts on a missing archive or unreadable manifest.

Verification and Expected Output

A sound archive logs the continuity result, the coverage percentage, and exits 0:

text
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:

text
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.

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.