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' + ); + } +}