CLI, Cron and Message Queues
|
This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Magento 2.4.9 (generally available 2026-05-12) and checked on 2026-10-27. Everything that was run was run on Mage-OS 3.6.0, the community distribution built on the Magento Open Source 2.4.9 code base (PHP 8.5.12, MariaDB 11.8.9, Valkey 9.1.3, OpenSearch 3.9.0); the reference documentation quoted is Adobe’s for Adobe Commerce and Magento Open Source 2.4.9. The three mechanisms described here apply to Magento Open Source, to Adobe Commerce on-premises and to Adobe Commerce on cloud infrastructure alike. They do not apply to Adobe Commerce as a Cloud Service (ACCS), which is versionless and has no PHP modules: work that would be a cron job or a queue consumer there is built out of process, with App Builder and Adobe I/O Events (see the page on editions and ecosystem, Editions and Ecosystem).
Magento runs a lot of work outside the web request. A shopper who places an order triggers a request that must finish in a second or two, and everything that can wait (confirmation e-mails, stock reservations, catalog image resizing, index updates, exports) is handed to the background. Three mechanisms carry that background work, and they build on one another:
-
the command line,
bin/magento, a Symfony Console application that every module can extend, is how an operator or a script starts something by hand; -
cron is the scheduler that starts jobs by the clock; Magento ships one system cron entry that calls
bin/magento cron:runevery minute and decides itself which jobs are due; -
the message queue framework decouples a producer from the code that does the work: the producer publishes a message to a named topic, and a consumer picks it up later, usually in another process that cron started.
The page follows the Pocket Coffee shop (see the running example): a console command that queues a roast batch for a SKU, a consumer that
roasts it, and a cron job that reports how many batches are waiting. The module used throughout is PocketCoffee_RoastQueue. It
follows the module anatomy page (registration, module.xml, the app/code layout) and
the dependency injection page (di.xml, preferences, factories), which it assumes, and it precedes
Caching, Indexing and Performance, where the indexers that cron keeps up to date are covered.
How the three pieces fit together
The Mermaid diagram shows the path of one roast request through the module.
Three things to keep in mind from the picture:
-
publishing and consuming are separate processes. A message survives in the queue until a consumer takes it, so a stopped consumer makes the queue grow but never loses work;
-
cron is the glue: it starts the consumers (job
consumers_runner) as well as ordinary jobs, which is why a store with a broken crontab shows stale indexes, unsent e-mails and growing queues all at once; -
the same XML vocabulary is read by every broker. Switching from the built-in MySQL queue to RabbitMQ or ActiveMQ Artemis changes
env.phpand the connection names, not the publisher or the consumer.
Custom CLI commands
bin/magento is a Symfony Console application (Symfony 7.4 components in 2.4.9). Adobe’s naming guideline for a command is
group:[subject:]action, joined with colons, with arguments and options named by one word or by dashed words; a one-letter shortcut is for frequently
used options only, and the name should contain a unique vendor segment to avoid collisions with other extensions. The Pocket Coffee command is
pocketcoffee:roast:schedule: vendor pocketcoffee, subject roast, action schedule.
The command class
A command lives in Console/Command of the module and extends Symfony\Component\Console\Command\Command. configure() sets the name, the
description, the arguments and the options; execute() does the work and returns an integer exit code (Command::SUCCESS, which is 0, or
Command::FAILURE). Dependencies come through the constructor like any other object created by the ObjectManager, and the constructor takes an
optional $name and passes it to the parent, because the name can also be set from di.xml.
<?php
declare(strict_types=1);
namespace PocketCoffee\RoastQueue\Console\Command;
use Magento\Framework\MessageQueue\PublisherInterface;
use PocketCoffee\RoastQueue\Api\Data\RoastRequestInterfaceFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class RoastScheduleCommand extends Command
{
public const TOPIC = 'pocketcoffee.roast.requested';
private const ARG_SKU = 'sku';
private const OPT_QTY = 'qty';
private const OPT_ORDER = 'order';
public function __construct(
private readonly PublisherInterface $publisher,
private readonly RoastRequestInterfaceFactory $requestFactory,
?string $name = null
) {
parent::__construct($name);
}
protected function configure(): void
{
$this->setName('pocketcoffee:roast:schedule')
->setDescription('Queues a roast batch for a SKU')
->addArgument(self::ARG_SKU, InputArgument::REQUIRED, 'Product SKU, for example PC-ETH-WHL-250')
->addOption(self::OPT_QTY, 'b', InputOption::VALUE_REQUIRED, 'Number of bags', '1')
->addOption(self::OPT_ORDER, null, InputOption::VALUE_REQUIRED, 'Order number that triggered the batch');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$request = $this->requestFactory->create();
$request->setSku((string)$input->getArgument(self::ARG_SKU))
->setQty((int)$input->getOption(self::OPT_QTY))
->setOrderNumber($input->getOption(self::OPT_ORDER));
$this->publisher->publish(self::TOPIC, $request);
$output->writeln(sprintf('<info>Queued %d x %s</info>', $request->getQty(), $request->getSku()));
return Command::SUCCESS;
}
}
The methods and constants used, and what they do:
| Element | Behaviour |
|---|---|
|
The command name typed after |
|
The line shown by |
|
Positional input. |
|
Named input. Modes are |
|
Returned as the process exit status. Return |
Output tags |
|
Registering the command
Magento does not discover commands by scanning Console/Command. A command exists once it is an item of the commands argument of
Magento\Framework\Console\CommandListInterface (whose preference is Magento\Framework\Console\CommandList), declared in etc/di.xml.
The item name is only a unique key; the value is the class name, created through the ObjectManager with its dependencies.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="PocketCoffee\RoastQueue\Api\Data\RoastRequestInterface"
type="PocketCoffee\RoastQueue\Model\RoastRequest"/>
<type name="Magento\Framework\Console\CommandListInterface">
<arguments>
<argument name="commands" xsi:type="array">
<item name="pocketcoffee_roast_schedule" xsi:type="object">PocketCoffee\RoastQueue\Console\Command\RoastScheduleCommand</item>
</argument>
</arguments>
</type>
</config>
What happened when this module was installed on the test store, in the order the commands were run:
M='docker exec -u www-data -w /var/www/html ec-magento php bin/magento'
$M module:enable PocketCoffee_RoastQueue # "To make sure that the enabled modules are properly registered, run 'setup:upgrade'."
$M setup:upgrade # Upgrade completed successfully.
$M cache:flush
$M pocketcoffee:roast:schedule PC-ETH-WHL-250 --qty=2 --order=000000042
# Queued 2 x PC-ETH-WHL-250
$M pocketcoffee:roast:schedule PC-COL-ESP-1000 -b 1
# Queued 1 x PC-COL-ESP-1000
The first version of the command declared the quantity option with the shortcut -q. Every bin/magento command inherits the global option -q, --quiet, so Symfony stopped with
An option with shortcut "q" already exists. on every invocation of --help and bin/magento list would have shown the same fault for this command.
The reserved shortcuts of the application are -h, -q, -V, -n and -v; the example uses -b (for bags). In 2.4.9 the application also defines --silent.
Things that commonly go wrong:
-
The command is missing from
bin/magento list. The module is disabled, the item is not registered indi.xml, the class has a PHP error, or thedi.xmlis cached:bin/magento cache:clean config(orcache:flush) and, in production mode,bin/magento setup:di:compile, which Adobe’s guide also tells you to run after registering a command. -
A constructor dependency on something that needs a web area. The CLI runs without a design area, so session, layout or customer objects that assume
frontendoradminhtmlfail. UseMagento\Framework\App\State::emulateAreaCode()around the call that needs an area, or better, depend on a service contract that does not. Do not callsetAreaCode()in the middle of a command that may be invoked from code which already set one: it throws when the area is already set. -
A heavy constructor. Magento resolves commands through a Symfony command loader, but listing and help still create command objects, so keep constructors cheap. Constructor arguments that are expensive to build belong behind a factory or a proxy (see the dependency injection page).
-
Exit codes. A command that catches an exception, prints it and returns 0 reports success to the shell; return
Command::FAILURE.
Related commands that every operator meets: bin/magento list (172 entries on the test store), bin/magento help <command>,
bin/magento --version (printing Mage-OS CLI 3.6.0 (based on Magento 2.4.9) on Mage-OS; Adobe prints Magento CLI and the version),
bin/magento maintenance:enable|disable|status and bin/magento module:status.
Cron
How Magento uses cron
Magento does not run a daemon. The operating system’s cron runs a single command every minute and Magento does the scheduling:
bin/magento cron:install # writes the Magento block into the crontab of the file system owner
bin/magento cron:install --force # rewrites an existing block
bin/magento cron:remove # removes the block
crontab -l
cron:install adds this entry between #~ MAGENTO START and #~ MAGENTO END comments:
* * * * * /usr/bin/php /var/www/html/bin/magento cron:run 2>&1 | grep -v "Ran jobs by schedule" >> /var/www/html/var/log/magento.cron.log
Each cron:run does the same three things for every cron group (or for the one given by --group): it generates rows in the table cron_schedule
for the jobs of the group that will be due in the next minutes, it runs the rows whose scheduled_at has come and whose status is pending, and it cleans old rows.
Because the real work is done by rows in the table, a job that did not run is visible as a row: the status column holds pending, running, success, missed (the run
window schedule_lifetime passed before it started) or error (the job threw; the message is stored in the messages column).
The cron:run options are --group=<name> (only that group), --exclude-group=<name> (repeatable, everything except those) and --bootstrap=<param> to add or override bootstrap parameters.
The crontab must belong to the file system owner of the installation, not to root, and in a multi-node deployment it must run on exactly one node.
Logging: var/log/cron.log has the lifecycle of every job; failed jobs with the status error or missed also go to var/log/support_report.log, a job in error is logged as critical in var/log/exception.log, and in
developer mode missed jobs are written to var/log/debug.log.
The system crontab itself could not be tested here: no crontab was installed in the containerised store and cron:install was deliberately not run. Instead the scheduler was exercised by calling
bin/magento cron:run --group=... by hand, which is the same code path that the crontab calls, as the rest of this section shows.
Defining a job: crontab.xml
A job is declared in etc/crontab.xml. The element tree is config / group (id) / job (name, instance, method) / schedule | config_path.
| Node or attribute | Meaning |
|---|---|
|
The cron group the jobs belong to. Group ids are not unique per module; many modules add jobs to |
|
A name unique across the whole installation. It becomes the |
|
The class (created through the ObjectManager) and the public method to call, usually |
|
A five-field cron expression. Omit the node when the schedule comes from configuration. |
|
The path of a configuration value that holds the cron expression. The convention (and what was tested) is |
The module declares one job that reads its schedule from configuration:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="pocketcoffee">
<job name="pocketcoffee_roast_report"
instance="PocketCoffee\RoastQueue\Cron\RoastReport"
method="execute">
<config_path>crontab/pocketcoffee/jobs/pocketcoffee_roast_report/schedule/cron_expr</config_path>
</job>
</group>
</config>
with its default in etc/config.xml, which is where the cron expression lives:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<crontab>
<pocketcoffee>
<jobs>
<pocketcoffee_roast_report>
<schedule>
<cron_expr>* * * * *</cron_expr>
</schedule>
</pocketcoffee_roast_report>
</jobs>
</pocketcoffee>
</crontab>
</default>
</config>
and the class that the job runs, a plain object with injected dependencies:
<?php
declare(strict_types=1);
namespace PocketCoffee\RoastQueue\Cron;
use Magento\Framework\App\ResourceConnection;
use Psr\Log\LoggerInterface;
class RoastReport
{
public function __construct(
private readonly ResourceConnection $resource,
private readonly LoggerInterface $logger
) {
}
public function execute(): void
{
$connection = $this->resource->getConnection();
$select = $connection->select()
->from(['s' => $this->resource->getTableName('queue_message_status')], ['n' => 'COUNT(*)'])
->join(['q' => $this->resource->getTableName('queue')], 'q.id = s.queue_id', [])
->where('q.name = ?', 'pocketcoffee.roast.queue')
->where('s.status = ?', 2); // 2 = new, see Magento\MysqlMq\Model\QueueManagement
$this->logger->info('PocketCoffee_RoastQueue: pending roast requests', [
'pending' => (int)$connection->fetchOne($select),
]);
}
}
To write a plain schedule directly, replace <config_path> by <schedule>0 3 * * *</schedule> (daily at 03:00). A job cannot be switched off by a flag: to disable a job declared
by another module, redeclare it in your module with a schedule that never matches, for example 0 0 30 2 * (the thirtieth of February).
The cron expression syntax is the standard five fields (minute, hour, day of month, month, day of week); the
cron expressions page on this site explains them.
Defining a group: cron_groups.xml
A group is created simply by using a new id in crontab.xml, but its timing parameters come from etc/cron_groups.xml. Without that file the group takes the values of default.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron_groups.xsd">
<group id="pocketcoffee">
<schedule_generate_every>5</schedule_generate_every>
<schedule_ahead_for>10</schedule_ahead_for>
<schedule_lifetime>10</schedule_lifetime>
<history_cleanup_every>10</history_cleanup_every>
<history_success_lifetime>60</history_success_lifetime>
<history_failure_lifetime>600</history_failure_lifetime>
<use_separate_process>0</use_separate_process>
</group>
</config>
| Option | Meaning (all in minutes except the last) | default group on 2.4.9 |
|---|---|---|
|
How often |
15 |
|
How far ahead rows are written. With |
20 |
|
The window in which a pending job must start; after it the row becomes |
15 |
|
How often finished rows are cleaned. |
10 |
|
How long rows of successful jobs are kept. |
60 |
|
How long rows of failed and missed jobs are kept. |
4320 |
|
|
0 |
The same options appear in the Admin under Stores > Configuration > Advanced > System, in the section Cron configuration options for group, one per group, and are stored in core_config_data under system/cron/<group>/<option>. Values set there override cron_groups.xml.
What happened when it ran
The sequence below was executed on the test store (the clock is that of the container, UTC):
$M pocketcoffee:roast:schedule PC-ETH-WHL-250 --qty=1 # one message waiting in the queue
$M cron:run --group=pocketcoffee # 16:53:21
# table cron_schedule afterwards:
# pocketcoffee_roast_report success scheduled 16:53:00 executed 16:53:21 finished 16:53:21
# pocketcoffee_roast_report pending scheduled 16:54:00 ... 17:02:00 (nine more rows, schedule_ahead_for = 10)
sleep 62
$M cron:run --group=pocketcoffee # 16:54:23: the 16:54:00 row ran
grep 'pending roast' var/log/system.log
# main.INFO: PocketCoffee_RoastQueue: pending roast requests {"pending":1}
Two details worth knowing: the first cron:run of a group generates the rows and runs the one that is already due in the same invocation, and a row is executed up to a minute after its scheduled_at
(here 21 and 23 seconds), because the crontab only ticks once a minute. The job class is built by the ObjectManager from di.xml, so a constructor error appears as a row with status error and the message in the messages column.
The other cron groups that matter on a typical store are default (most core jobs, such as sending queued e-mails and cleaning), index (indexer_reindex_all_invalid, indexer_update_all_views,
indexer_clean_all_changelogs; see the caching and indexing page), consumers (the queue runner, below) and the groups of Adobe Commerce modules.
On Kubernetes, cron:run, the queue consumers and the indexers run as their own workloads rather than from a crontab on a web server; see
Cloud: Cron, Queue Consumers and Indexers.
The message queue framework
Concepts
The message queue framework (MQF) lets a module publish messages to a named topic and defines the consumers that handle them asynchronously. The framework supports three transports:
| Connection name | Broker | Notes |
|---|---|---|
|
The MySQL adapter: tables |
No extra software, the default when |
|
RabbitMQ (AMQP 0.9.1), the primary broker. Only topic exchanges are used |
The 2.4.9 system requirements list RabbitMQ 4.3. |
|
Apache ActiveMQ Artemis over STOMP, introduced in 2.4.5; destinations use ANYCAST (one consumer gets each message) |
The 2.4.9 system requirements list Artemis 2; the release notes call Artemis the long-term replacement for RabbitMQ. |
The vocabulary is that of AMQP: a publisher sends a message to an exchange; the exchange routes it by the topic (a routing key of dot-separated words such as customer.created; * replaces one word and # zero or more) through a binding to one or more queues; a consumer reads one queue. The db and stomp connections emulate the model. In db mode a message is a row, a queue is a row of queue, and the status of each delivery is a row of queue_message_status.
The four XML files
Four files per module describe a topic from the producer to the handler. Their schema URNs are urn:magento:framework:Communication/etc/communication.xsd and urn:magento:framework-message-queue:etc/{topology,publisher,consumer}.xsd. In the test module all four validated with xmllint --schema against the XSDs in vendor/mage-os/framework*.
communication.xml declares the topics and what is sent on them:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Communication/etc/communication.xsd">
<topic name="pocketcoffee.roast.requested"
request="PocketCoffee\RoastQueue\Api\Data\RoastRequestInterface">
<handler name="pocketcoffeeRoastHandler"
type="PocketCoffee\RoastQueue\Model\RoastConsumer"
method="process"/>
</topic>
</config>
| Node or attribute | Meaning |
|---|---|
|
The topic, dot separated. Unique across the installation. |
|
The class or interface of the message: a data interface with typed getters and setters, a scalar type such as |
|
The type of the answer for a synchronous (RPC) topic, together with |
|
An alternative to |
|
A handler of the topic: class and method. A topic may have several handlers, each with a unique name, and a handler can be turned off with |
queue_topology.xml declares exchanges and bindings. Every core module uses an exchange named magento; the file below sets the connection explicitly on the exchange, which is how a module stays on db while the installation default is amqp:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework-message-queue:etc/topology.xsd">
<exchange name="magento" connection="db">
<binding id="pocketcoffeeRoastBinding"
topic="pocketcoffee.roast.requested"
destinationType="queue"
destination="pocketcoffee.roast.queue"/>
</exchange>
</config>
exchange accepts name, type (topic, the only value), connection, durable, autoDelete, internal and an arguments child (broker-specific). binding takes id, topic (a routing key; wildcards allowed on AMQP), destinationType (queue), destination (the queue name) and disabled. For the db connection the rows of the table queue are created by setup:upgrade (the recurring setup script of Magento_MysqlMq reads the configured queues), and for AMQP and STOMP the queues are declared in the broker at the same point, so run bin/magento setup:upgrade after adding or renaming a queue.
queue_publisher.xml says which topics can be published and to which connection and exchange:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework-message-queue:etc/publisher.xsd">
<publisher topic="pocketcoffee.roast.requested">
<connection name="db" exchange="magento"/>
</publisher>
</config>
publisher takes topic (required), queue (a shortcut that implies a direct queue; the examples in core use it as <publisher topic="..." queue="..."/>) and disabled. Its connection children take name, exchange and disabled. A publisher may list several connections; the message goes to all enabled ones.
queue_consumer.xml declares the consumers:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework-message-queue:etc/consumer.xsd">
<consumer name="pocketcoffee.roast.consumer"
queue="pocketcoffee.roast.queue"
connection="db"
handler="PocketCoffee\RoastQueue\Model\RoastConsumer::process"
maxMessages="500"/>
</config>
| Attribute | Meaning |
|---|---|
|
The consumer name used with |
|
The queue read. |
|
|
|
The consumer class. The default is |
|
|
|
Messages processed before the process exits and cron restarts it. Overridden by |
|
Idle timeout (seconds) after which a consumer that waits for messages ends, the pause between polls, and whether the runner starts the consumer only when the queue has a message. |
The default connection
When a file does not name a connection, Magento\Framework\MessageQueue\DefaultValueProvider picks one: queue/default_connection of env.php if set, otherwise stomp if queue/stomp is configured, otherwise amqp if queue/amqp is configured, otherwise db. The test store configures none of them, so every queue above is db. If
default_connection is amqp but a module’s XML files name db explicitly, that module still uses MySQL.
// app/etc/env.php - fragments only; credentials come from the environment and are placeholders here
'queue' => [
'default_connection' => 'amqp',
'amqp' => [
'host' => 'rabbitmq.example.test',
'port' => '5672',
'user' => 'magento',
'password' => '<your-rabbitmq-password>',
'virtualhost' => '/',
'ssl' => false,
],
'consumers_wait_for_messages' => 1,
],
'cron_consumers_runner' => [
'cron_run' => true,
'max_messages' => 10000,
'consumers' => [],
'multiple_processes' => ['pocketcoffee.roast.consumer' => 2],
],
The bin/magento setup:config:set options for the same values are --amqp-host, --amqp-port, --amqp-user, --amqp-password and --amqp-virtualhost, and the installer accepts the same names. For ActiveMQ Artemis the node is queue/stomp (host, port, user, password, ssl) and default_connection is stomp. Neither RabbitMQ nor ActiveMQ Artemis is installed on the test store, so these fragments are taken from the documentation and were not run.
Publishing and handling
A producer needs only the topic and a message that matches the topic’s request type. The data interface of the example and its model:
<?php
declare(strict_types=1);
namespace PocketCoffee\RoastQueue\Api\Data;
/**
* @api
*/
interface RoastRequestInterface
{
/** @return string */
public function getSku(): string;
/** @param string $sku @return $this */
public function setSku(string $sku): static;
/** @return int */
public function getQty(): int;
/** @param int $qty @return $this */
public function setQty(int $qty): static;
/** @return string|null */
public function getOrderNumber(): ?string;
/** @param string|null $orderNumber @return $this */
public function setOrderNumber(?string $orderNumber): static;
}
(The docblocks are shortened here; the module has the usual multi-line ones.) The model PocketCoffee\RoastQueue\Model\RoastRequest implements it with private properties and returns $this from the setters, and di.xml has the preference shown earlier. The handler:
<?php
declare(strict_types=1);
namespace PocketCoffee\RoastQueue\Model;
use PocketCoffee\RoastQueue\Api\Data\RoastRequestInterface;
use Psr\Log\LoggerInterface;
class RoastConsumer
{
public function __construct(private readonly LoggerInterface $logger)
{
}
public function process(RoastRequestInterface $request): void
{
if ($request->getQty() < 1) {
throw new \InvalidArgumentException('Quantity must be at least 1');
}
$this->logger->info('PocketCoffee_RoastQueue: roasting batch', [
'sku' => $request->getSku(),
'qty' => $request->getQty(),
'order' => $request->getOrderNumber(),
]);
}
}
In code, any class publishes with Magento\Framework\MessageQueue\PublisherInterface::publish(string $topicName, $data); the command at the top of the page does exactly that. For many messages at once, Magento\Framework\MessageQueue\BulkPublisherInterface::publish($topicName, array $data) sends them in one go; the asynchronous and bulk web API endpoints, the bulk operations and the async.operations.all consumer are built on it (see the REST and GraphQL page, REST and GraphQL APIs). Publishing happens during the web request, so a failure to reach the broker (RabbitMQ down) makes the request itself fail; with the db connection the publish is a row insert in the same database.
Running a consumer and what a failure does
The consumer is started by name. The options were read from bin/magento queue:consumers:start --help:
| Command or option | Behaviour |
|---|---|
|
Lists the names of all consumers (24 on the test store including |
|
Runs the consumer in the foreground until |
|
Messages to process before the process terminates. Without it the consumer processes everything queued. |
|
Messages per batch; for batch consumers only. |
|
The area in which the handler runs; default |
|
Prevents two copies of the same consumer from running at once (replaces the deprecated |
|
Number of processes for one consumer. |
|
Signals running consumers to restart (after a deployment, so that they load the new code). |
|
Checks that the queues defined in configuration exist in the queue system ("Queue config files are up to date."). |
The run on the test store published three messages (two good ones and one with --qty=0) and then consumed them:
$M pocketcoffee:roast:schedule PC-ETH-WHL-250 --qty=2 --order=000000042
$M pocketcoffee:roast:schedule PC-COL-ESP-1000 -b 1
$M pocketcoffee:roast:schedule PC-BRA-FIL-250 --qty=0
# queue_message.body of the first row, in JSON:
# {"sku":"PC-ETH-WHL-250","qty":2,"order_number":"000000042"}
$M queue:consumers:start pocketcoffee.roast.consumer --max-messages=3
# var/log/system.log:
# main.INFO: PocketCoffee_RoastQueue: roasting batch {"sku":"PC-ETH-WHL-250","qty":2,"order":"000000042"}
# main.INFO: PocketCoffee_RoastQueue: roasting batch {"sku":"PC-COL-ESP-1000","qty":1,"order":null}
# queue_message_status afterwards: message 1 status 4, message 2 status 4, message 3 status 6
The message body is stored as JSON in the property names of the data interface (order_number for getOrderNumber), which is how the message looks to any other consumer, including a non-PHP one on RabbitMQ.
The meaning of the status column of queue_message_status comes from the constants of Magento\MysqlMq\Model\QueueManagement:
| Value | Constant | Meaning |
|---|---|---|
2 |
|
Published, waiting. The cron job above counts these. |
3 |
|
Taken by a consumer. |
4 |
|
The handler returned. |
5 |
|
Failed; will be retried (the number of trials is in |
6 |
|
Failed for good. The third message above, whose handler threw |
7 |
|
Marked for the cleanup job |
On AMQP a failed message is not recorded in a status table: the broker holds it, and the framework either rejects it (it is dead-lettered or dropped, depending on the queue arguments) or requeues it; that behaviour was not tested here because no broker is installed. What is common to all connections is that a handler that throws does not stop the consumer, and that handlers must be idempotent: a message can be delivered more than once after a crash.
Who starts the consumers: consumers_runner
In production nobody types queue:consumers:start. The cron group consumers contains one job, consumers_runner (* * * * *, use_separate_process 1, failure history 600 minutes), whose class Magento\MessageQueue\Model\Cron\ConsumersRunner starts each configured consumer in a background process unless one is already running. The runner is configured by the node cron_consumers_runner of env.php:
| Key | Behaviour |
|---|---|
|
|
|
Messages per consumer process before it ends and cron starts a new one (default 10000; 0 never ends, which Adobe discourages). |
|
The consumer names to run. An empty array runs all of them. |
|
Map of consumer name to number of processes (2.4.4 and later). |
The node queue/consumers_wait_for_messages decides what a consumer does when the queue empties before max_messages: 1 (the installed value, and Adobe’s recommendation for busy stores) keeps waiting for messages; 0 ends the process, which saves resources on small stores and avoids long-running processes that block cron. The runner could not be exercised on the test store: running it would start a background process for every consumer in the installation (24 here) and leave them waiting, so the consumers were started by hand and the runner’s behaviour is taken from the documentation and the source of ConsumersRunner.
On Adobe Commerce on cloud infrastructure the same settings are controlled from the .magento.env.yaml variables CRON_CONSUMERS_RUNNER and CONSUMERS_WAIT_FOR_MAX_MESSAGES, not by editing env.php.
The consumers that ship with the product
The table lists consumers that the test store defines, with their purpose, to show how much of the platform depends on the queue.
| Consumer | Purpose |
|---|---|
|
Processes the individual tasks of a bulk operation (asynchronous and bulk web API calls, mass price or attribute changes when the Admin is set to run bulk operations asynchronously). |
|
Writes mass attribute updates made in the Admin. |
|
Catalog image resizing and Media Gallery work. |
|
Inventory Management: stock reservations, cleanups and the asynchronous stock indexer. |
|
Large exports, coupon batch generation, asynchronous configuration save, website-scope attribute sync, URL rewrite generation, product alerts, single-use coupon accounting, recollecting active carts after a price rule change, Page Builder template import. |
Adobe Commerce adds consumers for B2B (purchase orders, shared catalogs, negotiable quotes) and for the I/O Events publisher (commerce.eventing.event.publish); Open Source, and so the test store, has none of them.
Troubleshooting
| Symptom | Likely cause and check |
|---|---|
Queue grows, nothing is consumed |
No consumer process. Check |
|
The system crontab is not running, runs as the wrong user, or is slower than |
A consumer exits at once |
Empty queue with |
A handler change has no effect |
Long-running consumers keep the old code. |
Message ends in status 6 |
The handler threw. The exception is in |
Two copies of one job run at once |
The job is slower than its schedule and the group does not use a lock. Make the job re-entrant or use the lock provider ( |
Custom command missing or failing with a shortcut error |
See the section on commands: registration in |
What could not be run
The module’s PHP files passed php -l and vendor/bin/phpcs --standard=Magento2 --severity=6 (no errors; one warning each for the long xsi:noNamespaceSchemaLocation line of di.xml), and all XML files validated against Magento’s XSDs with xmllint --schema.
-
RabbitMQ and ActiveMQ Artemis: no broker is installed on the test store, so the
amqpandstompconnections, theirenv.phpnodes and their failure semantics are documented but not executed. Thedbconnection was run end to end. -
The system crontab and the
consumers_runnerjob (see above).cron:install,cron:removeand theconsumersgroup were not run; thepocketcoffeegroup was run throughcron:run --group. -
Adobe Commerce on cloud infrastructure and ACCS (cloud variables, no PHP modules on ACCS). Adobe Commerce-only consumers (B2B, Events).
-
Production mode: the commands were run in developer mode, where
setup:di:compileis not needed to see a new command.