Skip to main content

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

    • BackgroundService em .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árioAttention Queue faz sentido?Motivo
10 filas e 10 consumers fixosNãoComplexidade não compensa
5.000 filas, maioria vaziaSimEvita consumers ociosos
Uma fila única atende bemNãoSolução simples já resolve
Precisa de isolamento por tenantSimFila granular é útil
Forte desbalanceamento entre tenantsSimMelhora fairness
Volume baixo e estávelNãoOverengineering
Precisa pausar/reprocessar por clienteSimGovernanç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:

  • publish do payload real na fila work.<tenant>
  • publish da atenção em attention.work

O worker faz:

  • consume permanente de attention.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

AspectoGanhoCusto
Escala de muitas filasEvita consumers ociososMais componentes
Isolamento por tenantMelhor governançaMais topologia
FairnessEvita monopolizaçãoRegras extras
Consumo sob demandaEconomia operacionalMais coordenação
Flexibilidade por filaControle finoMais observabilidade
RobustezMelhor administração do backlogExige 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

  • IWorkPublisher
  • IAttentionPublisher
  • AttentionDispatcherBackgroundService
  • IQueueLeaseStore
  • IWorkMessageHandler
  • IMessageSerializer

Fluxo

  1. API publica WorkMessage
  2. API publica AttentionMessage
  3. Dispatcher consome AttentionMessage
  4. Dispatcher tenta lock da fila
  5. Dispatcher executa RunSliceAsync
  6. RunSliceAsync processa até um limite
  7. 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.