Skip to content

Commit 3484b41

Browse files
committed
feat(consumer): opt-in idempotent consumption (I2)
Adds a native idempotency layer to the consumer: an IdempotencyStore contract (seen/claim/remember/forget) + an in-memory reference, and IdempotencyOptions (opt-in via a 3rd Dispatcher ctor arg; key = keyResolver -> meta.id -> trace_id). The Dispatcher claims before the handler (a lost claim -> Drop/ack-discard), remembers only after success, and forgets on a throw so an at-least-once redelivery stays retryable. Default (no options) is unchanged. BYO Redis/PDO backend documented.
1 parent 544eb3e commit 3484b41

9 files changed

Lines changed: 773 additions & 3 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,12 @@ Bugs from `1.x` that no longer exist in the new design:
4545
and **RabbitMQ** (AMQP), each paired with the SDK's publish side.
4646
- A thin `Producer` facade over `EnvelopeCodec` + a `Transport`.
4747
- A `bin/queue` CLI worker entry point.
48+
- **Opt-in idempotent consumption.** A new `IdempotencyStore` contract
49+
(`Contracts/IdempotencyStore.php`) with an in-memory reference implementation
50+
(`InMemoryIdempotencyStore`) lets the `Dispatcher` dedupe at-least-once
51+
redeliveries, processing a message **at most once**. Enable it by passing an
52+
`IdempotencyOptions` to the `Dispatcher`; the dedup key derives from a custom
53+
resolver, the envelope's `meta.id`, or its `trace_id`, and is recorded only
54+
after a handler succeeds (a throwing handler stays retryable). Disabled by
55+
default — existing behaviour is unchanged. Bring a durable, atomic store
56+
(Redis/PDO) for fleet-wide deduplication; see the README.

README.md

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,105 @@ new WorkerOptions(
157157
);
158158
```
159159

160-
Delivery is **at-least-once** — make handlers idempotent.
160+
Delivery is **at-least-once** — make handlers idempotent, or let the worker do
161+
it for you (see below).
162+
163+
## Idempotent consumption
164+
165+
Because delivery is at-least-once, a transport may hand the same message to a
166+
worker more than once (after a crash before the ack, a lapsed reservation, or a
167+
broker hiccup). The dispatcher can dedupe these redeliveries so a message is
168+
processed **at most once** — opt in by giving it an `IdempotencyOptions`:
169+
170+
```php
171+
use InitPHP\Queue\Consumer\Dispatcher;
172+
use InitPHP\Queue\Consumer\IdempotencyOptions;
173+
use InitPHP\Queue\Consumer\InMemoryIdempotencyStore;
174+
175+
$dispatcher = new Dispatcher(
176+
$handlers,
177+
idempotency: new IdempotencyOptions(new InMemoryIdempotencyStore()),
178+
);
179+
```
180+
181+
With no options (the default) deduplication is off and the dispatcher behaves
182+
exactly as before — this is fully backward-compatible.
183+
184+
**How it dedupes.** Before running a handler the dispatcher derives a stable
185+
dedup key from the message and *claims* it; a key that is already recorded means
186+
the message was processed before, so the handler is skipped and the message is
187+
acknowledged and discarded. The key is **only recorded after the handler
188+
succeeds** — a handler that throws leaves the key free, so the at-least-once
189+
redelivery is retried as normal.
190+
191+
The key is derived, in order, from:
192+
193+
1. a custom `keyResolver` you supply (`fn (ReceivedMessage): ?string`), else
194+
2. the producer-minted **`meta.id`** (the canonical message identity), else
195+
3. the **`trace_id`**.
196+
197+
A message with none of these has no stable identity and is processed without
198+
deduplication.
199+
200+
```php
201+
new IdempotencyOptions(
202+
store: $store,
203+
keyPrefix: 'bq:idemp:', // namespaces keys in a shared store
204+
ttl: 86_400, // seconds a processed key is retained (null = forever)
205+
leaseSeconds: 300, // in-flight claim lease (frees a crashed worker's claim)
206+
keyResolver: fn ($m) => $m->getData()['order_id'] ?? null, // optional
207+
);
208+
```
209+
210+
### Bring your own store
211+
212+
`InMemoryIdempotencyStore` is correct for a **single long-running worker** but it
213+
is not shared between processes and does not survive a restart, so it cannot
214+
dedupe across the whole fleet. For that, back the
215+
`InitPHP\Queue\Contracts\IdempotencyStore` contract with a durable, atomic store —
216+
exactly how the InitPHP ecosystem treats Cache and Database backends as
217+
pluggable seams:
218+
219+
```php
220+
use InitPHP\Queue\Contracts\IdempotencyStore;
221+
222+
final class RedisIdempotencyStore implements IdempotencyStore
223+
{
224+
public function __construct(private \Redis $redis) {}
225+
226+
public function seen(string $key): bool
227+
{
228+
return (bool) $this->redis->exists($key);
229+
}
230+
231+
public function claim(string $key, int $leaseSeconds = 0): bool
232+
{
233+
// Atomic test-and-set: only the first caller wins the claim.
234+
$options = ['NX'];
235+
if ($leaseSeconds > 0) {
236+
$options['EX'] = $leaseSeconds;
237+
}
238+
239+
return (bool) $this->redis->set($key, '1', $options);
240+
}
241+
242+
public function remember(string $key, ?int $ttl = null): void
243+
{
244+
$ttl !== null && $ttl > 0
245+
? $this->redis->set($key, '1', ['EX' => $ttl])
246+
: $this->redis->set($key, '1');
247+
}
248+
249+
public function forget(string $key): void
250+
{
251+
$this->redis->del($key);
252+
}
253+
}
254+
```
255+
256+
A PDO-backed store works the same way: `claim()` is an `INSERT` that fails on a
257+
unique `key` column (caught and reported as `false`), `remember()` upserts the
258+
row, and a `processed_at`/`expires_at` column drives the TTL.
161259

162260
## Unknown-URN strategy
163261

src/Consumer/Dispatcher.php

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use BabelQueue\Exceptions\UnknownUrnException;
88
use BabelQueue\Routing\UnknownUrnStrategy;
99
use BabelQueue\Validation\EnvelopeValidator;
10+
use InitPHP\Queue\Contracts\Handler;
1011
use InitPHP\Queue\Contracts\HandlerResolver;
1112
use InitPHP\Queue\Exceptions\ConfigurationException;
1213
use InitPHP\Queue\Message\ReceivedMessage;
@@ -21,18 +22,27 @@
2122
* resolve the handler by URN, and run it. A handler that returns is an ack; one
2223
* that throws is a retry; an unmapped URN is resolved by the configured
2324
* {@see UnknownUrnStrategy} (`fail` / `delete` / `release` / `dead_letter`).
25+
*
26+
* When an {@see IdempotencyOptions} is supplied the pipeline gains a
27+
* deduplication step: a redelivered message whose key is already recorded is
28+
* dropped without re-running the handler, and a key is only recorded *after*
29+
* the handler succeeds — so a throwing handler stays retryable. With no options
30+
* (the default) the dispatcher behaves exactly as before.
2431
*/
2532
final class Dispatcher
2633
{
2734
/**
2835
* @param HandlerResolver $resolver Maps a message URN to its handler.
2936
* @param string $unknownUrnStrategy One of the {@see UnknownUrnStrategy} constants.
37+
* @param IdempotencyOptions|null $idempotency Opt-in deduplication; null
38+
* (the default) disables it and preserves the prior behaviour.
3039
*
3140
* @throws ConfigurationException When `$unknownUrnStrategy` is not a known strategy.
3241
*/
3342
public function __construct(
3443
private readonly HandlerResolver $resolver,
3544
private readonly string $unknownUrnStrategy = UnknownUrnStrategy::FAIL,
45+
private readonly ?IdempotencyOptions $idempotency = null,
3646
) {
3747
if (! in_array($unknownUrnStrategy, UnknownUrnStrategy::all(), true)) {
3848
throw new ConfigurationException(sprintf(
@@ -60,13 +70,44 @@ public function dispatch(ReceivedMessage $message): Outcome
6070
return $this->onUnknownUrn($message);
6171
}
6272

73+
return $this->runHandler($handler, $message);
74+
}
75+
76+
/**
77+
* Run the resolved handler, guarded by the idempotency layer when enabled.
78+
*
79+
* The dedup key is claimed *before* the handler runs (so a concurrent
80+
* redelivery is skipped) and only committed with {@see IdempotencyStore::remember()}
81+
* *after* the handler returns — a throwing handler leaves the key
82+
* uncommitted, so the at-least-once redelivery is retried as normal.
83+
*/
84+
private function runHandler(Handler $handler, ReceivedMessage $message): Outcome
85+
{
86+
$key = $this->idempotency?->keyFor($message);
87+
88+
// A duplicate that is already recorded: it was processed before, so
89+
// acknowledge and discard rather than running the handler again.
90+
if ($key !== null && ! $this->idempotency->store->claim($key, $this->idempotency->leaseSeconds)) {
91+
return Outcome::drop('duplicate');
92+
}
93+
6394
try {
6495
$handler->handle($message);
65-
66-
return Outcome::ack();
6796
} catch (Throwable $e) {
97+
// Release the in-flight claim so the redelivery can re-run; leaving
98+
// it set would suppress a legitimate retry.
99+
if ($key !== null) {
100+
$this->idempotency->store->forget($key);
101+
}
102+
68103
return Outcome::retry($e, 'failed');
69104
}
105+
106+
if ($key !== null) {
107+
$this->idempotency->store->remember($key, $this->idempotency->ttl);
108+
}
109+
110+
return Outcome::ack();
70111
}
71112

72113
private function onUnknownUrn(ReceivedMessage $message): Outcome
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace InitPHP\Queue\Consumer;
6+
7+
use Closure;
8+
use InitPHP\Queue\Contracts\IdempotencyStore;
9+
use InitPHP\Queue\Exceptions\ConfigurationException;
10+
use InitPHP\Queue\Message\ReceivedMessage;
11+
12+
/**
13+
* Opt-in configuration for the {@see Dispatcher}'s idempotency layer: the
14+
* {@see IdempotencyStore} that records seen messages, how a stable dedup key is
15+
* derived from each message, and the TTL/lease bookkeeping.
16+
*
17+
* Constructing one of these and passing it to the {@see Dispatcher} is the
18+
* single switch that turns deduplication on; with no instance (the default) the
19+
* dispatcher behaves exactly as before. It is immutable, mirroring
20+
* {@see WorkerOptions}.
21+
*
22+
* Key derivation, in order:
23+
* - a caller-supplied `$keyResolver` (`fn (ReceivedMessage): ?string`) wins
24+
* when it returns a non-empty string;
25+
* - otherwise the producer-minted **`meta.id`** (the canonical message
26+
* identity) is used;
27+
* - failing that, the **`trace_id`** is used as a last resort.
28+
* A message that yields none of these has no stable identity and is processed
29+
* normally (the dispatcher cannot safely dedupe it).
30+
*/
31+
final class IdempotencyOptions
32+
{
33+
/** @var (Closure(ReceivedMessage): ?string)|null */
34+
private readonly ?Closure $keyResolver;
35+
36+
/**
37+
* @param IdempotencyStore $store Backend recording processed/in-flight keys.
38+
* @param string $keyPrefix Namespacing prefix for every key (lets one
39+
* shared store serve several workers without
40+
* collisions).
41+
* @param int|null $ttl Seconds a processed key is retained (should
42+
* comfortably outlive the transport's redelivery
43+
* window); null retains it indefinitely.
44+
* @param int $leaseSeconds How long an in-flight claim is held before it
45+
* may be reclaimed should the worker die mid-handler.
46+
* @param (callable(ReceivedMessage): ?string)|null $keyResolver Optional
47+
* custom key derivation; return null/'' to fall back to meta.id/trace_id.
48+
*
49+
* @throws ConfigurationException When a numeric option is out of range.
50+
*/
51+
public function __construct(
52+
public readonly IdempotencyStore $store,
53+
public readonly string $keyPrefix = 'bq:idemp:',
54+
public readonly ?int $ttl = 86_400,
55+
public readonly int $leaseSeconds = 300,
56+
?callable $keyResolver = null,
57+
) {
58+
if ($ttl !== null && $ttl < 0) {
59+
throw new ConfigurationException('Idempotency ttl must be null or zero/positive.');
60+
}
61+
62+
if ($leaseSeconds < 0) {
63+
throw new ConfigurationException('Idempotency leaseSeconds must be zero or positive.');
64+
}
65+
66+
$this->keyResolver = $keyResolver !== null ? $keyResolver(...) : null;
67+
}
68+
69+
/**
70+
* Derive the stable dedup key for `$message`, or null when the message has
71+
* no stable identity (in which case it is processed without deduplication).
72+
*/
73+
public function keyFor(ReceivedMessage $message): ?string
74+
{
75+
$identity = $this->resolveIdentity($message);
76+
77+
return $identity === null ? null : $this->keyPrefix . $identity;
78+
}
79+
80+
private function resolveIdentity(ReceivedMessage $message): ?string
81+
{
82+
if ($this->keyResolver !== null) {
83+
$custom = ($this->keyResolver)($message);
84+
if (is_string($custom) && $custom !== '') {
85+
return $custom;
86+
}
87+
}
88+
89+
$metaId = $message->getMeta()['id'] ?? null;
90+
if (is_string($metaId) && $metaId !== '') {
91+
return $metaId;
92+
}
93+
94+
$traceId = $message->getTraceId();
95+
96+
return $traceId !== '' ? $traceId : null;
97+
}
98+
}

0 commit comments

Comments
 (0)