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
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.fromisoformatoffset 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 optionaltimeline_history(parent timeline, child timeline, switch LSN). pgBackRest, Barman, or an in-house catalog overpg_stat_archiverall satisfy this contract. -
Archived WAL reachable by the
restore_commandthe 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 runsSELECT 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.
#!/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
-
Self-test the arithmetic first. Run
python3 pg_pitr_target.py --selftestin CI to confirm the LSN-to-segment mapping and gap detection are correct before the tool touches a real catalog. -
Refresh the catalog from your archiver so
wal_segmentsreflects everything archived up to now; a stale catalog is the most common cause of a false “unreachable.” -
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=$?" -
Branch on the exit code.
0writes the emitted settings into the sandbox’s recovery configuration;1halts the drill because the instant is not recoverable;2aborts on a malformed catalog or invocation. -
Optionally cross-check the live timeline with
--verify-dsnso a resolvedrecovery_target_timelinethat disagrees with the running cluster’s timeline is caught before the restore runs. -
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:
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:
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:
#!/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."
Related
- Point-in-Time Recovery Targeting — the parent workflow this resolver plugs into as its PostgreSQL-native targeting stage.
- Point-in-time targeting for MongoDB backups — the sibling resolver that maps a target to a MongoDB oplog boundary instead of a WAL range.
- Sandbox provisioning — stands up the isolated instance the emitted recovery settings are applied to.
- RTO/RPO mapping — the recovery envelope that decides whether a resolved instant is acceptable.
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.