Skip to content

Commit 7818a6c

Browse files
marcelklehrAndyScherzinger
authored andcommitted
fix(TaskProcessing): Claim tasks atomically in both SynchronousBackgroundJob and TaskProcessingApiController
see #61053 Signed-off-by: Marcel Klehr <mklehr@gmx.net>
1 parent e3ed4e3 commit 7818a6c

2 files changed

Lines changed: 51 additions & 40 deletions

File tree

core/Controller/TaskProcessingApiController.php

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -693,26 +693,20 @@ public function getNextScheduledTask(array $providerIds, array $taskTypeIds): Da
693693
throw new NotFoundException();
694694
}
695695

696-
$taskIdsToIgnore = [];
697-
while (true) {
698-
// Until we find a task whose task type is set to be provided by the providers requested with this request
699-
// Or no scheduled task is found anymore (given the taskIds to ignore)
700-
$task = $this->taskProcessingManager->getNextScheduledTask($possibleTaskTypeIds, $taskIdsToIgnore);
701-
try {
702-
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
703-
if (in_array($provider->getId(), $possibleProviderIds, true)) {
704-
if ($this->taskProcessingManager->lockTask($task)) {
705-
break;
706-
}
707-
}
708-
} catch (Exception) {
709-
// There is no provider set for the task type of this task
710-
// proceed to ignore this task
711-
}
712-
713-
$taskIdsToIgnore[] = (int)$task->getId();
696+
// Atomically claim the oldest scheduled task across the eligible task types in a
697+
// single query (FOR UPDATE SKIP LOCKED, with a SQLite/Oracle fallback). This both
698+
// selects the task and marks it RUNNING, so multiple ex-app instances (e.g. several
699+
// replicas under Kubernetes) competing for the same queue never claim the same task
700+
// and no per-request ignore-list / retry loop is needed. $possibleTaskTypeIds is
701+
// already restricted to task types whose preferred provider is among the requested
702+
// providers, so any claimed task can be served by one of them.
703+
$task = $this->taskProcessingManager->claimNextScheduledTask($possibleTaskTypeIds);
704+
if ($task === null) {
705+
return new DataResponse(null, Http::STATUS_NO_CONTENT);
714706
}
715707

708+
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
709+
716710
/** @var CoreTaskProcessingTask $json */
717711
$json = $task->jsonSerialize();
718712

@@ -753,27 +747,36 @@ public function getNextScheduledTaskBatch(array $providerIds, array $taskTypeIds
753747
]);
754748
}
755749

756-
$tasks = $this->taskProcessingManager->getNextScheduledTasks($possibleTaskTypeIds, numberOfTasks: $numberOfTasks + 1);
757750
$tasksJson = [];
758-
// Stop when $numberOfTasks is reached or the json payload is larger than 50MiB
759-
while (count($tasks) > 0 && count($tasksJson) < $numberOfTasks && strlen(json_encode($tasks)) < 50 * 1024 * 1024) {
760-
// Until we find a task whose task type is set to be provided by the providers requested with this request
761-
// Or no scheduled task is found anymore (given the taskIds to ignore)
762-
$task = array_shift($tasks);
763-
try {
764-
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
765-
if (in_array($provider->getId(), $possibleProviderIds, true)) {
766-
if ($this->taskProcessingManager->lockTask($task)) {
767-
$tasksJson[] = ['task' => $task->jsonSerialize(), 'provider' => $provider->getId()];
768-
continue;
769-
}
770-
}
771-
} catch (Exception) {
772-
// There is no provider set for the task type of this task
773-
// proceed to ignore this task
751+
// Atomically claim up to $numberOfTasks scheduled tasks, one by one. Each claim uses
752+
// FOR UPDATE SKIP LOCKED (with a SQLite/Oracle fallback) to mark the task RUNNING in
753+
// the same step, so concurrent ex-app instances (e.g. several replicas under
754+
// Kubernetes) never hand out the same task twice and no per-request ignore-list is
755+
// needed. $possibleTaskTypeIds is already restricted to task types whose preferred
756+
// provider is among the requested providers, so any claimed task can be served.
757+
while (count($tasksJson) < $numberOfTasks) {
758+
$task = $this->taskProcessingManager->claimNextScheduledTask($possibleTaskTypeIds);
759+
if ($task === null) {
760+
// No more schedulable tasks.
761+
break;
762+
}
763+
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
764+
$tasksJson[] = ['task' => $task->jsonSerialize(), 'provider' => $provider->getId()];
765+
// Cap the response payload at ~50MiB. The task is already claimed, so it is always
766+
// included; we simply stop claiming further tasks once the limit is reached.
767+
if (strlen(json_encode($tasksJson)) >= 50 * 1024 * 1024) {
768+
break;
774769
}
775770
}
776-
$hasMore = count($tasks) > 0;
771+
772+
// Report whether at least one more schedulable task remains, without claiming it.
773+
$hasMore = false;
774+
try {
775+
$this->taskProcessingManager->getNextScheduledTask($possibleTaskTypeIds);
776+
$hasMore = true;
777+
} catch (NotFoundException) {
778+
// No further scheduled task remains.
779+
}
777780

778781
return new DataResponse([
779782
'tasks' => $tasksJson,

lib/private/TaskProcessing/SynchronousBackgroundJob.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,19 @@ protected function run($argument) {
4444
continue;
4545
}
4646
try {
47-
$task = $this->taskProcessingManager->getNextScheduledTask([$taskTypeId]);
48-
} catch (NotFoundException $e) {
49-
continue;
47+
// Atomically claim the oldest scheduled task and mark it RUNNING in one step.
48+
// Without this, a concurrently running taskprocessing:worker could pick up the
49+
// same row: this background job used to fetch-then-process, and processTask's
50+
// setTaskStatus(RUNNING) would blindly overwrite, so both executors ran the same
51+
// task. The atomic claim (FOR UPDATE SKIP LOCKED, with a SQLite/Oracle fallback)
52+
// guarantees at most one executor ever transitions a task SCHEDULED -> RUNNING.
53+
$task = $this->taskProcessingManager->claimNextScheduledTask([$taskTypeId]);
5054
} catch (Exception $e) {
51-
$this->logger->error('Unknown error while retrieving scheduled TaskProcessing tasks', ['exception' => $e]);
55+
$this->logger->error('Unknown error while claiming scheduled TaskProcessing tasks', ['exception' => $e]);
56+
continue;
57+
}
58+
if ($task === null) {
59+
// No schedulable task for this task type right now.
5260
continue;
5361
}
5462
if (!$this->taskProcessingManager->processTask($task, $provider)) {

0 commit comments

Comments
 (0)