0013 — Background job queue via Symfony Messenger¶
- Status: accepted
- Date: 2026-04-26
- Deciders: @kackey621, Willen Federation contributors
Context and Problem Statement¶
M6 introduces work that must not run on the request thread:
- Image embedding — calling an LLM vision endpoint on item registration takes 2-10 seconds. The operator should not stare at a spinner.
- OpenSearch indexing — every item write enqueues an upsert; bulk index rebuilds run as long-lived jobs.
- Similar-item recomputation — re-runs after writes against the k-NN index.
- AI rate-limit smoothing — when 30 operators register items in the same minute we must spread vendor calls across the rate-limit window.
- Notifications — Slack / email when the feature-flag circuit breaker trips (cf. ADR 0005).
We need a named, durable, retryable background-job system that fits PHP and the rest of our stack.
Decision Drivers¶
- PHP-native — no Ruby Sidekiq, no Java daemons. Operators run one runtime.
- Composes with ADR 0012's Redis — when Redis is configured, use it as the transport; when not, fall back to MariaDB.
- Reasonable retry semantics — exponential backoff, dead-letter queue, max-retry cap.
- Inspectable — operators can list pending and failed messages from a CLI.
- Compliance with ADR 0001 — message handlers live in
Application/, message classes inDomain/; transport configuration isInfrastructure/.
Considered Options¶
Option A — Sidekiq (Ruby)¶
- (+) Industry standard.
- (−) Requires a Ruby runtime alongside PHP. Out of scope.
Option B — Roll our own queue (DB-backed, polled by a worker script)¶
- (+) Zero dependency.
- (−) Reinvents retries, backoff, dead letters, and serialisation. Symfony Messenger already does these.
Option C — Laravel Queue¶
- (+) Mature, ergonomic.
- (−) Requires pulling in the Laravel container + Eloquent foundations. Doesn't compose with the rest of our Symfony-flavoured choices (ADR 0012's translator, M3-D's rate-limiter glue).
Option D — Symfony Messenger¶
- (+) Library; no framework lock-in.
- (+) First-class Redis Streams + Doctrine transports. We use both already in this project (Redis from ADR 0012, Doctrine through symfony/yaml's lineage).
- (+) Standard handlers, middleware (logging, retry, transaction wrapping), failure transport.
- (+)
messenger:consumeCLI works under any process supervisor — systemd, supervisord, the Docker container, or a cron-driven worker on shared hosting. - (−) Configuration ergonomics designed for the full Symfony framework; we wire it manually. We accept this — M3 already taught the codebase how to compose Symfony components without the kernel.
Decision Outcome¶
Chosen option: D — Symfony Messenger.
Composition¶
Saso\Domain\Messaging\
Message\ # plain DTOs
IndexItem.php
EmbedItem.php
RecomputeSimilar.php
NotifyFlagAutoDisabled.php
Saso\Application\Messaging\Handler\
IndexItemHandler.php
EmbedItemHandler.php
RecomputeSimilarHandler.php
NotifyFlagAutoDisabledHandler.php
Saso\Infrastructure\Messaging\
MessageBusFactory.php # builds Symfony\Component\Messenger\MessageBus
Transport\
TransportFactory.php # 'redis://...' OR 'doctrine://default'
Transport selection¶
Configurable in system_setting (messaging.transport = redis | doctrine). Default chooses based on Redis availability:
| Configured driver | Transport |
|---|---|
cache.driver = redis |
Redis Streams (messenger://redis/<group>) |
cache.driver = null |
Doctrine on MariaDB (messenger_messages table) |
Operators on shared hosting get the Doctrine transport — which is slower but works without Redis. Operators with Redis (preferred) get Streams.
Retry policy¶
Default: 3 retries, exponential backoff (1 s, 5 s, 30 s), then sent to a failed transport. The messenger:failed:show and messenger:failed:retry commands are exposed in make targets:
make messenger-consume # consume the default transport
make messenger-stats # pending counts per transport
make messenger-retry # retry every failed message
Failure handling¶
Failed messages emit SASO-INFRA-9100 (new code, range reserved for messaging). The audit row carries the message class, the transport, the retry count, and a truncated stack trace. The admin UI (M6-G) shows the failure list.
Deployment¶
- Docker:
docker-compose.ymladds aworkerservice runningmake messenger-consume. Restart-on-failure. - Shared hosting: cron entry runs
php bin/console messenger:consume async --time-limit=55every minute. (--time-limit=55because shared cPanel cron typically caps a job at 60 s.)
Consequences¶
- One
composer require symfony/messenger. - The
workerprofile in Docker is opt-in (docker compose --profile worker up). Defaultmake upkeeps the existing footprint. - Message handlers can be tested in isolation (instantiate the handler with mocked dependencies; assert side effects on a fake repository / fake search index).
messenger:consumeis the unit of restart for handler-code deploys; the legacy front controller is not affected.- This ADR does not pin a worker process supervisor. Operators choose between systemd, supervisord, Docker's restart policy, or cron, depending on their hosting shape.