feat(db-postgres): support read replicas (#12728)

Adds support for read replicas
https://orm.drizzle.team/docs/read-replicas that can be used to offload
read-heavy traffic.

To use (both `db-postgres` and `db-vercel-postgres` are supported):
```ts
import { postgresAdapter } from '@payloadcms/db-postgres'

database: postgresAdapter({
  pool: {
    connectionString: process.env.POSTGRES_URL,
  },
  readReplicas: [process.env.POSTGRES_REPLICA_URL],
})
```

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Sasha
2025-06-09 22:09:52 +03:00
committed by GitHub
parent 865a9cd9d1
commit 0a357372e9
10 changed files with 122 additions and 13 deletions

View File

@@ -80,6 +80,7 @@ export default buildConfig({
| `afterSchemaInit` | Drizzle schema hook. Runs after the schema is built. [More Details](#afterschemainit) | | `afterSchemaInit` | Drizzle schema hook. Runs after the schema is built. [More Details](#afterschemainit) |
| `generateSchemaOutputFile` | Override generated schema from `payload generate:db-schema` file path. Defaults to `{CWD}/src/payload-generated.schema.ts` | | `generateSchemaOutputFile` | Override generated schema from `payload generate:db-schema` file path. Defaults to `{CWD}/src/payload-generated.schema.ts` |
| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. | | `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. |
| `readReplicas` | An array of DB read replicas connection strings, can be used to offload read-heavy traffic. |
## Access to Drizzle ## Access to Drizzle

View File

@@ -1,31 +1,32 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types' import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Connect, Migration, Payload } from 'payload' import type { Connect, Migration } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle' import { pushDevSchema } from '@payloadcms/drizzle'
import { drizzle } from 'drizzle-orm/node-postgres' import { drizzle } from 'drizzle-orm/node-postgres'
import { withReplicas } from 'drizzle-orm/pg-core'
import type { PostgresAdapter } from './types.js' import type { PostgresAdapter } from './types.js'
const connectWithReconnect = async function ({ const connectWithReconnect = async function ({
adapter, adapter,
payload, pool,
reconnect = false, reconnect = false,
}: { }: {
adapter: PostgresAdapter adapter: PostgresAdapter
payload: Payload pool: PostgresAdapter['pool']
reconnect?: boolean reconnect?: boolean
}) { }) {
let result let result
if (!reconnect) { if (!reconnect) {
result = await adapter.pool.connect() result = await pool.connect()
} else { } else {
try { try {
result = await adapter.pool.connect() result = await pool.connect()
} catch (ignore) { } catch (ignore) {
setTimeout(() => { setTimeout(() => {
payload.logger.info('Reconnecting to postgres') adapter.payload.logger.info('Reconnecting to postgres')
void connectWithReconnect({ adapter, payload, reconnect: true }) void connectWithReconnect({ adapter, pool, reconnect: true })
}, 1000) }, 1000)
} }
} }
@@ -35,7 +36,7 @@ const connectWithReconnect = async function ({
result.prependListener('error', (err) => { result.prependListener('error', (err) => {
try { try {
if (err.code === 'ECONNRESET') { if (err.code === 'ECONNRESET') {
void connectWithReconnect({ adapter, payload, reconnect: true }) void connectWithReconnect({ adapter, pool, reconnect: true })
} }
} catch (ignore) { } catch (ignore) {
// swallow error // swallow error
@@ -54,12 +55,29 @@ export const connect: Connect = async function connect(
try { try {
if (!this.pool) { if (!this.pool) {
this.pool = new this.pg.Pool(this.poolOptions) this.pool = new this.pg.Pool(this.poolOptions)
await connectWithReconnect({ adapter: this, payload: this.payload }) await connectWithReconnect({ adapter: this, pool: this.pool })
} }
const logger = this.logger || false const logger = this.logger || false
this.drizzle = drizzle({ client: this.pool, logger, schema: this.schema }) this.drizzle = drizzle({ client: this.pool, logger, schema: this.schema })
if (this.readReplicaOptions) {
const readReplicas = this.readReplicaOptions.map((connectionString) => {
const options = {
...this.poolOptions,
connectionString,
}
const pool = new this.pg.Pool(options)
void connectWithReconnect({
adapter: this,
pool,
})
return drizzle({ client: pool, logger, schema: this.schema })
})
const myReplicas = withReplicas(this.drizzle, readReplicas as any)
this.drizzle = myReplicas
}
if (!hotReload) { if (!hotReload) {
if (process.env.PAYLOAD_DROP_DATABASE === 'true') { if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
this.payload.logger.info(`---- DROPPING TABLES SCHEMA(${this.schemaName || 'public'}) ----`) this.payload.logger.info(`---- DROPPING TABLES SCHEMA(${this.schemaName || 'public'}) ----`)

View File

@@ -139,6 +139,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
prodMigrations: args.prodMigrations, prodMigrations: args.prodMigrations,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
push: args.push, push: args.push,
readReplicaOptions: args.readReplicas,
relations: {}, relations: {},
relationshipsSuffix: args.relationshipsSuffix || '_rels', relationshipsSuffix: args.relationshipsSuffix || '_rels',
schema: {}, schema: {},

View File

@@ -3,13 +3,19 @@ import type {
GenericEnum, GenericEnum,
MigrateDownArgs, MigrateDownArgs,
MigrateUpArgs, MigrateUpArgs,
PostgresDB,
PostgresSchemaHook, PostgresSchemaHook,
} from '@payloadcms/drizzle/postgres' } from '@payloadcms/drizzle/postgres'
import type { DrizzleAdapter } from '@payloadcms/drizzle/types' import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { DrizzleConfig } from 'drizzle-orm' import type { DrizzleConfig, ExtractTablesWithRelations } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type { PgSchema, PgTableFn, PgTransactionConfig } from 'drizzle-orm/pg-core' import type {
PgDatabase,
PgQueryResultHKT,
PgSchema,
PgTableFn,
PgTransactionConfig,
PgWithReplicas,
} from 'drizzle-orm/pg-core'
import type { Pool, PoolConfig } from 'pg' import type { Pool, PoolConfig } from 'pg'
type PgDependency = typeof import('pg') type PgDependency = typeof import('pg')
@@ -55,6 +61,7 @@ export type Args = {
up: (args: MigrateUpArgs) => Promise<void> up: (args: MigrateUpArgs) => Promise<void>
}[] }[]
push?: boolean push?: boolean
readReplicas?: string[]
relationshipsSuffix?: string relationshipsSuffix?: string
/** /**
* The schema name to use for the database * The schema name to use for the database
@@ -74,7 +81,16 @@ type ResolveSchemaType<T> = 'schema' extends keyof T
? T['schema'] ? T['schema']
: GeneratedDatabaseSchema['schemaUntyped'] : GeneratedDatabaseSchema['schemaUntyped']
type Drizzle = NodePgDatabase<ResolveSchemaType<GeneratedDatabaseSchema>> type Drizzle =
| NodePgDatabase<ResolveSchemaType<GeneratedDatabaseSchema>>
| PgWithReplicas<
PgDatabase<
PgQueryResultHKT,
Record<string, unknown>,
ExtractTablesWithRelations<Record<string, unknown>>
>
>
export type PostgresAdapter = { export type PostgresAdapter = {
drizzle: Drizzle drizzle: Drizzle
pg: PgDependency pg: PgDependency

View File

@@ -4,6 +4,7 @@ import type { Connect, Migration } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle' import { pushDevSchema } from '@payloadcms/drizzle'
import { sql, VercelPool } from '@vercel/postgres' import { sql, VercelPool } from '@vercel/postgres'
import { drizzle } from 'drizzle-orm/node-postgres' import { drizzle } from 'drizzle-orm/node-postgres'
import { withReplicas } from 'drizzle-orm/pg-core'
import pg from 'pg' import pg from 'pg'
import type { VercelPostgresAdapter } from './types.js' import type { VercelPostgresAdapter } from './types.js'
@@ -46,6 +47,19 @@ export const connect: Connect = async function connect(
schema: this.schema, schema: this.schema,
}) })
if (this.readReplicaOptions) {
const readReplicas = this.readReplicaOptions.map((connectionString) => {
const options = {
...this.poolOptions,
connectionString,
}
const pool = new VercelPool(options)
return drizzle({ client: pool, logger, schema: this.schema })
})
const myReplicas = withReplicas(this.drizzle, readReplicas as any)
this.drizzle = myReplicas
}
if (!hotReload) { if (!hotReload) {
if (process.env.PAYLOAD_DROP_DATABASE === 'true') { if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
this.payload.logger.info(`---- DROPPING TABLES SCHEMA(${this.schemaName || 'public'}) ----`) this.payload.logger.info(`---- DROPPING TABLES SCHEMA(${this.schemaName || 'public'}) ----`)

View File

@@ -174,6 +174,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
find, find,
findGlobal, findGlobal,
findGlobalVersions, findGlobalVersions,
readReplicaOptions: args.readReplicas,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
findOne, findOne,
findVersions, findVersions,

View File

@@ -64,6 +64,7 @@ export type Args = {
up: (args: MigrateUpArgs) => Promise<void> up: (args: MigrateUpArgs) => Promise<void>
}[] }[]
push?: boolean push?: boolean
readReplicas?: string[]
relationshipsSuffix?: string relationshipsSuffix?: string
/** /**
* The schema name to use for the database * The schema name to use for the database

View File

@@ -159,6 +159,7 @@ export type BasePostgresAdapter = {
up: (args: MigrateUpArgs) => Promise<void> up: (args: MigrateUpArgs) => Promise<void>
}[] }[]
push: boolean push: boolean
readReplicaOptions?: string[]
rejectInitializing: () => void rejectInitializing: () => void
relations: Record<string, GenericRelation> relations: Record<string, GenericRelation>
relationshipsSuffix?: string relationshipsSuffix?: string

View File

@@ -0,0 +1,36 @@
# Copyright Broadcom, Inc. All Rights Reserved.
# SPDX-License-Identifier: APACHE-2.0
services:
postgresql-master:
image: docker.io/bitnami/postgresql:17
ports:
- '5433:5432'
volumes:
- 'postgresql_master_data:/bitnami/postgresql'
environment:
- POSTGRESQL_REPLICATION_MODE=master
- POSTGRESQL_REPLICATION_USER=repl_user
- POSTGRESQL_REPLICATION_PASSWORD=repl_password
- POSTGRESQL_USERNAME=postgres
- POSTGRESQL_PASSWORD=my_password
- POSTGRESQL_DATABASE=my_database
- ALLOW_EMPTY_PASSWORD=yes
postgresql-slave:
image: docker.io/bitnami/postgresql:17
ports:
- '5434:5432'
depends_on:
- postgresql-master
environment:
- POSTGRESQL_REPLICATION_MODE=slave
- POSTGRESQL_REPLICATION_USER=repl_user
- POSTGRESQL_REPLICATION_PASSWORD=repl_password
- POSTGRESQL_MASTER_HOST=postgresql-master
- POSTGRESQL_PASSWORD=my_password
- POSTGRESQL_MASTER_PORT_NUMBER=5432
- ALLOW_EMPTY_PASSWORD=yes
volumes:
postgresql_master_data:
driver: local

View File

@@ -47,6 +47,26 @@ export const allDatabaseAdapters = {
connectionString: process.env.POSTGRES_URL || 'postgres://127.0.0.1:5432/payloadtests', connectionString: process.env.POSTGRES_URL || 'postgres://127.0.0.1:5432/payloadtests',
}, },
})`, })`,
'postgres-read-replica': `
import { postgresAdapter } from '@payloadcms/db-postgres'
export const databaseAdapter = postgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL,
},
readReplicas: [process.env.POSTGRES_REPLICA_URL],
})
`,
'vercel-postgres-read-replica': `
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
export const databaseAdapter = vercelPostgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL,
},
readReplicas: [process.env.POSTGRES_REPLICA_URL],
})
`,
sqlite: ` sqlite: `
import { sqliteAdapter } from '@payloadcms/db-sqlite' import { sqliteAdapter } from '@payloadcms/db-sqlite'