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. Running the workers in production is covered in Running the workers.
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, 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. 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. |
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 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
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).
How async_heavy derives its queue from MESSENGER_TRANSPORT_DSN is shown in
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.
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: an application that queues its own
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:
# config/services.yaml
parameters:
thelia.messenger.allowed_message_classes:
- App\Message\SyncInvoices
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 (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. |
The core routes Symfony\Component\Mailer\Messenger\SendEmailMessage to async, and
Thelia\Domain\DataTransfer\Job\RunExportJob and Thelia\Domain\DataTransfer\Job\RunImportJob
to async_heavy.
Commands
| Command | Use |
|---|---|
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. |
messenger:failed:remove | Delete failed jobs. |
messenger:stop-workers | Ask every worker to stop after its current message. |
thelia:messenger:purge-failed | 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.
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, the newest first, with their description,
the reason of the failure, the date and the number of attempts. The description is the
describe()of a message implementingThelia\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) 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
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 goes back to failed. A job
that implements Thelia\Messenger\Message\ReplayableJob is put back as forReplay() returns
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 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: <reason> with its original class. Replayed, it would fail
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 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 "<input>" exited with code "<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.
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.
Back-office exports and imports
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. This section lists what they expose and what they accept.
| Export | Import | |
|---|---|---|
| Transport | async_heavy | async_heavy |
| 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 |
| 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/<Ymd>/ until the import is done, kept while it may be replayed |
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 |
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 #<id> and Import #<id> 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.
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, 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 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:
final class WarehouseRefusedException extends \RuntimeException implements \Thelia\Exception\UserFacingFailure
{
}
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 "<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 <n> 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 <n> 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:removeandthelia:messenger:purge-failedleave the file tomaintenance:purge. maintenance:purgedeletes thedonejobs 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 insidevar/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
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.
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
├── composer.json
├── Config/
│ ├── config.xml
│ ├── 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
└── Tests/
├── PushStockHandlerTest.php
└── StockChangedListenerTest.php
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.
// local/modules/StockSync/StockSync.php
<?php
declare(strict_types=1);
namespace StockSync;
use Propel\Runtime\Connection\ConnectionInterface;
use StockSync\Message\PushStock;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\Loader\Configurator\ServicesConfigurator;
use Thelia\Core\Install\Database;
use Thelia\Module\BaseModule;
final class StockSync extends BaseModule
{
public const DOMAIN_NAME = 'stocksync';
public static function configureServices(ServicesConfigurator $servicesConfigurator): void
{
$servicesConfigurator->load(self::getModuleCode().'\\', __DIR__)
->exclude([
__DIR__.'/I18n/*',
__DIR__.'/Model/*',
__DIR__.'/Message/*',
__DIR__.'/Tests/*',
__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. 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.
A shop can also route the message itself, in config/packages/messenger.yaml, which takes
precedence over the prepended module configuration:
# config/packages/messenger.yaml
framework:
messenger:
routing:
'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:
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 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.
Metadata and dependencies
<!-- local/modules/StockSync/Config/module.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<module xmlns="http://thelia.net/schema/dic/module"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://thelia.net/schema/dic/module http://thelia.net/schema/dic/module/module-2_2.xsd">
<fullnamespace>StockSync\StockSync</fullnamespace>
<descriptive locale="en_US">
<title>Stock sync</title>
<subtitle>Pushes the stock of each combination to a warehouse API</subtitle>
</descriptive>
<languages>
<language>en_US</language>
</languages>
<version>1.0.0</version>
<author>
<name>Your Name</name>
<email>you@example.com</email>
</author>
<type>classic</type>
<thelia>3.3.0</thelia>
<stability>prod</stability>
</module>
{
"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\\": ""
}
}
}
Config/config.xml declares nothing, since configureServices() registers the services, but
the module:generate:* commands refuse a module without it:
<!-- local/modules/StockSync/Config/config.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<config xmlns="http://thelia.net/schema/dic/config"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://thelia.net/schema/dic/config http://thelia.net/schema/dic/config/thelia-1.0.xsd">
</config>
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 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.
<!-- local/modules/StockSync/Config/schema.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<database defaultIdMethod="native" name="TheliaMain" namespace="StockSync\Model">
<!-- The core tables a foreign key points to, read for reference only. -->
<external-schema filename="local/config/schema.xml" referenceOnly="true"/>
<table name="stock_sync_state">
<column name="product_sale_elements_id" primaryKey="true" required="true" type="INTEGER"/>
<column name="pushed_quantity" required="true" type="FLOAT"/>
<column name="pushed_at" required="true" type="TIMESTAMP"/>
<foreign-key foreignTable="product_sale_elements" onDelete="CASCADE">
<reference local="product_sale_elements_id" foreign="id"/>
</foreign-key>
</table>
<table name="stock_sync_log">
<column autoIncrement="true" name="id" primaryKey="true" required="true" type="INTEGER"/>
<column name="product_sale_elements_id" required="true" type="INTEGER"/>
<column name="quantity" required="true" type="FLOAT"/>
<column name="created_at" required="true" type="TIMESTAMP"/>
</table>
</database>
Generate the models and the SQL file:
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.
// local/modules/StockSync/Message/PushStock.php
<?php
declare(strict_types=1);
namespace StockSync\Message;
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
API with the absolute quantity, then records the push. It assumes nothing about a request: the
shop URL comes from DEFAULT_URI, and the locale of the product title is the default language
of the shop, chosen explicitly.
// local/modules/StockSync/MessageHandler/PushStockHandler.php
<?php
declare(strict_types=1);
namespace StockSync\MessageHandler;
use Propel\Runtime\Propel;
use StockSync\Message\PushStock;
use StockSync\Model\StockSyncLog;
use StockSync\Model\StockSyncState;
use StockSync\Model\StockSyncStateQuery;
use StockSync\StockSync;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Thelia\Model\Lang;
use Thelia\Model\ProductSaleElementsQuery;
#[AsMessageHandler]
final readonly class PushStockHandler
{
public function __construct(
private HttpClientInterface $httpClient,
#[Autowire(env: 'DEFAULT_URI')]
private string $shopUrl,
) {
}
public function __invoke(PushStock $message): void
{
$productSaleElements = ProductSaleElementsQuery::create()->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.
// local/modules/StockSync/EventListener/StockChangedListener.php
<?php
declare(strict_types=1);
namespace StockSync\EventListener;
use StockSync\Message\PushStock;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Thelia\Core\Event\ProductSaleElement\ProductSaleElementUpdateEvent;
use Thelia\Core\Event\TheliaEvents;
final readonly class StockChangedListener implements EventSubscriberInterface
{
public function __construct(
private MessageBusInterface $bus,
) {
}
public static function getSubscribedEvents(): array
{
return [
// Below the core listener (128), which writes and commits the change.
TheliaEvents::PRODUCT_UPDATE_PRODUCT_SALE_ELEMENT => ['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.
// local/modules/StockSync/Command/PushAllStockCommand.php
<?php
declare(strict_types=1);
namespace StockSync\Command;
use StockSync\Message\PushStock;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
use Thelia\Model\ProductSaleElementsQuery;
#[AsCommand(name: 'stock-sync:push-all', description: 'Queue a stock push for every product combination.')]
#[AsCronTask('0 2 * * *', schedule: 'thelia')]
final class PushAllStockCommand extends Command
{
public function __construct(
private readonly MessageBusInterface $bus,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$ids = ProductSaleElementsQuery::create()->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.
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.
-
Install and activate the module. Activation creates its two tables:
composer dump-autoloadphp Thelia module:refreshphp Thelia module:activate StockSync -
Start a fake warehouse that answers
200and keeps what it receives:mkdir -p /tmp/warehouseecho '<?php file_put_contents("/tmp/warehouse/received.log", $_SERVER["REQUEST_URI"]." ".file_get_contents("php://input")."\n", FILE_APPEND);' > /tmp/warehouse/index.phpphp -S 127.0.0.1:8099 -t /tmp/warehouse /tmp/warehouse/index.php -
Give the module its two settings. The example has no configuration page; a module setting is stored in
module_configand its value inmodule_config_i18n, underen_USwhen no locale is given:INSERT INTO module_config (module_id, name, created_at, updated_at)SELECT m.id, s.name, NOW(), NOW()FROM module m, (SELECT 'api_url' AS name UNION ALL SELECT 'api_token') sWHERE 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_idWHERE m.code = 'StockSync' AND c.name IN ('api_url', 'api_token')ON DUPLICATE KEY UPDATE value = VALUES(value);Run again, it changes the two values without adding rows.
-
With
MESSENGER_TRANSPORT_DSN=doctrine://defaultin.env.localand 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:php Thelia messenger:stats async -
Run a worker until the queue is empty, then read what the warehouse received and what the module recorded:
php Thelia messenger:consume async --time-limit=10 -vvcat /tmp/warehouse/received.logSELECT * 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 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 sends it to failed. Nothing is lost and
nothing loops. The Background jobs screen lists them as Unreadable job: <reason> 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:
- 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:retryor from the back office, - a worker killed hard (
SIGKILL, out of memory) while handling the message: the Doctrine transport redelivers it afterredeliver_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_stateabove), - 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
failedfor 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: run the handler twice in a test and check that the effect happened once, as in Testing a message, its handler and its dispatch.
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:retryat 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 throughMailerFactory, 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.
// local/modules/StockSync/Tests/StockChangedListenerTest.php
<?php
declare(strict_types=1);
namespace StockSync\Tests;
use PHPUnit\Framework\TestCase;
use StockSync\EventListener\StockChangedListener;
use StockSync\Message\PushStock;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\Messenger\MessageBus;
use Symfony\Component\Messenger\Middleware\SendMessageMiddleware;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
use Symfony\Component\Messenger\Transport\Sender\SendersLocator;
use Thelia\Core\Event\ProductSaleElement\ProductSaleElementUpdateEvent;
use Thelia\Model\Product;
final class StockChangedListenerTest extends TestCase
{
public function testSavingACombinationQueuesOnePushForIt(): void
{
$queue = new InMemoryTransport();
// A bus that sends PushStock to the in-memory queue and handles nothing.
$bus = new MessageBus([
new SendMessageMiddleware(new SendersLocator(
[PushStock::class => ['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). 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.
// local/modules/StockSync/Tests/PushStockHandlerTest.php
<?php
declare(strict_types=1);
namespace StockSync\Tests;
use StockSync\Message\PushStock;
use StockSync\MessageHandler\PushStockHandler;
use StockSync\Model\StockSyncLogQuery;
use StockSync\StockSync;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Thelia\Messenger\Serializer\AllowedClassesSerializer;
use Thelia\Model\ProductSaleElements;
use Thelia\Test\IntegrationTestCase;
final class PushStockHandlerTest extends IntegrationTestCase
{
private ProductSaleElements $combination;
protected function setUp(): void
{
parent::setUp();
$factory = $this->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:
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).
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.