Com o alvo sincronizado, atraso abaixo de 2 segundos e índices recriados, o corte é um procedimento de segundos que precisa ser um script, não um checklist executado à mão. O pgbouncer faz o papel central: PAUSE espera as transações abertas terminarem e segura as novas na fila, sem erro para o cliente. A origem vira somente leitura, um marcador escrito na origem prova que o alvo recebeu a última mudança, as sequências são alinhadas, a replicação de volta é criada e só então o pgbouncer é recarregado apontando para o alvo e a fila drena.
// corte.js
// Corte com trafego ligado. O pgbouncer segura as novas transacoes na fila (PAUSE),
// a origem vira somente leitura, um marcador prova que o alvo recebeu a ultima
// escrita, as sequencias sao alinhadas, a replicacao de volta e criada e so entao
// o pgbouncer passa a apontar para o alvo (RESUME). Qualquer erro antes do RESUME
// devolve o trafego para a origem: o cliente ve latencia, nao erro.
import { randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import pg from 'pg';
const exec = promisify(execFile);
const origem = new pg.Client({ connectionString: process.env.ORIGEM_URL });
const alvo = new pg.Client({ connectionString: process.env.ALVO_URL });
const bouncer = new pg.Client({ connectionString: process.env.PGBOUNCER_ADMIN_URL });
const TETO_MS = 30_000;
const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function esperarMarcador(id) {
const inicio = Date.now();
while (Date.now() - inicio < TETO_MS) {
const { rowCount } = await alvo.query('SELECT 1 FROM upgrade_marcador WHERE id = $1', [id]);
if (rowCount === 1) return Date.now() - inicio;
await esperar(100);
}
throw new Error(`alvo nao recebeu o marcador ${id} em ${TETO_MS} ms`);
}
async function alinharSequencias() {
const { rows } = await origem.query(
'SELECT schemaname, sequencename, last_value FROM pg_sequences WHERE last_value IS NOT NULL',
);
for (const seq of rows) {
// A margem cobre valores pre-alocados por CACHE que nunca chegaram ao disco.
await alvo.query(
"SELECT setval(format('%I.%I', $1, $2)::regclass, $3::bigint + 1000, true)",
[seq.schemaname, seq.sequencename, seq.last_value],
);
}
return rows.length;
}
async function corte() {
await Promise.all([origem.connect(), alvo.connect(), bouncer.connect()]);
// Esta sessao continua escrevendo na origem depois que o banco virar read-only.
await origem.query('SET default_transaction_read_only = off');
const t0 = Date.now();
await bouncer.query('PAUSE clinicas'); // espera as transacoes abertas terminarem
try {
// 1. Fecha a porta para quem nao passa pelo pgbouncer (cron, job antigo).
await origem.query('ALTER DATABASE clinicas SET default_transaction_read_only = on');
await origem.query(`
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = 'clinicas' AND pid <> pg_backend_pid()
AND backend_type = 'client backend' AND usename <> 'replicador'`);
// 2. Ultima escrita da origem. Quando chegar no alvo, nada ficou para tras.
const marcador = randomUUID();
await origem.query('INSERT INTO upgrade_marcador (id) VALUES ($1)', [marcador]);
const atrasoMs = await esperarMarcador(marcador);
// 3. O que a replicacao nao leva.
const sequencias = await alinharSequencias();
// 4. Caminho de volta: tudo que for escrito no alvo a partir daqui volta para
// a origem. Criado ANTES do RESUME, para nenhuma escrita nova escapar.
// O papel replicador recebe read-only = off no nivel de ROLE, que prevalece
// sobre o do banco: sem isso o apply na origem falha por transacao read-only.
await alvo.query('ALTER SUBSCRIPTION sub_upgrade DISABLE');
await alvo.query('CREATE PUBLICATION pub_volta FOR ALL TABLES');
await origem.query('ALTER ROLE replicador SET default_transaction_read_only = off');
await origem.query(`
CREATE SUBSCRIPTION sub_volta
CONNECTION '${process.env.VOLTA_CONN}' PUBLICATION pub_volta
WITH (copy_data = false)`);
// 5. Ponto sem volta deste script: pgbouncer passa a apontar para o alvo.
await exec('cp', ['/etc/pgbouncer/pgbouncer.alvo.ini', '/etc/pgbouncer/pgbouncer.ini']);
await bouncer.query('RELOAD');
await bouncer.query('RESUME clinicas');
console.log({ pausaMs: Date.now() - t0, atrasoMs, sequencias });
} catch (erro) {
// Nada mudou para o cliente ainda: reabre a origem e libera a fila nela.
await origem.query('ALTER DATABASE clinicas SET default_transaction_read_only = off');
await bouncer.query('RESUME clinicas');
throw erro;
} finally {
await Promise.all([origem.end(), alvo.end(), bouncer.end()]);
}
}
corte().catch((erro) => {
console.error(erro);
process.exit(1);
});
Linha do tempo do corte (9 s de pausa no ensaio final e em produção).
t0 PAUSE clinicas novas transações esperam na fila do pgbouncer
|
+0,8 s origem read-only sessões fora do pgbouncer encerradas
|
+1,1 s INSERT marcador última escrita que a origem aceita
|
+2,4 s marcador no alvo atraso real da replicação naquele instante: 1,3 s
|
+4,0 s 96 sequências setval(last_value + 1000) no alvo
|
+6,9 s sub_volta criada alvo -> origem, slot aberto antes de qualquer escrita nova
|
+8,7 s RELOAD + RESUME fila drena no Postgres 17; p99 da API: 11 s nesse minuto, 0 erros
|
+7 dias DROP sub_volta origem desligada depois de uma semana sem precisar voltarDuas decisões no script merecem atenção. A replicação de volta é criada antes do RESUME, com copy_data = false, porque o slot é criado no momento do CREATE SUBSCRIPTION: tudo que for escrito no alvo a partir dali volta para a origem, e nenhuma escrita escapa entre a criação e a liberação da fila. E o ponto sem volta do script é o RELOAD do pgbouncer: até ele, qualquer exceção reabre a origem e libera a fila nela, e o cliente viu só uma pausa. Depois dele, voltar é executar o mesmo procedimento no sentido contrário, o que também é ensaiado.