Skip to content

Commit 1c8c958

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 3bd6a50 commit 1c8c958

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
@@ -721,26 +721,20 @@ public function getNextScheduledTask(array $providerIds, array $taskTypeIds): Da
721721
throw new NotFoundException();
722722
}
723723

724-
$taskIdsToIgnore = [];
725-
while (true) {
726-
// Until we find a task whose task type is set to be provided by the providers requested with this request
727-
// Or no scheduled task is found anymore (given the taskIds to ignore)
728-
$task = $this->taskProcessingManager->getNextScheduledTask($possibleTaskTypeIds, $taskIdsToIgnore);
729-
try {
730-
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
731-
if (in_array($provider->getId(), $possibleProviderIds, true)) {
732-
if ($this->taskProcessingManager->lockTask($task)) {
733-
break;
734-
}
735-
}
736-
} catch (Exception) {
737-
// There is no provider set for the task type of this task
738-
// proceed to ignore this task
739-
}
740-
741-
$taskIdsToIgnore[] = (int)$task->getId();
724+
// Atomically claim the oldest scheduled task across the eligible task types in a
725+
// single query (FOR UPDATE SKIP LOCKED, with a SQLite/Oracle fallback). This both
726+
// selects the task and marks it RUNNING, so multiple ex-app instances (e.g. several
727+
// replicas under Kubernetes) competing for the same queue never claim the same task
728+
// and no per-request ignore-list / retry loop is needed. $possibleTaskTypeIds is
729+
// already restricted to task types whose preferred provider is among the requested
730+
// providers, so any claimed task can be served by one of them.
731+
$task = $this->taskProcessingManager->claimNextScheduledTask($possibleTaskTypeIds);
732+
if ($task === null) {
733+
return new DataResponse(null, Http::STATUS_NO_CONTENT);
742734
}
743735

736+
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
737+
744738
/** @var CoreTaskProcessingTask $json */
745739
$json = $task->jsonSerialize();
746740

@@ -781,27 +775,36 @@ public function getNextScheduledTaskBatch(array $providerIds, array $taskTypeIds
781775
]);
782776
}
783777

784-
$tasks = $this->taskProcessingManager->getNextScheduledTasks($possibleTaskTypeIds, numberOfTasks: $numberOfTasks + 1);
785778
$tasksJson = [];
786-
// Stop when $numberOfTasks is reached or the json payload is larger than 50MiB
787-
while (count($tasks) > 0 && count($tasksJson) < $numberOfTasks && strlen(json_encode($tasks)) < 50 * 1024 * 1024) {
788-
// Until we find a task whose task type is set to be provided by the providers requested with this request
789-
// Or no scheduled task is found anymore (given the taskIds to ignore)
790-
$task = array_shift($tasks);
791-
try {
792-
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
793-
if (in_array($provider->getId(), $possibleProviderIds, true)) {
794-
if ($this->taskProcessingManager->lockTask($task)) {
795-
$tasksJson[] = ['task' => $task->jsonSerialize(), 'provider' => $provider->getId()];
796-
continue;
797-
}
798-
}
799-
} catch (Exception) {
800-
// There is no provider set for the task type of this task
801-
// proceed to ignore this task
779+
// Atomically claim up to $numberOfTasks scheduled tasks, one by one. Each claim uses
780+
// FOR UPDATE SKIP LOCKED (with a SQLite/Oracle fallback) to mark the task RUNNING in
781+
// the same step, so concurrent ex-app instances (e.g. several replicas under
782+
// Kubernetes) never hand out the same task twice and no per-request ignore-list is
783+
// needed. $possibleTaskTypeIds is already restricted to task types whose preferred
784+
// provider is among the requested providers, so any claimed task can be served.
785+
while (count($tasksJson) < $numberOfTasks) {
786+
$task = $this->taskProcessingManager->claimNextScheduledTask($possibleTaskTypeIds);
787+
if ($task === null) {
788+
// No more schedulable tasks.
789+
break;
790+
}
791+
$provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId());
792+
$tasksJson[] = ['task' => $task->jsonSerialize(), 'provider' => $provider->getId()];
793+
// Cap the response payload at ~50MiB. The task is already claimed, so it is always
794+
// included; we simply stop claiming further tasks once the limit is reached.
795+
if (strlen(json_encode($tasksJson)) >= 50 * 1024 * 1024) {
796+
break;
802797
}
803798
}
804-
$hasMore = count($tasks) > 0;
799+
800+
// Report whether at least one more schedulable task remains, without claiming it.
801+
$hasMore = false;
802+
try {
803+
$this->taskProcessingManager->getNextScheduledTask($possibleTaskTypeIds);
804+
$hasMore = true;
805+
} catch (NotFoundException) {
806+
// No further scheduled task remains.
807+
}
805808

806809
return new DataResponse([
807810
'tasks' => $tasksJson,

lib/private/TaskProcessing/SynchronousBackgroundJob.php

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

0 commit comments

Comments
 (0)