feat(phase2): add PostgreSQL multi-pool service and HA infrastructure

- Add MultiPoolService: registry of named Sequelize pools with create/get/close/healthcheck
- Add multi-pool config: write host + read replica host with separate ports
- Add DB_CONNECTION_STRING and DB_READ_HOST/DB_READ_PORT env vars
- Add docker-compose.ha.yml: Patroni + etcd cluster, HAProxy, PgBouncer
- Add HAProxy config: routes 5432 (write/primary) / 5433 (read/replica)
- Add PgBouncer userlist template
- Update dev/prod/staging configs with multi-pool hosts
Co-authored-by: 's avatarCursor <cursoragent@cursor.com>
parent 60ca47bd
...@@ -16,6 +16,11 @@ DB_PORT=5432 ...@@ -16,6 +16,11 @@ DB_PORT=5432
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_NAME=sso DB_NAME=sso
# Connection string (optional: use instead of individual DB_HOST/DB_USER/DB_PASSWORD)
DB_CONNECTION_STRING=
# Read replica host (for read/write split)
DB_READ_HOST=
DB_READ_PORT=5432
DB_POOL_MAX=50 DB_POOL_MAX=50
DB_POOL_MIN=5 DB_POOL_MIN=5
DB_POOL_ACQUIRE=30000 DB_POOL_ACQUIRE=30000
......
# ─────────────────────────────────────────────────────────────────
# SSO HA Infrastructure — PostgreSQL Patroni Cluster + PgBouncer + HAProxy
# Extends docker-compose.yml (requires postgres, mongo, redis, minio services)
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.ha.yml up -d
#
# Services added by this file:
# etcd1, etcd2, etcd3 — distributed consensus store
# postgres1, postgres2, postgres3 — Patroni-managed PostgreSQL cluster
# haproxy — routes write (5432) / read (5433)
# pgbouncer — connection pooling (transaction mode)
# ─────────────────────────────────────────────────────────────────
x-pool-common: &pool-common
max_client_conn: 500
default_pool_size: 25
pool_mode: transaction
server_lifetime: 3600
server_idle_timeout: 600
services:
# ── etcd cluster ────────────────────────────────────────────────
etcd1:
image: quay.io/coreos/etcd:v3.5.15
container_name: sso-etcd1
hostname: etcd1
environment:
ETCD_NAME: etcd1
ETCD_DATA_DIR: /etcd-data
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: sso-ha-cluster
ETCD_ADVERTISE_CLIENT_URLS: http://etcd1:2379
ETCD_ENABLE_V2: true
ports:
- '2379:2379'
volumes:
- etcd1-data:/etcd-data
networks:
- sso-network
restart: unless-stopped
healthcheck:
test: ['CMD', 'etcdctl', '--endpoints=http://localhost:2379', 'endpoint', 'health']
interval: 10s
timeout: 5s
retries: 5
etcd2:
image: quay.io/coreos/etcd:v3.5.15
container_name: sso-etcd2
hostname: etcd2
environment:
ETCD_NAME: etcd2
ETCD_DATA_DIR: /etcd-data
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: sso-ha-cluster
ETCD_ADVERTISE_CLIENT_URLS: http://etcd2:2379
ETCD_ENABLE_V2: true
ports:
- '2380:2379'
volumes:
- etcd2-data:/etcd-data
networks:
- sso-network
restart: unless-stopped
healthcheck:
test: ['CMD', 'etcdctl', '--endpoints=http://localhost:2379', 'endpoint', 'health']
interval: 10s
timeout: 5s
retries: 5
etcd3:
image: quay.io/coreos/etcd:v3.5.15
container_name: sso-etcd3
hostname: etcd3
environment:
ETCD_NAME: etcd3
ETCD_DATA_DIR: /etcd-data
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: sso-ha-cluster
ETCD_ADVERTISE_CLIENT_URLS: http://etcd3:2379
ETCD_ENABLE_V2: true
volumes:
- etcd3-data:/etcd-data
networks:
- sso-network
restart: unless-stopped
healthcheck:
test: ['CMD', 'etcdctl', '--endpoints=http://localhost:2379', 'endpoint', 'health']
interval: 10s
timeout: 5s
retries: 5
# ── PostgreSQL Patroni cluster ─────────────────────────────────
# NOTE: Requires custom Docker image with Patroni installed.
# Build with: docker build -t sso-vietprodev-postgres -f Dockerfile.patroni .
postgres1:
image: sso-vietprodev-postgres:latest
container_name: sso-postgres1
hostname: postgres1
user: postgres
environment:
PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres1
volumes:
- postgres1-data:/var/lib/postgresql/data
networks:
- sso-network
restart: unless-stopped
postgres2:
image: sso-vietprodev-postgres:latest
container_name: sso-postgres2
hostname: postgres2
user: postgres
environment:
PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres2
volumes:
- postgres2-data:/var/lib/postgresql/data
networks:
- sso-network
restart: unless-stopped
postgres3:
image: sso-vietprodev-postgres:latest
container_name: sso-postgres3
hostname: postgres3
user: postgres
environment:
PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres3
volumes:
- postgres3-data:/var/lib/postgresql/data
networks:
- sso-network
restart: unless-stopped
# ── HAProxy ──────────────────────────────────────────────────────
# Routes: :5432 (write) → primary PG | :5433 (read) → all replicas
haproxy:
image: haproxy:2.9
container_name: sso-haproxy
hostname: haproxy
ports:
- '5433:5432' # Write endpoint
- '5434:5433' # Read endpoint
- '8404:8404' # Stats UI
volumes:
- ./infrastructure/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
postgres1:
condition: service_started
postgres2:
condition: service_started
postgres3:
condition: service_started
networks:
- sso-network
restart: unless-stopped
healthcheck:
test: ['CMD', 'haproxy', '-c', '-f', '/usr/local/etc/haproxy/haproxy.cfg']
interval: 10s
timeout: 5s
retries: 3
# ── PgBouncer ───────────────────────────────────────────────────
# Connection pooling in transaction mode in front of HAProxy
pgbouncer:
image: edoburu/pgbouncer:latest
container_name: sso-pgbouncer
hostname: pgbouncer
environment:
DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@haproxy:5432/${DB_NAME:-sso}
POOL_MODE: transaction
MAX_CLIENT_CONN: 500
DEFAULT_POOL_SIZE: 25
ports:
- '6432:5432'
volumes:
- ./infrastructure/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro
depends_on:
haproxy:
condition: service_started
networks:
- sso-network
restart: unless-stopped
volumes:
etcd1-data:
etcd2-data:
etcd3-data:
postgres1-data:
postgres2-data:
postgres3-data:
# ─────────────────────────────────────────────
# HAProxy configuration for SSO PostgreSQL HA
# Routes: 5432 (write) / 5433 (read)
# ─────────────────────────────────────────────
global
log stdout format raw local0
maxconn 4096
defaults
log global
mode tcp
timeout connect 5s
timeout client 60s
timeout server 60s
# ── Write endpoint (primary) ─────────────────
frontend postgres_write
bind *:5432
default_backend postgres_primary
backend postgres_primary
option httpchk GET /primary
http-check expect status 200
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3
# ── Read endpoint (replicas) ───────────────
frontend postgres_read
bind *:5433
default_backend postgres_replica
backend postgres_replica
option httpchk GET /replica
http-check expect status 200
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3
# ── Stats UI ─────────────────────────────────
listen stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 30s
# ─────────────────────────────────────────────
# PgBouncer userlist for SSO
# Format: "username" "password_hash"
# Generate hash: echo -n "password" | pg_md5
# ─────────────────────────────────────────────
"postgres" "SCRAM-SHA-256$..."
...@@ -15,7 +15,10 @@ export const baseConfig: Config = { ...@@ -15,7 +15,10 @@ export const baseConfig: Config = {
port: 5432, port: 5432,
user: 'postgres', user: 'postgres',
password: '', password: '',
name: 'bekind', name: 'sso',
connectionString: '',
readHost: '',
readPort: 5432,
slowQueryThresholdMs: 500, slowQueryThresholdMs: 500,
pool: { pool: {
max: 50, max: 50,
......
...@@ -10,6 +10,14 @@ export const developmentConfig: Partial<Config> = { ...@@ -10,6 +10,14 @@ export const developmentConfig: Partial<Config> = {
backendUrl: 'http://localhost:3001', backendUrl: 'http://localhost:3001',
frontendUrl: 'http://localhost:3000,http://localhost:3001', frontendUrl: 'http://localhost:3000,http://localhost:3001',
}, },
database: {
...baseConfig.database,
host: 'localhost',
port: 5432,
connectionString: 'postgres://postgres:postgres@localhost:5432/sso',
readHost: '',
readPort: 5432,
},
redis: { redis: {
...baseConfig.redis, ...baseConfig.redis,
enabled: false, enabled: false,
......
...@@ -15,7 +15,10 @@ export const EnvSchema = z.object({ ...@@ -15,7 +15,10 @@ export const EnvSchema = z.object({
DB_PORT: z.coerce.number().default(5432), DB_PORT: z.coerce.number().default(5432),
DB_USER: z.string().default('postgres'), DB_USER: z.string().default('postgres'),
DB_PASSWORD: z.string().default(''), DB_PASSWORD: z.string().default(''),
DB_NAME: z.string().default('bekind'), DB_NAME: z.string().default('sso'),
DB_CONNECTION_STRING: z.string().default(''),
DB_READ_HOST: z.string().default(''),
DB_READ_PORT: z.coerce.number().default(5432),
SLOW_QUERY_THRESHOLD_MS: z.coerce.number().default(500), SLOW_QUERY_THRESHOLD_MS: z.coerce.number().default(500),
DB_POOL_MAX: z.coerce.number().default(50), DB_POOL_MAX: z.coerce.number().default(50),
DB_POOL_MIN: z.coerce.number().default(5), DB_POOL_MIN: z.coerce.number().default(5),
......
...@@ -86,6 +86,9 @@ function buildConfig(): Config { ...@@ -86,6 +86,9 @@ function buildConfig(): Config {
user: envVars.DB_USER, user: envVars.DB_USER,
password: envVars.DB_PASSWORD, password: envVars.DB_PASSWORD,
name: envVars.DB_NAME, name: envVars.DB_NAME,
connectionString: envVars.DB_CONNECTION_STRING,
readHost: envVars.DB_READ_HOST,
readPort: envVars.DB_READ_PORT,
slowQueryThresholdMs: envVars.SLOW_QUERY_THRESHOLD_MS, slowQueryThresholdMs: envVars.SLOW_QUERY_THRESHOLD_MS,
pool: { pool: {
max: envVars.DB_POOL_MAX, max: envVars.DB_POOL_MAX,
......
...@@ -7,8 +7,16 @@ export const productionConfig: Partial<Config> = { ...@@ -7,8 +7,16 @@ export const productionConfig: Partial<Config> = {
server: { server: {
...baseConfig.server, ...baseConfig.server,
port: 3001, port: 3001,
backendUrl: 'https://api.bekind.com', backendUrl: 'https://api.sso.vietprodev.com',
frontendUrl: 'https://bekind.com,https://www.bekind.com', frontendUrl: 'https://app.vietprodev.com,https://admin.vietprodev.com',
},
database: {
...baseConfig.database,
host: 'pgbouncer',
port: 5432,
connectionString: '',
readHost: 'haproxy',
readPort: 5433,
}, },
redis: { redis: {
...baseConfig.redis, ...baseConfig.redis,
......
...@@ -17,6 +17,9 @@ const DatabaseSchema = z.object({ ...@@ -17,6 +17,9 @@ const DatabaseSchema = z.object({
user: z.string(), user: z.string(),
password: z.string(), password: z.string(),
name: z.string(), name: z.string(),
connectionString: z.string().default(''),
readHost: z.string().default(''),
readPort: z.coerce.number().default(5432),
slowQueryThresholdMs: z.coerce.number().default(500), slowQueryThresholdMs: z.coerce.number().default(500),
pool: z.object({ pool: z.object({
max: z.coerce.number().default(50), max: z.coerce.number().default(50),
......
...@@ -6,17 +6,20 @@ import { productionConfig } from './production'; ...@@ -6,17 +6,20 @@ import { productionConfig } from './production';
export const stagingConfig: Partial<Config> = { export const stagingConfig: Partial<Config> = {
server: { server: {
port: 3001, port: 3001,
backendUrl: 'https://staging-api.bekind.com', backendUrl: 'https://staging-api.sso.vietprodev.com',
frontendUrl: 'https://staging.bekind.com,https://staging-admin.bekind.com', frontendUrl: 'https://staging.app.vietprodev.com,https://staging-admin.vietprodev.com',
projectName: productionConfig.server?.projectName || 'Bekind Backend', projectName: productionConfig.server?.projectName || 'SSO VietProDev',
projectVersion: productionConfig.server?.projectVersion || '1.0.0', projectVersion: productionConfig.server?.projectVersion || '1.0.0',
}, },
database: { database: {
host: 'staging-db.bekind.com', host: 'staging-pgbouncer.vietprodev.com',
port: productionConfig.database?.port || 5432, port: productionConfig.database?.port || 5432,
user: productionConfig.database?.user || 'postgres', user: productionConfig.database?.user || 'postgres',
password: productionConfig.database?.password || '', password: productionConfig.database?.password || '',
name: 'bekind_staging', name: 'sso_staging',
connectionString: '',
readHost: 'staging-haproxy.vietprodev.com',
readPort: 5433,
slowQueryThresholdMs: productionConfig.database?.slowQueryThresholdMs || 500, slowQueryThresholdMs: productionConfig.database?.slowQueryThresholdMs || 500,
pool: { pool: {
max: productionConfig.database?.pool?.max || 50, max: productionConfig.database?.pool?.max || 50,
...@@ -60,17 +63,17 @@ export const stagingConfig: Partial<Config> = { ...@@ -60,17 +63,17 @@ export const stagingConfig: Partial<Config> = {
jwtRefreshExpiresIn: productionConfig.auth?.jwtRefreshExpiresIn || '7d', jwtRefreshExpiresIn: productionConfig.auth?.jwtRefreshExpiresIn || '7d',
tokenEncryptionKey: productionConfig.auth?.tokenEncryptionKey || '', tokenEncryptionKey: productionConfig.auth?.tokenEncryptionKey || '',
tokenEncryptionKeyPrevious: productionConfig.auth?.tokenEncryptionKeyPrevious || [], tokenEncryptionKeyPrevious: productionConfig.auth?.tokenEncryptionKeyPrevious || [],
bcryptRounds: 10, // Slightly faster than production for testing bcryptRounds: 10,
defaultPassword: productionConfig.auth?.defaultPassword || '', defaultPassword: productionConfig.auth?.defaultPassword || 'Vietpro@123',
passwordMinLength: productionConfig.auth?.passwordMinLength || 8, passwordMinLength: productionConfig.auth?.passwordMinLength || 8,
passwordMaxLength: productionConfig.auth?.passwordMaxLength || 128, passwordMaxLength: productionConfig.auth?.passwordMaxLength || 128,
includeTokensInResponse: true, // Easier debugging in staging includeTokensInResponse: true,
skipSensitiveConfirm: false, // Keep security strict skipSensitiveConfirm: false,
enableRegister: productionConfig.auth?.enableRegister ?? true, enableRegister: productionConfig.auth?.enableRegister ?? true,
passwordHistoryLimit: productionConfig.auth?.passwordHistoryLimit || 5, passwordHistoryLimit: productionConfig.auth?.passwordHistoryLimit || 5,
}, },
cookie: { cookie: {
domain: '.meucorp.com', domain: '.vietprodev.com',
crossSite: productionConfig.cookie?.crossSite ?? false, crossSite: productionConfig.cookie?.crossSite ?? false,
forceSecure: productionConfig.cookie?.forceSecure ?? true, forceSecure: productionConfig.cookie?.forceSecure ?? true,
devSameSiteLax: productionConfig.cookie?.devSameSiteLax ?? false, devSameSiteLax: productionConfig.cookie?.devSameSiteLax ?? false,
......
import { Sequelize } from 'sequelize';
import { LoggingService } from '#services/file-system/logService';
const logger = new LoggingService();
export interface PoolConfig {
username: string;
password: string;
database: string;
host: string;
port: number;
pool?: {
max: number;
min: number;
acquire: number;
idle: number;
evict: number;
};
slowQueryThresholdMs?: number;
}
const slowQueryThresholdMs = 500;
export class MultiPoolService {
private static readonly pools = new Map<string, Sequelize>();
static getPool(name: string): Sequelize {
const existing = this.pools.get(name);
if (existing) return existing;
throw new Error(`Pool '${name}' not found. Did you call createPool() first?`);
}
static hasPool(name: string): boolean {
return this.pools.has(name);
}
static createPool(name: string, config: PoolConfig): Sequelize {
const existing = this.pools.get(name);
if (existing) {
void existing.close();
this.pools.delete(name);
}
const pool = config.pool ?? { max: 20, min: 2, acquire: 30000, idle: 10000, evict: 60000 };
const sequelize = new Sequelize({
username: config.username,
password: config.password,
database: config.database,
host: config.host,
port: config.port,
dialect: 'postgres',
pool: {
max: pool.max,
min: pool.min,
acquire: pool.acquire,
idle: pool.idle,
evict: pool.evict,
},
benchmark: true,
logging(sql, timing) {
const durationMs = typeof timing === 'number' ? timing : null;
if (durationMs !== null && durationMs >= (config.slowQueryThresholdMs ?? slowQueryThresholdMs)) {
void logger.logAsync('slow_query', 'Sequelize', `[${durationMs}ms] ${sql}`, null);
} else {
void logger.logDBAsync(sql);
}
},
});
this.pools.set(name, sequelize);
return sequelize;
}
static async closeAll(): Promise<void> {
await Promise.all([...this.pools.values()].map((pool) => pool.close()));
this.pools.clear();
}
static async healthCheck(name: string): Promise<boolean> {
const pool = this.pools.get(name);
if (!pool) return false;
try {
await pool.authenticate();
return true;
} catch {
return false;
}
}
static listPools(): string[] {
return [...this.pools.keys()];
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment