---
title: "AckWait É um Contrato: Como um Default de 30 Segundos Derrubou Meu Consumer JetStream"
url: https://tiarebalbi.com/pt-br/blog/nats-jetstream-ackwait-redelivery-contract-go
markdown: https://tiarebalbi.com/pt-br/blog/nats-jetstream-ackwait-redelivery-contract-go.md
description: "Os defaults do NATS JetStream — AckWait de 30s e MaxDeliver infinito — transformam consumers Go lentos em tempestades de reentrega. O contrato ConsumerConfig de 70 linhas que conserta isso."
author: "Tiarê Balbi Bonamini"
locale: pt-br
published: 2026-05-12
updated: 2026-05-12
category: "Distributed Systems"
tags: ["nats", "jetstream", "go", "sistemas-distribuidos", "mensageria", "backpressure", "ackwait", "pull-consumer"]
translation: https://tiarebalbi.com/en/blog/nats-jetstream-ackwait-redelivery-contract-go
---
# AckWait É um Contrato: Como um Default de 30 Segundos Derrubou Meu 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:

* `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.

​

![](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1777826690213-i0wvv808cui.jpeg)

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.
