AckWait É um Contrato: Como um Default de 30 Segundos Derrubou Meu Consumer JetStream
Perdi uma noite com um pull consumer NATS JetStream que dobrou seu trabalho em produção. A causa foram três linhas de ConsumerConfig que eu nunca escrevi. Estas são minhas anotações sobre o que o AckWait realmente conta, por que MaxDeliver = -1 é a armadilha silenciosa e o contrato Go de 70 linhas que agora envio em todo consumer JetStream.
Perdi uma noite com um pull consumer NATS JetStream que parecia correto em todos os testes e silenciosamente dobrava seu trabalho em produção. O padrão era o mesmo toda vez: o consumer processava uma mensagem, o trabalho levava 31 segundos em vez de 28, e uma segunda cópia da mesma mensagem chegava enquanto a primeira ainda estava em andamento. Em um minuto, uma única mensagem de processamento de pagamento havia sido entregue quatro vezes, duas delas para a mesma goroutine.
A causa foram três linhas de ConsumerConfig que eu não havia escrito. Os defaults do JetStream são agressivos de uma forma que só importa quando o trabalho é lento, e eu vinha tratando os defaults como ausência inofensiva, não como política.
Este post é o que entendi sobre o modelo de ack do pull consumer do JetStream no pacote moderno nats.go/jetstream: qual timer conta o quê, qual chamada de ack reseta esse timer e o menor conjunto de campos ConsumerConfig que agora trato como contrato inegociável em todo consumer que faço deploy.
O que o AckWait realmente conta
Quando o servidor JetStream entrega uma mensagem a um pull consumer, ele inicia um timer por mensagem chamado AckWait. Se nenhum acknowledgement — explícito, NAK ou sinal de progresso — chegar antes que esse timer expire, o servidor trata a mensagem como perdida e a reentrega. O default não modificado é de 30 segundos. O JsDefaultMaxAckPending do servidor para o limite de mensagens em voo correspondente é 1000. Ambos vêm dos defaults do servidor documentados na página oficial Consumer Details e confirmados no nats-server.
A coisa que a documentação não enfatiza — mas que importa mais que o próprio default — é o que o timer está realmente medindo. Não é o tempo entre chamadas de Fetch(). Não é o tempo entre a mensagem chegar à sua fila e seu handler retornar. É o tempo entre quando o servidor enviou a mensagem e quando o servidor viu seu ack voltar. Qualquer coisa que aconteça do seu lado — buffer TCP, pausa de GC, uma chamada HTTP downstream lenta, uma goroutine que foi descalonada — tudo isso conta contra o mesmo orçamento de 30 segundos.
A reentrega não é um retry; é uma duplicata. A cópia original ainda está no seu handler. Se o seu handler não for idempotente para aquele id de mensagem, você já queimou o contrato.
Três detalhes da spec moldaram como penso sobre isso:
MaxDelivertem default-1, que significa reentrega infinita. Uma mensagem que falhar para sempre fica em rotação para sempre. O seguro contra poison pill está desligado até você ligá-lo.BackOffsobrescreve oAckWaitpara o timing de reentrega, mas só quando o AckWait expira, não em umNak()puro. Um NAK sem espera é uma reentrega imediata, que é o oposto do que você quer sob carga.InProgress()reseta o timer do AckWait sem fazer ack da mensagem. Essa é a API que a documentação mal divulga e a que conserta trabalho de longa duração.
A pool de sobreviventes da reentrega tem um hook também: quando uma mensagem esgota o MaxDeliver, o servidor emite um advisory em $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<STREAM>.<CONSUMER> carregando o stream_seq original. Esse é o subject em que uma dead-letter queue se inscreve. Volto a isso.
O modo tempestade
Aqui está o que realmente acontece quando o trabalho cruza o AckWait, traçado a partir de algumas horas de saída de nats consumer info e logs do servidor. O diagrama abaixo mostra o caso quebrado em cima e o caso corrigido embaixo — leia o eixo do tempo como um cronômetro começando da primeira entrega.
Em t=0 o servidor entrega a mensagem #42 e inicia um AckWait de 30 segundos. Meu handler começa a processar. Em t=30 o timer expira; o servidor reentrega #42 ao mesmo durable (pode cair na mesma goroutine ou em qualquer outro subscriber ligado àquele consumer). Meu handler ainda está na primeira cópia. Em t=31 o trabalho original termina e dá ack; a segunda cópia agora está um segundo dentro da sua própria janela de 30 segundos. Em t=60 essa janela também expira — o servidor não vê ack porque o segundo handler ainda está rodando. Uma terceira cópia chega.
O acúmulo é multiplicativo porque o consumer agora está processando duplicatas que elas próprias levam mais que o AckWait, então cada uma gera sua própria reentrega. Os acks pendentes sobem e ficam em cima mesmo depois que o payload original teve sucesso. Quando num_ack_pending atinge MaxAckPending (default 1000), o consumer para de receber novas mensagens completamente. De fora parece que o consumer travou.
A sequência inteira roda sem um único log de erro. O servidor está fazendo exatamente o que a spec diz.
O contrato que agora envio
Cinco campos. Cada um deles definido deliberadamente.
package main
import (
"context"
"errors"
"log"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
type poisonErr struct{}
func (poisonErr) Error() string { return "unprocessable payload" }
// process simulates work that may exceed the AckWait window.
func process(ctx context.Context, msg jetstream.Msg) error {
deadline := time.Now().Add(50 * time.Second)
tick := time.NewTicker(15 * time.Second) // < AckWait / 2
defer tick.Stop()
for time.Now().Before(deadline) {
select {
case <-tick.C:
_ = msg.InProgress() // resets the server's AckWait timer
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
func main() {
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
log.Fatal(err)
}
defer nc.Drain()
js, err := jetstream.New(nc)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
stream, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: "ORDERS",
Subjects: []string{"orders.>"},
})
if err != nil {
log.Fatal(err)
}
cons, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
Durable: "orders-worker",
AckPolicy: jetstream.AckExplicitPolicy,
AckWait: 45 * time.Second,
MaxAckPending: 64,
MaxDeliver: 5,
BackOff: []time.Duration{
2 * time.Second, 8 * time.Second,
30 * time.Second, 2 * time.Minute,
},
})
if err != nil {
log.Fatal(err)
}
_, err = cons.Consume(func(msg jetstream.Msg) {
switch err := process(ctx, msg); {
case err == nil:
_ = msg.Ack()
case errors.As(err, new(poisonErr)):
_ = msg.Term() // do not redeliver
default:
_ = msg.NakWithDelay(5 * time.Second)
}
})
if err != nil {
log.Fatal(err)
}
select {}
}Execute com go run main.go contra um servidor iniciado com nats-server -js.
O que cada linha está fazendo:
AckWait: 45 * time.Second. Defino isso como aproximadamentep99(work) + 50%. A janela tem que cobrir o processamento mais lento realista de uma única mensagem, não a mediana. Trinta segundos é pouco para qualquer consumer que faça um write em banco, uma chamada HTTP externa e um publish — todos os quais já medi com p99 acima de 22 segundos.MaxAckPending: 64. Apertado, deliberadamente. O default de 1000 significa que um único subscriber pode ter mil mensagens em voo, que é o que faz o modo tempestade parecer um travamento. Com 64, a falha aparece rápido: o consumer fica em silêncio em segundos após uma fase lenta em vez de tamponar por minutos.MaxDeliver: 5. Retries limitados. O advisory em$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.ORDERS.orders-workeré o hook que um subscriber DLQ separado escuta; uma vez que uma mensagem é entregue cinco vezes, ela para de ser reentregue e o servidor emite um evento advisory com ostream_seqoriginal. Esse é o ponto em que um humano ou um worker separado assume.BackOff: [...]. Quatro durações aplicadas em ordem a cada expiração de AckWait. O comprimento da sequência deve ser≤ MaxDeliver. Isso para o chicote de reentrega imediata quando o AckWait dispara — o que ele ainda fará, porque nenhuma estratégia de heartbeat é perfeita.InProgress()dentro deprocess()é o heartbeat. Faço tick em menos queAckWait / 2porque um único tick perdido não pode ultrapassar a janela. A ticks de 15 segundos contra um AckWait de 45 segundos, dois misses consecutivos ainda deixam 15 segundos de folga.
A distinção da ação terminal importa tanto quanto a configuração. O handler escolhe um de três:
Ack()para sucesso.Term()para payloads sabidamente ruins. Isso emite o advisoryMSG_TERMINATEDe remove a mensagem da rotação independentemente doMaxDeliver. É isso que uma falha de desserialização deve fazer, nunca um NAK.NakWithDelay()para falhas transitórias. UmNak()puro é uma reentrega imediata;NakWithDelaydá ao downstream uma chance real de se recuperar antes da próxima tentativa.
O Nak() default é a terceira armadilha que continuo encontrando no código de outras pessoas. Sob uma manada estrondosa, ele faz o oposto do que o autor pretendia.
O que essa configuração realmente custa
Os trade-offs são visíveis. Um MaxAckPending de 64 limita a vazão de um único consumer. Com AckWait de 45 segundos e 64 em voo, o teto teórico é aproximadamente 64 * (1000ms / mean_processing_ms) mensagens por segundo em um único subscriber. Para trabalho mediano de 200 ms, isso é cerca de 320 msg/s por subscriber — bom para as cargas que rodo, ruim para um fanout que precisa mastigar milhões por minuto. A correção ali é mais subscribers ligados ao mesmo durable, não um MaxAckPending mais alto.
MaxDeliver: 5 mais um subscriber advisory significa que preciso de um segundo pedaço de código em algum lugar — um worker DLQ, mesmo que pequeno. O post de anti-patterns da Synadia de janeiro de 2025 recomenda ficar abaixo de aproximadamente 100.000 consumers por servidor e abaixo de aproximadamente 300 filtros de subject disjuntos por consumer; um subscriber DLQ por stream fica muito abaixo de ambos os limites.
Heartbeats de InProgress() não são de graça. Cada um é um pequeno publish em um subject de controle. Em cadência de 15 segundos contra 64 mensagens em voo, isso é cerca de quatro publishes por segundo por subscriber — desprezível contra qualquer carga real, mas vale saber que existe.
Quando o contrato é a ferramenta errada
Dois casos em que eu não recorreria a ele.
Trabalho de longa duração além de cinco minutos. O padrão de heartbeat começa a ficar ridículo quando uma única mensagem representa horas de computação. Nesse ponto a mensagem é um handle de job, não uma unidade de trabalho, e a forma certa é fazer ack da mensagem imediatamente, armazenar o job em um motor de workflow durável e deixar o motor de workflow ser dono do contrato de retry. Temporal e DBOS existem exatamente para esse caso.
Ordenação estrita entre uma partição. Pull consumers com MaxAckPending > 1 entregam mensagens em paralelo ao subscriber, e qualquer reordenação causada por uma reentrega quebra a ordenação por chave. Se a carga é um livro contábil por conta, a forma certa é MaxAckPending: 1 mais um subject particionado (orders.<accountId>) e um consumer por partição. Esse é um design diferente, não um ajuste neste contrato.
Conclusões
- AckWait é um timer do lado do servidor que conta seu tempo de processamento de relógio de parede, não o seu tempo de espera na fila. Defina-o como
p99(work) + 50%e nunca confie no default de 30 segundos. InProgress()é o heartbeat que reseta o timer. Faça tick em menos queAckWait / 2.MaxDeliver: -1é reentrega infinita. Sempre defina um número, e se inscreva no advisoryMAX_DELIVERIESpara drenar os sobreviventes em uma DLQ.Nak()sem delay é um chicote de reentrega imediata. UseNakWithDelay()ou confie noBackOff.Term()é para mensagens que nunca terão sucesso. Não é umNak()mais afiado; é uma forma diferente de acknowledgement.
Recorra a este contrato em qualquer pull consumer onde uma única mensagem possa fazer trabalho real. Pule-o em firehoses fire-and-forget, jobs que sobrevivem a uma única janela de AckWait limpa, e livros contábeis estritamente ordenados — esses querem formas inteiramente diferentes.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Idempotência É um Protocolo, Não uma Chave
Na primeira vez em que entreguei idempotência como um header UUID e uma consulta no Redis, uma cobrança duplicada escapou uma semana depois. Estas são minhas notas sobre tratar idempotência como um protocolo de quatro partes — deduplicação, determinismo, segurança concorrente, propagação downstream — com uma implementação mínima em Kotlin mais Postgres que se mantém firme sob retry.
O que `dbos ontime` realmente está perguntando: construindo um cron distribuído com leases do etcd em Go
Uma busca 0-click por `dbos ontime` apareceu no meu Search Console na semana passada. Quem digitou isso não está perguntando sobre DBOS — está perguntando como rodar um job a cada minuto, exatamente uma vez, em uma frota de máquinas. Pelas minhas próprias anotações, um lease do etcd, o pacote `concurrency.Election` e um fencing token cobrem esse caso em menos de 100 linhas de Go, sem precisar trazer um workflow engine.
O Transactional Outbox Não É uma Fila
O transactional outbox é um ledger, não uma fila. Tratá-lo como fila é o que quebra o Postgres sob carga. Este post percorre os modos de falha específicos — autovacuum travando, drift do horizonte xmin, lag do replication slot, poison pills — e as regras operacionais que realmente o mantêm funcionando em produção.