From 41fd0fed259fd2deabae2340c9ebaaca961848fe Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 5 Oct 2026 17:02:31 +0200 Subject: [PATCH 01/52] docs(architecture): explain background jobs, their transports and the classes a queue accepts --- docs/architecture/background-jobs.md | 224 +++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/architecture/background-jobs.md diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md new file mode 100644 index 000000000..1afc3b5cf --- /dev/null +++ b/docs/architecture/background-jobs.md @@ -0,0 +1,224 @@ +--- +title: Background jobs +sidebar_position: 6 +--- + +# Background jobs + +Some work does not have to happen while a customer waits for a page: handing a mail to the +mail server, writing a large export, pushing stock to a marketplace. Thelia runs that work +through [Symfony Messenger](https://symfony.com/doc/current/messenger.html). Code dispatches a +message, a handler does the work, and the configuration of the shop decides whether the +handler runs at once or later, in a worker. + +This page explains the model. The configuration, the commands and a complete module example +are in the [background jobs reference](../reference/background-jobs.md); running the workers +on a server is covered in [Running the workers](../getting-started/background-workers.md). + +## What the core ships + +The core requires `symfony/messenger`, `symfony/doctrine-messenger`, `doctrine/dbal` and +`symfony/scheduler`. It does not require the Doctrine ORM or DoctrineBundle: DBAL is only +there to serve the queue kept in the shop database. + +There is one bus, `messenger.bus.default`, and three transports: + +| Transport | What it carries | Where it lives | +| --- | --- | --- | +| `async` | The jobs of the shop: mail delivery, back-office exports, the messages of modules | `MESSENGER_TRANSPORT_DSN`, synchronous when empty | +| `failed` | The jobs that failed every attempt | The shop database, whatever `async` is | +| `scheduler_thelia` | The recurring tasks of the `thelia` schedule | Generated by the schedule, nothing to configure | + +## Nothing waits by default + +`MESSENGER_TRANSPORT_DSN` is shipped empty in `.env`. An empty value makes `async` fall back +to `sync://`: every message is handled immediately, in the request or the command that +dispatched it, exactly as if the handler had been called directly. Nothing is stored and +nothing waits for a worker, so a shop that runs no worker loses no job. A failure in the +handler surfaces in the code that dispatched the message, as any exception would. + +The shop opts in to a real queue by naming one: + +```bash +# .env.local +MESSENGER_TRANSPORT_DSN=doctrine://default +``` + +From then on, `async` messages are stored and wait for `messenger:consume`. Setting the +variable without running a worker leaves mails undelivered and exports pending, so the two +go together. + +| DSN | Queue | +| --- | --- | +| empty | No queue, every job runs at once | +| `doctrine://default` | Table `messenger_messages` of the shop database | +| `redis://host:6379/messages` | A Redis stream | +| `amqp://user:password@host:5672/%2f/messages` | An AMQP broker such as RabbitMQ | + +## The queue in the shop database + +`doctrine://default` is served by `Thelia\Messenger\Transport\ShopDatabaseTransportFactory`. +It opens a DBAL connection with the settings of the Propel connection, so the queue lives in +the same database as the shop, with no extra service. Only the connection name `default` is +accepted. + +The `messenger_messages` table is created by the installer and by the 3.3.0 update script. +The transport never creates it on the fly. + +The DBAL connection is a second connection to the database, independent from Propel. A +message dispatched inside a Propel transaction is written to the queue at once, and stays +there even if that transaction is rolled back. A worker can then pick up a message about a +row that was never committed. Dispatch after the commit, never inside the transaction. + +## Events or messages + +Thelia events and messages answer different needs. + +An event, dispatched through the event dispatcher, tells every listener that something +happens, and every listener runs before `dispatch()` returns. Listeners can change the event, +stop its propagation, or make the action fail. Events stay synchronous whatever the +configuration of the queue: the event dispatcher is not a message bus. See the +[events reference](../reference/events.md). + +A message asks for one piece of work to be done by its handler, now or later, without the +caller depending on the result. Use a message when: + +- the work is slow or calls a remote service (a mail server, a marketplace, an ERP), +- the caller does not need its result to answer, +- the work can be retried on its own if it fails. + +The two combine. A listener that reacts to an event but does not need to do the work in the +request dispatches a message, and the handler does the work: + +```php +public function onStockUpdated(ProductSaleElementUpdateEvent $event): void +{ + $this->bus->dispatch(new PushStock($event->getProductSaleElementId())); +} +``` + +The listener stays fast, and the handler gets the retries and the failure handling described +below. + +## Retries and failed jobs + +When a handler throws on the `async` transport, the message is retried three times, after +30 seconds, 2 minutes and 8 minutes (a multiplier of 4, plus the random jitter Symfony adds). +After the last attempt it goes to the `failed` transport. A message is never lost and never +retried in a loop. + +A handler that knows a retry cannot help, for instance because the remote service rejected +the data, throws `Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException` +and the message goes to `failed` at once. + +`failed` is `MESSENGER_FAILURE_TRANSPORT_DSN`, which defaults to +`doctrine://default?queue_name=failed`: the failed jobs are always kept in the shop database, +whatever queue `async` uses, so the back office has a single place to read them from. They +stay there until someone replays them, removes them, or the purge task deletes them. + +Because a failed job can be replayed, and because a retry runs a handler that may have done +part of its work, a handler must be idempotent: run twice, its effect happens once. The +reference shows how in [Idempotence and replay](../reference/background-jobs.md#idempotence-and-replay). + +## What a worker sees + +A handler running in a worker has no HTTP request, no visitor, no session and no cart. It +runs in a long process that handles one message after another, so the core resets the state +of the shop before each message (`Thelia\Messenger\EventListener\WorkerStateResetListener`): + +- settings are read again from the database, so a change made in the back office since the + previous message is seen, +- the active languages, the default country, the module configuration, the rewritten URLs and + the price currency are forgotten, +- the translator is set back to the default language. + +Symfony also resets the services tagged `kernel.reset` between messages, unless the worker is +started with `--no-reset`. + +A handler therefore never relies on what a request would have set. It receives ids, reads its +data again, chooses the locale explicitly, and builds absolute URLs from `DEFAULT_URI`. + +## What the core sends through the queue + +Two jobs of the core are routed to `async`. + +### E-mails + +`Symfony\Component\Mailer\Messenger\SendEmailMessage` is routed to `async`. The mail is +rendered in the request, where the language and the shop URL are known; only the delivery to +the mail server is deferred. With a queue, a slow or unreachable mail server no longer holds +up the page that sends the mail, and a mail that could not be delivered ends up in `failed`, +ready to be sent again. + +The order history line `email_sent` is written when the mail server accepted the mail. A +listener on the Symfony Mailer `SentMessageEvent` reads the `X-Thelia-Order-Id` and +`X-Thelia-Message-Code` headers of the mail. With a worker, the line appears when the worker +delivered the mail, and its author is `system`. + +With a queue, `MailerFactory::send*OrFail()` only throws when the mail could not be built or +queued. A delivery failure happens later, in the worker. + +### Back-office exports + +An export started from the back office is a job. +`Thelia\Domain\DataTransfer\Job\ExportJobLauncher::launch()` records an `export_job` row and +dispatches `Thelia\Domain\DataTransfer\Job\RunExportJob`. Without a queue, the export runs in +the request and the file is served at once. With a queue, the back office shows +the job page (`/admin/export/job/{id}`): waiting, running with the number of rows written +(refreshed every 3 seconds), done with a download link, or failed with the reason. + +A finished export is never run twice. A failed one is recorded on its row and goes straight to +`failed`; replaying it starts it over. `maintenance:purge` deletes the jobs older than 7 days, +and the export files are deleted after a day. + +## Recurring tasks + +The recurring tasks of the shop are declared in a Symfony Scheduler schedule named `thelia` +(`Thelia\Scheduler\TheliaSchedule`). A worker consuming `scheduler_thelia` runs them; until +one does, nothing runs, and a shop can keep its crontab instead. The schedule is stateful and +locked through `LOCK_DSN`, so two workers never run a task twice, and a worker that restarts +after a missed run catches up the last one only. The tasks and their settings are listed in +the [reference](../reference/background-jobs.md#recurring-tasks). + +## Which classes a queue accepts + +A queued message is data that is turned back into objects when a worker reads it, and whoever +can write to the queue would choose their classes. Thelia never uses PHP serialization for +this. Envelopes are JSON, written and read by the Symfony serializer through +`Thelia\Messenger\Serializer\AllowedClassesSerializer`, which checks the classes before +building anything: + +- a message class must be under `Thelia\`, under the namespace of an active module (the + namespace named after the module code, such as `MyModule\Message\SyncStock`), + `Symfony\Component\Mailer\Messenger\SendEmailMessage`, or listed by its exact name in the + container parameter `thelia.messenger.allowed_message_classes`, +- a stamp must be a Messenger stamp or come from those same namespaces, and `SerializerStamp` + is refused, as it would let the queue change how the rest of the envelope is read. + +The check runs when a message is read and also when it is dispatched. A module dispatching a +class the workers would refuse gets a `LogicException` ("is not one the shop queues") at +dispatch time, not a queue that never empties. + +For a module author, this means: + +- message classes live in the module namespace, +- they can be written and read as JSON by the Symfony serializer: public or promoted + constructor properties with scalar types, such as ids and codes, +- they never carry a Propel model. The handler reads the row again from its id. + +A class from outside these namespaces, such as an application class under `App\`, is +accepted only once a project adds its exact name to `thelia.messenger.allowed_message_classes`. + +## Personal data + +A queue holds personal data: a queued mail carries the address of its recipient and the +content of the order, and a failed job keeps everything it was dispatched with. Treat the +queue like the database: + +- a Redis or AMQP broker must not be reachable from outside the hosting, +- its credentials go in `.env.local` or the secrets of the hosting, never in Git, +- the retention of failed jobs is a processing to record in the GDPR register. The + `thelia:messenger:purge-failed` task deletes them after 30 days by default. + +See [Personal data](../security/personal-data.md) and +[Running the workers](../getting-started/background-workers.md#personal-data-in-the-queue). From dc91493f44d5183dcd299e3c3a14d8bcfc3e3fe6 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 5 Oct 2026 17:02:31 +0200 Subject: [PATCH 02/52] docs(reference): document the background job settings, commands and a complete module example --- docs/reference/background-jobs.md | 573 ++++++++++++++++++ docs/reference/cli/index.md | 15 + .../cli/thelia_messenger_purge_failed.md | 41 ++ docs/reference/index.md | 7 + 4 files changed, 636 insertions(+) create mode 100644 docs/reference/background-jobs.md create mode 100644 docs/reference/cli/thelia_messenger_purge_failed.md diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md new file mode 100644 index 000000000..0af7f35f2 --- /dev/null +++ b/docs/reference/background-jobs.md @@ -0,0 +1,573 @@ +--- +title: Background jobs +sidebar_position: 5 +--- + +# Background jobs reference + +This page lists the settings and commands of the background jobs, then walks through a +complete module that queues its own work. The model behind it (transports, retries, the +classes a queue accepts) is explained in [Background jobs](../architecture/background-jobs.md). +Running the workers in production is covered in +[Running the workers](../getting-started/background-workers.md). + +## Configuration + +Every setting is an environment variable, set in `.env.local` or in the environment of the +hosting. + +| Variable | Default | Effect | +| --- | --- | --- | +| `MESSENGER_TRANSPORT_DSN` | empty | The queue of the `async` transport. Empty means no queue: every job runs at once. `doctrine://default`, a Redis DSN or an AMQP DSN names a queue that a worker consumes. | +| `MESSENGER_FAILURE_TRANSPORT_DSN` | `doctrine://default?queue_name=failed` | Where the jobs that failed every attempt are kept. | +| `THELIA_SCHEDULE_SALE_CHECK` | `* * * * *` | Cron expression of `sale:check-activation`. Empty disables the task. | +| `THELIA_SCHEDULE_MAINTENANCE_PURGE` | `30 3 * * *` | Cron expression of `maintenance:purge`. Empty disables the task. | +| `THELIA_SCHEDULE_FAILED_JOBS_PURGE` | `0 4 * * *` | Cron expression of `thelia:messenger:purge-failed`. Empty disables the task. | +| `THELIA_SCHEDULE_CURRENCY_RATES` | empty | Cron expression of `currency:update-rates`. Off by default. | +| `LOCK_DSN` | `flock` | The lock store that keeps two workers from running the same scheduled task. | + +`doctrine://default` is the only Doctrine connection name accepted: it is the shop database, +reached with the settings of the Propel connection. The table `messenger_messages` is created +by the installer and by the 3.3.0 update script. + +The container parameter `thelia.messenger.allowed_message_classes` (an array, empty by +default) lists the exact names of the message classes that a project queues from outside +`Thelia\` and the namespaces of the active modules. + +### Transports + +| Transport | Retries | Notes | +| --- | --- | --- | +| `async` | 3, after 30 s, 2 min and 8 min (multiplier 4, with jitter) | Then the message goes to `failed`. | +| `failed` | none | Replay with `messenger:failed:retry` or from the back office. | +| `scheduler_thelia` | none | The recurring tasks of the `thelia` schedule. | + +The core routes `Symfony\Component\Mailer\Messenger\SendEmailMessage` and +`Thelia\Domain\DataTransfer\Job\RunExportJob` to `async`. + +## Commands + +| Command | Use | +| --- | --- | +| `messenger:consume async scheduler_thelia --time-limit=3600 --memory-limit=256M` | Run a worker. Prefer one worker per transport. | +| `messenger:stats` | Count the messages waiting in each transport. | +| `messenger:failed:show` | List the failed jobs, or show one with its error. | +| `messenger:failed:retry` | Replay failed jobs. | +| `messenger:failed:remove` | Delete failed jobs. | +| `messenger:stop-workers` | Ask every worker to stop after its current message. | +| [`thelia:messenger:purge-failed`](./cli/thelia_messenger_purge_failed.md) | Delete the failed jobs set aside for more than `--older-than` days (30 by default). | + +All of them run through `php Thelia`, for example `php Thelia messenger:stats`. + +## Back office + +Configuration > System > Background jobs (`/admin/configuration/background-jobs`) shows: + +- whether a queue is configured, and how many jobs wait in it, +- how many jobs failed, and the list of failed jobs with their description, the reason of the + failure, the date and the number of attempts, +- the last 20 exports. + +Each failed job can be replayed or deleted. Replay puts the job back on the transport it +failed on. Without a queue, it runs at once, and a job that fails again stays in the failed +list. + +The screen answers to the resource `admin.configuration.background-jobs`. Grant `VIEW` to read +it, `UPDATE` to replay and `DELETE` to delete. The description of a mail names its recipient +and a failure reason may quote personal data, so give it only to the profiles that need it. + +## Recurring tasks + +The `thelia` schedule runs these commands when a worker consumes `scheduler_thelia`: + +| Command | Variable | Default | +| --- | --- | --- | +| `sale:check-activation` | `THELIA_SCHEDULE_SALE_CHECK` | every minute | +| `maintenance:purge` | `THELIA_SCHEDULE_MAINTENANCE_PURGE` | 03:30 | +| `thelia:messenger:purge-failed` | `THELIA_SCHEDULE_FAILED_JOBS_PURGE` | 04:00 | +| `currency:update-rates` | `THELIA_SCHEDULE_CURRENCY_RATES` | off | + +`currency:update-rates` is off because it overwrites every rate, including those set by hand. +`sale:check-activation` switches a sale back on when it was turned off by hand inside its +dates, as the command always did. + +A shop that keeps these commands in its crontab must not also consume `scheduler_thelia`, or +each task runs twice. Choose one of the two. + +A module adds a task to the same schedule with a Symfony Scheduler attribute and +`schedule: 'thelia'`: + +- `#[AsCronTask('0 2 * * *', schedule: 'thelia')]` for a cron expression, +- `#[AsPeriodicTask('1 hour', schedule: 'thelia')]` for an interval. + +The attribute goes on a console command class, or on a service class with `method:` naming the +method to call. The example below schedules a command. + +## A complete module: StockSync + +`StockSync` pushes the stock of each product combination to a remote warehouse API. When a +combination is saved in the back office, a listener dispatches a message, and the handler +calls the API. A nightly task pushes every combination again, to catch changes made elsewhere. + +With an empty `MESSENGER_TRANSPORT_DSN`, the push happens during the save. With a queue, the +save returns at once and a worker does the push, with retries if the API is down. + +Without a queue, an exception thrown by the handler reaches the code that dispatched the +message: the save of the combination in the back office fails with it. A shop that relies on a +remote API this way should run a queue. + +``` +local/modules/StockSync/ +├── StockSync.php +├── composer.json +├── Config/ +│ ├── module.xml +│ ├── schema.xml +│ └── TheliaMain.sql # generated by module:generate:sql +├── Command/ +│ └── PushAllStockCommand.php +├── EventListener/ +│ └── StockChangedListener.php +├── Message/ +│ └── PushStock.php +├── MessageHandler/ +│ └── PushStockHandler.php +└── Model/ # generated by module:generate:model +``` + +### The module class + +`configureServices()` registers every class of the module, which makes the handler, the +listener and the command discovered. `configureContainer()` routes the message to `async`, in +the same way the core routes its own messages. `postActivation()` creates the tables. + +```php +// local/modules/StockSync/StockSync.php +load(self::getModuleCode().'\\', __DIR__) + ->exclude([ + __DIR__.'/I18n/*', + __DIR__.'/Model/*', + __DIR__.'/Message/*', + __DIR__.'/StockSync.php', + ]) + ->autowire() + ->autoconfigure(); + } + + public static function configureContainer(ContainerConfigurator $containerConfigurator): void + { + $containerConfigurator->extension('framework', [ + 'messenger' => [ + 'routing' => [ + PushStock::class => 'async', + ], + ], + ], prepend: true); + } + + public function postActivation(?ConnectionInterface $con = null): void + { + if (!self::getConfigValue('is_initialized', false)) { + (new Database($con))->insertSql(null, [__DIR__.'/Config/TheliaMain.sql']); + + self::setConfigValue('is_initialized', true); + } + } +} +``` + +`Message/` is excluded from the service scan: a message is data, not a service. + +A shop can also route the message itself, in `config/packages/messenger.yaml`, which takes +precedence over the prepended module configuration: + +```yaml +# config/packages/messenger.yaml +framework: + messenger: + routing: + 'StockSync\Message\PushStock': async +``` + +### Metadata and dependencies + +```xml + + + + StockSync\StockSync + + Stock sync + Pushes the stock of each combination to a warehouse API + + + en_US + + 1.0.0 + + + Your Name + + + classic + 3.3.0 + prod + +``` + +```json +{ + "name": "your-vendor/stock-sync-module", + "type": "thelia-module", + "license": "MIT", + "require": { + "thelia/installer": "~1.1", + "symfony/http-client": "^7.4" + }, + "extra": { + "installer-name": "StockSync" + }, + "autoload": { + "psr-4": { + "StockSync\\": "" + } + } +} +``` + +### The tables + +`stock_sync_state` remembers the last quantity pushed for each combination; it is what makes +the handler idempotent. `stock_sync_log` keeps one line per push. + +```xml + + + + + + + + + + + + +
+ + + + + + +
+
+``` + +Generate the models and the SQL file: + +```bash +php Thelia module:generate:model StockSync +php Thelia module:generate:sql StockSync +``` + +### The message + +The message carries an id, nothing else. It is a class of the module namespace, so the queue +accepts it, and its only property is a promoted scalar, so the Symfony serializer writes and +reads it as JSON. + +```php +// local/modules/StockSync/Message/PushStock.php +findPk($message->productSaleElementsId); + + // Deleted since the message was dispatched: nothing left to push. + if (null === $productSaleElements) { + return; + } + + $quantity = (float) $productSaleElements->getQuantity(); + $state = StockSyncStateQuery::create()->findPk($productSaleElements->getId()); + + // Already pushed with this quantity: a replay, or a second message for the same change. + if (null !== $state && $state->getPushedQuantity() === $quantity) { + return; + } + + $apiUrl = (string) StockSync::getConfigValue('api_url'); + + if ('' === $apiUrl) { + throw new UnrecoverableMessageHandlingException('StockSync has no api_url configured.'); + } + + $locale = Lang::getDefaultLanguage()->getLocale(); + $product = $productSaleElements->getProduct(); + $product->setLocale($locale); + + $response = $this->httpClient->request('PUT', rtrim($apiUrl, '/').'/stock/'.rawurlencode((string) $productSaleElements->getRef()), [ + 'auth_bearer' => (string) StockSync::getConfigValue('api_token'), + 'json' => [ + 'quantity' => $quantity, + 'title' => $product->getTitle(), + 'locale' => $locale, + 'source' => rtrim($this->shopUrl, '/'), + ], + ]); + + $status = $response->getStatusCode(); + + // The API refused the data: retrying would get the same answer. + if ($status >= 400 && $status < 500 && 429 !== $status) { + throw new UnrecoverableMessageHandlingException(\sprintf('The warehouse refused %s with HTTP %d.', $productSaleElements->getRef(), $status)); + } + + // A 5xx or a 429: throw, so Messenger retries later. + if ($status >= 300) { + throw new \RuntimeException(\sprintf('The warehouse answered HTTP %d for %s.', $status, $productSaleElements->getRef())); + } + + $this->recordPush($productSaleElements->getId(), $quantity, $state); + } + + private function recordPush(int $productSaleElementsId, float $quantity, ?StockSyncState $state): void + { + $connection = Propel::getConnection('TheliaMain'); + $connection->beginTransaction(); + + try { + $state ??= (new StockSyncState())->setProductSaleElementsId($productSaleElementsId); + $state + ->setPushedQuantity($quantity) + ->setPushedAt(new \DateTimeImmutable()) + ->save($connection); + + (new StockSyncLog()) + ->setProductSaleElementsId($productSaleElementsId) + ->setQuantity($quantity) + ->setCreatedAt(new \DateTimeImmutable()) + ->save($connection); + + $connection->commit(); + } catch (\Throwable $exception) { + $connection->rollBack(); + + throw $exception; + } + } +} +``` + +`StockSync::getConfigValue()` is read again for every message, because the worker forgets the +module configuration between messages: a token changed in the back office is used by the next +push without restarting the worker. + +The two writes of `recordPush()` share an explicit transaction on the Propel connection, so +the state and the log never disagree. + +### The listener + +The core saves a combination in its `PRODUCT_UPDATE_PRODUCT_SALE_ELEMENT` listener, at +priority 128, and commits its transaction there. The module listens to the same event at a +lower priority, so it runs after the commit, and dispatches the message. + +```php +// local/modules/StockSync/EventListener/StockChangedListener.php + ['onStockChanged', 0], + ]; + } + + public function onStockChanged(ProductSaleElementUpdateEvent $event): void + { + $this->bus->dispatch(new PushStock($event->getProductSaleElementId())); + } +} +``` + +The queue in the shop database uses its own connection, so a message dispatched before the +commit stays queued even if the transaction is rolled back. Dispatching after the action has +committed avoids a push for a change that never happened. If your own code wraps the action +in a larger transaction, dispatch after that outer commit instead. + +Stock also changes outside this event, when an order is placed for instance. The nightly task +below covers those changes; a module that needs them sooner listens to the events concerned +in the same way. + +### The recurring task + +`#[AsCronTask]` adds the command to the `thelia` schedule. It runs at 02:00 when a worker +consumes `scheduler_thelia`, and can be run by hand like any command. + +```php +// local/modules/StockSync/Command/PushAllStockCommand.php +select('Id')->find(); + + foreach ($ids as $id) { + $this->bus->dispatch(new PushStock((int) $id)); + } + + $output->writeln(\sprintf('%d stock push(es) queued.', \count($ids))); + + return self::SUCCESS; + } +} +``` + +The command only dispatches messages. With a queue, the pushes are spread over the `async` +worker; without one, they run during the command. Combinations whose quantity did not change +are skipped by the handler, so the nightly run only calls the API for real changes. + +## Idempotence and replay + +A handler can run more than once for the same message: + +- a retry after a failure that happened halfway, for instance when the API accepted the + push but the database write that follows failed, +- a replay from the failed jobs, by `messenger:failed:retry` or from the back office, +- a worker killed hard (`SIGKILL`, out of memory) while handling the message: the Doctrine + transport redelivers it after `redeliver_timeout`, one hour by default, +- two messages for the same row, dispatched by two saves in a row. + +Write every handler so that running it twice has the effect of running it once: + +- carry ids in the message, and read the current state of the row in the handler, +- check whether the work is already done before doing it (`stock_sync_state` above), +- send absolute values to remote services (a quantity, a status), never deltas such as + "remove 2", which a replay would apply twice, +- record what was done in the same transaction as the other writes of the handler, +- return without error when the row no longer exists or is in a state that makes the job + pointless. Throwing would only send the message to `failed` for nothing. + +Use `UnrecoverableMessageHandlingException` when a retry cannot succeed (data refused, +configuration missing). The message goes to `failed` at once, where it can be replayed once +the cause is fixed. + +Test the replay: dispatch the message twice in a test and check that the effect happened once. diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 1761c0d7b..867f83d91 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -105,6 +105,21 @@ with an existing invoice numbering to continue. `maintenance:purge`, `customer:anonymize` and `customer:export-personal-data` still declare their name with `setName()` in `configure()` rather than the `#[AsCommand]` attribute. Both styles work; new commands should use the attribute. ::: +### Background jobs + +| Command | Description | +| --- | --- | +| `messenger:consume` | Run a worker on one or more transports (`async`, `scheduler_thelia`). | +| `messenger:stats` | Count the messages waiting in each transport. | +| `messenger:failed:show` | List the failed jobs, or show one with its error. | +| `messenger:failed:retry` | Replay failed jobs. | +| `messenger:failed:remove` | Delete failed jobs. | +| `messenger:stop-workers` | Ask the workers to stop after their current message. | +| [`thelia:messenger:purge-failed`](./thelia_messenger_purge_failed.md) | Delete the failed jobs set aside for more than a number of days. | + +The `messenger:*` commands come from Symfony Messenger. See [Background jobs](../background-jobs.md) +and [Running the workers](../../getting-started/background-workers.md). + ### Customers and personal data | Command | Description | diff --git a/docs/reference/cli/thelia_messenger_purge_failed.md b/docs/reference/cli/thelia_messenger_purge_failed.md new file mode 100644 index 000000000..bfc829840 --- /dev/null +++ b/docs/reference/cli/thelia_messenger_purge_failed.md @@ -0,0 +1,41 @@ +--- +title: thelia:messenger:purge-failed +--- + +## Description +Delete the background jobs set aside in the failure transport for longer than a number of days. + +A job that failed every attempt is kept in the `failed` transport, with everything it was +dispatched with, until someone replays or removes it (`messenger:failed:show`, +`messenger:failed:retry`, `messenger:failed:remove`). A failed order mail holds the address of +the customer and the content of the order, so these jobs must not be kept forever. This command +deletes the old ones. + +## Usage +```shell + thelia:messenger:purge-failed [options] +``` + +## Options + - `--older-than=DAYS` Age in days from which a failed job is deleted. Defaults to `30`. Takes a whole number of days. + - `--dry-run` Count the jobs that would be deleted, and delete nothing. + +The `thelia` schedule runs the command every day at 04:00 when a worker consumes +`scheduler_thelia` (`THELIA_SCHEDULE_FAILED_JOBS_PURGE`, default `0 4 * * *`). A shop that runs +its tasks from a crontab adds it there. + +## Examples +Count the failed jobs older than 30 days: +```shell +php Thelia thelia:messenger:purge-failed --dry-run +``` + +Delete the failed jobs set aside for more than 14 days: +```shell +php Thelia thelia:messenger:purge-failed --older-than=14 +``` + +:::tip +Record the retention period in the GDPR register. See +[Background jobs](../background-jobs.md) and [Personal data](../../security/personal-data.md). +::: diff --git a/docs/reference/index.md b/docs/reference/index.md index df210f318..284853530 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -33,6 +33,12 @@ Transactional emails and order documents (invoice, delivery slip) are Twig theme See [Emails and PDF](./emails-and-pdf.md) for the theme reference and the preview commands. +### Background jobs + +Mail delivery, back-office exports and the work of modules can run in a queue consumed by a worker, and recurring tasks run from a schedule. + +See [Background jobs](./background-jobs.md) for the settings, the commands and a complete module example. + ## CLI commands Thelia provides console commands for common operations such as cache management, module handling, and database operations. @@ -47,6 +53,7 @@ See the [CLI reference](./cli/index.md) for all available commands. | [Forms](./forms.md) | Form creation and validation | | [Internationalization](./internationalization.md) | Multi-language support | | [Emails and PDF](./emails-and-pdf.md) | Email and PDF Twig themes | +| [Background jobs](./background-jobs.md) | Queues, workers and recurring tasks | | [CLI Commands](./cli/index.md) | Console commands | ## Legacy references From 32284cc884318589bb15395f0f3edb85cd0a6a33 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 5 Oct 2026 17:02:31 +0200 Subject: [PATCH 03/52] docs(getting-started): describe how to run, monitor and stop the background workers --- docs/getting-started/background-workers.md | 204 +++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/getting-started/background-workers.md diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md new file mode 100644 index 000000000..98e3e6479 --- /dev/null +++ b/docs/getting-started/background-workers.md @@ -0,0 +1,204 @@ +--- +title: Running the workers +sidebar_position: 4.6 +--- + +# Running the workers + +A shop that names a queue in `MESSENGER_TRANSPORT_DSN` needs at least one worker: a long +`messenger:consume` process that handles the queued jobs. Without a queue, nothing waits for +a worker and this page only matters for the recurring tasks. The model is explained in +[Background jobs](../architecture/background-jobs.md), the settings in the +[background jobs reference](../reference/background-jobs.md). + +## The workers to run + +```bash +php Thelia messenger:consume async --time-limit=3600 --memory-limit=256M +php Thelia messenger:consume scheduler_thelia --time-limit=3600 --memory-limit=256M +``` + +Run one worker per transport. A single process can consume both +(`messenger:consume async scheduler_thelia`), but then a long import holds up the mails and +the scheduled tasks behind it. + +Consume `async` only once `MESSENGER_TRANSPORT_DSN` names a queue. Left empty, the transport is +synchronous and there is nothing for a worker to read. The scheduler worker is useful either +way, if the shop runs its recurring tasks from the schedule rather than from a crontab. + +`--time-limit` and `--memory-limit` make the worker exit cleanly after an hour or once it uses +256 MB, and the process manager starts a fresh one. A PHP process that runs for days grows in +memory, and MySQL closes connections left idle longer than its `wait_timeout`: recycling the +worker avoids both. + +On `SIGTERM` or `SIGINT`, a worker finishes the message in hand, then stops. A worker killed +hard (`SIGKILL`, out of memory) leaves its Doctrine message marked as delivered; the transport +hands it to another worker after `redeliver_timeout`, 3600 seconds by default. That is one +reason handlers must be [idempotent](../reference/background-jobs.md#idempotence-and-replay). + +## Supervisor + +```ini +; /etc/supervisor/conf.d/thelia-worker.conf +[program:thelia-async] +command=php /var/www/shop/Thelia messenger:consume async --time-limit=3600 --memory-limit=256M +directory=/var/www/shop +user=www-data +numprocs=1 +process_name=%(program_name)s_%(process_num)02d +autostart=true +autorestart=true +startsecs=0 +stopsignal=TERM +stopwaitsecs=300 +redirect_stderr=true +stdout_logfile=/var/www/shop/var/log/worker-async.log + +[program:thelia-scheduler] +command=php /var/www/shop/Thelia messenger:consume scheduler_thelia --time-limit=3600 --memory-limit=256M +directory=/var/www/shop +user=www-data +numprocs=1 +autostart=true +autorestart=true +startsecs=0 +stopsignal=TERM +stopwaitsecs=300 +redirect_stderr=true +stdout_logfile=/var/www/shop/var/log/worker-scheduler.log +``` + +```bash +supervisorctl reread +supervisorctl update +supervisorctl status +``` + +`stopwaitsecs` is how long supervisor waits for the current message before killing the +process. Set it above the duration of your longest job, a large export for instance. + +## systemd + +```ini +# /etc/systemd/system/thelia-worker@.service +[Unit] +Description=Thelia worker (%i) +After=network.target mariadb.service + +[Service] +User=www-data +WorkingDirectory=/var/www/shop +ExecStart=/usr/bin/php /var/www/shop/Thelia messenger:consume %i --time-limit=3600 --memory-limit=256M +Restart=always +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=300 + +[Install] +WantedBy=multi-user.target +``` + +```bash +systemctl daemon-reload +systemctl enable --now thelia-worker@async thelia-worker@scheduler_thelia +``` + +`Restart=always` starts a new worker each time one exits on its time or memory limit. + +## Shared hosting: the crontab fallback + +A hosting that does not allow long processes can still consume the queue from cron: + +```bash +* * * * * cd /var/www/shop && php Thelia messenger:consume async scheduler_thelia --time-limit=55 +``` + +Each run consumes for 55 seconds, then exits before the next one starts. Jobs wait up to a +minute before they run. Two runs that overlap are safe: each message is handed to one worker +only. + +## DDEV + +In development, the synchronous default is usually all you need: mails reach Mailpit during +the request, exports are served at once. To try the queue, set +`MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local` and let DDEV run the worker: + +```yaml +# .ddev/config.yaml +web_extra_daemons: + - name: "messenger" + command: "php Thelia messenger:consume async scheduler_thelia --time-limit=3600" + directory: /var/www/html +``` + +Run `ddev restart` to start it. The worker runs the code it loaded at start: after changing a +handler, run `ddev exec php Thelia messenger:stop-workers` so it restarts on the new code. + +## Monitoring + +`messenger:stats` prints the number of messages waiting in each transport: + +```bash +php Thelia messenger:stats +php Thelia messenger:stats async failed +``` + +Wire it into the monitoring of the hosting and alert on two signals: + +- `async` above a threshold for several minutes in a row (for instance more than 100 messages + for 10 minutes): the workers are stopped or cannot keep up, +- `failed` above zero: a job failed every attempt and needs someone to read its reason + (`php Thelia messenger:failed:show`) and replay or remove it. + +The back office shows the same counts under Configuration > System > Background jobs. + +## Deploying + +A worker runs the code it loaded when it started. Left running through a deployment, it +consumes the messages of the new version with the old code. Stop the workers before touching +the cache, and start them once the new release is ready: + +1. Stop the workers through the process manager, which sends `SIGTERM` and waits for the + current message: `supervisorctl stop thelia-async thelia-scheduler`, or + `systemctl stop thelia-worker@async thelia-worker@scheduler_thelia`. +2. Deploy the code and run the update script (see [Updating](../upgrading/update.md)). +3. Rebuild the cache: remove `var/cache/prod` and `var/propel/prod`, then run + `php Thelia cache:warmup --env=prod`. Never run `cache:clear` while a worker runs. +4. Start the workers: `supervisorctl start thelia-async thelia-scheduler`, or + `systemctl start thelia-worker@async thelia-worker@scheduler_thelia`. + +When the release is switched atomically (a symlink pointing to the new release directory), +the workers can instead be restarted after the switch with +`php Thelia messenger:stop-workers`: each worker stops after its current message, and the +process manager starts new ones on the new release. The command leaves its signal in the +application cache, and a worker only sees it in the cache it reads: when each release has its +own `var/` directory, run the command from the release the workers were started from. + +Messages queued during the deployment wait in the queue and are handled once the workers are +back. + +## Personal data in the queue + +Queued mails carry the recipient address and the content of the order, and a failed job keeps +everything it was dispatched with. + +- Failed jobs are deleted by `thelia:messenger:purge-failed` after 30 days, every day at 04:00 + when the schedule runs. Keep the same task in the crontab of a shop that does not consume + the schedule. Record this retention in the GDPR register; see + [Personal data](../security/personal-data.md). +- A Redis or AMQP broker must not be reachable from the internet. Bind it to the private + network of the hosting and protect it with a password. +- Put the DSN and its credentials in `.env.local` or in the secrets of the hosting, never in + a committed file. +- Do not share one Redis between environments unless each one has its own stream: a staging + worker would otherwise send the mails of production. Give each environment its own stream + name in the DSN (`redis://host:6379/messages_staging`) or its own Redis database. + +## Sizing + +- One worker per transport is enough for most shops. Add `async` workers (`numprocs` in + supervisor) when `messenger:stats` shows the queue growing. +- Every worker is a PHP process. Count them against the PHP processes the server can run and + the connections MySQL accepts: workers that take every slot leave the web requests waiting. +- Keep `--memory-limit` below the `memory_limit` of PHP CLI, so the worker exits on its own + limit rather than being killed in the middle of a message. From d80d03a9426c1adb78e3d24f1a8d57c015421303 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 5 Oct 2026 17:05:38 +0200 Subject: [PATCH 04/52] docs(getting-started): say a worker without a queue sits idle instead of failing --- docs/getting-started/background-workers.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 98e3e6479..2c67b1121 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -22,9 +22,12 @@ Run one worker per transport. A single process can consume both (`messenger:consume async scheduler_thelia`), but then a long import holds up the mails and the scheduled tasks behind it. -Consume `async` only once `MESSENGER_TRANSPORT_DSN` names a queue. Left empty, the transport is -synchronous and there is nothing for a worker to read. The scheduler worker is useful either -way, if the shop runs its recurring tasks from the schedule rather than from a crontab. +A worker on `async` is only useful once `MESSENGER_TRANSPORT_DSN` names a queue. Started on a +shop without a queue, it sits idle and does nothing, because every job already ran in the +request that dispatched it: harmless, but useless. `php Thelia messenger:stats` and the +Background jobs screen of the back office ("Queue: None") tell whether a queue is configured. +The scheduler worker is useful either way, if the shop runs its recurring tasks from the +schedule rather than from a crontab. `--time-limit` and `--memory-limit` make the worker exit cleanly after an hour or once it uses 256 MB, and the process manager starts a fresh one. A PHP process that runs for days grows in @@ -75,7 +78,7 @@ supervisorctl status ``` `stopwaitsecs` is how long supervisor waits for the current message before killing the -process. Set it above the duration of your longest job, a large export for instance. +process. Set it above the duration of your longest job, a large export or import for instance. ## systemd @@ -120,7 +123,7 @@ only. ## DDEV In development, the synchronous default is usually all you need: mails reach Mailpit during -the request, exports are served at once. To try the queue, set +the request, exports are served and imports run at once. To try the queue, set `MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local` and let DDEV run the worker: ```yaml @@ -175,7 +178,8 @@ application cache, and a worker only sees it in the cache it reads: when each re own `var/` directory, run the command from the release the workers were started from. Messages queued during the deployment wait in the queue and are handled once the workers are -back. +back. The file of a queued import is kept in `var/data-transfer/import/`, outside the cache, +so rebuilding the cache does not lose it. ## Personal data in the queue From 78089a70153d707d17814afa86907784ebf92eb5 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 5 Oct 2026 17:05:38 +0200 Subject: [PATCH 05/52] docs: document back-office imports as background jobs --- docs/architecture/background-jobs.md | 27 +++++++++++++++++++---- docs/reference/background-jobs.md | 33 +++++++++++++++++++++++++--- docs/reference/index.md | 2 +- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 1afc3b5cf..60a305b71 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -25,7 +25,7 @@ There is one bus, `messenger.bus.default`, and three transports: | Transport | What it carries | Where it lives | | --- | --- | --- | -| `async` | The jobs of the shop: mail delivery, back-office exports, the messages of modules | `MESSENGER_TRANSPORT_DSN`, synchronous when empty | +| `async` | The jobs of the shop: mail delivery, back-office exports and imports, the messages of modules | `MESSENGER_TRANSPORT_DSN`, synchronous when empty | | `failed` | The jobs that failed every attempt | The shop database, whatever `async` is | | `scheduler_thelia` | The recurring tasks of the `thelia` schedule | Generated by the schedule, nothing to configure | @@ -45,7 +45,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default ``` From then on, `async` messages are stored and wait for `messenger:consume`. Setting the -variable without running a worker leaves mails undelivered and exports pending, so the two +variable without running a worker leaves mails undelivered and exports and imports pending, so the two go together. | DSN | Queue | @@ -140,7 +140,7 @@ data again, chooses the locale explicitly, and builds absolute URLs from `DEFAUL ## What the core sends through the queue -Two jobs of the core are routed to `async`. +Three jobs of the core are routed to `async`. ### E-mails @@ -158,7 +158,7 @@ delivered the mail, and its author is `system`. With a queue, `MailerFactory::send*OrFail()` only throws when the mail could not be built or queued. A delivery failure happens later, in the worker. -### Back-office exports +### Back-office exports and imports An export started from the back office is a job. `Thelia\Domain\DataTransfer\Job\ExportJobLauncher::launch()` records an `export_job` row and @@ -171,6 +171,25 @@ A finished export is never run twice. A failed one is recorded on its row and go `failed`; replaying it starts it over. `maintenance:purge` deletes the jobs older than 7 days, and the export files are deleted after a day. +An import started from the back office is a job too. +`Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file extension in the +request, moves the uploaded file to `var/data-transfer/import//`, records an `import_job` +row and dispatches `Thelia\Domain\DataTransfer\Job\RunImportJob`. The file is kept outside the +cache on purpose: a deployment empties the cache, and a queued import would lose its file. +Without a queue, the import runs in the request and the back office shows the outcome: the +number of rows imported and the rows refused with their reason. With a queue, the job page +(`/admin/import/job/{id}`, refreshed every 3 seconds) shows waiting, running, done with the +rows changed and the rows refused, or failed with the reason. + +A finished import is never run twice. A failed one goes to `failed`, and replaying it runs it +again from the first row. Each row is written on its own, so the rows written before the +failure are written again, with the same values. The uploaded file is deleted once the import +is done, and kept while the job may be replayed. `maintenance:purge` deletes the import jobs +older than 7 days and their files. + +Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, +`running`, `done`, `failed`. + ## Recurring tasks The recurring tasks of the shop are declared in a Symfony Scheduler schedule named `thelia` diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 0af7f35f2..3442a6ce8 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -42,8 +42,9 @@ default) lists the exact names of the message classes that a project queues from | `failed` | none | Replay with `messenger:failed:retry` or from the back office. | | `scheduler_thelia` | none | The recurring tasks of the `thelia` schedule. | -The core routes `Symfony\Component\Mailer\Messenger\SendEmailMessage` and -`Thelia\Domain\DataTransfer\Job\RunExportJob` to `async`. +The core routes `Symfony\Component\Mailer\Messenger\SendEmailMessage`, +`Thelia\Domain\DataTransfer\Job\RunExportJob` and `Thelia\Domain\DataTransfer\Job\RunImportJob` +to `async`. ## Commands @@ -66,7 +67,7 @@ Configuration > System > Background jobs (`/admin/configuration/background-jobs` - whether a queue is configured, and how many jobs wait in it, - how many jobs failed, and the list of failed jobs with their description, the reason of the failure, the date and the number of attempts, -- the last 20 exports. +- the last 20 exports and the last 20 imports. Each failed job can be replayed or deleted. Replay puts the job back on the transport it failed on. Without a queue, it runs at once, and a job that fails again stays in the failed @@ -76,6 +77,32 @@ The screen answers to the resource `admin.configuration.background-jobs`. Grant it, `UPDATE` to replay and `DELETE` to delete. The description of a mail names its recipient and a failure reason may quote personal data, so give it only to the profiles that need it. +## Back-office exports and imports + +Exports and imports started from the back office are jobs. Both use the status enum +`Thelia\Domain\DataTransfer\Job\JobStatus` (`queued`, `running`, `done`, `failed`). + +| | Export | Import | +| --- | --- | --- | +| Launcher | `ExportJobLauncher::launch(...)` | `ImportJobLauncher::launch(Import $import, File $file, string $originalName, ?Lang $language = null, ?int $adminId = null)` | +| Message | `RunExportJob(int $exportJobId)` | `RunImportJob(int $importJobId)` | +| Row | `export_job` | `import_job`: status, file name, rows imported, refused rows with their reason, error | +| Without a queue | Runs in the request, the file is served at once | Runs in the request, the page shows the rows imported and the rows refused | +| With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | +| Replay of a failed job | Starts the export over | Runs again from the first row | +| Files | Deleted after a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | +| `maintenance:purge` | Deletes the jobs older than 7 days | Deletes the jobs older than 7 days and their files | + +The classes live in `Thelia\Domain\DataTransfer\Job\`. Both job pages refresh every 3 seconds, +and a finished job is never run twice. + +`ImportJobLauncher::launch()` checks the extension of the file in the request, so a wrong file +is refused before anything is queued. The file is moved out of the upload directory to +`var/data-transfer/import/`, not to the cache, which a deployment empties. + +An import writes each row on its own. Replaying a failed import writes again the rows written +before the failure, with the same values, which leaves them as they were. + ## Recurring tasks The `thelia` schedule runs these commands when a worker consumes `scheduler_thelia`: diff --git a/docs/reference/index.md b/docs/reference/index.md index 284853530..1319d660a 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -35,7 +35,7 @@ See [Emails and PDF](./emails-and-pdf.md) for the theme reference and the previe ### Background jobs -Mail delivery, back-office exports and the work of modules can run in a queue consumed by a worker, and recurring tasks run from a schedule. +Mail delivery, back-office exports and imports, and the work of modules can run in a queue consumed by a worker, and recurring tasks run from a schedule. See [Background jobs](./background-jobs.md) for the settings, the commands and a complete module example. From 05829e5987d4e511d4368026092904b4ef73f3c6 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 10:54:35 +0200 Subject: [PATCH 06/52] docs: reflect unreadable jobs, job claims, retention and access rules of background jobs --- docs/architecture/background-jobs.md | 46 +++++++++++++++++----- docs/getting-started/background-workers.md | 4 +- docs/reference/background-jobs.md | 44 ++++++++++++++++++--- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 60a305b71..72697d991 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -58,8 +58,9 @@ go together. ## The queue in the shop database `doctrine://default` is served by `Thelia\Messenger\Transport\ShopDatabaseTransportFactory`. -It opens a DBAL connection with the settings of the Propel connection, so the queue lives in -the same database as the shop, with no extra service. Only the connection name `default` is +It opens a DBAL connection with the settings of the Propel connection, its PDO options and +attributes included (TLS certificates, for instance), so the queue lives in the same database +as the shop, with no extra service. Only the connection name `default` is accepted. The `messenger_messages` table is created by the installer and by the 3.3.0 update script. @@ -132,6 +133,10 @@ of the shop before each message (`Thelia\Messenger\EventListener\WorkerStateRese the price currency are forgotten, - the translator is set back to the default language. +Before each message, the Propel connection is also checked and reopened if MySQL closed it +after its `wait_timeout`. Propel instance pooling is disabled for the whole life of the worker, +so a handler never reads a model object kept in memory by a previous message. + Symfony also resets the services tagged `kernel.reset` between messages, unless the worker is started with `--no-reset`. @@ -152,8 +157,10 @@ ready to be sent again. The order history line `email_sent` is written when the mail server accepted the mail. A listener on the Symfony Mailer `SentMessageEvent` reads the `X-Thelia-Order-Id` and -`X-Thelia-Message-Code` headers of the mail. With a worker, the line appears when the worker -delivered the mail, and its author is `system`. +`X-Thelia-Message-Code` headers of the mail. These headers travel through the queue with the +mail and are removed right before it is handed to the mail server, so the customer never +receives them. With a worker, the line appears when the worker delivered the mail, and its +author is `system`. With a queue, `MailerFactory::send*OrFail()` only throws when the mail could not be built or queued. A delivery failure happens later, in the worker. @@ -168,8 +175,7 @@ the job page (`/admin/export/job/{id}`): waiting, running with the number of row (refreshed every 3 seconds), done with a download link, or failed with the reason. A finished export is never run twice. A failed one is recorded on its row and goes straight to -`failed`; replaying it starts it over. `maintenance:purge` deletes the jobs older than 7 days, -and the export files are deleted after a day. +`failed`; replaying it starts it over. The export files are deleted after a day. An import started from the back office is a job too. `Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file extension in the @@ -184,11 +190,25 @@ rows changed and the rows refused, or failed with the reason. A finished import is never run twice. A failed one goes to `failed`, and replaying it runs it again from the first row. Each row is written on its own, so the rows written before the failure are written again, with the same values. The uploaded file is deleted once the import -is done, and kept while the job may be replayed. `maintenance:purge` deletes the import jobs -older than 7 days and their files. +is done, and kept while the job may be replayed. The path of the file is stored relative to +the project, and its name is cut to 100 characters. Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, -`running`, `done`, `failed`. +`running`, `done`, `failed`. A worker claims a job atomically before running it +(`Thelia\Domain\DataTransfer\Job\JobClaim`), so two workers handed the same job never run it +at the same time. A job left `running` by a worker that died is taken again after one hour +(`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the Doctrine +transport). A job whose row no longer exists fails for good and stays in `failed`, rather than +passing for done. A job the queue refuses at dispatch is recorded as failed, and for an import +the uploaded file is deleted. + +`maintenance:purge` deletes the export and import jobs older than 7 days, with the files of +the imports. Failed jobs are kept 30 days, as long as the failed messages, so they can still +be replayed. + +The page of a job, and the download of an export, are only open to the administrator who +started it and to super-administrators (administrators without a profile). Other +administrators get a 403, even with the export or import right. ## Recurring tasks @@ -218,6 +238,14 @@ The check runs when a message is read and also when it is dispatched. A module d class the workers would refuse gets a `LogicException` ("is not one the shop queues") at dispatch time, not a queue that never empties. +A job already queued can still become unreadable: its class is refused by the check, or it can +no longer be built because its module was turned off, its class was removed, or its content no +longer fits the class. The worker then reads it as `Thelia\Messenger\Message\UndecodableJob`, +which keeps the original class (`originalType`), the reason and the original body, instead of +letting it be dropped. Its handler throws `UnrecoverableMessageHandlingException`, so the job +lands in `failed`. The Background jobs screen lists it as `Unreadable job: ` with its +original class, and it can be deleted there. + For a module author, this means: - message classes live in the module namespace, diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 2c67b1121..a25a7ffe3 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -31,8 +31,8 @@ schedule rather than from a crontab. `--time-limit` and `--memory-limit` make the worker exit cleanly after an hour or once it uses 256 MB, and the process manager starts a fresh one. A PHP process that runs for days grows in -memory, and MySQL closes connections left idle longer than its `wait_timeout`: recycling the -worker avoids both. +memory, and recycling the worker keeps it in check. A connection that MySQL closed after its +`wait_timeout` is reopened before the next message. On `SIGTERM` or `SIGINT`, a worker finishes the message in hand, then stops. A worker killed hard (`SIGKILL`, out of memory) leaves its Doctrine message marked as delivered; the transport diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 3442a6ce8..738a229f4 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -69,9 +69,17 @@ Configuration > System > Background jobs (`/admin/configuration/background-jobs` failure, the date and the number of attempts, - the last 20 exports and the last 20 imports. -Each failed job can be replayed or deleted. Replay puts the job back on the transport it -failed on. Without a queue, it runs at once, and a job that fails again stays in the failed -list. +Each failed job can be replayed or deleted. Replay takes the job out of `failed` first, then +puts it back on the transport it failed on; if that fails, the job is set aside again. A double +click cannot send it twice. Without a queue, a replayed job runs at once, and a job that fails +again stays in the failed list. + +A job the workers could no longer read (module turned off, class removed, content that no longer +fits) is listed as `Unreadable job: ` with its original class. Its handler always +refuses it, so replaying it only sets it aside again; delete it once you know what it was. + +The "Details" link of an export or import is only shown to the administrator who started the +job and to super-administrators. The screen answers to the resource `admin.configuration.background-jobs`. Grant `VIEW` to read it, `UPDATE` to replay and `DELETE` to delete. The description of a mail names its recipient @@ -91,14 +99,24 @@ Exports and imports started from the back office are jobs. Both use the status e | With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | | Replay of a failed job | Starts the export over | Runs again from the first row | | Files | Deleted after a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | -| `maintenance:purge` | Deletes the jobs older than 7 days | Deletes the jobs older than 7 days and their files | +| `maintenance:purge` | Deletes the jobs older than 7 days, failed ones after 30 days | Deletes the jobs older than 7 days and their files, failed ones after 30 days | +| Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | The classes live in `Thelia\Domain\DataTransfer\Job\`. Both job pages refresh every 3 seconds, -and a finished job is never run twice. +and a finished job is never run twice. Other administrators get a 403 on a job page, even with +the export or import right. + +A worker claims a job atomically (`JobClaim`) before running it: two workers handed the same job +never run it at the same time, and a job left `running` by a worker that died is taken again +after one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). A job whose row was deleted fails for +good and stays in `failed`. A job the queue refuses at dispatch is recorded as failed, and the +file of such an import is deleted. Failed jobs are kept 30 days, as long as the failed messages, +so they can still be replayed. `ImportJobLauncher::launch()` checks the extension of the file in the request, so a wrong file is refused before anything is queued. The file is moved out of the upload directory to -`var/data-transfer/import/`, not to the cache, which a deployment empties. +`var/data-transfer/import/`, not to the cache, which a deployment empties. Its path is stored +relative to the project, and its name is cut to 100 characters. An import writes each row on its own. Replaying a failed import writes again the rows written before the failure, with the same values, which leaves them as they were. @@ -572,6 +590,20 @@ The command only dispatches messages. With a queue, the pushes are spread over t worker; without one, they run during the command. Combinations whose quantity did not change are skipped by the handler, so the nightly run only calls the API for real changes. +### Turning the module off + +A queue accepts the messages of active modules only. Messages of `StockSync` still waiting when +the module is turned off can no longer be read: the worker reads each of them as +`Thelia\Messenger\Message\UndecodableJob` and sets it aside in `failed`. Nothing is lost and +nothing loops. The Background jobs screen lists them as `Unreadable job: ` with the +original class `StockSync\Message\PushStock`. Delete them there, and let the nightly task push +the stock again once the module is back. + +To avoid this, wait for `php Thelia messenger:stats async` to reach zero before turning the +module off. The same happens when a message class is renamed or removed, or when its +constructor changes so that queued content no longer fits: keep the old class until the queue +is empty. + ## Idempotence and replay A handler can run more than once for the same message: From 8bdce9e2313816b109e422ec20443cbde7968b10 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 11:05:53 +0200 Subject: [PATCH 07/52] docs: say a running job is taken again after an hour without progress and how a failed replay is kept --- docs/architecture/background-jobs.md | 9 ++++++--- docs/reference/background-jobs.md | 10 +++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 72697d991..e3eff8f05 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -196,9 +196,12 @@ the project, and its name is cut to 100 characters. Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, `running`, `done`, `failed`. A worker claims a job atomically before running it (`Thelia\Domain\DataTransfer\Job\JobClaim`), so two workers handed the same job never run it -at the same time. A job left `running` by a worker that died is taken again after one hour -(`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the Doctrine -transport). A job whose row no longer exists fails for good and stays in `failed`, rather than +at the same time. A job left `running` is taken again once it has given no sign of life for +one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the +Doctrine transport): the claim compares the `updated_at` of the row, not the time the job +started. An export refreshes it each time it reports its progress, and an import as it reads +its rows (`ImportHandler::import()` takes an optional `$onProgress` closure), so a long export +or import that is still working is never taken from under its worker. A job whose row no longer exists fails for good and stays in `failed`, rather than passing for done. A job the queue refuses at dispatch is recorded as failed, and for an import the uploaded file is deleted. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 738a229f4..8eff7b918 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -71,7 +71,9 @@ Configuration > System > Background jobs (`/admin/configuration/background-jobs` Each failed job can be replayed or deleted. Replay takes the job out of `failed` first, then puts it back on the transport it failed on; if that fails, the job is set aside again. A double -click cannot send it twice. Without a queue, a replayed job runs at once, and a job that fails +click cannot send it twice. If setting it aside fails too (queue and database both down), the +administrator is shown both reasons and the content of the job is written to the log at +critical level, so it can be dispatched again by hand. Without a queue, a replayed job runs at once, and a job that fails again stays in the failed list. A job the workers could no longer read (module turned off, class removed, content that no longer @@ -107,8 +109,10 @@ and a finished job is never run twice. Other administrators get a 403 on a job p the export or import right. A worker claims a job atomically (`JobClaim`) before running it: two workers handed the same job -never run it at the same time, and a job left `running` by a worker that died is taken again -after one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). A job whose row was deleted fails for +never run it at the same time. A job left `running` is taken again once its row has not been +updated for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). An export updates it as it +reports its progress and an import as it reads its rows (`ImportHandler::import()` takes an +optional `$onProgress` closure), so a long job that is still working keeps its worker. A job whose row was deleted fails for good and stays in `failed`. A job the queue refuses at dispatch is recorded as failed, and the file of such an import is deleted. Failed jobs are kept 30 days, as long as the failed messages, so they can still be replayed. From 45b3f1f75c1a22a4b14d20d180107ee6708a36aa Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 12:18:42 +0200 Subject: [PATCH 08/52] docs(reference): make the StockSync example run as written --- docs/reference/background-jobs.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 8eff7b918..1334d9b61 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -170,6 +170,7 @@ local/modules/StockSync/ ├── StockSync.php ├── composer.json ├── Config/ +│ ├── config.xml │ ├── module.xml │ ├── schema.xml │ └── TheliaMain.sql # generated by module:generate:sql @@ -274,11 +275,10 @@ framework: en_US 1.0.0 - - - Your Name - - + + Your Name + you@example.com + classic 3.3.0 prod @@ -305,16 +305,33 @@ framework: } ``` +`Config/config.xml` declares nothing, since `configureServices()` registers the services, but +the `module:generate:*` commands refuse a module without it: + +```xml + + + + +``` + ### The tables `stock_sync_state` remembers the last quantity pushed for each combination; it is what makes -the handler idempotent. `stock_sync_log` keeps one line per push. +the handler idempotent. `stock_sync_log` keeps one line per push. The foreign key points to a +core table, so the core schema is declared as an external schema, for reference only: without +it, `module:generate:sql` cannot resolve `product_sale_elements`. ```xml + + + From 9dd48e9b682316ddf70f38d391304c300d63538a Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 12:38:21 +0200 Subject: [PATCH 09/52] docs: document the heavy jobs queue, the Redis and AMQP bridges and atomic imports --- docs/architecture/background-jobs.md | 62 +++++++++++++++----- docs/getting-started/background-workers.md | 68 ++++++++++++++++------ docs/reference/background-jobs.md | 62 +++++++++++++++++--- docs/upgrading/update.md | 2 + 4 files changed, 152 insertions(+), 42 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index e3eff8f05..39c351d35 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -21,18 +21,19 @@ The core requires `symfony/messenger`, `symfony/doctrine-messenger`, `doctrine/d `symfony/scheduler`. It does not require the Doctrine ORM or DoctrineBundle: DBAL is only there to serve the queue kept in the shop database. -There is one bus, `messenger.bus.default`, and three transports: +There is one bus, `messenger.bus.default`, and four transports: | Transport | What it carries | Where it lives | | --- | --- | --- | -| `async` | The jobs of the shop: mail delivery, back-office exports and imports, the messages of modules | `MESSENGER_TRANSPORT_DSN`, synchronous when empty | -| `failed` | The jobs that failed every attempt | The shop database, whatever `async` is | +| `async` | The jobs of the shop: mail delivery, the messages of modules | `MESSENGER_TRANSPORT_DSN`, synchronous when empty | +| `async_heavy` | Back-office exports and imports | Derived from `MESSENGER_TRANSPORT_DSN`, or `MESSENGER_HEAVY_TRANSPORT_DSN` | +| `failed` | The jobs that failed every attempt | The shop database, whatever the queue is | | `scheduler_thelia` | The recurring tasks of the `thelia` schedule | Generated by the schedule, nothing to configure | ## Nothing waits by default -`MESSENGER_TRANSPORT_DSN` is shipped empty in `.env`. An empty value makes `async` fall back -to `sync://`: every message is handled immediately, in the request or the command that +`MESSENGER_TRANSPORT_DSN` is shipped empty in `.env`. An empty value makes `async` and +`async_heavy` fall back to `sync://`: every message is handled immediately, in the request or the command that dispatched it, exactly as if the handler had been called directly. Nothing is stored and nothing waits for a worker, so a shop that runs no worker loses no job. A failure in the handler surfaces in the code that dispatched the message, as any exception would. @@ -44,16 +45,38 @@ The shop opts in to a real queue by naming one: MESSENGER_TRANSPORT_DSN=doctrine://default ``` -From then on, `async` messages are stored and wait for `messenger:consume`. Setting the -variable without running a worker leaves mails undelivered and exports and imports pending, so the two +From then on, messages are stored and wait for `messenger:consume`. Setting the variable +without running a worker leaves mails undelivered and exports and imports pending, so the two go together. | DSN | Queue | | --- | --- | | empty | No queue, every job runs at once | | `doctrine://default` | Table `messenger_messages` of the shop database | -| `redis://host:6379/messages` | A Redis stream | -| `amqp://user:password@host:5672/%2f/messages` | An AMQP broker such as RabbitMQ | +| `redis://host:6379/messages` | A Redis stream, once `symfony/redis-messenger` is installed | +| `amqp://user:password@host:5672/%2f/messages` | An AMQP broker such as RabbitMQ, once `symfony/amqp-messenger` is installed | + +The core only ships the Doctrine transport. A Redis queue needs +`composer require symfony/redis-messenger` and the `redis` PHP extension; an AMQP queue needs +`composer require symfony/amqp-messenger` and the `amqp` extension. + +### A second queue for heavy jobs + +Exports and imports are routed to `async_heavy`, so a large import never holds up the mails +waiting on `async`. Its DSN is derived from `MESSENGER_TRANSPORT_DSN` by +`Thelia\Messenger\HeavyTransportDsnProcessor` +(`%env(thelia_heavy_queue:MESSENGER_TRANSPORT_DSN)%`): + +| `MESSENGER_TRANSPORT_DSN` | `async_heavy` | +| --- | --- | +| empty | `sync://`, the job runs at once | +| `doctrine://...` | The same table, with `queue_name=heavy` | +| `redis://host:port//...` | The stream `_heavy`, or `messages_heavy` when the DSN names no stream | +| anything else | The same DSN: both transports share one queue | + +`MESSENGER_HEAVY_TRANSPORT_DSN` overrides the derived value. A worker started with +`messenger:consume async async_heavy` takes the mails first, then the heavy jobs; a shop can +also run one worker per transport. ## The queue in the shop database @@ -145,7 +168,7 @@ data again, chooses the locale explicitly, and builds absolute URLs from `DEFAUL ## What the core sends through the queue -Three jobs of the core are routed to `async`. +The core routes three jobs: mails to `async`, exports and imports to `async_heavy`. ### E-mails @@ -187,9 +210,13 @@ number of rows imported and the rows refused with their reason. With a queue, th (`/admin/import/job/{id}`, refreshed every 3 seconds) shows waiting, running, done with the rows changed and the rows refused, or failed with the reason. -A finished import is never run twice. A failed one goes to `failed`, and replaying it runs it -again from the first row. Each row is written on its own, so the rows written before the -failure are written again, with the same values. The uploaded file is deleted once the import +An import runs in a single Propel transaction. Stopped half way, by an error, a killed worker +or a deployment, it leaves the catalog as it was. A row the import refuses (a missing +combination, an invalid GTIN) is not an error: it is listed with the job as a refusal, and the +other rows are kept. + +A finished import is never run twice. A failed one goes to `failed`, and replaying it starts +over from the first row. The uploaded file is deleted once the import is done, and kept while the job may be replayed. The path of the file is stored relative to the project, and its name is cut to 100 characters. @@ -201,7 +228,9 @@ one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery Doctrine transport): the claim compares the `updated_at` of the row, not the time the job started. An export refreshes it each time it reports its progress, and an import as it reads its rows (`ImportHandler::import()` takes an optional `$onProgress` closure), so a long export -or import that is still working is never taken from under its worker. A job whose row no longer exists fails for good and stays in `failed`, rather than +or import that is still working is never taken from under its worker. The import writes this +sign of life through a second database connection (`Thelia\Domain\DataTransfer\Job\JobHeartbeat`), +outside its own transaction, so the update is seen while the transaction is still open. A job whose row no longer exists fails for good and stays in `failed`, rather than passing for done. A job the queue refuses at dispatch is recorded as failed, and for an import the uploaded file is deleted. @@ -213,6 +242,11 @@ The page of a job, and the download of an export, are only open to the administr started it and to super-administrators (administrators without a profile). Other administrators get a 403, even with the export or import right. +An export or an import is one job for one file. It is not split into smaller jobs: a file is +written by a single writer, and an import has to be atomic. Splitting work into smaller jobs +fits work made of independent items, as in a module that pushes one message per product +combination. It does not fit the rows of a file: do not dispatch one message per row. + ## Recurring tasks The recurring tasks of the shop are declared in a Symfony Scheduler schedule named `thelia` diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index a25a7ffe3..cc531d490 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -15,14 +15,23 @@ a worker and this page only matters for the recurring tasks. The model is explai ```bash php Thelia messenger:consume async --time-limit=3600 --memory-limit=256M +php Thelia messenger:consume async_heavy --time-limit=3600 --memory-limit=256M php Thelia messenger:consume scheduler_thelia --time-limit=3600 --memory-limit=256M ``` -Run one worker per transport. A single process can consume both -(`messenger:consume async scheduler_thelia`), but then a long import holds up the mails and -the scheduled tasks behind it. +Mails and the messages of modules go to `async`, back-office exports and imports to +`async_heavy`, so a large import does not hold up the mails. One worker per transport keeps +them fully apart: a mail never waits behind an import, and the scheduled tasks never wait +behind either. A smaller setup can run a single worker on +`messenger:consume async async_heavy`, which takes the mails first, then the heavy jobs, plus +the scheduler worker. -A worker on `async` is only useful once `MESSENGER_TRANSPORT_DSN` names a queue. Started on a +A Redis or AMQP queue needs its Messenger bridge, which the core does not ship: +`composer require symfony/redis-messenger` (with the `redis` PHP extension) or +`composer require symfony/amqp-messenger` (with the `amqp` extension). The queue in the shop +database (`doctrine://default`) needs nothing more. + +A worker on `async` or `async_heavy` is only useful once `MESSENGER_TRANSPORT_DSN` names a queue. Started on a shop without a queue, it sits idle and does nothing, because every job already ran in the request that dispatched it: harmless, but useless. `php Thelia messenger:stats` and the Background jobs screen of the back office ("Queue: None") tell whether a queue is configured. @@ -57,11 +66,26 @@ stopwaitsecs=300 redirect_stderr=true stdout_logfile=/var/www/shop/var/log/worker-async.log +[program:thelia-async-heavy] +command=php /var/www/shop/Thelia messenger:consume async_heavy --time-limit=3600 --memory-limit=256M +directory=/var/www/shop +user=www-data +numprocs=1 +process_name=%(program_name)s_%(process_num)02d +autostart=true +autorestart=true +startsecs=0 +stopsignal=TERM +stopwaitsecs=300 +redirect_stderr=true +stdout_logfile=/var/www/shop/var/log/worker-async-heavy.log + [program:thelia-scheduler] command=php /var/www/shop/Thelia messenger:consume scheduler_thelia --time-limit=3600 --memory-limit=256M directory=/var/www/shop user=www-data numprocs=1 +process_name=%(program_name)s_%(process_num)02d autostart=true autorestart=true startsecs=0 @@ -78,7 +102,8 @@ supervisorctl status ``` `stopwaitsecs` is how long supervisor waits for the current message before killing the -process. Set it above the duration of your longest job, a large export or import for instance. +process. Set it above the duration of your longest job: on `async_heavy`, a large export or +import. ## systemd @@ -103,7 +128,7 @@ WantedBy=multi-user.target ```bash systemctl daemon-reload -systemctl enable --now thelia-worker@async thelia-worker@scheduler_thelia +systemctl enable --now thelia-worker@async thelia-worker@async_heavy thelia-worker@scheduler_thelia ``` `Restart=always` starts a new worker each time one exits on its time or memory limit. @@ -113,7 +138,7 @@ systemctl enable --now thelia-worker@async thelia-worker@scheduler_thelia A hosting that does not allow long processes can still consume the queue from cron: ```bash -* * * * * cd /var/www/shop && php Thelia messenger:consume async scheduler_thelia --time-limit=55 +* * * * * cd /var/www/shop && php Thelia messenger:consume async async_heavy scheduler_thelia --time-limit=55 ``` Each run consumes for 55 seconds, then exits before the next one starts. Jobs wait up to a @@ -130,11 +155,13 @@ the request, exports are served and imports run at once. To try the queue, set # .ddev/config.yaml web_extra_daemons: - name: "messenger" - command: "php Thelia messenger:consume async scheduler_thelia --time-limit=3600" + command: "php Thelia messenger:consume async async_heavy --time-limit=3600 --memory-limit=256M" directory: /var/www/html ``` -Run `ddev restart` to start it. The worker runs the code it loaded at start: after changing a +Run `ddev restart` to start it. Set `MESSENGER_TRANSPORT_DSN` in `.env.local`, not under +`web_environment` in `.ddev/config.yaml`: a value set there wins over the `.env` files, for +every command run in the container. The worker runs the code it loaded at start: after changing a handler, run `ddev exec php Thelia messenger:stop-workers` so it restarts on the new code. ## Monitoring @@ -143,17 +170,19 @@ handler, run `ddev exec php Thelia messenger:stop-workers` so it restarts on the ```bash php Thelia messenger:stats -php Thelia messenger:stats async failed +php Thelia messenger:stats async async_heavy failed ``` Wire it into the monitoring of the hosting and alert on two signals: - `async` above a threshold for several minutes in a row (for instance more than 100 messages - for 10 minutes): the workers are stopped or cannot keep up, + for 10 minutes): the workers are stopped or cannot keep up. `async_heavy` holds few, long + jobs; alert when a job waits there longer than your longest export or import should take, - `failed` above zero: a job failed every attempt and needs someone to read its reason (`php Thelia messenger:failed:show`) and replay or remove it. -The back office shows the same counts under Configuration > System > Background jobs. +The back office shows the same counts under Configuration > System > Background jobs, where +the waiting count adds both queues. ## Deploying @@ -162,13 +191,13 @@ consumes the messages of the new version with the old code. Stop the workers bef the cache, and start them once the new release is ready: 1. Stop the workers through the process manager, which sends `SIGTERM` and waits for the - current message: `supervisorctl stop thelia-async thelia-scheduler`, or - `systemctl stop thelia-worker@async thelia-worker@scheduler_thelia`. + current message: `supervisorctl stop thelia-async:* thelia-async-heavy:* thelia-scheduler:*`, or + `systemctl stop thelia-worker@async thelia-worker@async_heavy thelia-worker@scheduler_thelia`. 2. Deploy the code and run the update script (see [Updating](../upgrading/update.md)). 3. Rebuild the cache: remove `var/cache/prod` and `var/propel/prod`, then run `php Thelia cache:warmup --env=prod`. Never run `cache:clear` while a worker runs. -4. Start the workers: `supervisorctl start thelia-async thelia-scheduler`, or - `systemctl start thelia-worker@async thelia-worker@scheduler_thelia`. +4. Start the workers: `supervisorctl start thelia-async:* thelia-async-heavy:* thelia-scheduler:*`, or + `systemctl start thelia-worker@async thelia-worker@async_heavy thelia-worker@scheduler_thelia`. When the release is switched atomically (a symlink pointing to the new release directory), the workers can instead be restarted after the switch with @@ -196,12 +225,13 @@ everything it was dispatched with. a committed file. - Do not share one Redis between environments unless each one has its own stream: a staging worker would otherwise send the mails of production. Give each environment its own stream - name in the DSN (`redis://host:6379/messages_staging`) or its own Redis database. + name in the DSN (`redis://host:6379/messages_staging`, whose heavy jobs then go to the stream + `messages_staging_heavy`) or its own Redis database. ## Sizing -- One worker per transport is enough for most shops. Add `async` workers (`numprocs` in - supervisor) when `messenger:stats` shows the queue growing. +- One worker per transport is enough for most shops. Add `async` or `async_heavy` workers + (`numprocs` in supervisor) when `messenger:stats` shows that queue growing. - Every worker is a PHP process. Count them against the PHP processes the server can run and the connections MySQL accepts: workers that take every slot leave the web requests waiting. - Keep `--memory-limit` below the `memory_limit` of PHP CLI, so the worker exits on its own diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 1334d9b61..5c1957eb7 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -18,7 +18,8 @@ hosting. | Variable | Default | Effect | | --- | --- | --- | -| `MESSENGER_TRANSPORT_DSN` | empty | The queue of the `async` transport. Empty means no queue: every job runs at once. `doctrine://default`, a Redis DSN or an AMQP DSN names a queue that a worker consumes. | +| `MESSENGER_TRANSPORT_DSN` | empty | The queue of the `async` transport, from which the `async_heavy` queue is derived. Empty means no queue: every job runs at once. `doctrine://default`, a Redis DSN or an AMQP DSN names a queue that a worker consumes. | +| `MESSENGER_HEAVY_TRANSPORT_DSN` | derived | Overrides the queue of `async_heavy`. | | `MESSENGER_FAILURE_TRANSPORT_DSN` | `doctrine://default?queue_name=failed` | Where the jobs that failed every attempt are kept. | | `THELIA_SCHEDULE_SALE_CHECK` | `* * * * *` | Cron expression of `sale:check-activation`. Empty disables the task. | | `THELIA_SCHEDULE_MAINTENANCE_PURGE` | `30 3 * * *` | Cron expression of `maintenance:purge`. Empty disables the task. | @@ -30,6 +31,16 @@ hosting. reached with the settings of the Propel connection. The table `messenger_messages` is created by the installer and by the 3.3.0 update script. +A Redis or AMQP DSN needs its Messenger bridge, which the core does not ship: +`composer require symfony/redis-messenger` (with the `redis` PHP extension) or +`composer require symfony/amqp-messenger` (with the `amqp` extension). + +`async_heavy` takes its DSN from `MESSENGER_TRANSPORT_DSN` through +`Thelia\Messenger\HeavyTransportDsnProcessor`: empty gives `sync://`, a `doctrine://` DSN gives +the same table with `queue_name=heavy`, a Redis DSN gives the stream `_heavy` +(`messages_heavy` when it names no stream), and any other DSN is used as is, so both transports +share one queue. + The container parameter `thelia.messenger.allowed_message_classes` (an array, empty by default) lists the exact names of the message classes that a project queues from outside `Thelia\` and the namespaces of the active modules. @@ -39,18 +50,20 @@ default) lists the exact names of the message classes that a project queues from | Transport | Retries | Notes | | --- | --- | --- | | `async` | 3, after 30 s, 2 min and 8 min (multiplier 4, with jitter) | Then the message goes to `failed`. | +| `async_heavy` | none for exports and imports, which go to `failed` on their first failure | Back-office exports and imports. | | `failed` | none | Replay with `messenger:failed:retry` or from the back office. | | `scheduler_thelia` | none | The recurring tasks of the `thelia` schedule. | -The core routes `Symfony\Component\Mailer\Messenger\SendEmailMessage`, +The core routes `Symfony\Component\Mailer\Messenger\SendEmailMessage` to `async`, and `Thelia\Domain\DataTransfer\Job\RunExportJob` and `Thelia\Domain\DataTransfer\Job\RunImportJob` -to `async`. +to `async_heavy`. ## Commands | Command | Use | | --- | --- | -| `messenger:consume async scheduler_thelia --time-limit=3600 --memory-limit=256M` | Run a worker. Prefer one worker per transport. | +| `messenger:consume async async_heavy --time-limit=3600 --memory-limit=256M` | Run a worker. It takes the mails first, then the exports and imports. | +| `messenger:consume scheduler_thelia --time-limit=3600 --memory-limit=256M` | Run the recurring tasks. | | `messenger:stats` | Count the messages waiting in each transport. | | `messenger:failed:show` | List the failed jobs, or show one with its error. | | `messenger:failed:retry` | Replay failed jobs. | @@ -94,12 +107,13 @@ Exports and imports started from the back office are jobs. Both use the status e | | Export | Import | | --- | --- | --- | +| Transport | `async_heavy` | `async_heavy` | | Launcher | `ExportJobLauncher::launch(...)` | `ImportJobLauncher::launch(Import $import, File $file, string $originalName, ?Lang $language = null, ?int $adminId = null)` | | Message | `RunExportJob(int $exportJobId)` | `RunImportJob(int $importJobId)` | | Row | `export_job` | `import_job`: status, file name, rows imported, refused rows with their reason, error | | Without a queue | Runs in the request, the file is served at once | Runs in the request, the page shows the rows imported and the rows refused | | With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | -| Replay of a failed job | Starts the export over | Runs again from the first row | +| Replay of a failed job | Starts the export over | Starts over from the first row | | Files | Deleted after a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | | `maintenance:purge` | Deletes the jobs older than 7 days, failed ones after 30 days | Deletes the jobs older than 7 days and their files, failed ones after 30 days | | Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | @@ -122,8 +136,11 @@ is refused before anything is queued. The file is moved out of the upload direct `var/data-transfer/import/`, not to the cache, which a deployment empties. Its path is stored relative to the project, and its name is cut to 100 characters. -An import writes each row on its own. Replaying a failed import writes again the rows written -before the failure, with the same values, which leaves them as they were. +An import runs in a single Propel transaction: stopped half way (an error, a killed worker, a +deployment), it leaves the catalog as it was, and replaying it starts over from the first row. +A row the import refuses (a missing combination, an invalid GTIN) is listed with the job as a +refusal, and the other rows are kept. Its sign of life goes through a second database +connection (`JobHeartbeat`), so a long import is never taken from its worker. ## Recurring tasks @@ -188,8 +205,8 @@ local/modules/StockSync/ ### The module class `configureServices()` registers every class of the module, which makes the handler, the -listener and the command discovered. `configureContainer()` routes the message to `async`, in -the same way the core routes its own messages. `postActivation()` creates the tables. +listener and the command discovered. `configureContainer()` routes the message to `async`, in the +same way the core routes its own messages. `postActivation()` creates the tables. ```php // local/modules/StockSync/StockSync.php @@ -258,6 +275,33 @@ framework: 'StockSync\Message\PushStock': async ``` +### A queue of its own + +Routed to `async`, the pushes wait behind the mails of the shop. A module whose work is +plentiful can give it a transport of its own, still synchronous by default: + +```php +public static function configureContainer(ContainerConfigurator $containerConfigurator): void +{ + $containerConfigurator->parameters()->set('stock_sync.inline_transport_dsn', 'sync://'); + $containerConfigurator->extension('framework', [ + 'messenger' => [ + 'transports' => [ + 'stock_sync' => '%env(default:stock_sync.inline_transport_dsn:STOCK_SYNC_TRANSPORT_DSN)%', + ], + 'routing' => [ + PushStock::class => 'stock_sync', + ], + ], + ], prepend: true); +} +``` + +With `STOCK_SYNC_TRANSPORT_DSN` empty or unset, the pushes stay synchronous. A shop that wants +them queued sets, for instance, `STOCK_SYNC_TRANSPORT_DSN=doctrine://default?queue_name=stock_sync` +and runs a worker with `php Thelia messenger:consume stock_sync`. Pushes that fail every +attempt still go to the `failed` transport of the shop. + ### Metadata and dependencies ```xml diff --git a/docs/upgrading/update.md b/docs/upgrading/update.md index 48653481e..942583095 100644 --- a/docs/upgrading/update.md +++ b/docs/upgrading/update.md @@ -26,6 +26,8 @@ Read the release notes of the version you move to, and the matching page in relies on, and a module or a template you depend on may need its own bump in `composer.json`. +If the shop runs background workers, stop them before you start and restart them at the end, as described in [Deploying](../getting-started/background-workers.md#deploying). + ## 1. Update the code A project installed with `composer create-project thelia/thelia-project` depends on From 228c6792f6e6830ef78acb88bb75ab923af4abf9 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 15:33:14 +0200 Subject: [PATCH 10/52] docs: describe the job lifecycle, the handled-class allow list and the recurring task failures --- docs/architecture/background-jobs.md | 86 ++++++++++++--- docs/getting-started/background-workers.md | 8 +- docs/reference/background-jobs.md | 104 +++++++++++++----- docs/reference/cli/maintenance_purge.md | 7 ++ .../cli/thelia_messenger_purge_failed.md | 7 ++ 5 files changed, 169 insertions(+), 43 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 39c351d35..a7eaabbbd 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -129,7 +129,9 @@ below. When a handler throws on the `async` transport, the message is retried three times, after 30 seconds, 2 minutes and 8 minutes (a multiplier of 4, plus the random jitter Symfony adds). After the last attempt it goes to the `failed` transport. A message is never lost and never -retried in a loop. +retried in a loop. `async_heavy` has no retries (`max_retries: 0`): a message that fails there +goes to `failed` at once, since running a whole export or import again unchanged would fail the +same way. A handler that knows a retry cannot help, for instance because the remote service rejected the data, throws `Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException` @@ -160,6 +162,12 @@ Before each message, the Propel connection is also checked and reopened if MySQL after its `wait_timeout`. Propel instance pooling is disabled for the whole life of the worker, so a handler never reads a model object kept in memory by a previous message. +Once a message is handled or has failed, and before the worker acknowledges it, the DBAL +connection of the queue in the shop database is closed +(`Thelia\Messenger\EventListener\ShopDatabaseConnectionReleaseListener`). The acknowledgement, +or the retry, goes through a connection opened afresh, which MySQL cannot have closed while a +long job kept the worker busy. + Symfony also resets the services tagged `kernel.reset` between messages, unless the worker is started with `--no-reset`. @@ -200,9 +208,18 @@ the job page (`/admin/export/job/{id}`): waiting, running with the number of row A finished export is never run twice. A failed one is recorded on its row and goes straight to `failed`; replaying it starts it over. The export files are deleted after a day. +The reason recorded on the row is the one an administrator reads on the job page. A database, +transport or PHP error quotes SQL, values and host names, so it is not shown: when a +`PDOException`, a Propel or DBAL exception, a Messenger `TransportException` or a PHP `\Error` +is anywhere in the chain of the failure, the row says "The job failed because of a server +error. The details are in the server log." (`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`) +and the full reason goes to the log. Any other reason, such as an export with no data or a file +that cannot be read, is kept as it is, cut to 2000 characters. + An import started from the back office is a job too. -`Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file extension in the -request, moves the uploaded file to `var/data-transfer/import//`, records an `import_job` +`Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file in the request +(its extension, and its content: an archive must be an archive of its kind, anything else must +be text), moves the uploaded file to `var/data-transfer/import//`, records an `import_job` row and dispatches `Thelia\Domain\DataTransfer\Job\RunImportJob`. The file is kept outside the cache on purpose: a deployment empties the cache, and a queued import would lose its file. Without a queue, the import runs in the request and the back office shows the outcome: the @@ -211,9 +228,14 @@ number of rows imported and the rows refused with their reason. With a queue, th rows changed and the rows refused, or failed with the reason. An import runs in a single Propel transaction. Stopped half way, by an error, a killed worker -or a deployment, it leaves the catalog as it was. A row the import refuses (a missing -combination, an invalid GTIN) is not an error: it is listed with the job as a refusal, and the -other rows are kept. +or a deployment, it leaves the catalog as it was. Its outcome (the `done` status, the rows +imported and the rows refused) is written to the job row inside the same transaction, so the +catalog and the row never disagree. When the code that runs the import already holds a +transaction, the import works inside it and leaves the commit or the rollback to that code. A +row the import refuses (a missing combination, an invalid GTIN) is not an error: it is listed +with the job as a refusal, and the other rows are kept. A refusal whose text is not valid UTF-8 +is stored with the invalid bytes replaced. An archive is extracted next to its file and the +extracted copy is removed once the import is over. A finished import is never run twice. A failed one goes to `failed`, and replaying it starts over from the first row. The uploaded file is deleted once the import @@ -221,22 +243,40 @@ is done, and kept while the job may be replayed. The path of the file is stored the project, and its name is cut to 100 characters. Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, -`running`, `done`, `failed`. A worker claims a job atomically before running it -(`Thelia\Domain\DataTransfer\Job\JobClaim`), so two workers handed the same job never run it -at the same time. A job left `running` is taken again once it has given no sign of life for +`running`, `done`, `failed`. Their messages, `RunExportJob` and `RunImportJob`, implement +`Thelia\Domain\DataTransfer\Job\DataTransferJobMessage`, and their handlers go through +`Thelia\Domain\DataTransfer\Job\JobLifecycle`, which dispatches a job, claims it and records +its failure. A worker claims a job atomically before running it +(`Thelia\Domain\DataTransfer\Job\JobClaim`, a service), so two workers handed the same job never +run it at the same time. A job left `running` is taken again once it has given no sign of life for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the Doctrine transport): the claim compares the `updated_at` of the row, not the time the job started. An export refreshes it each time it reports its progress, and an import as it reads its rows (`ImportHandler::import()` takes an optional `$onProgress` closure), so a long export or import that is still working is never taken from under its worker. The import writes this sign of life through a second database connection (`Thelia\Domain\DataTransfer\Job\JobHeartbeat`), -outside its own transaction, so the update is seen while the transaction is still open. A job whose row no longer exists fails for good and stays in `failed`, rather than -passing for done. A job the queue refuses at dispatch is recorded as failed, and for an import -the uploaded file is deleted. - -`maintenance:purge` deletes the export and import jobs older than 7 days, with the files of -the imports. Failed jobs are kept 30 days, as long as the failed messages, so they can still -be replayed. +outside its own transaction, so the update is seen while the transaction is still open. + +A message that finds its job still `running`, held by another worker or left by a worker +killed less than an hour ago, is not dropped: it is dispatched again with a `DelayStamp` of +10 minutes (`JobLifecycle::POSTPONE_DELAY_SECONDS`, 600) and looks at the job again then, so a +job whose worker died is taken again once its row turns stale. It does so at most 72 times +(`JobLifecycle::MAX_POSTPONEMENTS`), 12 hours, then logs a warning and stops. The count travels +in the message (`$postponements`). Without a queue the message is never postponed: the run that +holds the job finishes it. + +A job whose row no longer exists fails for good and stays in `failed`, rather than +passing for done. A job the queue refuses at dispatch is recorded as failed with "The job could +not be queued. The details are in the server log.", and for an import the uploaded file is +deleted. + +`maintenance:purge` deletes the export and import jobs older than 7 days +(`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger::JOB_RETENTION_DAYS`), with the files +of the imports. Failed jobs are kept 30 days (`Thelia\Messenger\FailedMessagePurger::RETENTION_DAYS`), +as long as the failed messages, so they can still be replayed. The file of an import is only +deleted when it lies inside `var/data-transfer/import`, whatever path its row holds. The purge +also removes the files of that directory older than 30 days, such as the extracted copy of an +archive left by a killed worker, or a file whose row was deleted by hand. The page of a job, and the download of an export, are only open to the administrator who started it and to super-administrators (administrators without a profile). Other @@ -256,6 +296,13 @@ locked through `LOCK_DSN`, so two workers never run a task twice, and a worker t after a missed run catches up the last one only. The tasks and their settings are listed in the [reference](../reference/background-jobs.md#recurring-tasks). +A task of the schedule that fails does not go to `failed`: Messenger only sets aside what came +from a queue, and the task runs again at its next time anyway. +`Thelia\Scheduler\EventListener\RecurringTaskFailureListener` logs the failure and keeps the +last one of each task (`Thelia\Scheduler\RecurringTaskFailures`, in the `cache.app` pool) until +a run of that task goes through. The Background jobs screen lists them under "Recurring tasks +that failed", the reason following the same rule as the reason of a failed export or import. + ## Which classes a queue accepts A queued message is data that is turned back into objects when a worker reads it, and whoever @@ -268,6 +315,11 @@ building anything: namespace named after the module code, such as `MyModule\Message\SyncStock`), `Symfony\Component\Mailer\Messenger\SendEmailMessage`, or listed by its exact name in the container parameter `thelia.messenger.allowed_message_classes`, +- outside that parameter, a handler of the application must also take the message class, or + one of its parents or interfaces. The handled classes are collected when the container is + built (`Thelia\Core\DependencyInjection\Compiler\HandledMessageClassesPass`, parameter + `thelia.messenger.handled_message_classes`), so no other class of those namespaces is ever + built from a queue, - a stamp must be a Messenger stamp or come from those same namespaces, and `SerializerStamp` is refused, as it would let the queue change how the rest of the envelope is read. @@ -286,6 +338,8 @@ original class, and it can be deleted there. For a module author, this means: - message classes live in the module namespace, +- each message class has a handler: a message that no handler takes is refused when it is + dispatched to a queue, - they can be written and read as JSON by the Symfony serializer: public or promoted constructor properties with scalar types, such as ids and codes, - they never carry a Propel model. The handler reads the row again from its id. diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index cc531d490..ce423a250 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -41,7 +41,9 @@ schedule rather than from a crontab. `--time-limit` and `--memory-limit` make the worker exit cleanly after an hour or once it uses 256 MB, and the process manager starts a fresh one. A PHP process that runs for days grows in memory, and recycling the worker keeps it in check. A connection that MySQL closed after its -`wait_timeout` is reopened before the next message. +`wait_timeout` is reopened before the next message. The connection of the queue in the shop +database is closed after each message, before the worker acknowledges it, so a long export or +import never leaves the acknowledgement on a connection MySQL has dropped. On `SIGTERM` or `SIGINT`, a worker finishes the message in hand, then stops. A worker killed hard (`SIGKILL`, out of memory) leaves its Doctrine message marked as delivered; the transport @@ -182,7 +184,9 @@ Wire it into the monitoring of the hosting and alert on two signals: (`php Thelia messenger:failed:show`) and replay or remove it. The back office shows the same counts under Configuration > System > Background jobs, where -the waiting count adds both queues. +the waiting count adds both queues, or counts them once when `async` and `async_heavy` share the +same DSN. The same screen lists the recurring tasks whose last run failed: a failed task of +`scheduler_thelia` never reaches `failed`, so `messenger:stats` does not count it. ## Deploying diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 5c1957eb7..45c8a3d85 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -43,14 +43,17 @@ share one queue. The container parameter `thelia.messenger.allowed_message_classes` (an array, empty by default) lists the exact names of the message classes that a project queues from outside -`Thelia\` and the namespaces of the active modules. +`Thelia\` and the namespaces of the active modules. Any other message class must also be taken +by a handler, or have a parent or an interface a handler takes: the parameter +`thelia.messenger.handled_message_classes` is filled when the container is built and is not +meant to be set by hand. ### Transports | Transport | Retries | Notes | | --- | --- | --- | | `async` | 3, after 30 s, 2 min and 8 min (multiplier 4, with jitter) | Then the message goes to `failed`. | -| `async_heavy` | none for exports and imports, which go to `failed` on their first failure | Back-office exports and imports. | +| `async_heavy` | none (`max_retries: 0`) | Back-office exports and imports. A job that fails goes to `failed` at once. | | `failed` | none | Replay with `messenger:failed:retry` or from the back office. | | `scheduler_thelia` | none | The recurring tasks of the `thelia` schedule. | @@ -78,20 +81,33 @@ All of them run through `php Thelia`, for example `php Thelia messenger:stats`. Configuration > System > Background jobs (`/admin/configuration/background-jobs`) shows: - whether a queue is configured, and how many jobs wait in it, -- how many jobs failed, and the list of failed jobs with their description, the reason of the - failure, the date and the number of attempts, +- how many jobs failed, and the list of failed jobs, the newest first, with their description, + the reason of the failure, the date and the number of attempts, +- the recurring tasks whose last run failed, under "Recurring tasks that failed", - the last 20 exports and the last 20 imports. +The waiting count adds the jobs of `async` and of `async_heavy`, and counts them once when both +transports have the same DSN. When `failed` is in the shop database, the default, the list is +read in SQL, so the newest failures show however many there are. + Each failed job can be replayed or deleted. Replay takes the job out of `failed` first, then puts it back on the transport it failed on; if that fails, the job is set aside again. A double -click cannot send it twice. If setting it aside fails too (queue and database both down), the -administrator is shown both reasons and the content of the job is written to the log at -critical level, so it can be dispatched again by hand. Without a queue, a replayed job runs at once, and a job that fails -again stays in the failed list. +click cannot send it twice. Without a queue, a replayed job runs at once, and a job that fails +again stays in the failed list. A replay that fails shows "The job failed again. The details are +in the server log." and the reason goes to the log. If setting it aside fails too (queue and +database both down), the job is lost from the queues and a critical line of the log names it by +its id and its class only: its description and its content may name a customer, and the log is +kept far longer than the failed jobs. A job the workers could no longer read (module turned off, class removed, content that no longer -fits) is listed as `Unreadable job: ` with its original class. Its handler always -refuses it, so replaying it only sets it aside again; delete it once you know what it was. +fits) is listed as `Unreadable job: ` with its original class. Replayed, it would fail +the same way, so it has no Replay button (`FailedJob::$replayable` is false); delete it once you +know what it was. + +A recurring task of the `thelia` schedule that fails is not set aside in `failed`: it runs again +at its next time. `Thelia\Scheduler\EventListener\RecurringTaskFailureListener` keeps its last +failure in the `cache.app` pool (`Thelia\Scheduler\RecurringTaskFailures`) until a run of the +task goes through, and the screen lists it with the task, the reason and the date. The "Details" link of an export or import is only shown to the administrator who started the job and to super-administrators. @@ -109,37 +125,75 @@ Exports and imports started from the back office are jobs. Both use the status e | --- | --- | --- | | Transport | `async_heavy` | `async_heavy` | | Launcher | `ExportJobLauncher::launch(...)` | `ImportJobLauncher::launch(Import $import, File $file, string $originalName, ?Lang $language = null, ?int $adminId = null)` | -| Message | `RunExportJob(int $exportJobId)` | `RunImportJob(int $importJobId)` | +| Message | `RunExportJob(int $exportJobId, int $postponements = 0)` | `RunImportJob(int $importJobId, int $postponements = 0)` | | Row | `export_job` | `import_job`: status, file name, rows imported, refused rows with their reason, error | | Without a queue | Runs in the request, the file is served at once | Runs in the request, the page shows the rows imported and the rows refused | | With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | | Replay of a failed job | Starts the export over | Starts over from the first row | | Files | Deleted after a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | | `maintenance:purge` | Deletes the jobs older than 7 days, failed ones after 30 days | Deletes the jobs older than 7 days and their files, failed ones after 30 days | +| Right to launch | `VIEW` on the export resource | `UPDATE` on the import resource | | Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | -The classes live in `Thelia\Domain\DataTransfer\Job\`. Both job pages refresh every 3 seconds, +The classes live in `Thelia\Domain\DataTransfer\Job\`. Both messages implement +`DataTransferJobMessage` and `Thelia\Messenger\Message\DescribedJob` (they are listed as +`Export #` and `Import #` among the failed jobs). Both job pages refresh every 3 seconds, and a finished job is never run twice. Other administrators get a 403 on a job page, even with the export or import right. -A worker claims a job atomically (`JobClaim`) before running it: two workers handed the same job -never run it at the same time. A job left `running` is taken again once its row has not been -updated for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). An export updates it as it -reports its progress and an import as it reads its rows (`ImportHandler::import()` takes an -optional `$onProgress` closure), so a long job that is still working keeps its worker. A job whose row was deleted fails for -good and stays in `failed`. A job the queue refuses at dispatch is recorded as failed, and the -file of such an import is deleted. Failed jobs are kept 30 days, as long as the failed messages, -so they can still be replayed. - -`ImportJobLauncher::launch()` checks the extension of the file in the request, so a wrong file -is refused before anything is queued. The file is moved out of the upload directory to +An administrator can launch 10 exports and imports in 10 minutes, the two counted together +(rate limiter `admin_data_transfer_launch`, declared in the core `framework` configuration). Past +that, the launch is refused with a message asking to wait a few minutes. + +The job page passes the reason of a failed job through the translator, so the fixed messages +below are shown in the language of the administrator. A database, transport or PHP error +anywhere in the chain of the failure is shown as "The job failed because of a server error. +The details are in the server log." (`Thelia\Messenger\JobFailureMessage::forAdministrator()`), +and its text goes to the log; any other reason is kept, cut to 2000 characters. + +`JobLifecycle` dispatches a job, claims it and records its failure. A worker claims a job +atomically (`JobClaim::claim(string $table, int $jobId)`, an injected service) before running it: +two workers handed the same job never run it at the same time. A job left `running` is taken +again once its row has not been updated for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). An +export updates it as it reports its progress and an import as it reads its rows, every 500 rows +(`Thelia\Domain\DataTransfer\DataTransferProgress::STEP`) and once at the end +(`ImportHandler::import()` takes an optional `$onProgress` closure), so a long job that is still +working keeps its worker. + +A message that finds its job still `running` is dispatched again with a `DelayStamp` of +10 minutes (`JobLifecycle::POSTPONE_DELAY_SECONDS`, 600), at most 72 times +(`JobLifecycle::MAX_POSTPONEMENTS`, 12 hours), each time with `$postponements` one higher. A job +whose worker was killed is therefore taken again once its row is stale. After the last check +the message logs a warning and stops. Without a queue it is never postponed. + +A job whose row was deleted fails for good and stays in `failed`. A job the queue refuses at +dispatch is recorded as failed with "The job could not be queued. The details are in the +server log.", and the file of such an import is deleted. Failed jobs are kept 30 days +(`FailedMessagePurger::RETENTION_DAYS`), as long as the failed messages, so they can still be +replayed. + +`maintenance:purge` delegates to `Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: +the jobs older than 7 days (`DataTransferJobPurger::JOB_RETENTION_DAYS`) are deleted, failed +ones after 30 days. The file of an import is deleted with its row only when it lies inside +`var/data-transfer/import`. The files of that directory older than 30 days are removed too, +along with the directories they leave empty. + +`ImportJobLauncher::launch()` checks the file in the request through +`ImportHandler::validateUpload(string $fileName, ?File $file = null)`, so a wrong file is +refused before anything is queued. The extension must be one the import handlers accept, and, +when the file is given, its content is read with `finfo`: an archive must match the type of its +archiver, anything else must be text. The file is moved out of the upload directory to `var/data-transfer/import/`, not to the cache, which a deployment empties. Its path is stored relative to the project, and its name is cut to 100 characters. An import runs in a single Propel transaction: stopped half way (an error, a killed worker, a deployment), it leaves the catalog as it was, and replaying it starts over from the first row. -A row the import refuses (a missing combination, an invalid GTIN) is listed with the job as a -refusal, and the other rows are kept. Its sign of life goes through a second database +The `done` status, the rows imported and the rows refused are written inside that transaction. +The handler opens and commits the transaction only when the caller holds none; otherwise the +caller commits or rolls back. A row the import refuses (a missing combination, an invalid GTIN) +is listed with the job as a refusal, and the other rows are kept; a refusal that is not valid +UTF-8 is stored with the invalid bytes replaced. `ImportHandler::import()` removes the extracted +copy of an archive once the import is over. The sign of life of an import goes through a second database connection (`JobHeartbeat`), so a long import is never taken from its worker. ## Recurring tasks diff --git a/docs/reference/cli/maintenance_purge.md b/docs/reference/cli/maintenance_purge.md index 2493710eb..458dcb505 100644 --- a/docs/reference/cli/maintenance_purge.md +++ b/docs/reference/cli/maintenance_purge.md @@ -38,6 +38,13 @@ At the end of its run the command dispatches `TheliaEvents::MAINTENANCE_PURGE`: listens to it, purges its own tables, and appends a line to the report with `$event->addResult()`. +The core listens to the same event to purge the back-office exports and imports, through +`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: the jobs older than 7 days, the +failed ones after 30 days (as long as the failed jobs they can be replayed from), the uploaded +file of each deleted import when it lies inside `var/data-transfer/import`, the files of that +directory older than 30 days. The same listener removes the export files of the cache older +than a day. These periods are fixed. See [Background jobs](../background-jobs.md#back-office-exports-and-imports). + ## Examples See what would be removed: ```shell diff --git a/docs/reference/cli/thelia_messenger_purge_failed.md b/docs/reference/cli/thelia_messenger_purge_failed.md index bfc829840..23b38f1ee 100644 --- a/docs/reference/cli/thelia_messenger_purge_failed.md +++ b/docs/reference/cli/thelia_messenger_purge_failed.md @@ -20,6 +20,13 @@ deletes the old ones. - `--older-than=DAYS` Age in days from which a failed job is deleted. Defaults to `30`. Takes a whole number of days. - `--dry-run` Count the jobs that would be deleted, and delete nothing. +The age of a job is counted from the date it was set aside. When `failed` is in the shop +database, the default (`doctrine://default?queue_name=failed`), that is the `created_at` of its +row, and the old jobs are counted or deleted in one SQL statement however many there are. With +any other failure transport, every job is read and dated by its last `RedeliveryStamp`; a job +that carries none is kept. The default of `--older-than` is +`Thelia\Messenger\FailedMessagePurger::RETENTION_DAYS`. + The `thelia` schedule runs the command every day at 04:00 when a worker consumes `scheduler_thelia` (`THELIA_SCHEDULE_FAILED_JOBS_PURGE`, default `0 4 * * *`). A shop that runs its tasks from a crontab adds it there. From 839d35e15cde28da69b58867a46a71b6ae74dae2 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 15:34:58 +0200 Subject: [PATCH 11/52] docs: state when the queue allow list runs and the retention of background jobs --- docs/architecture/background-jobs.md | 13 ++++++++----- docs/security/personal-data.md | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index a7eaabbbd..3471a01b3 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -323,9 +323,12 @@ building anything: - a stamp must be a Messenger stamp or come from those same namespaces, and `SerializerStamp` is refused, as it would let the queue change how the rest of the envelope is read. -The check runs when a message is read and also when it is dispatched. A module dispatching a -class the workers would refuse gets a `LogicException` ("is not one the shop queues") at -dispatch time, not a queue that never empties. +The check runs when a message is read from a queue and when it is written to one. A module +dispatching to a real queue a class the workers would refuse gets a `LogicException` ("is not +one the shop queues") at dispatch time, not a queue that never empties. Without a queue +(`sync://`), the message is handled in the same call and never serialized, so the check does +not run: a message that no handler takes fails with Symfony's `NoHandlerForMessageException` +instead, also at dispatch time. A job already queued can still become unreadable: its class is refused by the check, or it can no longer be built because its module was turned off, its class was removed, or its content no @@ -338,8 +341,8 @@ original class, and it can be deleted there. For a module author, this means: - message classes live in the module namespace, -- each message class has a handler: a message that no handler takes is refused when it is - dispatched to a queue, +- each message class has a handler: a message that no handler takes fails at dispatch, with + a `LogicException` when it goes to a queue, with a `NoHandlerForMessageException` without one, - they can be written and read as JSON by the Symfony serializer: public or promoted constructor properties with scalar types, such as ids and codes, - they never carry a Propel model. The handler reads the row again from its id. diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index 3d2d38b83..f6c63feab 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -156,6 +156,27 @@ purge cannot hand a blocked IP address a fresh set of attempts. See [`maintenance:purge`](../reference/cli/maintenance_purge.md). +### Background jobs + +Exports and imports started from the back office, and jobs that failed in the queue, hold +personal data too: an import file can list customers, and a failed order mail keeps the address +of the customer and the content of the order. Their retention periods are fixed in the code. + +| Data | Purged by | Kept | +| --- | --- | --- | +| Export and import jobs | `maintenance:purge` | 7 days | +| Failed export and import jobs | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | +| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done; a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | +| Files left in `var/data-transfer/import` | `maintenance:purge` | 30 days | +| Failed jobs of the queue | `thelia:messenger:purge-failed` | 30 days, `--older-than` to change it | + +The 30-day sweep of `var/data-transfer/import` removes what no job row points to any more, such +as the extracted copy of an archive left by a worker that was killed, or a file whose row was +deleted by hand. The `thelia` schedule runs `thelia:messenger:purge-failed` every day at 04:00; +a shop that runs its tasks from a crontab adds it there. Record both retentions in the GDPR +register. See [Background jobs](../architecture/background-jobs.md#personal-data) and +[`thelia:messenger:purge-failed`](../reference/cli/thelia_messenger_purge_failed.md). + ## Adding a module purge to the same run `maintenance:purge` dispatches `TheliaEvents::MAINTENANCE_PURGE` at the end of its run. A @@ -188,4 +209,5 @@ To be explicit, so nobody promises it: - [Security policy](./security-policy.md) - [`customer:anonymize`](../reference/cli/customer_anonymize.md), [`customer:export-personal-data`](../reference/cli/customer_export_personal_data.md), - [`maintenance:purge`](../reference/cli/maintenance_purge.md) + [`maintenance:purge`](../reference/cli/maintenance_purge.md), + [`thelia:messenger:purge-failed`](../reference/cli/thelia_messenger_purge_failed.md) From c9e54ec26274d8ff9503c405453ac86e055388bb Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 15:39:10 +0200 Subject: [PATCH 12/52] docs: describe the set-aside look-again, the converted PHP warnings and the queue-less tests --- docs/architecture/background-jobs.md | 14 ++++++++++---- docs/reference/background-jobs.md | 15 +++++++++++---- docs/testing/index.md | 2 ++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 3471a01b3..106d9c249 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -210,11 +210,13 @@ A finished export is never run twice. A failed one is recorded on its row and go The reason recorded on the row is the one an administrator reads on the job page. A database, transport or PHP error quotes SQL, values and host names, so it is not shown: when a -`PDOException`, a Propel or DBAL exception, a Messenger `TransportException` or a PHP `\Error` +`PDOException`, a Propel or DBAL exception, a Messenger `TransportException`, a PHP `\Error`, +or a PHP warning or notice turned into an `\ErrorException` (any severity other than `E_ERROR`) is anywhere in the chain of the failure, the row says "The job failed because of a server error. The details are in the server log." (`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`) and the full reason goes to the log. Any other reason, such as an export with no data or a file -that cannot be read, is kept as it is, cut to 2000 characters. +that cannot be read, is kept as it is, cut to 2000 characters. An `\ErrorException` the core +throws with a message of its own keeps the default `E_ERROR` severity, so its message is kept. An import started from the back office is a job too. `Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file in the request @@ -261,8 +263,12 @@ A message that finds its job still `running`, held by another worker or left by killed less than an hour ago, is not dropped: it is dispatched again with a `DelayStamp` of 10 minutes (`JobLifecycle::POSTPONE_DELAY_SECONDS`, 600) and looks at the job again then, so a job whose worker died is taken again once its row turns stale. It does so at most 72 times -(`JobLifecycle::MAX_POSTPONEMENTS`), 12 hours, then logs a warning and stops. The count travels -in the message (`$postponements`). Without a queue the message is never postponed: the run that +(`JobLifecycle::MAX_POSTPONEMENTS`), 12 hours. After the last check it throws +`UnrecoverableMessageHandlingException` and is set aside in `failed`, where it is listed with +the other failed jobs; replaying it takes the job over once its worker has gone quiet. The count +travels in the message (`$postponements`). A postponed message never restarts a job that failed +in the meantime: only the original message, for instance replayed by an administrator from the +failed jobs, takes a failed job. Without a queue the message is never postponed: the run that holds the job finishes it. A job whose row no longer exists fails for good and stays in `failed`, rather than diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 45c8a3d85..e52958bf5 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -146,13 +146,15 @@ An administrator can launch 10 exports and imports in 10 minutes, the two counte that, the launch is refused with a message asking to wait a few minutes. The job page passes the reason of a failed job through the translator, so the fixed messages -below are shown in the language of the administrator. A database, transport or PHP error +below are shown in the language of the administrator. A database, transport or PHP error, or +a PHP warning or notice turned into an `\ErrorException` (severity other than `E_ERROR`), anywhere in the chain of the failure is shown as "The job failed because of a server error. The details are in the server log." (`Thelia\Messenger\JobFailureMessage::forAdministrator()`), and its text goes to the log; any other reason is kept, cut to 2000 characters. `JobLifecycle` dispatches a job, claims it and records its failure. A worker claims a job -atomically (`JobClaim::claim(string $table, int $jobId)`, an injected service) before running it: +atomically (`JobClaim::claim(string $table, int $jobId, bool $allowFailed = true)`, an injected +service) before running it: two workers handed the same job never run it at the same time. A job left `running` is taken again once its row has not been updated for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). An export updates it as it reports its progress and an import as it reads its rows, every 500 rows @@ -164,7 +166,11 @@ A message that finds its job still `running` is dispatched again with a `DelaySt 10 minutes (`JobLifecycle::POSTPONE_DELAY_SECONDS`, 600), at most 72 times (`JobLifecycle::MAX_POSTPONEMENTS`, 12 hours), each time with `$postponements` one higher. A job whose worker was killed is therefore taken again once its row is stale. After the last check -the message logs a warning and stops. Without a queue it is never postponed. +the message throws `UnrecoverableMessageHandlingException` and is set aside in `failed`; +replaying it from there takes the job over once its worker has gone quiet. `JobLifecycle` passes +`$allowFailed` only for the original message (`$postponements` at 0): a postponed message never +restarts a job that failed in the meantime, while a failed job replayed by an administrator is +taken again. Without a queue a message is never postponed. A job whose row was deleted fails for good and stays in `failed`. A job the queue refuses at dispatch is recorded as failed with "The job could not be queued. The details are in the @@ -176,7 +182,8 @@ replayed. the jobs older than 7 days (`DataTransferJobPurger::JOB_RETENTION_DAYS`) are deleted, failed ones after 30 days. The file of an import is deleted with its row only when it lies inside `var/data-transfer/import`. The files of that directory older than 30 days are removed too, -along with the directories they leave empty. +and its empty directories once they are more than a day old, since a fresh one may be about to +receive an upload. `ImportJobLauncher::launch()` checks the file in the request through `ImportHandler::validateUpload(string $fileName, ?File $file = null)`, so a wrong file is diff --git a/docs/testing/index.md b/docs/testing/index.md index 3cd80f454..ac5737cf6 100644 --- a/docs/testing/index.md +++ b/docs/testing/index.md @@ -277,6 +277,8 @@ It performs the following: It does not set templates, create an admin, or import demo data. It is the minimal setup a test run needs. +Tests always run without a queue: `tests/bootstrap.php` empties `MESSENGER_TRANSPORT_DSN` and `MESSENGER_HEAVY_TRANSPORT_DSN` after the `.env` files are read, so every background job runs at once, whatever queue the machine is configured with. + :::caution Stale Propel cache If tests suddenly hit the wrong database after switching branches or environments, delete `var/propel/test/` and re-run `composer test:prepare`. Step 5 above does this automatically, but a manual run is the quickest fix when a cache survives. ::: From cba7f285d9bdacfb51d68c06599f849f9c23602c Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 16:43:56 +0200 Subject: [PATCH 13/52] docs: describe the user-facing failures, the fresh replay, the archive limits and the purge by status --- docs/architecture/background-jobs.md | 74 +++++++++++++-------- docs/reference/background-jobs.md | 86 +++++++++++++++++-------- docs/reference/cli/maintenance_purge.md | 8 +-- docs/security/personal-data.md | 11 +++- 4 files changed, 119 insertions(+), 60 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 106d9c249..a979f8dda 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -84,7 +84,7 @@ also run one worker per transport. It opens a DBAL connection with the settings of the Propel connection, its PDO options and attributes included (TLS certificates, for instance), so the queue lives in the same database as the shop, with no extra service. Only the connection name `default` is -accepted. +accepted. The settings are read from Propel by `Thelia\Messenger\Transport\PdoDsnReader`. The `messenger_messages` table is created by the installer and by the 3.3.0 update script. The transport never creates it on the fly. @@ -208,15 +208,20 @@ the job page (`/admin/export/job/{id}`): waiting, running with the number of row A finished export is never run twice. A failed one is recorded on its row and goes straight to `failed`; replaying it starts it over. The export files are deleted after a day. -The reason recorded on the row is the one an administrator reads on the job page. A database, -transport or PHP error quotes SQL, values and host names, so it is not shown: when a -`PDOException`, a Propel or DBAL exception, a Messenger `TransportException`, a PHP `\Error`, -or a PHP warning or notice turned into an `\ErrorException` (any severity other than `E_ERROR`) -is anywhere in the chain of the failure, the row says "The job failed because of a server -error. The details are in the server log." (`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`) -and the full reason goes to the log. Any other reason, such as an export with no data or a file -that cannot be read, is kept as it is, cut to 2000 characters. An `\ErrorException` the core -throws with a message of its own keeps the default `E_ERROR` severity, so its message is kept. +The reason recorded on the row is the one an administrator reads on the job page. Only a +reason written for the administrator is shown: an exception implementing +`Thelia\Messenger\UserFacingFailure`, anywhere in the chain of the failure, gives its message, +cut to 2000 characters. The core ones are `Thelia\Form\Exception\FormValidationException` and, +in `Thelia\Domain\DataTransfer\Exception`, `DataTransferNoDataFoundException`, +`HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. Any other +failure may quote SQL, the values of a row, paths or host names, so the row says "The job failed +because of a server error. The details are in the server log." +(`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`). The error of the failed message set aside +in `failed` is the same text, so the Background jobs screen shows nothing more. The log line of +the job names such a failure by the class, the code and the file and line of its first cause +(`JobFailureMessage::forLog()`), never by a text that may hold the personal data of a customer. +A module whose exception carries a message meant for the administrator implements +`UserFacingFailure` on it. An import started from the back office is a job too. `Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file in the request @@ -236,8 +241,13 @@ catalog and the row never disagree. When the code that runs the import already h transaction, the import works inside it and leaves the commit or the rollback to that code. A row the import refuses (a missing combination, an invalid GTIN) is not an error: it is listed with the job as a refusal, and the other rows are kept. A refusal whose text is not valid UTF-8 -is stored with the invalid bytes replaced. An archive is extracted next to its file and the -extracted copy is removed once the import is over. +is stored with the invalid bytes replaced. + +An archive is looked into before it is extracted, both when it is uploaded and when it is +imported (`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more +than 1000 files, more than 512 MB once extracted, or a name that is absolute or contains `..`. +It is then extracted next to its file, and the extracted copy is removed once the import is +over, whatever came of it. A finished import is never run twice. A failed one goes to `failed`, and replaying it starts over from the first row. The uploaded file is deleted once the import @@ -245,10 +255,11 @@ is done, and kept while the job may be replayed. The path of the file is stored the project, and its name is cut to 100 characters. Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, -`running`, `done`, `failed`. Their messages, `RunExportJob` and `RunImportJob`, implement -`Thelia\Domain\DataTransfer\Job\DataTransferJobMessage`, and their handlers go through -`Thelia\Domain\DataTransfer\Job\JobLifecycle`, which dispatches a job, claims it and records -its failure. A worker claims a job atomically before running it +`running`, `done`, `failed`. Their models implement `Thelia\Domain\DataTransfer\Job\DataTransferJob` +and their messages, `RunExportJob` and `RunImportJob`, +`Thelia\Domain\DataTransfer\Job\DataTransferJobMessage`. The handlers go through +`Thelia\Domain\DataTransfer\Job\JobLifecycle`, which dispatches a job, claims it or postpones +it (`claimOrPostpone()`) and records its failure. A worker claims a job atomically before running it (`Thelia\Domain\DataTransfer\Job\JobClaim`, a service), so two workers handed the same job never run it at the same time. A job left `running` is taken again once it has given no sign of life for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the @@ -267,20 +278,27 @@ job whose worker died is taken again once its row turns stale. It does so at mos `UnrecoverableMessageHandlingException` and is set aside in `failed`, where it is listed with the other failed jobs; replaying it takes the job over once its worker has gone quiet. The count travels in the message (`$postponements`). A postponed message never restarts a job that failed -in the meantime: only the original message, for instance replayed by an administrator from the -failed jobs, takes a failed job. Without a queue the message is never postponed: the run that -holds the job finishes it. +in the meantime: only the original message takes a failed job. Without a queue the message is +never postponed: the run that holds the job finishes it. + +A replayed job starts afresh. The messages implement `Thelia\Messenger\Message\ReplayableJob`, +whose `forReplay()` gives the same message with no postponement counted. The back office +replays a failed job with it, and `Thelia\Messenger\Middleware\ReplayedJobMiddleware`, on the +default bus, does the same for a job replayed with `messenger:failed:retry`. A replay can +therefore restart a job that failed, which a look-again message never does. A job whose row no longer exists fails for good and stays in `failed`, rather than passing for done. A job the queue refuses at dispatch is recorded as failed with "The job could not be queued. The details are in the server log.", and for an import the uploaded file is deleted. -`maintenance:purge` deletes the export and import jobs older than 7 days -(`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger::JOB_RETENTION_DAYS`), with the files -of the imports. Failed jobs are kept 30 days (`Thelia\Messenger\FailedMessagePurger::RETENTION_DAYS`), -as long as the failed messages, so they can still be replayed. The file of an import is only -deleted when it lies inside `var/data-transfer/import`, whatever path its row holds. The purge +`maintenance:purge` deletes the export and import jobs that are `done` and were created more +than 7 days ago (`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger::JOB_RETENTION_DAYS`), +with the files of the imports. Any other job, failed, queued or running, is kept 30 days +(`Thelia\Messenger\FailedMessagePurger::RETENTION_DAYS`), as long as the failed messages, so it +can still be replayed. The file of an import is only deleted when it lies inside +`var/data-transfer/import` (`Thelia\Domain\DataTransfer\Job\ImportStorage::DIRECTORY`), +whatever path its row holds. The purge also removes the files of that directory older than 30 days, such as the extracted copy of an archive left by a killed worker, or a file whose row was deleted by hand. @@ -306,8 +324,10 @@ A task of the schedule that fails does not go to `failed`: Messenger only sets a from a queue, and the task runs again at its next time anyway. `Thelia\Scheduler\EventListener\RecurringTaskFailureListener` logs the failure and keeps the last one of each task (`Thelia\Scheduler\RecurringTaskFailures`, in the `cache.app` pool) until -a run of that task goes through. The Background jobs screen lists them under "Recurring tasks -that failed", the reason following the same rule as the reason of a failed export or import. +a run of that task goes through, or for a month at most, so a task taken off the schedule does +not stay listed forever. The Background jobs screen lists them under "Recurring tasks that +failed". A command that exits with an error code shows `Command "" exited with code "".`; any +other failure follows the same rule as the reason of a failed export or import. ## Which classes a queue accepts @@ -325,7 +345,7 @@ building anything: one of its parents or interfaces. The handled classes are collected when the container is built (`Thelia\Core\DependencyInjection\Compiler\HandledMessageClassesPass`, parameter `thelia.messenger.handled_message_classes`), so no other class of those namespaces is ever - built from a queue, + built from a queue. A handler that takes any message (`*` or `object`) does not count, - a stamp must be a Messenger stamp or come from those same namespaces, and `SerializerStamp` is refused, as it would let the queue change how the rest of the envelope is read. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index e52958bf5..221f0c288 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -46,7 +46,7 @@ default) lists the exact names of the message classes that a project queues from `Thelia\` and the namespaces of the active modules. Any other message class must also be taken by a handler, or have a parent or an interface a handler takes: the parameter `thelia.messenger.handled_message_classes` is filled when the container is built and is not -meant to be set by hand. +meant to be set by hand. A handler that takes any message (`*` or `object`) does not count. ### Transports @@ -91,7 +91,10 @@ transports have the same DSN. When `failed` is in the shop database, the default read in SQL, so the newest failures show however many there are. Each failed job can be replayed or deleted. Replay takes the job out of `failed` first, then -puts it back on the transport it failed on; if that fails, the job is set aside again. A double +puts it back on the transport it failed on; if that fails, the job is set aside again. A job +that implements `Thelia\Messenger\Message\ReplayableJob` is put back as `forReplay()` returns +it, so it starts afresh; `Thelia\Messenger\Middleware\ReplayedJobMiddleware`, on the default +bus, does the same for `messenger:failed:retry`. A double click cannot send it twice. Without a queue, a replayed job runs at once, and a job that fails again stays in the failed list. A replay that fails shows "The job failed again. The details are in the server log." and the reason goes to the log. If setting it aside fails too (queue and @@ -107,7 +110,9 @@ know what it was. A recurring task of the `thelia` schedule that fails is not set aside in `failed`: it runs again at its next time. `Thelia\Scheduler\EventListener\RecurringTaskFailureListener` keeps its last failure in the `cache.app` pool (`Thelia\Scheduler\RecurringTaskFailures`) until a run of the -task goes through, and the screen lists it with the task, the reason and the date. +task goes through, or for a month at most, and the screen lists it with the task, the reason and +the date. A command that exits with an error code gives `Command "" exited with code +"".` as its reason. The "Details" link of an export or import is only shown to the administrator who started the job and to super-administrators. @@ -131,28 +136,52 @@ Exports and imports started from the back office are jobs. Both use the status e | With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | | Replay of a failed job | Starts the export over | Starts over from the first row | | Files | Deleted after a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | -| `maintenance:purge` | Deletes the jobs older than 7 days, failed ones after 30 days | Deletes the jobs older than 7 days and their files, failed ones after 30 days | +| `maintenance:purge` | Deletes the done jobs older than 7 days, any other after 30 days | Deletes the done jobs older than 7 days, any other after 30 days, with their files | | Right to launch | `VIEW` on the export resource | `UPDATE` on the import resource | | Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | The classes live in `Thelia\Domain\DataTransfer\Job\`. Both messages implement -`DataTransferJobMessage` and `Thelia\Messenger\Message\DescribedJob` (they are listed as -`Export #` and `Import #` among the failed jobs). Both job pages refresh every 3 seconds, +`DataTransferJobMessage`, `Thelia\Messenger\Message\ReplayableJob` and +`Thelia\Messenger\Message\DescribedJob` (they are listed as `Export #` and `Import #` +among the failed jobs), and both models (`ExportJob`, `ImportJob`) implement `DataTransferJob`. Both job pages refresh every 3 seconds, and a finished job is never run twice. Other administrators get a 403 on a job page, even with the export or import right. An administrator can launch 10 exports and imports in 10 minutes, the two counted together -(rate limiter `admin_data_transfer_launch`, declared in the core `framework` configuration). Past -that, the launch is refused with a message asking to wait a few minutes. +(rate limiter `admin_data_transfer_launch`, declared in the core `framework` configuration). A +launch is only counted once its form is valid: a form sent without a file, with a file the +server did not receive whole, or with an invalid option is not counted. +Past the limit, the launch is refused with a message asking to wait a few minutes. + +Without a queue, an import that refused more than 10 rows leads to its job page, which lists +them all; with fewer, the form page shows them in its message. The description of an export or +an import, written in HTML by its module, is shown with scripts, event attributes and +`javascript:` links removed. + +The reason of a failed job is a whitelist (`Thelia\Messenger\JobFailureMessage::forAdministrator()`). +An exception implementing `Thelia\Messenger\UserFacingFailure`, anywhere in the chain of the +failure, gives its message, cut to 2000 characters: `Thelia\Form\Exception\FormValidationException` +and, in `Thelia\Domain\DataTransfer\Exception`, `DataTransferNoDataFoundException`, +`HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. Any other +failure is shown as "The job failed because of a server error. The details are in the server +log." (`JobFailureMessage::SERVER_ERROR`), both on the job row and as the error of the failed +job on the Background jobs screen, and the error line the job writes to the log names it by +class, code and file:line (`JobFailureMessage::forLog()`). The job page +passes the reason through the translator, so the fixed messages are shown in the language of +the administrator. + +A module whose export or import throws an exception meant for the administrator implements +the interface on it: -The job page passes the reason of a failed job through the translator, so the fixed messages -below are shown in the language of the administrator. A database, transport or PHP error, or -a PHP warning or notice turned into an `\ErrorException` (severity other than `E_ERROR`), -anywhere in the chain of the failure is shown as "The job failed because of a server error. -The details are in the server log." (`Thelia\Messenger\JobFailureMessage::forAdministrator()`), -and its text goes to the log; any other reason is kept, cut to 2000 characters. +```php +final class WarehouseRefusedException extends \RuntimeException implements \Thelia\Messenger\UserFacingFailure +{ +} +``` -`JobLifecycle` dispatches a job, claims it and records its failure. A worker claims a job +`JobLifecycle` dispatches a job, claims it or postpones it (`claimOrPostpone()`) and records its +failure; it reads from `Thelia\Messenger\Transport\ConfiguredQueues` whether the heavy jobs run +without a queue (`heavyJobsRunInline()`). A worker claims a job atomically (`JobClaim::claim(string $table, int $jobId, bool $allowFailed = true)`, an injected service) before running it: two workers handed the same job never run it at the same time. A job left `running` is taken @@ -169,19 +198,20 @@ whose worker was killed is therefore taken again once its row is stale. After th the message throws `UnrecoverableMessageHandlingException` and is set aside in `failed`; replaying it from there takes the job over once its worker has gone quiet. `JobLifecycle` passes `$allowFailed` only for the original message (`$postponements` at 0): a postponed message never -restarts a job that failed in the meantime, while a failed job replayed by an administrator is -taken again. Without a queue a message is never postponed. +restarts a job that failed in the meantime, while a replayed job, which starts again at 0, takes +a failed job. Without a queue a message is never postponed. A job whose row was deleted fails for good and stays in `failed`. A job the queue refuses at dispatch is recorded as failed with "The job could not be queued. The details are in the -server log.", and the file of such an import is deleted. Failed jobs are kept 30 days -(`FailedMessagePurger::RETENTION_DAYS`), as long as the failed messages, so they can still be -replayed. +server log." (`JobLifecycle::NOT_QUEUED`), and the file of such an import is deleted. Failed +jobs are kept 30 days (`FailedMessagePurger::RETENTION_DAYS`), as long as the failed messages, +so they can still be replayed. `maintenance:purge` delegates to `Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: -the jobs older than 7 days (`DataTransferJobPurger::JOB_RETENTION_DAYS`) are deleted, failed -ones after 30 days. The file of an import is deleted with its row only when it lies inside -`var/data-transfer/import`. The files of that directory older than 30 days are removed too, +the `done` jobs created more than 7 days ago (`DataTransferJobPurger::JOB_RETENTION_DAYS`) are +deleted, any other (failed, queued, running) after 30 days. The file of an import is deleted +with its row only when it lies inside `var/data-transfer/import` +(`Thelia\Domain\DataTransfer\Job\ImportStorage`, `ImportStorage::DIRECTORY`). The files of that directory older than 30 days are removed too, and its empty directories once they are more than a day old, since a fresh one may be about to receive an upload. @@ -189,8 +219,12 @@ receive an upload. `ImportHandler::validateUpload(string $fileName, ?File $file = null)`, so a wrong file is refused before anything is queued. The extension must be one the import handlers accept, and, when the file is given, its content is read with `finfo`: an archive must match the type of its -archiver, anything else must be text. The file is moved out of the upload directory to -`var/data-transfer/import/`, not to the cache, which a deployment empties. Its path is stored +archiver, anything else must be text. An archive is also looked into +(`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more than 1000 +files (`MAX_ENTRIES`), more than 512 MB once extracted (`MAX_EXTRACTED_BYTES`), or a name that +is absolute or contains `..`. `ImportHandler::import()` checks it again before extracting it. +The file is moved out of the upload directory to `var/data-transfer/import/` +(`ImportStorage::DIRECTORY`), not to the cache, which a deployment empties. Its path is stored relative to the project, and its name is cut to 100 characters. An import runs in a single Propel transaction: stopped half way (an error, a killed worker, a @@ -200,7 +234,7 @@ The handler opens and commits the transaction only when the caller holds none; o caller commits or rolls back. A row the import refuses (a missing combination, an invalid GTIN) is listed with the job as a refusal, and the other rows are kept; a refusal that is not valid UTF-8 is stored with the invalid bytes replaced. `ImportHandler::import()` removes the extracted -copy of an archive once the import is over. The sign of life of an import goes through a second database +copy of an archive once the import is over, whether it succeeded or not. The sign of life of an import goes through a second database connection (`JobHeartbeat`), so a long import is never taken from its worker. ## Recurring tasks diff --git a/docs/reference/cli/maintenance_purge.md b/docs/reference/cli/maintenance_purge.md index 458dcb505..79f7a8290 100644 --- a/docs/reference/cli/maintenance_purge.md +++ b/docs/reference/cli/maintenance_purge.md @@ -39,10 +39,10 @@ listens to it, purges its own tables, and appends a line to the report with `$event->addResult()`. The core listens to the same event to purge the back-office exports and imports, through -`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: the jobs older than 7 days, the -failed ones after 30 days (as long as the failed jobs they can be replayed from), the uploaded -file of each deleted import when it lies inside `var/data-transfer/import`, the files of that -directory older than 30 days. The same listener removes the export files of the cache older +`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: the done jobs older than 7 days, +any other (failed, queued, running) after 30 days, as long as the failed jobs they can be +replayed from, the uploaded file of each deleted import when it lies inside +`var/data-transfer/import`, and the files of that directory older than 30 days. The same listener removes the export files of the cache older than a day. These periods are fixed. See [Background jobs](../background-jobs.md#back-office-exports-and-imports). ## Examples diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index f6c63feab..a2f513711 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -164,8 +164,8 @@ of the customer and the content of the order. Their retention periods are fixed | Data | Purged by | Kept | | --- | --- | --- | -| Export and import jobs | `maintenance:purge` | 7 days | -| Failed export and import jobs | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | +| Export and import jobs that are done | `maintenance:purge` | 7 days | +| Other export and import jobs (failed, queued, running) | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | | Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done; a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | | Files left in `var/data-transfer/import` | `maintenance:purge` | 30 days | | Failed jobs of the queue | `thelia:messenger:purge-failed` | 30 days, `--older-than` to change it | @@ -174,7 +174,12 @@ The 30-day sweep of `var/data-transfer/import` removes what no job row points to as the extracted copy of an archive left by a worker that was killed, or a file whose row was deleted by hand. The `thelia` schedule runs `thelia:messenger:purge-failed` every day at 04:00; a shop that runs its tasks from a crontab adds it there. Record both retentions in the GDPR -register. See [Background jobs](../architecture/background-jobs.md#personal-data) and +register. + +The reason an administrator reads of a failed export or import, on its job page and on the +Background jobs screen, does not quote the data: unless the exception was written for the +administrator, it reads as a server error, and the error line Thelia writes to the log names it +by its class, code and place in the code rather than by its message. See [Background jobs](../architecture/background-jobs.md#personal-data) and [`thelia:messenger:purge-failed`](../reference/cli/thelia_messenger_purge_failed.md). ## Adding a module purge to the same run From bf683f28a5b88abc13232a32233fde9558574aac Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Tue, 6 Oct 2026 16:51:49 +0200 Subject: [PATCH 14/52] docs: state that a failed export or import keeps only its sanitized reason --- docs/architecture/background-jobs.md | 5 +++-- docs/security/personal-data.md | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index a979f8dda..dbfe68ec8 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -216,8 +216,9 @@ in `Thelia\Domain\DataTransfer\Exception`, `DataTransferNoDataFoundException`, `HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. Any other failure may quote SQL, the values of a row, paths or host names, so the row says "The job failed because of a server error. The details are in the server log." -(`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`). The error of the failed message set aside -in `failed` is the same text, so the Background jobs screen shows nothing more. The log line of +(`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`). The failed message set aside in `failed` +stores only that text, without the original exception, so neither the Background jobs screen +nor `messenger:failed:show` shows anything more. The log line of the job names such a failure by the class, the code and the file and line of its first cause (`JobFailureMessage::forLog()`), never by a text that may hold the personal data of a customer. A module whose exception carries a message meant for the administrator implements diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index a2f513711..c457b406a 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -179,7 +179,10 @@ register. The reason an administrator reads of a failed export or import, on its job page and on the Background jobs screen, does not quote the data: unless the exception was written for the administrator, it reads as a server error, and the error line Thelia writes to the log names it -by its class, code and place in the code rather than by its message. See [Background jobs](../architecture/background-jobs.md#personal-data) and +by its class, code and place in the code rather than by its message. The failed job kept in +`failed` stores only that text: the original exception is not chained to it, since Symfony +keeps the whole chain of an exception it sets aside. This holds for the exports and imports of +the back office; a failed mail or module job keeps the exception its handler threw. See [Background jobs](../architecture/background-jobs.md#personal-data) and [`thelia:messenger:purge-failed`](../reference/cli/thelia_messenger_purge_failed.md). ## Adding a module purge to the same run From ac6755cc1ff8355408278319030adc299647cb1e Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Wed, 7 Oct 2026 10:01:15 +0200 Subject: [PATCH 15/52] docs: describe the upload refusals, the rejected messages and the streamed archive checks --- docs/architecture/background-jobs.md | 39 +++++++++++++------- docs/reference/background-jobs.md | 55 ++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index dbfe68ec8..fa299a71d 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -210,19 +210,26 @@ A finished export is never run twice. A failed one is recorded on its row and go The reason recorded on the row is the one an administrator reads on the job page. Only a reason written for the administrator is shown: an exception implementing -`Thelia\Messenger\UserFacingFailure`, anywhere in the chain of the failure, gives its message, -cut to 2000 characters. The core ones are `Thelia\Form\Exception\FormValidationException` and, -in `Thelia\Domain\DataTransfer\Exception`, `DataTransferNoDataFoundException`, -`HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. Any other +`Thelia\Exception\UserFacingFailure`, anywhere in the chain of the failure, gives its message, +cut to 2000 characters. The core ones are, in `Thelia\Domain\DataTransfer\Exception`, +`UploadRefusedException` (the refusals of an uploaded file, a `FormValidationException`), +`DataTransferNoDataFoundException`, `HandlerUnavailableException`, `MissingColumnsException` +and `JobRefusedException`. A `FormValidationException` that does not implement the interface +is not shown. Any other failure may quote SQL, the values of a row, paths or host names, so the row says "The job failed because of a server error. The details are in the server log." (`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`). The failed message set aside in `failed` stores only that text, without the original exception, so neither the Background jobs screen nor `messenger:failed:show` shows anything more. The log line of the job names such a failure by the class, the code and the file and line of its first cause -(`JobFailureMessage::forLog()`), never by a text that may hold the personal data of a customer. -A module whose exception carries a message meant for the administrator implements -`UserFacingFailure` on it. +(`JobFailureMessage::forLog()`), never by a text that may hold the personal data of a customer; +a reason written for the administrator is logged on one line. A module whose exception carries +a message meant for the administrator implements `UserFacingFailure` on it. + +A message that fails before its job is taken (its row cannot be read, the claim fails, or the +queue refuses the look-again message) is set aside through `JobLifecycle::reject()`, with the +same sanitized text only. Its row is left as it is: it may be unreadable, or held by another +run. An import started from the back office is a job too. `Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file in the request @@ -246,9 +253,13 @@ is stored with the invalid bytes replaced. An archive is looked into before it is extracted, both when it is uploaded and when it is imported (`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more -than 1000 files, more than 512 MB once extracted, or a name that is absolute or contains `..`. -It is then extracted next to its file, and the extracted copy is removed once the import is -over, whatever came of it. +than 1000 files, more than 512 MB once extracted, a name that is absolute or contains `..`, or a +link. A zip is read through its directory; a tar, compressed or not, is read header by header +through its compression, never held in memory. It is then extracted next to its file, what the +extraction really wrote is measured against the same 512 MB, and the extracted copy is removed +once the import is over, whatever came of it. A file that does not parse in its format is +refused with a message asking to check its content. The import only reads a file that lies in +the import storage. A finished import is never run twice. A failed one goes to `failed`, and replaying it starts over from the first row. The uploaded file is deleted once the import @@ -260,7 +271,8 @@ Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `que and their messages, `RunExportJob` and `RunImportJob`, `Thelia\Domain\DataTransfer\Job\DataTransferJobMessage`. The handlers go through `Thelia\Domain\DataTransfer\Job\JobLifecycle`, which dispatches a job, claims it or postpones -it (`claimOrPostpone()`) and records its failure. A worker claims a job atomically before running it +it (`claimOrPostpone()`, which returns `ClaimOutcome::Owned`, `Finished` or `Postponed`) and +records its failure. A worker claims a job atomically before running it (`Thelia\Domain\DataTransfer\Job\JobClaim`, a service), so two workers handed the same job never run it at the same time. A job left `running` is taken again once it has given no sign of life for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the @@ -325,8 +337,9 @@ A task of the schedule that fails does not go to `failed`: Messenger only sets a from a queue, and the task runs again at its next time anyway. `Thelia\Scheduler\EventListener\RecurringTaskFailureListener` logs the failure and keeps the last one of each task (`Thelia\Scheduler\RecurringTaskFailures`, in the `cache.app` pool) until -a run of that task goes through, or for a month at most, so a task taken off the schedule does -not stay listed forever. The Background jobs screen lists them under "Recurring tasks that +a run of that task goes through, or for a month after the date of that failure, so a task +taken off the schedule does not stay listed forever. The list is changed under a lock, as two +workers may finish tasks at the same moment. The Background jobs screen lists them under "Recurring tasks that failed". A command that exits with an error code shows `Command "" exited with code "".`; any other failure follows the same rule as the reason of a failed export or import. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 221f0c288..be643b6af 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -110,7 +110,7 @@ know what it was. A recurring task of the `thelia` schedule that fails is not set aside in `failed`: it runs again at its next time. `Thelia\Scheduler\EventListener\RecurringTaskFailureListener` keeps its last failure in the `cache.app` pool (`Thelia\Scheduler\RecurringTaskFailures`) until a run of the -task goes through, or for a month at most, and the screen lists it with the task, the reason and +task goes through, or for a month after the date of that failure, and the screen lists it with the task, the reason and the date. A command that exits with an error code gives `Command "" exited with code "".` as its reason. @@ -150,23 +150,32 @@ the export or import right. An administrator can launch 10 exports and imports in 10 minutes, the two counted together (rate limiter `admin_data_transfer_launch`, declared in the core `framework` configuration). A launch is only counted once its form is valid: a form sent without a file, with a file the -server did not receive whole, or with an invalid option is not counted. +server did not receive whole, with a file whose name or content the import refuses, or with an +invalid option is not counted. Past the limit, the launch is refused with a message asking to wait a few minutes. Without a queue, an import that refused more than 10 rows leads to its job page, which lists them all; with fewer, the form page shows them in its message. The description of an export or -an import, written in HTML by its module, is shown with scripts, event attributes and -`javascript:` links removed. +an import, written in HTML by its module, keeps only `p`, `br`, `strong`, `b`, `em`, `i`, `u`, +`ul`, `ol`, `li`, `code`, `pre`, `blockquote`, `span` and `a` (with `href` and `title`, and only +`http`, `https` and `mailto` links). Anything else, scripts, styles, media, `id` and `name` +attributes included, is removed. The reason of a failed job is a whitelist (`Thelia\Messenger\JobFailureMessage::forAdministrator()`). -An exception implementing `Thelia\Messenger\UserFacingFailure`, anywhere in the chain of the -failure, gives its message, cut to 2000 characters: `Thelia\Form\Exception\FormValidationException` -and, in `Thelia\Domain\DataTransfer\Exception`, `DataTransferNoDataFoundException`, -`HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. Any other +An exception implementing `Thelia\Exception\UserFacingFailure`, anywhere in the chain of the +failure, gives its message, cut to 2000 characters. The core ones live in +`Thelia\Domain\DataTransfer\Exception`: `UploadRefusedException` (a `FormValidationException` +raised when an uploaded file is refused), `DataTransferNoDataFoundException`, +`HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. +`Thelia\Form\Exception\FormValidationException` itself does not implement it. An export whose year or month is +malformed (`ExportHandler::resolveRangeDate()`, called by the launcher and by the export) or +whose format is no longer available on the server is refused with a `JobRefusedException`, +which the administrator reads as it is. Any other failure is shown as "The job failed because of a server error. The details are in the server log." (`JobFailureMessage::SERVER_ERROR`), both on the job row and as the error of the failed job on the Background jobs screen, and the error line the job writes to the log names it by -class, code and file:line (`JobFailureMessage::forLog()`). The job page +class, code and file:line (`JobFailureMessage::forLog()`), or by its reason, on one line, when +it was written for the administrator. The job page passes the reason through the translator, so the fixed messages are shown in the language of the administrator. @@ -174,13 +183,15 @@ A module whose export or import throws an exception meant for the administrator the interface on it: ```php -final class WarehouseRefusedException extends \RuntimeException implements \Thelia\Messenger\UserFacingFailure +final class WarehouseRefusedException extends \RuntimeException implements \Thelia\Exception\UserFacingFailure { } ``` -`JobLifecycle` dispatches a job, claims it or postpones it (`claimOrPostpone()`) and records its -failure; it reads from `Thelia\Messenger\Transport\ConfiguredQueues` whether the heavy jobs run +`JobLifecycle` dispatches a job, claims it or postpones it +(`claimOrPostpone(DataTransferJob $job, DataTransferJobMessage $message): ClaimOutcome`, the table +coming from `DataTransferJob::tableName()`), sets aside a message that fails before its job is +taken (`reject()`) and records the failure of a job (`fail()`); it reads from `Thelia\Messenger\Transport\ConfiguredQueues` whether the heavy jobs run without a queue (`heavyJobsRunInline()`). A worker claims a job atomically (`JobClaim::claim(string $table, int $jobId, bool $allowFailed = true)`, an injected service) before running it: @@ -194,14 +205,19 @@ working keeps its worker. A message that finds its job still `running` is dispatched again with a `DelayStamp` of 10 minutes (`JobLifecycle::POSTPONE_DELAY_SECONDS`, 600), at most 72 times (`JobLifecycle::MAX_POSTPONEMENTS`, 12 hours), each time with `$postponements` one higher. A job -whose worker was killed is therefore taken again once its row is stale. After the last check +whose worker was killed is therefore taken again once its row is stale. `ClaimOutcome` tells +the handler what happened: `Owned` (it runs the job), `Finished` (the job is over, or no queue +can look at it again) or `Postponed`. After the last check the message throws `UnrecoverableMessageHandlingException` and is set aside in `failed`; replaying it from there takes the job over once its worker has gone quiet. `JobLifecycle` passes `$allowFailed` only for the original message (`$postponements` at 0): a postponed message never restarts a job that failed in the meantime, while a replayed job, which starts again at 0, takes a failed job. Without a queue a message is never postponed. -A job whose row was deleted fails for good and stays in `failed`. A job the queue refuses at +A job whose row was deleted fails for good and stays in `failed`. A message that fails before +its job is taken (the row cannot be read, the claim fails, the look-again message is refused) +goes to `failed` through `JobLifecycle::reject()` with the sanitized reason only, and its row is +left untouched. A job the queue refuses at dispatch is recorded as failed with "The job could not be queued. The details are in the server log." (`JobLifecycle::NOT_QUEUED`), and the file of such an import is deleted. Failed jobs are kept 30 days (`FailedMessagePurger::RETENTION_DAYS`), as long as the failed messages, @@ -211,7 +227,8 @@ so they can still be replayed. the `done` jobs created more than 7 days ago (`DataTransferJobPurger::JOB_RETENTION_DAYS`) are deleted, any other (failed, queued, running) after 30 days. The file of an import is deleted with its row only when it lies inside `var/data-transfer/import` -(`Thelia\Domain\DataTransfer\Job\ImportStorage`, `ImportStorage::DIRECTORY`). The files of that directory older than 30 days are removed too, +(`Thelia\Domain\DataTransfer\Job\ImportStorage`, `ImportStorage::DIRECTORY`). The files of that +directory older than 30 days are removed too, and its empty directories once they are more than a day old, since a fresh one may be about to receive an upload. @@ -222,7 +239,13 @@ when the file is given, its content is read with `finfo`: an archive must match archiver, anything else must be text. An archive is also looked into (`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more than 1000 files (`MAX_ENTRIES`), more than 512 MB once extracted (`MAX_EXTRACTED_BYTES`), or a name that -is absolute or contains `..`. `ImportHandler::import()` checks it again before extracting it. +is absolute or contains `..`, or a link entry, zip and tar alike. A tar, gzip or bzip2 archive +is read header by header through `compress.zlib://` or `compress.bzip2://`, never held in +memory and never copied. `ImportHandler::import()` checks it again before extracting it, then +measures what the extraction wrote (`ArchiveInspector::assertExtractedSize()`). +`ImportHandler` takes the inspector as an optional constructor argument. A file that does not +parse in its format is refused with an `UploadRefusedException` asking to check its content, +and the import handler only reads a file that lies inside `ImportStorage`. The file is moved out of the upload directory to `var/data-transfer/import/` (`ImportStorage::DIRECTORY`), not to the cache, which a deployment empties. Its path is stored relative to the project, and its name is cut to 100 characters. From 206147a54d5cbfce65a39e283fa75c4efaf43e88 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Wed, 7 Oct 2026 12:03:52 +0200 Subject: [PATCH 16/52] docs: describe queued mail checks, sanitized worker logs, bounded import refusals and the 3.3 update notes --- docs/architecture/background-jobs.md | 73 +++++++++++++++++++--- docs/getting-started/background-workers.md | 15 ++++- docs/reference/background-jobs.md | 61 +++++++++++++----- docs/security/personal-data.md | 11 +++- docs/upgrading/update.md | 25 ++++++++ 5 files changed, 159 insertions(+), 26 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index fa299a71d..cb739c8a7 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -74,7 +74,9 @@ waiting on `async`. Its DSN is derived from `MESSENGER_TRANSPORT_DSN` by | `redis://host:port//...` | The stream `_heavy`, or `messages_heavy` when the DSN names no stream | | anything else | The same DSN: both transports share one queue | -`MESSENGER_HEAVY_TRANSPORT_DSN` overrides the derived value. A worker started with +`MESSENGER_HEAVY_TRANSPORT_DSN` overrides the derived value. On AMQP, or any queue other than +Doctrine and Redis, set it: sharing the queue of `async`, the exports and imports are read by +the worker of `async` and tried again three times like its jobs. A worker started with `messenger:consume async async_heavy` takes the mails first, then the heavy jobs; a shop can also run one worker per transport. @@ -142,6 +144,17 @@ and the message goes to `failed` at once. whatever queue `async` uses, so the back office has a single place to read them from. They stay there until someone replays them, removes them, or the purge task deletes them. +Messenger writes the exception of a failed or retried job to the log of the workers, and a +database error quotes the values of a row. `Thelia\Messenger\Log\FailedJobLogProcessor`, on +the `messenger` channel, takes the exception out of these log lines and names it by its class, +code and place instead (`JobFailureMessage::forLog()`). The back office shows the reason of a +failed job only when it was written for the administrator (an exception implementing +`Thelia\Exception\UserFacingFailure`, such as `Thelia\Messenger\JobSetAsideException`, which +the core throws to set a job aside), or when it is the answer of the mail server, with the +credentials of any URL in it hidden (`Thelia\Mailer\TransportCredentials`, which hides the +whole password even when it holds an `@`). Either is cut to 2000 characters; anything else +reads as a server error. + Because a failed job can be replayed, and because a retry runs a handler that may have done part of its work, a handler must be idempotent: run twice, its effect happens once. The reference shows how in [Idempotence and replay](../reference/background-jobs.md#idempotence-and-replay). @@ -194,7 +207,24 @@ receives them. With a worker, the line appears when the worker delivered the mai author is `system`. With a queue, `MailerFactory::send*OrFail()` only throws when the mail could not be built or -queued. A delivery failure happens later, in the worker. +queued. A delivery failure happens later, in the worker. `MailerFactory::sendNow()` hands a mail +to the mail server at once, queue or not: the back office uses it to test the mail +configuration, whose only point is the answer of the server. + +A queued mail holds everything it is sent with, and names no file of the server +(`Thelia\Messenger\Serializer\QueuedMailFiles`): + +- an attachment given by its path (`attachFromPath()`) is read when the mail is queued and + stored in the queue with its content. It is held in the memory of the request, and a large + one is bounded by the `max_allowed_packet` of the database, +- a mail is read back from the queue only when its class is exactly `RawMessage`, `Message` or + `Email`, or a `TemplatedEmail` or `NotificationEmail` already rendered, and when every part is + one Symfony builds a mail with. A part that names a file, in the mail or in a mail it carries + (`MessagePart`), a part of another class, or a part that throws when it is looked at, makes + the job an `UndecodableJob`, never sent, +- a mail that would be refused is refused when it is queued, with a `LogicException`. Symfony + queues a `TemplatedEmail` before rendering it, and the worker renders nothing: render it + first (`BodyRenderer`), or send it without a queue. ### Back-office exports and imports @@ -249,13 +279,25 @@ catalog and the row never disagree. When the code that runs the import already h transaction, the import works inside it and leaves the commit or the rollback to that code. A row the import refuses (a missing combination, an invalid GTIN) is not an error: it is listed with the job as a refusal, and the other rows are kept. A refusal whose text is not valid UTF-8 -is stored with the invalid bytes replaced. +is stored with the invalid bytes replaced. The refusals are kept up to 60,000 bytes +(`ImportJob::ROW_ERRORS_MAX_BYTES`): past that, the first ones are kept and a last line says +` more rows were refused.` + +A row whose save fails inside the transaction of the import leaves the transaction unable to +commit, even when a module catches the failure. The import stops at that row with +`Row could not be saved: nothing was imported.` rather than failing as a server error at the end; +the stock import checks a GTIN and a part number before saving for that reason. A stock or a +price must be a number, given as text or as a number, below 10^10 in absolute value: a list, an +object, a missing price or anything else refuses the row with its reason, and the price is +checked before anything is written for the row. An archive is looked into before it is extracted, both when it is uploaded and when it is imported (`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more than 1000 files, more than 512 MB once extracted, a name that is absolute or contains `..`, or a link. A zip is read through its directory; a tar, compressed or not, is read header by header -through its compression, never held in memory. It is then extracted next to its file, what the +through its compression, never held in memory. The GNU long-name and pax records of a tar +count against the limits like any entry, a pax record is read record by record for the name it +gives, and a header record over 64 KB is refused. It is then extracted next to its file, what the extraction really wrote is measured against the same 512 MB, and the extracted copy is removed once the import is over, whatever came of it. A file that does not parse in its format is refused with a message asking to check its content. The import only reads a file that lies in @@ -263,7 +305,10 @@ the import storage. A finished import is never run twice. A failed one goes to `failed`, and replaying it starts over from the first row. The uploaded file is deleted once the import -is done, and kept while the job may be replayed. The path of the file is stored relative to +is done, and kept while the job may be replayed; without a queue a failed job can never be +replayed, so its file is deleted at once. A file that cannot be deleted is left to the purge +and logged as a warning: the job is still reported as it ended. A failure the job row cannot +record (the database gone) is logged, and the message is set aside in `failed` all the same. The path of the file is stored relative to the project, and its name is cut to 100 characters. Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, @@ -317,7 +362,11 @@ archive left by a killed worker, or a file whose row was deleted by hand. The page of a job, and the download of an export, are only open to the administrator who started it and to super-administrators (administrators without a profile). Other -administrators get a 403, even with the export or import right. +administrators get a 403, even with the export or import right, and the lists of recent +exports and imports only show their own jobs. Launching an import takes the `UPDATE` right on +`admin.import`; `VIEW` only shows the imports and their jobs. A job page refreshes itself +every 3 seconds, at most 100 times, then stops and offers a link to refresh it by hand; a "Stop +refreshing" link stops it earlier. An export or an import is one job for one file. It is not split into smaller jobs: a file is written by a single writer, and an import has to be atomic. Splitting work into smaller jobs @@ -363,6 +412,10 @@ building anything: - a stamp must be a Messenger stamp or come from those same namespaces, and `SerializerStamp` is refused, as it would let the queue change how the rest of the envelope is read. +Whether a message class belongs to an active module is read from the database each time. When +MySQL closed the connection of the worker during a long wait, the Propel connection is opened +again once and the module is read again. + The check runs when a message is read from a queue and when it is written to one. A module dispatching to a real queue a class the workers would refuse gets a `LogicException` ("is not one the shop queues") at dispatch time, not a queue that never empties. Without a queue @@ -374,8 +427,12 @@ A job already queued can still become unreadable: its class is refused by the ch no longer be built because its module was turned off, its class was removed, or its content no longer fits the class. The worker then reads it as `Thelia\Messenger\Message\UndecodableJob`, which keeps the original class (`originalType`), the reason and the original body, instead of -letting it be dropped. Its handler throws `UnrecoverableMessageHandlingException`, so the job -lands in `failed`. The Background jobs screen lists it as `Unreadable job: ` with its +letting it be dropped. Whatever the inner serializer throws while building the message, the +reason is a fixed one, `Its content no longer fits the class .`, and the exception is +named in the log by its class, code and place (`JobFailureMessage::forLog()`): a message that +cannot be built never stops the worker, however often it is delivered. The handler of an +`UndecodableJob` throws `Thelia\Messenger\JobSetAsideException`, so the job lands in +`failed`. The Background jobs screen lists it as `Unreadable job: ` with its original class, and it can be deleted there. For a module author, this means: diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index ce423a250..440e820e2 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -29,7 +29,10 @@ the scheduler worker. A Redis or AMQP queue needs its Messenger bridge, which the core does not ship: `composer require symfony/redis-messenger` (with the `redis` PHP extension) or `composer require symfony/amqp-messenger` (with the `amqp` extension). The queue in the shop -database (`doctrine://default`) needs nothing more. +database (`doctrine://default`) needs nothing more. On AMQP, also set +`MESSENGER_HEAVY_TRANSPORT_DSN` to a queue of its own: otherwise `async_heavy` shares the queue +of `async`, its exports and imports are read by the `async` worker and tried again three times +like the mails. A worker on `async` or `async_heavy` is only useful once `MESSENGER_TRANSPORT_DSN` names a queue. Started on a shop without a queue, it sits idle and does nothing, because every job already ran in the @@ -214,6 +217,12 @@ Messages queued during the deployment wait in the queue and are handled once the back. The file of a queued import is kept in `var/data-transfer/import/`, outside the cache, so rebuilding the cache does not lose it. +The web servers and the workers must share two directories: `var/data-transfer/import/`, where +an uploaded import waits for its worker, and `var/cache/export/`, where a worker writes the +export file the back office serves. When each release has its own `var/` directory, or the +workers run on another machine, point both at the same storage, and never empty them in a +deployment while jobs wait. + ## Personal data in the queue Queued mails carry the recipient address and the content of the order, and a failed job keeps @@ -223,6 +232,10 @@ everything it was dispatched with. when the schedule runs. Keep the same task in the crontab of a shop that does not consume the schedule. Record this retention in the GDPR register; see [Personal data](../security/personal-data.md). +- A failed job keeps everything it was dispatched with, but its reason is cleaned before it is + shown or logged: the log of the workers names the exception of a failed job by its class, + code and place (`Thelia\Messenger\Log\FailedJobLogProcessor`), and the credentials of a + mail server are hidden in the reasons the back office shows. - A Redis or AMQP broker must not be reachable from the internet. Bind it to the private network of the hosting and protect it with a password. - Put the DSN and its credentials in `.env.local` or in the secrets of the hosting, never in diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index be643b6af..623f6eb19 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -19,7 +19,7 @@ hosting. | Variable | Default | Effect | | --- | --- | --- | | `MESSENGER_TRANSPORT_DSN` | empty | The queue of the `async` transport, from which the `async_heavy` queue is derived. Empty means no queue: every job runs at once. `doctrine://default`, a Redis DSN or an AMQP DSN names a queue that a worker consumes. | -| `MESSENGER_HEAVY_TRANSPORT_DSN` | derived | Overrides the queue of `async_heavy`. | +| `MESSENGER_HEAVY_TRANSPORT_DSN` | derived | Overrides the queue of `async_heavy`. Set it on AMQP: otherwise the exports and imports share the queue of `async` and its three retries. | | `MESSENGER_FAILURE_TRANSPORT_DSN` | `doctrine://default?queue_name=failed` | Where the jobs that failed every attempt are kept. | | `THELIA_SCHEDULE_SALE_CHECK` | `* * * * *` | Cron expression of `sale:check-activation`. Empty disables the task. | | `THELIA_SCHEDULE_MAINTENANCE_PURGE` | `30 3 * * *` | Cron expression of `maintenance:purge`. Empty disables the task. | @@ -41,9 +41,12 @@ the same table with `queue_name=heavy`, a Redis DSN gives the stream `_h (`messages_heavy` when it names no stream), and any other DSN is used as is, so both transports share one queue. -The container parameter `thelia.messenger.allowed_message_classes` (an array, empty by +The core configures Messenger for the whole application: its serializer, its bus, its +`failure_transport` and the routing of its messages are loaded before the configuration of the +project. The container parameter `thelia.messenger.allowed_message_classes` (an array, empty by default) lists the exact names of the message classes that a project queues from outside -`Thelia\` and the namespaces of the active modules. Any other message class must also be taken +`Thelia\` and the namespaces of the active modules: an application that queues its own +`App\Message\…` classes gets a `LogicException` on dispatch until they are listed there. Any other message class must also be taken by a handler, or have a parent or an interface a handler takes: the parameter `thelia.messenger.handled_message_classes` is filled when the container is built and is not meant to be set by hand. A handler that takes any message (`*` or `object`) does not count. @@ -84,7 +87,14 @@ Configuration > System > Background jobs (`/admin/configuration/background-jobs` - how many jobs failed, and the list of failed jobs, the newest first, with their description, the reason of the failure, the date and the number of attempts, - the recurring tasks whose last run failed, under "Recurring tasks that failed", -- the last 20 exports and the last 20 imports. +- the last 20 exports and the last 20 imports of the administrator looking at it, of every + administrator for a super-administrator. + +The reason of a failed job is shown when it was written for the administrator (an exception +implementing `Thelia\Exception\UserFacingFailure`, `Thelia\Messenger\JobSetAsideException` +among them) or when it is the answer of the mail server, with the credentials of any URL in it +hidden by `Thelia\Mailer\TransportCredentials` (a password holding an `@` is hidden whole). +Either is cut to 2000 characters. Any other reason reads as a server error. The waiting count adds the jobs of `async` and of `async_heavy`, and counts them once when both transports have the same DSN. When `failed` is in the shop database, the default, the list is @@ -99,8 +109,8 @@ click cannot send it twice. Without a queue, a replayed job runs at once, and a again stays in the failed list. A replay that fails shows "The job failed again. The details are in the server log." and the reason goes to the log. If setting it aside fails too (queue and database both down), the job is lost from the queues and a critical line of the log names it by -its id and its class only: its description and its content may name a customer, and the log is -kept far longer than the failed jobs. +its id and its class, and the two failures by their class, code and place: its description and +its content may name a customer, and the log is kept far longer than the failed jobs. A job the workers could no longer read (module turned off, class removed, content that no longer fits) is listed as `Unreadable job: ` with its original class. Replayed, it would fail @@ -137,14 +147,15 @@ Exports and imports started from the back office are jobs. Both use the status e | Replay of a failed job | Starts the export over | Starts over from the first row | | Files | Deleted after a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | | `maintenance:purge` | Deletes the done jobs older than 7 days, any other after 30 days | Deletes the done jobs older than 7 days, any other after 30 days, with their files | -| Right to launch | `VIEW` on the export resource | `UPDATE` on the import resource | +| Right to launch | `VIEW` on the export resource | `UPDATE` on the import resource; `VIEW` only shows the imports and their jobs | | Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | The classes live in `Thelia\Domain\DataTransfer\Job\`. Both messages implement `DataTransferJobMessage`, `Thelia\Messenger\Message\ReplayableJob` and `Thelia\Messenger\Message\DescribedJob` (they are listed as `Export #` and `Import #` among the failed jobs), and both models (`ExportJob`, `ImportJob`) implement `DataTransferJob`. Both job pages refresh every 3 seconds, -and a finished job is never run twice. Other administrators get a 403 on a job page, even with +at most 100 times (`JobPageRefresh::MAX_ROUNDS` in the theme), then offer a link to refresh by +hand, and a finished job is never run twice. Other administrators get a 403 on a job page, even with the export or import right. An administrator can launch 10 exports and imports in 10 minutes, the two counted together @@ -167,11 +178,15 @@ failure, gives its message, cut to 2000 characters. The core ones live in `Thelia\Domain\DataTransfer\Exception`: `UploadRefusedException` (a `FormValidationException` raised when an uploaded file is refused), `DataTransferNoDataFoundException`, `HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. -`Thelia\Form\Exception\FormValidationException` itself does not implement it. An export whose year or month is -malformed (`ExportHandler::resolveRangeDate()`, called by the launcher and by the export) or -whose format is no longer available on the server is refused with a `JobRefusedException`, -which the administrator reads as it is. Any other -failure is shown as "The job failed because of a server error. The details are in the server +`Thelia\Form\Exception\FormValidationException` itself does not implement it. + +An export whose year is not four digits or whose month is not 1 to 12 +(`ExportHandler::resolveRangeDate()`, called by the launcher and by the export) is refused with +"The dates of the export are not valid.", and one whose format is no longer available on the +server with `The format "" is no longer available on this server.`. Both are a +`JobRefusedException`, which the administrator reads as it is. + +Any other failure is shown as "The job failed because of a server error. The details are in the server log." (`JobFailureMessage::SERVER_ERROR`), both on the job row and as the error of the failed job on the Background jobs screen, and the error line the job writes to the log names it by class, code and file:line (`JobFailureMessage::forLog()`), or by its reason, on one line, when @@ -241,7 +256,9 @@ archiver, anything else must be text. An archive is also looked into files (`MAX_ENTRIES`), more than 512 MB once extracted (`MAX_EXTRACTED_BYTES`), or a name that is absolute or contains `..`, or a link entry, zip and tar alike. A tar, gzip or bzip2 archive is read header by header through `compress.zlib://` or `compress.bzip2://`, never held in -memory and never copied. `ImportHandler::import()` checks it again before extracting it, then +memory and never copied. GNU long-name and pax records count against the limits like any +entry, a pax record is read record by record, and a header record over 64 KB is refused. +`ImportHandler::import()` checks it again before extracting it, then measures what the extraction wrote (`ArchiveInspector::assertExtractedSize()`). `ImportHandler` takes the inspector as an optional constructor argument. A file that does not parse in its format is refused with an `UploadRefusedException` asking to check its content, @@ -256,7 +273,21 @@ The `done` status, the rows imported and the rows refused are written inside tha The handler opens and commits the transaction only when the caller holds none; otherwise the caller commits or rolls back. A row the import refuses (a missing combination, an invalid GTIN) is listed with the job as a refusal, and the other rows are kept; a refusal that is not valid -UTF-8 is stored with the invalid bytes replaced. `ImportHandler::import()` removes the extracted +UTF-8 is stored with the invalid bytes replaced. The refusals are kept up to 60,000 bytes +(`ImportJob::ROW_ERRORS_MAX_BYTES`), the last line then saying ` more rows were refused.` + +A row whose save leaves the transaction unable to commit, even when a module catches the +failure, stops the import with `Row could not be saved: nothing was imported.` +(`JobRefusedException`). A stock or a price is read from text or from a number, and must be +below 10^10 in absolute value; a list, an object, a missing price or anything else refuses the +row with its reason, and the price of a row is checked before anything is written for it. + +Without a queue, a failed import can never be replayed, so its file is deleted at once +(`JobLifecycle::keepsFailedJobs()`). A file that cannot be deleted is logged and left to the +purge; the job still ends as it did. When the job row cannot record its failure, the error is +logged and the message is set aside in `failed` all the same. + +`ImportHandler::import()` removes the extracted copy of an archive once the import is over, whether it succeeded or not. The sign of life of an import goes through a second database connection (`JobHeartbeat`), so a long import is never taken from its worker. diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index c457b406a..6073371c8 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -166,7 +166,7 @@ of the customer and the content of the order. Their retention periods are fixed | --- | --- | --- | | Export and import jobs that are done | `maintenance:purge` | 7 days | | Other export and import jobs (failed, queued, running) | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | -| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done; a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | +| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from; a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | | Files left in `var/data-transfer/import` | `maintenance:purge` | 30 days | | Failed jobs of the queue | `thelia:messenger:purge-failed` | 30 days, `--older-than` to change it | @@ -182,7 +182,14 @@ administrator, it reads as a server error, and the error line Thelia writes to t by its class, code and place in the code rather than by its message. The failed job kept in `failed` stores only that text: the original exception is not chained to it, since Symfony keeps the whole chain of an exception it sets aside. This holds for the exports and imports of -the back office; a failed mail or module job keeps the exception its handler threw. See [Background jobs](../architecture/background-jobs.md#personal-data) and +the back office; a failed mail or module job keeps the exception its handler threw, but the +Background jobs screen only shows its reason when it was written for the administrator or is +the answer of the mail server, credentials hidden, and the log of the workers names its +exception by class, code and place (`Thelia\Messenger\Log\FailedJobLogProcessor`). + +A queued mail stores the content of its attachments in the queue, an attachment given by its +path included: the queue, and `failed` for a mail that could not be delivered, hold the +documents a mail carries, such as an invoice. See [Background jobs](../architecture/background-jobs.md#personal-data) and [`thelia:messenger:purge-failed`](../reference/cli/thelia_messenger_purge_failed.md). ## Adding a module purge to the same run diff --git a/docs/upgrading/update.md b/docs/upgrading/update.md index 942583095..552dbe1da 100644 --- a/docs/upgrading/update.md +++ b/docs/upgrading/update.md @@ -154,6 +154,31 @@ cross, in order: - [Updating from 3.0 to 3.1](./from-3.0-to-3.1.md), patch releases 3.1.1, 3.1.2, 3.1.3 and 3.1.4 included. - [Updating from 3.1 to 3.2](./from-3.1-to-3.2.md), patch releases 3.2.1, 3.2.2 and 3.2.3 included. +### Updating to 3.3: background jobs + +Thelia 3.3 ships Symfony Messenger and the [background jobs](../architecture/background-jobs.md). +Check these points when you cross it: + +- The core requires `symfony/messenger`. When Composer applies the Symfony recipe of that + package instead of the Thelia one, it writes `config/packages/messenger.yaml` and adds + `MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0` to `.env`, and every mail then waits + for a worker. Delete `config/packages/messenger.yaml`: the core configures Messenger itself. + Unless a worker runs, also delete that line, so `async` stays synchronous. +- The core configures Messenger for the whole application: its serializer, its bus, its + `failure_transport` and the routing of its messages come before the configuration of the + project. An application that already queued messages of its own (`App\Message\…`) gets a + `LogicException` on dispatch until their classes are listed in the + `thelia.messenger.allowed_message_classes` parameter. A module message is accepted when it + lives in the namespace of an active module and has a handler. +- With a queue, the web servers and the workers must share `var/data-transfer/import/` and + `var/cache/export/`, and a deployment must not empty them while jobs wait. See + [Running the workers](../getting-started/background-workers.md#deploying). +- On an AMQP queue, set `MESSENGER_HEAVY_TRANSPORT_DSN`: otherwise the exports and imports share + the queue of `async`, are read by its worker and tried again three times like its jobs. +- Launching an import from the back office takes the `UPDATE` right on `admin.import`; `VIEW` + now only shows the imports and their jobs. Review the profiles of the administrators who + import. + ## Recommendations 1. Back up your database before updating. From 2704e95153b071dd1b520228ec2b1603214a9161 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Wed, 7 Oct 2026 12:05:31 +0200 Subject: [PATCH 17/52] docs: list the 3.3 breaking changes of the background jobs, the mailer and the import right --- docs/upgrading/breaking-changes.md | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/upgrading/breaking-changes.md b/docs/upgrading/breaking-changes.md index 6ac0da8ef..fb024e48e 100644 --- a/docs/upgrading/breaking-changes.md +++ b/docs/upgrading/breaking-changes.md @@ -9,6 +9,42 @@ This page lists, release by release, what a module or a theme has to change when For the update procedure itself, see [Update](./update.md). +## Thelia 3.3 + +The update gestures (the Symfony Messenger recipe, the shared directories, the queue of the heavy jobs) are in [Updating to 3.3: background jobs](./update.md#updating-to-33-background-jobs). See [Background jobs](../architecture/background-jobs.md) for the model. + +### Back-office theme + +The `default-twig` back-office theme requires `thelia/core ^3.3` in its `composer.json`. Update the core and the theme together. + +### Export and import handlers + +`ExportHandler::export()`, `ImportHandler::import()` and `ImportHandler::validateUpload()` take a new optional last argument. A module that overrides one of them has to declare it. + +| Method | New last argument | +|--------|-------------------| +| `Thelia\Domain\DataTransfer\ExportHandler::export()` | `?\Closure $onProgress = null`, told the rows written | +| `Thelia\Domain\DataTransfer\ImportHandler::import()` | `?\Closure $onProgress = null`, told the rows read | +| `Thelia\Domain\DataTransfer\ImportHandler::validateUpload()` | `?File $file = null`, the uploaded file whose content is checked | + +`processExport()` and `processImport()` keep their signature. `ImportHandler::import()` now extracts an archive through a private method, so an override of `extractArchive()` is no longer called by it. + +### Mails + +- The order history line `email_sent` is written once the mail server has taken the mail, by `Thelia\Mailer\EventListener\OrderEmailHistoryListener` on the Symfony Mailer `SentMessageEvent`, and no longer by `MailerFactory` right after handing the mail over. Without a queue this happens in the same request; with one, it is the worker that writes it, later, and the author of the line is `system`. +- `MailerFactory` names the order in two headers of the mail, `X-Thelia-Order-Id` and `X-Thelia-Message-Code`. They travel through the queue and are removed right before the mail is handed to the mail server. +- `MailerFactory` no longer takes an `OrderHistoryRecorder`. Its fourth constructor argument is now an optional `Symfony\Component\Mailer\Transport\TransportInterface`, used by `sendNow()`. A module that builds it with `new` and still passes the recorder gets a `TypeError`. +- With a queue, `sendEmailMessageOrFail()` and the methods built on it only throw when the mail could not be built or queued. A delivery failure no longer reaches the caller: the mail is set aside in `failed`. +- A `TemplatedEmail` that is not rendered yet is refused when it is queued, with a `LogicException`: the worker renders nothing. Render it before sending it (`BodyRenderer`), or send it without a queue. + +### Messenger configuration + +The core configures Messenger for the whole application: its serializer, its bus, its `failure_transport` and the routing of its messages come before the configuration of the project. An application or a module that already queued messages of its own (`App\Message\…`) gets a `LogicException` on dispatch until their classes are listed in the `thelia.messenger.allowed_message_classes` parameter, or live in the namespace of an active module and have a handler. See [Which classes a queue accepts](../architecture/background-jobs.md#which-classes-a-queue-accepts). + +### Import right + +Launching an import from the back office takes the `UPDATE` right on `admin.import`. The `VIEW` right used to be enough; it now only shows the imports and their jobs. Review the profiles of the administrators who import. + ## Thelia 3.2 A class you read from the container needs nothing when its constructor gains arguments. The "has to pass" cases below concern a module that builds the class with `new`. From ff9285b8095dd8217cd919de03dd5bd13cf0ea7a Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Wed, 7 Oct 2026 15:27:46 +0200 Subject: [PATCH 18/52] docs: say a failed import deleted from the back office takes its file with it --- docs/architecture/background-jobs.md | 4 +++- docs/reference/background-jobs.md | 5 ++++- docs/security/personal-data.md | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index cb739c8a7..c0c97c5bd 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -306,7 +306,9 @@ the import storage. A finished import is never run twice. A failed one goes to `failed`, and replaying it starts over from the first row. The uploaded file is deleted once the import is done, and kept while the job may be replayed; without a queue a failed job can never be -replayed, so its file is deleted at once. A file that cannot be deleted is left to the purge +replayed, so its file is deleted at once. Deleting the failed job from the back office deletes +its file too (`FailedJobRemovedEvent`); `messenger:failed:remove` leaves it to the purge. A +file that cannot be deleted is left to the purge and logged as a warning: the job is still reported as it ended. A failure the job row cannot record (the database gone) is logged, and the message is set aside in `failed` all the same. The path of the file is stored relative to the project, and its name is cut to 100 characters. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 623f6eb19..3d482436d 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -283,7 +283,10 @@ below 10^10 in absolute value; a list, an object, a missing price or anything el row with its reason, and the price of a row is checked before anything is written for it. Without a queue, a failed import can never be replayed, so its file is deleted at once -(`JobLifecycle::keepsFailedJobs()`). A file that cannot be deleted is logged and left to the +(`JobLifecycle::keepsFailedJobs()`). With a queue, deleting the failed import from the back +office dispatches `Thelia\Messenger\Event\FailedJobRemovedEvent` and its file is deleted with +it; `messenger:failed:remove` leaves the file to the purge. A file that cannot be deleted is +logged and left to the purge; the job still ends as it did. When the job row cannot record its failure, the error is logged and the message is set aside in `failed` all the same. diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index 6073371c8..c88d48aeb 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -166,7 +166,7 @@ of the customer and the content of the order. Their retention periods are fixed | --- | --- | --- | | Export and import jobs that are done | `maintenance:purge` | 7 days | | Other export and import jobs (failed, queued, running) | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | -| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from; a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | +| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from, or once its failed job is deleted from the back office; a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | | Files left in `var/data-transfer/import` | `maintenance:purge` | 30 days | | Failed jobs of the queue | `thelia:messenger:purge-failed` | 30 days, `--older-than` to change it | From 5e3d1fd532ef65170267dc3f65913760e0302613 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Wed, 7 Oct 2026 16:51:17 +0200 Subject: [PATCH 19/52] docs: describe the request a job gets and the replay done once --- docs/architecture/background-jobs.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index c0c97c5bd..6cd66591a 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -142,7 +142,9 @@ and the message goes to `failed` at once. `failed` is `MESSENGER_FAILURE_TRANSPORT_DSN`, which defaults to `doctrine://default?queue_name=failed`: the failed jobs are always kept in the shop database, whatever queue `async` uses, so the back office has a single place to read them from. They -stay there until someone replays them, removes them, or the purge task deletes them. +stay there until someone replays them, removes them, or the purge task deletes them. A replay +takes the job out of `failed` before sending it again, on a condition: two administrators, or a +double click, replaying the same job at once send it once. Messenger writes the exception of a failed or retried job to the log of the workers, and a database error quotes the values of a row. `Thelia\Messenger\Log\FailedJobLogProcessor`, on @@ -161,8 +163,12 @@ reference shows how in [Idempotence and replay](../reference/background-jobs.md# ## What a worker sees -A handler running in a worker has no HTTP request, no visitor, no session and no cart. It -runs in a long process that handles one message after another, so the core resets the state +A handler running in a worker serves no page: there is no visitor, no cart and no HTTP +request of a browser. Each message gets a request of its own on the address of the shop +(`DEFAULT_URI`), with an empty session, so the loops of a template a handler renders, a mail +of a module for instance, work as they do in a page +(`Thelia\Messenger\EventListener\WorkerRequestListener`). It goes once the message is handled +or has failed, so the next message starts without it. The handler still runs in a long process that handles one message after another, so the core resets the state of the shop before each message (`Thelia\Messenger\EventListener\WorkerStateResetListener`): - settings are read again from the database, so a change made in the back office since the @@ -184,7 +190,7 @@ long job kept the worker busy. Symfony also resets the services tagged `kernel.reset` between messages, unless the worker is started with `--no-reset`. -A handler therefore never relies on what a request would have set. It receives ids, reads its +A handler therefore never relies on what a visitor would have set. It receives ids, reads its data again, chooses the locale explicitly, and builds absolute URLs from `DEFAULT_URI`. ## What the core sends through the queue From c68e59b54c84454d016d0f9e25ed28694a3b603c Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Wed, 7 Oct 2026 17:07:17 +0200 Subject: [PATCH 20/52] docs: bound the replay done once, say what a job request does not set up and how long a left file stays --- docs/architecture/background-jobs.md | 33 ++++++++++++++++++---------- docs/reference/background-jobs.md | 3 ++- docs/security/personal-data.md | 2 +- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 6cd66591a..ad5f52c32 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -140,11 +140,14 @@ the data, throws `Symfony\Component\Messenger\Exception\UnrecoverableMessageHand and the message goes to `failed` at once. `failed` is `MESSENGER_FAILURE_TRANSPORT_DSN`, which defaults to -`doctrine://default?queue_name=failed`: the failed jobs are always kept in the shop database, -whatever queue `async` uses, so the back office has a single place to read them from. They -stay there until someone replays them, removes them, or the purge task deletes them. A replay -takes the job out of `failed` before sending it again, on a condition: two administrators, or a -double click, replaying the same job at once send it once. +`doctrine://default?queue_name=failed`: by default the failed jobs are kept in the shop +database, whatever queue `async` uses, so the back office has a single place to read them from. +They stay there until someone replays them, removes them, or the purge task deletes them. In the +shop database, the back office takes a job out of `failed` on a condition before replaying or +deleting it: two administrators, or a double click, acting on the same job at once act once, and +a job a worker holds (`messenger:failed:retry` running it) is left to that worker until its +redeliver timeout. With a failure queue elsewhere, the back office removes the job without that +condition. Messenger writes the exception of a failed or retried job to the log of the workers, and a database error quotes the values of a row. `Thelia\Messenger\Log\FailedJobLogProcessor`, on @@ -165,11 +168,15 @@ reference shows how in [Idempotence and replay](../reference/background-jobs.md# A handler running in a worker serves no page: there is no visitor, no cart and no HTTP request of a browser. Each message gets a request of its own on the address of the shop -(`DEFAULT_URI`), with an empty session, so the loops of a template a handler renders, a mail -of a module for instance, work as they do in a page -(`Thelia\Messenger\EventListener\WorkerRequestListener`). It goes once the message is handled -or has failed, so the next message starts without it. The handler still runs in a long process that handles one message after another, so the core resets the state -of the shop before each message (`Thelia\Messenger\EventListener\WorkerStateResetListener`): +(`DEFAULT_URI`, its folder included), with an empty session, so the loops of a template a +handler renders, a mail of a module for instance, can run +(`Thelia\Messenger\EventListener\WorkerRequestListener`). Nothing a page sets up runs for it: +the session language is the default language, the request is not one of the back office, and +no customer or administrator is restored from a cookie. The request goes once the message is +handled or has failed, and a message the worker skipped never hands its request to the next. + +The handler still runs in a long process that handles one message after another, so the core +resets the state of the shop before each message (`Thelia\Messenger\EventListener\WorkerStateResetListener`): - settings are read again from the database, so a change made in the back office since the previous message is seen, @@ -313,8 +320,10 @@ A finished import is never run twice. A failed one goes to `failed`, and replayi over from the first row. The uploaded file is deleted once the import is done, and kept while the job may be replayed; without a queue a failed job can never be replayed, so its file is deleted at once. Deleting the failed job from the back office deletes -its file too (`FailedJobRemovedEvent`); `messenger:failed:remove` leaves it to the purge. A -file that cannot be deleted is left to the purge +its file too (`FailedJobRemovedEvent`), unless the import is still marked running. +`messenger:failed:remove` and `thelia:messenger:purge-failed` leave the file to +`maintenance:purge`, which deletes it after 30 days at most. A file that cannot be deleted is +left to the purge and logged as a warning: the job is still reported as it ended. A failure the job row cannot record (the database gone) is logged, and the message is set aside in `failed` all the same. The path of the file is stored relative to the project, and its name is cut to 100 characters. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 3d482436d..11d620051 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -285,7 +285,8 @@ row with its reason, and the price of a row is checked before anything is writte Without a queue, a failed import can never be replayed, so its file is deleted at once (`JobLifecycle::keepsFailedJobs()`). With a queue, deleting the failed import from the back office dispatches `Thelia\Messenger\Event\FailedJobRemovedEvent` and its file is deleted with -it; `messenger:failed:remove` leaves the file to the purge. A file that cannot be deleted is +it, unless the import is still marked running; `messenger:failed:remove` and +`thelia:messenger:purge-failed` leave the file to `maintenance:purge`, 30 days at most. A file that cannot be deleted is logged and left to the purge; the job still ends as it did. When the job row cannot record its failure, the error is logged and the message is set aside in `failed` all the same. diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index c88d48aeb..7277bed9f 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -166,7 +166,7 @@ of the customer and the content of the order. Their retention periods are fixed | --- | --- | --- | | Export and import jobs that are done | `maintenance:purge` | 7 days | | Other export and import jobs (failed, queued, running) | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | -| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from, or once its failed job is deleted from the back office; a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | +| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from, or once its failed job is deleted from the back office (not by `messenger:failed:remove` nor `thelia:messenger:purge-failed`, which leave it to `maintenance:purge`, 30 days at most); a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | | Files left in `var/data-transfer/import` | `maintenance:purge` | 30 days | | Failed jobs of the queue | `thelia:messenger:purge-failed` | 30 days, `--older-than` to change it | From f418eab8a22a9f5a8c08f8574e0f1e09d98a6dd6 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Wed, 7 Oct 2026 17:11:55 +0200 Subject: [PATCH 21/52] docs: say which replay a worker holding a failed job wins and what the retry command does not hold --- docs/architecture/background-jobs.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index ad5f52c32..efee50fb7 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -145,9 +145,11 @@ database, whatever queue `async` uses, so the back office has a single place to They stay there until someone replays them, removes them, or the purge task deletes them. In the shop database, the back office takes a job out of `failed` on a condition before replaying or deleting it: two administrators, or a double click, acting on the same job at once act once, and -a job a worker holds (`messenger:failed:retry` running it) is left to that worker until its -redeliver timeout. With a failure queue elsewhere, the back office removes the job without that -condition. +a job a worker consuming `failed` holds (`messenger:consume failed`) is left to that worker until +its redeliver timeout, the back office then answering that it found no such job. +`messenger:failed:retry` holds nothing while it asks what to do with a job: replayed from the +screen and from that command at the same moment, a job may be sent twice. With a failure queue +elsewhere, the back office removes the job without that condition. Messenger writes the exception of a failed or retried job to the log of the workers, and a database error quotes the values of a row. `Thelia\Messenger\Log\FailedJobLogProcessor`, on From 14dfea3fa6fa811b983f35c73ac729cf109944f6 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 8 Oct 2026 10:00:25 +0200 Subject: [PATCH 22/52] docs: correct the email_sent writer, add the failed jobs purge of maintenance:purge and the queue threat model --- docs/architecture/background-jobs.md | 20 ++++++++++---- docs/back-office/order-history.md | 8 ++++-- docs/getting-started/background-workers.md | 14 +++++++--- docs/reference/background-jobs.md | 26 ++++++++++++++----- docs/reference/cli/index.md | 2 +- docs/reference/cli/maintenance_purge.md | 6 ++++- .../cli/thelia_messenger_purge_failed.md | 2 +- docs/security/personal-data.md | 2 +- docs/upgrading/update.md | 9 +++++-- 9 files changed, 66 insertions(+), 23 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index efee50fb7..4fe94c386 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -142,7 +142,9 @@ and the message goes to `failed` at once. `failed` is `MESSENGER_FAILURE_TRANSPORT_DSN`, which defaults to `doctrine://default?queue_name=failed`: by default the failed jobs are kept in the shop database, whatever queue `async` uses, so the back office has a single place to read them from. -They stay there until someone replays them, removes them, or the purge task deletes them. In the +They stay there until someone replays them, removes them, or a purge deletes them: +`thelia:messenger:purge-failed`, and `maintenance:purge`, which deletes the failed jobs older +than 30 days too (`Thelia\Messenger\EventListener\FailedJobsMaintenancePurgeListener`). In the shop database, the back office takes a job out of `failed` on a condition before replaying or deleting it: two administrators, or a double click, acting on the same job at once act once, and a job a worker consuming `failed` holds (`messenger:consume failed`) is left to that worker until @@ -251,10 +253,12 @@ the job page (`/admin/export/job/{id}`): waiting, running with the number of row (refreshed every 3 seconds), done with a download link, or failed with the reason. A finished export is never run twice. A failed one is recorded on its row and goes straight to -`failed`; replaying it starts it over. The export files are deleted after a day. +`failed`; replaying it starts it over. The export files are deleted once older than a day, +when `maintenance:purge` runs. -The reason recorded on the row is the one an administrator reads on the job page. Only a -reason written for the administrator is shown: an exception implementing +The reason recorded on the row is the one an administrator reads on the job page. On that row, +only a reason written for the administrator is shown (the answer of a mail server, which the +Background jobs screen also shows for a failed mail, never comes from an export or an import): an exception implementing `Thelia\Exception\UserFacingFailure`, anywhere in the chain of the failure, gives its message, cut to 2000 characters. The core ones are, in `Thelia\Domain\DataTransfer\Exception`, `UploadRefusedException` (the refusals of an uploaded file, a `FormValidationException`), @@ -475,7 +479,13 @@ queue like the database: - a Redis or AMQP broker must not be reachable from outside the hosting, - its credentials go in `.env.local` or the secrets of the hosting, never in Git, - the retention of failed jobs is a processing to record in the GDPR register. The - `thelia:messenger:purge-failed` task deletes them after 30 days by default. + `thelia:messenger:purge-failed` task deletes them after 30 days by default, and + `maintenance:purge` deletes those older than 30 days too. + +A Redis or AMQP queue is read by the workers as the queue of the shop: whoever can write to it +can send mails through the mail server of the shop, and replay the exports and imports it +names. This is an accepted risk, and the reason such a server must stay private. A queue in the +shop database adds nothing to what access to that database already gives. See [Personal data](../security/personal-data.md) and [Running the workers](../getting-started/background-workers.md#personal-data-in-the-queue). diff --git a/docs/back-office/order-history.md b/docs/back-office/order-history.md index a65fc220b..1ca6d537b 100644 --- a/docs/back-office/order-history.md +++ b/docs/back-office/order-history.md @@ -144,8 +144,12 @@ The recorder: The listeners that feed the journal are `RecordOrderHistoryListener` (order creation, payment, status, delivery and transaction references, address) and `RecordOrderReturnHistoryListener`. Invoice numbering is recorded where the reference is allocated, by the automatic path -(`invoice_ref_auto`) and by the `allocate_invoice_ref` status action. `MailerFactory` records -`email_sent` when the message parameters carry `order_id` or `order_ref`. +(`invoice_ref_auto`) and by the `allocate_invoice_ref` status action. `email_sent` is recorded +by `Thelia\Mailer\EventListener\OrderEmailHistoryListener` on the Symfony Mailer +`SentMessageEvent`, once the mail server has accepted the mail; with a queue, the worker +writes it, and its author is `system`. `MailerFactory` only names the order in the +`X-Thelia-Order-Id` and `X-Thelia-Message-Code` headers of the mail, when the message +parameters carry `order_id` or `order_ref`. A payment module that dispatches an `OrderEvent` should name itself with `OrderEvent::setSourceModuleCode()`, so the line is authored by the module. The base payment diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 440e820e2..9024d2c80 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -26,6 +26,10 @@ behind either. A smaller setup can run a single worker on `messenger:consume async async_heavy`, which takes the mails first, then the heavy jobs, plus the scheduler worker. +Do not consume `scheduler_thelia` when the same commands already run from a crontab +(`maintenance:purge`, `sale:check-activation`, `thelia:messenger:purge-failed`): each would run +twice. See the warning in the [recurring tasks reference](../reference/background-jobs.md#recurring-tasks). + A Redis or AMQP queue needs its Messenger bridge, which the core does not ship: `composer require symfony/redis-messenger` (with the `redis` PHP extension) or `composer require symfony/amqp-messenger` (with the `amqp` extension). The queue in the shop @@ -229,15 +233,19 @@ Queued mails carry the recipient address and the content of the order, and a fai everything it was dispatched with. - Failed jobs are deleted by `thelia:messenger:purge-failed` after 30 days, every day at 04:00 - when the schedule runs. Keep the same task in the crontab of a shop that does not consume - the schedule. Record this retention in the GDPR register; see + when the schedule runs, and by `maintenance:purge`, which deletes the failed jobs older than + 30 days on each of its runs. Keep the same tasks in the crontab of a shop that does not + consume the schedule. Record this retention in the GDPR register; see [Personal data](../security/personal-data.md). - A failed job keeps everything it was dispatched with, but its reason is cleaned before it is shown or logged: the log of the workers names the exception of a failed job by its class, code and place (`Thelia\Messenger\Log\FailedJobLogProcessor`), and the credentials of a mail server are hidden in the reasons the back office shows. - A Redis or AMQP broker must not be reachable from the internet. Bind it to the private - network of the hosting and protect it with a password. + network of the hosting and protect it with a password. The workers read its queue as the + queue of the shop: whoever can write to it can send mails through the mail server of the shop + and replay the exports and imports it names. A queue in the shop database adds nothing to + what access to that database already gives. - Put the DSN and its credentials in `.env.local` or in the secrets of the hosting, never in a committed file. - Do not share one Redis between environments unless each one has its own stream: a staging diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 11d620051..586722a0d 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -25,7 +25,7 @@ hosting. | `THELIA_SCHEDULE_MAINTENANCE_PURGE` | `30 3 * * *` | Cron expression of `maintenance:purge`. Empty disables the task. | | `THELIA_SCHEDULE_FAILED_JOBS_PURGE` | `0 4 * * *` | Cron expression of `thelia:messenger:purge-failed`. Empty disables the task. | | `THELIA_SCHEDULE_CURRENCY_RATES` | empty | Cron expression of `currency:update-rates`. Off by default. | -| `LOCK_DSN` | `flock` | The lock store that keeps two workers from running the same scheduled task. | +| `LOCK_DSN` | `flock` in the `.env` of the core, `semaphore` in the `.env.dist` of `thelia-project` | The lock store that keeps two workers from running the same scheduled task. | `doctrine://default` is the only Doctrine connection name accepted: it is the shop database, reached with the settings of the Propel connection. The table `messenger_messages` is created @@ -46,10 +46,22 @@ The core configures Messenger for the whole application: its serializer, its bus project. The container parameter `thelia.messenger.allowed_message_classes` (an array, empty by default) lists the exact names of the message classes that a project queues from outside `Thelia\` and the namespaces of the active modules: an application that queues its own -`App\Message\…` classes gets a `LogicException` on dispatch until they are listed there. Any other message class must also be taken -by a handler, or have a parent or an interface a handler takes: the parameter -`thelia.messenger.handled_message_classes` is filled when the container is built and is not -meant to be set by hand. A handler that takes any message (`*` or `object`) does not count. +`App\Message\…` classes gets a `LogicException` on dispatch until they are listed there. The +core declares it empty (`core/lib/Thelia/Config/Resources/parameters/messenger.php`). Any other +message class must also be taken by a handler, or have a parent or an interface a handler +takes: the parameter `thelia.messenger.handled_message_classes` is filled when the container is +built and is not meant to be set by hand. A handler that takes any message (`*` or `object`) +does not count. + +A project sets the list in `config/services.yaml`, which is loaded after the configuration of +the core: + +```yaml +# config/services.yaml +parameters: + thelia.messenger.allowed_message_classes: + - App\Message\SyncInvoices +``` ### Transports @@ -75,7 +87,7 @@ to `async_heavy`. | `messenger:failed:retry` | Replay failed jobs. | | `messenger:failed:remove` | Delete failed jobs. | | `messenger:stop-workers` | Ask every worker to stop after its current message. | -| [`thelia:messenger:purge-failed`](./cli/thelia_messenger_purge_failed.md) | Delete the failed jobs set aside for more than `--older-than` days (30 by default). | +| [`thelia:messenger:purge-failed`](./cli/thelia_messenger_purge_failed.md) | Delete the failed jobs set aside for more than `--older-than` days (30 by default). `maintenance:purge` deletes those older than 30 days too. | All of them run through `php Thelia`, for example `php Thelia messenger:stats`. @@ -145,7 +157,7 @@ Exports and imports started from the back office are jobs. Both use the status e | Without a queue | Runs in the request, the file is served at once | Runs in the request, the page shows the rows imported and the rows refused | | With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | | Replay of a failed job | Starts the export over | Starts over from the first row | -| Files | Deleted after a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | +| Files | Deleted by `maintenance:purge` once older than a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | | `maintenance:purge` | Deletes the done jobs older than 7 days, any other after 30 days | Deletes the done jobs older than 7 days, any other after 30 days, with their files | | Right to launch | `VIEW` on the export resource | `UPDATE` on the import resource; `VIEW` only shows the imports and their jobs | | Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 867f83d91..5930bbb31 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -109,7 +109,7 @@ with an existing invoice numbering to continue. | Command | Description | | --- | --- | -| `messenger:consume` | Run a worker on one or more transports (`async`, `scheduler_thelia`). | +| `messenger:consume` | Run a worker on one or more transports (`async`, `async_heavy`, `scheduler_thelia`). | | `messenger:stats` | Count the messages waiting in each transport. | | `messenger:failed:show` | List the failed jobs, or show one with its error. | | `messenger:failed:retry` | Replay failed jobs. | diff --git a/docs/reference/cli/maintenance_purge.md b/docs/reference/cli/maintenance_purge.md index 79f7a8290..0f20ccde8 100644 --- a/docs/reference/cli/maintenance_purge.md +++ b/docs/reference/cli/maintenance_purge.md @@ -43,7 +43,11 @@ The core listens to the same event to purge the back-office exports and imports, any other (failed, queued, running) after 30 days, as long as the failed jobs they can be replayed from, the uploaded file of each deleted import when it lies inside `var/data-transfer/import`, and the files of that directory older than 30 days. The same listener removes the export files of the cache older -than a day. These periods are fixed. See [Background jobs](../background-jobs.md#back-office-exports-and-imports). +than a day. These periods are fixed. + +The failed jobs set aside in `failed` for more than 30 days are deleted on the same run +(`Thelia\Messenger\EventListener\FailedJobsMaintenancePurgeListener`), as +[`thelia:messenger:purge-failed`](./thelia_messenger_purge_failed.md) does with its default. See [Background jobs](../background-jobs.md#back-office-exports-and-imports). ## Examples See what would be removed: diff --git a/docs/reference/cli/thelia_messenger_purge_failed.md b/docs/reference/cli/thelia_messenger_purge_failed.md index 23b38f1ee..d3a9f0b46 100644 --- a/docs/reference/cli/thelia_messenger_purge_failed.md +++ b/docs/reference/cli/thelia_messenger_purge_failed.md @@ -17,7 +17,7 @@ deletes the old ones. ``` ## Options - - `--older-than=DAYS` Age in days from which a failed job is deleted. Defaults to `30`. Takes a whole number of days. + - `--older-than=DAYS` Age in days from which a failed job is deleted. Defaults to `30`. Takes a whole number of days, from 1 to 3650; any other value ends with the `INVALID` exit code. - `--dry-run` Count the jobs that would be deleted, and delete nothing. The age of a job is counted from the date it was set aside. When `failed` is in the shop diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index 7277bed9f..09767b08a 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -168,7 +168,7 @@ of the customer and the content of the order. Their retention periods are fixed | Other export and import jobs (failed, queued, running) | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | | Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from, or once its failed job is deleted from the back office (not by `messenger:failed:remove` nor `thelia:messenger:purge-failed`, which leave it to `maintenance:purge`, 30 days at most); a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | | Files left in `var/data-transfer/import` | `maintenance:purge` | 30 days | -| Failed jobs of the queue | `thelia:messenger:purge-failed` | 30 days, `--older-than` to change it | +| Failed jobs of the queue | `thelia:messenger:purge-failed`, and `maintenance:purge` | 30 days; `--older-than` changes it for `thelia:messenger:purge-failed` | The 30-day sweep of `var/data-transfer/import` removes what no job row points to any more, such as the extracted copy of an archive left by a worker that was killed, or a file whose row was diff --git a/docs/upgrading/update.md b/docs/upgrading/update.md index 552dbe1da..83f49df9d 100644 --- a/docs/upgrading/update.md +++ b/docs/upgrading/update.md @@ -162,8 +162,13 @@ Check these points when you cross it: - The core requires `symfony/messenger`. When Composer applies the Symfony recipe of that package instead of the Thelia one, it writes `config/packages/messenger.yaml` and adds `MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0` to `.env`, and every mail then waits - for a worker. Delete `config/packages/messenger.yaml`: the core configures Messenger itself. - Unless a worker runs, also delete that line, so `async` stays synchronous. + for a worker. Delete the `config/packages/messenger.yaml` the Symfony recipe wrote: the core + configures Messenger itself. A `messenger.yaml` of your own, such as one that routes a module + message, stays. Unless a worker runs, also delete that line, so `async` stays synchronous. +- The Symfony recipe of `symfony/scheduler` writes `src/Schedule.php`. Delete it too: the core + declares its own `thelia` schedule. +- Going back from a queue to no queue leaves the jobs still waiting in `messenger_messages`. + Let a worker empty the queues before you empty `MESSENGER_TRANSPORT_DSN`. - The core configures Messenger for the whole application: its serializer, its bus, its `failure_transport` and the routing of its messages come before the configuration of the project. An application that already queued messages of its own (`App\Message\…`) gets a From 15c88cde3fc66024076d01bb043919c933101631 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 8 Oct 2026 14:46:38 +0200 Subject: [PATCH 23/52] docs: tell the worker restart, the import locks and the crontab double run --- docs/architecture/background-jobs.md | 23 ++++++++++++++++++---- docs/getting-started/background-workers.md | 3 ++- docs/reference/background-jobs.md | 8 +++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 4fe94c386..5f75a2761 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -184,8 +184,9 @@ resets the state of the shop before each message (`Thelia\Messenger\EventListene - settings are read again from the database, so a change made in the back office since the previous message is seen, -- the active languages, the default country, the module configuration, the rewritten URLs and - the price currency are forgotten, +- the active languages, the default country and currency, the module configuration, the + rewritten URLs, the price currency, the taxes of a rule, the return statuses and the results + of the loops are forgotten, - the translator is set back to the default language. Before each message, the Propel connection is also checked and reopened if MySQL closed it @@ -201,6 +202,12 @@ long job kept the worker busy. Symfony also resets the services tagged `kernel.reset` between messages, unless the worker is started with `--no-reset`. +What a worker does not read again is its container: the mail settings and the handlers of the +modules active when it started. Clearing the cache of the shop (saving the mail settings, +turning a module on or off, `thelia:cache:clear`) therefore asks every worker to stop once its +current message is done, as `messenger:stop-workers` does +(`Thelia\Messenger\WorkerRestartSignal`), and its supervisor starts it again on the new cache. + A handler therefore never relies on what a visitor would have set. It receives ids, reads its data again, chooses the locale explicitly, and builds absolute URLs from `DEFAULT_URI`. @@ -295,8 +302,16 @@ An import runs in a single Propel transaction. Stopped half way, by an error, a or a deployment, it leaves the catalog as it was. Its outcome (the `done` status, the rows imported and the rows refused) is written to the job row inside the same transaction, so the catalog and the row never disagree. When the code that runs the import already holds a -transaction, the import works inside it and leaves the commit or the rollback to that code. A -row the import refuses (a missing combination, an invalid GTIN) is not an error: it is listed +transaction, the import works inside it and leaves the commit or the rollback to that code. + +The rows an import has written stay locked until it ends: an order or a back-office edit +touching one of them waits, then fails after `innodb_lock_wait_timeout`. Run a large stock or +price import outside trading hours. The listeners of `IMPORT_BEGIN`, `IMPORT_FINISHED` and +`IMPORT_SUCCESS` run inside the transaction too: what they write is cancelled with the import, +what they send is not, and a job they queue in the shop database is committed at once, so a +worker may run it before the import is committed. + +A row the import refuses (a missing combination, an invalid GTIN) is not an error: it is listed with the job as a refusal, and the other rows are kept. A refusal whose text is not valid UTF-8 is stored with the invalid bytes replaced. The refusals are kept up to 60,000 bytes (`ImportJob::ROW_ERRORS_MAX_BYTES`): past that, the first ones are kept and a last line says diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 9024d2c80..f35f8ed2d 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -150,7 +150,8 @@ A hosting that does not allow long processes can still consume the queue from cr * * * * * cd /var/www/shop && php Thelia messenger:consume async async_heavy scheduler_thelia --time-limit=55 ``` -Each run consumes for 55 seconds, then exits before the next one starts. Jobs wait up to a +Drop `scheduler_thelia` from this line if `maintenance:purge` and the other tasks stay in your +crontab: each would run twice. Each run consumes for 55 seconds, then exits before the next one starts. Jobs wait up to a minute before they run. Two runs that overlap are safe: each message is handed to one worker only. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 586722a0d..a23f415a3 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -133,8 +133,8 @@ A recurring task of the `thelia` schedule that fails is not set aside in `failed at its next time. `Thelia\Scheduler\EventListener\RecurringTaskFailureListener` keeps its last failure in the `cache.app` pool (`Thelia\Scheduler\RecurringTaskFailures`) until a run of the task goes through, or for a month after the date of that failure, and the screen lists it with the task, the reason and -the date. A command that exits with an error code gives `Command "" exited with code -"".` as its reason. +the date. A command that exits with an error code gives +`Command "" exited with code "".` as its reason. The "Details" link of an export or import is only shown to the administrator who started the job and to super-administrators. @@ -465,7 +465,9 @@ public static function configureContainer(ContainerConfigurator $containerConfig With `STOCK_SYNC_TRANSPORT_DSN` empty or unset, the pushes stay synchronous. A shop that wants them queued sets, for instance, `STOCK_SYNC_TRANSPORT_DSN=doctrine://default?queue_name=stock_sync` and runs a worker with `php Thelia messenger:consume stock_sync`. Pushes that fail every -attempt still go to the `failed` transport of the shop. +attempt still go to the `failed` transport of the shop, and the back office lists them there, +but it does not count the pushes waiting in `stock_sync`: watch that queue with +`messenger:stats stock_sync`. ### Metadata and dependencies From 8520ebddd4ecebe75fff16ef87f7a0303147a3ed Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 8 Oct 2026 14:53:26 +0200 Subject: [PATCH 24/52] docs: say which cache clear stops the workers, and how a module routes its messages --- docs/getting-started/background-workers.md | 5 +++++ docs/reference/background-jobs.md | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index f35f8ed2d..fbf4e576a 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -218,6 +218,11 @@ process manager starts new ones on the new release. The command leaves its signa application cache, and a worker only sees it in the cache it reads: when each release has its own `var/` directory, run the command from the release the workers were started from. +Outside a deployment, a cache clear made by the shop (`php Thelia thelia:cache:clear`, saving +the mail settings, turning a module on or off in the back office) stops the workers by itself +once their current message is done, and the process manager starts them again on the new cache. +Removing `var/cache/prod` by hand sends no such signal: stop the workers first, as above. + Messages queued during the deployment wait in the queue and are handled once the workers are back. The file of a queued import is kept in `var/data-transfer/import/`, outside the cache, so rebuilding the cache does not lose it. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index a23f415a3..08a69976e 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -427,7 +427,9 @@ final class StockSync extends BaseModule } ``` -`Message/` is excluded from the service scan: a message is data, not a service. +`Message/` is excluded from the service scan: a message is data, not a service. The message +class may also carry the route itself, with `#[AsMessage('async')]`. A message routed nowhere runs +in the request that dispatched it. A shop can also route the message itself, in `config/packages/messenger.yaml`, which takes precedence over the prepended module configuration: @@ -464,7 +466,8 @@ public static function configureContainer(ContainerConfigurator $containerConfig With `STOCK_SYNC_TRANSPORT_DSN` empty or unset, the pushes stay synchronous. A shop that wants them queued sets, for instance, `STOCK_SYNC_TRANSPORT_DSN=doctrine://default?queue_name=stock_sync` -and runs a worker with `php Thelia messenger:consume stock_sync`. Pushes that fail every +and runs a worker of its own with `php Thelia messenger:consume stock_sync`: the workers of the +shop do not consume it. Pushes that fail every attempt still go to the `failed` transport of the shop, and the back office lists them there, but it does not count the pushes waiting in `stock_sync`: watch that queue with `messenger:stats stock_sync`. From f245bcc53aead63cbdc03710f7f0081b48d16baa Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 8 Oct 2026 14:55:55 +0200 Subject: [PATCH 25/52] docs: say where a worker sees the restart signal, and not to launch an import in a transaction --- docs/architecture/background-jobs.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 5f75a2761..691011a24 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -207,6 +207,10 @@ modules active when it started. Clearing the cache of the shop (saving the mail turning a module on or off, `thelia:cache:clear`) therefore asks every worker to stop once its current message is done, as `messenger:stop-workers` does (`Thelia\Messenger\WorkerRestartSignal`), and its supervisor starts it again on the new cache. +A clear of the image or document cache leaves the workers alone. The signal is written in the +application cache pools, so workers running on another server only see it when +`THELIA_CACHE_DSN` points to a cache server they share; otherwise, run +`php Thelia messenger:stop-workers` on their server. A handler therefore never relies on what a visitor would have set. It receives ids, reads its data again, chooses the locale explicitly, and builds absolute URLs from `DEFAULT_URI`. @@ -302,7 +306,9 @@ An import runs in a single Propel transaction. Stopped half way, by an error, a or a deployment, it leaves the catalog as it was. Its outcome (the `done` status, the rows imported and the rows refused) is written to the job row inside the same transaction, so the catalog and the row never disagree. When the code that runs the import already holds a -transaction, the import works inside it and leaves the commit or the rollback to that code. +transaction, the import works inside it and leaves the commit or the rollback to that code: an +import that fails half way is then that code's to roll back, so do not launch an import inside a +transaction of your own. The rows an import has written stay locked until it ends: an order or a back-office edit touching one of them waits, then fails after `innodb_lock_wait_timeout`. Run a large stock or From 60cfac619fed181088693926e45c767230c3055b Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 8 Oct 2026 15:44:33 +0200 Subject: [PATCH 26/52] docs: tell the job retention margin, the archive entry limit and the DDEV worker the core ships --- docs/architecture/background-jobs.md | 16 ++++++++++------ docs/getting-started/background-workers.md | 7 ++++--- docs/reference/background-jobs.md | 8 ++++---- docs/security/personal-data.md | 6 +++--- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 691011a24..ca5842db5 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -333,7 +333,7 @@ checked before anything is written for the row. An archive is looked into before it is extracted, both when it is uploaded and when it is imported (`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more -than 1000 files, more than 512 MB once extracted, a name that is absolute or contains `..`, or a +than 1000 entries (folders included), more than 512 MB once extracted, a name that is absolute or contains `..`, or a link. A zip is read through its directory; a tar, compressed or not, is read header by header through its compression, never held in memory. The GNU long-name and pax records of a tar count against the limits like any entry, a pax record is read record by record for the name it @@ -349,7 +349,9 @@ is done, and kept while the job may be replayed; without a queue a failed job ca replayed, so its file is deleted at once. Deleting the failed job from the back office deletes its file too (`FailedJobRemovedEvent`), unless the import is still marked running. `messenger:failed:remove` and `thelia:messenger:purge-failed` leave the file to -`maintenance:purge`, which deletes it after 30 days at most. A file that cannot be deleted is +`maintenance:purge`, which deletes it after 31 days at most. A job deleted from the failed +jobs before it ever ran is marked failed, "Deleted from the failed jobs before it ran.", so its +row no longer reads as waiting for a worker. A file that cannot be deleted is left to the purge and logged as a warning: the job is still reported as it ended. A failure the job row cannot record (the database gone) is logged, and the message is set aside in `failed` all the same. The path of the file is stored relative to @@ -396,12 +398,14 @@ deleted. `maintenance:purge` deletes the export and import jobs that are `done` and were created more than 7 days ago (`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger::JOB_RETENTION_DAYS`), -with the files of the imports. Any other job, failed, queued or running, is kept 30 days -(`Thelia\Messenger\FailedMessagePurger::RETENTION_DAYS`), as long as the failed messages, so it -can still be replayed. The file of an import is only deleted when it lies inside +with the files of the imports. Any other job, failed, queued or running, is kept 31 days: the +30 days of the failed messages (`Thelia\Messenger\FailedMessagePurger::RETENTION_DAYS`) and one +more (`DataTransferJobPurger::REPLAY_MARGIN_DAYS`), since a failed message ages from the day it +was set aside, up to half a day after its row was written, and must still find its row when it +is replayed. The file of an import is only deleted when it lies inside `var/data-transfer/import` (`Thelia\Domain\DataTransfer\Job\ImportStorage::DIRECTORY`), whatever path its row holds. The purge -also removes the files of that directory older than 30 days, such as the extracted copy of an +also removes the files of that directory older than 31 days, such as the extracted copy of an archive left by a killed worker, or a file whose row was deleted by hand. The page of a job, and the download of an export, are only open to the administrator who diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index fbf4e576a..74baa7393 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -120,6 +120,7 @@ import. # /etc/systemd/system/thelia-worker@.service [Unit] Description=Thelia worker (%i) +# Name the unit of your database server, or drop it when the database runs elsewhere. After=network.target mariadb.service [Service] @@ -162,10 +163,10 @@ the request, exports are served and imports run at once. To try the queue, set `MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local` and let DDEV run the worker: ```yaml -# .ddev/config.yaml +# .ddev/config.worker.yaml, shipped with the core web_extra_daemons: - - name: "messenger" - command: "php Thelia messenger:consume async async_heavy --time-limit=3600 --memory-limit=256M" + - name: messenger-worker + command: "php bin/console messenger:consume async async_heavy --time-limit=3600 --memory-limit=256M" directory: /var/www/html ``` diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 08a69976e..5edc038fe 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -158,7 +158,7 @@ Exports and imports started from the back office are jobs. Both use the status e | With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | | Replay of a failed job | Starts the export over | Starts over from the first row | | Files | Deleted by `maintenance:purge` once older than a day | Kept in `var/data-transfer/import//` until the import is done, kept while it may be replayed | -| `maintenance:purge` | Deletes the done jobs older than 7 days, any other after 30 days | Deletes the done jobs older than 7 days, any other after 30 days, with their files | +| `maintenance:purge` | Deletes the done jobs older than 7 days, any other after 31 days | Deletes the done jobs older than 7 days, any other after 31 days, with their files | | Right to launch | `VIEW` on the export resource | `UPDATE` on the import resource; `VIEW` only shows the imports and their jobs | | Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | @@ -252,10 +252,10 @@ so they can still be replayed. `maintenance:purge` delegates to `Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: the `done` jobs created more than 7 days ago (`DataTransferJobPurger::JOB_RETENTION_DAYS`) are -deleted, any other (failed, queued, running) after 30 days. The file of an import is deleted +deleted, any other (failed, queued, running) after 31 days, a day past the failed message it may be replayed from. The file of an import is deleted with its row only when it lies inside `var/data-transfer/import` (`Thelia\Domain\DataTransfer\Job\ImportStorage`, `ImportStorage::DIRECTORY`). The files of that -directory older than 30 days are removed too, +directory older than 31 days are removed too, and its empty directories once they are more than a day old, since a fresh one may be about to receive an upload. @@ -298,7 +298,7 @@ Without a queue, a failed import can never be replayed, so its file is deleted a (`JobLifecycle::keepsFailedJobs()`). With a queue, deleting the failed import from the back office dispatches `Thelia\Messenger\Event\FailedJobRemovedEvent` and its file is deleted with it, unless the import is still marked running; `messenger:failed:remove` and -`thelia:messenger:purge-failed` leave the file to `maintenance:purge`, 30 days at most. A file that cannot be deleted is +`thelia:messenger:purge-failed` leave the file to `maintenance:purge`, 31 days at most. A file that cannot be deleted is logged and left to the purge; the job still ends as it did. When the job row cannot record its failure, the error is logged and the message is set aside in `failed` all the same. diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index 09767b08a..fedf8d1ca 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -165,9 +165,9 @@ of the customer and the content of the order. Their retention periods are fixed | Data | Purged by | Kept | | --- | --- | --- | | Export and import jobs that are done | `maintenance:purge` | 7 days | -| Other export and import jobs (failed, queued, running) | `maintenance:purge` | 30 days, as long as the failed jobs they can be replayed from | -| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from, or once its failed job is deleted from the back office (not by `messenger:failed:remove` nor `thelia:messenger:purge-failed`, which leave it to `maintenance:purge`, 30 days at most); a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | -| Files left in `var/data-transfer/import` | `maintenance:purge` | 30 days | +| Other export and import jobs (failed, queued, running) | `maintenance:purge` | 31 days, a day longer than the failed jobs they can be replayed from | +| Uploaded import files | The import itself, then `maintenance:purge` | Deleted once the import is done, or once it failed when there is no queue to replay it from, or once its failed job is deleted from the back office (not by `messenger:failed:remove` nor `thelia:messenger:purge-failed`, which leave it to `maintenance:purge`, 31 days at most); a file kept for a replay goes with its job, only when it lies under `var/data-transfer/import` | +| Files left in `var/data-transfer/import` | `maintenance:purge` | 31 days | | Failed jobs of the queue | `thelia:messenger:purge-failed`, and `maintenance:purge` | 30 days; `--older-than` changes it for `thelia:messenger:purge-failed` | The 30-day sweep of `var/data-transfer/import` removes what no job row points to any more, such From e48a2d85bd8e17b4ceb0b8f1f9dc9369331a7cce Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 8 Oct 2026 16:58:04 +0200 Subject: [PATCH 27/52] docs: tell the messages never built from a queue and how a worker meets a cache clear a job asked for --- docs/architecture/background-jobs.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index ca5842db5..d107165f1 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -207,6 +207,9 @@ modules active when it started. Clearing the cache of the shop (saving the mail turning a module on or off, `thelia:cache:clear`) therefore asks every worker to stop once its current message is done, as `messenger:stop-workers` does (`Thelia\Messenger\WorkerRestartSignal`), and its supervisor starts it again on the new cache. +A clear asked for by a job (a module handler that saves a setting) stops the worker running it +once the job is acknowledged, and the cache is cleared when that worker exits: clearing it +in the middle would delete the files the worker still loads its own listeners from. A clear of the image or document cache leaves the workers alone. The signal is written in the application cache pools, so workers running on another server only see it when `THELIA_CACHE_DSN` points to a cache server they share; otherwise, run @@ -459,6 +462,12 @@ building anything: built from a queue. A handler that takes any message (`*` or `object`) does not count, - a stamp must be a Messenger stamp or come from those same namespaces, and `SerializerStamp` is refused, as it would let the queue change how the rest of the envelope is read. +- a message that runs a console command, a process, an HTTP request or a scheduled service + call, or that dispatches another message (the classes under `Symfony\Component\Console\Messenger\`, + `Process\Messenger\`, `HttpClient\Messenger\`, `Scheduler\Messenger\`, `Cache\Messenger\` and + `Messenger\Message\`), is never built from a queue, even when a project lists it: whoever writes + to the queue would run anything on the server. The schedule of the shop is not affected, as + its messages never go through a queue. Whether a message class belongs to an active module is read from the database each time. When MySQL closed the connection of the worker during a long wait, the Propel connection is opened @@ -494,6 +503,9 @@ For a module author, this means: A class from outside these namespaces, such as an application class under `App\`, is accepted only once a project adds its exact name to `thelia.messenger.allowed_message_classes`. +The check for files a mail would make the worker read only applies to Symfony's +`SendEmailMessage`: a listed class that carries an `Email` of its own must refuse attachments by +path itself. ## Personal data From a5970ea2c252c3fd00a8202532882dfe823d2b80 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 8 Oct 2026 16:58:35 +0200 Subject: [PATCH 28/52] docs: say the mail transport of MailerFactory is required and list the archiver and import handler changes --- docs/upgrading/breaking-changes.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/upgrading/breaking-changes.md b/docs/upgrading/breaking-changes.md index fb024e48e..a6f3afe8d 100644 --- a/docs/upgrading/breaking-changes.md +++ b/docs/upgrading/breaking-changes.md @@ -29,11 +29,13 @@ The `default-twig` back-office theme requires `thelia/core ^3.3` in its `compose `processExport()` and `processImport()` keep their signature. `ImportHandler::import()` now extracts an archive through a private method, so an override of `extractArchive()` is no longer called by it. +`ImportHandler` takes a fourth constructor argument, `Thelia\Domain\DataTransfer\ArchiveInspector`: a module that builds or extends it passes it. `AbstractArchiver` implements the new `Thelia\Core\Archiver\ClosableArchiverInterface` (`close(): bool`, `discard(): void`): an archiver of a module that declares either method with another signature must align it. + ### Mails - The order history line `email_sent` is written once the mail server has taken the mail, by `Thelia\Mailer\EventListener\OrderEmailHistoryListener` on the Symfony Mailer `SentMessageEvent`, and no longer by `MailerFactory` right after handing the mail over. Without a queue this happens in the same request; with one, it is the worker that writes it, later, and the author of the line is `system`. - `MailerFactory` names the order in two headers of the mail, `X-Thelia-Order-Id` and `X-Thelia-Message-Code`. They travel through the queue and are removed right before the mail is handed to the mail server. -- `MailerFactory` no longer takes an `OrderHistoryRecorder`. Its fourth constructor argument is now an optional `Symfony\Component\Mailer\Transport\TransportInterface`, used by `sendNow()`. A module that builds it with `new` and still passes the recorder gets a `TypeError`. +- `MailerFactory` no longer takes an `OrderHistoryRecorder`. Its fourth constructor argument is now the required `Symfony\Component\Mailer\Transport\TransportInterface` (the `mailer.transports` service), used by `sendNow()`. A module that builds it with `new` and still passes the recorder gets a `TypeError`. - With a queue, `sendEmailMessageOrFail()` and the methods built on it only throw when the mail could not be built or queued. A delivery failure no longer reaches the caller: the mail is set aside in `failed`. - A `TemplatedEmail` that is not rendered yet is refused when it is queued, with a `LogicException`: the worker renders nothing. Render it before sending it (`BodyRenderer`), or send it without a queue. From e24b815af7e609af8e8be098e4195925f993ad71 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 10:30:31 +0200 Subject: [PATCH 29/52] docs: tell the cache clear of a recurring task run by a worker --- docs/architecture/background-jobs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index d107165f1..3be40888e 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -207,8 +207,8 @@ modules active when it started. Clearing the cache of the shop (saving the mail turning a module on or off, `thelia:cache:clear`) therefore asks every worker to stop once its current message is done, as `messenger:stop-workers` does (`Thelia\Messenger\WorkerRestartSignal`), and its supervisor starts it again on the new cache. -A clear asked for by a job (a module handler that saves a setting) stops the worker running it -once the job is acknowledged, and the cache is cleared when that worker exits: clearing it +A clear asked for by a job (a module handler that saves a setting), or by the command of a +recurring task the worker runs, stops the worker running it once the job is acknowledged, and the cache is cleared when that worker exits: clearing it in the middle would delete the files the worker still loads its own listeners from. A clear of the image or document cache leaves the workers alone. The signal is written in the application cache pools, so workers running on another server only see it when From 628392dc0657674be7db5b7a66d67ff6f869e70a Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 10:57:23 +0200 Subject: [PATCH 30/52] docs: say a worker's cache clear runs when its command ends, whatever stopped it --- docs/architecture/background-jobs.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 3be40888e..bd70cc344 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -208,8 +208,9 @@ turning a module on or off, `thelia:cache:clear`) therefore asks every worker to current message is done, as `messenger:stop-workers` does (`Thelia\Messenger\WorkerRestartSignal`), and its supervisor starts it again on the new cache. A clear asked for by a job (a module handler that saves a setting), or by the command of a -recurring task the worker runs, stops the worker running it once the job is acknowledged, and the cache is cleared when that worker exits: clearing it -in the middle would delete the files the worker still loads its own listeners from. +recurring task the worker runs, stops the worker running it once the job is acknowledged, and +the cache is cleared when that worker's command ends, whatever stopped it: clearing it in the +middle would delete the files the worker still loads its own listeners from. A clear of the image or document cache leaves the workers alone. The signal is written in the application cache pools, so workers running on another server only see it when `THELIA_CACHE_DSN` points to a cache server they share; otherwise, run From 076048705eebe8ca989007b66d9da53a5889c860 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 11:40:20 +0200 Subject: [PATCH 31/52] docs: say a fatal error or a killed worker loses the cache clear it held --- docs/architecture/background-jobs.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index bd70cc344..1129ccac8 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -209,8 +209,9 @@ current message is done, as `messenger:stop-workers` does (`Thelia\Messenger\WorkerRestartSignal`), and its supervisor starts it again on the new cache. A clear asked for by a job (a module handler that saves a setting), or by the command of a recurring task the worker runs, stops the worker running it once the job is acknowledged, and -the cache is cleared when that worker's command ends, whatever stopped it: clearing it in the -middle would delete the files the worker still loads its own listeners from. +the cache is cleared when that worker's command ends, an exception included: clearing it in +the middle would delete the files the worker still loads its own listeners from. A worker +stopped by a fatal error or killed loses the clear: run `php Thelia thelia:cache:clear` then. A clear of the image or document cache leaves the workers alone. The signal is written in the application cache pools, so workers running on another server only see it when `THELIA_CACHE_DSN` points to a cache server they share; otherwise, run From 9ccb0ebd268ad438be9794563bb037a19d8bd750 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 11:41:59 +0200 Subject: [PATCH 32/52] docs: describe the job lifecycle as it is now, its table an enum --- docs/architecture/background-jobs.md | 10 +++++----- docs/reference/background-jobs.md | 13 +++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 1129ccac8..2dc072db4 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -292,8 +292,8 @@ a reason written for the administrator is logged on one line. A module whose exc a message meant for the administrator implements `UserFacingFailure` on it. A message that fails before its job is taken (its row cannot be read, the claim fails, or the -queue refuses the look-again message) is set aside through `JobLifecycle::reject()`, with the -same sanitized text only. Its row is left as it is: it may be unreadable, or held by another +queue refuses the look-again message) is set aside by `JobLifecycle::take()`, with the same +sanitized text only. Its row is left as it is: it may be unreadable, or held by another run. An import started from the back office is a job too. @@ -366,9 +366,9 @@ Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `que `running`, `done`, `failed`. Their models implement `Thelia\Domain\DataTransfer\Job\DataTransferJob` and their messages, `RunExportJob` and `RunImportJob`, `Thelia\Domain\DataTransfer\Job\DataTransferJobMessage`. The handlers go through -`Thelia\Domain\DataTransfer\Job\JobLifecycle`, which dispatches a job, claims it or postpones -it (`claimOrPostpone()`, which returns `ClaimOutcome::Owned`, `Finished` or `Postponed`) and -records its failure. A worker claims a job atomically before running it +`Thelia\Domain\DataTransfer\Job\JobLifecycle`, which dispatches a job (`dispatch()`), takes it +for a run (`take()`: the job when this run owns it, `null` when another run holds it or +finished it, which is then looked at again later) and records its failure (`fail()`). A worker claims a job atomically before running it (`Thelia\Domain\DataTransfer\Job\JobClaim`, a service), so two workers handed the same job never run it at the same time. A job left `running` is taken again once it has given no sign of life for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 5edc038fe..b96575eca 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -215,12 +215,13 @@ final class WarehouseRefusedException extends \RuntimeException implements \Thel } ``` -`JobLifecycle` dispatches a job, claims it or postpones it -(`claimOrPostpone(DataTransferJob $job, DataTransferJobMessage $message): ClaimOutcome`, the table -coming from `DataTransferJob::tableName()`), sets aside a message that fails before its job is -taken (`reject()`) and records the failure of a job (`fail()`); it reads from `Thelia\Messenger\Transport\ConfiguredQueues` whether the heavy jobs run +`JobLifecycle` dispatches a job (`dispatch()`), takes it for a run +(`take(DataTransferJobMessage $message, \Closure $find): ?DataTransferJob`: claimed, or looked at +again later, the table coming from `DataTransferJobMessage::jobTable()`, a `JobTable`), sets +aside a message that fails before its job is taken, and records the failure of a job +(`fail(DataTransferJobMessage $message, DataTransferJob $job, \Throwable $exception)`); it reads from `Thelia\Messenger\Transport\ConfiguredQueues` whether the heavy jobs run without a queue (`heavyJobsRunInline()`). A worker claims a job -atomically (`JobClaim::claim(string $table, int $jobId, bool $allowFailed = true)`, an injected +atomically (`JobClaim::claim(JobTable $table, int $jobId, bool $allowFailed = true)`, an injected service) before running it: two workers handed the same job never run it at the same time. A job left `running` is taken again once its row has not been updated for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). An @@ -243,7 +244,7 @@ a failed job. Without a queue a message is never postponed. A job whose row was deleted fails for good and stays in `failed`. A message that fails before its job is taken (the row cannot be read, the claim fails, the look-again message is refused) -goes to `failed` through `JobLifecycle::reject()` with the sanitized reason only, and its row is +goes to `failed` through `JobLifecycle::take()` with the sanitized reason only, and its row is left untouched. A job the queue refuses at dispatch is recorded as failed with "The job could not be queued. The details are in the server log." (`JobLifecycle::NOT_QUEUED`), and the file of such an import is deleted. Failed From c8082e15976fd9a3b1d8bcd66f7a21d230e8f9d5 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 12:14:40 +0200 Subject: [PATCH 33/52] docs: name the class that reads the period of an export --- docs/reference/background-jobs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index b96575eca..b16519d1c 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -193,7 +193,7 @@ raised when an uploaded file is refused), `DataTransferNoDataFoundException`, `Thelia\Form\Exception\FormValidationException` itself does not implement it. An export whose year is not four digits or whose month is not 1 to 12 -(`ExportHandler::resolveRangeDate()`, called by the launcher and by the export) is refused with +(`Thelia\Domain\DataTransfer\Export\ExportPeriod::resolve()`, called by the launcher and by the export) is refused with "The dates of the export are not valid.", and one whose format is no longer available on the server with `The format "" is no longer available on this server.`. Both are a `JobRefusedException`, which the administrator reads as it is. From be0554899f1cd05f703e5b9b5818aad8b2f3dc3d Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 13:23:06 +0200 Subject: [PATCH 34/52] docs: run the web server and the workers under the same system user --- docs/getting-started/background-workers.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 74baa7393..2c9c6c9a6 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -234,6 +234,10 @@ export file the back office serves. When each release has its own `var/` directo workers run on another machine, point both at the same storage, and never empty them in a deployment while jobs wait. +Run the web server and the workers under the same system user. The shop closes the export +folder to every other account of the server (mode 0700), since its files hold the data of the +customers: a worker running as another user writes exports the back office cannot serve. + ## Personal data in the queue Queued mails carry the recipient address and the content of the order, and a failed job keeps From f489517cb3722673d59b41d839e8d4dfe8be07eb Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 13:56:46 +0200 Subject: [PATCH 35/52] docs: run the shop and its workers under one user or one group, as the export and import files are 0640 --- docs/getting-started/background-workers.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 2c9c6c9a6..ca52758e9 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -234,9 +234,12 @@ export file the back office serves. When each release has its own `var/` directo workers run on another machine, point both at the same storage, and never empty them in a deployment while jobs wait. -Run the web server and the workers under the same system user. The shop closes the export -folder to every other account of the server (mode 0700), since its files hold the data of the -customers: a worker running as another user writes exports the back office cannot serve. +Run the web server, the workers and any crontab or script that exports or runs +`maintenance:purge` under the same system user, or under users that share a group. The shop +creates the exports, their archives and the uploaded imports readable by their owner and their +group only (mode 0640, in folders it makes 0750), since they hold the data of the customers: an +export written by a worker under a user outside that group could not be served by the back +office, nor an import it stored read by a worker. ## Personal data in the queue From 77ee10764143906645bc2bb2854dad9c690f5430 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 14:32:48 +0200 Subject: [PATCH 36/52] docs: the folders of the exports and imports are writable by their group, and a folder made before keeps its mode --- docs/getting-started/background-workers.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index ca52758e9..d07f2d232 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -237,8 +237,10 @@ deployment while jobs wait. Run the web server, the workers and any crontab or script that exports or runs `maintenance:purge` under the same system user, or under users that share a group. The shop creates the exports, their archives and the uploaded imports readable by their owner and their -group only (mode 0640, in folders it makes 0750), since they hold the data of the customers: an -export written by a worker under a user outside that group could not be served by the back +group only (mode 0640), since they hold the data of the customers, in folders it makes writable +by their group (mode 0770), since a worker deletes the import the web server stored and the purge +removes what either left. A folder made before keeps its mode: check it when the users differ. +An export written by a worker under a user outside that group could not be served by the back office, nor an import it stored read by a worker. ## Personal data in the queue From 5994efbe962c226221ca9ce9d55770231d149507 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Fri, 9 Oct 2026 14:56:15 +0200 Subject: [PATCH 37/52] docs: the group reads the files of the exports and imports, and how to fix the mode of a folder made before --- docs/getting-started/background-workers.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index d07f2d232..693b27c7c 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -239,7 +239,9 @@ Run the web server, the workers and any crontab or script that exports or runs creates the exports, their archives and the uploaded imports readable by their owner and their group only (mode 0640), since they hold the data of the customers, in folders it makes writable by their group (mode 0770), since a worker deletes the import the web server stored and the purge -removes what either left. A folder made before keeps its mode: check it when the users differ. +removes what either left; the group reads a file, it never rewrites one. A folder made before keeps +its mode: when the users differ, check `var/data-transfer/import` and the export folder of the +cache, and give them `chmod 0770` if they were made more open or more closed. An export written by a worker under a user outside that group could not be served by the back office, nor an import it stored read by a worker. From e9ae7e32764854b1528169e0cb562e52291579e5 Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 12 Oct 2026 10:38:22 +0200 Subject: [PATCH 38/52] docs: keep the jobs 31 days, share the lock across servers, send a mail once, and try the example step by step --- docs/architecture/background-jobs.md | 13 ++++- docs/getting-started/background-workers.md | 5 ++ docs/reference/background-jobs.md | 60 +++++++++++++++++++++- docs/reference/cli/maintenance_purge.md | 6 +-- docs/security/personal-data.md | 2 +- docs/upgrading/breaking-changes.md | 6 ++- docs/upgrading/update.md | 7 ++- 7 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 2dc072db4..9e115cbe4 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -239,6 +239,14 @@ mail and are removed right before it is handed to the mail server, so the custom receives them. With a worker, the line appears when the worker delivered the mail, and its author is `system`. +A queue may hand a job out twice: when a worker stops after the mail server accepted the mail +but before the job was acknowledged, the job comes back once its redelivery delay is over. +`MailerFactory` gives every mail an identifier in an `X-Thelia-Mail-Id` header; a worker keeps +the identifiers of the mails the server accepted for a week, in the `thelia.cache.sent_mails` +pool, and does not send a mail whose identifier it kept. Workers on several servers share them +when `THELIA_CACHE_DSN` points to a cache server they share. A mail sent with the Symfony +mailer directly, not through `MailerFactory`, carries no identifier and is not guarded. + With a queue, `MailerFactory::send*OrFail()` only throws when the mail could not be built or queued. A delivery failure happens later, in the worker. `MailerFactory::sendNow()` hands a mail to the mail server at once, queue or not: the back office uses it to test the mail @@ -432,7 +440,10 @@ The recurring tasks of the shop are declared in a Symfony Scheduler schedule nam (`Thelia\Scheduler\TheliaSchedule`). A worker consuming `scheduler_thelia` runs them; until one does, nothing runs, and a shop can keep its crontab instead. The schedule is stateful and locked through `LOCK_DSN`, so two workers never run a task twice, and a worker that restarts -after a missed run catches up the last one only. The tasks and their settings are listed in +after a missed run catches up the last one only. Across servers this holds only when both are +shared: `LOCK_DSN` must name a store every worker reaches (not the default `flock`), and +`THELIA_CACHE_DSN` a cache server they all use; otherwise each server runs the tasks on its own, +and a cache clear forgets the last runs. The tasks and their settings are listed in the [reference](../reference/background-jobs.md#recurring-tasks). A task of the schedule that fails does not go to `failed`: Messenger only sets aside what came diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 693b27c7c..9be016526 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -30,6 +30,11 @@ Do not consume `scheduler_thelia` when the same commands already run from a cron (`maintenance:purge`, `sale:check-activation`, `thelia:messenger:purge-failed`): each would run twice. See the warning in the [recurring tasks reference](../reference/background-jobs.md#recurring-tasks). +With workers on several servers, run the scheduler worker on one of them, or share the lock +and the cache: `LOCK_DSN` must name a store every server reaches (the default `flock` locks one +server only) and `THELIA_CACHE_DSN` a cache server they all use, which keeps the last run of +each task. Otherwise each server runs every task. + A Redis or AMQP queue needs its Messenger bridge, which the core does not ship: `composer require symfony/redis-messenger` (with the `redis` PHP extension) or `composer require symfony/amqp-messenger` (with the `amqp` extension). The queue in the shop diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index b16519d1c..423b25cbd 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -139,7 +139,8 @@ the date. A command that exits with an error code gives The "Details" link of an export or import is only shown to the administrator who started the job and to super-administrators. -The screen answers to the resource `admin.configuration.background-jobs`. Grant `VIEW` to read +The screen answers to the resource `admin.configuration.background-jobs`, given to no existing +profile by the update: until it is granted, only super-administrators see the screen. Grant `VIEW` to read it, `UPDATE` to replay and `DELETE` to delete. The description of a mail names its recipient and a failure reason may quote personal data, so give it only to the profiles that need it. @@ -826,6 +827,63 @@ The command only dispatches messages. With a queue, the pushes are spread over t worker; without one, they run during the command. Combinations whose quantity did not change are skipped by the handler, so the nightly run only calls the API for real changes. +### Trying it + +With the files above in `local/modules/StockSync/`, a local stand-in for the warehouse API is +enough to watch a push go through the queue. + +1. Install and activate the module. Activation creates its two tables: + + ```bash + composer dump-autoload + php Thelia module:refresh + php Thelia module:activate StockSync + ``` + +2. Start a fake warehouse that answers `200` and keeps what it receives: + + ```bash + mkdir -p /tmp/warehouse + echo ' /tmp/warehouse/index.php + php -S 127.0.0.1:8099 -t /tmp/warehouse /tmp/warehouse/index.php + ``` + +3. Give the module its two settings. The example has no configuration page; a module setting + is stored in `module_config` and its value in `module_config_i18n`, under `en_US` when no + locale is given: + + ```sql + INSERT INTO module_config (module_id, name, created_at, updated_at) + SELECT id, 'api_url', NOW(), NOW() FROM module WHERE code = 'StockSync'; + INSERT INTO module_config_i18n (id, locale, value) VALUES (LAST_INSERT_ID(), 'en_US', 'http://127.0.0.1:8099'); + INSERT INTO module_config (module_id, name, created_at, updated_at) + SELECT id, 'api_token', NOW(), NOW() FROM module WHERE code = 'StockSync'; + INSERT INTO module_config_i18n (id, locale, value) VALUES (LAST_INSERT_ID(), 'en_US', 'test-token'); + ``` + +4. With `MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local`, change the stock of a + combination in the back office. The page answers at once and the message waits: + + ```bash + php Thelia messenger:stats async + ``` + +5. Run a worker for one message, then read what the warehouse received and what the module + recorded: + + ```bash + php Thelia messenger:consume async --limit=1 -vv + cat /tmp/warehouse/received.log + ``` + + ```sql + SELECT * FROM stock_sync_log ORDER BY id DESC LIMIT 1; + ``` + +Without a queue, the push happens during the save of the stock, in the same request. Stop the +fake warehouse and change the stock again to see a retry: the worker logs the failure, waits 30 +seconds, and sets the message aside in `failed` after three more attempts. + ### Turning the module off A queue accepts the messages of active modules only. Messages of `StockSync` still waiting when diff --git a/docs/reference/cli/maintenance_purge.md b/docs/reference/cli/maintenance_purge.md index 0f20ccde8..2193704c3 100644 --- a/docs/reference/cli/maintenance_purge.md +++ b/docs/reference/cli/maintenance_purge.md @@ -40,10 +40,10 @@ listens to it, purges its own tables, and appends a line to the report with The core listens to the same event to purge the back-office exports and imports, through `Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: the done jobs older than 7 days, -any other (failed, queued, running) after 30 days, as long as the failed jobs they can be +any other (failed, queued, running) after 31 days, a day longer than the failed jobs they can be replayed from, the uploaded file of each deleted import when it lies inside -`var/data-transfer/import`, and the files of that directory older than 30 days. The same listener removes the export files of the cache older -than a day. These periods are fixed. +`var/data-transfer/import`, and the files of that directory older than 31 days. The same listener removes the export files of the cache older +than a day; with `--dry-run`, it counts them and deletes none. These periods are fixed. The failed jobs set aside in `failed` for more than 30 days are deleted on the same run (`Thelia\Messenger\EventListener\FailedJobsMaintenancePurgeListener`), as diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index fedf8d1ca..029d74855 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -170,7 +170,7 @@ of the customer and the content of the order. Their retention periods are fixed | Files left in `var/data-transfer/import` | `maintenance:purge` | 31 days | | Failed jobs of the queue | `thelia:messenger:purge-failed`, and `maintenance:purge` | 30 days; `--older-than` changes it for `thelia:messenger:purge-failed` | -The 30-day sweep of `var/data-transfer/import` removes what no job row points to any more, such +The 31-day sweep of `var/data-transfer/import` removes what no job row points to any more, such as the extracted copy of an archive left by a worker that was killed, or a file whose row was deleted by hand. The `thelia` schedule runs `thelia:messenger:purge-failed` every day at 04:00; a shop that runs its tasks from a crontab adds it there. Record both retentions in the GDPR diff --git a/docs/upgrading/breaking-changes.md b/docs/upgrading/breaking-changes.md index a6f3afe8d..f21ee09dd 100644 --- a/docs/upgrading/breaking-changes.md +++ b/docs/upgrading/breaking-changes.md @@ -13,6 +13,10 @@ For the update procedure itself, see [Update](./update.md). The update gestures (the Symfony Messenger recipe, the shared directories, the queue of the heavy jobs) are in [Updating to 3.3: background jobs](./update.md#updating-to-33-background-jobs). See [Background jobs](../architecture/background-jobs.md) for the model. +### Doctrine DBAL 4 + +The core requires `doctrine/dbal ^4.2`, for the queue in the shop database (DBAL alone, no ORM). A project or a module held on DBAL 3 must move to DBAL 4 before it updates. + ### Back-office theme The `default-twig` back-office theme requires `thelia/core ^3.3` in its `composer.json`. Update the core and the theme together. @@ -34,7 +38,7 @@ The `default-twig` back-office theme requires `thelia/core ^3.3` in its `compose ### Mails - The order history line `email_sent` is written once the mail server has taken the mail, by `Thelia\Mailer\EventListener\OrderEmailHistoryListener` on the Symfony Mailer `SentMessageEvent`, and no longer by `MailerFactory` right after handing the mail over. Without a queue this happens in the same request; with one, it is the worker that writes it, later, and the author of the line is `system`. -- `MailerFactory` names the order in two headers of the mail, `X-Thelia-Order-Id` and `X-Thelia-Message-Code`. They travel through the queue and are removed right before the mail is handed to the mail server. +- `MailerFactory` names the order in two headers of the mail, `X-Thelia-Order-Id` and `X-Thelia-Message-Code`, and gives every mail an identifier in `X-Thelia-Mail-Id`. They travel through the queue and are removed right before the mail is handed to the mail server. A worker keeps the identifiers of the mails the server accepted for a week, and does not send a mail whose identifier it kept: the queue may hand a job out twice when a worker stopped before acknowledging it. - `MailerFactory` no longer takes an `OrderHistoryRecorder`. Its fourth constructor argument is now the required `Symfony\Component\Mailer\Transport\TransportInterface` (the `mailer.transports` service), used by `sendNow()`. A module that builds it with `new` and still passes the recorder gets a `TypeError`. - With a queue, `sendEmailMessageOrFail()` and the methods built on it only throw when the mail could not be built or queued. A delivery failure no longer reaches the caller: the mail is set aside in `failed`. - A `TemplatedEmail` that is not rendered yet is refused when it is queued, with a `LogicException`: the worker renders nothing. Render it before sending it (`BodyRenderer`), or send it without a queue. diff --git a/docs/upgrading/update.md b/docs/upgrading/update.md index 83f49df9d..64dfea24a 100644 --- a/docs/upgrading/update.md +++ b/docs/upgrading/update.md @@ -142,9 +142,10 @@ run the module's own `update()` method: ```bash php Thelia module:refresh -php Thelia cache:clear ``` +Then rebuild the cache as in [step 3](#3-rebuild-the-cache), workers stopped. + ## Notes for each version Each release can ask for something the generic procedure above does not cover: a setting to @@ -183,6 +184,10 @@ Check these points when you cross it: - Launching an import from the back office takes the `UPDATE` right on `admin.import`; `VIEW` now only shows the imports and their jobs. Review the profiles of the administrators who import. +- The new screen Configuration > System > Background jobs answers to the resource + `admin.configuration.background-jobs`, which the update gives to no profile: grant it to the + profiles that follow the jobs. Until then, only super-administrators see it. +- The core requires `doctrine/dbal ^4.2`: a project or a module held on DBAL 3 moves to DBAL 4. ## Recommendations From b59d749d868efde0452a9b943466cd66c149555a Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 12 Oct 2026 11:20:06 +0200 Subject: [PATCH 39/52] docs: make the settings of the example replayable and tell what saving every combination queues --- docs/reference/background-jobs.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 423b25cbd..a127fc319 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -349,6 +349,8 @@ Without a queue, an exception thrown by the handler reaches the code that dispat message: the save of the combination in the back office fails with it. A shop that relies on a remote API this way should run a queue. +The first line of each block below names the file it goes in; it is not part of the file. + ``` local/modules/StockSync/ ├── StockSync.php @@ -854,25 +856,33 @@ enough to watch a push go through the queue. ```sql INSERT INTO module_config (module_id, name, created_at, updated_at) - SELECT id, 'api_url', NOW(), NOW() FROM module WHERE code = 'StockSync'; - INSERT INTO module_config_i18n (id, locale, value) VALUES (LAST_INSERT_ID(), 'en_US', 'http://127.0.0.1:8099'); - INSERT INTO module_config (module_id, name, created_at, updated_at) - SELECT id, 'api_token', NOW(), NOW() FROM module WHERE code = 'StockSync'; - INSERT INTO module_config_i18n (id, locale, value) VALUES (LAST_INSERT_ID(), 'en_US', 'test-token'); + SELECT m.id, s.name, NOW(), NOW() + FROM module m, (SELECT 'api_url' AS name UNION ALL SELECT 'api_token') s + WHERE m.code = 'StockSync' + AND NOT EXISTS (SELECT 1 FROM module_config c WHERE c.module_id = m.id AND c.name = s.name); + INSERT INTO module_config_i18n (id, locale, value) + SELECT c.id, 'en_US', IF(c.name = 'api_url', 'http://127.0.0.1:8099', 'test-token') + FROM module_config c JOIN module m ON m.id = c.module_id + WHERE m.code = 'StockSync' AND c.name IN ('api_url', 'api_token') + ON DUPLICATE KEY UPDATE value = VALUES(value); ``` -4. With `MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local`, change the stock of a - combination in the back office. The page answers at once and the message waits: + Run again, it changes the two values without adding rows. + +4. With `MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local` and no worker running, + change the stock of a combination in the back office. The page answers at once and the + messages wait: "Save all combinations" saves every row of the product, so one message per + combination is queued, and the handler skips those whose quantity did not change: ```bash php Thelia messenger:stats async ``` -5. Run a worker for one message, then read what the warehouse received and what the module - recorded: +5. Run a worker until the queue is empty, then read what the warehouse received and what the + module recorded: ```bash - php Thelia messenger:consume async --limit=1 -vv + php Thelia messenger:consume async --time-limit=10 -vv cat /tmp/warehouse/received.log ``` From 3835d7eab75f40e26a9b828835f9bfebe566819c Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Thu, 22 Oct 2026 10:03:17 +0200 Subject: [PATCH 40/52] docs: list the known limits of the background jobs in the developer guide --- docs/reference/background-jobs.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index a127fc319..b114e8487 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -934,3 +934,17 @@ configuration missing). The message goes to `failed` at once, where it can be re the cause is fixed. Test the replay: dispatch the message twice in a test and check that the effect happened once. + +### Known limits + +Two cases are not guarded by the core in this version: + +- A failed job replayed from the back office and with `messenger:failed:retry` at the same + moment may be sent twice: the command reads the job without holding it while it asks what to + do with it. Replay a job from one place at a time. +- A mail sent with the Symfony mailer directly, instead of `MailerFactory`, carries no + identifier: when the queue hands its job out again (a worker stopped between the mail server + accepting the mail and the job being acknowledged), it is sent a second time. Send the mails + of a module through `MailerFactory`, which a worker never delivers twice. + +Both are a reason more to keep every handler idempotent. From 94055de174af646e970702eacc5563e9eecda730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:56:28 +0200 Subject: [PATCH 41/52] docs(background-jobs): describe the jobs of the example module with DescribedJob --- docs/architecture/background-jobs.md | 5 ++++- docs/reference/background-jobs.md | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 9e115cbe4..6b618934c 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -512,7 +512,10 @@ For a module author, this means: a `LogicException` when it goes to a queue, with a `NoHandlerForMessageException` without one, - they can be written and read as JSON by the Symfony serializer: public or promoted constructor properties with scalar types, such as ids and codes, -- they never carry a Propel model. The handler reads the row again from its id. +- they never carry a Propel model. The handler reads the row again from its id, +- they implement `Thelia\Messenger\Message\DescribedJob`, so the back office lists a failed job + by a line that says what it was, with no personal data in it. See + [the message of the example module](../reference/background-jobs.md#the-message). A class from outside these namespaces, such as an application class under `App\`, is accepted only once a project adds its exact name to `thelia.messenger.allowed_message_classes`. diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index b114e8487..82c759415 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -590,18 +590,42 @@ declare(strict_types=1); namespace StockSync\Message; -final readonly class PushStock +use Thelia\Messenger\Message\DescribedJob; + +final readonly class PushStock implements DescribedJob { public function __construct( public int $productSaleElementsId, ) { } + + public function describe(): string + { + return \sprintf('Stock push of combination #%d', $this->productSaleElementsId); + } } ``` Never put a Propel model in a message. It cannot be serialized as JSON, and by the time the worker reads the message the row may have changed or been deleted. +`Thelia\Messenger\Message\DescribedJob` gives the job the line the Background jobs screen shows +for it among the failed jobs. A message that does not implement it is listed by the short name +of its class (`PushStock`), which tells the administrator nothing about which combination +failed. `describe()` runs when the screen is displayed, on the message read back from the +queue. + +The description is shown to every administrator who may read the screen, and it stays as long +as the failed job does. Never put personal data in it: no name, e-mail address or postal +address of a customer, no order content. Give ids and references, which the administrator can +look up with their own rights. + +`Thelia\Messenger\Message\ReplayableJob` is for a message that carries a state a replay must +reset, such as the number of times it was postponed: its `forReplay()` returns the message to +put back on the queue when the job is replayed, from the back office or with +`messenger:failed:retry`. `PushStock` carries only an id, so it has nothing to reset and does +not implement it. + ### The handler The handler re-reads the combination, compares it with what was pushed last time, calls the From 204f8de9605832c315677ae05fdf00858805ea80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:56:28 +0200 Subject: [PATCH 42/52] docs(background-jobs): show how a module tests its message, its handler and its dispatch --- docs/reference/background-jobs.md | 205 +++++++++++++++++++++++++++++- docs/testing/index.md | 2 +- 2 files changed, 203 insertions(+), 4 deletions(-) diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 82c759415..a5fd74f0a 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -368,7 +368,10 @@ local/modules/StockSync/ │ └── PushStock.php ├── MessageHandler/ │ └── PushStockHandler.php -└── Model/ # generated by module:generate:model +├── Model/ # generated by module:generate:model +└── Tests/ + ├── PushStockHandlerTest.php + └── StockChangedListenerTest.php ``` ### The module class @@ -403,6 +406,7 @@ final class StockSync extends BaseModule __DIR__.'/I18n/*', __DIR__.'/Model/*', __DIR__.'/Message/*', + __DIR__.'/Tests/*', __DIR__.'/StockSync.php', ]) ->autowire() @@ -431,7 +435,8 @@ final class StockSync extends BaseModule } ``` -`Message/` is excluded from the service scan: a message is data, not a service. The message +`Message/` is excluded from the service scan: a message is data, not a service. `Tests/` is +excluded too: a test class is not a service, and PHPUnit is not installed in production. The message class may also carry the route itself, with `#[AsMessage('async')]`. A message routed nowhere runs in the request that dispatched it. @@ -957,7 +962,8 @@ Use `UnrecoverableMessageHandlingException` when a retry cannot succeed (data re configuration missing). The message goes to `failed` at once, where it can be replayed once the cause is fixed. -Test the replay: dispatch the message twice in a test and check that the effect happened once. +Test the replay: run the handler twice in a test and check that the effect happened once, as +in [Testing a message, its handler and its dispatch](#testing-a-message-its-handler-and-its-dispatch). ### Known limits @@ -972,3 +978,196 @@ Two cases are not guarded by the core in this version: of a module through `MailerFactory`, which a worker never delivers twice. Both are a reason more to keep every handler idempotent. + +## Testing a message, its handler and its dispatch + +Three things are worth a test in a module that queues work: that the right message is +dispatched, that the handler does its work, and that running the handler twice has the effect +of running it once. A fourth test checks that the queue of the shop accepts the message, which +otherwise only shows when a real queue refuses it. + +Test them apart. The tests of the core run without a queue: `tests/bootstrap.php` empties +`MESSENGER_TRANSPORT_DSN` and `MESSENGER_HEAVY_TRANSPORT_DSN`, so `async` is synchronous and a +message dispatched through the bus of the shop is handled at once, by the real handler. A module +test that goes through that bus cannot tell "dispatched" from "handled", and its handler calls +the real API. Give the listener a bus of its own instead, and call the handler directly. + +### The dispatch, without a database + +The listener receives a `MessageBus` that sends `PushStock` to an `InMemoryTransport` and has no +handler. `getSent()` returns what was queued; nothing ran. + +```php +// local/modules/StockSync/Tests/StockChangedListenerTest.php + ['stock_sync']], + new ServiceLocator(['stock_sync' => static fn (): InMemoryTransport => $queue]), + )), + ]); + + (new StockChangedListener($bus))->onStockChanged(new ProductSaleElementUpdateEvent(new Product(), 42)); + + $sent = $queue->getSent(); + self::assertCount(1, $sent); + self::assertEquals(new PushStock(42), $sent[0]->getMessage()); + } +} +``` + +### The handler and its replay, with the test database + +The handler reads the database, so its test extends `Thelia\Test\IntegrationTestCase`, which +runs each test in a transaction rolled back at the end (see [Testing](../testing/index.md)). The +handler is built by hand with a `MockHttpClient`, which answers in place of the warehouse and +counts the requests. Calling it twice with the same message is the replay: the second call must +not reach the warehouse, and only one push is logged. + +```php +// local/modules/StockSync/Tests/PushStockHandlerTest.php +createFixtureFactory(); + $product = $factory->product($factory->category(), $factory->taxRule(), $factory->currency()); + + $this->combination = $product->getDefaultSaleElements(); + $this->combination->setQuantity(7)->save($this->getPropelConnection()); + + StockSync::setConfigValue('api_url', 'https://warehouse.test'); + StockSync::setConfigValue('api_token', 'test-token'); + } + + public function testThePushSendsTheQuantityOfTheCombination(): void + { + $warehouse = new MockHttpClient(new MockResponse('', ['http_code' => 200])); + + $this->handler($warehouse)(new PushStock($this->combination->getId())); + + self::assertSame(1, $warehouse->getRequestsCount()); + self::assertSame(1, $this->pushesLogged()); + } + + public function testAReplayCallsTheWarehouseOnce(): void + { + $warehouse = new MockHttpClient([ + new MockResponse('', ['http_code' => 200]), + new MockResponse('', ['http_code' => 200]), + ]); + $handler = $this->handler($warehouse); + + $handler(new PushStock($this->combination->getId())); + $handler(new PushStock($this->combination->getId())); + + self::assertSame(1, $warehouse->getRequestsCount()); + self::assertSame(1, $this->pushesLogged()); + } + + public function testARefusalIsNotRetried(): void + { + $warehouse = new MockHttpClient(new MockResponse('', ['http_code' => 422])); + + $this->expectException(UnrecoverableMessageHandlingException::class); + + $this->handler($warehouse)(new PushStock($this->combination->getId())); + } + + public function testTheQueueOfTheShopAcceptsTheMessage(): void + { + $serializer = $this->getService(AllowedClassesSerializer::class); + + $decoded = $serializer->decode($serializer->encode(new Envelope(new PushStock(42)))); + + self::assertEquals(new PushStock(42), $decoded->getMessage()); + } + + private function handler(MockHttpClient $warehouse): PushStockHandler + { + return new PushStockHandler($warehouse, 'https://shop.test'); + } + + private function pushesLogged(): int + { + return StockSyncLogQuery::create() + ->filterByProductSaleElementsId($this->combination->getId()) + ->count(); + } +} +``` + +`testTheQueueOfTheShopAcceptsTheMessage()` writes and reads the message with the serializer of +the queues, `Thelia\Messenger\Serializer\AllowedClassesSerializer`. It fails with +`The message class "StockSync\Message\PushStock" is not one the shop queues` when the message +has no handler, lives outside the namespace of the module, or the module is not active in the +test database. + +### Running the tests + +In a checkout of `thelia/thelia`, the module in `local/modules/StockSync` is registered and +activated in the test database by `test:prepare`, which also runs its `postActivation()` and so +creates its tables. Prepare the test database once the module is there, then run its tests with +the same wrapper as the core suites: + +```bash +ddev exec composer test:prepare +ddev exec composer phpc ./vendor/bin/phpunit local/modules/StockSync/Tests +``` + +`phpc` sets `APP_ENV=test` and unsets the `DATABASE_*` and `MESSENGER_*` variables DDEV injects, +so the tests use the `test` database and no queue (see [Testing](../testing/index.md#run-the-tests)). + +A project created with `thelia/thelia-project` ships `bin/test-prepare`, but neither PHPUnit nor +a PHPUnit configuration. To run the tests of a module there, require `phpunit/phpunit` as a dev +dependency and give the project a `phpunit.xml.dist` and a `tests/bootstrap.php` like those of +`thelia/thelia`. + +Check that a test can fail before trusting it: remove the "already pushed" check of the handler +and `testAReplayCallsTheWarehouseOnce()` fails; remove the `dispatch()` of the listener and +`testSavingACombinationQueuesOnePushForIt()` fails. diff --git a/docs/testing/index.md b/docs/testing/index.md index ac5737cf6..dd36faef7 100644 --- a/docs/testing/index.md +++ b/docs/testing/index.md @@ -277,7 +277,7 @@ It performs the following: It does not set templates, create an admin, or import demo data. It is the minimal setup a test run needs. -Tests always run without a queue: `tests/bootstrap.php` empties `MESSENGER_TRANSPORT_DSN` and `MESSENGER_HEAVY_TRANSPORT_DSN` after the `.env` files are read, so every background job runs at once, whatever queue the machine is configured with. +Tests always run without a queue: `tests/bootstrap.php` empties `MESSENGER_TRANSPORT_DSN` and `MESSENGER_HEAVY_TRANSPORT_DSN` after the `.env` files are read, so every background job runs at once, whatever queue the machine is configured with. A message dispatched through the bus of the shop in a test is therefore handled at once, by its real handler. To test a module that queues work (the dispatch with an in-memory transport, the handler and its replay with the test database), see [Testing a message, its handler and its dispatch](../reference/background-jobs.md#testing-a-message-its-handler-and-its-dispatch). :::caution Stale Propel cache If tests suddenly hit the wrong database after switching branches or environments, delete `var/propel/test/` and re-run `composer test:prepare`. Step 5 above does this automatically, but a manual run is the quickest fix when a cache survives. From 49af69995f868c911dce6fda8c271a2a91cbb47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:56:39 +0200 Subject: [PATCH 43/52] docs(background-jobs): list the queue and schedule variables on the configuration page --- docs/getting-started/configuration.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 15c447662..62c14d23a 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -42,8 +42,32 @@ JWT_PASSPHRASE=your-passphrase # Lock LOCK_DSN=flock + +# Background jobs: empty means no queue, every job runs at once +MESSENGER_TRANSPORT_DSN= ``` +### Background jobs and recurring tasks + +These variables decide whether mails, exports, imports and the jobs of modules wait in a queue +for a worker, and when the recurring tasks run. Only `MESSENGER_TRANSPORT_DSN` is written in +`.env`; the others take the default below when they are not set. + +| Variable | Default | Effect | +| --- | --- | --- | +| `MESSENGER_TRANSPORT_DSN` | empty | The queue of the jobs. Empty, every job runs at once in the request that dispatched it. `doctrine://default` keeps the queue in the shop database. | +| `MESSENGER_HEAVY_TRANSPORT_DSN` | derived from `MESSENGER_TRANSPORT_DSN` | The queue of the back-office exports and imports. | +| `MESSENGER_FAILURE_TRANSPORT_DSN` | `doctrine://default?queue_name=failed` | Where the jobs that failed every attempt are kept. | +| `THELIA_SCHEDULE_SALE_CHECK` | `* * * * *` | When `sale:check-activation` runs. Empty disables it. | +| `THELIA_SCHEDULE_MAINTENANCE_PURGE` | `30 3 * * *` | When `maintenance:purge` runs. Empty disables it. | +| `THELIA_SCHEDULE_FAILED_JOBS_PURGE` | `0 4 * * *` | When `thelia:messenger:purge-failed` runs. Empty disables it. | +| `THELIA_SCHEDULE_CURRENCY_RATES` | empty | When `currency:update-rates` runs. Off by default. | + +The recurring tasks only run when a worker consumes the `scheduler_thelia` transport. Naming a +queue in `MESSENGER_TRANSPORT_DSN` without running a worker leaves the mails undelivered. See the +[background jobs reference](../reference/background-jobs.md#configuration) for the details of +each variable, and [Running the workers](./background-workers.md) to run them. + ### Database configuration Database credentials are set as individual environment variables (not a `DATABASE_URL` string): From ef03dfc6cbde0ff3a8167b59e7d57564344fec03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:56:56 +0200 Subject: [PATCH 44/52] docs(background-jobs): say when to choose Doctrine, Redis or AMQP --- docs/architecture/background-jobs.md | 23 ++++++++++++++++++++++ docs/getting-started/background-workers.md | 2 ++ 2 files changed, 25 insertions(+) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 6b618934c..b6eca2800 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -60,6 +60,29 @@ The core only ships the Doctrine transport. A Redis queue needs `composer require symfony/redis-messenger` and the `redis` PHP extension; an AMQP queue needs `composer require symfony/amqp-messenger` and the `amqp` extension. +### Choosing a transport + +Start with `doctrine://default`. It needs nothing to install and no server to run: the queue is +a table of the shop database, saved with its backups, and the failed jobs are kept in that +database whatever the queue is. Workers on several servers can share it, since they all reach +the shop database. The jobs of a shop (mails, back-office exports and imports, the messages of a +few modules) are well within what a table can serve. + +Move to another transport for one of these reasons: + +- the volume: modules that dispatch many messages, a push per product combination on a large + catalog for instance, keep the workers reading and writing the table all day. Redis or a + broker takes that load off the database the shop serves its pages from, +- the hosting already runs a Redis server or a RabbitMQ broker, with its monitoring and its + backups: use it rather than adding a queue to the database, +- the team already operates AMQP for other applications and wants the shop jobs in the same + place. + +Redis is the shorter step: one package, one PHP extension, and the heavy queue is derived from +the DSN. AMQP also needs `MESSENGER_HEAVY_TRANSPORT_DSN` set, see below. Whatever the transport, +a Redis or AMQP server holds personal data and must stay private, see +[Personal data](../security/personal-data.md#background-jobs). + ### A second queue for heavy jobs Exports and imports are routed to `async_heavy`, so a large import never holds up the mails diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 9be016526..c92b7e820 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -35,6 +35,8 @@ and the cache: `LOCK_DSN` must name a store every server reaches (the default `f server only) and `THELIA_CACHE_DSN` a cache server they all use, which keeps the last run of each task. Otherwise each server runs every task. +The queue in the shop database is the one to start with; when to prefer Redis or AMQP is +explained in [Choosing a transport](../architecture/background-jobs.md#choosing-a-transport). A Redis or AMQP queue needs its Messenger bridge, which the core does not ship: `composer require symfony/redis-messenger` (with the `redis` PHP extension) or `composer require symfony/amqp-messenger` (with the `amqp` extension). The queue in the shop From a4f8560e4933569db471018aac58870a7777f99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:59:05 +0200 Subject: [PATCH 45/52] docs(background-jobs): tell operators what happens when a worker stops, cleanly or not --- docs/getting-started/background-workers.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index c92b7e820..ec81431c6 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -59,11 +59,27 @@ memory, and recycling the worker keeps it in check. A connection that MySQL clos database is closed after each message, before the worker acknowledges it, so a long export or import never leaves the acknowledgement on a connection MySQL has dropped. -On `SIGTERM` or `SIGINT`, a worker finishes the message in hand, then stops. A worker killed -hard (`SIGKILL`, out of memory) leaves its Doctrine message marked as delivered; the transport -hands it to another worker after `redeliver_timeout`, 3600 seconds by default. That is one +## When a worker stops + +A worker stopped cleanly, by `SIGTERM` or `SIGINT` from its process manager, by +`php Thelia messenger:stop-workers`, or for a deployment, finishes the message in hand, then +stops. Nothing is lost: the messages it did not take wait in the queue for the next worker. + +A worker stopped hard (`SIGKILL`, out of memory, a server that goes down) leaves the message it +was handling marked as delivered. The Doctrine transport hands it out again once its +`redeliver_timeout` has passed: 3600 seconds by default, set in the DSN, for instance +`MESSENGER_TRANSPORT_DSN=doctrine://default?redeliver_timeout=600`. The heavy queue is derived +from the same DSN and takes the same value. The handler then runs a second time, which is one reason handlers must be [idempotent](../reference/background-jobs.md#idempotence-and-replay). +An export or an import is not taken over that fast. Its job row records its progress, and the +job is only started again once that row has shown no progress for an hour. A redelivered message +that finds the row still fresh looks again 10 minutes later. An export or an import cut by a hard +stop therefore starts over about an hour after the worker died, an hour and ten minutes at most, +and a shorter `redeliver_timeout` does not make it sooner. This is on purpose: a long export or +import that is still working on another worker is never started a second time. A job still held +after 12 hours of such checks is set aside in `failed`, where it can be replayed. + ## Supervisor ```ini From 84728675208f543e57cdb70b68f8072a28574d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:59:05 +0200 Subject: [PATCH 46/52] docs(background-jobs): keep one description of the exports and imports, without internals --- docs/architecture/background-jobs.md | 202 ++++++------------------- docs/reference/background-jobs.md | 218 +++++++++++---------------- 2 files changed, 135 insertions(+), 285 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index b6eca2800..7d35760e4 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -292,171 +292,55 @@ A queued mail holds everything it is sent with, and names no file of the server ### Back-office exports and imports -An export started from the back office is a job. -`Thelia\Domain\DataTransfer\Job\ExportJobLauncher::launch()` records an `export_job` row and -dispatches `Thelia\Domain\DataTransfer\Job\RunExportJob`. Without a queue, the export runs in -the request and the file is served at once. With a queue, the back office shows -the job page (`/admin/export/job/{id}`): waiting, running with the number of rows written -(refreshed every 3 seconds), done with a download link, or failed with the reason. - -A finished export is never run twice. A failed one is recorded on its row and goes straight to -`failed`; replaying it starts it over. The export files are deleted once older than a day, -when `maintenance:purge` runs. - -The reason recorded on the row is the one an administrator reads on the job page. On that row, -only a reason written for the administrator is shown (the answer of a mail server, which the -Background jobs screen also shows for a failed mail, never comes from an export or an import): an exception implementing -`Thelia\Exception\UserFacingFailure`, anywhere in the chain of the failure, gives its message, -cut to 2000 characters. The core ones are, in `Thelia\Domain\DataTransfer\Exception`, -`UploadRefusedException` (the refusals of an uploaded file, a `FormValidationException`), -`DataTransferNoDataFoundException`, `HandlerUnavailableException`, `MissingColumnsException` -and `JobRefusedException`. A `FormValidationException` that does not implement the interface -is not shown. Any other -failure may quote SQL, the values of a row, paths or host names, so the row says "The job failed -because of a server error. The details are in the server log." -(`Thelia\Messenger\JobFailureMessage::SERVER_ERROR`). The failed message set aside in `failed` -stores only that text, without the original exception, so neither the Background jobs screen -nor `messenger:failed:show` shows anything more. The log line of -the job names such a failure by the class, the code and the file and line of its first cause -(`JobFailureMessage::forLog()`), never by a text that may hold the personal data of a customer; -a reason written for the administrator is logged on one line. A module whose exception carries -a message meant for the administrator implements `UserFacingFailure` on it. - -A message that fails before its job is taken (its row cannot be read, the claim fails, or the -queue refuses the look-again message) is set aside by `JobLifecycle::take()`, with the same -sanitized text only. Its row is left as it is: it may be unreadable, or held by another -run. - -An import started from the back office is a job too. -`Thelia\Domain\DataTransfer\Job\ImportJobLauncher::launch()` checks the file in the request -(its extension, and its content: an archive must be an archive of its kind, anything else must -be text), moves the uploaded file to `var/data-transfer/import//`, records an `import_job` -row and dispatches `Thelia\Domain\DataTransfer\Job\RunImportJob`. The file is kept outside the -cache on purpose: a deployment empties the cache, and a queued import would lose its file. -Without a queue, the import runs in the request and the back office shows the outcome: the -number of rows imported and the rows refused with their reason. With a queue, the job page -(`/admin/import/job/{id}`, refreshed every 3 seconds) shows waiting, running, done with the -rows changed and the rows refused, or failed with the reason. - -An import runs in a single Propel transaction. Stopped half way, by an error, a killed worker -or a deployment, it leaves the catalog as it was. Its outcome (the `done` status, the rows -imported and the rows refused) is written to the job row inside the same transaction, so the -catalog and the row never disagree. When the code that runs the import already holds a -transaction, the import works inside it and leaves the commit or the rollback to that code: an -import that fails half way is then that code's to roll back, so do not launch an import inside a -transaction of your own. - -The rows an import has written stay locked until it ends: an order or a back-office edit -touching one of them waits, then fails after `innodb_lock_wait_timeout`. Run a large stock or -price import outside trading hours. The listeners of `IMPORT_BEGIN`, `IMPORT_FINISHED` and -`IMPORT_SUCCESS` run inside the transaction too: what they write is cancelled with the import, -what they send is not, and a job they queue in the shop database is committed at once, so a -worker may run it before the import is committed. - -A row the import refuses (a missing combination, an invalid GTIN) is not an error: it is listed -with the job as a refusal, and the other rows are kept. A refusal whose text is not valid UTF-8 -is stored with the invalid bytes replaced. The refusals are kept up to 60,000 bytes -(`ImportJob::ROW_ERRORS_MAX_BYTES`): past that, the first ones are kept and a last line says -` more rows were refused.` - -A row whose save fails inside the transaction of the import leaves the transaction unable to -commit, even when a module catches the failure. The import stops at that row with -`Row could not be saved: nothing was imported.` rather than failing as a server error at the end; -the stock import checks a GTIN and a part number before saving for that reason. A stock or a -price must be a number, given as text or as a number, below 10^10 in absolute value: a list, an -object, a missing price or anything else refuses the row with its reason, and the price is -checked before anything is written for the row. - -An archive is looked into before it is extracted, both when it is uploaded and when it is -imported (`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more -than 1000 entries (folders included), more than 512 MB once extracted, a name that is absolute or contains `..`, or a -link. A zip is read through its directory; a tar, compressed or not, is read header by header -through its compression, never held in memory. The GNU long-name and pax records of a tar -count against the limits like any entry, a pax record is read record by record for the name it -gives, and a header record over 64 KB is refused. It is then extracted next to its file, what the -extraction really wrote is measured against the same 512 MB, and the extracted copy is removed -once the import is over, whatever came of it. A file that does not parse in its format is -refused with a message asking to check its content. The import only reads a file that lies in -the import storage. - -A finished import is never run twice. A failed one goes to `failed`, and replaying it starts -over from the first row. The uploaded file is deleted once the import -is done, and kept while the job may be replayed; without a queue a failed job can never be -replayed, so its file is deleted at once. Deleting the failed job from the back office deletes -its file too (`FailedJobRemovedEvent`), unless the import is still marked running. -`messenger:failed:remove` and `thelia:messenger:purge-failed` leave the file to -`maintenance:purge`, which deletes it after 31 days at most. A job deleted from the failed -jobs before it ever ran is marked failed, "Deleted from the failed jobs before it ran.", so its -row no longer reads as waiting for a worker. A file that cannot be deleted is -left to the purge -and logged as a warning: the job is still reported as it ended. A failure the job row cannot -record (the database gone) is logged, and the message is set aside in `failed` all the same. The path of the file is stored relative to -the project, and its name is cut to 100 characters. - -Both jobs share the status enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, -`running`, `done`, `failed`. Their models implement `Thelia\Domain\DataTransfer\Job\DataTransferJob` -and their messages, `RunExportJob` and `RunImportJob`, -`Thelia\Domain\DataTransfer\Job\DataTransferJobMessage`. The handlers go through -`Thelia\Domain\DataTransfer\Job\JobLifecycle`, which dispatches a job (`dispatch()`), takes it -for a run (`take()`: the job when this run owns it, `null` when another run holds it or -finished it, which is then looked at again later) and records its failure (`fail()`). A worker claims a job atomically before running it -(`Thelia\Domain\DataTransfer\Job\JobClaim`, a service), so two workers handed the same job never -run it at the same time. A job left `running` is taken again once it has given no sign of life for -one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600 seconds, the default redelivery timeout of the -Doctrine transport): the claim compares the `updated_at` of the row, not the time the job -started. An export refreshes it each time it reports its progress, and an import as it reads -its rows (`ImportHandler::import()` takes an optional `$onProgress` closure), so a long export -or import that is still working is never taken from under its worker. The import writes this -sign of life through a second database connection (`Thelia\Domain\DataTransfer\Job\JobHeartbeat`), -outside its own transaction, so the update is seen while the transaction is still open. - -A message that finds its job still `running`, held by another worker or left by a worker -killed less than an hour ago, is not dropped: it is dispatched again with a `DelayStamp` of -10 minutes (`JobLifecycle::POSTPONE_DELAY_SECONDS`, 600) and looks at the job again then, so a -job whose worker died is taken again once its row turns stale. It does so at most 72 times -(`JobLifecycle::MAX_POSTPONEMENTS`), 12 hours. After the last check it throws -`UnrecoverableMessageHandlingException` and is set aside in `failed`, where it is listed with -the other failed jobs; replaying it takes the job over once its worker has gone quiet. The count -travels in the message (`$postponements`). A postponed message never restarts a job that failed -in the meantime: only the original message takes a failed job. Without a queue the message is -never postponed: the run that holds the job finishes it. - -A replayed job starts afresh. The messages implement `Thelia\Messenger\Message\ReplayableJob`, -whose `forReplay()` gives the same message with no postponement counted. The back office -replays a failed job with it, and `Thelia\Messenger\Middleware\ReplayedJobMiddleware`, on the -default bus, does the same for a job replayed with `messenger:failed:retry`. A replay can -therefore restart a job that failed, which a look-again message never does. - -A job whose row no longer exists fails for good and stays in `failed`, rather than -passing for done. A job the queue refuses at dispatch is recorded as failed with "The job could -not be queued. The details are in the server log.", and for an import the uploaded file is -deleted. - -`maintenance:purge` deletes the export and import jobs that are `done` and were created more -than 7 days ago (`Thelia\Domain\DataTransfer\Service\DataTransferJobPurger::JOB_RETENTION_DAYS`), -with the files of the imports. Any other job, failed, queued or running, is kept 31 days: the -30 days of the failed messages (`Thelia\Messenger\FailedMessagePurger::RETENTION_DAYS`) and one -more (`DataTransferJobPurger::REPLAY_MARGIN_DAYS`), since a failed message ages from the day it -was set aside, up to half a day after its row was written, and must still find its row when it -is replayed. The file of an import is only deleted when it lies inside -`var/data-transfer/import` (`Thelia\Domain\DataTransfer\Job\ImportStorage::DIRECTORY`), -whatever path its row holds. The purge -also removes the files of that directory older than 31 days, such as the extracted copy of an -archive left by a killed worker, or a file whose row was deleted by hand. - -The page of a job, and the download of an export, are only open to the administrator who -started it and to super-administrators (administrators without a profile). Other -administrators get a 403, even with the export or import right, and the lists of recent -exports and imports only show their own jobs. Launching an import takes the `UPDATE` right on -`admin.import`; `VIEW` only shows the imports and their jobs. A job page refreshes itself -every 3 seconds, at most 100 times, then stops and offers a link to refresh it by hand; a "Stop -refreshing" link stops it earlier. +An export or an import started from the back office is a job. The launcher +(`Thelia\Domain\DataTransfer\Job\ExportJobLauncher` or `ImportJobLauncher`) records a row in +`export_job` or `import_job` and dispatches `RunExportJob` or `RunImportJob`, routed to +`async_heavy`. The row is what the back office reads. Without a queue, the job runs in the +request and the page shows its outcome at once. With a queue, the page of the job follows it: +waiting, running, done, or failed with its reason. + +An import file is checked in the request, before anything is queued, and moved to +`var/data-transfer/import/`. It is kept outside the cache on purpose: a deployment empties the +cache, and a queued import would lose its file. + +An import runs in a single database transaction. Stopped half way, by an error, a killed +worker or a deployment, it leaves the catalog as it was, and its outcome is written to its row +in the same transaction, so the catalog and the row never disagree. The cost is that the rows it +writes stay locked until it ends: an order or a back-office edit touching one of them waits, then +fails after `innodb_lock_wait_timeout`. Run a large stock or price import outside trading hours. +When the code that launches an import already holds a transaction, the import works inside it +and leaves the commit or the rollback to that code, so do not launch an import inside a +transaction of your own. The listeners of `IMPORT_BEGIN`, `IMPORT_FINISHED` and `IMPORT_SUCCESS` +run inside the transaction too: what they write is cancelled with the import, what they send is +not, and a job they queue in the shop database is committed at once, so a worker may run it +before the import is committed. + +A job never runs twice at the same time, and a finished job never runs again. A worker claims +the row of the job before running it. A message that finds its job still running, because +another worker holds it or because its worker died, does not run it: it is queued again to look +at the job later. The job is taken over only once its row has shown no progress for an hour, so +a long export or import that is still working is never started a second time. What this means +for an operator after a worker is killed is in +[Running the workers](../getting-started/background-workers.md#when-a-worker-stops). + +A job that fails goes to `failed` at once, with no retry: running a whole export or import +again unchanged would fail the same way. Replaying it from the failed jobs starts it over, an +import from its first row. + +The reason a job failed is shown to the administrator only when it was written for them, by an +exception implementing `Thelia\Exception\UserFacingFailure`. Any other failure may quote SQL, +the values of a row, paths or host names: the administrator reads that the job failed because of +a server error, and the log names the exception by its class, code and place in the code, never +by its message. An export or an import is one job for one file. It is not split into smaller jobs: a file is written by a single writer, and an import has to be atomic. Splitting work into smaller jobs fits work made of independent items, as in a module that pushes one message per product combination. It does not fit the rows of a file: do not dispatch one message per row. +The launchers, the rights, the limits of an uploaded file and the retention of the jobs and their +files are in the [reference](../reference/background-jobs.md#back-office-exports-and-imports). + ## Recurring tasks The recurring tasks of the shop are declared in a Symfony Scheduler schedule named `thelia` diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index a5fd74f0a..493cd92a7 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -146,14 +146,16 @@ and a failure reason may quote personal data, so give it only to the profiles th ## Back-office exports and imports -Exports and imports started from the back office are jobs. Both use the status enum -`Thelia\Domain\DataTransfer\Job\JobStatus` (`queued`, `running`, `done`, `failed`). +Exports and imports started from the back office are jobs. How they run, and why an import is +a single transaction, is explained in +[Background jobs](../architecture/background-jobs.md#back-office-exports-and-imports). This +section lists what they expose and what they accept. | | Export | Import | | --- | --- | --- | | Transport | `async_heavy` | `async_heavy` | -| Launcher | `ExportJobLauncher::launch(...)` | `ImportJobLauncher::launch(Import $import, File $file, string $originalName, ?Lang $language = null, ?int $adminId = null)` | -| Message | `RunExportJob(int $exportJobId, int $postponements = 0)` | `RunImportJob(int $importJobId, int $postponements = 0)` | +| Launcher | `ExportJobLauncher::launch(Export $export, string $serializerId, ?string $archiverId = null, ?Lang $language = null, bool $includeImages = false, bool $includeDocuments = false, ?array $rangeDate = null, ?int $adminId = null): ExportJob` | `ImportJobLauncher::launch(Import $import, File $file, string $originalName, ?Lang $language = null, ?int $adminId = null): ImportJob` | +| Message | `RunExportJob(int $exportJobId)` | `RunImportJob(int $importJobId)` | | Row | `export_job` | `import_job`: status, file name, rows imported, refused rows with their reason, error | | Without a queue | Runs in the request, the file is served at once | Runs in the request, the page shows the rows imported and the rows refused | | With a queue | `/admin/export/job/{id}`: waiting, running with the rows written, done with the download, failed with the reason | `/admin/import/job/{id}`: waiting, running, done with the rows changed and refused, failed with the reason | @@ -163,20 +165,24 @@ Exports and imports started from the back office are jobs. Both use the status e | Right to launch | `VIEW` on the export resource | `UPDATE` on the import resource; `VIEW` only shows the imports and their jobs | | Job page access | The administrator who started it and super-administrators, download included | The administrator who started it and super-administrators | -The classes live in `Thelia\Domain\DataTransfer\Job\`. Both messages implement -`DataTransferJobMessage`, `Thelia\Messenger\Message\ReplayableJob` and -`Thelia\Messenger\Message\DescribedJob` (they are listed as `Export #` and `Import #` -among the failed jobs), and both models (`ExportJob`, `ImportJob`) implement `DataTransferJob`. Both job pages refresh every 3 seconds, -at most 100 times (`JobPageRefresh::MAX_ROUNDS` in the theme), then offer a link to refresh by -hand, and a finished job is never run twice. Other administrators get a 403 on a job page, even with -the export or import right. +The launchers and the messages live in `Thelia\Domain\DataTransfer\Job\`. A job has one of the +statuses of the enum `Thelia\Domain\DataTransfer\Job\JobStatus`: `queued`, `running`, `done`, +`failed`. Both messages implement `Thelia\Messenger\Message\DescribedJob`, and are listed as +`Export #` and `Import #` among the failed jobs. + +### The job pages + +A job page refreshes itself every 3 seconds, at most 100 times, then stops and offers a link to +refresh it by hand; a "Stop refreshing" link stops it earlier. Other administrators than the one +who started the job get a 403, even with the export or import right, and the lists of recent +exports and imports only show their own jobs, except to super-administrators. An administrator can launch 10 exports and imports in 10 minutes, the two counted together (rate limiter `admin_data_transfer_launch`, declared in the core `framework` configuration). A launch is only counted once its form is valid: a form sent without a file, with a file the server did not receive whole, with a file whose name or content the import refuses, or with an -invalid option is not counted. -Past the limit, the launch is refused with a message asking to wait a few minutes. +invalid option is not counted. Past the limit, the launch is refused with a message asking to +wait a few minutes. Without a queue, an import that refused more than 10 rows leads to its job page, which lists them all; with fewer, the form page shows them in its message. The description of an export or @@ -185,27 +191,20 @@ an import, written in HTML by its module, keeps only `p`, `br`, `strong`, `b`, ` `http`, `https` and `mailto` links). Anything else, scripts, styles, media, `id` and `name` attributes included, is removed. -The reason of a failed job is a whitelist (`Thelia\Messenger\JobFailureMessage::forAdministrator()`). -An exception implementing `Thelia\Exception\UserFacingFailure`, anywhere in the chain of the -failure, gives its message, cut to 2000 characters. The core ones live in -`Thelia\Domain\DataTransfer\Exception`: `UploadRefusedException` (a `FormValidationException` -raised when an uploaded file is refused), `DataTransferNoDataFoundException`, -`HandlerUnavailableException`, `MissingColumnsException` and `JobRefusedException`. -`Thelia\Form\Exception\FormValidationException` itself does not implement it. - -An export whose year is not four digits or whose month is not 1 to 12 -(`Thelia\Domain\DataTransfer\Export\ExportPeriod::resolve()`, called by the launcher and by the export) is refused with -"The dates of the export are not valid.", and one whose format is no longer available on the -server with `The format "" is no longer available on this server.`. Both are a -`JobRefusedException`, which the administrator reads as it is. - -Any other failure is shown as "The job failed because of a server error. The details are in the server -log." (`JobFailureMessage::SERVER_ERROR`), both on the job row and as the error of the failed -job on the Background jobs screen, and the error line the job writes to the log names it by -class, code and file:line (`JobFailureMessage::forLog()`), or by its reason, on one line, when -it was written for the administrator. The job page -passes the reason through the translator, so the fixed messages are shown in the language of -the administrator. +### The reason of a failure + +The reason an administrator reads, on the job page and on the Background jobs screen, is the +message of an exception implementing `Thelia\Exception\UserFacingFailure`, found anywhere in the +chain of the failure, cut to 2000 characters. The core ones live in +`Thelia\Domain\DataTransfer\Exception`: `UploadRefusedException` (an uploaded file refused), +`DataTransferNoDataFoundException`, `HandlerUnavailableException`, `MissingColumnsException` and +`JobRefusedException`. `Thelia\Form\Exception\FormValidationException` itself does not implement +the interface. + +Any other failure reads "The job failed because of a server error. The details are in the server +log.", and the log line of the job names the exception by its class, code and place in the code. +The job page passes the reason through the translator, so the fixed messages are shown in the +language of the administrator. A module whose export or import throws an exception meant for the administrator implements the interface on it: @@ -216,98 +215,65 @@ final class WarehouseRefusedException extends \RuntimeException implements \Thel } ``` -`JobLifecycle` dispatches a job (`dispatch()`), takes it for a run -(`take(DataTransferJobMessage $message, \Closure $find): ?DataTransferJob`: claimed, or looked at -again later, the table coming from `DataTransferJobMessage::jobTable()`, a `JobTable`), sets -aside a message that fails before its job is taken, and records the failure of a job -(`fail(DataTransferJobMessage $message, DataTransferJob $job, \Throwable $exception)`); it reads from `Thelia\Messenger\Transport\ConfiguredQueues` whether the heavy jobs run -without a queue (`heavyJobsRunInline()`). A worker claims a job -atomically (`JobClaim::claim(JobTable $table, int $jobId, bool $allowFailed = true)`, an injected -service) before running it: -two workers handed the same job never run it at the same time. A job left `running` is taken -again once its row has not been updated for one hour (`JobClaim::STALE_AFTER_SECONDS`, 3600). An -export updates it as it reports its progress and an import as it reads its rows, every 500 rows -(`Thelia\Domain\DataTransfer\DataTransferProgress::STEP`) and once at the end -(`ImportHandler::import()` takes an optional `$onProgress` closure), so a long job that is still -working keeps its worker. - -A message that finds its job still `running` is dispatched again with a `DelayStamp` of -10 minutes (`JobLifecycle::POSTPONE_DELAY_SECONDS`, 600), at most 72 times -(`JobLifecycle::MAX_POSTPONEMENTS`, 12 hours), each time with `$postponements` one higher. A job -whose worker was killed is therefore taken again once its row is stale. `ClaimOutcome` tells -the handler what happened: `Owned` (it runs the job), `Finished` (the job is over, or no queue -can look at it again) or `Postponed`. After the last check -the message throws `UnrecoverableMessageHandlingException` and is set aside in `failed`; -replaying it from there takes the job over once its worker has gone quiet. `JobLifecycle` passes -`$allowFailed` only for the original message (`$postponements` at 0): a postponed message never -restarts a job that failed in the meantime, while a replayed job, which starts again at 0, takes -a failed job. Without a queue a message is never postponed. - -A job whose row was deleted fails for good and stays in `failed`. A message that fails before -its job is taken (the row cannot be read, the claim fails, the look-again message is refused) -goes to `failed` through `JobLifecycle::take()` with the sanitized reason only, and its row is -left untouched. A job the queue refuses at -dispatch is recorded as failed with "The job could not be queued. The details are in the -server log." (`JobLifecycle::NOT_QUEUED`), and the file of such an import is deleted. Failed -jobs are kept 30 days (`FailedMessagePurger::RETENTION_DAYS`), as long as the failed messages, -so they can still be replayed. - -`maintenance:purge` delegates to `Thelia\Domain\DataTransfer\Service\DataTransferJobPurger`: -the `done` jobs created more than 7 days ago (`DataTransferJobPurger::JOB_RETENTION_DAYS`) are -deleted, any other (failed, queued, running) after 31 days, a day past the failed message it may be replayed from. The file of an import is deleted -with its row only when it lies inside `var/data-transfer/import` -(`Thelia\Domain\DataTransfer\Job\ImportStorage`, `ImportStorage::DIRECTORY`). The files of that -directory older than 31 days are removed too, -and its empty directories once they are more than a day old, since a fresh one may be about to -receive an upload. - -`ImportJobLauncher::launch()` checks the file in the request through -`ImportHandler::validateUpload(string $fileName, ?File $file = null)`, so a wrong file is -refused before anything is queued. The extension must be one the import handlers accept, and, -when the file is given, its content is read with `finfo`: an archive must match the type of its -archiver, anything else must be text. An archive is also looked into -(`Thelia\Domain\DataTransfer\ArchiveInspector`): it is refused when it holds more than 1000 -files (`MAX_ENTRIES`), more than 512 MB once extracted (`MAX_EXTRACTED_BYTES`), or a name that -is absolute or contains `..`, or a link entry, zip and tar alike. A tar, gzip or bzip2 archive -is read header by header through `compress.zlib://` or `compress.bzip2://`, never held in -memory and never copied. GNU long-name and pax records count against the limits like any -entry, a pax record is read record by record, and a header record over 64 KB is refused. -`ImportHandler::import()` checks it again before extracting it, then -measures what the extraction wrote (`ArchiveInspector::assertExtractedSize()`). -`ImportHandler` takes the inspector as an optional constructor argument. A file that does not -parse in its format is refused with an `UploadRefusedException` asking to check its content, -and the import handler only reads a file that lies inside `ImportStorage`. -The file is moved out of the upload directory to `var/data-transfer/import/` -(`ImportStorage::DIRECTORY`), not to the cache, which a deployment empties. Its path is stored -relative to the project, and its name is cut to 100 characters. - -An import runs in a single Propel transaction: stopped half way (an error, a killed worker, a -deployment), it leaves the catalog as it was, and replaying it starts over from the first row. -The `done` status, the rows imported and the rows refused are written inside that transaction. -The handler opens and commits the transaction only when the caller holds none; otherwise the -caller commits or rolls back. A row the import refuses (a missing combination, an invalid GTIN) -is listed with the job as a refusal, and the other rows are kept; a refusal that is not valid -UTF-8 is stored with the invalid bytes replaced. The refusals are kept up to 60,000 bytes -(`ImportJob::ROW_ERRORS_MAX_BYTES`), the last line then saying ` more rows were refused.` - -A row whose save leaves the transaction unable to commit, even when a module catches the -failure, stops the import with `Row could not be saved: nothing was imported.` -(`JobRefusedException`). A stock or a price is read from text or from a number, and must be -below 10^10 in absolute value; a list, an object, a missing price or anything else refuses the -row with its reason, and the price of a row is checked before anything is written for it. - -Without a queue, a failed import can never be replayed, so its file is deleted at once -(`JobLifecycle::keepsFailedJobs()`). With a queue, deleting the failed import from the back -office dispatches `Thelia\Messenger\Event\FailedJobRemovedEvent` and its file is deleted with -it, unless the import is still marked running; `messenger:failed:remove` and -`thelia:messenger:purge-failed` leave the file to `maintenance:purge`, 31 days at most. A file that cannot be deleted is -logged and left to the -purge; the job still ends as it did. When the job row cannot record its failure, the error is -logged and the message is set aside in `failed` all the same. - -`ImportHandler::import()` removes the extracted -copy of an archive once the import is over, whether it succeeded or not. The sign of life of an import goes through a second database -connection (`JobHeartbeat`), so a long import is never taken from its worker. +Some refusals of the core, all `JobRefusedException`: + +- an export whose year is not four digits or whose month is not 1 to 12: "The dates of the + export are not valid.", +- an export whose format is no longer available on the server: + `The format "" is no longer available on this server.`, +- an import row whose save leaves the transaction unable to commit, even when a module catches + the failure: `Row could not be saved: nothing was imported.` + +A job the queue refuses at dispatch is recorded as failed with "The job could not be queued. The +details are in the server log.", and the file of such an import is deleted. A job whose row was +deleted fails for good and stays in `failed`. A job deleted from the failed jobs before it ever +ran is marked failed with "Deleted from the failed jobs before it ran.", so its row no longer +reads as waiting for a worker. + +### What an import accepts + +The file is checked in the request, before anything is queued. Its extension must be one the +import handlers accept, and its content is read: an archive must match the type of its +archiver, anything else must be text. A file that does not parse in its format is refused with +a message asking to check its content. + +An archive is looked into before it is extracted, both when it is uploaded and when it is +imported (`Thelia\Domain\DataTransfer\ArchiveInspector`). It is refused when it holds more than +1000 entries, folders included, more than 512 MB once extracted, a name that is absolute or +contains `..`, or a link, zip and tar alike. What the extraction really wrote is measured against +the same 512 MB, and the extracted copy is removed once the import is over, whatever came of it. +`ImportHandler` takes the `ArchiveInspector` as a required constructor argument: code that builds +or extends it passes one. + +A row the import refuses (a missing combination, an invalid GTIN) is listed with the job as a +refusal, and the other rows are kept. A refusal that is not valid UTF-8 is stored with the +invalid bytes replaced. The refusals are kept up to 60,000 bytes; past that, the first ones are +kept and a last line says ` more rows were refused.` A stock or a price must be a number, +given as text or as a number, below 10^10 in absolute value: a list, an object, a missing price +or anything else refuses the row with its reason, and the price of a row is checked before +anything is written for it. + +The file is moved out of the upload directory to `var/data-transfer/import/`, not to the cache, +which a deployment empties. Its path is stored relative to the project, its name is cut to 100 +characters, and the import only reads a file that lies in that directory. + +### Files and retention + +- An import file is deleted once the import is done. Without a queue a failed import can never + be replayed, so its file is deleted at once; with a queue it is kept while the job may be + replayed. +- Deleting a failed import from the back office deletes its file too + (`Thelia\Messenger\Event\FailedJobRemovedEvent`), unless the import is still marked running. + `messenger:failed:remove` and `thelia:messenger:purge-failed` leave the file to + `maintenance:purge`. +- `maintenance:purge` deletes the `done` jobs created more than 7 days ago, and any other job + (failed, queued, running) after 31 days: a day past the 30 days a failed message is kept, so a + replayed job still finds its row. The file of an import goes with its row only when it lies + inside `var/data-transfer/import`. The purge also removes the files of that directory older + than 31 days, such as the extracted copy of an archive left by a killed worker, and its empty + directories once they are more than a day old. +- A file that cannot be deleted is logged as a warning and left to the purge; the job is still + reported as it ended. ## Recurring tasks From 25e6cbd171babb3c568ab9d1cecad104daf123ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:59:08 +0200 Subject: [PATCH 47/52] docs(background-jobs): give the DDEV worker file to projects and name semaphore as a one-server lock --- docs/getting-started/background-workers.md | 33 ++++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index ec81431c6..23212a4f7 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -31,9 +31,10 @@ Do not consume `scheduler_thelia` when the same commands already run from a cron twice. See the warning in the [recurring tasks reference](../reference/background-jobs.md#recurring-tasks). With workers on several servers, run the scheduler worker on one of them, or share the lock -and the cache: `LOCK_DSN` must name a store every server reaches (the default `flock` locks one -server only) and `THELIA_CACHE_DSN` a cache server they all use, which keeps the last run of -each task. Otherwise each server runs every task. +and the cache: `LOCK_DSN` must name a store every server reaches and `THELIA_CACHE_DSN` a cache +server they all use, which keeps the last run of each task. `flock`, the default of the core, and +`semaphore`, the default of a project created from `thelia/thelia-project`, both lock one server +only. Otherwise each server runs every task. The queue in the shop database is the one to start with; when to prefer Redis or AMQP is explained in [Choosing a transport](../architecture/background-jobs.md#choosing-a-transport). @@ -183,20 +184,34 @@ only. In development, the synchronous default is usually all you need: mails reach Mailpit during the request, exports are served and imports run at once. To try the queue, set -`MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local` and let DDEV run the worker: +`MESSENGER_TRANSPORT_DSN=doctrine://default` in `.env.local` and let DDEV run the worker. + +The `thelia/thelia` repository ships `.ddev/config.worker.yaml` for that. A project created +with `composer create-project thelia/thelia-project` has no `.ddev/` directory: after +`ddev config`, create the file with this content: ```yaml -# .ddev/config.worker.yaml, shipped with the core +# .ddev/config.worker.yaml web_extra_daemons: - name: messenger-worker command: "php bin/console messenger:consume async async_heavy --time-limit=3600 --memory-limit=256M" directory: /var/www/html ``` -Run `ddev restart` to start it. Set `MESSENGER_TRANSPORT_DSN` in `.env.local`, not under -`web_environment` in `.ddev/config.yaml`: a value set there wins over the `.env` files, for -every command run in the container. The worker runs the code it loaded at start: after changing a -handler, run `ddev exec php Thelia messenger:stop-workers` so it restarts on the new code. +Run `ddev restart` to start it. Without a queue the worker waits and does nothing. It leaves +out `scheduler_thelia` on purpose, since the recurring tasks would purge the carts and the logs of +the development database at night; run them by hand with +`ddev exec php bin/console messenger:consume scheduler_thelia`. + +Set `MESSENGER_TRANSPORT_DSN` in `.env.local`, not under `web_environment` in +`.ddev/config.yaml`: a value set there wins over the `.env` files, for every command run in the +container. The worker runs the code it loaded at start: after changing a handler, run +`ddev exec php Thelia messenger:stop-workers` so it restarts on the new code. To stop the worker +for a while, run `ddev exec "supervisorctl stop webextradaemons:messenger-worker"`. + +To kill workers by hand, as when testing a hard stop, match the command with a pattern that +does not match itself: `pkill -f '[m]essenger:consume'`. A plain `pkill -f messenger:consume` +run through a shell also matches that shell, and kills it. ## Monitoring From 9e1119ec962a3ebdb6cd526e1f3aa141c75595fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 14:59:27 +0200 Subject: [PATCH 48/52] docs(background-jobs): describe the personal data of the queue on the personal data page only --- docs/architecture/background-jobs.md | 21 ++++------------- docs/getting-started/background-workers.md | 26 ++++------------------ docs/security/personal-data.md | 24 +++++++++++++++++++- 3 files changed, 31 insertions(+), 40 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 7d35760e4..97c916bec 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -432,20 +432,7 @@ path itself. ## Personal data -A queue holds personal data: a queued mail carries the address of its recipient and the -content of the order, and a failed job keeps everything it was dispatched with. Treat the -queue like the database: - -- a Redis or AMQP broker must not be reachable from outside the hosting, -- its credentials go in `.env.local` or the secrets of the hosting, never in Git, -- the retention of failed jobs is a processing to record in the GDPR register. The - `thelia:messenger:purge-failed` task deletes them after 30 days by default, and - `maintenance:purge` deletes those older than 30 days too. - -A Redis or AMQP queue is read by the workers as the queue of the shop: whoever can write to it -can send mails through the mail server of the shop, and replay the exports and imports it -names. This is an accepted risk, and the reason such a server must stay private. A queue in the -shop database adds nothing to what access to that database already gives. - -See [Personal data](../security/personal-data.md) and -[Running the workers](../getting-started/background-workers.md#personal-data-in-the-queue). +A queue holds personal data: a queued mail carries the address of its recipient and the content +of the order, and a failed job keeps everything it was dispatched with. How long each is kept, +and how to protect a Redis or AMQP server, is in +[Personal data](../security/personal-data.md#background-jobs). diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 23212a4f7..94d25f514 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -286,28 +286,10 @@ office, nor an import it stored read by a worker. ## Personal data in the queue Queued mails carry the recipient address and the content of the order, and a failed job keeps -everything it was dispatched with. - -- Failed jobs are deleted by `thelia:messenger:purge-failed` after 30 days, every day at 04:00 - when the schedule runs, and by `maintenance:purge`, which deletes the failed jobs older than - 30 days on each of its runs. Keep the same tasks in the crontab of a shop that does not - consume the schedule. Record this retention in the GDPR register; see - [Personal data](../security/personal-data.md). -- A failed job keeps everything it was dispatched with, but its reason is cleaned before it is - shown or logged: the log of the workers names the exception of a failed job by its class, - code and place (`Thelia\Messenger\Log\FailedJobLogProcessor`), and the credentials of a - mail server are hidden in the reasons the back office shows. -- A Redis or AMQP broker must not be reachable from the internet. Bind it to the private - network of the hosting and protect it with a password. The workers read its queue as the - queue of the shop: whoever can write to it can send mails through the mail server of the shop - and replay the exports and imports it names. A queue in the shop database adds nothing to - what access to that database already gives. -- Put the DSN and its credentials in `.env.local` or in the secrets of the hosting, never in - a committed file. -- Do not share one Redis between environments unless each one has its own stream: a staging - worker would otherwise send the mails of production. Give each environment its own stream - name in the DSN (`redis://host:6379/messages_staging`, whose heavy jobs then go to the stream - `messages_staging_heavy`) or its own Redis database. +everything it was dispatched with. The failed jobs are deleted after 30 days by +`thelia:messenger:purge-failed` and `maintenance:purge`: a shop that does not consume the +schedule keeps both in its crontab. The retention periods, and how to protect a Redis or AMQP +server, are in [Personal data](../security/personal-data.md#background-jobs). ## Sizing diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index 029d74855..7d31c9a7d 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -189,9 +189,31 @@ exception by class, code and place (`Thelia\Messenger\Log\FailedJobLogProcessor` A queued mail stores the content of its attachments in the queue, an attachment given by its path included: the queue, and `failed` for a mail that could not be delivered, hold the -documents a mail carries, such as an invoice. See [Background jobs](../architecture/background-jobs.md#personal-data) and +documents a mail carries, such as an invoice. See [`thelia:messenger:purge-failed`](../reference/cli/thelia_messenger_purge_failed.md). +The description of a failed job on the Background jobs screen names the recipient of a mail. +Give the screen (`admin.configuration.background-jobs`) only to the profiles that need it. A +module describes its own jobs with ids and references, never personal data, see +[the message of the example module](../reference/background-jobs.md#the-message). + +#### The queue server + +Treat the queue like the database: + +- A Redis or AMQP server must not be reachable from the internet. Bind it to the private + network of the hosting and protect it with a password. The workers read its queue as the + queue of the shop: whoever can write to it can send mails through the mail server of the shop + and replay the exports and imports it names. This is an accepted risk, and the reason such a + server must stay private. A queue in the shop database adds nothing to what access to that + database already gives. +- Put the DSN and its credentials in `.env.local` or in the secrets of the hosting, never in a + committed file. +- Do not share one Redis between environments unless each one has its own stream: a staging + worker would otherwise send the mails of production. Give each environment its own stream + name in the DSN (`redis://host:6379/messages_staging`, whose heavy jobs then go to the stream + `messages_staging_heavy`) or its own Redis database. + ## Adding a module purge to the same run `maintenance:purge` dispatches `TheliaEvents::MAINTENANCE_PURGE` at the end of its run. A From 0f39279ed89c473a8f97910958fae735d123eedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 15:00:57 +0200 Subject: [PATCH 49/52] docs(background-jobs): drop internal class names and link the known limits instead of repeating them --- docs/architecture/background-jobs.md | 104 +++++++++++---------------- docs/reference/background-jobs.md | 46 ++++++------ 2 files changed, 66 insertions(+), 84 deletions(-) diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 97c916bec..b33f7456c 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -27,7 +27,7 @@ There is one bus, `messenger.bus.default`, and four transports: | --- | --- | --- | | `async` | The jobs of the shop: mail delivery, the messages of modules | `MESSENGER_TRANSPORT_DSN`, synchronous when empty | | `async_heavy` | Back-office exports and imports | Derived from `MESSENGER_TRANSPORT_DSN`, or `MESSENGER_HEAVY_TRANSPORT_DSN` | -| `failed` | The jobs that failed every attempt | The shop database, whatever the queue is | +| `failed` | The jobs that failed every attempt (the failure transport) | The shop database by default, whatever the queue is; `MESSENGER_FAILURE_TRANSPORT_DSN` overrides it | | `scheduler_thelia` | The recurring tasks of the `thelia` schedule | Generated by the schedule, nothing to configure | ## Nothing waits by default @@ -86,9 +86,7 @@ a Redis or AMQP server holds personal data and must stay private, see ### A second queue for heavy jobs Exports and imports are routed to `async_heavy`, so a large import never holds up the mails -waiting on `async`. Its DSN is derived from `MESSENGER_TRANSPORT_DSN` by -`Thelia\Messenger\HeavyTransportDsnProcessor` -(`%env(thelia_heavy_queue:MESSENGER_TRANSPORT_DSN)%`): +waiting on `async`. Its DSN is derived from `MESSENGER_TRANSPORT_DSN`: | `MESSENGER_TRANSPORT_DSN` | `async_heavy` | | --- | --- | @@ -105,11 +103,10 @@ also run one worker per transport. ## The queue in the shop database -`doctrine://default` is served by `Thelia\Messenger\Transport\ShopDatabaseTransportFactory`. -It opens a DBAL connection with the settings of the Propel connection, its PDO options and -attributes included (TLS certificates, for instance), so the queue lives in the same database -as the shop, with no extra service. Only the connection name `default` is -accepted. The settings are read from Propel by `Thelia\Messenger\Transport\PdoDsnReader`. +`doctrine://default` opens a DBAL connection with the settings of the Propel connection, its +PDO options and attributes included (TLS certificates, for instance), so the queue lives in the +same database as the shop, with no extra service. Only the connection name `default` is +accepted. The `messenger_messages` table is created by the installer and by the 3.3.0 update script. The transport never creates it on the fly. @@ -165,27 +162,19 @@ and the message goes to `failed` at once. `failed` is `MESSENGER_FAILURE_TRANSPORT_DSN`, which defaults to `doctrine://default?queue_name=failed`: by default the failed jobs are kept in the shop database, whatever queue `async` uses, so the back office has a single place to read them from. -They stay there until someone replays them, removes them, or a purge deletes them: -`thelia:messenger:purge-failed`, and `maintenance:purge`, which deletes the failed jobs older -than 30 days too (`Thelia\Messenger\EventListener\FailedJobsMaintenancePurgeListener`). In the -shop database, the back office takes a job out of `failed` on a condition before replaying or -deleting it: two administrators, or a double click, acting on the same job at once act once, and -a job a worker consuming `failed` holds (`messenger:consume failed`) is left to that worker until -its redeliver timeout, the back office then answering that it found no such job. -`messenger:failed:retry` holds nothing while it asks what to do with a job: replayed from the -screen and from that command at the same moment, a job may be sent twice. With a failure queue -elsewhere, the back office removes the job without that condition. +They stay there until someone replays them, removes them, or a purge deletes them after 30 +days (`thelia:messenger:purge-failed` and `maintenance:purge`). When `failed` is in the shop +database, replaying or deleting a job from the back office acts once, even when two +administrators, or a double click, act on the same job at the same moment. Replaying the same +job from the back office and with `messenger:failed:retry` at the same moment is not guarded: +see [Known limits](../reference/background-jobs.md#known-limits). Messenger writes the exception of a failed or retried job to the log of the workers, and a -database error quotes the values of a row. `Thelia\Messenger\Log\FailedJobLogProcessor`, on -the `messenger` channel, takes the exception out of these log lines and names it by its class, -code and place instead (`JobFailureMessage::forLog()`). The back office shows the reason of a -failed job only when it was written for the administrator (an exception implementing -`Thelia\Exception\UserFacingFailure`, such as `Thelia\Messenger\JobSetAsideException`, which -the core throws to set a job aside), or when it is the answer of the mail server, with the -credentials of any URL in it hidden (`Thelia\Mailer\TransportCredentials`, which hides the -whole password even when it holds an `@`). Either is cut to 2000 characters; anything else -reads as a server error. +database error quotes the values of a row. The core takes the exception out of these log lines, +on the `messenger` channel, and names it by its class, code and place in the code instead. The +back office shows the reason of a failed job only when it was written for the administrator, or +when it is the answer of the mail server with its credentials hidden; anything else reads as a +server error. The rule is detailed in the [reference](../reference/background-jobs.md#back-office). Because a failed job can be replayed, and because a retry runs a handler that may have done part of its work, a handler must be idempotent: run twice, its effect happens once. The @@ -196,14 +185,13 @@ reference shows how in [Idempotence and replay](../reference/background-jobs.md# A handler running in a worker serves no page: there is no visitor, no cart and no HTTP request of a browser. Each message gets a request of its own on the address of the shop (`DEFAULT_URI`, its folder included), with an empty session, so the loops of a template a -handler renders, a mail of a module for instance, can run -(`Thelia\Messenger\EventListener\WorkerRequestListener`). Nothing a page sets up runs for it: +handler renders, a mail of a module for instance, can run. Nothing a page sets up runs for it: the session language is the default language, the request is not one of the back office, and no customer or administrator is restored from a cookie. The request goes once the message is handled or has failed, and a message the worker skipped never hands its request to the next. The handler still runs in a long process that handles one message after another, so the core -resets the state of the shop before each message (`Thelia\Messenger\EventListener\WorkerStateResetListener`): +resets the state of the shop before each message: - settings are read again from the database, so a change made in the back office since the previous message is seen, @@ -217,8 +205,7 @@ after its `wait_timeout`. Propel instance pooling is disabled for the whole life so a handler never reads a model object kept in memory by a previous message. Once a message is handled or has failed, and before the worker acknowledges it, the DBAL -connection of the queue in the shop database is closed -(`Thelia\Messenger\EventListener\ShopDatabaseConnectionReleaseListener`). The acknowledgement, +connection of the queue in the shop database is closed. The acknowledgement, or the retry, goes through a connection opened afresh, which MySQL cannot have closed while a long job kept the worker busy. @@ -228,8 +215,7 @@ started with `--no-reset`. What a worker does not read again is its container: the mail settings and the handlers of the modules active when it started. Clearing the cache of the shop (saving the mail settings, turning a module on or off, `thelia:cache:clear`) therefore asks every worker to stop once its -current message is done, as `messenger:stop-workers` does -(`Thelia\Messenger\WorkerRestartSignal`), and its supervisor starts it again on the new cache. +current message is done, as `messenger:stop-workers` does, and its supervisor starts it again on the new cache. A clear asked for by a job (a module handler that saves a setting), or by the command of a recurring task the worker runs, stops the worker running it once the job is acknowledged, and the cache is cleared when that worker's command ends, an exception included: clearing it in @@ -262,21 +248,22 @@ mail and are removed right before it is handed to the mail server, so the custom receives them. With a worker, the line appears when the worker delivered the mail, and its author is `system`. -A queue may hand a job out twice: when a worker stops after the mail server accepted the mail -but before the job was acknowledged, the job comes back once its redelivery delay is over. -`MailerFactory` gives every mail an identifier in an `X-Thelia-Mail-Id` header; a worker keeps -the identifiers of the mails the server accepted for a week, in the `thelia.cache.sent_mails` -pool, and does not send a mail whose identifier it kept. Workers on several servers share them -when `THELIA_CACHE_DSN` points to a cache server they share. A mail sent with the Symfony -mailer directly, not through `MailerFactory`, carries no identifier and is not guarded. +A queue may deliver a job twice: when a worker stops after the mail server accepted the mail +but before the job was acknowledged, the transport redelivers the job once its +`redeliver_timeout` is over. `MailerFactory` gives each mail it sends an identifier in an +`X-Thelia-Mail-Id` header, a new one on each send, even when the same `Email` object is sent +again. A worker keeps the identifiers of the mails the server accepted for a week, and does not +send a mail whose identifier it kept. Workers on several servers share them when +`THELIA_CACHE_DSN` points to a cache server they share. A mail sent with the Symfony mailer +directly, not through `MailerFactory`, is not guarded: see +[Known limits](../reference/background-jobs.md#known-limits). With a queue, `MailerFactory::send*OrFail()` only throws when the mail could not be built or queued. A delivery failure happens later, in the worker. `MailerFactory::sendNow()` hands a mail to the mail server at once, queue or not: the back office uses it to test the mail configuration, whose only point is the answer of the server. -A queued mail holds everything it is sent with, and names no file of the server -(`Thelia\Messenger\Serializer\QueuedMailFiles`): +A queued mail holds everything it is sent with, and names no file of the server: - an attachment given by its path (`attachFromPath()`) is read when the mail is queued and stored in the queue with its content. It is held in the memory of the request, and a large @@ -348,20 +335,18 @@ The recurring tasks of the shop are declared in a Symfony Scheduler schedule nam one does, nothing runs, and a shop can keep its crontab instead. The schedule is stateful and locked through `LOCK_DSN`, so two workers never run a task twice, and a worker that restarts after a missed run catches up the last one only. Across servers this holds only when both are -shared: `LOCK_DSN` must name a store every worker reaches (not the default `flock`), and +shared: `LOCK_DSN` must name a store every worker reaches (not `flock` nor `semaphore`, which +lock one server only), and `THELIA_CACHE_DSN` a cache server they all use; otherwise each server runs the tasks on its own, and a cache clear forgets the last runs. The tasks and their settings are listed in the [reference](../reference/background-jobs.md#recurring-tasks). -A task of the schedule that fails does not go to `failed`: Messenger only sets aside what came -from a queue, and the task runs again at its next time anyway. -`Thelia\Scheduler\EventListener\RecurringTaskFailureListener` logs the failure and keeps the -last one of each task (`Thelia\Scheduler\RecurringTaskFailures`, in the `cache.app` pool) until -a run of that task goes through, or for a month after the date of that failure, so a task -taken off the schedule does not stay listed forever. The list is changed under a lock, as two -workers may finish tasks at the same moment. The Background jobs screen lists them under "Recurring tasks that -failed". A command that exits with an error code shows `Command "" exited with code "".`; any -other failure follows the same rule as the reason of a failed export or import. +A task of the schedule that fails does not go to the failure transport: Messenger only sends +there what came from a queue, and the task runs again at its next time anyway. The core logs the +failure and keeps the last one of each task, in the application cache, until a run of that task +goes through, or for a month after the date of that failure, so a task taken off the schedule +does not stay listed forever. The Background jobs screen lists them under "Recurring tasks that +failed", see the [reference](../reference/background-jobs.md#back-office). ## Which classes a queue accepts @@ -377,9 +362,7 @@ building anything: container parameter `thelia.messenger.allowed_message_classes`, - outside that parameter, a handler of the application must also take the message class, or one of its parents or interfaces. The handled classes are collected when the container is - built (`Thelia\Core\DependencyInjection\Compiler\HandledMessageClassesPass`, parameter - `thelia.messenger.handled_message_classes`), so no other class of those namespaces is ever - built from a queue. A handler that takes any message (`*` or `object`) does not count, + built, so no other class of those namespaces is ever built from a queue. A handler that takes any message (`*` or `object`) does not count, - a stamp must be a Messenger stamp or come from those same namespaces, and `SerializerStamp` is refused, as it would let the queue change how the rest of the envelope is read. - a message that runs a console command, a process, an HTTP request or a scheduled service @@ -406,10 +389,9 @@ longer fits the class. The worker then reads it as `Thelia\Messenger\Message\Und which keeps the original class (`originalType`), the reason and the original body, instead of letting it be dropped. Whatever the inner serializer throws while building the message, the reason is a fixed one, `Its content no longer fits the class .`, and the exception is -named in the log by its class, code and place (`JobFailureMessage::forLog()`): a message that -cannot be built never stops the worker, however often it is delivered. The handler of an -`UndecodableJob` throws `Thelia\Messenger\JobSetAsideException`, so the job lands in -`failed`. The Background jobs screen lists it as `Unreadable job: ` with its +named in the log by its class, code and place: a message that cannot be built never stops the +worker, however often it is delivered. The handler of an `UndecodableJob` sends the job to +`failed` at once. The Background jobs screen lists it as `Unreadable job: ` with its original class, and it can be deleted there. For a module author, this means: diff --git a/docs/reference/background-jobs.md b/docs/reference/background-jobs.md index 493cd92a7..8e29e12f6 100644 --- a/docs/reference/background-jobs.md +++ b/docs/reference/background-jobs.md @@ -35,11 +35,12 @@ A Redis or AMQP DSN needs its Messenger bridge, which the core does not ship: `composer require symfony/redis-messenger` (with the `redis` PHP extension) or `composer require symfony/amqp-messenger` (with the `amqp` extension). -`async_heavy` takes its DSN from `MESSENGER_TRANSPORT_DSN` through -`Thelia\Messenger\HeavyTransportDsnProcessor`: empty gives `sync://`, a `doctrine://` DSN gives -the same table with `queue_name=heavy`, a Redis DSN gives the stream `_heavy` -(`messages_heavy` when it names no stream), and any other DSN is used as is, so both transports -share one queue. +How `async_heavy` derives its queue from `MESSENGER_TRANSPORT_DSN` is shown in +[A second queue for heavy jobs](../architecture/background-jobs.md#a-second-queue-for-heavy-jobs). +The options of the Symfony transport go in the DSN, such as +`doctrine://default?redeliver_timeout=600`; the heavy queue derived from it keeps them. What +`redeliver_timeout` changes, and what it does not, is explained in +[When a worker stops](../getting-started/background-workers.md#when-a-worker-stops). The core configures Messenger for the whole application: its serializer, its bus, its `failure_transport` and the routing of its messages are loaded before the configuration of the @@ -87,7 +88,7 @@ to `async_heavy`. | `messenger:failed:retry` | Replay failed jobs. | | `messenger:failed:remove` | Delete failed jobs. | | `messenger:stop-workers` | Ask every worker to stop after its current message. | -| [`thelia:messenger:purge-failed`](./cli/thelia_messenger_purge_failed.md) | Delete the failed jobs set aside for more than `--older-than` days (30 by default). `maintenance:purge` deletes those older than 30 days too. | +| [`thelia:messenger:purge-failed`](./cli/thelia_messenger_purge_failed.md) | Delete the jobs in the failure transport for more than `--older-than` days (30 by default). `maintenance:purge` deletes those older than 30 days too. | All of them run through `php Thelia`, for example `php Thelia messenger:stats`. @@ -97,15 +98,16 @@ Configuration > System > Background jobs (`/admin/configuration/background-jobs` - whether a queue is configured, and how many jobs wait in it, - how many jobs failed, and the list of failed jobs, the newest first, with their description, - the reason of the failure, the date and the number of attempts, + the reason of the failure, the date and the number of attempts. The description is the + `describe()` of a message implementing `Thelia\Messenger\Message\DescribedJob`, the subject + and the recipients of a mail, or else the short name of the message class, - the recurring tasks whose last run failed, under "Recurring tasks that failed", - the last 20 exports and the last 20 imports of the administrator looking at it, of every administrator for a super-administrator. The reason of a failed job is shown when it was written for the administrator (an exception -implementing `Thelia\Exception\UserFacingFailure`, `Thelia\Messenger\JobSetAsideException` -among them) or when it is the answer of the mail server, with the credentials of any URL in it -hidden by `Thelia\Mailer\TransportCredentials` (a password holding an `@` is hidden whole). +implementing `Thelia\Exception\UserFacingFailure`) or when it is the answer of the mail server, +with the credentials of any URL in it hidden (a password holding an `@` is hidden whole). Either is cut to 2000 characters. Any other reason reads as a server error. The waiting count adds the jobs of `async` and of `async_heavy`, and counts them once when both @@ -113,27 +115,25 @@ transports have the same DSN. When `failed` is in the shop database, the default read in SQL, so the newest failures show however many there are. Each failed job can be replayed or deleted. Replay takes the job out of `failed` first, then -puts it back on the transport it failed on; if that fails, the job is set aside again. A job +puts it back on the transport it failed on; if that fails, the job goes back to `failed`. A job that implements `Thelia\Messenger\Message\ReplayableJob` is put back as `forReplay()` returns -it, so it starts afresh; `Thelia\Messenger\Middleware\ReplayedJobMiddleware`, on the default -bus, does the same for `messenger:failed:retry`. A double -click cannot send it twice. Without a queue, a replayed job runs at once, and a job that fails +it, so it starts afresh, and the same goes for a job replayed with `messenger:failed:retry`. A +double click cannot send it twice. Without a queue, a replayed job runs at once, and a job that fails again stays in the failed list. A replay that fails shows "The job failed again. The details are -in the server log." and the reason goes to the log. If setting it aside fails too (queue and +in the server log." and the reason goes to the log. If putting it back in `failed` fails too (queue and database both down), the job is lost from the queues and a critical line of the log names it by its id and its class, and the two failures by their class, code and place: its description and its content may name a customer, and the log is kept far longer than the failed jobs. A job the workers could no longer read (module turned off, class removed, content that no longer fits) is listed as `Unreadable job: ` with its original class. Replayed, it would fail -the same way, so it has no Replay button (`FailedJob::$replayable` is false); delete it once you +the same way, so it has no Replay button; delete it once you know what it was. -A recurring task of the `thelia` schedule that fails is not set aside in `failed`: it runs again -at its next time. `Thelia\Scheduler\EventListener\RecurringTaskFailureListener` keeps its last -failure in the `cache.app` pool (`Thelia\Scheduler\RecurringTaskFailures`) until a run of the -task goes through, or for a month after the date of that failure, and the screen lists it with the task, the reason and -the date. A command that exits with an error code gives +A recurring task of the `thelia` schedule that fails does not go to `failed`: it runs again +at its next time. Its last failure is kept in the application cache until a run of the task +goes through, or for a month after the date of that failure, and the screen lists it with the +task, the reason and the date. A command that exits with an error code gives `Command "" exited with code "".` as its reason. The "Details" link of an export or import is only shown to the administrator who started the @@ -887,13 +887,13 @@ enough to watch a push go through the queue. Without a queue, the push happens during the save of the stock, in the same request. Stop the fake warehouse and change the stock again to see a retry: the worker logs the failure, waits 30 -seconds, and sets the message aside in `failed` after three more attempts. +seconds, and sends the message to `failed` after three more attempts. ### Turning the module off A queue accepts the messages of active modules only. Messages of `StockSync` still waiting when the module is turned off can no longer be read: the worker reads each of them as -`Thelia\Messenger\Message\UndecodableJob` and sets it aside in `failed`. Nothing is lost and +`Thelia\Messenger\Message\UndecodableJob` and sends it to `failed`. Nothing is lost and nothing loops. The Background jobs screen lists them as `Unreadable job: ` with the original class `StockSync\Message\PushStock`. Delete them there, and let the nightly task push the stock again once the module is back. From ca25d3d24a52aaecd9c601df0ec1cbed82f5abc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 15:00:57 +0200 Subject: [PATCH 50/52] docs(background-jobs): say failure transport rather than set aside --- docs/getting-started/background-workers.md | 2 +- docs/reference/cli/thelia_messenger_purge_failed.md | 6 +++--- docs/security/personal-data.md | 2 +- docs/upgrading/breaking-changes.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/getting-started/background-workers.md b/docs/getting-started/background-workers.md index 94d25f514..ef4b7e411 100644 --- a/docs/getting-started/background-workers.md +++ b/docs/getting-started/background-workers.md @@ -79,7 +79,7 @@ that finds the row still fresh looks again 10 minutes later. An export or an imp stop therefore starts over about an hour after the worker died, an hour and ten minutes at most, and a shorter `redeliver_timeout` does not make it sooner. This is on purpose: a long export or import that is still working on another worker is never started a second time. A job still held -after 12 hours of such checks is set aside in `failed`, where it can be replayed. +after 12 hours of such checks is sent to the failure transport, `failed`, where it can be replayed. ## Supervisor diff --git a/docs/reference/cli/thelia_messenger_purge_failed.md b/docs/reference/cli/thelia_messenger_purge_failed.md index d3a9f0b46..f1bba6c93 100644 --- a/docs/reference/cli/thelia_messenger_purge_failed.md +++ b/docs/reference/cli/thelia_messenger_purge_failed.md @@ -3,7 +3,7 @@ title: thelia:messenger:purge-failed --- ## Description -Delete the background jobs set aside in the failure transport for longer than a number of days. +Delete the background jobs kept in the failure transport for longer than a number of days. A job that failed every attempt is kept in the `failed` transport, with everything it was dispatched with, until someone replays or removes it (`messenger:failed:show`, @@ -20,7 +20,7 @@ deletes the old ones. - `--older-than=DAYS` Age in days from which a failed job is deleted. Defaults to `30`. Takes a whole number of days, from 1 to 3650; any other value ends with the `INVALID` exit code. - `--dry-run` Count the jobs that would be deleted, and delete nothing. -The age of a job is counted from the date it was set aside. When `failed` is in the shop +The age of a job is counted from the date it was sent to the failure transport. When `failed` is in the shop database, the default (`doctrine://default?queue_name=failed`), that is the `created_at` of its row, and the old jobs are counted or deleted in one SQL statement however many there are. With any other failure transport, every job is read and dated by its last `RedeliveryStamp`; a job @@ -37,7 +37,7 @@ Count the failed jobs older than 30 days: php Thelia thelia:messenger:purge-failed --dry-run ``` -Delete the failed jobs set aside for more than 14 days: +Delete the failed jobs kept for more than 14 days: ```shell php Thelia thelia:messenger:purge-failed --older-than=14 ``` diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md index 7d31c9a7d..fb42a9b38 100644 --- a/docs/security/personal-data.md +++ b/docs/security/personal-data.md @@ -181,7 +181,7 @@ Background jobs screen, does not quote the data: unless the exception was writte administrator, it reads as a server error, and the error line Thelia writes to the log names it by its class, code and place in the code rather than by its message. The failed job kept in `failed` stores only that text: the original exception is not chained to it, since Symfony -keeps the whole chain of an exception it sets aside. This holds for the exports and imports of +keeps the whole chain of an exception it sends to the failure transport. This holds for the exports and imports of the back office; a failed mail or module job keeps the exception its handler threw, but the Background jobs screen only shows its reason when it was written for the administrator or is the answer of the mail server, credentials hidden, and the log of the workers names its diff --git a/docs/upgrading/breaking-changes.md b/docs/upgrading/breaking-changes.md index f21ee09dd..ab8cce9bd 100644 --- a/docs/upgrading/breaking-changes.md +++ b/docs/upgrading/breaking-changes.md @@ -40,7 +40,7 @@ The `default-twig` back-office theme requires `thelia/core ^3.3` in its `compose - The order history line `email_sent` is written once the mail server has taken the mail, by `Thelia\Mailer\EventListener\OrderEmailHistoryListener` on the Symfony Mailer `SentMessageEvent`, and no longer by `MailerFactory` right after handing the mail over. Without a queue this happens in the same request; with one, it is the worker that writes it, later, and the author of the line is `system`. - `MailerFactory` names the order in two headers of the mail, `X-Thelia-Order-Id` and `X-Thelia-Message-Code`, and gives every mail an identifier in `X-Thelia-Mail-Id`. They travel through the queue and are removed right before the mail is handed to the mail server. A worker keeps the identifiers of the mails the server accepted for a week, and does not send a mail whose identifier it kept: the queue may hand a job out twice when a worker stopped before acknowledging it. - `MailerFactory` no longer takes an `OrderHistoryRecorder`. Its fourth constructor argument is now the required `Symfony\Component\Mailer\Transport\TransportInterface` (the `mailer.transports` service), used by `sendNow()`. A module that builds it with `new` and still passes the recorder gets a `TypeError`. -- With a queue, `sendEmailMessageOrFail()` and the methods built on it only throw when the mail could not be built or queued. A delivery failure no longer reaches the caller: the mail is set aside in `failed`. +- With a queue, `sendEmailMessageOrFail()` and the methods built on it only throw when the mail could not be built or queued. A delivery failure no longer reaches the caller: the mail goes to the failure transport, `failed`. - A `TemplatedEmail` that is not rendered yet is refused when it is queued, with a `LogicException`: the worker renders nothing. Render it before sending it (`BodyRenderer`), or send it without a queue. ### Messenger configuration From d3b9506f386025411ad7e0e697b3fe789c594275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 15:01:23 +0200 Subject: [PATCH 51/52] docs(background-jobs): warn that without a GitHub token the mails wait for a worker --- docs/getting-started/index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 10c23b8ff..1102bf7b7 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -26,6 +26,12 @@ falls back on generated recipes without reporting anything, the `config/packages Thelia expects are never written, and the install fails much later on an unrelated message, most often `You must either configure a "public_key" or a "secret_key"`. +Another symptom of a missing token: Flex applies the Symfony recipe of `symfony/messenger` +instead of the Thelia one. It writes `config/packages/messenger.yaml` and adds +`MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0` to `.env`, so every mail waits in a +queue for a worker that does not run, and no mail leaves the shop. Delete that file and that +line, as described in [Updating from 3.2 to 3.3](../upgrading/from-3.2-to-3.3.md#symfony-recipes). + Create a token on [github.com/settings/tokens](https://github.com/settings/tokens). No scope is needed, the recipes are public. Give it to Composer once: From 9baf0cf82b96b29a6b86299a54e08c03d14bdd41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 23 Oct 2026 15:01:23 +0200 Subject: [PATCH 52/52] docs(background-jobs): move the 3.3 update notes to a page of their own --- docs/upgrading/breaking-changes.md | 2 +- docs/upgrading/from-3.2-to-3.3.md | 55 ++++++++++++++++++++++++++++++ docs/upgrading/update.md | 35 +------------------ 3 files changed, 57 insertions(+), 35 deletions(-) create mode 100644 docs/upgrading/from-3.2-to-3.3.md diff --git a/docs/upgrading/breaking-changes.md b/docs/upgrading/breaking-changes.md index ab8cce9bd..1e8b46179 100644 --- a/docs/upgrading/breaking-changes.md +++ b/docs/upgrading/breaking-changes.md @@ -11,7 +11,7 @@ For the update procedure itself, see [Update](./update.md). ## Thelia 3.3 -The update gestures (the Symfony Messenger recipe, the shared directories, the queue of the heavy jobs) are in [Updating to 3.3: background jobs](./update.md#updating-to-33-background-jobs). See [Background jobs](../architecture/background-jobs.md) for the model. +The update gestures (the Symfony Messenger recipe, the shared directories, the queue of the heavy jobs) are in [Updating from 3.2 to 3.3](./from-3.2-to-3.3.md). See [Background jobs](../architecture/background-jobs.md) for the model. ### Doctrine DBAL 4 diff --git a/docs/upgrading/from-3.2-to-3.3.md b/docs/upgrading/from-3.2-to-3.3.md new file mode 100644 index 000000000..0eee26cef --- /dev/null +++ b/docs/upgrading/from-3.2-to-3.3.md @@ -0,0 +1,55 @@ +--- +title: Updating from 3.2 to 3.3 +sidebar_position: 2.3 +--- + +# Updating from 3.2 to 3.3 + +This page lists what is specific to the move from Thelia 3.2 to 3.3. The procedure itself +(backup, Composer, database script, cache, assets, modules) is on [Update](./update.md); the +steps below come on top of it. If you start from 3.1, apply +[Updating from 3.1 to 3.2](./from-3.1-to-3.2.md) first. + +Thelia 3.3 ships Symfony Messenger and the [background jobs](../architecture/background-jobs.md). +Nothing waits for a worker after the update: `MESSENGER_TRANSPORT_DSN` stays empty and every job +runs at once, as before, until you name a queue. + +## Symfony recipes + +- The core requires `symfony/messenger`. When Composer applies the Symfony recipe of that + package instead of the Thelia one, it writes `config/packages/messenger.yaml` and adds + `MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0` to `.env`, and every mail then waits + for a worker. Delete the `config/packages/messenger.yaml` the Symfony recipe wrote: the core + configures Messenger itself. A `messenger.yaml` of your own, such as one that routes a module + message, stays. Unless a worker runs, also delete that line, so `async` stays synchronous. +- The Symfony recipe of `symfony/scheduler` writes `src/Schedule.php`. Delete it too: the core + declares its own `thelia` schedule. + +## Queues and workers + +- Going back from a queue to no queue leaves the jobs still waiting in `messenger_messages`. + Let a worker empty the queues before you empty `MESSENGER_TRANSPORT_DSN`. +- The core configures Messenger for the whole application: its serializer, its bus, its + `failure_transport` and the routing of its messages come before the configuration of the + project. An application that already queued messages of its own (`App\Message\…`) gets a + `LogicException` on dispatch until their classes are listed in the + `thelia.messenger.allowed_message_classes` parameter. A module message is accepted when it + lives in the namespace of an active module and has a handler. +- With a queue, the web servers and the workers must share `var/data-transfer/import/` and + `var/cache/export/`, and a deployment must not empty them while jobs wait. See + [Running the workers](../getting-started/background-workers.md#deploying). +- On an AMQP queue, set `MESSENGER_HEAVY_TRANSPORT_DSN`: otherwise the exports and imports share + the queue of `async`, are read by its worker and tried again three times like its jobs. + +## Rights + +- Launching an import from the back office takes the `UPDATE` right on `admin.import`; `VIEW` + now only shows the imports and their jobs. Review the profiles of the administrators who + import. +- The new screen Configuration > System > Background jobs answers to the resource + `admin.configuration.background-jobs`, which the update gives to no profile: grant it to the + profiles that follow the jobs. Until then, only super-administrators see it. + +## Dependencies + +- The core requires `doctrine/dbal ^4.2`: a project or a module held on DBAL 3 moves to DBAL 4. diff --git a/docs/upgrading/update.md b/docs/upgrading/update.md index 64dfea24a..ef1aeca4f 100644 --- a/docs/upgrading/update.md +++ b/docs/upgrading/update.md @@ -154,40 +154,7 @@ cross, in order: - [Updating from 3.0 to 3.1](./from-3.0-to-3.1.md), patch releases 3.1.1, 3.1.2, 3.1.3 and 3.1.4 included. - [Updating from 3.1 to 3.2](./from-3.1-to-3.2.md), patch releases 3.2.1, 3.2.2 and 3.2.3 included. - -### Updating to 3.3: background jobs - -Thelia 3.3 ships Symfony Messenger and the [background jobs](../architecture/background-jobs.md). -Check these points when you cross it: - -- The core requires `symfony/messenger`. When Composer applies the Symfony recipe of that - package instead of the Thelia one, it writes `config/packages/messenger.yaml` and adds - `MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0` to `.env`, and every mail then waits - for a worker. Delete the `config/packages/messenger.yaml` the Symfony recipe wrote: the core - configures Messenger itself. A `messenger.yaml` of your own, such as one that routes a module - message, stays. Unless a worker runs, also delete that line, so `async` stays synchronous. -- The Symfony recipe of `symfony/scheduler` writes `src/Schedule.php`. Delete it too: the core - declares its own `thelia` schedule. -- Going back from a queue to no queue leaves the jobs still waiting in `messenger_messages`. - Let a worker empty the queues before you empty `MESSENGER_TRANSPORT_DSN`. -- The core configures Messenger for the whole application: its serializer, its bus, its - `failure_transport` and the routing of its messages come before the configuration of the - project. An application that already queued messages of its own (`App\Message\…`) gets a - `LogicException` on dispatch until their classes are listed in the - `thelia.messenger.allowed_message_classes` parameter. A module message is accepted when it - lives in the namespace of an active module and has a handler. -- With a queue, the web servers and the workers must share `var/data-transfer/import/` and - `var/cache/export/`, and a deployment must not empty them while jobs wait. See - [Running the workers](../getting-started/background-workers.md#deploying). -- On an AMQP queue, set `MESSENGER_HEAVY_TRANSPORT_DSN`: otherwise the exports and imports share - the queue of `async`, are read by its worker and tried again three times like its jobs. -- Launching an import from the back office takes the `UPDATE` right on `admin.import`; `VIEW` - now only shows the imports and their jobs. Review the profiles of the administrators who - import. -- The new screen Configuration > System > Background jobs answers to the resource - `admin.configuration.background-jobs`, which the update gives to no profile: grant it to the - profiles that follow the jobs. Until then, only super-administrators see it. -- The core requires `doctrine/dbal ^4.2`: a project or a module held on DBAL 3 moves to DBAL 4. +- [Updating from 3.2 to 3.3](./from-3.2-to-3.3.md): the background jobs. ## Recommendations