An acknowledgement moves a cursor, it does not delete anything. The messages stay until retention removes them, so the history is still there to be read a second time.
Two operations use that, and they are not interchangeable. A new group is pointed at the
beginning with subscriptionMode('all'), which is how a consumer that did not exist when the
events happened gets backfilled. The mode applies once, when the cursor is created, so it can
never rewind a group that already exists.
That is what a seek is for. It moves an existing group back to a timestamp, and it releases any
live lease, so an in-flight batch is abandoned rather than acknowledged. Here the seek goes
through admin(), a method that returns the same Admin instance every time, and the timestamp
travels as a string, so it has to be ISO 8601 in UTC and not a DateTimeImmutable.
//
// Tutorial 4 of 4: replay.
//
// Acknowledging a message does not delete it. Consumption is a cursor per
// consumer group, and the messages stay until retention removes them, so a new
// group can read the whole history and an existing group can be moved back.
//
// This is the tutorial that shows what a cursor buys you: reprocessing after a
// bug, backfilling a new consumer, and auditing what was delivered, all without
// asking the producer to send anything twice.
//
// Run it:
// QUEEN_URL=http://localhost:6632 php 04-replay.php
require __DIR__ . '/vendor/autoload.php';
use Queen\Queen;
$QUEEN_URL = getenv('QUEEN_URL') ?: 'http://localhost:6632';
$RUN = base_convert((string) (int) (microtime(true) * 1000), 10, 36);
$EVENTS = "tut-php-replay-{$RUN}";
$EVENTS_IN = [
['seq' => 1, 'type' => 'created'],
['seq' => 2, 'type' => 'updated'],
['seq' => 3, 'type' => 'shipped'],
['seq' => 4, 'type' => 'delivered'],
];
$checks = 0;
$assert = function (bool $condition, string $description) use (&$checks): void {
if (!$condition) {
throw new RuntimeException($description);
}
$checks++;
echo " ok: {$description}\n";
};
$queen = new Queen($QUEEN_URL);
$exitCode = 0;
// A closure captures by value at the point it is written, so the client has to
// exist before this helper does. It reads one lane to the end and returns the
// sequence numbers it saw, in arrival order.
$drain = function (string $group, int $expected, ?string $mode = null) use ($queen, $EVENTS): array {
$seen = [];
$builder = $queen
->queue($EVENTS)
->partition('order-1')
->group($group)
->each()
->limit($expected)
->idleMillis(4000);
if ($mode !== null) {
$builder->subscriptionMode($mode);
}
$builder
->consume(function (array $msg) use (&$seen): void {
$seen[] = $msg['data']['seq'];
})
->execute();
return $seen;
};
try {
echo "broker {$QUEEN_URL}\n";
foreach ($EVENTS_IN as $event) {
$queen->queue($EVENTS)->partition('order-1')->push([['data' => $event]])->execute();
}
echo 'pushed ' . count($EVENTS_IN) . " events\n";
// The live consumer. It drains the lane and commits as it goes.
echo "\nthe live consumer\n";
$live = $drain('tut-php-live', 4, 'all');
echo ' saw ' . implode(', ', $live) . "\n";
$assert($live === [1, 2, 3, 4], 'the live group read the lane in order');
// A second group, created now, after every message was already stored and
// acknowledged by someone else. subscriptionMode('all') is what points its
// new cursor at the beginning: the default for a new group is the tail, so
// without it this group would sit idle waiting for the next event.
//
// The mode applies when the cursor is created and never again, so it cannot
// rewind a group that already exists. That is what seek below is for.
echo "\na new group, backfilled from the beginning\n";
$audit = $drain('tut-php-audit', 4, 'all');
echo ' saw ' . implode(', ', $audit) . "\n";
$assert($audit === [1, 2, 3, 4], 'a new group replayed the whole history');
// Nothing was re-pushed and nothing was copied: both groups read the same
// stored messages through their own cursors.
echo "\nrewinding an existing group\n";
// Move the live group's cursor back an hour, which is before anything in this
// run was pushed. The seek also releases any live lease, so an in-flight batch
// is abandoned rather than acknowledged.
//
// admin() is a method on this client, not a property, and it hands back the
// same Admin instance every time. The timestamp goes over the wire as a
// string, so it has to be ISO 8601 in UTC and not a DateTime object.
$anHourAgo = (new DateTimeImmutable('-1 hour', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z');
$queen->admin()->seekConsumerGroup('tut-php-live', $EVENTS, ['timestamp' => $anHourAgo]);
$replayed = $drain('tut-php-live', 4);
echo ' saw ' . implode(', ', $replayed) . "\n";
$assert(
$replayed === [1, 2, 3, 4],
'the rewound group read the same events again, in the same order'
);
// Replay is per group. The audit group was not moved, so it stays where it
// was and sees nothing new.
$auditAgain = $queen
->queue($EVENTS)
->partition('order-1')
->group('tut-php-audit')
->batch(10)
->wait(false)
->pop();
$assert(count($auditAgain) === 0, 'rewinding one group left the other where it was');
$queen->queue($EVENTS)->delete()->execute();
echo "\nPASS: {$checks} checks\n";
} catch (Throwable $error) {
fwrite(STDERR, "\nFAIL: " . $error->getMessage() . "\n");
$exitCode = 1;
} finally {
$queen->close();
}
exit($exitCode);Run it
Against a broker from the quickstart, from examples/tutorials/php with the
PHP client installed:
QUEEN_URL=http://localhost:6632 php 04-replay.phpThe program checks its own outcome and exits non-zero if a check fails. Every tutorial in this
section runs in the repository’s own suite: examples/tutorials/run.sh php.