Queue (RabbitMQ)

Note

Not part of core. Install it separately:

composer require kinetis/queue-rabbitmq

Adds RabbitMQ as a backend for Queue. Switching to it changes configuration, not application code.

QUEUE_CONNECTION=rabbitmq
QUEUE_RABBITMQ_URL=amqp://guest:guest@localhost:5672/
vendor/bin/kinetis queue:work --queue=high,default

Every AMQP call this backend makes, including a worker checking for its next job, suspends the calling Fiber instead of blocking the process.

Configuring

QUEUE_RABBITMQ_URL is required — a standard AMQP URI (amqp://user:password@host:port/vhost). Several hosts, separated by commas, connect to whichever answers first:

QUEUE_RABBITMQ_URL=amqp://guest:guest@rabbit-a:5672,rabbit-b:5672/

Username, password and vhost are percent-decoded, so a value carrying a URI delimiter is written encoded — %40 for @, %2F for /:

QUEUE_RABBITMQ_URL=amqp://guest:p%40ssword@rabbit:5672/%2Fstaging

That authenticates as guest with the password p@ssword against the vhost /staging. An empty path, the trailing / in every example above, is the default vhost /.

QUEUE_RABBITMQ_QUEUE_PREFIX (optional) is prepended to every queue name, so staging and production can share one broker without both using default. Named connections scope both keys:

QUEUE_CONNECTION_NAME=reports
QUEUE_REPORTS_CONNECTION=rabbitmq
QUEUE_REPORTS_RABBITMQ_URL=amqp://reports:secret@rabbitmq-reports:5672/reports
QUEUE_REPORTS_RABBITMQ_QUEUE_PREFIX=myapp-reports-

Queues are declared for you

A queue name you push to does not need to exist ahead of time: the backend declares it durable the first time anything touches it. Delayed jobs use further queues and exchanges, declared the same way, whose names cannot collide with yours (see the delay ladder).

Every publish waits for the broker

push() and release() publish on a channel in confirm mode and return only after RabbitMQ acknowledges the message. Publishing is mandatory, so a queue deleted under a worker is an error rather than a silent drop. Without an acknowledgement both throw Kinetis\QueueRabbitMq\Exception\PublishNotConfirmedException; after a push(), treat the job as possibly queued (see Queue’s “When push() throws”).

Waiting is not the same as bounded. QueueInterface::push() takes no deadline and no cancellation, and the confirmation this backend awaits offers neither, so once the message is out the wait lasts as long as the broker takes to answer or the connection takes to fail. The two bounds the AMQP URI does carry are connection properties:

QUEUE_RABBITMQ_URL=amqp://guest:guest@rabbit:5672/?connection_timeout=10&heartbeat=60

connection_timeout (seconds, default 10) bounds establishing the connection. heartbeat (seconds, default 60) is the interval at which the peers exchange heartbeats, which is how a connection that has stopped answering gets noticed at all. Neither is a ceiling on a confirmation, and this backend offers no in-process one: no argument on push() or on the confirmation carries a deadline or a cancellation, so nothing in the application can end that wait once it has begun.

What can end it is something outside the process killing the process — a container stop timeout, a SAPI request limit. That bounds the process, not the publish: the message may already be at the broker, and the publish outcome is then the unknown one Queue’s “When push() throws” describes. Treat the job as possibly queued rather than as not sent.

A released job can be delivered twice

release() publishes the replacement, waits for the broker to acknowledge it, and only then discards the original — two AMQP operations, since AMQP 0-9-1 has no transaction spanning messages. A failure before the acknowledgement leaves the original unacked, so the broker redelivers it once the connection drops and the job is not lost. A worker that stops between the two leaves both the replacement and the redelivered original queued, and the job runs twice.

A worker that dies mid-job has its delivery redelivered as soon as its connection drops, with the attempt count unchanged, so maxAttempts does not count those runs. Handlers on this backend must tolerate running more than once. RabbitMQ mechanisms has the full settlement sequence.

Attempts the broker does not count

QueuedJob::$attempts travels in the message the application published, so a redelivery of that same message arrives carrying the same number. A worker killed mid-job, or a connection that dropped before the job settled, costs the job a real processing attempt that maxAttempts never sees. What maxAttempts bounds is how many times the application itself released a job — a backstop against a retry loop, not a count of failures.

A domain rule that has to act on processing failures records them itself, durably, keyed by the logical work rather than by the delivery — the same identifier the handler already needs to be idempotent. Counting them exactly is not available to a process that can be killed mid-job, so the choice is which error to take:

  • Record a claim before the work. Commit a row for this work id in its own transaction, before the effect’s transaction runs, and read that count. It survives a hard process death and a broker redelivery, which is what a durable cap needs, and it overcounts: a delivery that dies before doing anything useful has still spent an attempt.

  • Record a failure after the work. Write the row from the handler’s own catch. It never overcounts, and it cannot see a worker that was killed — a job that dies that way every time is never capped by it.

A counter written inside the effect’s own transaction is neither: it rolls back with the effect it was meant to count.

Connection lifetime

RabbitMqQueueFactory::fromConfig() builds the queue’s Thesis\Amqp\Client and hands the queue that client’s disconnect(), which closes this instance’s channel and the connection. For a queue the bootstrap binds, or one queue:work --connection=<name> builds, that runs when the worker ends, with no wiring of yours; build the backend yourself and registering $app->onDispose($queue->dispose(...)) is yours too. A RabbitMqQueue constructed around a client you built disconnects nothing — see Appendix: Queue Contracts’s “Connection ownership”.

Clearing a queue

RabbitMqQueue declares ClearableQueueInterface (see Queue’s “Clearing is a separate capability”). Clearing purges the queue and all of its delay queues and reports the total the broker removed. Messages delivered to a worker and not yet acked stay in place.

queue:stats and queue:clear see delayed jobs from any process, but they read the queue and its delay queues one at a time, not at one instant. A job moving between delay queues meanwhile can be counted twice, missed, or survive the purge.

Delayed jobs

$this->queue->push(new SendReminderEmail($userId), delaySeconds: 3600);

Delays need nothing installed in the broker — no delayed-message plugin and no scheduler process. A delay is a floor: the job is available no sooner than the delay, and the broker hands it over when it reaches it. One delay never waits behind another:

$this->queue->push(new HourlyRollup(), delaySeconds: 3600);
$this->queue->push(new SendReminderEmail($userId), delaySeconds: 3);
// The reminder is poppable 3 seconds later, not in an hour.

A single delay can be at most 4,194,303 seconds, about 48 days, a limit of the AMQP client’s encoding rather than of RabbitMQ. push() rejects a longer delay with an InvalidArgumentException naming the ceiling. RabbitMQ mechanisms explains the delay ladder and its ceiling.

Retries and giving up

Retries follow Queue: maxAttempts, QUEUE_MAX_ATTEMPTS and QUEUE_RETRY_BASE_DELAY_SECONDS. A delayed retry publishes its replacement into the same delay ladder a delayed push uses, subject to the same ceiling, and is still confirmed before the original delivery is discarded.

See also

  • Queue — jobs, workers, retries and delivery guarantees.

  • Appendix: Queue Contracts — publish confirmation, the delay ladder and delivery contracts.

  • Configuration — named connections and every QUEUE_RABBITMQ_* key.