Idempotency
Attach an IdempotencyStamp to a dispatch, and a second dispatch of the same message class
with the same key is dropped while the first one is still locked. The bundle does not
implement deduplication itself. Its IdempotencyStampDecider converts the stamp into
Symfony Messenger's DeduplicateStamp, and Messenger's deduplicate middleware enforces it
with a lock from the Lock component.
Requirements
- symfony/lock.
framework.lockenabled. FrameworkBundle adds Messenger'sdeduplicate_middlewareto buses that use the default middleware only when the lock component is enabled. It is enabled automatically oncesymfony/lockis installed, unless you turned it off.- A shared lock store that keeps keys until their TTL. The default stores do not work:
flockandsemaphorerelease a key as soon as the dispatch returns, so a second dispatch with the same key goes through, andin-memoryonly deduplicates within one process. The keys offlock,semaphore,postgresql+advisoryandzookeeperstores are tied to the process or connection and cannot be sent with async messages: an asynchronous dispatch with anIdempotencyStampthen fails with aLogicExceptionthat names the problem. PostgreSQL advisory locks and ZooKeeper also ignore the TTL: a key stays locked as long as the connection that took it, which in a long-running worker can be forever. Pointframework.lockat Redis, Memcached or a PDO/DBAL database; the container compilation log warns when the configured store is one of the others (for a DSN from an environment variable, with the value it had when the container was compiled):
A missing piece never breaks the build, because idempotency is enabled by default. Instead,
the IdempotencyStamp is ignored and nothing is deduplicated. The first message that carries
an IdempotencyStamp in a process logs a warning with the reason (The IdempotencyStamp of
App\Command\ChargePayment may not prevent duplicates: …), and the container compilation log
says the same. In debug mode, Symfony writes it to
var/cache/<env>/<ContainerClass>Compiler.log:
It reports one of the following:
Idempotency is enabled but needs symfony/lock; IdempotencyStamp is ignored until it is installed.Idempotency is enabled but Messenger's deduplicate middleware is not registered, so DeduplicateStamp is not enforced. Enable the lock component ("framework.lock").Idempotency is enabled but the lock store "in-memory" only deduplicates within one process. …Idempotency is enabled but the lock store "flock" releases a key as soon as the dispatch returns and only lives on one host: …Idempotency is enabled but the lock store "postgresql+advisory://…" ties its keys to one connection: …
Usage
<?php
declare(strict_types=1);
namespace App\Application;
use App\Application\Command\ChargePayment;
use SomeWork\CqrsBundle\Contract\CommandBusInterface;
use SomeWork\CqrsBundle\Exception\DuplicateMessageException;
use SomeWork\CqrsBundle\Stamp\IdempotencyStamp;
final class PaymentService
{
public function __construct(
private readonly CommandBusInterface $commandBus,
) {
}
public function charge(string $orderId): ?string
{
try {
return $this->commandBus->dispatchSync(new ChargePayment($orderId), new IdempotencyStamp($orderId));
} catch (DuplicateMessageException $e) {
// Dispatched recently (within the TTL): $e->deduplicationKey === 'App\Application\Command\ChargePayment::<orderId>'.
// Usually charged already, or in progress; after a killed process the charge may not have
// run (see "Lock lifetime"), so check the order's state before reporting success.
return null;
}
}
public function chargeLater(string $orderId): void
{
// A duplicate is not sent to the transport; no exception is thrown.
$this->commandBus->dispatchAsync(new ChargePayment($orderId), new IdempotencyStamp($orderId));
}
}
The key should identify the operation, for example an order id or a client-supplied request
id. Keys are global per message class: two users or tenants that send the same key for the same
message class collide, and the second message is dropped as a duplicate. Scope keys that come
from clients, e.g. new IdempotencyStamp($tenantId.':'.$userId.':'.$requestId). Keys are
stored in the lock store, logged and shown in DuplicateMessageException: use ids, never
personal data such as e-mail addresses (see Personal data).
new IdempotencyStamp('') throws an InvalidArgumentException. Stamps are variadic
arguments of every dispatch method: dispatch($message, DispatchMode::DEFAULT, ...$stamps),
dispatchSync($message, ...$stamps), dispatchAsync($message, ...$stamps) and
ask($query, ...$stamps).
How it works
IdempotencyStampDecider runs in the stamp pipeline for every message type, at priority 50.
When the stamps contain an IdempotencyStamp, it:
- builds the key
<message FQCN>::<idempotency key>, for exampleApp\Application\Command\ChargePayment::order-123. The same raw key on two message classes gives two different locks; - adds
new DeduplicateStamp($key, $ttl, false), where$ttlisidempotency.ttlandfalseis Messenger'sonlyDeduplicateInQueueflag (see below); - keeps the
IdempotencyStampon the envelope, so handlers and middleware can still read the key.
A DeduplicateStamp passed by the caller wins. The decider then adds nothing. Use this
approach to set a different TTL for one dispatch. The key is then yours as given, not
namespaced:
<?php
declare(strict_types=1);
namespace App\Application;
use App\Application\Command\GenerateInvoice;
use SomeWork\CqrsBundle\Contract\CommandBusInterface;
use Symfony\Component\Messenger\Stamp\DeduplicateStamp;
final class InvoiceService
{
public function __construct(
private readonly CommandBusInterface $commandBus,
) {
}
public function generate(string $invoiceId): void
{
$this->commandBus->dispatchAsync(
new GenerateInvoice($invoiceId),
new DeduplicateStamp(GenerateInvoice::class.'::'.$invoiceId, 3600.0),
);
}
}
Messenger's DeduplicateMiddleware on the target bus then tries to acquire the lock without
waiting. Acquisition is atomic in the lock store. If the lock is already held, the envelope
is returned unhandled and unsent.
Lock lifetime
With onlyDeduplicateInQueue = false, the lock lives as follows:
| Situation | Lock |
|---|---|
| Handled synchronously, handler succeeded | Held until the TTL expires. Further dispatches with the key are dropped during that window. |
| Handled synchronously, handler threw | Released immediately by the bundle's DeduplicationLockReleaseMiddleware, so the caller can retry with the same key at once. The same applies when sending to a transport fails. |
| Handled synchronously, PHP fatal error (memory or time limit) | Released when PHP shuts down, by a shutdown function of the same middleware. |
| Handled synchronously, process killed (SIGKILL, OOM killer) | Held until the TTL expires: a retry is reported as a duplicate although the handler did not finish. Keep the TTL short for operations the caller retries, and do not treat DuplicateMessageException as proof that the operation succeeded. |
| Sent to a transport | Held while the message waits in the queue and while a worker handles it. Released after the worker handled it successfully. |
| Worker handler threw | Kept. It is released when a Messenger retry of the message succeeds, and otherwise expires with the TTL. |
| Queue wait or processing longer than the TTL | The lock expires, and a new dispatch with the same key goes through. |
Pick a TTL longer than the time a message typically waits in the queue plus its processing time.
What the caller sees when a duplicate is dropped
| Call | Result |
|---|---|
CommandBus::dispatchSync(), QueryBus::ask() |
Throw SomeWork\CqrsBundle\Exception\DuplicateMessageException, because no handler result exists. Its public read-only properties are messageClass, busName and deduplicationKey. |
EventBus::dispatchSync(), or dispatch() resolving to sync on either bus |
No exception. No handler runs, and the returned envelope has no HandledStamp. |
dispatchAsync(), or dispatch() resolving to async |
No exception. The message is not sent, and the returned envelope has no SentStamp. |
Asynchronous dispatches carry a DispatchAfterCurrentBusStamp by default. When such a
dispatch happens inside a handler, Messenger defers it until the current handler finishes,
and the duplicate check runs at that later point.
Configuration
| Option | Default | Description |
|---|---|---|
enabled |
true |
Registers IdempotencyStampDecider. The decider is also skipped when symfony/lock is missing. The value decides which services exist, so it must be a plain boolean, not an %env()% value. |
ttl |
300 |
Lock TTL in seconds (integer, at least 1) for every DeduplicateStamp the bundle creates. |
Limitations
- Dispatch-side only. The conversion happens in the stamp pipeline of the CQRS buses,
also for a dispatch through the outbox, where it happens when
the message is stored. Messages dispatched directly on a Messenger bus, and messages stored
with
OutboxWriter::store(), are not converted, and the relay does not convert the rows it sends. Worker redeliveries are not checked again either. ADeduplicateStampstored in the outbox (through the buses or withOutboxWriter) gets a key scoped to the row's transport (<key>@<transport>), so it does not deduplicate against the same key dispatched directly on a bus. With a lock store whose keys cannot be sent (flock,semaphore, PostgreSQL advisory locks, ZooKeeper), a message stored in the outbox with aDeduplicateStamp(from anIdempotencyStamp, its default stamps or the caller) is refused when it is stored, since the relay could never send it. - Time-bounded. Deduplication lasts as long as the lock (see Lock lifetime). It is not a permanent record of processed operations.
- Only as reliable as the lock store. The local stores (flock, semaphore, in-memory) do not deduplicate reliably (see Requirements), and a store that loses its data (for example, a Redis restart) forgets the locks.
For guarantees that do not expire, check on the consuming side as well. For example, record
processed keys in a table with a unique constraint. An EnvelopeAware handler (see
Receiving the envelope) can read the key with
$this->getEnvelope()->last(IdempotencyStamp::class)?->getKey().