Skip to content

Commit 77da2bc

Browse files
committed
Add optional stale-timeout to QueuedJobsTable::isQueued()
isQueued() counts any row with `completed IS NULL`. A job that a worker fetched and then died on (OOM, timeout, kill) without marking it completed or failed stays `completed IS NULL` forever, so callers that gate on isQueued() — notably a non-concurrent scheduler deciding whether to dispatch the next run — can wedge permanently behind a job that will never make progress. Adds an optional `$staleTimeout` (seconds) parameter: a row fetched longer ago than the timeout and still not completed is presumed abandoned and excluded. Not-yet-fetched rows, and rows fetched within the window, still count. The default `null` preserves the original behaviour, so this is fully backward compatible.
1 parent 25b2c08 commit 77da2bc

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

src/Model/Table/QueuedJobsTable.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,14 +276,31 @@ protected function jobTask(string $jobType): string {
276276
}
277277

278278
/**
279+
* Whether a not-yet-completed job exists for the given reference.
280+
*
281+
* By default any row with `completed IS NULL` counts — including a job
282+
* that was fetched by a worker which then died (OOM, timeout, kill)
283+
* without ever marking the row completed or failed. Such a row stays
284+
* `completed IS NULL` indefinitely, so callers that gate on `isQueued()`
285+
* (e.g. a non-concurrent scheduler) can wedge permanently behind a job
286+
* that will never make progress.
287+
*
288+
* Pass `$staleTimeout` (seconds) to discount those abandoned rows: a row
289+
* that was fetched longer ago than the timeout and is still not completed
290+
* is presumed dead and no longer counts as queued. Rows that have not been
291+
* fetched yet, or were fetched within the window, still count. The default
292+
* `null` preserves the original behaviour and is fully backward compatible.
293+
*
279294
* @param string $reference
280295
* @param string|null $jobTask
296+
* @param int|null $staleTimeout Seconds after which a fetched-but-not-completed
297+
* row is treated as abandoned and excluded. `null` (default) disables the check.
281298
*
282299
* @throws \InvalidArgumentException
283300
*
284301
* @return bool
285302
*/
286-
public function isQueued(string $reference, ?string $jobTask = null): bool {
303+
public function isQueued(string $reference, ?string $jobTask = null, ?int $staleTimeout = null): bool {
287304
if (!$reference) {
288305
throw new InvalidArgumentException('A reference is needed');
289306
}
@@ -295,6 +312,12 @@ public function isQueued(string $reference, ?string $jobTask = null): bool {
295312
if ($jobTask) {
296313
$conditions['job_task'] = $jobTask;
297314
}
315+
if ($staleTimeout !== null) {
316+
$conditions['OR'] = [
317+
'fetched IS' => null,
318+
'fetched >=' => $this->getDateTime()->subSeconds($staleTimeout),
319+
];
320+
}
298321

299322
return (bool)$this->find()->where($conditions)->select(['id'])->first();
300323
}

tests/TestCase/Model/Table/QueuedJobsTableTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,47 @@ public function testIsQueued() {
834834
$this->assertFalse($result);
835835
}
836836

837+
/**
838+
* A job fetched longer ago than the stale timeout, but never completed,
839+
* is presumed abandoned and excluded once `$staleTimeout` is given.
840+
*
841+
* @return void
842+
*/
843+
public function testIsQueuedStaleTimeout() {
844+
$queuedJob = $this->QueuedJobs->newEntity([
845+
'key' => 'key',
846+
'job_task' => 'FooBar',
847+
'reference' => 'foo-bar',
848+
'fetched' => (new DateTime())->subSeconds(120),
849+
]);
850+
$this->QueuedJobs->saveOrFail($queuedJob);
851+
852+
// Without the timeout the abandoned row still counts (unchanged default).
853+
$this->assertTrue($this->QueuedJobs->isQueued('foo-bar'));
854+
855+
// Fetched 120s ago, timeout 60s => presumed dead, excluded.
856+
$this->assertFalse($this->QueuedJobs->isQueued('foo-bar', null, 60));
857+
858+
// Fetched 120s ago, timeout 300s => still within window, counts.
859+
$this->assertTrue($this->QueuedJobs->isQueued('foo-bar', null, 300));
860+
}
861+
862+
/**
863+
* A not-yet-fetched job always counts as queued, regardless of timeout.
864+
*
865+
* @return void
866+
*/
867+
public function testIsQueuedStaleTimeoutIgnoresUnfetched() {
868+
$queuedJob = $this->QueuedJobs->newEntity([
869+
'key' => 'key',
870+
'job_task' => 'FooBar',
871+
'reference' => 'foo-bar',
872+
]);
873+
$this->QueuedJobs->saveOrFail($queuedJob);
874+
875+
$this->assertTrue($this->QueuedJobs->isQueued('foo-bar', null, 1));
876+
}
877+
837878
/**
838879
* @return void
839880
*/

0 commit comments

Comments
 (0)