Point-in-Time Targeting for PostgreSQL WAL

This page implements one concrete task inside point-in-time recovery targeting: a headless Python tool that, given a drill’s target timestamp, resolves the exact PostgreSQL recovery configuration by selecting the base backup to restore, computing the write-ahead-log segment range needed to replay up to that instant, and proving the WAL timeline is continuous the whole way. It runs against the disposable instance that sandbox provisioning stands up, and its output — the recovery_target_time, an optional recovery_target_lsn, recovery_target_timeline, and restore_command — is what that restore consumes. Whether the resolved instant is acceptable is a separate question answered by your RTO/RPO mapping, and the artifacts it plans to replay must already have passed the checksum validation pipeline before a single segment is applied.

Unlike the MongoDB oplog resolver that already exists as a sibling of this page, PostgreSQL recovery is driven by continuous WAL archiving and timeline history files rather than a single oplog collection. A base backup fixes a starting LSN, and recovery replays archived WAL forward from there until it reaches the requested recovery_target_time; if any segment in that range is missing, or a timeline branch is not followed correctly, the replay stops short of the target and the drill silently validates the wrong instant. The tool here refuses to guess: it walks the segment range explicitly, follows every timeline switch recorded in the history, and treats a gap as a hard failure that halts the drill rather than a warning the restore can proceed past.

Architecture and Execution Model

PostgreSQL WAL point-in-time target resolver A five-stage vertical pipeline: resolve the base backup that precedes the target, map the WAL segment range up to the target, validate timeline continuity across any branch, emit the recovery target configuration, and return a POSIX exit code for the orchestrator. Resolve base backup Map WAL range to target Validate timeline continuity Emit recovery target config Exit code for orchestrator

Figure. The resolver selects the base backup preceding the target, maps the WAL segments needed to reach it, verifies the timeline is gap-free across any branch, and emits the recovery settings only when the whole path is proven present.

A WAL segment file name encodes everything the resolver needs: 24 hexadecimal characters, where the first eight are the timeline id and the remaining sixteen are the logical segment number split into a high and low half. With the default 16 MiB segment size there are 256 segments per logical file, so a log sequence number (LSN) maps to a segment by dividing by the segment size, and a segment number renders back to a file name by that same 256-way split. Because segment numbers increase monotonically and continue across a timeline switch, the resolver can treat a recovery target as a contiguous integer range of segment numbers, walk it one at a time, and follow a branch onto a child timeline exactly where the history file says the switch occurred. That arithmetic is the heart of the tool; everything else is catalog lookup and validation around it.

Prerequisites

  • Python 3.9+ (the tool uses dataclasses, datetime.fromisoformat offset handling, and standard typing).

  • The psycopg (v3) driver with its binary build, for the optional live-cluster timeline cross-check:

    bash
    pip install "psycopg[binary]>=3.1"
    
  • A backup catalog describing base backups and archived WAL. The resolver reads a JSON catalog with base_backups (label, timeline, stop time, stop LSN), wal_segments (segment name, end time), and optional timeline_history (parent timeline, child timeline, switch LSN). pgBackRest, Barman, or an in-house catalog over pg_stat_archiver all satisfy this contract.

  • Archived WAL reachable by the restore_command the tool emits. The resolver only plans the recovery; a downstream step applies the emitted settings inside the sandbox.

  • A read-only role on the source cluster if you use --verify-dsn. The cross-check runs SELECT timeline_id FROM pg_control_checkpoint() and nothing else, so the credential can be scoped to read-only.

  • Confirmation that archiving is healthy before scheduling a drill whose target is recent: a target newer than the last archived segment can never resolve, which the tool reports as unreachable rather than guessing.

Production Implementation

The tool separates the arithmetic (LSN-to-segment mapping) from the catalog logic (base-backup selection, range mapping, continuity validation) so each part is independently testable, and it self-tests with an embedded catalog so it runs without any external file. It returns strict POSIX exit codes: 0 when the target resolves and the timeline is continuous, 1 when the target is unreachable, and 2 on a usage or parse error.

python
#!/usr/bin/env python3
"""Compute a PostgreSQL point-in-time recovery target for a restore drill.

Given a target timestamp and a backup catalog, this tool selects the base
backup to restore from, maps the WAL segment range required to replay up to
the target, validates that the WAL timeline is continuous across that range,
and emits the recovery.conf-style settings the sandbox restore will apply.

Exit codes (consumed by the DR drill orchestrator):
    0  target resolved and the WAL timeline is continuous -> proceed
    1  target unreachable: no base backup before it, target beyond the
       archive, or a gap in the WAL timeline                -> halt, escalate
    2  usage / configuration / catalog-parse error          -> abort
"""
from __future__ import annotations

import argparse
import datetime
import json
import sys
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple

WAL_SEGMENT_SIZE = 1 << 24            # 16 MiB, the default segment size
SEGMENTS_PER_LOGID = 1 << 8          # 2**32 / WAL_SEGMENT_SIZE = 256


class Unreachable(Exception):
    """The requested instant cannot be recovered from the catalog."""


@dataclass(frozen=True)
class BaseBackup:
    label: str
    timeline: int
    stop_time: int      # epoch seconds when the backup finished
    stop_lsn: str       # "X/Y" LSN at backup stop


@dataclass(frozen=True)
class WalSegment:
    name: str           # 24 hex chars: timeline + high + low
    end_time: int       # epoch seconds of the last record in the segment


def parse_epoch(iso: str) -> int:
    dt = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=datetime.timezone.utc)
    return int(dt.timestamp())


def lsn_to_segno(lsn: str) -> int:
    """Map an 'X/Y' LSN to its logical WAL segment number."""
    hi, _, lo = lsn.partition("/")
    value = (int(hi, 16) << 32) | int(lo, 16)
    return value >> 24


def segno_to_name(segno: int, timeline: int) -> str:
    """Render a segment number + timeline as a 24-hex WAL file name."""
    return "%08X%08X%08X" % (timeline,
                             segno // SEGMENTS_PER_LOGID,
                             segno % SEGMENTS_PER_LOGID)


def name_to_tli_segno(name: str) -> Tuple[int, int]:
    tli = int(name[0:8], 16)
    seg = int(name[8:16], 16) * SEGMENTS_PER_LOGID + int(name[16:24], 16)
    return tli, seg


def select_base_backup(backups: List[BaseBackup], target: int) -> BaseBackup:
    """Latest base backup that finished at or before the target."""
    candidates = [b for b in backups if b.stop_time <= target]
    if not candidates:
        raise Unreachable(
            f"no base backup finished at or before {target}; "
            "target predates the earliest full backup")
    return max(candidates, key=lambda b: b.stop_time)


def resolve_target_segment(segments: List[WalSegment],
                           target: int) -> Tuple[int, int]:
    """Return (timeline, segno) of the earliest WAL segment covering the target.

    Segments are ordered by end time across every timeline, so a target that
    falls on a branched timeline still resolves to the segment that must be
    replayed to reach it.
    """
    for seg in sorted(segments, key=lambda s: s.end_time):
        if seg.end_time >= target:
            return name_to_tli_segno(seg.name)
    raise Unreachable(
        f"target {target} is beyond the newest archived WAL; the archive "
        "does not yet cover the requested instant")


def validate_continuity(start_segno: int, end_segno: int, timeline: int,
                        present: set, switches: Dict[int, Tuple[int, int]]
                        ) -> Tuple[int, List[str]]:
    """Walk the segment range, following timeline switches, list any gaps."""
    missing: List[str] = []
    current_tli = timeline
    for segno in range(start_segno, end_segno + 1):
        if (current_tli, segno) not in present:
            missing.append(segno_to_name(segno, current_tli))
        switch = switches.get(current_tli)
        if switch and segno == switch[0]:
            current_tli = switch[1]      # branch onto the child timeline
    return current_tli, missing


def build_recovery_config(target_iso: str, timeline: int,
                          restore_command: str,
                          target_lsn: Optional[str]) -> str:
    lines = [
        f"recovery_target_time = '{target_iso}'",
        "recovery_target_inclusive = true",
        f"recovery_target_timeline = '{timeline}'",
        f"restore_command = '{restore_command}'",
    ]
    if target_lsn:
        lines.insert(1, f"recovery_target_lsn = '{target_lsn}'")
    return "\n".join(lines)


def resolve(catalog: dict, target_iso: str, restore_command: str,
            target_lsn: Optional[str]) -> str:
    target = parse_epoch(target_iso)
    backups = [BaseBackup(b["label"], b["timeline"],
                          parse_epoch(b["stop_time"]), b["stop_lsn"])
               for b in catalog["base_backups"]]
    segments = [WalSegment(s["name"], parse_epoch(s["end_time"]))
                for s in catalog["wal_segments"]]
    switches = {h["parent_timeline"]:
                (lsn_to_segno(h["switch_lsn"]), h["timeline"])
                for h in catalog.get("timeline_history", [])}

    base = select_base_backup(backups, target)
    start_segno = lsn_to_segno(base.stop_lsn)
    _end_tli, end_segno = resolve_target_segment(segments, target)
    present = {name_to_tli_segno(s.name) for s in segments}

    final_tli, missing = validate_continuity(
        start_segno, end_segno, base.timeline, present, switches)
    if missing:
        raise Unreachable(
            "WAL timeline is not continuous to the target; missing "
            f"segments: {', '.join(missing[:5])}")

    print(f"BASE_BACKUP {base.label} timeline={base.timeline} "
          f"start_segment={segno_to_name(start_segno, base.timeline)}")
    print(f"WAL_RANGE {segno_to_name(start_segno, base.timeline)}.."
          f"{segno_to_name(end_segno, final_tli)} "
          f"segments={end_segno - start_segno + 1}")
    return build_recovery_config(target_iso, final_tli, restore_command,
                                 target_lsn)


SELFTEST_CATALOG = {
    "base_backups": [
        {"label": "full-0009", "timeline": 1,
         "stop_time": "2024-11-15T12:00:00Z", "stop_lsn": "0/3000000"}
    ],
    "wal_segments": [
        {"name": "000000010000000000000003", "end_time": "2024-11-15T12:40:00Z"},
        {"name": "000000010000000000000004", "end_time": "2024-11-15T13:20:00Z"},
        {"name": "000000010000000000000005", "end_time": "2024-11-15T14:10:00Z"},
        {"name": "000000010000000000000006", "end_time": "2024-11-15T15:00:00Z"}
    ],
    "timeline_history": []
}


def selftest() -> int:
    assert lsn_to_segno("0/3000000") == 3
    assert segno_to_name(3, 1) == "000000010000000000000003"
    assert name_to_tli_segno("000000010000000000000006") == (1, 6)
    cfg = resolve(SELFTEST_CATALOG, "2024-11-15T14:30:00Z",
                  "cp /wal/%f %p", None)
    assert "recovery_target_time = '2024-11-15T14:30:00Z'" in cfg
    assert "recovery_target_timeline = '1'" in cfg
    # A gap makes the same target unreachable.
    holed = json.loads(json.dumps(SELFTEST_CATALOG))
    holed["wal_segments"] = [s for s in holed["wal_segments"]
                             if s["name"] != "000000010000000000000005"]
    try:
        resolve(holed, "2024-11-15T14:30:00Z", "cp /wal/%f %p", None)
    except Unreachable:
        print("SELFTEST ok: resolves a target and detects a WAL gap")
        return 0
    print("SELFTEST failed: gap not detected", file=sys.stderr)
    return 1


def current_timeline(dsn: str) -> int:
    """Read the source cluster's live timeline as a cross-check.

    Lazily imports psycopg so the catalog-only path needs no driver. The
    live timeline must match the resolved recovery_target_timeline, or the
    catalog is stale relative to the running cluster.
    """
    import psycopg
    with psycopg.connect(dsn) as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT timeline_id FROM pg_control_checkpoint()")
            return int(cur.fetchone()[0])


def main() -> int:
    ap = argparse.ArgumentParser(description="Resolve a PostgreSQL PITR target.")
    ap.add_argument("--catalog", help="path to the backup catalog JSON")
    ap.add_argument("--target", help="ISO-8601 recovery target time")
    ap.add_argument("--restore-command", default="cp /wal_archive/%f %p",
                    help="restore_command the sandbox will use")
    ap.add_argument("--target-lsn", default=None,
                    help="optional explicit recovery_target_lsn to emit")
    ap.add_argument("--verify-dsn", default=None,
                    help="optional source-cluster DSN for a live timeline check")
    ap.add_argument("--selftest", action="store_true",
                    help="run offline checks with an embedded catalog")
    args = ap.parse_args()

    if args.selftest:
        return selftest()
    if not args.catalog or not args.target:
        print("usage: pg_pitr_target.py --catalog C --target ISO "
              "[--restore-command CMD] [--target-lsn X/Y] [--verify-dsn DSN]",
              file=sys.stderr)
        return 2

    try:
        catalog = json.loads(open(args.catalog, encoding="utf-8").read())
        config = resolve(catalog, args.target, args.restore_command,
                         args.target_lsn)
    except Unreachable as exc:
        print(f"UNREACHABLE: {exc}", file=sys.stderr)
        return 1
    except (OSError, KeyError, ValueError, json.JSONDecodeError) as exc:
        print(f"config error: {exc}", file=sys.stderr)
        return 2

    if args.verify_dsn:
        try:
            import psycopg  # noqa: F401  (import checked before connecting)
        except ImportError:
            print("psycopg not installed: pip install 'psycopg[binary]'",
                  file=sys.stderr)
            return 2
        try:
            live = current_timeline(args.verify_dsn)
            print(f"LIVE_TIMELINE {live}")
        except psycopg.Error as exc:
            print(f"cluster cross-check failed: {exc}", file=sys.stderr)
            return 2

    print("--- recovery settings ---")
    print(config)
    return 0


if __name__ == "__main__":
    sys.exit(main())

The recovery_target_inclusive = true line matters: with it set, replay stops at the first transaction that commits at or after the target, so the recovered database includes the boundary transaction; setting it false stops just before. The tool defaults to inclusive because a drill usually wants the state as of the requested instant, and it emits recovery_target_lsn alongside the time only when the caller passes an explicit LSN, since a byte-exact LSN target is unambiguous where a timestamp can land between commits.

Step-by-Step Execution Walkthrough

  1. Self-test the arithmetic first. Run python3 pg_pitr_target.py --selftest in CI to confirm the LSN-to-segment mapping and gap detection are correct before the tool touches a real catalog.

  2. Refresh the catalog from your archiver so wal_segments reflects everything archived up to now; a stale catalog is the most common cause of a false “unreachable.”

  3. Run the resolver for the target instant, capturing its exit code:

    bash
    python3 pg_pitr_target.py --catalog catalog.json \
        --target "2024-11-15T14:30:00Z" \
        --restore-command 'pgbackrest --stanza=main archive-get %f %p'; echo "exit=$?"
    
  4. Branch on the exit code. 0 writes the emitted settings into the sandbox’s recovery configuration; 1 halts the drill because the instant is not recoverable; 2 aborts on a malformed catalog or invocation.

  5. Optionally cross-check the live timeline with --verify-dsn so a resolved recovery_target_timeline that disagrees with the running cluster’s timeline is caught before the restore runs.

  6. Apply and start recovery inside the isolated sandbox, then confirm the restored cluster reached the target before validation trusts it.

Verification and Expected Output

A resolvable target prints the chosen base backup, the WAL range, and the recovery settings, then exits 0:

text
BASE_BACKUP full-0009 timeline=1 start_segment=000000010000000000000003
WAL_RANGE 000000010000000000000003..000000010000000000000006 segments=4
--- recovery settings ---
recovery_target_time = '2024-11-15T14:30:00Z'
recovery_target_inclusive = true
recovery_target_timeline = '1'
restore_command = 'cp /wal_archive/%f %p'

A gap in the archived WAL makes the target unreachable, prints the missing segment, and exits 1 — the drill must not proceed:

text
UNREACHABLE: WAL timeline is not continuous to the target; missing segments: 000000010000000000000005

The exit code is the contract the orchestrator reads: 0 means the recovery is planned and provably continuous, 1 means the requested instant cannot be reached and the drill halts, and 2 means the catalog or invocation is broken and must be fixed before rerun.

Failure Modes and Troubleshooting

Symptom Cause Remediation
Exit 1, missing segments An archived WAL segment was pruned or never uploaded Restore the segment from a secondary archive; alert on archive_command failures so gaps are caught before a drill
Exit 1, target predates the earliest full backup No base backup finished before the target Shorten the base-backup interval, or accept the oldest recoverable point as the drill’s floor
Exit 1, beyond the newest archived WAL Target is more recent than the last archived segment Wait for archiving to catch up, or lower the target; check pg_stat_archiver.last_archived_time
Resolved timeline is wrong after a failover The timeline switch was not recorded in timeline_history Include the .history file’s branch point in the catalog so the walk follows onto the child timeline
--verify-dsn reports a different live timeline The catalog is stale relative to a source cluster that has since branched Rebuild the catalog from the current archive before planning the drill
Recovery overshoots the intended instant recovery_target_inclusive left at its default when an exclusive stop was wanted Emit recovery_target_inclusive = false for targets that must exclude the boundary transaction

Post-recovery, confirm the sandbox actually reached the planned target before validation trusts it: check that recovery reported reaching the target and that the recovered cluster’s timeline matches the emitted recovery_target_timeline. Any deviation between the planned and achieved recovery point must halt the pipeline, because a drill that quietly recovered to the wrong instant is worse than one that failed loudly.

Integration Notes

The tool is built for headless orchestration: flags in, parseable lines out, strict POSIX codes. A thin wrapper turns the exit code into a restore decision or an escalation:

bash
#!/usr/bin/env bash
set -euo pipefail

OUT=$(python3 pg_pitr_target.py --catalog "$CATALOG" --target "$TARGET_ISO")
case $? in
  0) echo "$OUT" | sed -n '/--- recovery settings ---/,$p' | tail -n +2 \
       > "$SANDBOX_PGDATA/postgresql.auto.conf.d/recovery.conf" ;;
  1) curl -s -X POST "$PAGERDUTY_WEBHOOK" -d '{"event":"pitr_target_unreachable"}'; exit 1 ;;
  *) echo "[$(date -u)] resolver misconfigured; aborting"; exit 2 ;;
esac

Wire that wrapper into whichever scheduler owns the drill. In Airflow, invoke it from a BashOperator, or a PythonOperator that imports resolve directly and pushes the emitted settings to XCom so the restore task consumes a typed value rather than scraping stdout; a non-zero exit fails the task and short-circuits the restore. Under Celery or cron, the strict exit codes let a systemd OnFailure handler route the alert without extra glue. The resolved recovery point becomes a deterministic input for the isolated restore that sandbox provisioning stands up, and it feeds the broader restore drill orchestration and environment isolation chain. Feed the target time, chosen base backup, WAL range, and resolved timeline into an immutable audit sink so every recovery decision carries evidence of which instant was planned and which segments proved it reachable.

Frequently Asked Questions

How does a WAL segment file name encode the LSN and timeline?

A segment file name is 24 hexadecimal characters: the first eight are the timeline id, and the remaining sixteen are the logical segment number written as a high and low half. With the default 16 MiB segment size there are 256 segments per logical file, so an LSN maps to a segment number by dividing by the segment size, and a segment number renders back to a name by that same 256-way split. The tool uses exactly this arithmetic to turn a base backup's stop LSN and a target into a contiguous range of segment numbers.

Why validate timeline continuity instead of trusting the archive?

Because a single missing segment stops replay short of the target, and PostgreSQL will happily recover to wherever the WAL runs out rather than failing. The resolver walks every segment number from the base backup to the target, following timeline switches recorded in the history at the exact LSN they occurred, and reports any name that is not present in the catalog. A gap is treated as a hard 1 exit that halts the drill, not a warning, so the restore never validates a truncated instant.

What does recovery_target_inclusive actually change?

It decides whether the transaction that commits exactly at the target time is included. With it set to true, replay stops at the first commit at or after the target, so the boundary transaction is present; with it false, replay stops just before, excluding it. The tool defaults to true because a drill usually wants the database state as of the requested instant, but a target that must exclude a specific bad transaction should emit it as false.

When does the resolver exit 1 versus exit 2?

Exit 1 is a recovery verdict: the catalog was well formed but the instant cannot be reached — no base backup precedes it, the target is newer than the last archived segment, or a WAL segment is missing. Exit 2 is an invocation or configuration fault: a malformed catalog, a bad timestamp, or a missing argument. Orchestrators should escalate 1 as "this point is not recoverable" and treat 2 as "fix the input and rerun."

This script is one component of the broader Point-in-Time Recovery Targeting workflow.

For authoritative behavior of the underlying tooling, consult the PostgreSQL documentation on continuous archiving and point-in-time recovery and recovery target settings, and the Python documentation for datetime.