Attention Queue Pattern com .NET e RabbitMQ | RabbitMQ | CSharp
- Documentação detalhada do padrão Attention Queue, quando usar, topologia, fluxo, decisões arquiteturais e implementação base em .NET | RabbitMQ | CSharp
O que é o Attention Queue Pattern
O Attention Queue Pattern é um padrão para processar muitas filas específicas sob demanda, sem manter consumidores permanentes em todas elas. A ideia central é separar:
- a fila de trabalho, que contém o payload real
- da fila de atenção, que contém apenas um aviso leve informando qual fila precisa ser processada naquele momento.
No artigo referenciado, o fluxo é descrito assim:
- a aplicação publica o trabalho na fila específica
- também publica uma mensagem leve em uma fila central de atenção
- workers permanentes consomem a fila de atenção
- ao receber esse aviso, um worker localiza a fila específica, processa uma fatia limitada
- se ainda houver backlog, publica nova atenção para essa mesma fila.
Problema que o padrão resolve
Esse padrão existe para cenários em que você possui:
- muitas filas granulares, normalmente uma por tenant, loja, cliente, parceiro ou recurso
- a maior parte dessas filas fica vazia boa parte do tempo
- você precisa manter isolamento por fila
- não quer sustentar um consumer fixo em cada fila
- também não quer jogar tudo em uma fila única central e perder isolamento, justiça e controle operacional.
O artigo usa exatamente essa motivação: manter filas específicas por loja preserva isolamento e governança, mas torna caro manter consumidores permanentes em todas elas; por outro lado, consolidar tudo em uma única fila simplifica o consumo, porém sacrifica esse isolamento.
Ideia central do padrão
A fila de atenção funciona como um escalonador de processamento.
Ela não processa o negócio. Ela só responde à pergunta:
“qual fila merece CPU agora?”
Já a fila de trabalho responde a outra pergunta:
“onde está o payload real?”
Essa lógica é comparada no artigo a um modelo de time sharing, no qual cada fila específica recebe uma fatia de processamento e, se ainda tiver mensagens pendentes, volta para a disputa em outra rodada. Isso melhora a justiça entre filas com volumes muito diferentes.
Elementos da solução
Uma implementação típica com .NET e RabbitMQ fica dividida assim:
-
API Publisher
- recebe a solicitação
- publica a mensagem real na fila de trabalho
- publica uma mensagem leve na fila de atenção
-
Attention Queue
- fila central consumida permanentemente
- carrega apenas metadados mínimos para localizar a fila real
-
Attention Dispatcher
BackgroundServiceem .NET- fica sempre consumindo a fila de atenção
- quando chega um aviso, dispara uma fatia de consumo da fila real
-
Work Slice Runner
- abre um consumo temporário da fila específica
- processa até um limite
- encerra
- republica atenção se ainda houver backlog
-
Work Message Handler
- executa a regra de negócio real
Esse tipo de serviço de longa duração encaixa naturalmente no modelo de Worker Service / BackgroundService do .NET.
Desenho da arquitetura
Fluxo completo
Publicação
Quando a API recebe uma solicitação:
- gera um
CorrelationId - calcula a fila real, por exemplo
work.tenant-873 - publica o payload real nessa fila
- publica um aviso leve na
attention.work
Consumo da atenção
O AttentionDispatcherBackgroundService:
- consome permanentemente
attention.work - desserializa a mensagem de atenção
- resolve qual fila real deve ser processada
- tenta adquirir um lock da fila, se necessário
Fatia de processamento
O SliceRunner:
-
abre uma janela de consumo temporária da fila real
-
processa por:
- quantidade máxima de mensagens
- tempo máximo
- ou até a fila ficar vazia
-
encerra
Nova rodada
Se a fila ainda tiver mensagens:
- o sistema publica nova mensagem de atenção
- a fila volta para a disputa por outra fatia
Esse comportamento de processar trabalho em fatias limitadas e repor a fila na rodada seguinte é a essência do padrão descrito no artigo.
O mesmo worker consome as duas filas?
Essa foi a principal dúvida, então a resposta precisa ser precisa:
Sim, mas não da mesma forma
O mesmo serviço .NET participa dos dois mundos, porém com papéis diferentes:
- ele consome permanentemente a attention queue
- ele não fica inscrito permanentemente em todas as filas de trabalho
- ele acessa a work queue de forma temporária, sob demanda
Em outras palavras:
- assinatura permanente: só na attention queue
- consumo temporário: na work queue específica
Esse detalhe é importante porque, se o worker ficar com um consumer fixo em milhares de filas reais, você perde o principal benefício do padrão: evitar custo operacional e desperdício em filas ociosas.
A fila de atenção deve usar fanout?
Na topologia principal do padrão, não.
O mais comum
Para a fila de atenção principal, o padrão mais coerente é:
- uma queue compartilhada
- N workers concorrentes
- modelo de competing consumers
Ou seja:
- cada mensagem de atenção deve ser tratada por um worker
- não por todos
O fanout no RabbitMQ é um mecanismo de broadcast: ele replica a mesma mensagem para múltiplas filas ligadas ao mesmo exchange. Isso é útil quando você quer que vários fluxos recebam a mesma mensagem. O tutorial oficial do RabbitMQ usa fanout justamente com esse objetivo.
Para Attention Queue principal
A atenção não é um evento de broadcast. Ela é um sinal interno de escalonamento. Portanto, normalmente você quer:
direct- ou
topic - ou até o exchange padrão com bind direto simples
Quando fanout faria sentido
Só faria sentido se você quisesse duplicar a atenção para propósitos diferentes, por exemplo:
- dispatcher operacional
- auditoria
- métricas
- observabilidade
Exemplo:
attention.exchange (fanout)
├─ attention.dispatch.queue
├─ attention.audit.queue
└─ attention.metrics.queue
Mesmo nesse caso, a queue que realmente dirige o consumo das work queues continua sendo uma só.
Quando usar esse padrão
Use quando você tem estes sinais:
Muitas filas granulares
Exemplos:
- uma fila por tenant
- uma fila por loja
- uma fila por cliente
- uma fila por parceiro
- uma fila por integração externa
Filas frequentemente ociosas
Esse é o melhor sinal de uso:
- milhares de filas cadastradas
- poucas ativas ao mesmo tempo
- muito custo para manter consumers fixos em todas elas
Necessidade de isolamento
Você precisa preservar por fila:
- backlog separado
- pausa operacional
- prioridade
- reprocessamento
- limitação por tenant
- throttling por parceiro
Rajadas desbalanceadas
Alguns tenants ou lojas geram muito mais volume que outros. O padrão melhora a justiça porque evita que uma fila monopolize os workers indefinidamente.
Polling distribuído caro
Se hoje você precisa consultar milhares de filas para saber se há trabalho, o Attention Queue substitui esse custo por um aviso leve de coordenação.
Quando não usar
Evite quando:
- você tem poucas filas
- o número de consumers fixos é pequeno e barato
- uma fila única já atende
- não existe necessidade de isolamento por tenant
- o volume é baixo e previsível
- você não precisa de fairness entre filas
Nesses casos, o padrão adiciona complexidade desnecessária:
- fila de atenção
- republicação
- lock
- controle de fatia
- observabilidade extra
- potencial duplicação de atenção
Decisão arquitetural simplificada
| Cenário | Attention Queue faz sentido? | Motivo |
|---|---|---|
| 10 filas e 10 consumers fixos | Não | Complexidade não compensa |
| 5.000 filas, maioria vazia | Sim | Evita consumers ociosos |
| Uma fila única atende bem | Não | Solução simples já resolve |
| Precisa de isolamento por tenant | Sim | Fila granular é útil |
| Forte desbalanceamento entre tenants | Sim | Melhora fairness |
| Volume baixo e estável | Não | Overengineering |
| Precisa pausar/reprocessar por cliente | Sim | Governança por fila |
Modelo de filas e exchanges
Uma topologia mínima com RabbitMQ pode ficar assim:
Exchange: attention.exchange (direct)
Queue: attention.work
Queue: work.tenant-001
Queue: work.tenant-002
Queue: work.tenant-873
Queue: work.tenant-999
A API faz:
publishdo payload real na filawork.<tenant>publishda atenção emattention.work
O worker faz:
consumepermanente deattention.work- ao receber atenção, processa temporariamente
work.<tenant>
Exemplo de contratos
public sealed record WorkMessage(
string TenantId,
string QueueName,
string CorrelationId,
string Payload);
public sealed record AttentionMessage(
string TenantId,
string QueueName,
string CorrelationId,
DateTimeOffset CreatedAtUtc);
Papel de cada contrato
WorkMessage
Representa o trabalho real:
- payload de negócio
- tenant
- fila
- correlação
AttentionMessage
Representa apenas a intenção operacional:
- qual tenant
- qual fila
- qual correlação
- quando a atenção foi criada
A mensagem de atenção deve ser leve e conter somente o mínimo necessário para localizar a fila e aplicar políticas. Isso é alinhado com o artigo, que trata a attention message como um aviso enxuto, não como o payload real.
Estrutura sugerida da solução .NET
/src
/Api
Endpoints/
PublishWorkEndpoint.cs
Messaging/
WorkPublisher.cs
AttentionPublisher.cs
/Worker
AttentionDispatcherBackgroundService.cs
SliceRunner.cs
QueueResolver.cs
QueueLeaseStore.cs
/Application
Handlers/
WorkMessageHandler.cs
/Contracts
WorkMessage.cs
AttentionMessage.cs
/Infrastructure.RabbitMq
RabbitConnectionFactory.cs
RabbitChannelProvider.cs
JsonMessageSerializer.cs
Papel do BackgroundService
No .NET, BackgroundService é a abstração natural para:
- workers de longa duração
- serviços que ficam consumindo fila
- loops de execução contínua
- componentes que precisam participar do ciclo de vida do host.
Nesse padrão, o AttentionDispatcherBackgroundService:
- vive enquanto a aplicação estiver ativa
- consome a attention queue
- dispara slices sob demanda
- respeita
CancellationToken
Exemplo do fluxo na API
app.MapPost("/process", async (
ProcessRequest request,
IWorkPublisher workPublisher,
IAttentionPublisher attentionPublisher,
CancellationToken ct) =>
{
var correlationId = Guid.NewGuid().ToString("N");
var queueName = $"work.{request.TenantId}";
var work = new WorkMessage(
TenantId: request.TenantId,
QueueName: queueName,
CorrelationId: correlationId,
Payload: request.Payload);
var attention = new AttentionMessage(
TenantId: request.TenantId,
QueueName: queueName,
CorrelationId: correlationId,
CreatedAtUtc: DateTimeOffset.UtcNow);
await workPublisher.PublishAsync(work, ct);
await attentionPublisher.PublishAsync(attention, ct);
return Results.Accepted($"/process/{correlationId}", new { correlationId, queueName });
});
O que esse endpoint faz
- calcula a fila de trabalho com base no tenant
- publica a mensagem real na fila do tenant
- publica a mensagem leve de atenção
- devolve
202 Accepted
Exemplo do dispatcher
O dispatcher fica permanentemente na attention queue.
public sealed class AttentionDispatcherBackgroundService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// 1. conecta no RabbitMQ
// 2. declara exchange/queue da attention queue
// 3. consome permanentemente
// 4. ao receber uma atenção:
// - resolve a fila alvo
// - tenta adquirir lock
// - chama RunSliceAsync
}
}
Responsabilidades dele
- consumir a fila de atenção
- desserializar
AttentionMessage - validar se aquela fila pode ser processada agora
- delegar para o slice runner
- ack/nack da atenção
Exemplo do slice runner
O slice runner executa a fatia de processamento.
private async Task RunSliceAsync(AttentionMessage attention, CancellationToken ct)
{
const int maxMessages = 20;
var maxDuration = TimeSpan.FromSeconds(10);
// abre um channel
// lê da work queue associada
// processa até atingir um limite
// verifica backlog
// se ainda houver mensagens, republica atenção
}
Limites comuns da fatia
- quantidade máxima de mensagens
- duração máxima
- fila vazia
- erro
- limite por prioridade
Esses limites existem para preservar fairness e evitar monopolização da capacidade por uma única fila. Isso reflete a lógica de time-sharing descrita no artigo.
Por que usar consumo temporário da work queue
Esse é o ponto principal do padrão.
Se você deixar um consumer fixo em cada work queue:
- perde o benefício do desenho
- mantém custo constante em filas vazias
- complica governança quando o número de filas explode
Já o consumo temporário:
- abre a work queue só quando necessário
- processa uma fatia
- fecha
- volta a competir pela próxima atenção
BasicGet vs BasicConsume na work queue
BasicGet
Vantagens neste padrão:
- mais simples para controlar fatia
- fácil parar exatamente após N mensagens
- loop explícito e previsível
Desvantagens:
- não é streaming contínuo
- pode ser menos eficiente em alguns cenários de throughput extremo
BasicConsume
Vantagens:
- fluxo contínuo
- modelo natural de consumer do RabbitMQ
Desvantagens para esse padrão:
- controlar encerramento exato da fatia é mais trabalhoso
- cancelamento do consumo precisa ser gerenciado com mais cuidado
Para slices temporários, BasicGet costuma deixar a implementação mais simples e legível.
Necessidade de lock por fila
Sem lock, dois workers podem:
- consumir duas mensagens de atenção diferentes
- ambas apontando para a mesma
work.<tenant> - abrir duas janelas de consumo simultâneas para a mesma fila
Às vezes isso é aceitável. Às vezes não.
Use lock quando precisar garantir:
- exclusividade por fila
- controle de concorrência por tenant
- preservação de ordem mais previsível
- proteção contra disputa duplicada
Implementações possíveis
- memória local, apenas para POC
- Redis
- banco relacional
- mecanismo distribuído próprio
Em produção, lock em memória geralmente não é suficiente se há mais de uma instância do worker.
Papel do prefetch na attention queue
No RabbitMQ, prefetch controla quantas mensagens um consumer pode receber sem confirmar. Para attention queue, um prefetch pequeno, como 1, tende a ser apropriado porque:
- evita que uma instância capture muitas atenções de uma vez
- melhora fairness entre instâncias
- combina com o fato de cada atenção disparar trabalho não trivial.
Ack e Nack
Na attention queue
-
Ack- quando a atenção foi tratada com sucesso
- inclusive se a fila já estava sendo processada e você decidiu encerrar aquela atenção de forma controlada
-
Nack- quando houve falha operacional e você quer requeue
Na work queue
-
Ack- após o handler concluir com sucesso
-
Nack- em caso de falha
- com ou sem
requeue, conforme estratégia
RabbitMQ documenta formalmente os conceitos de confirmação de consumo e publisher confirms, que são fundamentais para confiabilidade.
Publisher confirms e segurança de publicação
Como a API publica:
- a mensagem real
- a mensagem de atenção
há um risco de inconsistência se uma publicação acontecer e a outra não.
Exemplos de inconsistência
Caso 1
Publicou o WorkMessage, mas falhou ao publicar a atenção
Resultado:
- a mensagem real existe
- ninguém sabe que precisa processá-la
Caso 2
Publicou a atenção, mas falhou ao publicar o WorkMessage
Resultado:
- o dispatcher acorda uma fila vazia
Mitigações
- publisher confirms
- retry idempotente
- outbox pattern
- reconciliação periódica
RabbitMQ trata publisher confirms como o mecanismo oficial para a aplicação saber se a mensagem foi aceita pelo broker.
Idempotência da atenção
Atenção duplicada precisa ser tolerada.
Isso é importante porque:
- a mesma fila pode receber mais de uma atenção
- republicações podem ocorrer
- retries de publicação são possíveis
- falhas parciais podem introduzir duplicação
Então o sistema deve assumir que:
- a atenção pode chegar duplicada
- o lock ou as regras de coordenação precisam suportar isso
- a fila real continua sendo a fonte do trabalho verdadeiro
O próprio artigo destaca a necessidade de lidar com atenção duplicada e tratar publicação de trabalho e atenção como a mesma intenção operacional.
Estratégias de parada da fatia
A fatia pode encerrar por:
- número máximo de mensagens
- tempo máximo
- fila vazia
- erro operacional
- orçamento de CPU
- orçamento por tenant
Exemplo prático
const int maxMessages = 20;
var maxDuration = TimeSpan.FromSeconds(10);
Isso gera comportamento previsível:
- uma fila ativa não monopoliza o worker para sempre
- outras filas também recebem chance de execução
Estratégias de fairness
O padrão ajuda a distribuir capacidade entre filas, mas fairness de verdade depende da política.
Políticas possíveis
- round-robin implícito pela ordem das atenções
- limite de mensagens por fatia
- limite de tempo por fatia
- prioridade por tenant
- cooldown entre rodadas
- múltiplas attention queues por classe de serviço
Exemplo
- tenants premium podem receber fatia maior
- tenants free podem receber fatia menor
- parceiros lentos podem ter throttle específico
Exemplo de políticas por fila
Esse padrão é útil quando você precisa aplicar política individual por queue.
Exemplos reais
- pausar uma loja específica
- limitar uma integração externa com API rate limited
- reprocessar apenas um tenant
- isolar backlog de um parceiro problemático
- alterar prioridade por conta
Sem filas granulares, esse tipo de governança fica muito mais difícil.
Observabilidade recomendada
Para operar esse padrão bem, acompanhe métricas como:
- quantidade de mensagens por work queue
- quantidade de atenções publicadas
- taxa de republicação de atenção
- tempo médio por slice
- mensagens processadas por slice
- filas com backlog alto cont ínuo
- filas com muitas atenções e pouco progresso
- taxa de lock negado
- erro por tenant
- tempo até primeira execução após atenção
Sinais de problema
- mesma fila republicando atenção sem reduzir backlog
- taxa alta de lock negado
- muitas atenções para filas vazias
- latência alta entre atenção e processamento
- crescimento contínuo de backlog em tenants específicos
Riscos e trade-offs
| Aspecto | Ganho | Custo |
|---|---|---|
| Escala de muitas filas | Evita consumers ociosos | Mais componentes |
| Isolamento por tenant | Melhor governança | Mais topologia |
| Fairness | Evita monopolização | Regras extras |
| Consumo sob demanda | Economia operacional | Mais coordenação |
| Flexibilidade por fila | Controle fino | Mais observabilidade |
| Robustez | Melhor administração do backlog | Exige idempotência e lock |
Exemplo de decisão arquitetural
Use
- milhares de filas
- baixa ocupação média por fila
- isolamento necessário
- políticas por tenant
- forte variação de volume
Evite
- poucas filas
- baixo volume
- sem necessidade de isolamento
- uma fila única já satisfaz
- equipe ainda sem maturidade operacional em mensageria
Implementação base resumida
Componentes
IWorkPublisherIAttentionPublisherAttentionDispatcherBackgroundServiceIQueueLeaseStoreIWorkMessageHandlerIMessageSerializer
Fluxo
- API publica
WorkMessage - API publica
AttentionMessage - Dispatcher consome
AttentionMessage - Dispatcher tenta lock da fila
- Dispatcher executa
RunSliceAsync RunSliceAsyncprocessa até um limite- Se houver backlog, republica nova atenção
Exemplo de desenho sequencial
Exemplo de naming
Filas de trabalho
work.tenant-001
work.tenant-002
work.tenant-873
Fila de atenção
attention.work
Exchanges
attention.exchange
Exemplo de código simplificado do endpoint
app.MapPost("/process", async (
ProcessRequest request,
IWorkPublisher workPublisher,
IAttentionPublisher attentionPublisher,
CancellationToken ct) =>
{
var correlationId = Guid.NewGuid().ToString("N");
var queueName = $"work.{request.TenantId}";
await workPublisher.PublishAsync(
new WorkMessage(request.TenantId, queueName, correlationId, request.Payload),
ct);
await attentionPublisher.PublishAsync(
new AttentionMessage(request.TenantId, queueName, correlationId, DateTimeOffset.UtcNow),
ct);
return Results.Accepted();
});
Exemplo de slice runner simplificado
private async Task RunSliceAsync(AttentionMessage attention, CancellationToken ct)
{
const int maxMessages = 20;
var maxDuration = TimeSpan.FromSeconds(10);
var processed = 0;
var startedAt = DateTimeOffset.UtcNow;
while (!ct.IsCancellationRequested)
{
if (processed >= maxMessages)
break;
if (DateTimeOffset.UtcNow - startedAt >= maxDuration)
break;
var result = await channel.BasicGetAsync(attention.QueueName, autoAck: false, cancellationToken: ct);
if (result is null)
break;
var work = _serializer.Deserialize<WorkMessage>(result.Body);
await _handler.HandleAsync(work, ct);
await channel.BasicAckAsync(result.DeliveryTag, false, ct);
processed++;
}
var stillHasMessages = await channel.MessageCountAsync(attention.QueueName, ct) > 0;
if (stillHasMessages)
{
await _attentionPublisher.PublishAsync(
new AttentionMessage(
attention.TenantId,
attention.QueueName,
Guid.NewGuid().ToString("N"),
DateTimeOffset.UtcNow),
ct);
}
}
Boas práticas para produção
Mensageria
- usar filas duráveis
- usar mensagens persistentes
- configurar DLQ
- usar publisher confirms
- definir política clara de retry
Coordenação
- lock distribuído real
- evitar concorrência indesejada na mesma fila
- manter attention message idempotente
Operação
- métricas por tenant
- tracing por
CorrelationId - dashboard de backlog por fila
- alertas para republicação excessiva
Evolução
- separar attention queue por domínio, se necessário
- introduzir prioridade por classe de serviço
- ajustar slice conforme perfil do workload
Anti-padrões comuns
1. Consumer permanente em todas as work queues
Quebra a proposta do padrão.
2. Attention message com payload real
Mistura coordenação com trabalho real.
3. Fanout na attention queue principal sem necessidade
Transforma escalonamento em broadcast.
4. Falta de lock em cenário multi-instância
Pode gerar concorrência indesejada na mesma fila.
5. Sem idempotência
Duplica processamento ou entra em loops difíceis de rastrear.
6. Fatia muito longa
Reduz fairness e aproxima o desenho de consumer fixo.
7. Fatia curta demais
Aumenta overhead de republicação.
Regra prática final de uso
Você provavelmente deve usar esse padrão quando consegue dizer:
- tenho muitas filas
- a maioria fica vazia parte relevante do tempo
- preciso de isolamento por tenant/loja/cliente
- alguns tenants geram rajadas muito maiores
- consumer fixo por fila está caro ou inviável
- quero escalonar capacidade com justiça
Se esses pontos não existem, a tendência é que uma topologia mais simples seja melhor.