ReplicaManager routes read queries across a set of replica databases, tracksimport { ReplicaManager } from 'ts-prorm-orm';
const replicas = new ReplicaManager({
enabled: true,
defaultRoutingStrategy: 'lowest-lag',
autoFailover: true,
lagDetection: {
enabled: true,
interval: 5_000, // check every 5s
maxAcceptableLag: 2_000, // 2s behind is too far
maxFailures: 3, // 3 failed checks in a row -> unhealthy
timeout: 1_000,
},
});
await replicas.addReplica({
name: 'eu-west-1a',
dialect: 'postgres',
host: 'replica-1.internal', port: 5432,
database: 'app', username: 'reader', password: process.env.PG_PASSWORD!,
pool: { min: 2, max: 10 },
});
replicas.startLagMonitor();
const { rows } = await replicas.queryReplica("SELECT id, email FROM users WHERE status = 'active'");queryReplica(sql, options) passes positional replacements or bind totimeout as the per-query timeoutquery_timeout on PostgreSQL):await replicas.queryReplica('SELECT id, email FROM users WHERE status = $1', { replacements: ['active'] });{ id: 1 } for :id) are refused with named
replacements are not supported. Before 4.9.2, queryReplica sent the SQLReplicaPool opens pools for and knows how to measure. Any other dialectUnsupported dialect for replica <name>.DB_HOST=primary.internal
DB_NAME=app
DB_USER=app
DB_PASSWORD=change-me
REPLICA_HOSTS=replica-1.internal,replica-2.internal
REPLICA_USER=reader
REPLICA_PASSWORD=change-me-tooSELECT; if it cannot write, a misrouted writeimport { Prorm, ReplicaManager } from 'ts-prorm-orm';
export const prorm = new Prorm({
dialect: 'postgres',
host: process.env.DB_HOST,
database: process.env.DB_NAME,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
});
export const replicas = new ReplicaManager({
enabled: true,
defaultRoutingStrategy: 'lowest-lag',
lagDetection: { interval: 5_000, maxAcceptableLag: 2_000 },
});
for (const [i, host] of (process.env.REPLICA_HOSTS ?? '').split(',').filter(Boolean).entries()) {
await replicas.addReplica({
name: `replica-${i + 1}`,
dialect: 'postgres',
host,
port: 5432,
database: process.env.DB_NAME!,
username: process.env.REPLICA_USER!,
password: process.env.REPLICA_PASSWORD!,
pool: { max: 10 },
});
}maxAcceptableLag. Wait for that before sendingimport { once } from 'node:events';
await prorm.connect();
replicas.startLagMonitor();
await Promise.race([
once(replicas, 'replica:healthy'),
new Promise((resolve) => setTimeout(resolve, 10_000)), // start anyway, reads fall back to the primary
]);prorm; reads that canreplicas. See Using it in an application.process.on('SIGTERM', async () => {
replicas.stopLagMonitor();
await replicas.close();
await prorm.close();
process.exit(0);
});| Strategy | Picks |
|---|---|
round-robin (default) | The next replica in rotation, skipping known-unhealthy ones. |
least-connections | The healthy replica with the fewest in-flight queries. |
lowest-lag | The healthy replica with the smallest measured lag. |
least-connections and lowest-lag both fall back to round-robin when noreplicas.setRoutingStrategy('least-connections');
replicas.getRoutingStrategy();checkLag(name) measures how far one replica trails, in milliseconds:| Dialect | Query | Source |
|---|---|---|
| MySQL / MariaDB | SHOW SLAVE STATUS | Seconds_Behind_Master × 1000 |
| PostgreSQL | SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) * 1000 AS lag | replay timestamp |
| Anything else | — | returns null and logs a warning |
startLagMonitor() runs that check for every replica on the configuredmaxAcceptableLag marks the replica healthy; a lag over it, a null lag, ormaxFailures, and only that many failuresmaxFailures was storedstopLagMonitor() ends the loop — call it during shutdown or the process willError checking lag for replica r1: toconsole.error, and all-replicas-unhealthy is emitted once, when everyreplica:unhealthy does not[!CAUTION]
On PostgreSQL the lag isnow() - pg_last_xact_replay_timestamp(): the time
since the last replayed transaction. When the primary is idle, nothing is
replayed and the number keeps growing, so a perfectly caught-up replica can
crossmaxAcceptableLagovernight and leave rotation. Pointed at the primary
itself, the function returnsNULLand the node is always unhealthy. Keep a
little write traffic on the primary (a heartbeat row updated every few
seconds) if you rely on lag for health.
const lag = await replicas.checkLag('eu-west-1a'); // ms, or null
replicas.setLagDetectionConfig({ maxAcceptableLag: 5_000 });ReplicaManager is an EventEmitter. These are the hooks for metrics and| Event | Fired when |
|---|---|
replica:added / replica:removed | Membership changes. |
replica:healthy / replica:unhealthy | A replica's health flips. |
replica:lag-detected | Every successful lag measurement, with the value. |
replica:selected | A query picked a replica. |
all-replicas-unhealthy | Nothing is left to route to. Emitted once per outage; it can fire again after a replica has recovered. Before 4.9.2 it fired on every monitor cycle. |
monitor:started / monitor:stopped | Monitor lifecycle. |
replicas.on('replica:unhealthy', (name, status) =>
logger.warn('replica out of rotation', { name, lag: status.lag, error: status.error }));
replicas.on('all-replicas-unhealthy', () => {
useReplicas = false; // send reads to the primary until one recovers
});
replicas.on('replica:healthy', () => {
useReplicas = true;
});autoFailover: true and a master connection in the options, aqueryReplica() call made while every replica has been checked and none isreplica:selected names the master.master (or with autoFailover off), reads keep going to a replicaautoFailover wasconst replicas = new ReplicaManager({
autoFailover: true,
master: { name: 'primary', dialect: 'postgres', host: 'primary.internal', port: 5432, database: 'app', username: 'app', password: process.env.DB_PASSWORD! },
lagDetection: { enabled: true, interval: 5_000, maxAcceptableLag: 2_000, maxFailures: 3, timeout: 1_000 },
});| Call | Returns |
|---|---|
getReplicaStats() | Map<name, ReplicaStatus> — healthy, lag, activeConnections, lastCheck, error. |
getReplicaStat(name) | One replica's status. |
getHealthyReplicas() | Names currently in rotation. |
getReplicaCount() / getReplicaNames() | Membership. |
isReplicationEnabled() | Whether routing is on. |
getReplica(name) | The underlying ReplicaPool — getConnection(), releaseConnection(), query(), close(). |
queryReplica() takes raw SQL and positional parameters. Model finders alwaysqueryReplica(sql, { replacements }) is enough for most reads. When you needCOPY), getReplica(name) returns theReplicaPool, and getConnection() on it returns the driver's client (pg'sPoolClient for PostgreSQL). selectReplica() applies the routing strategy:import type { PoolClient } from 'pg';
async function readReplica<T>(sql: string, params: unknown[]): Promise<T[]> {
const { pool } = await replicas.selectReplica();
const client = (await pool.getConnection()) as PoolClient;
try {
const result = await client.query(sql, params);
return result.rows as T[];
} finally {
await pool.releaseConnection(client);
}
}
const active = await readReplica<{ id: number; email: string }>(
'SELECT id, email FROM users WHERE status = $1 AND created_at > $2',
['active', new Date(Date.now() - 86_400_000)],
);async function readOnly<T>(sql: string, params: unknown[]): Promise<T[]> {
if (replicas.getHealthyReplicas().length === 0) {
return (await prorm.query(sql, { replacements: params })).rows; // primary
}
return readReplica<T>(sql, params);
}selectReplica() counts the pick towards the replica's activeConnectionsqueryReplica() counts it back down (also when the query throws), soround-robin or lowest-lag rather thanleast-connections, or use queryReplica() with replacements instead.const pinnedUntil = new Map<number, number>();
const PIN_MS = 5_000;
async function updateProfile(userId: number, bio: string) {
await prorm.query('UPDATE profiles SET bio = $1 WHERE user_id = $2', {
replacements: [bio, userId],
});
pinnedUntil.set(userId, Date.now() + PIN_MS);
}
async function loadProfile(userId: number) {
const sql = 'SELECT * FROM profiles WHERE user_id = $1';
if ((pinnedUntil.get(userId) ?? 0) > Date.now() || replicas.getHealthyReplicas().length === 0) {
return (await prorm.query(sql, { replacements: [userId] })).rows;
}
return readReplica(sql, [userId]);
}maxAcceptableLag: a replica is allowed to be that farcheckLag() gives you the number to reason with.replicas.stopLagMonitor();
await replicas.close(); // closes every replica pool