Skip to content

feat: pgdog.min_lsn read-your-writes replica routing floor - #4

Open
mediprtl wants to merge 58 commits into
mainfrom
feat/min-lsn-read-routing
Open

mediprtl wants to merge 58 commits into
mainfrom
feat/min-lsn-read-routing

Conversation

@mediprtl

@mediprtl mediprtl commented Jun 2, 2026 •

Copy link
Copy Markdown
Owner

Closes #3

Summary

Adds a read-your-writes routing floor. A client sets pgdog.min_lsn and PgDog routes the read only to a replica that has replayed at least that far. No application-side cache or replica pinning.

  • New pgdog.min_lsn param (startup options=-c or SET pgdog.min_lsn = 'X/Y'), added to UNTRACKED_PARAMS so it is not forwarded to the server. Parsed via the existing Lsn type.
  • Request carries min_lsn: Option<i64>; populated in connect.rs from the connection params (so SET mid-session works).
  • The load balancer filters replica candidates to those whose replayed LSN (tracked per pool by the LSN monitor, pool.lsn_stats()) is >= min_lsn.
  • When no replica qualifies, the read errors with NoReplicaCaughtUp (a recoverable no-server error, surfaced as FATAL: no replica caught up to the requested min_lsn, SQLSTATE 58000) so the client retries. Falling back to the primary is opt-in via general.min_lsn_primary_fallback (default off).

Conservative by construction: replay LSN is monotonic and the cached value is <= actual, so the floor never serves a stale read.

SET pgdog.min_lsn = '3A/B0C12345';   -- then just run your reads
[general]
min_lsn_primary_fallback = false   # default: error when no replica is caught up

Test plan

  • Integration (3-node primary + 2 replicas, integration/load_balancer/pgx/min_lsn_test.go): min_lsn = 0/0 -> all reads served by replicas, primary untouched; min_lsn = 7FFFFFFF/FFFFFFFF -> read errors (NoReplicaCaughtUp), nothing served. Green at -count 3.
  • Manually verified min_lsn_primary_fallback = true flips the unmet case to succeed via the primary.
  • Full load_balancer suite unaffected.
  • cargo fmt / cargo clippy clean (no new warnings); JSON schema regenerated.

See #3 for design and the alternative considered (ClientAffinity, #1/#2). Relates to upstream pgdogdev#912.

@mediprtl
mediprtl force-pushed the feat/min-lsn-read-routing branch from d8025a0 to b3ea7c9 Compare June 13, 2026 07:19
@mediprtl
mediprtl changed the base branch from main to v0.1.44-base June 15, 2026 20:45
@mediprtl
mediprtl marked this pull request as ready for review June 15, 2026 20:46
@mediprtl
mediprtl changed the base branch from v0.1.44-base to main June 15, 2026 21:07
@mediprtl
mediprtl force-pushed the feat/min-lsn-read-routing branch from b3ea7c9 to fc5705b Compare June 15, 2026 21:17
levkk and others added 23 commits June 15, 2026 14:39
…#1078)

Handle listen/notify commands without enabling the parser for all other
statements.
- feat: `SHOW LISTENERS` admin database stats on each pub/sub channel
- feat: OpenMetrics/OTEL for the same thing
- fix: race condition in `LISTEN` command handler when registering for
the first time

Stats recorded:

- number of listeners
- number of messages received
- number of messages dropped
…pgdogdev#1086)

### Breaking change

Using `SET pgdog.shard` and `pgdog.set_sharding_key` inside a
transaction (`BEGIN`) binds the
entire transaction to that one shard only.

Added the following checks to prevent data consistency errors:

1. Omnisharded tables cannot be written to inside direct-to-shard
transactions anymore
2. Caller cannot change shards mid-transaction anymore, e.g. by calling
`SET pgdog.shard` or by running a direct-to-shard or cross-shard query

Technically, this not a breaking change per-se but a but a bug fix,
since this was causing silent data corruption errors previously, but
still worth calling out.

fix pgdogdev#1085

### Other changes

- refactor: rename really poorly named methods in `Route`
- refactor: connection binding enum handling was nonsense, now it's less
so
- refactor: added lots of comments in places where I touched something
in this PR
- refactor: removed some dead code
Upload `.deb` packages to R2 to make them available from our apt repo at
`apt.pgdog.dev`.
Reverts pgdogdev#1091

Maybe not today. A bit too much work to maintain.
…v#1077)

Rehaul of sharded_mapping configuration

## What is done

- embed mapping configuration inside the `[[sharded_tables]]`instead of
it being defined separately with `[[sharded_mapping]]`. The benefits of
doing this:
- make the relation between those explicit and without ambiguity when
the mapping should apply, no need to duplicate fields between two and
build the search keys to find the related mapping.
- solve the original issue - the mapping/table without table name, how
they should be connected and applied together. The mapping is always
defined on the proper table/catch-all table
	- no decoupled mappings in config due to misconfiguration
- the old `[[sharded_mapping]]` is left for backward compatibility, but
no fixes for it were provided, therefore it's not working properly in
some cases and this is intentionally:
- if table name is not defined for mapping it will be applied only for
the sharded_table without name. No more guessing if it should provided
for some other tables etc. as I initially did in this pr.
- the schema is not checked for the search and could be applied wrong if
the schema is defined on the table/mapping
- the mapping is now fully compound and all the settings are used in
order (List -> Range -> Default kinds) as it was previously either Range
or List + Default
- split some types to config and runtime shapes - the config shape with
only values parsed from toml and runtime shape with calculated/omitted
fields
- add multiple validations for mapping configuration (currently shown as
warn)
- add ..Ref types to avoid clonning but still be able to search in maps
- drop unreliable Hash implementations (they often diverge from the type
itself) and use other approaches instead

## The config difference

<img width="1140" height="432" alt="image"
src="https://github.com/user-attachments/assets/3f3d3809-7e8e-4841-881c-5eb1d5dcde4c"
/>

## Roadmap

Preserve the old definition for some time and remove in some future
release leaving only the new way for configuring mapping.
…gdogdev#1095)

When adding new replicas to a cluster, `RELOAD` previously dropped all
existing backend connections because the target count changed.
Connection preservation now uses address-based matching instead of
requiring identical replica counts. Existing replicas keep their
connections, and new replicas start with empty pools.

If a database was paused via `PAUSE` before a `RELOAD`, the new pools
now inherit the `paused` state so the database stays paused.
Add `prefer_primary` read_write_split option which will send all queries to the primary unless the query explicitly opts into being sent to a replica.
This is the first in a series of (hopefully smaller from this point)
commits that port the codebase over to use the new parser. The function
this commit focuses on is StatementParser::run_walk.

This commit leaks *way* outside that function, since it required most of
the glue code to pass the new AST around. The parsed AST must live at
the cache layer, since `pg_raw_parse::ParseResult` is what holds the
`MemoryContext`, and all nodes contain a lifetime referring to that
struct.

We're doing a funky thing where we parse once at the same time as the
old parser runs, but then later we unparse and reparse and start passing
that around. The reason for this is that the rewriter hasn't been ported
yet, so it only operates on the old parser's AST. If we didn't
deparse/reparse at that point, code after that which gets ported would
be based on the untransformed query.

The actual porting code is relatively straightforward. It's mostly 1:1
changes with fewer layers of indirection, but I did clean up some edge
cases in advisory locks while I was at it. The new code can handle
advisory locks coming from values clauses without an explicit column
name given, and also correctly handles completely silly cases where the
advisory lock comes form a subselect.

These edge cases are unlikely to ever show up in the wild, but it's
imporant that if we ever do encounter them, we do something sensible
instead of silently doing the wrong thing. And handling the tricky edge
cases also gave me an opportunity to stress test the capability of the
new parser's AST walking API.
…#1100)

Block cross-shard sharding key updates when the row is referenced by a
foreign key with a destructive `ON DELETE` action (`CASCADE`, `SET
NULL`, or `SET DEFAULT`). Those moves are implemented as `DELETE +
INSERT`, so the delete step could silently affect related rows. PgDog
now checks loaded schema metadata before the delete/insert and returns a
recoverable error; same-shard updates and `ON DELETE RESTRICT / NO
ACTION` are unchanged.
I changed the behavior of the parser to not pass the root node to the
callback. I found recursing based on pointer equality to be too painful
as I work on porting the routing functions.

As a side effect, this should be more efficient. We did a full AST walk
of each subselect looking for advisory locks, and then walked it again
looking for tables. With this change they're now a single walk, and the
code should be easier to follow
This port diverges from the old implementation in that it doesn't try to
stick any int looking ConstValue::String into an integer, as there
wasn't any clear reason for us to be doing so.
…v#1108)

- feat: accelerate failover detection by adding an event the shard role
monitor listens for when the role changes. Previously, we would poll
every 333ms.
- feat: if LSN monitor gets a checkout timeout from the connection pool,
it will create a standalone connection to perform the LSN check. This
prevents LSN detection from failing when the connection pool is
completely saturated, e.g. during an incident
- fix: unban all fallback didn't handle edge case where all pools
reported healthy, e.g. after a complete failover
…v#1112)

We now use the new parser for routing for anything other than an INSERT
statement, which has its own special logic and justifies its own PR.
Really this is just a port of shard_select, but udpates and deletes use
the same logic and the only thing that changes is how we construct the
context.

In any cases where I have a function that I couldn't #[cfg] out on the
new parser, I've renamed the old function rather than putting _new on
the new ones. Since the intention is to eventually delete the old
functions, I figured we'd include the churn now while I'm adding the new
functions, rather than adding a lot of churn on the new ones later.

For the actual logic, we essentially have two modes:
- We're looking for a binary operator that we can use to determine a
shard
  - We're looking for a column name/value expr to resolve that operator

In both cases, when we encounter a select statement, we must immediately
construct a new context and go see if *it* had what we need to route to
a shard. We never get an expression from inside a subselect, but if it
ever does find something, or if we encounter an error at any point, we
want to immediately abort and return whatever that value was.

For this, we make extensive use of the standard library's `ControlFlow`
type, which is the underlying type that the `?` operator uses, and what
`pg_raw_parse` uses to control recursion in its AST walking function.
Any time we have a `Result<Shard, Error>`, we put that in a
`ControlFlow::Break` which sends that all the way up the stack.

When we're in "search for expression" mode, we do a separate recursive
call (so we can go through things like type casts and such arbitrarily),
and break *either* if we find a Shard *or* an expression we can resolve.

Because each call to `walk_manual` can return an error (if PG received a
node it doesn't recognize), and also returns an `Option` (if it walked
an entire AST without geting a `ControlFlow::Break`, this can lead to
the type signatures getting a little hairy (at the bottom of our
expression search we end up with `Result<Option<Result<Either<Shard,
SearchResult<'_>>, Error>>, Error>`.

At this point I'm fairly certain I want to remove the `Result` from
`walk_manual`, and just have it panic since receiving a node we don't
recognize shouldn't actually be possible. Once I've done that, the types
should get a little bit less confusing, and in particular I'll be able
to remove all the `transpose` calls (which may be unfamiliar to any devs
who haven't done any functional programming).

Because `ControlFlow` is a newer type that isn't as widely used as other
try types like `Result` and `Option`, it's missing a lot of the nice
convenience methods those types have. I've added an extension trait to
convert a `Result` into a `ControlFlow` that breaks whenever it sees an
error, and continues when it sees an `Ok`

The only other thing of note is that this somehow made the codebase
faster. This honestly doesn't make sense given that we still run
everything through the old parser, then do the very slow old deparse,
then reparse. I can't believe this change somehow makes enough of a
performance difference to outweigh the cost of the deparse/reparse, but
the numbers don't lie.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
sgrif and others added 29 commits July 1, 2026 13:11
I renamed `new_stmt` to `stmt` and `stmt` to `stmt_old` now to avoid
more churn on the ported lines later
The old version of this function failed to detect locks anywhere outside
of a CTE or top level query. As part of the port, I've fixed that
behavior.
`locking_behavior` was never read, and all the logic around lock
functions here was redundant with our checks for locking functions that
we do elsewhere. The lists we were searching were far to small to
justify the complexity of `Lazy<HashMap<...>>`
Since ultimately that function, cte_writes, and has_locks are all trying
to determine the same thing, I've moved all three into a single AST
walk.
This is the last function in `parser/query/select.rs`
…gdogdev#1132)

There's quite a bit of churn in this one, as I needed to change
StatementRewrite to always have the new AST so every test that was
previously passing `None` needed to be updated. One file in particular
had a *bunch* of repeated code in its test, which I extracted to a
helper function.

The other bit of funkiness here is that since the return value of this
function is actually passed to later functions, and I am still trying to
break this port up into pieces, we deparse and reparse to convert to the
equivalent node in the old parser
- fix: the primary/replica evaluation order was inverted, so the replica
ban never triggered
- fix: `prefer_primary` incorrectly excluded primary when all replicas
were banned @stewart-glabs 🙏
- feat: show the reason why pools are unbanned (helps debugging)
This is what happens when you mark everything as `pub`, robots
This removes all `pub` from the module, deletes the little bit of dead
code there was, and got rid of a few impls that could be replaced by a
derive or a one-liner
THIS IS WHAT HAPPENS WHEN YOU MARK EVERYTHING AS PUB, ROBOTS
…ev#1140)

GEE IT SURE WOULD BE NICE IF LESS CODE WAS WRITTEN BY ROBOTS WHO MARK
EVERYTHING AS PUB
- feat: allow clients to manually pin their connections to do some
session-level stuff, e.g. temp tables. Use with caution: this steals
connections from the shared pool.
- feat: discard temporary tables when connections are pinned - this was
leaking them before, e.g. an advisory lock would pin and the client
could create temporary tables in the same exchange which would remain
behind.
- refactor: remove some dead code.
…oken (pgdogdev#1131)

Replace custom AbortSignal with CancellationToken to make the
cancellation explicit and not relied on futures drop (that holds rx part
of channel). Pass cancellation token for graceful cancellation. Early
cancellation on errors
I think the original intent here was to be able to have something like
`SELECT COUNT(foo::bigint), AVG(foo::bigint)` use the existing count
column based on any arbitrary expression. However, if this ever did fire
it would have been wrong for anything other than `COUNT`, since it
assumed things like typecasts were noops which is very much not the case
for any other aggregate function, and none of this matters anyway since
I deleted the helper deduplication a while ago. So instead we can just
use the index of the output column as the identifier for each helper.
…v#1136)

More specifically, this ports over the glue code as well as the last
subroutine of that function, `create_stmts`. This should be the beefiest
function we have to port (dear god please let this be the beefiest one).

The new implementation diverges from the old in a couple of ways:

- We deparse things lazily instead of eagerly
  - The old code was only deparsing eagerly due to the slowness of
    deparsing. This should no longer be slow enough to justify that. If
    needed we can memoize the result, but I'd rather have it be a memoized
    method than something upstream code cares about
- ShardingKeyUpdate holds the entire original UpdateStmt rather than
  smaller pieces
  - I actually tried having this hold onto references of the original
  query, but ultimately this struct ends up in a field that can't is a
  sibling of the original AST, so it can't hold a lifetime like that. I
  probably will revist that at some point, but for now copying the whole
  thing made more sense than copying each smaller piece into its own
  allocation

Other than the caveats mentioned above, the actual logic is mostly a
line for line port. The syntax is certainly very different for
constructing these trees, but it's a clean mapping each way.
This is more or less a line for line rewrite, though with some minor
refactoring to make the code flow a bit more clearly. A few tests got a
bit funky because they're assuming the AST passed to `Aggregate::parse`
is the same AST that gets rewritten later, which is not currently the
case. That funkiness should go away as soon as I port the aggregate
rewrite code to the new parser
This is mostly a line-for-line port with a few minor refactors to make
the code flow more smoothly.

The main structural difference in the refactor is that I've removed any
`return Ok(None)` that appeared other than cases where the node we're
looking at really does mean NULL. Things like `A_Const` with a value
other than an integer fall through to the `_` branch of the top level
match, so that if/when we change this to treat unrecognized nodes as an
error, it all happens in one place.
The API for how mutation works on the new parser is pretty fundamentally
different from the old one. It doesn't make sense to have it as a field
on the struct, and passing it explicitly will also make it clear when I
need to deparse/reparse as I'm porting individual functions over.
…#1151)

This is mostly a line for line port, with some minor refactoring to make
the code flow better. There is a minor behavioral change in that we
generate `SUM(arg::numeric * arg::numeric)` for sum of squares instead
of `SUM(POWER(arg::numeric, 2))`. This was slightly more convenient to
write, and in most languages `x * x` tends to be more performant than
`pow(x, 2)` since generic power functions have to handle fractional
exponents
We only do this if we have a CRUD statement, as this function gets
called with all the obscure top level statements (transaction, create,
etc) that PG errors with if passed to its walker
There's too many cfgs to be able to follow the code at this point
Add an opt-in read-your-writes floor. A client sets the `pgdog.min_lsn`
connection parameter (startup option or SET) to a commit LSN it must observe;
PgDog then routes the read only to a replica that has replayed at least that far.

- Reads carrying `pgdog.min_lsn` are filtered to replicas whose replayed LSN
  (from the existing LSN monitor) is >= the floor.
- When no replica qualifies, PgDog raises NoReplicaCaughtUp (SQLSTATE 58000)
  carrying a catch-up ETA ("eta ~Ns"), derived from the replica's replication lag
  in time (now() - pg_last_xact_replay_timestamp()), so a client can size a retry.
  The rejection logs at debug (expected backpressure), not error.
- Optional `min_lsn_primary_fallback`: an unmet floor falls back to the primary
  instead of erroring.
- `pgdog.min_lsn` is registered as an untracked parameter; inert when unset or on
  setups without replicas.

Includes unit tests (routing, NoReplicaCaughtUp, ETA-from-lag) and a pgx
integration test.
@mediprtl
mediprtl force-pushed the feat/min-lsn-read-routing branch from fc5705b to a2349ce Compare July 7, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read-your-writes: route reads to a replica caught up to a client min_lsn

8 participants