~ / tutoriais /filas-laravel-2026-horizon-redis-tracing $ _

Filas no Laravel em 2026: Horizon, Redis e tracing distribuído

Lucas Souza Lucas Souza 11 min de leitura Tutoriais
Filas no Laravel em 2026: Horizon, Redis e tracing distribuído

Em 2024 a gente reclamava de job de envio de email travando a fila. Em 2026 o problema é outro: um único job pode ficar 90 segundos esperando o LLM responder, gastar 2 dólares de token, e ainda falhar silenciosamente porque a API devolveu um JSON vazio que o seu código aceitou como "sucesso".

Fila com IA dentro não é fila. É roleta russa com observabilidade ruim.

Esse post é o desenho de stack que tem segurado os pipelines que estamos colocando em produção: Horizon + Redis 7 + supervisor por SLA, retry com idempotency key, e instrumentação OpenTelemetry de ponta a ponta. Você vai sair sabendo separar fila de email de fila de embedding, configurar backoff que não te bane na OpenAI e enxergar onde o tempo realmente está sendo gasto entre dispatch e consumer.

TL;DR

  • O que é: stack de filas Laravel para workloads que misturam jobs rápidos (email, webhook) e jobs lentos com IA (LLM call, embedding, rerank).
  • Stack: Laravel 12, Horizon, Redis 7, keepsuit/laravel-opentelemetry, exporter OTLP para o backend que você usa (Tempo, Jaeger, Honeycomb, Datadog).
  • Custo/Acesso: Horizon é gratuito (MIT). Redis self-hosted ou managed. Backend de tracing varia.
  • Repositório/Link útil: docs Horizon 12.x, docs Queues 12.x.

O contexto: por que filas de 2026 são outro bicho

Job de 2022 era CPU-bound ou IO-bound previsível. Você sabia que SendInvoiceEmail ia rodar em 800ms, no pior caso 2s. Dimensionava worker em cima disso e dormia tranquilo.

Job de 2026 chama LLM. E LLM tem cauda longa absurda:

  • Uma chamada para Claude 4.7 com 50k tokens de contexto pode levar de 4 a 40 segundos. Mesma rota, mesma entrada parecida.
  • Geração com extended thinking estoura facilmente o timeout=60 default.
  • Rerank em cima de 50 documentos pode dobrar a latência sem aviso.
  • Embedding em batch é rápido, mas se a quota da OpenAI estourar você toma 429 em rajada.

Misturar isso com o job que envia o email de confirmação no mesmo worker é um erro de design. Worker fica preso esperando o LLM, fila de email engata, usuário não recebe nada, suporte abre ticket, alguém culpa o "Laravel lento". O Laravel não tem nada a ver. A arquitetura é que está errada.

A fix tem três partes: separar filas por SLA, instrumentar tudo com tracing, e tratar idempotency como cidadão de primeira classe. É isso que a gente desce a seguir.

Pré-requisitos

  • [ ] Laravel 11 ou 12 (os exemplos abaixo são da 12, mas funcionam na 11 com ajustes mínimos).
  • [ ] Redis 7+ (Horizon não roda em Redis Cluster — fica em standalone ou Sentinel, docs Horizon).
  • [ ] PHP 8.3+ com phpredis (PECL) se você quer extrair performance — é mais rápido que o cliente userland.
  • [ ] Supervisor (o do Linux, supervisord) ou systemd para manter o horizon rodando.
  • [ ] Algum backend OTLP: Grafana Tempo, Jaeger, Honeycomb, Datadog, etc.

Filas separadas por SLA

A primeira decisão é parar de jogar tudo em default. Crie filas com semântica clara, dimensionadas pelo SLA esperado:

fila latência alvo exemplo de job
high < 5s webhook crítico, OTP, billing
default < 60s email transacional, notificação
ai < 5min LLM call, embedding, rerank, agente
bulk minutos a horas reindexação, export CSV gigante

No config/horizon.php, isso vira um supervisor por perfil de carga. A ordem das filas no array importa — o worker drena a primeira antes de olhar a próxima (Auto Balancing).

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-realtime' => [
            'connection' => 'redis',
            'queue' => ['high', 'default'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 2,
            'maxProcesses' => 20,
            'balanceMaxShift' => 2,
            'balanceCooldown' => 3,
            'timeout' => 90,
            'tries' => 3,
        ],

        'supervisor-ai' => [
            'connection' => 'redis',
            'queue' => ['ai'],
            'balance' => 'simple',
            'minProcesses' => 4,
            'maxProcesses' => 12,
            'timeout' => 600,
            'tries' => 5,
            'memory' => 512,
        ],

        'supervisor-bulk' => [
            'connection' => 'redis',
            'queue' => ['bulk'],
            'balance' => false,
            'minProcesses' => 1,
            'maxProcesses' => 4,
            'timeout' => 3600,
            'tries' => 1,
        ],
    ],
],

Três coisas importantes nesse bloco:

  1. supervisor-realtime usa autoScalingStrategy => 'time'. Horizon olha o tempo estimado para drenar a fila e ajusta processos. Para job rápido isso responde bem a picos. Para job de IA não funciona — o tempo estimado oscila demais.
  2. supervisor-ai tem timeout => 600. LLM call de 90s é normal. Se o seu timeout for menor, você vai matar job que ia terminar bem.
  3. supervisor-bulk tem tries => 1. Reindexar 200 mil registros e dar retry automático em cima de falha não recuperável é receita pra queimar 8 horas de CPU à toa. Em bulk, falhou, vai pro failed_jobs, alguém olha.

No nível do job, marque a fila no construtor:

class GenerateProductEmbedding implements ShouldQueue
{
    public function __construct(public Product $product)
    {
        $this->onQueue('ai');
    }
}

Retry inteligente: backoff exponencial e idempotency key

Retry burro derruba sistema inteiro. Se a OpenAI te devolveu 429, retentar em 1s vai te devolver outro 429. Em 2s, idem. O default do Laravel não te protege disso — você precisa configurar.

Backoff progressivo

$backoff aceita array — cada posição é o delay em segundos da tentativa correspondente (docs Queues).

class CallLLM implements ShouldQueue
{
    public $tries = 5;
    public $backoff = [10, 30, 120, 300, 600];
    public $timeout = 180;

    public function handle(): void
    {
        // ...
    }
}

Tradução: tentativa 1 falhou, espera 10s. Falhou de novo, 30s. Depois 2min, 5min, 10min. Quem trabalha com API externa sabe que essa curva absorve quase todo rate limit transitório.

Throttle de exceções via Redis

ThrottlesExceptionsWithRedis usa o seu Redis como bucket compartilhado entre workers. Útil quando você tem 8 workers chamando a mesma API e quer parar todos depois de N falhas:

use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis;

public function middleware(): array
{
    return [
        (new ThrottlesExceptionsWithRedis(maxAttempts: 10, decaySeconds: 600))
            ->by('openai-api')
            ->backoff(5),
    ];
}

public function retryUntil(): DateTime
{
    return now()->addMinutes(30);
}

by('openai-api') é a chave do bucket — todos os jobs com a mesma chave compartilham o contador. Estourou 10 falhas em 10 minutos? O middleware para de tentar e libera daqui a 5 minutos. Sua bill agradece.

Idempotency key real, não fingida

Aqui é onde a maioria erra. Job que chama API externa e que pode rodar duas vezes precisa de idempotency. Não basta ShouldBeUnique — o lock cai depois que o job começa a executar, e se o worker morre no meio você vai duplicar a chamada na próxima tentativa.

O padrão certo: gera a chave no construtor, persiste, e usa nas duas pontas (cache local + header HTTP para a API).

class CallLLM implements ShouldQueue, ShouldBeUnique
{
    public string $idempotencyKey;

    public function __construct(public Conversation $conversation, public string $prompt)
    {
        $this->idempotencyKey = (string) Str::uuid();
    }

    public function uniqueId(): string
    {
        return "llm:{$this->conversation->id}:{$this->idempotencyKey}";
    }

    public $uniqueFor = 3600;

    public function handle(Anthropic $client): void
    {
        $cached = Cache::get("llm-result:{$this->idempotencyKey}");
        if ($cached !== null) {
            $this->conversation->messages()->create($cached);
            return;
        }

        $response = $client->messages()->create([
            'model' => 'claude-opus-4-7',
            'messages' => [['role' => 'user', 'content' => $this->prompt]],
        ], headers: ['Idempotency-Key' => $this->idempotencyKey]);

        Cache::put("llm-result:{$this->idempotencyKey}", [
            'role' => 'assistant',
            'content' => $response->content,
        ], now()->addHour());

        $this->conversation->messages()->create([
            'role' => 'assistant',
            'content' => $response->content,
        ]);
    }
}

Detalhe que parece bobagem mas não é: o ShouldBeUnique do Laravel cobre dispatch duplicado, mas não funciona dentro de batches (issue #48882). Se você dispara Bus::batch([...]) com jobs unique, o lock é ignorado. Quando precisar de batch idempotente, controle a chave você mesmo no banco.

Tracing distribuído: vendo o tempo entre dispatch e consumer

Sem tracing você não tem como saber se a sua fila ai está lenta por causa do LLM ou por causa do tempo parada na queue do Redis. Métrica de Horizon te mostra throughput, não te mostra latência ponta a ponta. Aí entra o OpenTelemetry.

Instale o pacote da Keepsuit:

composer require keepsuit/laravel-opentelemetry
php artisan vendor:publish \
  --provider="Keepsuit\LaravelOpenTelemetry\LaravelOpenTelemetryServiceProvider" \
  --tag="opentelemetry-config"

Configure o exporter OTLP no .env:

OTEL_SERVICE_NAME=app-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_TRACES_EXPORTER=otlp
OTEL_PHP_AUTOLOAD_ENABLED=true

O pacote já instrumenta filas: cria um span PRODUCER no dispatch e um CONSUMER no handle, com o trace ID propagado pelo payload do job (keepsuit/laravel-opentelemetry). Isso conecta a request HTTP que disparou o job ao próprio job no seu backend de tracing — você vê a cascata inteira.

Para o job de LLM, abra spans manuais nas chamadas caras:

use Keepsuit\LaravelOpenTelemetry\Facades\Tracer;

public function handle(Anthropic $client): void
{
    $response = Tracer::newSpan('llm.anthropic.messages.create')
        ->setAttributes([
            'llm.model' => 'claude-opus-4-7',
            'llm.input_tokens_estimated' => Str::wordCount($this->prompt),
            'idempotency_key' => $this->idempotencyKey,
        ])
        ->measure(fn () => $client->messages()->create([/* ... */]));

    Tracer::activeSpan()->setAttributes([
        'llm.output_tokens' => $response->usage->output_tokens,
        'llm.input_tokens' => $response->usage->input_tokens,
    ]);
}

Cuidado fino com worker de longa duração: span context pode vazar de um job pro próximo se você guardar tracer no container como singleton (Uptrace guide). Liga worker_mode.flush_after_each_iteration no config do pacote pra forçar flush por job:

// config/opentelemetry.php
'worker_mode' => [
    'flush_after_each_iteration' => true,
],

Em troca de um pouco mais de overhead você ganha telemetria limpa, sem mistura de trace.

O bug do "success" silencioso

Esse é o que mais dói porque parece que está tudo bem. O LLM devolve resposta vazia ou um JSON quebrado, o seu código pega $response->content[0]->text ?? '', salva string vazia no banco e marca o job como concluído. Métrica do Horizon mostra throughput verde. Usuário vê tela em branco. Suporte abre ticket. Você passa duas horas procurando bug em lugar nenhum.

Regra: toda saída de LLM precisa passar por uma validação explícita antes do job retornar com sucesso. Se a validação falhar, lance exceção para o retry pegar.

public function handle(Anthropic $client): void
{
    $response = $client->messages()->create([/* ... */]);

    $text = $response->content[0]->text ?? '';

    if (trim($text) === '') {
        throw new EmptyLLMResponseException(
            "Modelo devolveu resposta vazia. request_id={$response->id}"
        );
    }

    if (! $this->parsesAsExpectedSchema($text)) {
        throw new MalformedLLMResponseException(
            "Resposta não bate com schema esperado. request_id={$response->id}"
        );
    }

    $this->conversation->messages()->create([
        'role' => 'assistant',
        'content' => $text,
    ]);
}

Marca como métrica também — adicione um span attribute para que o backend de tracing destaque jobs com resposta vazia mesmo quando "concluídos sem erro":

Tracer::activeSpan()->setAttributes([
    'llm.response.is_empty' => trim($text) === '',
    'llm.response.length' => strlen($text),
]);

Aí você consegue criar um alerta em cima de llm.response.is_empty = true sem precisar instrumentar o erro a cada chamada.

Limitações e pontos de atenção

  • Horizon não suporta Redis Cluster (docs). Se a sua infra exige Cluster, ou você usa standalone com Sentinel para HA, ou troca para outro driver (SQS, Beanstalkd). Aceitar isso de antemão evita refactor doloroso.
  • Worker reciclado por memória. LLM call processa payload grande, PHP infla, memória não volta. Sempre rode php artisan horizon (que já recicla automaticamente) ou para queue:work, use --max-jobs=1000 --max-time=3600 (referência).
  • Idempotency em batch. Como dito, ShouldBeUnique ignora dentro de batch. Se precisar batch idempotente, use uma tabela job_idempotency com unique index na chave e cheque no início do handle().
  • Custo de tracing. Sample agressivo em fila high (1% talvez), sample integral em ai (cada job custa dinheiro, você quer visibilidade total). Não use a mesma estratégia para todas as filas.
  • Failed jobs com payload grande. Job de LLM tem prompt enorme no payload. A tabela failed_jobs cresce rápido. Use php artisan queue:prune-failed --hours=72 em schedule e considere truncar o payload no listener Queue::failing.

FAQ

Posso usar SQS no lugar do Redis para a fila ai? Pode. SQS aguenta job de longa duração com visibility_timeout grande. Você perde Horizon e ganha durabilidade. Para volume baixo de jobs caros (LLM), SQS funciona muito bem. Para alto throughput (webhook, email), Redis sai na frente em latência e custo.

Como medir se o tracing está pesando demais? Compare p95 de latência do job antes e depois de habilitar o exporter. Se subir mais que 5%, baixe sampling, troque o exporter para batch (em vez de simple) ou use o SpanExporter async via OTLP HTTP/gRPC. Não rode tracing síncrono em produção.

Devo usar Bus::batch ou jobs encadeados (Bus::chain)? Batch para jobs que podem rodar em paralelo (gerar embedding de 1000 produtos). Chain para pipeline sequencial (extrair texto → resumir → indexar). Não misture sem necessidade.

O WithoutOverlapping resolve o problema de idempotency? Não. Ele evita que dois jobs do mesmo recurso rodem ao mesmo tempo, mas se o primeiro morre o segundo ainda roda — e a API externa pode ter recebido as duas chamadas. Idempotency precisa ser a nível de payload + cache + header HTTP.

Conclusão

Fila com IA dentro é um sistema distribuído inteiro embaixo do que parece um dispatch() inocente. Você precisa separar SLA, controlar retry com cabeça, ter idempotency de verdade e instrumentar tudo — caso contrário o pico de uso vai te pegar de calça curta às 3 da manhã.

Esse tipo de dor é exatamente o que rola toda semana de discussão na Beer and Code, a melhor comunidade de AI engineering em português, com grupo no WhatsApp aberto pra quem está construindo IA em produção e quer trocar figurinha sobre o que está quebrando no real, não em slide de conferência.

O próximo passo é olhar failed_jobs da sua aplicação hoje e contar quantos são por timeout de chamada externa. Se for mais que 5%, sua stack ainda está pedindo cuidado.

Lucas Souza
Lucas Souza

{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.

Você também pode gostar

Deploy de Laravel em produção em 2026: Forge, Cloud, Sail ou Kubernetes?
Tutoriais

Deploy de Laravel em produção em 2026: Forge, Cloud, Sail ou Kubernetes?

Quatro caminhos pra rodar Laravel em produção em 2026 (Forge, Cloud, Sail+VPS ou Kubernetes) comparados por cenário, com a armadilha que mata seu banco e o checklist de 18 itens antes do go-live.

· 14 min
Hands-on: meu primeiro Pull Request 100% gerado por agente em Laravel (com diff e revisão)
Tutoriais

Hands-on: meu primeiro Pull Request 100% gerado por agente em Laravel (com diff e revisão)

Liguei o agente, fui tomar café e voltei 43 minutos depois com um PR de 380 linhas em 9 arquivos. Case study real com harness Laravel + Claude Agent SDK + sandbox isolado, a task escolhida, o loop cronometrado de 43 min em 12 iterações, o diff comentado, os 3 bugs que escaparam pro code review humano, custo total em USD e o veredito sobre soltar isso em produção. Repositório público no final.

· 13 min
Tracking 24/7: do agente que responde "quanto custa?" ao agente que avisa "baixou agora"
Tutoriais

Tracking 24/7: do agente que responde "quanto custa?" ao agente que avisa "baixou agora"

Como evoluir do agente que responde "quanto custa?" para o agente que avisa "baixou agora": cron, webhook, idempotência, deduplicação de alerta e janelas de monitoramento sem estourar custo. Com snippets em Laravel e o que muda no harness quando o agente passa a viver sozinho.

· 5 min
Otimize sua aplicação Laravel com o novo Memoized Cache Driver (Laravel 12.9)
Tutoriais

Otimize sua aplicação Laravel com o novo Memoized Cache Driver (Laravel 12.9)

O Laravel 12.9 trouxe uma novidade poderosa: o Memoized Cache Driver. Essa feature otimiza o desempenho das aplicações ao armazenar em memória os valores obtidos do cache durante o tempo de execução da requisição, evitando múltiplos acessos ao cache.

· 3 min

VirguIA

beer & code assistant

conectando…

Não foi possível iniciar o chat agora.

tocando