Quorum queues não suportam global QoS e o Celery precisa do x-queue-type declarado

O que aconteceu

Nenhum worker de staging consumia nada. As filas acumulavam backlog com consumers = 0 — onion-staging com 118 mil mensagens, onion-staging-habits com 61 mil, onion-staging-progress com 42 mil.

O sintoma que expôs o problema foi o webhook de checkout: o endpoint respondia com task_id, a mensagem chegava em onion-staging-checkout, e nunca era processada. A investigação mostrou que não era um problema do checkout — os três workers estavam igualmente parados, em loop infinito de reconexão:

mingle: all alone consumer: Connection to broker lost. Trying to re-establish the connection... amqp.exceptions.AMQPNotImplementedError: Basic.consume: (540) NOT_IMPLEMENTED - queue 'onion-staging-progress' in vhost 'onion-backend--staging' does not support global qos

Causa raiz

O broker de staging é Amazon MQ rodando RabbitMQ 4.2.9, e o vhost onion-backend--staging tem default_queue_type = quorum. Toda fila criada nesse vhost nasce quorum, mesmo quando o cliente não pede — e quorum queue não suporta global QoS.

O Celery 5.5 sabe contornar isso: Tasks.qos_global() desliga o global QoS quando detect_quorum_queues() encontra uma fila quorum. Só que a detecção lê apenas o queue_arguments das filas declaradas em task_queues:

python # celery/utils/quorum_queues.py for qname in queues: qarguments = queues[qname].queue_arguments or {} if qarguments.get("x-queue-type") == "quorum": return True, qname

Como task_queues era None e as filas nasciam da declaração implícita feita a partir do -Q "${CELERY_QUEUES}" (run/runtime), o Celery as tratava como clássicas, mantinha apply_global=True no basic.qos, o RabbitMQ rejeitava o basic.consume e a conexão caía — para sempre.

O ponto não óbvio: o tipo real da fila no broker não importa para a detecção. O Celery decide pelo que ele mesmo declarou, não pelo que existe no servidor. Uma fila que é quorum no RabbitMQ mas foi declarada sem x-queue-type é, para o Celery, clássica.

Correção

config/queues.py ganhou quorum_task_queues(), e config/settings/staging.py passou a declarar as filas explicitamente:

python CELERY_TASK_QUEUES = quorum_task_queues(["default", "habits", "progress", "checkout", "analytics"]) CELERY_TASK_DEFAULT_QUEUE = resolve_queue("default")

Contexto da implementação:

  • A declaração espelha bit a bit o que já existia no broker — exchange direct homônimo, routing key igual ao nome da fila — verificado nos bindings do vhost antes da mudança. Declaração equivalente não gera PRECONDITION_FAILED nem muda roteamento
  • -Q continua filtrando o consumo: declarar as cinco filas em task_queues não faz o worker consumir as cinco. Quem manda no consumo é queues.consume_from, populado pelo select() do -Q
  • CELERY_TASK_DEFAULT_QUEUE entrou junto porque o pod web sobe com CELERY_QUEUES=tasks, o que fazia o default do produtor ser a fila tasks — que ninguém consome, e que explicava 17 mil mensagens órfãs no vhost

Como evitar

  • A correção não pode ser compartilhada entre ambientes com versões diferentes de broker. O compose local usa rabbitmq:3.13, cujo default é fila classic: declarar x-queue-type: quorum num settings compartilhado faria o Celery redeclarar filas locais existentes como quorum, resultando em PRECONDITION_FAILED. Por isso a mudança ficou confinada em config/settings/staging.py — produção usa SQS e o config/settings/__init__.py despacha por ENV, então staging.py nunca é importado lá
  • Ao subir ou migrar um broker RabbitMQ 4.x, checar o default_queue_type do vhost antes de assumir que as filas serão classic
  • Fila com backlog crescente e consumers = 0 é sinal de consumidor caindo no handshake, não de produtor errado. O task_id retornado pela API só prova que a mensagem foi publicada — nunca que existe alguém consumindo
  • Ao declarar task_queues manualmente, replicar exchange e routing key da declaração implícita do Celery, senão a mudança de roteamento silenciosamente órfã as mensagens já enfileiradas

Referências