Skip to content

Commit 265d758

Browse files
authored
Keep the message that hits the limit in the queue (#340)
* Keep a message the handler refused in the queue `queue:run --limit=1` over two queued messages reported one processed and left the queue empty: `InMemoryAdapter::runExisting()` removed the message that tripped the limit even though the callback returned `false` without handling it. Both shipped adapters already treat `false` as "not consumed" — `queue-redis` deletes only when the callback returned `true`, `queue-amqp` nacks with requeue otherwise — so the stub was the outlier. Spell that contract out on `runExisting()` and `subscribe()`. * Check the loop before handling a message A handled message was kept and handled again when the loop stopped right after it. The callback now returns false only for a message it left untouched, which is what the adapter contract says. Drop the FIFO claim and the contract sentence on subscribe(): queue-db orders by priority then id, and queue-amqp's subscribe() acks regardless of the callback result. * Do not pause queue:listen-all after the loop asked to stop The pause between iterations ran even when the loop had already stopped, so SIGTERM waited out the full --pause before the command exited. The limit option is read once, and both commands return Command::SUCCESS. * Cover the pause between empty iterations The pause line lost its only (accidental) coverage when the command stopped sleeping on shutdown. * Say on subscribe() too that false means the message was not consumed
1 parent b4d0060 commit 265d758

7 files changed

Lines changed: 111 additions & 17 deletions

File tree

‎src/Adapter/AdapterInterface.php‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
interface AdapterInterface
1111
{
1212
/**
13-
* Returns the first message from the queue if it exists (null otherwise).
13+
* Passes existing messages to the given handler, in the order the adapter delivers them, until the queue is
14+
* empty or the handler refuses a message.
1415
*
15-
* @param callable(MessageInterface): bool $handlerCallback The handler which will handle messages. Returns false if it cannot continue handling messages
16+
* The handler returns `false` for a message it did not consume: that message must stay in the queue, and no
17+
* further messages are passed. Only a message the handler returned `true` for may be removed from the queue.
18+
*
19+
* @param callable(MessageInterface): bool $handlerCallback The handler which will handle messages. Returns false for a message it did not consume.
1620
*/
1721
public function runExisting(callable $handlerCallback): void;
1822

@@ -32,7 +36,10 @@ public function push(MessageInterface $message): MessageInterface;
3236
/**
3337
* Listen to the queue and pass messages to the given handler as they come.
3438
*
35-
* @param callable(MessageInterface): bool $handlerCallback The handler which will handle messages. Returns false if it cannot continue handling messages.
39+
* The handler returns `false` for a message it did not consume, as in {@see runExisting()}: that message must
40+
* stay in the queue.
41+
*
42+
* @param callable(MessageInterface): bool $handlerCallback The handler which will handle messages. Returns false for a message it did not consume.
3643
*/
3744
public function subscribe(callable $handlerCallback): void;
3845
}

‎src/Command/ListenAllCommand.php‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8484
$pauseSeconds = 1;
8585
}
8686

87+
$limit = (int) $input->getOption('limit');
88+
8789
while ($this->loop->canContinue()) {
8890
$hasMessages = false;
8991
foreach ($consumers as $consumer) {
90-
$hasMessages = $consumer->run((int) $input->getOption('limit')) > 0 || $hasMessages;
92+
$hasMessages = $consumer->run($limit) > 0 || $hasMessages;
9193
}
9294

93-
if (!$hasMessages) {
95+
if (!$hasMessages && $this->loop->canContinue()) {
9496
/** @psalm-var 0|positive-int $pauseSeconds */
9597
sleep($pauseSeconds);
9698
}
9799
}
98100

99-
return 0;
101+
return Command::SUCCESS;
100102
}
101103
}

‎src/Command/RunCommand.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6060
$output->writeln("Messages processed: $count.");
6161
}
6262

63-
return 0;
63+
return Command::SUCCESS;
6464
}
6565
}

‎src/QueueConsumer.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ public function listen(): void
5858

5959
private function handle(MessageInterface $message): bool
6060
{
61+
if (!$this->loop->canContinue()) {
62+
return false;
63+
}
64+
6165
$this->worker->process($message, $this->queueName);
62-
return $this->loop->canContinue();
66+
return true;
6367
}
6468
}

‎stubs/InMemoryAdapter.php‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ public function push(MessageInterface $message): MessageInterface
4848

4949
public function runExisting(callable $handlerCallback): void
5050
{
51-
$result = true;
52-
while (isset($this->messages[$this->current]) && $result === true) {
53-
$result = $handlerCallback($this->messages[$this->current]);
51+
while (isset($this->messages[$this->current])) {
52+
if ($handlerCallback($this->messages[$this->current]) !== true) {
53+
break;
54+
}
55+
5456
unset($this->messages[$this->current]);
5557
$this->current++;
5658
}

‎tests/Unit/Command/ListenAllCommandTest.php‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,56 @@ public function testRunsOnlyConsumerRolesByDefault(): void
3030
{
3131
$consumer = $this->createMock(QueueConsumerInterface::class);
3232
$consumer->expects($this->once())->method('run')->willReturn(0);
33-
$loop = $this->createMock(LoopInterface::class);
34-
$loop->method('canContinue')->willReturn(true, false);
3533
$command = new ListenAllCommand(new PredefinedQueueProvider([
3634
'producer' => ['producer' => new StubQueueProducer()],
3735
'consumer' => ['consumer' => $consumer],
38-
]), $loop);
39-
$input = new ArrayInput([], $command->getNativeDefinition());
40-
$input->setOption('pause', 0);
41-
self::assertSame(0, $command->run($input, $this->createMock(OutputInterface::class)));
36+
]), $this->createLoop(1));
37+
$input = new ArrayInput(['--pause' => 0], $command->getNativeDefinition());
38+
self::assertSame(Command::SUCCESS, $command->run($input, $this->createMock(OutputInterface::class)));
39+
}
40+
41+
public function testDoesNotPauseWhenTheLoopCannotContinue(): void
42+
{
43+
$consumer = $this->createMock(QueueConsumerInterface::class);
44+
$consumer->method('run')->willReturn(0);
45+
$command = new ListenAllCommand(new PredefinedQueueProvider([
46+
'consumer' => ['consumer' => $consumer],
47+
]), $this->createLoop(1));
48+
$input = new ArrayInput(['--pause' => 3], $command->getNativeDefinition());
49+
50+
$start = hrtime(true);
51+
$command->run($input, $this->createMock(OutputInterface::class));
52+
53+
self::assertLessThan(1.0, (hrtime(true) - $start) / 1_000_000_000, 'The pause is skipped once the loop asked to stop.');
54+
}
55+
56+
public function testPausesWhenThereAreNoMessages(): void
57+
{
58+
$consumer = $this->createMock(QueueConsumerInterface::class);
59+
$consumer->method('run')->willReturn(0);
60+
$command = new ListenAllCommand(new PredefinedQueueProvider([
61+
'consumer' => ['consumer' => $consumer],
62+
]), $this->createLoop(2));
63+
$input = new ArrayInput(['--pause' => 1], $command->getNativeDefinition());
64+
65+
$start = hrtime(true);
66+
$command->run($input, $this->createMock(OutputInterface::class));
67+
68+
self::assertGreaterThanOrEqual(1.0, (hrtime(true) - $start) / 1_000_000_000, 'An empty queue is polled once per pause.');
69+
}
70+
71+
/**
72+
* @return LoopInterface A loop which allows the given number of checks and then asks to stop.
73+
*/
74+
private function createLoop(int $continueTimes): LoopInterface
75+
{
76+
return new class ($continueTimes) implements LoopInterface {
77+
public function __construct(private int $continueTimes) {}
78+
79+
public function canContinue(): bool
80+
{
81+
return $this->continueTimes-- > 0;
82+
}
83+
};
4284
}
4385
}

‎tests/Unit/QueueTest.php‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
use Yiisoft\Queue\QueueConsumer;
99
use Yiisoft\Queue\QueueConsumerInterface;
1010
use Yiisoft\Queue\QueueProducerInterface;
11+
use Yiisoft\Queue\Cli\LoopInterface;
1112
use Yiisoft\Queue\Message\GenericMessage;
1213
use Yiisoft\Queue\Message\IdEnvelope;
1314
use Yiisoft\Queue\MessageStatus;
1415
use Yiisoft\Queue\Stubs\InMemoryAdapter;
16+
use Yiisoft\Queue\Stubs\StubLoop;
1517
use Yiisoft\Queue\Tests\TestCase;
1618

1719
use function count;
@@ -81,5 +83,40 @@ public function testConsumerStopsAtLimit(): void
8183
$consumer = new QueueConsumer($this->getWorker(), $this->getLoop(), new NullLogger(), $adapter);
8284
self::assertSame(1, $consumer->run(1));
8385
self::assertSame(1, $this->executionTimes);
86+
self::assertCount(1, $adapter->getMessagesList(), 'The message that hits the limit stays in the queue.');
87+
}
88+
89+
public function testMessageIsNotConsumedWhenLoopCannotContinue(): void
90+
{
91+
$adapter = new InMemoryAdapter();
92+
$producer = $this->createQueue($adapter);
93+
$producer->push(new GenericMessage('simple', null));
94+
$producer->push(new GenericMessage('simple', null));
95+
$consumer = new QueueConsumer($this->getWorker(), new StubLoop(false), new NullLogger(), $adapter);
96+
97+
self::assertSame(0, $consumer->run());
98+
self::assertSame(0, $this->executionTimes);
99+
self::assertCount(2, $adapter->getMessagesList());
100+
}
101+
102+
public function testMessageHandledBeforeStopIsNotHandledAgain(): void
103+
{
104+
$adapter = new InMemoryAdapter();
105+
$producer = $this->createQueue($adapter);
106+
$producer->push(new GenericMessage('simple', null));
107+
$producer->push(new GenericMessage('simple', null));
108+
$loop = $this->createMock(LoopInterface::class);
109+
$loop->method('canContinue')->willReturn(true, false);
110+
$consumer = new QueueConsumer($this->getWorker(), $loop, new NullLogger(), $adapter);
111+
112+
self::assertSame(1, $consumer->run());
113+
self::assertSame(1, $this->executionTimes);
114+
self::assertCount(1, $adapter->getMessagesList());
115+
116+
$consumer = new QueueConsumer($this->getWorker(), new StubLoop(), new NullLogger(), $adapter);
117+
118+
self::assertSame(1, $consumer->run());
119+
self::assertSame(2, $this->executionTimes, 'The message handled before the stop is not handled again.');
120+
self::assertCount(0, $adapter->getMessagesList());
84121
}
85122
}

0 commit comments

Comments
 (0)