Runbook — Failover do Primário Local
Roteiro executável para simulação e atuação durante a queda do nó primário local no fluxo local-first.
Status: FUNDAÇÃO / SHADOW. Este runbook cobre o que é executável hoje (testes + SQL/RPCs contra a migration 046) e o que está planejado (rotas, workers, admin API não existem). Comandos futuros estão em blocos rotulados “PLANEJADO — NÃO EXECUTAR AINDA”: não inventar binários, endpoints ou serviços.
Autoridade: o banco Postgres no homelab (primário local). O Supabase hospedado é Auth + continuidade (fila + snapshots). Nunca promova o Supabase a primário.
Arquitetura completa:
src/content/docs/db/local-first-continuity.md.
0. Pré-requisitos (setup)
Tenha à mão antes de qualquer incidente real:
- Acesso SSH ao homelab (
<HOMELAB_HOST>) — Postgres primário +cloudflared. wranglerautenticado no projeto Cloudflare Pages.- Credencial service-role do Supabase em
.dev.vars(versrc/content/docs/db/supabase-setup.md). - Acesso ao dashboard Supabase do projeto (
<SUPABASE_PROJECT_REF>). - Este runbook + a arquitetura abertos.
O que existe hoje (pode rodar agora)
- Testes da camada de continuidade —
node tooling/test-continuity.mjs(determinísticos, in-memory, sem rede). - SQL/RPCs contra as tabelas da migration 046 — apenas após aplicar a migration ao projeto Supabase (hoje não aplicada).
- Código da camada — núcleo agnóstico + clientes/adapters server-only em
src/lib/continuity/. O adapter de negócioPrimaryStoreainda não existe.
O que NÃO existe hoje (não tente executar)
cron/systemd workers no homelab (local-continuity-*).- Binários em
/opt/webapp/bin/*. - Página/API
/admin/continuity,/api/admin/continuity/*,/api/_healthcom bloco de continuidade. - Health endpoint
/internal/healthno primário. - Alertas Telegram/E-mail de continuidade.
- Métricas exportadas.
- Conexão entre router e rotas de pedido/webhook reais.
1. Verificações executáveis AGORA
1.1 Rodar a suíte de testes da camada
node tooling/test-continuity.mjs
- Cubre 8 grupos: primary success, circuit opening, degraded reads, essential queue, non-essential rejection, primary idempotency (late commit + replay), recovery, idempotency/dedupe.
- Saída esperada:
<N> passed, 0 failed. Código de saída 0. - Sem rede, sem Supabase — valida o contrato da camada.
1.2 Build de produção
npm run build
2. Após aplicar a migration 046 — SQL/RPCs reais
Condição: a migration
db/supabase/migrations/046_continuity_transport.sqlprecisa estar aplicada ao projeto Supabase. Aplicar via dashboard/CLI (planejado; hoje não aplicada).
Fonte canônica do esquema:
db/supabase/migrations/046_continuity_transport.sql.
2.1 Inspeção das tabelas (SQL editor)
-- Status da fila de comandos
SELECT status, COUNT(*) AS n
FROM public.continuity_commands
GROUP BY status;
-- Outbox espelhado
SELECT status, COUNT(*) AS n
FROM public.continuity_outbox
GROUP BY status;
-- Heartbeat/cursor por nó
SELECT node_id, role, mode, last_seen_at,
last_command_seq, last_snapshot_at, last_drain_at
FROM public.continuity_replication_state;
-- Snapshots compactos (frescor)
SELECT aggregate_type, COUNT(*) AS n,
MAX(captured_at) AS newest
FROM public.continuity_snapshots
GROUP BY aggregate_type;
2.2 Claim / ack / fail (RPCs reais)
Só service-role executa estas RPCs (
EXECUTErevogado dePUBLIC/anon/authenticated). Use um cliente com a service-role key.
-- Claim até 20 comandos, lease de 60s, worker "drain-1"
SELECT id, queue_sequence, command_id, command_type, aggregate_id,
occurred_at, attempts
FROM public.continuity_claim_commands(
p_worker_id := 'drain-1',
p_limit := 20,
p_lease_seconds := 60
);
-- Ack de um comando (só o holder do lease)
SELECT public.continuity_ack_command(
p_id := '<command-row-uuid>',
p_worker_id := 'drain-1'
);
-- Falha (backoff exponencial, dead após max_attempts=8)
SELECT public.continuity_fail_command(
p_id := '<command-row-uuid>',
p_worker_id := 'drain-1',
p_error := 'primary: connection refused'
);
2.3 Comportamento esperado ( já garantido pelas RPCs)
continuity_claim_commandsusaFOR UPDATE SKIP LOCKED+claimed_until: dois workers concorrentes nunca recebem o mesmo comando.- Claims seguem
queue_sequence, não o relógiooccurred_atda borda. - Lease expirado +
attempts >= max_attempts→dead(mantido p/ auditoria, nunca deletado). - Lease expirado + tentativas restantes → reclaimável.
- Backoff:
LEAST(3600, 15 * 2^LEAST(attempts, 8))segundos.
2.4 Verificar idempotência (prova manual)
-- Tentar inserir o mesmo idempotency_key deve violar a UNIQUE
-- (uq_continuity_commands_idempotency).
INSERT INTO public.continuity_commands
(command_id, idempotency_key, aggregate_type, aggregate_id,
command_type, actor_id, occurred_at, payload)
VALUES (
'<novo-uuid>', 'pay-order-42', 'order', 'order-42',
'order.confirm_payment', 'system', now(),
'{"amount": 250}'::jsonb
);
-- Segunda vez com mesmo idempotency_key → erro de PK/UNIQUE esperado.
3. Máquina de estados (recordatório)
Implementada em
src/lib/continuity/circuit-breaker.ts. Defaults:failureThreshold=3,openWindowMs=30000,recoveryProbes=2.
PRIMARY ──(failures ≥ 3)──► DEGRADED
▲ │
│ │ openWindowMs (30s) elapses
│ 2 probes OK ▼
└──────── ◄──── RECOVERING ◄─┘
(1 probe por vez)
| Estado | Escrita no primário | Leitura dinâmica | Escritas essenciais na borda | Auth |
|---|---|---|---|---|
| PRIMARY | direta | primário | não usa fila | Supabase |
| DEGRADED | não chamada | continuity_snapshots | continuity_commands | Supabase |
| RECOVERING | probe (1 por vez por instância) | primário (probe) | chamadas sem o slot ainda enfileiram | Supabase |
O breaker atual é local a cada instância e fecha após probes. Antes de ligar escritas reais, implemente o fencing/high-water mark global descrito na arquitetura §6.4; até lá, use esta camada somente em shadow/logs.
4. PLANEJADO — NÃO EXECUTAR AINDA
Os comandos abaixo são especificação futura. Não há endpoints, binários nem services implementados. Estão aqui para guiar as próximas fases do rollout (ver arquitetura §16). Não copie nem execute enquanto a fase correspondente não for entregue.
4.1 Health e estado (fase 3)
PLANEJADO — NÃO EXECUTAR AINDA
GET <APP_URL>/api/_health
→ { circuit_state, pending_count, last_snapshot_age, lease_holder }
GET <APP_URL>/api/admin/continuity (admin page)
→ painel com fila, snapshots, heartbeat por nó
4.2 Forçar estado do circuito (fase 3+)
PLANEJADO — NÃO EXECUTAR AINDA
POST <APP_URL>/api/admin/continuity/force-state
body: { "state": "degraded" | "primary", "reason": "..." }
4.3 Worker de drenagem no homelab (fase 5)
PLANEJADO — NÃO EXECUTAR AINDA
ssh <HOMELAB_HOST>
# worker que chama continuity_claim_commands → PrimaryStore.execute → ack/fail
# (binário/unit ainda a definir; NÃO existe /opt/webapp/bin/drain)
4.4 Restauração PITR do primário (fase 6, após configurar WAL archiving)
PLANEJADO — NÃO EXECUTAR AINDA
ssh <HOMELAB_HOST>
sudo systemctl stop webapp-api
sudo -u postgres pgbackrest --type=time \
--target="<timestamp>" --target-action=promote restore
sudo systemctl start postgresql
5. Diagnóstico — sintomas comuns
5.1 Testes da camada falham
node tooling/test-continuity.mjs
- Verifique
npm run build(typecheck do módulosrc/lib/continuity/). - Os testes são determinísticos e sem rede; falha é regressão no código da camada — não infra.
5.2 RPC devolve erro de permissão
EXECUTEdas RPCs é concedido só aservice_role. Erro 42501 indica uso de chave anônima/authenticated. Confirme a service-role (src/content/docs/db/supabase-setup.md).
5.3 Comando preso em claimed (worker morreu)
- Não é necessário agir:
continuity_claim_commandsreclaims leases expirados (claimed_until < now()). Basta um próximo claim. - Se
attempts >= max_attemptsapós expirar, viradeadautomaticamente.
5.4 Comandos em dead acumulando
SELECT id, command_type, aggregate_id, attempts, last_error, updated_at
FROM public.continuity_commands
WHERE status = 'dead'
ORDER BY updated_at DESC;
deadé terminal para auditoria (não se deleta). Ação humana: lerlast_error, corrigir causa raiz e, se aplicável, re-enfileirar com novoidempotency_key(a UNIQUE impede reusar a antiga).
5.5 Split-brain suspeito (projeção ≠ primário)
- Hoje não há nem projeções publicadas em produção nem router ligado a rotas; split-brain não é alcançável ainda.
- Quando a fase 4 estiver ativa, o procedimento será: desligar a feature flag
LOCAL_CONTINUITY_PHASE(volta ao comportamento anterior) e auditarcontinuity_commands+ estado real do primário. O primário é autoridade.
6. Cola rápida — SQL de bolso (após migration aplicada)
-- Resumo da fila
SELECT status, COUNT(*) FROM public.continuity_commands GROUP BY status;
-- Pendentes/claim expirado mais antigos (candidatos a drenagem)
SELECT id, queue_sequence, command_type, aggregate_id, occurred_at,
attempts, available_at
FROM public.continuity_commands
WHERE status IN ('pending','claimed')
ORDER BY queue_sequence
LIMIT 20;
-- Detalhe de um comando
SELECT * FROM public.continuity_commands WHERE id = '<uuid>';
-- Erros recentes
SELECT id, command_type, attempts, last_error, updated_at
FROM public.continuity_commands
WHERE status = 'dead' AND updated_at > now() - interval '1 day'
ORDER BY updated_at DESC;
-- Heartbeat dos nós
SELECT node_id, role, mode, last_seen_at,
age(now(), last_seen_at) AS silent_for
FROM public.continuity_replication_state;
7. Pós-incidente (checklist alvo, quando a camada estiver ativa)
Hoje nenhum item é executável (não há incidente possível sem rotas ligadas). Lista de rascunho para a fase 5+:
-
continuity_commandssempending/claimedantigos. -
circuit_state = PRIMARYestável por ≥ 1h. -
continuity_snapshots.captured_atrecente (≤snapshot_interval). - Sem
deadsem dono (cada um com decisão registrada). - Postmortem: timeline, RTO observado, RPO observado, lições.
- Atualizar este runbook + a arquitetura.
8. Referências rápidas
- Arquitetura completa:
src/content/docs/db/local-first-continuity.md - Migration:
db/supabase/migrations/046_continuity_transport.sql - Camada TS: núcleo em
src/lib/continuity/index.ts; integração server-only emsrc/lib/continuity/supabase.ts - Testes:
node tooling/test-continuity.mjs - Backup/PITR PostgreSQL: https://www.postgresql.org/docs/current/backup.html
- Supabase backups (Free vs paid): https://supabase.com/docs/guides/platform/backups
- Cloudflare Tunnel: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
- Setup da service-role:
src/content/docs/db/supabase-setup.md - Webhooks de pagamento:
src/content/docs/pagamentos/c6-pix-setup.md