yourmaze / background-job-bundle
Database-backed background jobs for Symfony
Package info
github.com/yourmaze/background-job-bundle
Type:symfony-bundle
pkg:composer/yourmaze/background-job-bundle
Requires
- php: ^8.4
- ext-pcntl: *
- doctrine/dbal: ^3.10|^4.0
- doctrine/doctrine-bundle: ^2.13|^3.0
- doctrine/migrations: ^3.9
- psr/clock: ^1.0
- psr/log: ^3.0
- symfony/config: ^7.4|^8.0
- symfony/console: ^7.4|^8.0
- symfony/dependency-injection: ^7.4|^8.0
- symfony/event-dispatcher-contracts: ^3.6
- symfony/http-kernel: ^7.4|^8.0
- symfony/uid: ^7.4|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.2
- symfony/clock: ^7.4|^8.0
- symfony/event-dispatcher: ^7.4|^8.0
- symfony/framework-bundle: ^7.4|^8.0
- symfony/yaml: ^7.4|^8.0
README
Самостоятельный Symfony Bundle для фоновых задач с очередью в PostgreSQL. Пакет рассчитан на PHP 8.4+, Symfony 7.4 LTS или 8.1, Doctrine DBAL 3.10 или 4.x, DoctrineBundle 2.13 или 3.x и Doctrine Migrations 3.9. Он не использует Symfony Messenger, RabbitMQ, Redis и не запускает дочерний PHP-процесс для каждой задачи.
Гарантии и модель исполнения
- Одна таблица
background_jobобслуживает любые типы задач. - Доставка at-least-once: после потери lease задача может быть выполнена повторно. Handler обязан быть идемпотентным либо использовать собственный idempotency key/checkpoint.
- Claim выполняется одним PostgreSQL statement через
FOR UPDATE SKIP LOCKEDиUPDATE ... RETURNING, поэтому workers безопасно работают параллельно. - Каждая попытка получает новый UUID v7
lease_token. Heartbeat, progress, complete и fail применяются только для текущего непросроченного token. Старый worker не может перезаписать результат новой попытки. configuration_dataнеизменяемы: пакет не предоставляет update API, а миграция дополнительно устанавливает PostgreSQL trigger.execution_dataпредназначены для progress/checkpoint.- Нормальный возврат
handle()означаетcompleted. ЛюбойThrowableповторяется с capped exponential backoff и jitter, пока не исчерпаныmax_attempts.NonRetryableJobExceptionсразу завершает задачу какfailed. - Для выполняющейся задачи отмена кооперативна: выставляется
cancellation_requested_at, а handler периодически вызываетisCancellationRequested(). После возврата handler инфраструктура выставляетcancelled.
Установка
composer require yourmaze/background-job-bundle
Если Symfony Flex не зарегистрировал bundle, добавьте его вручную:
// config/bundles.php return [ YourMaze\BackgroundJob\BackgroundJobBundle::class => ['all' => true], ];
Минимальная конфигурация использует default Doctrine connection, Symfony clock, logger и event dispatcher:
# config/packages/background_job.yaml background_job: connection_service: doctrine.dbal.default_connection default_max_attempts: 3 lease_seconds: 300 retry_base_delay_seconds: 30 retry_max_delay_seconds: 1800 retry_jitter: 0.20 poll_interval_seconds: 1.0
lease_seconds должен быть больше максимального интервала между вызовами JobExecution::heartbeat() в handler-е.
Миграция
Пакет поставляет Doctrine Migration в namespace YourMaze\BackgroundJob\Migrations. Подключите каталог в приложении:
# config/packages/doctrine_migrations.yaml doctrine_migrations: migrations_paths: 'YourMaze\BackgroundJob\Migrations': '%kernel.project_dir%/vendor/yourmaze/background-job-bundle/migrations'
Затем выполните обычную команду приложения:
bin/console doctrine:migrations:migrate
Для окружений без Doctrine Migrations эквивалентная схема находится в resources/schema.sql.
Handler
<?php declare(strict_types=1); namespace App\Job; use YourMaze\BackgroundJob\Handler\AsJobHandler; use YourMaze\BackgroundJob\Handler\JobHandlerInterface; use YourMaze\BackgroundJob\Worker\JobExecution; #[AsJobHandler(type: 'catalog.reindex')] final readonly class ReindexCatalogHandler implements JobHandlerInterface { public function handle(JobExecution $execution): void { $catalogId = (string) $execution->configurationData()['catalog_id']; $checkpoint = $execution->executionData(); $offset = (int) ($checkpoint['offset'] ?? 0); foreach ($this->loadProducts($catalogId, $offset) as $product) { if ($execution->isCancellationRequested()) { return; } $this->indexProductIdempotently($product); ++$offset; $execution->checkpoint(['offset' => $offset]); $execution->heartbeat(); } } }
Attribute работает для autoconfigured services. При явной регистрации используется тот же tag:
services: App\Job\ReindexCatalogHandler: tags: - { name: background_job.handler, type: catalog.reindex }
Для постоянной ошибки, которую повторять бессмысленно:
throw new YourMaze\BackgroundJob\Exception\NonRetryableJobException('Catalog no longer exists.');
Создание задач
use YourMaze\BackgroundJob\Job\ScheduleOptions; use YourMaze\BackgroundJob\Scheduler\JobScheduler; use Symfony\Component\Uid\Uuid; $groupId = (string) Uuid::v7(); $jobId = $scheduler->schedule( type: 'catalog.reindex', configurationData: ['catalog_id' => 'summer-2026'], options: new ScheduleOptions( name: 'Summer catalog', groupId: $groupId, uniqueKey: 'catalog:summer-2026', maxAttempts: 5, availableAt: new DateTimeImmutable('+10 minutes'), ), );
Одинаковые (type, unique_key) дедуплицируются, пока существующая задача имеет статус queued или running; scheduler вернёт её id. После terminal status тот же key можно запланировать снова.
Чтение, группы, отмена и ручный retry
$job = $jobReader->find($jobId); $progress = $jobReader->groupProgress($groupId); $jobControl->cancel($jobId); // queued -> cancelled; running -> cancellation request $jobControl->retry($failedJobId, new DateTimeImmutable('+5 minutes'));
GroupProgress содержит счётчики всех статусов и completionPercentage(). Ручной retry разрешён только для failed, сбрасывает attempts и вновь ставит задачу в queued.
Workers
Один или несколько типов задаются повторяющимся option:
bin/console background-job:consume \ --type=catalog.reindex \ --type=export.generate \ --memory-limit=256M \ --poll-interval=1
Полезные режимы:
bin/console background-job:consume --type=catalog.reindex --run-once bin/console background-job:consume --type=catalog.reindex --limit=100 bin/console background-job:consume --type=catalog.reindex --worker-id=worker-catalog-01
SIGTERM и SIGINT останавливают цикл между задачами. В v1 handler выполняется в процессе worker, поэтому сигнал не прерывает его посередине. Запуск, рестарт и hard timeout процесса настраиваются в Supervisor, systemd, Kubernetes или Docker.
Пример Supervisor:
[program:background-catalog] command=php /app/bin/console background-job:consume --type=catalog.reindex --memory-limit=256M numprocs=4 autorestart=true stopwaitsecs=360
Lifecycle events
Через Symfony EventDispatcher публикуются JobScheduled, JobStarted, JobCompleted, JobRetried, JobFailed и JobCancelled. Они подходят для metrics/audit logging, но не являются транзакционным outbox: listener не должен быть необходим для корректности lifecycle задачи.
Разработка и проверки
make install
make test
make phpstan
make cs-check
make postgres-up
make test-integration
make postgres-down
Integration suite проверяет конкурентный claim, fencing token, retry/backoff, active dedupe, восстановление expired lease и неизменяемость configuration JSON.
CI проверяет нижнюю поддерживаемую комбинацию (PHP 8.4, Symfony 7.4, DBAL 3.10, DoctrineBundle 2.13) и актуальную (PHP 8.5, Symfony 8.1, DBAL 4.4, DoctrineBundle 3.3); обе также выполняют PostgreSQL integration suite.
Лицензия
Пакет распространяется на условиях MIT License.