From b4b05fb9ba9e84e00e6967d625958dd594c9677f Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Sat, 15 Aug 2026 12:54:58 +0530 Subject: [PATCH] fix: bound the Redis read so a dead socket cannot park a worker forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHP client carries the same defect that stalled the Python fleet on 2026-08-15, reached by a different route. `blPop(['ml_tasks'], 1)` already passed a timeout, so this looked safe. That timeout is the *server's* — it tells Redis how long to hold the pop before answering "nothing". It says nothing about how long this process will wait for that answer to arrive. phpredis defaults its read timeout to 0, meaning wait forever, and `connect()` was called without one. So when a connection is silently dropped the reply can never arrive, the read never returns, and the worker parks permanently on a socket the server has already discarded. - `connect()` now passes CONNECT_TIMEOUT and READ_TIMEOUT. - OPT_TCP_KEEPALIVE is set so the kernel probes an idle peer, guarded by defined() since it needs phpredis 5+. - The blocking pop uses BLPOP_TIMEOUT, which is held below READ_TIMEOUT. That ordering is the point: invert it and every idle poll aborts the read before Redis has replied, turning a correct empty result into an exception. Callers that inject their own Redis are unaffected; modelslab-frontend-v2 already sets a 5s connect and read timeout on the client it passes in. Tests: 3 unit tests, no Redis required. Mutation-tested — replacing the constant in blPop and restoring READ_TIMEOUT to 0 each turn them red. Suite goes 130 -> 133 with the same 13 pre-existing Integration failures and no new phpstan errors (36 before, 36 after). --- .phpunit.cache/test-results | 1 + src/ModelQ.php | 35 ++++++++++- tests/Unit/ConnectionLivenessTest.php | 87 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 .phpunit.cache/test-results create mode 100644 tests/Unit/ConnectionLivenessTest.php diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results new file mode 100644 index 0000000..8a88591 --- /dev/null +++ b/.phpunit.cache/test-results @@ -0,0 +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}} \ No newline at end of file diff --git a/src/ModelQ.php b/src/ModelQ.php index 581fe70..34933d0 100644 --- a/src/ModelQ.php +++ b/src/ModelQ.php @@ -30,6 +30,23 @@ class ModelQ public const TASK_TTL = 86400; // 24 hours TTL for all tasks public const DEFAULT_STREAM_TIMEOUT = 300; // 5 minutes default stream timeout + /** + * Connection liveness. + * + * BLPOP_TIMEOUT is what we ask the *server* to wait. READ_TIMEOUT is what + * the *client* will wait for that answer to arrive. The second must exceed + * the first, or every idle poll aborts the read before Redis has replied. + * + * phpredis defaults both connect and read timeouts to 0, meaning "wait + * forever". A silently dropped TCP connection then parks the reader + * permanently: the server-side BLPOP timeout is irrelevant because the + * reply it would have produced can never reach us. + */ + public const BLPOP_TIMEOUT = 1; // seconds Redis holds the blocking pop + public const CONNECT_TIMEOUT = 5; // seconds to establish a connection + public const READ_TIMEOUT = 10; // seconds to wait for any single reply + public const TCP_KEEPALIVE = 60; // seconds idle before the kernel probes + private Redis $redis; private string $serverId; private array $allowedTasks = []; @@ -62,7 +79,21 @@ public function __construct( $this->redis = $redisClient; } else { $this->redis = new Redis(); - $this->redis->connect($host, $port); + // Bounded connect and read. Without the read timeout phpredis waits + // forever for a reply, so a half-open socket strands the caller + // instead of raising — see the constants above. + $this->redis->connect( + $host, + $port, + self::CONNECT_TIMEOUT, + null, + 0, + self::READ_TIMEOUT + ); + // Let the kernel notice a peer that has gone away while we are idle. + if (defined('Redis::OPT_TCP_KEEPALIVE')) { + $this->redis->setOption(Redis::OPT_TCP_KEEPALIVE, self::TCP_KEEPALIVE); + } if ($password) { $this->redis->auth($password); } @@ -259,7 +290,7 @@ public function startWorkers(int $workers = 1): void // Process tasks $this->updateServerStatus('idle'); - $taskData = $this->redis->blPop(['ml_tasks'], 1); + $taskData = $this->redis->blPop(['ml_tasks'], self::BLPOP_TIMEOUT); if (!$taskData) { continue; diff --git a/tests/Unit/ConnectionLivenessTest.php b/tests/Unit/ConnectionLivenessTest.php new file mode 100644 index 0000000..fc505d6 --- /dev/null +++ b/tests/Unit/ConnectionLivenessTest.php @@ -0,0 +1,87 @@ +assertGreaterThan( + ModelQ::BLPOP_TIMEOUT, + ModelQ::READ_TIMEOUT, + 'READ_TIMEOUT must exceed BLPOP_TIMEOUT or idle polls abort mid-read' + ); + } + + /** + * Every liveness bound must actually be bounded. + * + * Zero is phpredis' "wait forever", which is the defect itself. + */ + public function testLivenessBoundsAreFinite(): void + { + foreach ([ + 'BLPOP_TIMEOUT' => ModelQ::BLPOP_TIMEOUT, + 'CONNECT_TIMEOUT' => ModelQ::CONNECT_TIMEOUT, + 'READ_TIMEOUT' => ModelQ::READ_TIMEOUT, + 'TCP_KEEPALIVE' => ModelQ::TCP_KEEPALIVE, + ] as $name => $value) { + $this->assertGreaterThan(0, $value, "$name must be a finite bound, not 0 (wait forever)"); + } + } + + /** + * The worker's blocking pop must carry the configured bound. + */ + public function testWorkerBlockingPopUsesTheConfiguredTimeout(): void + { + $redis = $this->createMock(Redis::class); + $seenTimeout = null; + + // The loop also runs its prune/requeue housekeeping; give those reads an + // empty result so the assertion below is about BLPOP and nothing else. + $redis->method('hGetAll')->willReturn([]); + $redis->method('zRangeByScore')->willReturn([]); + $redis->method('sMembers')->willReturn([]); + + $modelq = new ModelQ(redisClient: $redis, serverId: 'liveness-test'); + + $redis->method('blPop')->willReturnCallback( + function (array $keys, $timeout) use (&$seenTimeout, $modelq) { + $seenTimeout = $timeout; + $modelq->stop(); // one iteration is enough + + return false; + } + ); + + $modelq->startWorkers(1); + + $this->assertSame( + ModelQ::BLPOP_TIMEOUT, + $seenTimeout, + 'worker issued a blocking pop that does not use BLPOP_TIMEOUT' + ); + } +}