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.
  • wrangler autenticado no projeto Cloudflare Pages.
  • Credencial service-role do Supabase em .dev.vars (ver src/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 continuidadenode tooling/test-continuity.mjs (determinísticos, in-memory, sem rede).
  • SQL/RPCs contra as tabelas da migration 046apenas 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ócio PrimaryStore ainda 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/_health com bloco de continuidade.
  • Health endpoint /internal/health no 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.sql precisa 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 (EXECUTE revogado de PUBLIC/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_commands usa FOR UPDATE SKIP LOCKED + claimed_until: dois workers concorrentes nunca recebem o mesmo comando.
  • Claims seguem queue_sequence, não o relógio occurred_at da borda.
  • Lease expirado + attempts >= max_attemptsdead (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)
EstadoEscrita no primárioLeitura dinâmicaEscritas essenciais na bordaAuth
PRIMARYdiretaprimárionão usa filaSupabase
DEGRADEDnão chamadacontinuity_snapshotscontinuity_commandsSupabase
RECOVERINGprobe (1 por vez por instância)primário (probe)chamadas sem o slot ainda enfileiramSupabase

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ódulo src/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

  • EXECUTE das RPCs é concedido só a service_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_commands reclaims leases expirados (claimed_until < now()). Basta um próximo claim.
  • Se attempts >= max_attempts após expirar, vira dead automaticamente.

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: ler last_error, corrigir causa raiz e, se aplicável, re-enfileirar com novo idempotency_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 auditar continuity_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_commands sem pending/claimed antigos.
  • circuit_state = PRIMARY estável por ≥ 1h.
  • continuity_snapshots.captured_at recente (≤ snapshot_interval).
  • Sem dead sem 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