Blog

Log append-only: a estrutura mais subestimada de arquitetura.

A estrutura de dados mais útil que eu conheço em backend não é uma árvore nem um grafo. É uma lista onde só se acrescenta no fim.

Kafka é isso. O WAL do Postgres é isso. Git é isso. Blockchain é isso com criptografia em cima. Toda vez que um sistema precisa saber o que aconteceu, e não só como as coisas estão agora, a resposta converge pra mesma forma.

E, na maioria dos projetos, o padrão é o oposto: uma tabela de estado atual que se sobrescreve, e uma tabela de log que ninguém lê porque virou lixeira de string.

O que muda quando o log é a fonte da verdade

Numa tabela de estado, status = 'cancelado' é tudo o que você tem. Quem cancelou, quando, por qual caminho, se já tinha sido cancelado antes e reaberto, nada disso existe. A informação foi destruída no UPDATE.

Num log de eventos, o estado atual é o resultado de aplicar todos os eventos em ordem. O caminho fica todo preservado, de graça, porque nunca foi apagado.

pedido_criado         { itens: [...], total: 24900 }
pagamento_autorizado  { gateway: 'x', authId: '...' }
item_removido         { itemId: '...', motivo: 'sem estoque' }
pagamento_ajustado    { de: 24900, para: 19900 }
pedido_enviado        { rastreio: '...' }

Quando o cliente ligar perguntando por que foi cobrado 199 e não 249, a resposta está ali. Sem essa sequência, alguém vai ter que reconstruir a história a partir de log de aplicação e memória.

A tabela mínima

create table event (
  id            bigserial primary key,
  stream_type   text not null,      -- 'order', 'account'
  stream_id     uuid not null,      -- a entidade
  version       int not null,       -- posição dentro do stream
  type          text not null,      -- 'pedido_criado'
  payload       jsonb not null,
  metadata      jsonb not null default '{}',  -- quem, de onde, correlation id
  occurred_at   timestamptz not null,
  recorded_at   timestamptz not null default now(),

  unique (stream_type, stream_id, version)
);

A constraint única em (stream_type, stream_id, version) é o coração da coisa e passa despercebida.

Ela dá controle de concorrência otimista de graça. Dois processos que leem o pedido na versão 7 e tentam gravar a versão 8 ao mesmo tempo: um grava, o outro viola a constraint e precisa reler e decidir o que fazer. Sem lock explícito, sem SELECT FOR UPDATE, sem deadlock.

occurred_at e recorded_at separados importam mais do que parece. Um é quando o fato aconteceu, o outro é quando o sistema soube. Eles divergem sempre que existe fila, integração ou aplicativo offline, e ter só uma coluna te impede de responder “o sistema estava atrasado ou o dado chegou errado?”.

Não é event sourcing

Vale separar as duas ideias, porque confundir assusta gente à toa.

Event sourcing é reconstruir o estado a partir dos eventos, sempre. Você não guarda o pedido: guarda os eventos e calcula o pedido quando precisa. É poderoso e caro, com versionamento de evento, projeção, snapshot e uma curva de aprendizado que derruba time.

Log append-only é só ter o registro do que aconteceu, ao lado da tabela de estado. Você continua com sua tabela orders normal. O log é o histórico, a fonte pra auditoria e o gatilho pra integração.

Noventa por cento do benefício, dez por cento do custo. Comece por aqui. Se um dia precisar de event sourcing de verdade, você já tem os eventos.

Escrever o evento junto com o estado

O erro que mata o padrão é gravar o estado numa transação e o evento em outra. Uma falha no meio produz estado sem evento, e o log deixa de ser confiável. Log que às vezes falha é pior que log nenhum, porque cria confiança falsa.

await dataSource.transaction(async (tx) => {
  await tx.update(Order, { id }, { status: "cancelled" });
  await tx.insert(Event, {
    streamType: "order",
    streamId: id,
    version: nextVersion,
    type: "pedido_cancelado",
    payload: { motivo },
    metadata: { userId: user.id, correlationId },
  });
});

Mesma transação, sempre.

Quando o efeito também precisa sair pra fora, mandar e-mail, avisar um parceiro, aí entra o padrão outbox: o evento gravado na mesma transação vira a fila, e um processo separado lê e publica. Assim você nunca tem “avisei o parceiro mas o banco deu rollback”.

O preço

Log cresce e nunca encolhe. Isso precisa estar no desenho, não no incidente.

Particione por tempo desde o começo, mesmo com pouco volume. Mudar de tabela simples pra particionada com centenas de milhões de linhas é bem mais chato do que já nascer assim.

Defina retenção por tipo de evento. Nem tudo precisa viver pra sempre no banco quente. Evento financeiro fica anos, evento de interface fica meses, e o resto vai pra armazenamento barato.

E cuidado com o que entra no payload. Log append-only e direito à exclusão de dado pessoal conversam mal. A saída usual é guardar referência em vez de conteúdo sensível, e apagar o conteúdo no lugar onde ele é mutável.

Onde isso vira vantagem competitiva

Todo produto sério acaba precisando responder “o que aconteceu com esse registro?”. Suporte pergunta, cliente pergunta, auditoria pergunta, e um dia advogado pergunta.

Quem tem log responde em trinta segundos com uma consulta. Quem não tem monta uma força-tarefa que remonta a história a partir de log de aplicação, e entrega uma resposta que começa com “pelo que a gente conseguiu apurar”.

A diferença entre as duas situações é uma tabela criada no começo do projeto.

É a mesma base do desenho que descrevi em system design de rastreabilidade com hash único e em o que é um core ledger.

← Voltar pro blog