Разработка/ PHP/ Быстрые решения

Symfony Messenger: retry с jitter и failure transport

Одинаковая задержка у всех сообщений создаёт новый пик после восстановления внешнего API. Exponential backoff разносит попытки во времени, а jitter не даёт сотням jobs проснуться в одну миллисекунду.

Transport и очередь failed

framework:
  messenger:
    failure_transport: failed

    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          service: App\Messenger\Retry\JitterRetryStrategy

      failed:
        dsn: 'doctrine://default?queue_name=failed'

    routing:
      'App\Message\GenerateReport': async

failed хранится отдельно от рабочей очереди. Сообщение попадает туда после исчерпания попыток и не крутится бесконечно.

RetryStrategy с jitter

<?php

namespace App\Messenger\Retry;

use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Retry\RetryStrategyInterface;
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;

final class JitterRetryStrategy implements RetryStrategyInterface
{
    private const MAX_RETRIES = 4;
    private const INITIAL_DELAY = 1_000;
    private const MAX_DELAY = 60 * 60 * 1_000;

    public function isRetryable(
        Envelope $envelope,
        ?\Throwable $throwable = null,
    ): bool {
        if ($throwable instanceof UnrecoverableMessageHandlingException) {
            return false;
        }

        return $this->retryCount($envelope) < self::MAX_RETRIES;
    }

    public function getWaitingTime(
        Envelope $envelope,
        ?\Throwable $throwable = null,
    ): int {
        $base = min(
            self::INITIAL_DELAY * (2 ** $this->retryCount($envelope)),
            self::MAX_DELAY,
        );

        $spread = (int) round($base * 0.20);

        return max(0, $base + random_int(-$spread, $spread));
    }

    private function retryCount(Envelope $envelope): int
    {
        $stamp = $envelope->last(RedeliveryStamp::class);

        return $stamp instanceof RedeliveryStamp
            ? $stamp->getRetryCount()
            : 0;
    }
}

Messenger ожидает задержку в миллисекундах. При базовых значениях попытки идут примерно через 1, 2, 4 и 8 секунд с разбросом ±20%. Ограничение в час защищает от переполнения integer при большом retry count.

Какие ошибки не повторять

use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;

if (!$report->canBeGenerated()) {
    throw new UnrecoverableMessageHandlingException(
        'Report input is permanently invalid.',
    );
}

Ошибка валидации, отсутствующий обязательный объект или запрещённый переход не станут исправнее через десять секунд. Timeout, 429 и временный 5xx можно повторять. Эту границу лучше выражать типом exception, а не поиском текста в message.

Команды эксплуатации

php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M -vv
php bin/console messenger:failed:show
php bin/console messenger:failed:retry
php bin/console messenger:failed:remove

После deploy воркер перезапускается: долгоживущий процесс не подхватит новый PHP-код сам. Для production messenger:consume держит Supervisor или systemd, а --time-limit обеспечивает регулярный чистый restart.

Retry не должен дублировать эффект

Стратегия повторов бесполезна, если handler дважды списывает деньги или отправляет два письма. В сообщение добавляют operation ID, а в БД — уникальный индекс на него. Запись результата и отметка обработки выполняются в одной транзакции.

Минимальная проверка: первая попытка меняет состояние и падает перед ack, вторая получает то же сообщение и завершает работу без второго побочного эффекта.

Теги

Читать дальше