-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
feat(taskprocessing): add worker command for synchronous task processing #59015
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
marcelklehr
merged 8 commits into
master
from
copilot/add-taskprocessing-worker-command
Mar 19, 2026
+546
−0
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
118ca6a
feat(taskprocessing): Add worker command with tests and registration
Copilot 9cc3343
feat(taskprocessing): Add --taskTypes whitelist option to taskprocess…
Copilot de9852e
fix: Fix Task mock error: use real Task instances; run autoloaderchecker
Copilot e46b967
fix: Fix task type starvation in WorkerCommand::processNextTask by sh…
Copilot 549b081
fix: Fix task type starvation: collect all eligible task types then p…
Copilot b1517d8
test(taskprocessing): fix broken multi-type assertions and add starva…
Copilot ad5e709
chore: Address review comments
marcelklehr a51d744
fix: Apply suggestions from code review
marcelklehr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
| namespace OC\Core\Command\TaskProcessing; | ||
|
|
||
| use OC\Core\Command\Base; | ||
| use OC\Core\Command\InterruptedException; | ||
| use OCP\TaskProcessing\Exception\Exception; | ||
| use OCP\TaskProcessing\Exception\NotFoundException; | ||
| use OCP\TaskProcessing\IManager; | ||
| use OCP\TaskProcessing\ISynchronousProvider; | ||
| use Psr\Log\LoggerInterface; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Input\InputOption; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| class WorkerCommand extends Base { | ||
| public function __construct( | ||
| private readonly IManager $taskProcessingManager, | ||
| private readonly LoggerInterface $logger, | ||
| ) { | ||
| parent::__construct(); | ||
| } | ||
|
|
||
| protected function configure(): void { | ||
| $this | ||
| ->setName('taskprocessing:worker') | ||
| ->setDescription('Run a dedicated worker for synchronous TaskProcessing providers') | ||
| ->addOption( | ||
| 'timeout', | ||
| 't', | ||
| InputOption::VALUE_OPTIONAL, | ||
| 'Duration in seconds after which the worker exits (0 = run indefinitely). You should regularly (e.g. every 5 minutes) restart this worker by using this option to make sure it picks up configuration changes.', | ||
| 0 | ||
| ) | ||
| ->addOption( | ||
| 'interval', | ||
| 'i', | ||
| InputOption::VALUE_OPTIONAL, | ||
| 'Sleep duration in seconds between polling iterations when no task was processed', | ||
| 1 | ||
| ) | ||
| ->addOption( | ||
| 'once', | ||
| null, | ||
| InputOption::VALUE_NONE, | ||
| 'Process at most one task then exit' | ||
| ) | ||
| ->addOption( | ||
| 'taskTypes', | ||
| null, | ||
| InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, | ||
| 'Only process tasks of the given task type IDs (can be specified multiple times)' | ||
| ); | ||
| parent::configure(); | ||
| } | ||
|
|
||
| protected function execute(InputInterface $input, OutputInterface $output): int { | ||
| $startTime = time(); | ||
| $timeout = (int)$input->getOption('timeout'); | ||
| $interval = (int)$input->getOption('interval'); | ||
| $once = $input->getOption('once') === true; | ||
| /** @var list<string> $taskTypes */ | ||
| $taskTypes = $input->getOption('taskTypes'); | ||
|
|
||
| if ($timeout > 0) { | ||
| $output->writeln('<info>Task processing worker will stop after ' . $timeout . ' seconds</info>'); | ||
| } | ||
|
|
||
| while (true) { | ||
| // Stop if timeout exceeded | ||
| if ($timeout > 0 && ($startTime + $timeout) < time()) { | ||
| $output->writeln('Timeout reached, exiting...', OutputInterface::VERBOSITY_VERBOSE); | ||
| break; | ||
| } | ||
|
|
||
| // Handle SIGTERM/SIGINT gracefully | ||
| try { | ||
| $this->abortIfInterrupted(); | ||
| } catch (InterruptedException $e) { | ||
| $output->writeln('<info>Task processing worker stopped</info>'); | ||
| break; | ||
| } | ||
|
|
||
| $processedTask = $this->processNextTask($output, $taskTypes); | ||
|
|
||
| if ($once) { | ||
| break; | ||
| } | ||
|
|
||
| if (!$processedTask) { | ||
| $output->writeln('No task processed, waiting ' . $interval . ' second(s)...', OutputInterface::VERBOSITY_VERBOSE); | ||
| sleep($interval); | ||
| } | ||
| } | ||
|
|
||
| return 0; | ||
| } | ||
|
|
||
| /** | ||
| * Attempt to process one task across all preferred synchronous providers. | ||
| * | ||
| * To avoid starvation, all eligible task types are first collected and then | ||
| * the oldest scheduled task across all of them is fetched in a single query. | ||
| * This ensures that tasks are processed in the order they were scheduled, | ||
| * regardless of which provider handles them. | ||
| * | ||
| * @param list<string> $taskTypes When non-empty, only providers for these task type IDs are considered. | ||
| * @return bool True if a task was processed, false if no task was found | ||
| */ | ||
| private function processNextTask(OutputInterface $output, array $taskTypes = []): bool { | ||
| $providers = $this->taskProcessingManager->getProviders(); | ||
|
|
||
| // Build a map of eligible taskTypeId => provider for all preferred synchronous providers | ||
| /** @var array<string, ISynchronousProvider> $eligibleProviders */ | ||
| $eligibleProviders = []; | ||
| foreach ($providers as $provider) { | ||
| if (!$provider instanceof ISynchronousProvider) { | ||
| continue; | ||
| } | ||
|
|
||
| $taskTypeId = $provider->getTaskTypeId(); | ||
|
|
||
| // If a task type whitelist was provided, skip providers not in the list | ||
| if (!empty($taskTypes) && !in_array($taskTypeId, $taskTypes, true)) { | ||
| continue; | ||
| } | ||
|
|
||
| // Only use this provider if it is the preferred one for the task type | ||
| try { | ||
| $preferredProvider = $this->taskProcessingManager->getPreferredProvider($taskTypeId); | ||
| } catch (Exception $e) { | ||
| $this->logger->error('Failed to get preferred provider for task type ' . $taskTypeId, ['exception' => $e]); | ||
| continue; | ||
| } | ||
|
|
||
| if ($provider->getId() !== $preferredProvider->getId()) { | ||
| continue; | ||
| } | ||
|
|
||
| $eligibleProviders[$taskTypeId] = $provider; | ||
| } | ||
|
|
||
| if (empty($eligibleProviders)) { | ||
| return false; | ||
| } | ||
|
|
||
| // Fetch the oldest scheduled task across all eligible task types in one query. | ||
| // This naturally prevents starvation: regardless of how many tasks one provider | ||
| // has queued, another provider's older tasks will be picked up first. | ||
| try { | ||
| $task = $this->taskProcessingManager->getNextScheduledTask(array_keys($eligibleProviders)); | ||
| } catch (NotFoundException) { | ||
| return false; | ||
| } catch (Exception $e) { | ||
| $this->logger->error('Unknown error while retrieving scheduled TaskProcessing tasks', ['exception' => $e]); | ||
| return false; | ||
| } | ||
|
|
||
| $taskTypeId = $task->getTaskTypeId(); | ||
| $provider = $eligibleProviders[$taskTypeId]; | ||
|
|
||
| $output->writeln( | ||
| 'Processing task ' . $task->getId() . ' of type ' . $taskTypeId . ' with provider ' . $provider->getId(), | ||
| OutputInterface::VERBOSITY_VERBOSE | ||
| ); | ||
|
|
||
| $this->taskProcessingManager->processTask($task, $provider); | ||
|
|
||
| $output->writeln( | ||
| 'Finished processing task ' . $task->getId(), | ||
| OutputInterface::VERBOSITY_VERBOSE | ||
| ); | ||
|
|
||
| return true; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.