Dev & EngARTICLE

Polar Proxy splits an open PostgreSQL transaction between primary and replica

Article · Dev & Eng
Reading
8 min
Tables
1
Code
1
In short

A technical article on the Awide Labs blog details how Polar Proxy sends reads from an open PostgreSQL transaction to a replica without losing snapshot, locks, or read committed consistency.

The problem: a transaction doesn't split easily

Sending a standalone SELECT, in autocommit, to a replica is trivial: any decent proxy already does that. The problem appears when the client opens a transaction (BEGIN, some commands, COMMIT), because the whole sequence has to behave as a single unit. The most common approach, described by Vadim Iatsenko in an article published on November 1, 2026 on the Awide Labs blog, is the most conservative one possible: the entire transaction, from the first to the last command, goes to the primary, even the SELECTs that never write anything.

This conservatism has a technical reason, not laziness. A PostgreSQL transaction pins a snapshot (the picture of the database it sees), the locks it has already taken, and the session settings to a single backend. If the proxy sent the second command to a different server, that server wouldn't have the uncommitted changes, the locks, or the snapshot from the first command, and the transaction would simply break.

Why the isolation level decides everything

The key to understanding where there's room for an opening is the isolation level. Under repeatable read and serializable, the transaction takes a snapshot on the first command and keeps that same snapshot until the end: a read in the middle of the transaction has to see exactly what the first read saw, which makes offloading to a replica practically impossible.

Under read committed, though, each command takes a new snapshot. A SELECT only needs to see everything that was committed before it, and that's exactly what you can guarantee by waiting for the replica to reach a given LSN (log sequence number, the position in the WAL stream). That's the window Polar Proxy exploits.

What a pooled connection carries with it

Two characteristics of a pooled connection shape the entire solution. The first is identity: a PostgreSQL connection pool only reuses a connection when the user and the database match the client's, because it's that identity that decides which tables the session can read. Giving the client a connection authenticated as another user makes the read run silently under the wrong permissions.

The second is lifecycle. For an autocommit command, the answer is obvious: grab the connection, run it, return it. But a split read runs inside an open transaction, and there's no upstream precedent to say how long the borrowed replica should stay reserved; the PolarDB team had to decide this from scratch.

How the rest of the ecosystem deals (or doesn't) with this

Before diving into Polar Proxy's mechanism, it's worth measuring the bar against what already exists.

ToolDoes it split the transaction?How it guarantees consistency
Pgpool-IINo: the documentation explicitly states that load balancing isn't guaranteed inside a transactionNot applicable
PgBouncerDoesn't route: forwards commands without analyzing themNot applicable
Vitess (MySQL)Only in autocommit or an explicitly read-only transactionGTID tracking
Amazon Aurora PostgreSQLYes, but inside the engineaurora_replica_read_consistency = 'session', with access to the commit LSN inside the kernel
ProxySQL upstream (PostgreSQL)No: routes the entire transaction to the primaryNot applicable
Polar ProxyYes, at the proxy layerXID import plus LSN wait

The Aurora case is the most revealing one: it solves the same problem, but only because it owns the storage engine and can carry the commit LSN inside each backend. A proxy doesn't have that access, and that's exactly the gap Polar Proxy tries to fill from the outside.

Seeing the transaction state without another round trip

The first obstacle is that the standard protocol tells you almost nothing about the transaction in progress. PostgreSQL's ReadyForQuery (RFQ) carries just one status byte: idle, in a transaction, or in a failed transaction. Nothing about XIDs, nor about whether it has already written anything.

The Awide team extended the libpq embedded in Polar Proxy to request this detail at startup, via the _polar_send_xact parameter. The backend starts appending an extra byte to every RFQ: x means a splittable transaction (no pending flush of its own WAL), w means it has already written and that WAL hasn't been persisted yet. A string of XIDs accompanies the marker when the transaction is splittable; empty means it's still in the read-only prefix.

Three new calls, PQgetXactSplitXids, PQisXactSplittable, and PQisXactWalPending, expose these facts to the proxy without an extra round trip.

The five conditions for releasing a read

A read only goes to the replica if it passes all of these checks:

  1. The transaction runs under read committed (never repeatable read or serializable).
  2. It's splittable: no pending flush of its own WAL, or, as an accepted exception, the monitor has already seen a replica in the group replicate past the reported LSN.
  3. The command is a top-level SELECT, with no lock clause (FOR UPDATE, FOR SHARE, FOR NO KEY UPDATE, FOR KEY SHARE).
  4. It's a single command, with no multi-statement and no extended protocol.
  5. If the read comes before the first write, the session hasn't run any SET inside the transaction yet.

Any failure in one of these checks keeps the read on the primary.

The default is safety; the offload is the exception.

Vadim Iatsenko, author of the article on the Awide Labs blog

The mechanism: four SETs before the SELECT

When a read passes all the checks, the proxy picks a compatible replica connection (same user, same database, same session configuration) without closing the connection to the primary. It sends, in the same round trip, a fixed sequence of hidden commands followed by the original SELECT:

sql
SET polar_xact_split_xids = '1,1234,5678'; -- command id, then the transaction's XID and its children's
SET polar_consistency_mode = 'strict';     -- forced internally by the wrapper
SET polar_proxy_wait_timeout_ms = 5000;    -- wait deadline, in milliseconds
SET polar_xact_split_wait_lsn = '97500059720'; -- waits for the replica to replicate up to here
-- the original SELECT goes here

Two different paths use this mechanism. Before the first write, the read uses the REPLICA_WITH_WAIT path: there's no XID to import yet, just the wait for the LSN. After the first write, the path becomes REPLICA_TXN_SPLIT, which also imports the transaction's XIDs so the replica can see the transaction's own writes it hasn't replicated yet.

It's worth noting that the strict mode and the timeout don't come from the client's session: the wrapper forces them on its own, precisely so a split read never serves stale data without warning.

Pinning the replica until the transaction ends

The first eligible read in a transaction picks a replica, and the session keeps it reserved until the end of that transaction; every subsequent read reuses the same connection instead of choosing another one. The connection to the primary stays untouched the whole time, still holding the snapshot, the locks, and the settings.

This is a design decision from the PolarDB team, with no upstream precedent, because the original ProxySQL never split a transaction and so never had to answer this. Keeping the same replica avoids a new selection on every command, and lets a later read skip the LSN wait if the replica has already reached the needed point. The only exception is if the routing target changes or the chosen replica goes down: then the session releases it and picks another one.

When the replica fails mid-read

A split read can fail in three ways: the connection to the replica drops, the wait time runs out, or the replica returns a re-runnable SQL error. The proxy classifies the failure and applies a configurable policy, with sensible defaults: a dropped connection tries another compatible replica; a timeout and a re-runnable error fall back to the primary, whose result is considered authoritative.

There's a clear limit: as soon as any result row has already been sent to the client, the proxy stops retrying and preserves the original failure path. The client never sees a partial result followed by a second attempt.

What this changes for whoever administers the database

The concrete gain is read offload where it used to be simply impossible: a long, predominantly read-only transaction (the typical profile of a report or batch job) can have its SELECTs served by replicas while the transaction stays coherent on the primary. It's a proxy solution, not an engine one: unlike Aurora, Polar Proxy has no access to PostgreSQL's kernel, and it makes up for that with XID import and LSN waiting.

The eligibility bar is narrow on purpose. Transactions under repeatable read or serializable, reads with a lock clause, multiple commands in the same round trip, and any session SET before the first write are left out, and it's good that they are, because none of those situations lets you rebuild the transaction's visibility on a replica without risking inconsistent data. Whoever administers an application with lots of mixed transactions, with reads and writes interleaved, stronger isolation, or explicit locks, shouldn't expect load relief: most of that traffic stays on the primary.

There's a caveat worth making, from someone who reads the execution plan before trusting any promise of scale: the Awide article describes the design and the guarantees, but doesn't publish latency or throughput numbers; that's left for a future piece in the series, promised by the author himself. Before adopting this pattern in production, the responsible path is to measure the cost of the LSN wait under your database's actual load, not to assume it's negligible just because the design is elegant.

Translated from the Brazilian Portuguese original · Read the original