feat: optional read replica, and one query to identify the caller - #5
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a second connection pool against a Postgres standby, off by default.
ichat.datasource.replica.enabledgates 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.