Filas e Jobs Assíncronos

Filas e Jobs Assíncronos

Uma fila sobre Redis, com envelope de metadados, retry com backoff, dead-letter queue e um worker que atende a SIGTERM sem largar o job pela metade. Inclui o detalhe que faz o retry funcionar de verdade: alguém precisa promover o que ficou reagendado.
PHP

• • 26 min de leitura

Algumas operações não precisam — e não devem — acontecer na requisição HTTP: enviar email, gerar PDF, processar imagem, notificar sistemas externos. Cada milissegundo que você gasta nessas tarefas é milissegundo que o usuário espera.

Filas resolvem isso: a requisição enfileira o trabalho e retorna imediatamente. Um processo separado (worker) consome a fila em background e executa os jobs. O resultado: respostas HTTP em menos de 50ms mesmo quando a operação real leva minutos.

Neste artigo implementamos uma fila com Redis (lista circular), um sistema de jobs com retry automático, dead-letter queue para falhas persistentes, e um worker robusto com tratamento de sinais do sistema operacional.

Estrutura de uma fila com Redis

Redis é a escolha mais comum para filas PHP por três motivos: operações atômicas com LPUSH/BRPOP, TTL nativo para expiração automática, e latência abaixo de 1ms para enfileirar e desenfileirar.

<?php
declare(strict_types=1);

namespace App\Queue;

// ── Contrato: qualquer job deve implementar esta interface ─────────────
interface JobInterface
{
    public function processar(): void;

    // Número máximo de tentativas antes de ir para a dead-letter queue
    public function tentativasMaximas(): int;

    // Delay em segundos antes da próxima tentativa (backoff exponencial)
    public function backoffEmSegundos(int $tentativa): int;
}

// ── Envelope: metadados ao redor do job ───────────────────────────────
final class JobEnvelope
{
    public function __construct(
        public readonly string $id,
        public readonly string $classeJob,
        public readonly array  $payload,
        public readonly int    $tentativa       = 1,
        public readonly int    $criadoEm        = 0,
        public readonly ?string $erro           = null,
    ) {}

    public static function criar(string $classeJob, array $payload): self
    {
        return new self(
            id:        bin2hex(random_bytes(16)),
            classeJob: $classeJob,
            payload:   $payload,
            tentativa: 1,
            criadoEm:  time(),
        );
    }

    public function comFalha(string $erro): self
    {
        return new self(
            id:        $this->id,
            classeJob: $this->classeJob,
            payload:   $this->payload,
            tentativa: $this->tentativa + 1,
            criadoEm:  $this->criadoEm,
            erro:      $erro,
        );
    }

    public function serializar(): string
    {
        return json_encode([
            'id'        => $this->id,
            'classe'    => $this->classeJob,
            'payload'   => $this->payload,
            'tentativa' => $this->tentativa,
            'criado_em' => $this->criadoEm,
            'erro'      => $this->erro,
        ], JSON_UNESCAPED_UNICODE);
    }

    public static function desserializar(string $json): self
    {
        $dados = json_decode($json, associative: true);
        return new self(
            id:        $dados['id'],
            classeJob: $dados['classe'],
            payload:   $dados['payload'],
            tentativa: $dados['tentativa'],
            criadoEm:  $dados['criado_em'],
            erro:      $dados['erro'] ?? null,
        );
    }
}

Dispatcher — enfileira jobs

<?php
declare(strict_types=1);

namespace App\Queue;

class QueueDispatcher
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $filaDefault = 'jobs:default',
    ) {}

    // Enfileira e retorna o ID do job — rápido, não bloqueia
    public function despachar(string $classeJob, array $payload, string $fila = ''): string
    {
        $envelope = JobEnvelope::criar($classeJob, $payload);
        $nomeFila = $fila ?: $this->filaDefault;

        // LPUSH: insere no início da lista (BRPOP consome do fim = FIFO)
        $this->redis->lPush($nomeFila, $envelope->serializar());

        return $envelope->id;
    }

    // Agenda job para execução futura (usando sorted set por timestamp)
    public function agendarPara(\DateTimeImmutable $quando, string $classeJob, array $payload): string
    {
        $envelope = JobEnvelope::criar($classeJob, $payload);
        $score     = $quando->getTimestamp();

        // ZADD: sorted set com timestamp como score
        $this->redis->zAdd('jobs:scheduled', $score, $envelope->serializar());

        return $envelope->id;
    }

    // Move jobs agendados cujo tempo chegou para a fila principal
    public function promoverJobsAgendados(): int
    {
        $agora    = time();
        $prontos  = $this->redis->zRangeByScore('jobs:scheduled', 0, $agora);

        if (empty($prontos)) return 0;

        // O MULTI aqui não daria a garantia que parece dar: a leitura acima
        // aconteceu fora dele. Com quatro workers lendo o mesmo sorted set,
        // todos enxergam os mesmos jobs e todos os enfileirariam.
        // Quem desempata é o zRem, que devolve quantos elementos removeu de
        // fato: só o processo que conseguir remover o item o enfileira.
        $movidos = 0;
        foreach ($prontos as $item) {
            if ($this->redis->zRem('jobs:scheduled', $item) === 1) {
                $this->redis->lPush($this->filaDefault, $item);
                $movidos++;
            }
        }

        return $movidos;
    }

    public function tamanho(string $fila = ''): int
    {
        return (int) $this->redis->lLen($fila ?: $this->filaDefault);
    }
}

Jobs concretos

<?php
declare(strict_types=1);

namespace App\Jobs;

use App\Queue\JobInterface;

// ── Job de email ──────────────────────────────────────────────────────
class EnviarEmailJob implements JobInterface
{
    public function __construct(
        private readonly string $destinatario,
        private readonly string $assunto,
        private readonly string $corpo,
        private readonly string $template = 'padrao',
    ) {}

    public static function criar(string $destinatario, string $assunto, string $corpo): array
    {
        // Retorna o payload para o Dispatcher
        return [
            'destinatario' => $destinatario,
            'assunto'      => $assunto,
            'corpo'        => $corpo,
        ];
    }

    public function processar(): void
    {
        // Em produção: PHPMailer, Symfony Mailer, AWS SES, etc.
        $enviado = mail(
            $this->destinatario,
            $this->assunto,
            $this->corpo,
            ['Content-Type' => 'text/html; charset=UTF-8'],
        );

        if (!$enviado) {
            throw new \RuntimeException("Falha ao enviar email para {$this->destinatario}");
        }

        error_log("[EmailJob] Enviado para {$this->destinatario}: {$this->assunto}");
    }

    public function tentativasMaximas(): int { return 3; }

    public function backoffEmSegundos(int $tentativa): int
    {
        // Backoff exponencial de razão 3: 60s, 180s, 540s.
        return 60 * (3 ** ($tentativa - 1));
    }
}

// ── Job de geração de relatório ───────────────────────────────────────
class GerarRelatorioJob implements JobInterface
{
    public function __construct(
        private readonly int    $pedidoId,
        private readonly string $formato,   // 'pdf' | 'csv'
        private readonly string $emailDestino,
    ) {}

    public function processar(): void
    {
        error_log("[RelatorioJob] Gerando {$this->formato} para pedido #{$this->pedidoId}");

        // Simula geração pesada
        $conteudo = $this->gerarConteudo();
        $caminho  = $this->salvarArquivo($conteudo);

        // Enfileira job de email com o relatório — composição de jobs
        // $dispatcher->despachar(EnviarEmailJob::class, [...]);

        error_log("[RelatorioJob] Relatório salvo em {$caminho}");
    }

    private function gerarConteudo(): string
    {
        // Lógica de geração real aqui
        return "Relatório do pedido #{$this->pedidoId} em {$this->formato}";
    }

    private function salvarArquivo(string $conteudo): string
    {
        $caminho = sys_get_temp_dir() . "/relatorio-{$this->pedidoId}.{$this->formato}";
        file_put_contents($caminho, $conteudo);
        return $caminho;
    }

    public function tentativasMaximas(): int  { return 1; } // Sem retry — idempotência complexa
    public function backoffEmSegundos(int $n): int { return 0; }
}

Worker — consome a fila

<?php
declare(strict_types=1);

namespace App\Queue;

// ── Worker robusto com tratamento de sinais ───────────────────────────
class Worker
{
    private bool  $rodando       = true;
    private int   $processados   = 0;
    private int   $falhas        = 0;
    private float $iniciadoEm;

    public function __construct(
        private readonly \Redis $redis,
        private readonly string $fila        = 'jobs:default',
        private readonly string $filaDLQ     = 'jobs:dead',
        private readonly int    $timeout     = 5,   // segundos para BRPOP esperar
        private readonly int    $limiteMemoria = 128, // MB
    ) {
        $this->iniciadoEm = microtime(true);
    }

    public function iniciar(): void
    {
        $this->registrarSinais();
        error_log("[Worker] Iniciado. PID: " . getmypid() . ", Fila: {$this->fila}");

        while ($this->rodando) {
            $this->verificarMemoria();

            // Sem esta chamada o retry não acontece. O recolocarComDelay() manda
            // o job para o sorted set 'jobs:scheduled', e o BRPOP abaixo só olha
            // a fila principal: sem alguém movendo um para o outro, todo job
            // reagendado fica parado para sempre — sem sumir e sem falhar,
            // que é a forma mais difícil de perceber.
            $this->promoverAgendados();

            // BRPOP bloqueia até que um item chegue ou o timeout expire
            $item = $this->redis->brPop([$this->fila], $this->timeout);

            if ($item === null || $item === false) {
                continue; // timeout — verifica sinais e continua
            }

            [, $json] = $item; // [nomeDaFila, valor]
            $this->processarEnvelope($json);
        }

        error_log("[Worker] Encerrado. Processados: {$this->processados}, Falhas: {$this->falhas}");
    }

    private function processarEnvelope(string $json): void
    {
        $envelope = JobEnvelope::desserializar($json);

        try {
            $job = $this->instanciarJob($envelope);
            $job->processar();
            $this->processados++;

            error_log("[Worker] ✓ Job {$envelope->id} ({$envelope->classeJob}) processado.");

        } catch (\Throwable $e) {
            $this->falhas++;
            $envelopeComFalha = $envelope->comFalha($e->getMessage());

            error_log("[Worker] ✗ Job {$envelope->id} falhou (tentativa {$envelope->tentativa}): {$e->getMessage()}");

            // Instanciar de novo dentro do catch pede proteção: se a falha
            // original foi exatamente essa — classe de job que sumiu num deploy
            // — a exceção escaparia daqui e derrubaria o worker a cada
            // mensagem malformada que passasse pela fila.
            try {
                $job = $this->instanciarJob($envelope);
            } catch (\Throwable) {
                $this->moverParaDLQ($envelopeComFalha);
                return;
            }

            if ($envelope->tentativa < $job->tentativasMaximas()) {
                // Recoloca na fila com delay (backoff)
                $delay = $job->backoffEmSegundos($envelope->tentativa);
                $this->recolocarComDelay($envelopeComFalha, $delay);
            } else {
                // Esgotou as tentativas — vai para a Dead Letter Queue
                $this->moverParaDLQ($envelopeComFalha);
            }
        }
    }

    private function instanciarJob(JobEnvelope $envelope): JobInterface
    {
        $classe = $envelope->classeJob;

        if (!class_exists($classe)) {
            throw new \RuntimeException("Classe de job não encontrada: {$classe}");
        }

        // Instancia o job a partir do payload usando named arguments
        return new $classe(...$envelope->payload);
    }

    /**
     * Move para a fila principal os jobs agendados cujo horário já chegou.
     * O limite por rodada existe para que o worker não gaste um ciclo inteiro
     * promovendo quando um pico de reagendamentos vence de uma vez só.
     */
    private function promoverAgendados(int $limite = 50): int
    {
        $prontos = $this->redis->zRangeByScore(
            'jobs:scheduled', 0, time(), ['limit' => [0, $limite]]
        );

        $movidos = 0;
        foreach ($prontos as $item) {
            // Quem consegue remover é quem enfileira: com vários workers, o
            // zRem é o que impede o mesmo job de entrar na fila duas vezes.
            if ($this->redis->zRem('jobs:scheduled', $item) === 1) {
                $this->redis->lPush($this->fila, $item);
                $movidos++;
            }
        }

        return $movidos;
    }

    private function recolocarComDelay(JobEnvelope $envelope, int $delaySegundos): void
    {
        if ($delaySegundos <= 0) {
            $this->redis->lPush($this->fila, $envelope->serializar());
            return;
        }

        $quando = new \DateTimeImmutable("+{$delaySegundos} seconds");
        $this->redis->zAdd('jobs:scheduled', $quando->getTimestamp(), $envelope->serializar());

        error_log("[Worker] Job {$envelope->id} reagendado para {$quando->format('H:i:s')} (+{$delaySegundos}s)");
    }

    private function moverParaDLQ(JobEnvelope $envelope): void
    {
        $this->redis->lPush($this->filaDLQ, $envelope->serializar());
        error_log("[Worker] Job {$envelope->id} movido para DLQ após {$envelope->tentativa} tentativas.");
    }

    private function verificarMemoria(): void
    {
        $usoMB = memory_get_usage(true) / 1024 / 1024;
        if ($usoMB > $this->limiteMemoria) {
            error_log("[Worker] Memória excedida ({$usoMB}MB). Encerrando graciosamente.");
            $this->rodando = false;
        }
    }

    private function registrarSinais(): void
    {
        if (!extension_loaded('pcntl')) return;

        // SIGTERM: encerramento gracioso (Kubernetes, systemd)
        pcntl_signal(SIGTERM, function (): void {
            error_log("[Worker] SIGTERM recebido — encerrando após job atual.");
            $this->rodando = false;
        });

        // SIGINT: Ctrl+C durante desenvolvimento
        pcntl_signal(SIGINT, function (): void {
            error_log("[Worker] SIGINT recebido — encerrando.");
            $this->rodando = false;
        });

        pcntl_async_signals(true); // processa sinais sem ticks
    }

    public function estatisticas(): array
    {
        return [
            'processados'    => $this->processados,
            'falhas'         => $this->falhas,
            'uptime_segundos'=> (int) (microtime(true) - $this->iniciadoEm),
            'memoria_mb'     => round(memory_get_usage(true) / 1024 / 1024, 2),
            'fila_tamanho'   => (int) $this->redis->lLen($this->fila),
            'dlq_tamanho'    => (int) $this->redis->lLen($this->filaDLQ),
        ];
    }
}

Ponto de entrada do worker

<?php
// bin/worker.php — executado como processo separado

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$redis = new \Redis();
$redis->connect(
    host:    $_ENV['REDIS_HOST'] ?? '127.0.0.1',
    port:    (int) ($_ENV['REDIS_PORT'] ?? 6379),
);

$fila = $argv[1] ?? 'jobs:default'; // php bin/worker.php jobs:emails

$worker = new \App\Queue\Worker(
    redis:          $redis,
    fila:           $fila,
    filaDLQ:        'jobs:dead',
    timeout:        5,
    limiteMemoria:  128,
);

$worker->iniciar();
# supervisord.conf — mantém o worker rodando em produção
[program:worker-default]
command=php /var/www/bin/worker.php jobs:default
numprocs=4
autostart=true
autorestart=true
stdout_logfile=/var/log/worker-default.log
stderr_logfile=/var/log/worker-default-error.log

Uma fila troca latência por complexidade, e a conta só fecha se a segunda parte for levada a sério. A resposta HTTP cai para poucos milissegundos, mas em troca aparecem perguntas que a requisição síncrona nunca fez: o que acontece se o worker morrer no meio, quantas vezes vale a pena tentar de novo, e o que fazer com o job que falhou todas. A dead-letter queue não é refinamento — é o que separa "falhou e ficou registrado" de "sumiu".

Repare que jobs precisam ser idempotentes, e essa exigência não vem da fila: vem do retry. Um job de e-mail que roda duas vezes manda duas mensagens; um que debita saldo duas vezes cobra em dobro. Por isso o job de relatório deste artigo declara uma única tentativa — assumir que a operação não pode ser repetida é uma resposta legítima, e mais honesta que fingir que pode.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Implemente ProcessarPagamentoJob que chama uma API de pagamento (mock). Se a API retornar 429 Too Many Requests, relance RateLimitException — o worker deve fazer backoff mais agressivo (5min, 15min, 1h). Se retornar 402, lance PagamentoRecusadoException — sem retry, vai direto para DLQ.

Ver resposta

✓ Resposta: A distinção entre erro transitório e definitivo é o que separa uma fila saudável de uma que se autodestrói: retentar um cartão recusado cinco vezes não o aprova, gasta cota do gateway e pode duplicar cobrança. E respeitar o Retry-After do 429 é o mínimo de educação com o serviço do outro lado.

<?php

declare(strict_types=1);

final class RateLimitException extends \RuntimeException
{
    public function __construct(public readonly int $retryAfter = 300)
    {
        parent::__construct("Gateway limitou a taxa; tentar em {$retryAfter}s.");
    }
}

/** Erro definitivo: retentar não muda o resultado. */
final class PagamentoRecusadoException extends \RuntimeException
{
    public function __construct(public readonly string $codigo, public readonly int $pedidoId)
    {
        parent::__construct("Pagamento do pedido {$pedidoId} recusado (código {$codigo}).");
    }
}

interface JobInterface
{
    public function handle(): void;

    public function tentativasMaximas(): int;

    /** Segundos de espera antes da tentativa $tentativa (1-based). */
    public function backoff(int $tentativa): int;

    /** Erros que NÃO devem ser retentados — vão direto para a DLQ. */
    public function naoRetentar(): array;
}

final class ProcessarPagamentoJob implements JobInterface
{
    public function __construct(
        public readonly int $pedidoId,
        public readonly int $valorCentavos,
        public readonly string $cartaoToken,
    ) {}

    public function handle(): void
    {
        $resposta = $this->chamarGateway();

        // 429: o gateway está sobrecarregado. Não é erro nosso — vale
        // esperar bastante e tentar de novo.
        if ($resposta['status'] === 429) {
            throw new RateLimitException(
                retryAfter: (int) ($resposta['headers']['Retry-After'] ?? 300)
            );
        }

        // 402: o banco recusou. Retentar 5 vezes só irrita o cliente e
        // pode gerar cobrança duplicada.
        if ($resposta['status'] === 402) {
            throw new PagamentoRecusadoException(
                codigo: (string) ($resposta['corpo']['codigo'] ?? 'desconhecido'),
                pedidoId: $this->pedidoId,
            );
        }

        if ($resposta['status'] >= 500) {
            // Erro transitório do servidor: backoff padrão.
            throw new \RuntimeException("Gateway indisponível ({$resposta['status']}).");
        }

        if ($resposta['status'] !== 200) {
            throw new \RuntimeException("Resposta inesperada: {$resposta['status']}.");
        }

        error_log("Pedido {$this->pedidoId} pago — {$resposta['corpo']['autorizacao']}");
    }

    public function tentativasMaximas(): int { return 5; }

    /**
     * Backoff exponencial para erro comum; escalada agressiva quando o
     * gateway limitou a taxa — martelar um gateway limitado só piora.
     */
    public function backoff(int $tentativa): int
    {
        return match ($tentativa) {
            1 => 60,      // 1 min
            2 => 300,     // 5 min
            3 => 900,     // 15 min
            default => 3600,   // 1 h
        };
    }

    public function naoRetentar(): array
    {
        return [PagamentoRecusadoException::class];
    }

    /** Mock do gateway. */
    private function chamarGateway(): array
    {
        return match (true) {
            $this->valorCentavos > 1_000_000 => ['status' => 402,
                'corpo' => ['codigo' => '51'], 'headers' => []],
            $this->cartaoToken === 'tok_limitado' => ['status' => 429,
                'corpo' => [], 'headers' => ['Retry-After' => '600']],
            default => ['status' => 200,
                'corpo' => ['autorizacao' => 'AUT-' . bin2hex(random_bytes(3))], 'headers' => []],
        };
    }
}

// ---------- O worker que aplica as políticas -----------------------------
final class Worker
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $fila = 'jobs:default',
    ) {}

    public function processar(JobInterface $job, array $envelope): void
    {
        $tentativa = ($envelope['tentativas'] ?? 0) + 1;

        try {
            $job->handle();

            $this->redis->incr('stats:jobs:sucesso');

        } catch (\Throwable $e) {
            // Erro definitivo: nem tenta de novo.
            foreach ($job->naoRetentar() as $classe) {
                if ($e instanceof $classe) {
                    $this->paraDlq($envelope, $e, 'erro_definitivo');
                    return;
                }
            }

            if ($tentativa >= $job->tentativasMaximas()) {
                $this->paraDlq($envelope, $e, 'tentativas_esgotadas');
                return;
            }

            // O próprio erro pode ditar a espera (Retry-After do 429).
            $espera = $e instanceof RateLimitException
                ? max($e->retryAfter, $job->backoff($tentativa))
                : $job->backoff($tentativa);

            $envelope['tentativas'] = $tentativa;
            $envelope['ultimo_erro'] = $e->getMessage();

            // ZADD com score = quando pode rodar: a fila de atrasados é um
            // sorted set, e o worker só puxa o que já venceu.
            $this->redis->zAdd('jobs:delayed', time() + $espera, json_encode($envelope));

            $this->redis->incr('stats:jobs:retentados');
        }
    }

    private function paraDlq(array $envelope, \Throwable $e, string $motivo): void
    {
        $envelope['falhou_em'] = date('c');
        $envelope['motivo'] = $motivo;
        $envelope['erro'] = $e->getMessage();
        $envelope['excecao'] = $e::class;

        $this->redis->lPush('jobs:dlq', json_encode($envelope));
        $this->redis->incr('stats:jobs:dlq');

        error_log("DLQ ({$motivo}): {$e->getMessage()}");
    }
}

Exercício 2

Crie um painel de monitoramento simples em PHP puro: endpoint /admin/queues que exibe via JSON o tamanho de todas as filas, jobs na DLQ (últimos 10), e estatísticas de processamento salvas no Redis com INCR.

Ver resposta

✓ Resposta: O número mais útil do painel é atrasados.vencidos: job que já passou da hora e continua parado significa worker morto. Fila crescendo você percebe; worker parado com fila pequena passa despercebido por horas.

<?php
// public/admin/queues.php — GET /admin/queues

declare(strict_types=1);

require __DIR__ . '/../../vendor/autoload.php';

final class PainelDeFilas
{
    private const FILAS = ['jobs:high', 'jobs:default', 'jobs:low'];

    public function __construct(private readonly \Redis $redis) {}

    public function relatorio(): array
    {
        return [
            'gerado_em'   => date('c'),
            'filas'       => $this->tamanhos(),
            'atrasados'   => $this->atrasados(),
            'dlq'         => $this->dlq(10),
            'estatisticas'=> $this->estatisticas(),
            'redis'       => $this->infoRedis(),
        ];
    }

    private function tamanhos(): array
    {
        // pipeline: uma ida ao Redis em vez de N. Com 3 filas não muda nada,
        // com 30 muda muito.
        $pipe = $this->redis->multi(\Redis::PIPELINE);

        foreach (self::FILAS as $fila) {
            $pipe->lLen($fila);
        }

        $resultados = $pipe->exec();

        $saida = [];

        foreach (self::FILAS as $i => $fila) {
            $saida[$fila] = (int) $resultados[$i];
        }

        $saida['total'] = array_sum($saida);

        return $saida;
    }

    private function atrasados(): array
    {
        $agora = time();

        return [
            'total'    => (int) $this->redis->zCard('jobs:delayed'),
            // Já venceram mas ninguém puxou: sinal de worker parado.
            'vencidos' => (int) $this->redis->zCount('jobs:delayed', '-inf', $agora),
            'proximo_em' => (function () use ($agora): ?int {
                $proximos = $this->redis->zRange('jobs:delayed', 0, 0, true);

                return $proximos ? max(0, (int) reset($proximos) - $agora) : null;
            })(),
        ];
    }

    private function dlq(int $quantos): array
    {
        $itens = $this->redis->lRange('jobs:dlq', 0, $quantos - 1) ?: [];

        return [
            'total'    => (int) $this->redis->lLen('jobs:dlq'),
            'ultimos'  => array_map(static function (string $json): array {
                $envelope = json_decode($json, true) ?: [];

                return [
                    'job'        => $envelope['job'] ?? '?',
                    'motivo'     => $envelope['motivo'] ?? '?',
                    'erro'       => mb_substr((string) ($envelope['erro'] ?? ''), 0, 160),
                    'tentativas' => $envelope['tentativas'] ?? 0,
                    'falhou_em'  => $envelope['falhou_em'] ?? null,
                ];
            }, $itens),
        ];
    }

    private function estatisticas(): array
    {
        $chaves = ['stats:jobs:sucesso', 'stats:jobs:retentados', 'stats:jobs:dlq'];

        // mGet: um round-trip para todos os contadores.
        $valores = $this->redis->mGet($chaves);

        $stats = [];

        foreach ($chaves as $i => $chave) {
            $stats[str_replace('stats:jobs:', '', $chave)] = (int) ($valores[$i] ?: 0);
        }

        $total = $stats['sucesso'] + $stats['dlq'];

        $stats['taxa_sucesso'] = $total > 0
            ? round($stats['sucesso'] / $total * 100, 2)
            : 100.0;

        // Contadores por hora, para desenhar o gráfico das últimas 24h.
        $porHora = [];

        for ($h = 23; $h >= 0; $h--) {
            $chave = 'stats:jobs:hora:' . date('YmdH', strtotime("-{$h} hours"));
            $porHora[date('H:00', strtotime("-{$h} hours"))] = (int) ($this->redis->get($chave) ?: 0);
        }

        $stats['ultimas_24h'] = $porHora;

        return $stats;
    }

    private function infoRedis(): array
    {
        $info = $this->redis->info('memory');

        return [
            'memoria_usada' => $info['used_memory_human'] ?? '?',
            'memoria_pico'  => $info['used_memory_peak_human'] ?? '?',
        ];
    }
}

// ---------- Endpoint ------------------------------------------------------
$redis = new Redis();
$redis->connect($_ENV['REDIS_HOST'] ?? '127.0.0.1', 6379);

// Painel operacional é dado sensível: nunca exponha sem autenticação.
if (($_SERVER['HTTP_X_ADMIN_TOKEN'] ?? '') !== ($_ENV['ADMIN_TOKEN'] ?? null)
    || ($_ENV['ADMIN_TOKEN'] ?? '') === '') {
    http_response_code(401);
    echo json_encode(['erro' => 'nao_autorizado']);
    exit;
}

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

echo json_encode(
    (new PainelDeFilas($redis))->relatorio(),
    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
);

// {
//   "filas": { "jobs:high": 0, "jobs:default": 42, "jobs:low": 310, "total": 352 },
//   "atrasados": { "total": 18, "vencidos": 0, "proximo_em": 240 },
//   "dlq": { "total": 3, "ultimos": [ ... ] },
//   "estatisticas": { "sucesso": 18422, "dlq": 3, "taxa_sucesso": 99.98 }
// }

Exercício 3

Implemente QueueDispatcher::despacharLote(array $jobs): array que enfileira múltiplos jobs numa única transação Redis (MULTI/EXEC). Escreva testes que verificam que ou todos são enfileirados ou nenhum (atomicidade).

Ver resposta

✓ Resposta: Vale entender o limite do MULTI/EXEC: ele garante isolamento (nenhum outro cliente se intromete) e "tudo ou nada" quanto à execução, mas não tem rollback — se um comando falhar em runtime, os anteriores já valeram. Não é transação de banco relacional.

<?php

declare(strict_types=1);

final class QueueDispatcher
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $filaPadrao = 'jobs:default',
    ) {}

    public function despachar(JobInterface $job, string $prioridade = 'default'): string
    {
        $envelope = $this->envelopar($job);

        $this->redis->lPush($this->filaDe($prioridade), json_encode($envelope));

        return $envelope['id'];
    }

    /**
     * Ou TODOS entram, ou nenhum entra.
     *
     * MULTI/EXEC no Redis enfileira os comandos e executa todos de uma vez,
     * sem que outro cliente se intrometa no meio. Não é transação de banco:
     * não há rollback se um comando falhar por erro de runtime — mas nenhum
     * comando é executado se o EXEC não acontecer, que é a garantia que o
     * exercício pede.
     *
     * @param JobInterface[] $jobs
     * @return string[] ids gerados
     */
    public function despacharLote(array $jobs, string $prioridade = 'default'): array
    {
        if ($jobs === []) {
            return [];
        }

        $envelopes = array_map($this->envelopar(...), $jobs);
        $fila = $this->filaDe($prioridade);

        $pipe = $this->redis->multi();   // MULTI

        foreach ($envelopes as $envelope) {
            $pipe->lPush($fila, json_encode($envelope, JSON_THROW_ON_ERROR));
        }

        $pipe->incrBy('stats:jobs:enfileirados', count($envelopes));

        $resultados = $pipe->exec();     // EXEC

        // exec() devolve false se a transação foi abortada (ex.: WATCH
        // detectou alteração). Nesse caso NADA foi enfileirado.
        if ($resultados === false) {
            throw new \RuntimeException('Transação Redis abortada; nenhum job foi enfileirado.');
        }

        return array_column($envelopes, 'id');
    }

    /** Versão com WATCH: aborta se a fila mudar durante a montagem. */
    public function despacharLoteSeFilaEstavel(array $jobs, string $prioridade = 'default'): array
    {
        $fila = $this->filaDe($prioridade);

        $this->redis->watch($fila);

        $tamanhoAntes = $this->redis->lLen($fila);

        if ($tamanhoAntes > 10_000) {
            $this->redis->unwatch();
            throw new \RuntimeException('Fila congestionada; lote recusado.');
        }

        return $this->despacharLote($jobs, $prioridade);
    }

    private function envelopar(JobInterface $job): array
    {
        return [
            'id'         => bin2hex(random_bytes(12)),
            'job'        => $job::class,
            'payload'    => get_object_vars($job),
            'tentativas' => 0,
            'criado_em'  => date('c'),
        ];
    }

    private function filaDe(string $prioridade): string
    {
        return match ($prioridade) {
            'high' => 'jobs:high',
            'low'  => 'jobs:low',
            default => $this->filaPadrao,
        };
    }
}

// ---------------------------------------- Tests/Integration/QueueDispatcherTest.php
declare(strict_types=1);

namespace Tests\Integration;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class QueueDispatcherTest extends TestCase
{
    private \Redis $redis;
    private QueueDispatcher $dispatcher;

    protected function setUp(): void
    {
        $this->redis = new \Redis();
        $this->redis->connect('127.0.0.1', 6379);
        $this->redis->select(15);   // banco 15 só para teste
        $this->redis->flushDB();

        $this->dispatcher = new QueueDispatcher($this->redis);
    }

    protected function tearDown(): void
    {
        $this->redis->flushDB();
    }

    #[Test]
    public function loteEnfileiraTodosOsJobs(): void
    {
        $jobs = [
            new ProcessarPagamentoJob(1, 10000, 'tok_a'),
            new ProcessarPagamentoJob(2, 20000, 'tok_b'),
            new ProcessarPagamentoJob(3, 30000, 'tok_c'),
        ];

        $ids = $this->dispatcher->despacharLote($jobs);

        self::assertCount(3, $ids);
        self::assertSame(3, $this->redis->lLen('jobs:default'));
        self::assertSame(3, (int) $this->redis->get('stats:jobs:enfileirados'));

        // Ids únicos: dois jobs nunca compartilham identificador.
        self::assertCount(3, array_unique($ids));
    }

    #[Test]
    public function loteVazioNaoTocaNaFila(): void
    {
        self::assertSame([], $this->dispatcher->despacharLote([]));
        self::assertSame(0, $this->redis->lLen('jobs:default'));
        self::assertFalse($this->redis->exists('stats:jobs:enfileirados') > 0);
    }

    #[Test]
    public function atomicidadeNenhumJobEntraSeATransacaoAborta(): void
    {
        $this->redis->set('jobs:default', 'valor-que-nao-eh-lista');

        try {
            $this->dispatcher->despacharLote([new ProcessarPagamentoJob(1, 100, 'tok')]);
        } catch (\Throwable) {
            // esperado
        }

        // A chave continua sendo a string: nenhum lPush teve efeito.
        self::assertSame('string', $this->redis->type('jobs:default') === \Redis::REDIS_STRING
            ? 'string' : 'outro');
    }

    #[Test]
    public function prioridadeDirecionaParaAFilaCerta(): void
    {
        $this->dispatcher->despacharLote([new ProcessarPagamentoJob(1, 100, 'a')], 'high');
        $this->dispatcher->despacharLote([new ProcessarPagamentoJob(2, 200, 'b')], 'low');

        self::assertSame(1, $this->redis->lLen('jobs:high'));
        self::assertSame(1, $this->redis->lLen('jobs:low'));
        self::assertSame(0, $this->redis->lLen('jobs:default'));
    }
}

Exercício 4

Adicione prioridades à fila: despachar(..., prioridade: 'high') deve ir para jobs:high e despachar(..., prioridade: 'low') para jobs:low. O worker deve consumir primeiro a fila alta — use BRPOP ['jobs:high', 'jobs:low'] (Redis processa na ordem).

Ver resposta

✓ Resposta: O ponto que o exercício ensina: a prioridade não é um campo do job — é a ordem das chaves no BRPOP. E cuidado com o outro lado: se jobs:high nunca esvaziar, jobs:low jamais é atendida. Fila de baixa prioridade sob pressão constante precisa de worker dedicado.

<?php

declare(strict_types=1);

final class WorkerPrioritario
{
    /** A ORDEM DO ARRAY é a prioridade: BRPOP percorre as chaves na ordem
     *  dada e devolve o primeiro elemento que encontrar. */
    private const FILAS = ['jobs:high', 'jobs:default', 'jobs:low'];

    private bool $rodando = true;

    public function __construct(
        private readonly \Redis $redis,
        private readonly int $timeout = 5,
    ) {}

    public function rodar(): void
    {
        // Sinais precisam ser despachados: sem isto, o handler nunca roda.
        pcntl_async_signals(true);
        pcntl_signal(SIGTERM, fn () => $this->rodando = false);
        pcntl_signal(SIGINT,  fn () => $this->rodando = false);

        echo "Worker iniciado (PID " . getmypid() . ")", PHP_EOL;

        while ($this->rodando) {
            $this->promoverAtrasados();

            // BRPOP bloqueia até chegar job ou estourar o timeout — sem
            // polling, sem queimar CPU. O timeout existe para que o laço
            // volte de tempos em tempos e possa checar $this->rodando.
            $resultado = $this->redis->brPop(self::FILAS, $this->timeout);

            if (empty($resultado)) {
                continue;   // timeout: nenhuma fila tinha job
            }

            [$fila, $payload] = $resultado;

            $this->executar($fila, $payload);
        }

        echo 'Worker encerrado com elegância.', PHP_EOL;
    }

    /** Move para a fila normal os jobs atrasados cujo horário já chegou. */
    private function promoverAtrasados(): void
    {
        $vencidos = $this->redis->zRangeByScore('jobs:delayed', '-inf', (string) time(),
                                                ['limit' => [0, 50]]);

        foreach ($vencidos as $payload) {
            // zRem antes do lPush: se outro worker já removeu, não duplica.
            if ($this->redis->zRem('jobs:delayed', $payload) > 0) {
                $envelope = json_decode($payload, true) ?: [];

                $this->redis->lPush($envelope['fila'] ?? 'jobs:default', $payload);
            }
        }
    }

    private function executar(string $fila, string $payload): void
    {
        $envelope = json_decode($payload, true);

        if (!is_array($envelope) || !isset($envelope['job'])) {
            $this->redis->lPush('jobs:dlq', $payload);
            return;
        }

        $inicio = hrtime(true);

        try {
            $classe = $envelope['job'];

            if (!class_exists($classe) || !is_subclass_of($classe, JobInterface::class)) {
                throw new \RuntimeException("Job desconhecido: {$classe}");
            }

            $job = new $classe(...array_values($envelope['payload'] ?? []));
            $job->handle();

            $this->redis->incr('stats:jobs:sucesso');
            $this->redis->incr('stats:jobs:hora:' . date('YmdH'));

            printf("[%s] %s ok (%.1f ms)%s", $fila, $classe,
                   (hrtime(true) - $inicio) / 1_000_000, PHP_EOL);

        } catch (\Throwable $e) {
            $envelope['fila'] = $fila;

            (new Worker($this->redis, $fila))->processar($job ?? null, $envelope);
        }
    }
}

// ---------- Despachando com prioridade -----------------------------------
$dispatcher = new QueueDispatcher($redis);

// Pagamento não espera atrás de 300 e-mails de newsletter.
$dispatcher->despachar(new ProcessarPagamentoJob(42, 34990, 'tok_x'), prioridade: 'high');
$dispatcher->despachar(new EnviarNewsletterJob(assinanteId: 9001), prioridade: 'low');

(new WorkerPrioritario($redis))->rodar();

// [jobs:high] ProcessarPagamentoJob ok (182.4 ms)
// [jobs:low]  EnviarNewsletterJob ok (44.1 ms)

Exercício 5

Desafio: implemente um worker com múltiplos processos filhos usando pcntl_fork(). O processo pai mantém N filhos rodando, relança filhos que morrem com código de saída diferente de 0, e encerra todos graciosamente ao receber SIGTERM.

Ver resposta

✓ Resposta: A armadilha nº 1 do fork é herdar conexão: pai e filho no mesmo socket de Redis ou MySQL produzem respostas trocadas e erros impossíveis de reproduzir — sempre reconecte no filho. A nº 2 é relançar sem pausa: um filho que morre no boot vira milhares de forks por segundo.

<?php
// worker-pool.php — uso: php worker-pool.php [numero_de_filhos]

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

final class PoolDeWorkers
{
    /** @var array<int, bool> pid => vivo */
    private array $filhos = [];

    private bool $encerrando = false;

    public function __construct(
        private readonly int $quantidade = 4,
        private readonly string $script = 'worker.php',
    ) {}

    public function iniciar(): void
    {
        if (!function_exists('pcntl_fork')) {
            throw new \RuntimeException('pcntl não disponível (só CLI, só Unix).');
        }

        pcntl_async_signals(true);

        // O pai não morre no SIGTERM: ele repassa aos filhos e espera.
        pcntl_signal(SIGTERM, $this->encerrar(...));
        pcntl_signal(SIGINT,  $this->encerrar(...));

        echo "Pai {$this->pid()} iniciando {$this->quantidade} filho(s)…", PHP_EOL;

        for ($i = 0; $i < $this->quantidade; $i++) {
            $this->criarFilho();
        }

        $this->supervisionar();
    }

    private function criarFilho(): void
    {
        $pid = pcntl_fork();

        if ($pid === -1) {
            throw new \RuntimeException('Falha no fork.');
        }

        if ($pid === 0) {
            // ---- FILHO ----
            // Tudo aqui é uma cópia da memória do pai. A conexão com Redis
            // NÃO pode ser herdada: dois processos no mesmo socket embaralham
            // as respostas. Reconecte sempre depois do fork.
            $this->rodarComoFilho();

            exit(0);   // filho nunca volta ao laço do pai
        }

        // ---- PAI ----
        $this->filhos[$pid] = true;
        echo "  filho {$pid} criado", PHP_EOL;
    }

    private function rodarComoFilho(): void
    {
        // O filho herda os handlers do pai: reinstale os dele.
        pcntl_signal(SIGTERM, static function (): void {
            echo '  filho ' . getmypid() . ' encerrando…', PHP_EOL;
            exit(0);
        });

        $redis = new \Redis();
        $redis->connect($_ENV['REDIS_HOST'] ?? '127.0.0.1', 6379);

        (new WorkerPrioritario($redis))->rodar();
    }

    private function supervisionar(): void
    {
        while (!$this->encerrando || $this->filhos !== []) {
            // WNOHANG: não bloqueia; o pai continua respondendo a sinais.
            $pid = pcntl_waitpid(-1, $status, WNOHANG);

            if ($pid > 0) {
                unset($this->filhos[$pid]);

                $codigo = pcntl_wifexited($status) ? pcntl_wexitstatus($status) : -1;

                if ($this->encerrando) {
                    echo "  filho {$pid} encerrou ({$codigo})", PHP_EOL;
                } elseif ($codigo === 0) {
                    echo "  filho {$pid} terminou normalmente; recriando", PHP_EOL;
                    $this->criarFilho();
                } else {
                    // Saída != 0 = morreu com erro. Relançar é o trabalho do
                    // supervisor — mas com pausa, senão um bug vira loop de
                    // fork a milhares por segundo.
                    echo "  ⚠️ filho {$pid} morreu (código {$codigo}); relançando em 1s", PHP_EOL;
                    sleep(1);
                    $this->criarFilho();
                }

                continue;
            }

            usleep(200_000);   // 200 ms: o supervisor quase não consome CPU
        }

        echo "Pai {$this->pid()} encerrado.", PHP_EOL;
    }

    private function encerrar(): void
    {
        if ($this->encerrando) {
            return;
        }

        $this->encerrando = true;

        echo 'SIGTERM recebido: avisando ', count($this->filhos), ' filho(s)…', PHP_EOL;

        foreach (array_keys($this->filhos) as $pid) {
            posix_kill($pid, SIGTERM);
        }

        // Prazo para terminar o job em andamento; depois, SIGKILL.
        $limite = time() + 30;

        while ($this->filhos !== [] && time() < $limite) {
            $pid = pcntl_waitpid(-1, $status, WNOHANG);

            if ($pid > 0) {
                unset($this->filhos[$pid]);
            }

            usleep(100_000);
        }

        foreach (array_keys($this->filhos) as $pid) {
            echo "  filho {$pid} não respondeu: SIGKILL", PHP_EOL;
            posix_kill($pid, SIGKILL);
        }
    }

    private function pid(): int { return getmypid(); }
}

$quantidade = (int) ($argv[1] ?? 4);

(new PoolDeWorkers(max(1, $quantidade)))->iniciar();

// $ php worker-pool.php 3
// Pai 8412 iniciando 3 filho(s)…
//   filho 8413 criado
//   filho 8414 criado
//   filho 8415 criado
// ^C
// SIGTERM recebido: avisando 3 filho(s)…
//   filho 8413 encerrando…
Comentários

Mais em PHP

Estruturas de Repetição
Estruturas de Repetição

Repetir é onde o PHP oferece mais caminhos para o mesmo destino: for quando o…

Performance e Otimização
Performance e Otimização

OPcache, cache de queries no Redis, o problema N+1, índices, filas e profiling…

Domain-Driven Design
Domain-Driven Design

Value Objects que recusam estado inválido no construtor, um Aggregate que…