2

O que aprendi construindo uma fila de mensagens onde o S3 é a única fonte de verdade

O banco escondido de toda fila

Toda fila de mensagens tem um banco escondido dentro dela. Às vezes é o Redis do Sidekiq/BullMQ, às vezes é o disco do RabbitMQ, os brokers do Kafka, ou aquele Postgres com FOR UPDATE SKIP LOCKED que ia ser temporário. Tanto faz qual: alguém precisa ser dono do estado, saber o que está em voo, o que já foi ackado, quando reentregar. E esse alguém vem com disco local, réplica e eleição de líder no pacote. É a parte chata.

Minha aposta foi inverter isso. Todo o estado durável mora no bucket (S3, MinIO ou R2) e os nós de computação não guardam nada. Matou um processo no meio de um receive? Outro assume. Escalar é subir mais um processo apontando pro mesmo bucket. Não tem membership de cluster, não tem eleição de líder, não tem disco pra perder.

O que destravou isso

Durante anos S3 era só "grava blob, lê blob". Isso mudou em 2024, quando a AWS adicionou escritas condicionais: primeiro If-None-Match, depois compare-and-swap via ETag. Ou seja, o bucket ganhou uma primitiva de concorrência. É a mesma onda que sustenta o WarpStream (Kafka sobre S3) e o SlateDB (LSM sobre object storage), e eu quis ver o que acontecia aplicando a ideia a filas.

O design que ficou de pé

Depois de algumas tentativas sobraram três tipos de objeto.

Produtores escrevem mensagens em segmentos append-only e nada é editado depois. Imutabilidade elimina uma classe inteira de corrida, o que é meio que trapaça, mas é trapaça legal.

Cada fila tem um manifesto, avançado por compare-and-swap, e ele é a autoridade única sobre quais segmentos existem, o que foi ackado e onde está a marca d'água de purge. Dois nós tentando avançar ao mesmo tempo: um perde o CAS, relê e tenta de novo. Só isso.

Entregas em andamento ficam cercadas por leases, que são objetos pequenos fazendo o papel do visibility timeout do SQS. Cada transição é uma função pura do estado observado mais tempo, aplicada via CAS. Esse foi o pedaço que mais me custou pra deixar simples.

O problema mais difícil: relógios

A pergunta que me tirou o sono foi o que acontece quando o relógio de um nó atrasa.

A resposta que o design permite dar é assimétrica, e foi a decisão mais importante do projeto: um relógio ruim pode causar reentrega, mas não consegue perder mensagem nem ackar duas vezes. O fencing acontece no bucket, via CAS, não na memória de quem acha que sabe as horas. Reentrega é o preço que você já paga em at-least-once. Os outros dois eu não estava disposto a pagar.

Custo como restrição de design

Object storage cobra por requisição, então fingir que isso não existe seria desonesto. Batching com linger amortiza as chamadas, e a tabela de custo do repo é gerada por benchmark de verdade (tem um just cost que regenera ela, justamente pra eu não ficar inventando número em post). Com linger de 100 ms dá algo em torno de US$ 8 por milhão de mensagens, considerando US$ 5,00/milhão de PUTs e US$ 0,40/milhão de GETs. Mais caro que uma fila em Redis que você já opera? Provavelmente. Depende de quanto vale não operar nada.

O que ele NÃO resolve

Object storage tem piso de latência de dezenas de ms, então isso não serve pra nada sub-milissegundo. Não tem jeito, é física de rede e API. Ordenação hoje é best effort e FIFO está no roadmap, que é o jeito educado de dizer que ainda não está feito. Dedup é best effort no produtor, então o consumidor precisa ser idempotente, igual em qualquer at-least-once. Sendo o autor, essa é a parte que eu mais devo a vocês.

Pra discussão

Vocês rodariam uma fila de produção em cima de object storage, trocando latência por zero operação de estado? Alguém já usou escrita condicional do S3 como lock/CAS em outro contexto, fora fila e banco?

E a que eu mais quero ouvir: que teste vocês fariam pra tentar quebrar um design desses? Eu tenho a minha lista, mas ela é enviesada por quem escreveu o código.

O projeto se chama okuri, é open source (Rust, Apache-2.0, ainda em alpha): https://okuri.dev e https://github.com/okuri-oss/okuri

Carregando publicação patrocinada...