Skip to content

feat: optional read replica, and one query to identify the caller - #5

Merged
olavgg merged 1 commit into
mainfrom
feat/read-replica
Aug 9, 2026
Merged

feat: optional read replica, and one query to identify the caller#5
olavgg merged 1 commit into
mainfrom
feat/read-replica

Conversation

@olavgg

@olavgg olavgg commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Adds a second connection pool against a Postgres standby, off by default. ichat.datasource.replica.enabled gates the whole thing, and off means the beans do not exist rather than sitting idle — a deployment that never asks for one gains no proxy, no second pool and no new way to fail. Same contract as the optional Vault backend, which can now also carry the replica: db.replica-enabled, db.replica-url, db.replica-username, db.replica-password (plus db.url for the primary, which was env-only). The enabled flag is mapped deliberately, since @ConditionalOnProperty evaluates long after an EnvironmentPostProcessor runs and splitting one topology decision across Vault and the env file is how the two drift.

Routing is @Transactional(readOnly = true) and nothing else. A LazyConnectionDataSourceProxy fronts both pools with the replica as its read-only variant; HibernateJpaDialect marks the transaction's connection read-only before the proxy has fetched a physical one, and the proxy then takes that one from the replica. The laziness is the mechanism, not a tuning choice: Hibernate checks a connection out when the transaction begins, which is before the read-only flag exists, so a pool wired straight to the EntityManagerFactory would have taken a primary connection before anything could route it.

Writes, Flyway (pinned with @FlywayDataSource rather than left to follow @primary) and raw JDBC stay on the primary. A Spring Data repository call from a non-transactional caller does not: SimpleJpaRepository declares readOnly = true, so it opens its own read-only transaction and reaches the replica.

Three reads that drive deletes are moved off readOnly deliberately. The Lucene<->Postgres reconciles in CleanupTasks and LuceneBootstrap compare the two and drop what looks stale; on a lagging replica a just-committed message reads as absent from Postgres, and the sweep would delete the search document for a message that exists. Each carries a comment saying so, because the annotation looks like an oversight without one.

The replica pool is read-only at the driver, not per transaction, because LazyConnectionDataSourceProxy suppresses the per-transaction setReadOnly once a dedicated read-only DataSource is configured. A startup check warns when pg_is_in_recovery() is false — the failure where "replica" points back at the primary and the deployment runs two pools against one machine believing it split the load.

Second change, in the same shape: resolving a logged-in principal used to cost two queries inside a writable transaction on every authenticated request, to re-derive a row that had not changed since the request before. UserService.findUnchanged answers the same question with one select in a read-only transaction, and only when the stored row agrees with the token on every field the write path sets; anything else falls through to upsert, which is unchanged and still the only thing that writes. Both halves read claims through one shared ClaimView, because a second copy of the claim-reading logic would let them disagree about whether anything changed — a write on every request and no error anywhere. upsert also skips the collision query when the account already holds the handle, which uk_users_username_lower makes provably redundant.

That fast path is what decides whether a replica is worth having. The old cost fell on every request regardless of how little it did, so it dominated exactly the light endpoints a replica is otherwise best at absorbing: a page load moves from about 80% of its reads on the replica to 90-95%, and an endpoint that only reads now puts nothing on the primary at all.

It also weakens one documented invariant, so the comment claiming it no longer does. CurrentUser's suspension backstop reads a row that may be up to the replication lag old. A ban through BanService is unaffected — it updates SuspensionRegistry before writing the row, and the filter refuses the request before it reaches the backstop. What lags is what the registry cannot see: a suspended_at edited directly in psql, or a ban issued by another node. Delayed by the lag, not missed.

Tests: ReadReplicaDataSourceConfigTest (7 cases) and UserProvisioningFastPathTest (10 cases) run without a container and pass. ReadReplicaRoutingIT proves the routing itself against a real Postgres — one container, two pools tagged with different ApplicationNames, asking the server which one served each transaction — and UserProvisioningIT covers the round-trip property mocks cannot reach, that upsert settles a row into exactly the shape findUnchanged tests for. Neither IT has been executed: this machine has no container runtime.

Adds a second connection pool against a Postgres standby, off by default.
`ichat.datasource.replica.enabled` gates the whole thing, and off means the
beans do not exist rather than sitting idle — a deployment that never asks for
one gains no proxy, no second pool and no new way to fail. Same contract as the
optional Vault backend, which can now also carry the replica: db.replica-enabled,
db.replica-url, db.replica-username, db.replica-password (plus db.url for the
primary, which was env-only). The enabled flag is mapped deliberately, since
@ConditionalOnProperty evaluates long after an EnvironmentPostProcessor runs and
splitting one topology decision across Vault and the env file is how the two
drift.

Routing is `@Transactional(readOnly = true)` and nothing else. A
LazyConnectionDataSourceProxy fronts both pools with the replica as its
read-only variant; HibernateJpaDialect marks the transaction's connection
read-only before the proxy has fetched a physical one, and the proxy then takes
that one from the replica. The laziness is the mechanism, not a tuning choice:
Hibernate checks a connection out when the transaction begins, which is before
the read-only flag exists, so a pool wired straight to the EntityManagerFactory
would have taken a primary connection before anything could route it.

Writes, Flyway (pinned with @FlywayDataSource rather than left to follow
@primary) and raw JDBC stay on the primary. A Spring Data repository call from a
non-transactional caller does not: SimpleJpaRepository declares readOnly = true,
so it opens its own read-only transaction and reaches the replica.

Three reads that drive deletes are moved off readOnly deliberately. The
Lucene<->Postgres reconciles in CleanupTasks and LuceneBootstrap compare the two
and drop what looks stale; on a lagging replica a just-committed message reads
as absent from Postgres, and the sweep would delete the search document for a
message that exists. Each carries a comment saying so, because the annotation
looks like an oversight without one.

The replica pool is read-only at the driver, not per transaction, because
LazyConnectionDataSourceProxy suppresses the per-transaction setReadOnly once a
dedicated read-only DataSource is configured. A startup check warns when
pg_is_in_recovery() is false — the failure where "replica" points back at the
primary and the deployment runs two pools against one machine believing it split
the load.

Second change, in the same shape: resolving a logged-in principal used to cost
two queries inside a writable transaction on every authenticated request, to
re-derive a row that had not changed since the request before.
UserService.findUnchanged answers the same question with one select in a
read-only transaction, and only when the stored row agrees with the token on
every field the write path sets; anything else falls through to upsert, which is
unchanged and still the only thing that writes. Both halves read claims through
one shared ClaimView, because a second copy of the claim-reading logic would let
them disagree about whether anything changed — a write on every request and no
error anywhere. upsert also skips the collision query when the account already
holds the handle, which uk_users_username_lower makes provably redundant.

That fast path is what decides whether a replica is worth having. The old cost
fell on every request regardless of how little it did, so it dominated exactly
the light endpoints a replica is otherwise best at absorbing: a page load moves
from about 80% of its reads on the replica to 90-95%, and an endpoint that only
reads now puts nothing on the primary at all.

It also weakens one documented invariant, so the comment claiming it no longer
does. CurrentUser's suspension backstop reads a row that may be up to the
replication lag old. A ban through BanService is unaffected — it updates
SuspensionRegistry before writing the row, and the filter refuses the request
before it reaches the backstop. What lags is what the registry cannot see: a
suspended_at edited directly in psql, or a ban issued by another node. Delayed
by the lag, not missed.

Tests: ReadReplicaDataSourceConfigTest (7 cases) and UserProvisioningFastPathTest
(10 cases) run without a container and pass. ReadReplicaRoutingIT proves the
routing itself against a real Postgres — one container, two pools tagged with
different ApplicationNames, asking the server which one served each transaction —
and UserProvisioningIT covers the round-trip property mocks cannot reach, that
upsert settles a row into exactly the shape findUnchanged tests for. Neither IT
has been executed: this machine has no container runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@olavgg
olavgg merged commit 518261e into main Aug 9, 2026
1 check passed
@olavgg
olavgg deleted the feat/read-replica branch August 9, 2026 11:40
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.

1 participant