Pular para o conteúdo principal
Todos os Posts
Distributed Systems10 min de leitura

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.

Tiarê Balbi BonaminiEngenheiro de Software · Vancouver
2/4

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:

  • MaxDeliver tem 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.
  • BackOff sobrescreve o AckWait para o timing de reentrega, mas só quando o AckWait expira, não em um Nak() 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.

go
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 aproximadamente p99(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 o stream_seq original. 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 de process() é o heartbeat. Faço tick em menos que AckWait / 2 porque 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 advisory MSG_TERMINATED e remove a mensagem da rotação independentemente do MaxDeliver. É isso que uma falha de desserialização deve fazer, nunca um NAK.
  • NakWithDelay() para falhas transitórias. Um Nak() puro é uma reentrega imediata; NakWithDelay dá 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 que AckWait / 2.
  • MaxDeliver: -1 é reentrega infinita. Sempre defina um número, e se inscreva no advisory MAX_DELIVERIES para drenar os sobreviventes em uma DLQ.
  • Nak() sem delay é um chicote de reentrega imediata. Use NakWithDelay() ou confie no BackOff.
  • Term() é para mensagens que nunca terão sucesso. Não é um Nak() 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.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.