Skip to content

Commit be06cc5

Browse files
authored
Merge pull request #3 from ModelsLab/fix/redis-read-timeout-and-keepalive
fix: bound the Redis read so a dead socket cannot park a worker forever
2 parents 1f2bebd + b4b05fb commit be06cc5

3 files changed

Lines changed: 121 additions & 2 deletions

File tree

.phpunit.cache/test-results

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"version":2,"defects":{"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueue":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskDetails":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskHistoryWithErrorDetails":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithEmptyPayload":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithLargePayload":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithSpecialCharacters":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithNestedPayload":7,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamingTask":7,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamCombinedResult":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskExecution":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskWithReturnValue":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testEchoTask":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testMultipleConcurrentTasks":7,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testWorkerLoopDoesNotScanTaskResults":7,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testOnlyTheKeyThatLostItsTtlIsRead":7,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testHealthyKeysAreNeverRead":7,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testWorkerBlockingPopUsesTheConfiguredTimeout":7,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testReadTimeoutExceedsBlpopTimeout":7,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testLivenessBoundsAreFinite":7},"times":{"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutException":0.001,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutExceptionWithCustomMessage":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskProcessingException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testRetryTaskException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testRetryTaskExceptionWithCustomMessage":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testExceptionsAreCatchable":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testExceptionsCanBeChained":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutExceptionWithEmptyTaskId":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskProcessingExceptionWithEmptyError":0.001,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskProcessingExceptionWithSpecialCharacters":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutExceptionWithUnicodeTaskId":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testRetryTaskExceptionCanBeRethrown":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testExceptionCodePreserved":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testDeepExceptionChaining":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testMiddlewareCanBeInstantiated":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testBaseMiddlewareMethodsDoNothing":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testCustomMiddlewareCallsTracked":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnErrorReceivesException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnTimeoutCalled":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testMiddlewareWithNullTask":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testBeforeWorkerShutdownCalled":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testAfterWorkerShutdownCalled":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testFullWorkerLifecycle":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testBaseMiddlewareShutdownMethodsDoNothing":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnErrorWithNullException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnErrorWithNullTaskAndException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testMiddlewareWithDifferentExceptionTypes":0,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetQueuedTaskCountUsesLlen":0.001,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetQueuedTaskCountReturnsZeroOnEmptyQueue":0,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetQueuedTaskCountCastsFalseToZero":0,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetProcessingTaskCountUsesScard":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskCreation":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskIdIsUuid":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskTimeout":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testToArray":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArray":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArrayWithMissingOptionalFields":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testSerializationRoundTrip":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testOriginalPayloadPreserved":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskWithAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskWithoutAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testToArrayIncludesAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testToArrayWithEmptyAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArrayExtractsAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArrayWithNoAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testAdditionalParamsRoundTrip":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testInstantiation":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testInstantiationWithCustomRedisClient":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskRegistration":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMultipleTaskRegistration":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskRegistrationWithOptions":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testServerRegistration":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueue":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithAdditionalParams":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetAllQueuedTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStatus":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testRemoveTaskFromQueue":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testDeleteQueue":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueDelayedTask":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetProcessingTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMiddlewareHooks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetRedisClient":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelTask":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelNonexistentTask":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testIsTaskCancelled":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetCancelledTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTaskAsErrorDrainsQueueAndSetsFailedStatus":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTaskAsErrorIsIdempotentAndSafeWhenKeyEvicted":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTasksAsErrorBulkDrainsManyWithOneMlTasksScan":0.004,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTasksAsErrorWithEmptyListIsANoOp":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testReportProgress":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testReportProgressClampsValues":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskProgressNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testReportProgressWithoutMessage":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskDetails":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskDetailsNotFound":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistory":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryWithStatusFilter":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryWithNameFilter":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetFailedTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetCompletedTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTasksByName":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStats":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryCount":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testClearTaskHistory":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskHistoryWithErrorDetails":0.007,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCleanupExpiredTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testConfigurableTaskTtl":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testConfigurableHistoryRetention":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkers":0.005,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkersMultiple":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorker":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkerNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkerWithSystemInfo":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkersWithMalformedJson":0.053,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetStatus":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetStatusNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetProgress":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetProgressNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskIsCancelled":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithEmptyPayload":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithLargePayload":0.009,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithSpecialCharacters":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithNestedPayload":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCustomTaskId":0.006,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testRemoveNonexistentTask":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStatusNonexistent":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelAlreadyCompletedTask":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelAlreadyFailedTask":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMultipleCancellationCalls":0.008,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryPagination":0.005,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetProcessingTasksWithStaleEntries":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetProcessingTasksWithWrongStatus":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithCustomTaskIdAndAdditionalParams":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testStopMethod":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testDelayedTaskWithZeroDelay":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testDelayedTaskWithLargeDelay":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryWithMissingData":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStatsWithEmptyHistory":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testClearTaskHistoryWithNoOldTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\QueueDepthIntegrationTest::testQueuedTaskCountMatchesLlenAndOldCountPath":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\QueueDepthIntegrationTest::testProcessingTaskCountMatchesScard":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamingTask":3.366,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamTaskCompletion":3.424,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamCombinedResult":3.268,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskExecution":2.242,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskWithReturnValue":2.166,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testEchoTask":2.18,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testMultipleConcurrentTasks":2.156,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskStatusTransition":2.282,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testHealthyKeysAreNeverRead":0.007,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testOnlyTheKeyThatLostItsTtlIsRead":0.004,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testRecentOrphanIsBoundedNotDeleted":0,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testWorkerLoopDoesNotScanTaskResults":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testReadTimeoutExceedsBlpopTimeout":0.005,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testLivenessBoundsAreFinite":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testWorkerBlockingPopUsesTheConfiguredTimeout":0.025}}

src/ModelQ.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ class ModelQ
3131
public const DEFAULT_STREAM_TIMEOUT = 300; // 5 minutes default stream timeout
3232
public const SCAN_BATCH = 500; // keys per SCAN round-trip
3333

34+
/**
35+
* Connection liveness.
36+
*
37+
* BLPOP_TIMEOUT is what we ask the *server* to wait. READ_TIMEOUT is what
38+
* the *client* will wait for that answer to arrive. The second must exceed
39+
* the first, or every idle poll aborts the read before Redis has replied.
40+
*
41+
* phpredis defaults both connect and read timeouts to 0, meaning "wait
42+
* forever". A silently dropped TCP connection then parks the reader
43+
* permanently: the server-side BLPOP timeout is irrelevant because the
44+
* reply it would have produced can never reach us.
45+
*/
46+
public const BLPOP_TIMEOUT = 1; // seconds Redis holds the blocking pop
47+
public const CONNECT_TIMEOUT = 5; // seconds to establish a connection
48+
public const READ_TIMEOUT = 10; // seconds to wait for any single reply
49+
public const TCP_KEEPALIVE = 60; // seconds idle before the kernel probes
50+
3451
private Redis $redis;
3552
private string $serverId;
3653
private array $allowedTasks = [];
@@ -63,7 +80,21 @@ public function __construct(
6380
$this->redis = $redisClient;
6481
} else {
6582
$this->redis = new Redis();
66-
$this->redis->connect($host, $port);
83+
// Bounded connect and read. Without the read timeout phpredis waits
84+
// forever for a reply, so a half-open socket strands the caller
85+
// instead of raising — see the constants above.
86+
$this->redis->connect(
87+
$host,
88+
$port,
89+
self::CONNECT_TIMEOUT,
90+
null,
91+
0,
92+
self::READ_TIMEOUT
93+
);
94+
// Let the kernel notice a peer that has gone away while we are idle.
95+
if (defined('Redis::OPT_TCP_KEEPALIVE')) {
96+
$this->redis->setOption(Redis::OPT_TCP_KEEPALIVE, self::TCP_KEEPALIVE);
97+
}
6798
if ($password) {
6899
$this->redis->auth($password);
69100
}
@@ -264,7 +295,7 @@ public function startWorkers(int $workers = 1): void
264295

265296
// Process tasks
266297
$this->updateServerStatus('idle');
267-
$taskData = $this->redis->blPop(['ml_tasks'], 1);
298+
$taskData = $this->redis->blPop(['ml_tasks'], self::BLPOP_TIMEOUT);
268299

269300
if (!$taskData) {
270301
continue;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ModelsLab\ModelQ\Tests\Unit;
6+
7+
use ModelsLab\ModelQ\ModelQ;
8+
use PHPUnit\Framework\TestCase;
9+
use Redis;
10+
11+
/**
12+
* Regression tests for the 2026-08-15 silent worker stall.
13+
*
14+
* A dropped TCP connection to Redis left workers parked forever on a blocking
15+
* read. phpredis defaults its read timeout to 0 — "wait forever" — so the
16+
* server-side BLPOP timeout is no protection at all: the reply that would end
17+
* the wait can never arrive over a socket that no longer exists.
18+
*/
19+
class ConnectionLivenessTest extends TestCase
20+
{
21+
/**
22+
* The client must outwait the server, or every idle poll aborts early.
23+
*
24+
* BLPOP asks Redis to hold the pop for BLPOP_TIMEOUT seconds. If the client
25+
* gives up reading before then, a correct empty reply is turned into an
26+
* exception on every single idle loop.
27+
*/
28+
public function testReadTimeoutExceedsBlpopTimeout(): void
29+
{
30+
$this->assertGreaterThan(
31+
ModelQ::BLPOP_TIMEOUT,
32+
ModelQ::READ_TIMEOUT,
33+
'READ_TIMEOUT must exceed BLPOP_TIMEOUT or idle polls abort mid-read'
34+
);
35+
}
36+
37+
/**
38+
* Every liveness bound must actually be bounded.
39+
*
40+
* Zero is phpredis' "wait forever", which is the defect itself.
41+
*/
42+
public function testLivenessBoundsAreFinite(): void
43+
{
44+
foreach ([
45+
'BLPOP_TIMEOUT' => ModelQ::BLPOP_TIMEOUT,
46+
'CONNECT_TIMEOUT' => ModelQ::CONNECT_TIMEOUT,
47+
'READ_TIMEOUT' => ModelQ::READ_TIMEOUT,
48+
'TCP_KEEPALIVE' => ModelQ::TCP_KEEPALIVE,
49+
] as $name => $value) {
50+
$this->assertGreaterThan(0, $value, "$name must be a finite bound, not 0 (wait forever)");
51+
}
52+
}
53+
54+
/**
55+
* The worker's blocking pop must carry the configured bound.
56+
*/
57+
public function testWorkerBlockingPopUsesTheConfiguredTimeout(): void
58+
{
59+
$redis = $this->createMock(Redis::class);
60+
$seenTimeout = null;
61+
62+
// The loop also runs its prune/requeue housekeeping; give those reads an
63+
// empty result so the assertion below is about BLPOP and nothing else.
64+
$redis->method('hGetAll')->willReturn([]);
65+
$redis->method('zRangeByScore')->willReturn([]);
66+
$redis->method('sMembers')->willReturn([]);
67+
68+
$modelq = new ModelQ(redisClient: $redis, serverId: 'liveness-test');
69+
70+
$redis->method('blPop')->willReturnCallback(
71+
function (array $keys, $timeout) use (&$seenTimeout, $modelq) {
72+
$seenTimeout = $timeout;
73+
$modelq->stop(); // one iteration is enough
74+
75+
return false;
76+
}
77+
);
78+
79+
$modelq->startWorkers(1);
80+
81+
$this->assertSame(
82+
ModelQ::BLPOP_TIMEOUT,
83+
$seenTimeout,
84+
'worker issued a blocking pop that does not use BLPOP_TIMEOUT'
85+
);
86+
}
87+
}

0 commit comments

Comments
 (0)