Skip to content

Commit f1f1659

Browse files
bygaddmarcelklehr
authored andcommitted
fix(taskprocessing): guard lockTask on scheduled, record started_at, Oracle fallback
Address review feedback (@marcelklehr, Copilot): - lockTask claims only SCHEDULED tasks (was status != RUNNING) and stamps started_at in the same atomic UPDATE, so a finished task cannot be re-claimed and the external-provider claim path records started_at as well. - claimWithBoundedRetry re-reads after lockTask instead of a follow-up UPDATE. - Oracle joins SQLite on the bounded-retry fallback: Oracle cannot combine a row-limiting clause with FOR UPDATE (ORA-02014), which failed the claim tests on Oracle CI. - Reword the worker docblock/comments to "prefer oldest available" (parallel SKIP LOCKED does not guarantee a strict global order). - Add a regression test that lockTask does not resurrect a finished task. Signed-off-by: Yoan Bozhilov <bygadd@gmail.com> Assisted-by: Claude Code:claude-opus-4-8
1 parent 57bb450 commit f1f1659

3 files changed

Lines changed: 64 additions & 28 deletions

File tree

core/Command/TaskProcessing/WorkerCommand.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
118118
*
119119
* To avoid starvation, all eligible task types are first collected and then
120120
* the oldest scheduled task across all of them is claimed in a single atomic
121-
* query (FOR UPDATE SKIP LOCKED, with a SQLite fallback). This ensures tasks
122-
* are processed in the order they were scheduled, regardless of which provider
123-
* handles them, and guarantees no two workers ever claim the same task.
121+
* query (FOR UPDATE SKIP LOCKED, with a SQLite fallback). Each claim prefers the
122+
* oldest available scheduled task -- under parallel workers SKIP LOCKED skips rows
123+
* another worker has locked, so this reduces starvation rather than guaranteeing a
124+
* strict global processing order -- and no two workers ever claim the same task.
124125
*
125126
* @param list<string> $taskTypes When non-empty, only providers for these task type IDs are considered.
126127
* @return bool True if a task was processed, false if no task was found
@@ -165,9 +166,10 @@ private function processNextTask(OutputInterface $output, array $taskTypes = [])
165166
// Atomically claim the oldest scheduled task across all eligible task types in
166167
// one query. SELECT ... FOR UPDATE SKIP LOCKED (with a SQLite fallback) both
167168
// fetches and marks the task RUNNING, so multiple workers never claim the same
168-
// task and no per-worker ignore-list / retry loop is needed. This also naturally
169-
// prevents starvation: regardless of how many tasks one provider has queued,
170-
// another provider's older tasks are picked up first.
169+
// task and no per-worker ignore-list / retry loop is needed. This also reduces
170+
// starvation: each claim prefers the oldest available task, so a provider with a
171+
// large queue does not indefinitely block another provider's older tasks (though a
172+
// worker may claim a newer task while an older one is locked by another worker).
171173
try {
172174
$task = $this->taskProcessingManager->claimNextScheduledTask(array_keys($eligibleProviders));
173175
} catch (Exception $e) {

lib/private/TaskProcessing/Db/TaskMapper.php

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,15 @@ public function findOldestScheduledByType(array $taskTypes, array $taskIdsToIgno
9090
* followed by a guarded UPDATE to RUNNING. Concurrent workers skip rows already
9191
* locked by another transaction, so no two workers ever claim the same task.
9292
*
93-
* SQLite does not support SKIP LOCKED (verified: Doctrine throws "Operation
94-
* 'SKIP LOCKED' is not supported by platform"), so we feature-detect via the DB
95-
* provider and fall back to the existing bounded {@see lockTask} retry, which is
96-
* still safe because the UPDATE ... WHERE status = SCHEDULED is itself atomic and
97-
* SQLite serialises writers.
93+
* Two databases cannot use the SKIP LOCKED path and fall back to a bounded
94+
* lock-and-retry claim instead:
95+
* - SQLite has no SKIP LOCKED (Doctrine throws "Operation 'SKIP LOCKED' is not
96+
* supported by platform").
97+
* - Oracle cannot combine a row-limiting clause with FOR UPDATE: the LIMIT is
98+
* emulated with a ROWNUM sub-select, and selecting FOR UPDATE from that derived
99+
* view raises ORA-02014.
100+
* The fallback is still safe because the UPDATE ... WHERE status = SCHEDULED is itself
101+
* atomic (SQLite additionally serialises writers).
98102
*
99103
* A task is only ever transitioned SCHEDULED -> RUNNING here; it is never marked
100104
* FAILED by claiming. If the task cannot be claimed (none scheduled, or it was
@@ -105,8 +109,10 @@ public function findOldestScheduledByType(array $taskTypes, array $taskIdsToIgno
105109
* @throws Exception
106110
*/
107111
public function claimOldestScheduledTask(array $taskTypes): ?Task {
108-
if ($this->db->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE) {
109-
// SKIP LOCKED is unsupported on SQLite: fall back to the bounded lock-and-retry claim.
112+
$provider = $this->db->getDatabaseProvider();
113+
// SKIP LOCKED is unusable on SQLite (unsupported) and Oracle (LIMIT + FOR UPDATE =>
114+
// ORA-02014): both fall back to the bounded lock-and-retry claim.
115+
if ($provider === IDBConnection::PLATFORM_SQLITE || $provider === IDBConnection::PLATFORM_ORACLE) {
110116
return $this->claimWithBoundedRetry($taskTypes);
111117
}
112118

@@ -184,7 +190,7 @@ private function claimWithSkipLocked(array $taskTypes): ?Task {
184190
}
185191

186192
/**
187-
* Fallback claim for databases without SKIP LOCKED (SQLite).
193+
* Fallback claim for databases that cannot use the SKIP LOCKED path (SQLite, Oracle).
188194
*
189195
* Repeatedly fetches the oldest scheduled task and attempts the atomic
190196
* UPDATE ... WHERE status = SCHEDULED. Tasks lost to another worker are added to a
@@ -207,19 +213,9 @@ private function claimWithBoundedRetry(array $taskTypes): ?Task {
207213
}
208214

209215
if ($this->lockTask($task) !== 0) {
210-
$task->setStatus(\OCP\TaskProcessing\Task::STATUS_RUNNING);
211-
// Record the start time at claim time. lockTask only flips the status (and is
212-
// shared with other callers), so persist started_at with a targeted follow-up
213-
// UPDATE rather than changing lockTask's behaviour. The worker receives the task
214-
// already RUNNING, so Manager::setTaskStatus would otherwise never write it.
215-
$startedAt = $this->timeFactory->now()->getTimestamp();
216-
$update = $this->db->getQueryBuilder();
217-
$update->update($this->tableName)
218-
->set('started_at', $update->createPositionalParameter($startedAt, IQueryBuilder::PARAM_INT))
219-
->where($update->expr()->eq('id', $update->createPositionalParameter($task->getId(), IQueryBuilder::PARAM_INT)));
220-
$update->executeStatement();
221-
$task->setStartedAt($startedAt);
222-
return $task;
216+
// lockTask atomically flipped SCHEDULED -> RUNNING and stamped started_at.
217+
// Re-read so the returned task reflects the persisted status and started_at.
218+
return $this->find($task->getId());
223219
}
224220

225221
// Another worker took it; skip this id and try the next oldest.
@@ -376,12 +372,25 @@ public function update(Entity $entity): Entity {
376372
return parent::update($entity);
377373
}
378374

375+
/**
376+
* Atomically claim a task by transitioning it SCHEDULED -> RUNNING.
377+
*
378+
* The UPDATE is guarded on `status = SCHEDULED` so a task another worker has already
379+
* finished (SUCCESSFUL/FAILED) between a caller's SELECT and this UPDATE can never be
380+
* re-claimed and processed twice. started_at is stamped in the same statement: the
381+
* worker receives the task already RUNNING, so the later SCHEDULED -> RUNNING edge in
382+
* Manager::setTaskStatus (which used to set started_at) no longer fires.
383+
*
384+
* @return int Number of rows updated: 1 if the task was claimed, 0 if it was no longer scheduled.
385+
*/
379386
public function lockTask(Entity $entity): int {
387+
$startedAt = $this->timeFactory->now()->getTimestamp();
380388
$qb = $this->db->getQueryBuilder();
381389
$qb->update($this->tableName)
382390
->set('status', $qb->createPositionalParameter(\OCP\TaskProcessing\Task::STATUS_RUNNING, IQueryBuilder::PARAM_INT))
391+
->set('started_at', $qb->createPositionalParameter($startedAt, IQueryBuilder::PARAM_INT))
383392
->where($qb->expr()->eq('id', $qb->createPositionalParameter($entity->getId(), IQueryBuilder::PARAM_INT)))
384-
->andWhere($qb->expr()->neq('status', $qb->createPositionalParameter(2, IQueryBuilder::PARAM_INT)));
393+
->andWhere($qb->expr()->eq('status', $qb->createPositionalParameter(\OCP\TaskProcessing\Task::STATUS_SCHEDULED, IQueryBuilder::PARAM_INT)));
385394
try {
386395
return $qb->executeStatement();
387396
} catch (Exception) {

tests/lib/TaskProcessing/TaskProcessingTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,4 +1757,29 @@ public function testClaimRecordsStartedAt(): void {
17571757
self::assertGreaterThanOrEqual($before, $persisted->getStartedAt());
17581758
self::assertLessThanOrEqual($after, $persisted->getStartedAt());
17591759
}
1760+
1761+
public function testLockTaskDoesNotResurrectFinishedTask(): void {
1762+
// Regression guard for the lockTask claim path (used by the SQLite fallback and the
1763+
// external-provider API claim). lockTask must only ever transition SCHEDULED -> RUNNING.
1764+
// If another worker finished a task (SUCCESSFUL/FAILED) between the SELECT and this
1765+
// UPDATE, lockTask must NOT flip it back to RUNNING -- otherwise a completed task is
1766+
// resurrected and processed twice. (The previous `status != RUNNING` guard let a
1767+
// SUCCESSFUL/FAILED row be re-locked.)
1768+
$this->registerTextToTextProvider();
1769+
1770+
$task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null);
1771+
$this->manager->scheduleTask($task);
1772+
$id = $task->getId();
1773+
1774+
// Simulate another worker having already finished the task.
1775+
$entity = $this->taskMapper->find($id);
1776+
$entity->setStatus(Task::STATUS_SUCCESSFUL);
1777+
$this->taskMapper->update($entity);
1778+
1779+
// Attempting to claim the (now SUCCESSFUL) task must be a no-op.
1780+
$affected = $this->taskMapper->lockTask($this->taskMapper->find($id));
1781+
1782+
self::assertSame(0, $affected, 'lockTask must not claim a task that is no longer SCHEDULED');
1783+
self::assertEquals(Task::STATUS_SUCCESSFUL, $this->manager->getTask($id)->getStatus());
1784+
}
17601785
}

0 commit comments

Comments
 (0)