Transaction Replay (TR) — In-Session Failover
Transaction Replay (TR) — In-Session Failover
Transaction Replay keeps client sessions alive through a primary failure: sessions re-home and restore state, an interrupted read re-runs when it is provably side-effect-free, and opt-in transaction mode replays uncommitted work on the new primary — verifying every replayed response against what the client already saw, and never retrying a COMMIT whose outcome is unknown.
In-session replay is core. It needs no cargo feature and no application change —
any PostgreSQL-wire client, any PG-wire backend. The ha-tr
feature is a separate thing: the write journal plus the operator-driven /api/replay
and /api/shadow endpoints and the failover library components. A build without
ha-tr still does everything on this page.
As of HeliosProxy 1.7.0, replay verifies its own results. This page states exactly
what each mode does, what is always true regardless of mode, what the proxy refuses to
do, and the requirements and limits you must plan for. It does not claim zero data loss
for in-flight COMMITs — see What is always true — and it does
not claim cursor, temp-table or PREPARE survival; see
What is never restored.
Why it matters
In a plain PostgreSQL HA setup, when the primary fails the pool drops active connections, in-flight statements get a connection-reset error, and every application must reconnect and decide on its own whether to retry. Most do not do this correctly.
With Transaction Replay the proxy is the reconnect-and-retry layer: the client
connection stays open, the proxy re-homes it to the new primary, restores the session
state, and — depending on tr_mode — re-executes what can safely be re-executed.
Anything whose outcome cannot be established is reported to the client with a precise
SQLSTATE instead of being guessed.
In-session replay (the core path)
This is what a live client experiences when its backend dies mid-session. It runs in
core, in builds with and without ha-tr, and is independent of the ha-tr journal and
the administrative replay endpoints.
What is recorded. While a session is inside a transaction under tr_mode = select
or transaction, the proxy records each statement, the session state it changed, and a
bounded digest of the response frames the client was shown ([limits] tr_max_observation_bytes, default 1 MiB). Autocommit traffic records nothing.
What happens on a backend fault. The proxy classifies how far the statement got. If the outcome is not delivered, recovery is safe. If the outcome is unknown — the statement may have committed — it is never re-executed. Once any part of a response has reached the client, the session is closed rather than have a second result appended to the first.
What recovery does. Under one deadline (write_timeout_secs) it waits for a healthy
primary, connects and authenticates, restores the session’s tracked SET state, and
replays the recorded transaction. Each replayed statement’s response is hashed and
compared with what the client originally saw; any divergence rolls the replay back and
returns 40001.
Behaviour by mode (tr_mode)
tr_mode | What the client sees when the primary fails |
|---|---|
none | A proper PostgreSQL error (SQLSTATE 57P01, admin shutdown) and the connection closes. Equivalent to a plain pooler. |
session (default) | The client connection stays alive. The proxy re-homes it to the new primary and restores SET/GUC state. The interrupted statement returns one error: 57P01 if it never reached the old primary, 08007 (transaction resolution unknown) if its outcome is unknown. Autocommit statements that had not yet been delivered are re-executed transparently. |
select | Everything in session, plus an interrupted read is re-run on the new primary — but only when every function it calls is provably side-effect-free (a PostgreSQL built-in on the proxy’s allowlist, or a name you listed in tr_read_functions). A read calling a user-defined function, nextval, pg_notify, set_config, an advisory lock, a quoted-identifier call, or using SELECT … INTO is opaque: it returns 08007 and is never run twice. Read-only explicit transactions are replayed from BEGIN. |
transaction (opt-in) | Everything in select, plus the uncommitted transaction is replayed from BEGIN on the new primary and the in-flight statement re-executed inside it. Replay is verified: each statement’s original response frames were digested as the client saw them (within [limits] tr_max_observation_bytes), the replacement backend’s responses are digested the same way, and any divergence rolls the replay back and returns 40001 rather than continuing on top of rows the client never observed. A failed replayed statement produces the same 40001. |
What is always true
These hold in every mode, including transaction:
- A COMMIT whose outcome is unknown is never retried. The client receives
08007and must check before retrying. This is what prevents double-apply; it also means Transaction Replay does not promise zero data loss for a COMMIT that was in flight at the moment of failure. - One deadline covers the whole recovery. Since 1.7.0
write_timeout_secsbounds waiting for a healthy primary, connect and authentication, session-state restore, replay and re-execution together — previously each phase carried its own timeout and they could add up well past the configured value. If no primary appears in the window the client gets08006. - One error, then a clean transaction state. When the single error is returned
inside an explicit transaction, the client-visible transaction is aborted (
25P02until the client issuesROLLBACK), exactly as PostgreSQL would. - A replay that fails yields
40001(serialization failure), so standard retry logic applies; the session is not left half-replayed. The same40001is returned when replay verification detects divergence. - Protocol-level named prepared statements are re-prepared lazily on the new
primary as they are next used. This is the extended-protocol
Parse-with-a-name case only — SQL-levelPREPARE foo AS …is not restored. See What is never restored. - A COPY in progress always fails with
08006(connection failure). COPY streams are not journaled or resumed. - The proxy returns real PostgreSQL SQLSTATEs (
57P01,08007,08006,40001) so existing driver and application retry logic keeps working.
What it refuses to do
Transparent recovery is the goal, a clear error is the fallback, and a wrong answer is never acceptable. Recovery is declined, conservatively, when:
- a
COMMIT’s outcome is genuinely unknown — never retried.08007, with guidance to verify. CoversCOMMIT,END,PREPARE TRANSACTIONandCOMMIT PREPARED. - part of a response already reached the client — the session closes rather than concatenate a second result onto the first.
- the transaction ran at
SERIALIZABLEorREPEATABLE READ— set onBEGIN, viaSET TRANSACTION, or inherited fromdefault_transaction_isolation. No replacement backend can reproduce that snapshot, so the transaction is marked non-replayable. - a response was larger than
[limits] tr_max_observation_bytes— it has no digest to verify against, so its transaction is not replayed on faith. - an interrupted read calls anything outside the side-effect-free set —
08007, and it is never run a second time. - tracked session state exceeded
[limits] tr_max_session_set_statements— the failover is refused with08006rather than re-homing a session with incomplete state. The cap counts distinct variables. - a
COPYwas in progress at the fault —08006, connection closed. - replay verification detected divergence — the replay is rolled back and the
interrupted statement returns
40001.
Verification catches divergent results; it does not create determinism. Replay will
not make now(), random() or a RETURNING serial reproduce their first values.
What is never restored
Not restored across a failover, in any build, with or without ha-tr:
- SQL-level
PREPAREd statements - session temporary tables
- open cursors
- advisory locks
SETs issued through the extended query protocol — as most JDBC drivers send them
— are not tracked either, and so are not restored. SET LOCAL, SET TRANSACTION and
SET CONSTRAINTS are transaction-scoped by definition and never tracked.
The src/session_migrate.rs and src/cursor_restore.rs modules implement parts of
this, but they are library components and are not on the recovery path.
Applications depending on that state surviving a failover still need application-level
coordination.
Sequence (transaction mode)
Client HeliosProxy PostgreSQL │ │ │ │── BEGIN ─────────────────▶ │── BEGIN ────────────────────────▶│ primary │── INSERT ... ────────────▶ │── INSERT ... ───────────────────▶│ │ │ [record: BEGIN, INSERT │ │ │ + digest of response seen] │ │── UPDATE ... ────────────▶ │── UPDATE ... ────────────────── ✗ primary dies │ │ [detect; wait for new primary] │ │ │ ←── one write_timeout_secs ──→ │ │ │── connect, auth, SET restore ───▶│ new primary │ │── BEGIN; INSERT ...; UPDATE ... ▶│ (replayed) │ │ [re-digest; compare] │ │ │ match -> continue │ │ │ differ -> ROLLBACK, 40001 │ │◀─ UPDATE result ────────── │◀─ ... ───────────────────────── │ │── COMMIT ────────────────▶ │── COMMIT ───────────────────────▶│ │◀─ COMMIT OK ───────────── │◀─ COMMIT OK ──────────────────── │If the failure had happened while the COMMIT itself was outstanding, the client
would instead receive 08007 and nothing would be retried.
Configuration
tr_enabled = truetr_mode = "session" # none | session | select | transactionwrite_timeout_secs = 30 # ONE deadline for the whole recoverytr_read_functions = [] # extra provably-pure functions an interrupted read may call
[auth]mode = "scram" # required to fail over onto password-protected backends
[limits]tr_max_replay_statements = 1000 # cap on statements recorded per transactiontr_max_replay_bytes = 4194304 # cap on recorded bytes per transaction (4 MiB)tr_max_session_set_statements = 256 # cap on DISTINCT session variables restoredtr_max_observation_bytes = 1048576 # per-statement response-digest budget (1 MiB)max_backend_frame_bytes = 104857600 # ceiling on one backend protocol frame (100 MiB)backend_response_timeout_secs = 0 # 0 = off; whole-response deadlineThese are the shipping defaults. A transaction that exceeds a replay bound is not
replayed; the client receives an error instead of a partial replay. Exceeding
tr_max_session_set_statements refuses the failover outright with 08006.
tr_read_functions takes plain unqualified identifiers, matched case-insensitively;
schema qualification is ignored, so pg_catalog.lower(...) is lower. A call through
a quoted identifier ("MyFn"(...)) is never eligible — its case is opaque to the
policy.
max_backend_frame_bytes and backend_response_timeout_secs are relay hardening
rather than TR policy: the first closes a backend whose declared frame length is above
the cap or below the 4-byte protocol minimum, the second bounds a backend that drips
bytes inside a single response, which a per-read timeout cannot catch.
Metrics
Exported on the admin endpoint (/metrics, Prometheus format):
| Metric | Meaning |
|---|---|
tr_failovers_total | Primary failovers handled by the TR path |
tr_statements_reexecuted_total | Autocommit statements and reads re-executed transparently |
tr_transactions_replayed_total | Uncommitted transactions replayed in transaction mode |
tr_replay_failures_total | Replays that could not be completed (client got an error) |
tr_unknown_outcome_errors_total | 08007 errors returned because an outcome was unknown |
tr_replay_cap_exceeded_total | Transactions marked non-replayable by a tr_max_replay_* bound |
tr_session_set_cap_exceeded_total | Sessions whose SET tracking hit tr_max_session_set_statements; a subsequent failover is refused with 08006 |
The Admin API reference documents the same counters
alongside the rest of /metrics.
Requirements and limits
- Auth boundary. Failing over onto password-protected backends requires the proxy
to be the authentication boundary:
[auth] mode = "scram". In pass-through auth the proxy never sees the client’s password (SCRAM is designed for that), so a fresh backend connection can only be completed against a backend that does not challenge; a challenging backend fails the recovery immediately with08006. - Session-state restore is transactional.
SET/RESETtracking mirrors PostgreSQL: aSETinside a transaction takes effect for restore only when that transaction commits,ROLLBACK TO SAVEPOINTdiscards the ones issued after the savepoint (RELEASEkeeps them), andRESET/RESET ALL/DISCARD ALLinside a transaction are deferred to commit — so a rolled-backRESET ALLno longer wipes the restore set. Variables are keyed by name, so repeating aSETreuses its slot. - GUC restore scope. Restore covers simple-protocol
SET/RESETstatements. Parameters changed by other means — inside functions, or viaSETsent through the extended protocol — are not tracked. See What is never restored. - Snapshot-pinned transactions are never replayed.
SERIALIZABLEandREPEATABLE READare non-replayable by policy; see What it refuses to do. - Replay resends the original SQL. Non-deterministic statements —
now(),clock_timestamp(),random(),RETURNINGof serial or sequence values — can produce different results on the new primary. Verification will catch a divergent result and roll the replay back with40001, but it cannot make the values reproduce. This is whytransactionmode is opt-in: enable it for workloads whose transactions are deterministic or idempotent. - COPY is never resumed (
08006). - Replay bounds are governed by the
[limits]keys above.
Verification
The behaviour on this page is exercised against live PostgreSQL, not asserted:
- 77-check failover battery (
scripts/regress/tr-failover-test.sh) across all fourtr_modevalues against PostgreSQL 18.4 — interrupted autocommit statements, interrupted reads, uncommitted transactions, in-flight COMMITs, prepared statements and COPY. This established the base path at 1.6.0. - Live commit-outcome suite, 7 cases. The connection is destroyed while a
COMMITis in flight, then the suite asserts against the database’s real state whether the client’s report was correct. The pre-fix binary failed 4 of 7. - Live streaming suite, 13 cases. Faults during large result sets, across
Flushchunks, mid-DataRow, afterCommandComplete— asserting that no client ever sees a duplicated row or a second protocol completion. The pre-fix binary failed 12 of 13. - Synthetic boundary harness, 26 cases. Commit-boundary classification against comments, quoted and dollar-quoted text, multi-statement strings, backslash escapes, savepoint stacks and GUC cap behaviour.
- Deterministic partial-writer. A writer that fails after every byte offset, confirming no offset can produce a duplicate committed write.
- Full gate matrix on the release commit.
clippy -D warningsacross four feature profiles; 432 / 518 / 1726 / 1727 tests ondefault,ha-tr,all-featuresandall-features,postgres-topology, zero failures; MSRV 1.86 with a locked dependency graph.
The mode × fault-phase × transaction-state × statement-kind decision table
(tr_decide) is exhaustively unit-tested.
Release history
- 1.6.0 (2026-09-04) — introduced
tr_modein-session failover. - 1.6.1 (2026-09-09) — fixed commit-outcome, publication and delivery safety.
- 1.7.0 (2026-09-11) — completes the series: verification, refusals, and one deadline.
Related
- Configuration reference —
tr_mode,tr_read_functions,[limits],[auth] - Feature flags — what the optional
ha-trbuild feature adds on top of the core path - Architecture — journal and failover controller
- Admin API —
/metricsTR counters,/api/replay,/api/shadow - Demos: Bank Ledger, vs PgBouncer