Add read/write splitting to the pg module #2
Reference in New Issue
Block a user
Delete Branch "benvin/pg-rw-split"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Stacked on #1 — based on
benvin/initial-pg, so the diff stays to the read/write splitting. Gitea retargets it tomainwhen #1 merges.Why
Services in the estate read far more than they write, and CloudNativePG already publishes a
<cluster>-roservice pointing at the replicas that nothing uses. Every service that wants to use it has to grow its own second pool, its own fallback when the replica is down, and its own rule about which queries are safe to send there. That is the same copy-paste golib exists to absorb.Routing has to be explicit rather than inferred from the SQL. Statement inspection gets it wrong in both directions: a CTE with an
INSERTin it reads as aSELECTand would be sent to a read-only replica, andSELECT ... FOR UPDATEreads as a plain query but takes row locks a replica cannot grant. The caller knows which it wants, so the caller picks.How
pg.Cluster, wrapping a primary and an optional replica*pgxpool.Pool.Write()always returns the primary;Read()returns the replica when one is configured and healthy, the primary otherwise;Primary()is the same pool asWrite(), named for the read-your-writes case.ReplicaDSNis empty, or equal toPrimaryDSN, so a service can useClusterunconditionally and the deployment decides whether reads split.ReportReplicaError(err)trips it; while tripped no probe is issued until the backoff window expires, and the next read then pays for one probe that either promotes the replica or doubles the window, fromReplicaRetryMintoReplicaRetryMax. A healthy replica is never probed, so the split costs nothing on the read path.pg.ClusterDSNsFromEnv(prefix), resolving the replica from<PREFIX>DATABASE_RO_URL,DATABASE_RO_URL, or<PREFIX>DB_RO_HOST/DB_RO_HOSTsubstituted into the primary's other fields. Nothing is derived — the read-only host is never rewritten out of the primary's, and a host-only replica variable alongside a whole-URL primary is an error rather than a guess.ClusteraMigratethat runs throughWrite(), so migrations cannot reach a replica.DSNFromEnv's field resolution intoconnPartsFromEnvso both endpoints share it; the rendered primary DSN is unchanged.Primary()escape hatch, the no-SQL-parsing rationale, the single-pool fallback, and the CNPG-rw/-roenv shape in the README.ClusterDSNsFromEnvtable, and a migration that must reach the primary while the replica is the poolRead()would hand out. Extend the container-backed integration test with one server standing in for both endpoints.make coverreports 93.1% (gate 90%);gofmt,go vet,golangci-lintandpre-commitare clean.Both review findings addressed in
0223391.ClusterDSNsFromEnvnow names the replica host variable that actually resolved.lookupfalls back from the prefixed name to the bare one, but the ambiguous-replica error printed the prefixed form unconditionally, soAPP_DATABASE_URL+ bareDB_RO_HOSTtold the operator to look atAPP_DB_RO_HOST, which was not in their environment. NewlookupNamedreturns the variable the value came from;TestClusterDSNsFromEnv_Errorsgains the bare-var case and asserts the prefixed name does not appear.NewClusternow builds the replica pool before the primary. pgxpool connects lazily so nothing is dialled, but the replica DSN is parsed, which means a bad one fails before any primary pool exists — the untestableprimary.Close()cleanup branch is gone rather than merely asserted, andnewClusteris infallible.TestNewCluster_RejectsAnUnparseableReplicaDSNBeforeOpeningThePrimarygoes through the public constructor with an unreachable primary and asserts the replica error, notping postgres, comes back. The mirror path (primary unreachable, replica pool already open) closes the replica and is exercised byTestNewCluster_PropagatesPrimaryFailure.make cover95.0% (min 90); gofmt, go vet, golangci-lint and pre-commit all clean.