mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 09:42:09 -04:00
Merge pull request #60795 from nextcloud/backport/60765/stable34
[stable34] Background jobs improvements
This commit is contained in:
commit
533f84fde7
16 changed files with 731 additions and 3 deletions
92
core/Command/Background/RunningJobs.php
Normal file
92
core/Command/Background/RunningJobs.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?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\Background;
|
||||
|
||||
use OC\BackgroundJob\JobRuns;
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Override;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class RunningJobs extends Base {
|
||||
public function __construct(
|
||||
private readonly JobRuns $jobRuns,
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$help = <<<EOF
|
||||
Display all currently running background jobs.
|
||||
|
||||
You can find the following informations:
|
||||
- <info>Run ID:</info> job identifier a found in database (Snowflake ID)
|
||||
- <info>Class:</info> class of the job
|
||||
- <info>Started at:</info> start time of the job
|
||||
- <info>Server ID:</info> server ID as defined in <options=bold>config.php</> (see `serverid`). Highlighted if it’s running on current server.
|
||||
- <info>PID:</info> PID of process executing the job
|
||||
- <info>Running since:</info> human readable elapsed time since job started
|
||||
|
||||
EOF;
|
||||
|
||||
$this
|
||||
->setName('background-job:running')
|
||||
->setDescription('Show currently running jobs')
|
||||
->setHelp($help)
|
||||
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Maximum number of results returned by the command', 200);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$limit = (int)$input->getOption('limit');
|
||||
$jobs = $this->jobRuns->runningJobs($limit);
|
||||
$this->writeStreamingTableInOutputFormat($input, $output, $this->formatLine($jobs), 20);
|
||||
|
||||
return Base::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatLine(iterable $jobs): \Generator {
|
||||
$now = time();
|
||||
$currentServerId = $this->config->getSystemValueInt('serverid', -1);
|
||||
foreach ($jobs as $job) {
|
||||
yield [
|
||||
'Run ID' => $job->runId,
|
||||
'Class' => $job->className,
|
||||
'Started at' => $job->startedAt->format('Y-m-d H:i:s'),
|
||||
'Server ID' => $job->serverId === $currentServerId ? '<info>' . $job->serverId . '</info>' : $job->serverId,
|
||||
'PID' => $job->pid,
|
||||
'Running since' => $this->formatDuration($now - $job->startedAt->format('U')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO Move this function to utils class with better formatting (plural, i18n…)
|
||||
*/
|
||||
private function formatDuration(int $seconds): string {
|
||||
if ($seconds < 60) {
|
||||
return sprintf('%d seconds', $seconds);
|
||||
}
|
||||
if ($seconds < 3600) {
|
||||
return sprintf('%d minutes', $seconds / 60);
|
||||
}
|
||||
if ($seconds < (3600 * 24)) {
|
||||
return sprintf('> %d hours', $seconds / 3600);
|
||||
}
|
||||
|
||||
return sprintf('> %d days', $seconds / (3600 * 24));
|
||||
}
|
||||
}
|
||||
47
core/Migrations/Version34000Date20260518163022.php
Normal file
47
core/Migrations/Version34000Date20260518163022.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Core\Migrations;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\Attributes\AddIndex;
|
||||
use OCP\Migration\Attributes\CreateTable;
|
||||
use OCP\Migration\Attributes\IndexType;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
use Override;
|
||||
|
||||
#[CreateTable(table: 'job_classes_registry', columns: ['class_id', 'class_name'], description: 'New table to map job class name to an ID')]
|
||||
#[AddIndex(table: 'job_classes_registry', type: IndexType::PRIMARY)]
|
||||
#[AddIndex(table: 'job_classes_registry', type: IndexType::UNIQUE, description: 'Ensure each class is registered only once')]
|
||||
class Version34000Date20260518163022 extends SimpleMigrationStep {
|
||||
#[Override]
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('job_classes_registry')) {
|
||||
$table = $schema->createTable('job_classes_registry');
|
||||
$table->addColumn('class_id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('class_name', Types::STRING, ['notnull' => true, 'length' => 255]);
|
||||
$table->setPrimaryKey(['class_id']);
|
||||
$table->addUniqueConstraint(['class_name'], 'class_index');
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
54
core/Migrations/Version34000Date20260521110333.php
Normal file
54
core/Migrations/Version34000Date20260521110333.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Core\Migrations;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\Attributes\AddIndex;
|
||||
use OCP\Migration\Attributes\CreateTable;
|
||||
use OCP\Migration\Attributes\IndexType;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
use Override;
|
||||
|
||||
#[CreateTable(
|
||||
table: 'job_runs',
|
||||
columns: ['class_id', 'pid', 'status', 'duration', 'ram_peak_usage'],
|
||||
description: 'New table to store executions of background jobs',
|
||||
)]
|
||||
#[AddIndex(table: 'job_runs', type: IndexType::PRIMARY)]
|
||||
#[AddIndex(table: 'job_runs', type: IndexType::INDEX, description: 'Allows to search on job status')]
|
||||
class Version34000Date20260521110333 extends SimpleMigrationStep {
|
||||
#[Override]
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('job_runs')) {
|
||||
$table = $schema->createTable('job_runs');
|
||||
$table->addColumn('run_id', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('class_id', Types::BIGINT, ['notnull' => true]);
|
||||
$table->addColumn('pid', Types::INTEGER, ['notnull' => true]); // Should be MEDIUMINT
|
||||
$table->addColumn('status', Types::SMALLINT, ['notnull' => true]); // Should be TINYINT
|
||||
$table->addColumn('duration', Types::INTEGER, ['notnull' => true, 'default' => 0]);
|
||||
$table->addColumn('ram_peak_usage', Types::INTEGER, ['notnull' => true, 'default' => 0]); // Should be MEDIUMINT
|
||||
$table->setPrimaryKey(['run_id']);
|
||||
$table->addIndex(['status'], 'status');
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ namespace OC\Core\Service;
|
|||
|
||||
use OC;
|
||||
use OC\Authentication\LoginCredentials\Store;
|
||||
use OC\BackgroundJob\JobClassesRegistry;
|
||||
use OC\BackgroundJob\JobRuns;
|
||||
use OC\DB\Connection;
|
||||
use OC\Security\CSRF\TokenStorage\SessionStorage;
|
||||
use OC\Session\CryptoWrapper;
|
||||
|
|
@ -49,6 +51,8 @@ class CronService {
|
|||
private readonly ITempManager $tempManager,
|
||||
private readonly IAppConfig $appConfig,
|
||||
private readonly IJobList $jobList,
|
||||
private readonly JobRuns $jobRuns,
|
||||
private readonly JobClassesRegistry $jobClassesRegistry,
|
||||
private readonly ISetupManager $setupManager,
|
||||
private readonly bool $isCLI,
|
||||
) {
|
||||
|
|
@ -185,20 +189,27 @@ class CronService {
|
|||
break;
|
||||
}
|
||||
|
||||
$jobDetails = get_class($job) . ' (id: ' . $job->getId() . ', arguments: ' . json_encode($job->getArgument()) . ')';
|
||||
$jobClass = get_class($job);
|
||||
$jobDetails = $jobClass . ' (id: ' . $job->getId() . ', arguments: ' . json_encode($job->getArgument()) . ')';
|
||||
$this->logger->debug('CLI cron call has selected job ' . $jobDetails, ['app' => 'cron']);
|
||||
|
||||
$this->verboseOutput('Starting job ' . $jobDetails);
|
||||
|
||||
$startTime = microtime(true);
|
||||
$referenceMemory = memory_get_usage();
|
||||
memory_reset_peak_usage();
|
||||
|
||||
$jobClassId = $this->jobClassesRegistry->getId($jobClass);
|
||||
$jobRunId = $this->jobRuns->started($jobClassId);
|
||||
$startTime = microtime(true);
|
||||
$job->start($this->jobList);
|
||||
|
||||
$memoryIncrease = memory_get_usage() - $referenceMemory;
|
||||
$timeSpent = microtime(true) - $startTime;
|
||||
$jobMemoryPeak = memory_get_peak_usage() - $referenceMemory;
|
||||
$jobMemoryPeak = memory_get_peak_usage();
|
||||
// TODO Job failure will never be caught here because exceptions are caught within $job->start method
|
||||
// The error will only be visible in server logs.
|
||||
// It should be a temporary state until a proper job runner is implemented.
|
||||
$this->jobRuns->finished($jobRunId, (int)($timeSpent * 1000), (int)($jobMemoryPeak / 1024));
|
||||
|
||||
$cronInterval = 5 * 60;
|
||||
if ($timeSpent > $cronInterval) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use OC\Core\Command\Background\Job;
|
|||
use OC\Core\Command\Background\JobWorker;
|
||||
use OC\Core\Command\Background\ListCommand;
|
||||
use OC\Core\Command\Background\Mode;
|
||||
use OC\Core\Command\Background\RunningJobs;
|
||||
use OC\Core\Command\Broadcast\Test;
|
||||
use OC\Core\Command\Check;
|
||||
use OC\Core\Command\Config\App\DeleteConfig;
|
||||
|
|
@ -144,6 +145,7 @@ if ($config->getSystemValueBool('installed', false)) {
|
|||
$application->add(Server::get(ListCommand::class));
|
||||
$application->add(Server::get(Delete::class));
|
||||
$application->add(Server::get(JobWorker::class));
|
||||
$application->add(Server::get(RunningJobs::class));
|
||||
|
||||
$application->add(Server::get(Test::class));
|
||||
|
||||
|
|
|
|||
|
|
@ -200,8 +200,11 @@ return array(
|
|||
'OCP\\AutoloadNotAllowedException' => $baseDir . '/lib/public/AutoloadNotAllowedException.php',
|
||||
'OCP\\BackgroundJob\\IJob' => $baseDir . '/lib/public/BackgroundJob/IJob.php',
|
||||
'OCP\\BackgroundJob\\IJobList' => $baseDir . '/lib/public/BackgroundJob/IJobList.php',
|
||||
'OCP\\BackgroundJob\\IJobRuns' => $baseDir . '/lib/public/BackgroundJob/IJobRuns.php',
|
||||
'OCP\\BackgroundJob\\IParallelAwareJob' => $baseDir . '/lib/public/BackgroundJob/IParallelAwareJob.php',
|
||||
'OCP\\BackgroundJob\\Job' => $baseDir . '/lib/public/BackgroundJob/Job.php',
|
||||
'OCP\\BackgroundJob\\JobRun' => $baseDir . '/lib/public/BackgroundJob/JobRun.php',
|
||||
'OCP\\BackgroundJob\\JobStatus' => $baseDir . '/lib/public/BackgroundJob/JobStatus.php',
|
||||
'OCP\\BackgroundJob\\QueuedJob' => $baseDir . '/lib/public/BackgroundJob/QueuedJob.php',
|
||||
'OCP\\BackgroundJob\\TimedJob' => $baseDir . '/lib/public/BackgroundJob/TimedJob.php',
|
||||
'OCP\\BeforeSabrePubliclyLoadedEvent' => $baseDir . '/lib/public/BeforeSabrePubliclyLoadedEvent.php',
|
||||
|
|
@ -1250,7 +1253,9 @@ return array(
|
|||
'OC\\Avatar\\GuestAvatar' => $baseDir . '/lib/private/Avatar/GuestAvatar.php',
|
||||
'OC\\Avatar\\PlaceholderAvatar' => $baseDir . '/lib/private/Avatar/PlaceholderAvatar.php',
|
||||
'OC\\Avatar\\UserAvatar' => $baseDir . '/lib/private/Avatar/UserAvatar.php',
|
||||
'OC\\BackgroundJob\\JobClassesRegistry' => $baseDir . '/lib/private/BackgroundJob/JobClassesRegistry.php',
|
||||
'OC\\BackgroundJob\\JobList' => $baseDir . '/lib/private/BackgroundJob/JobList.php',
|
||||
'OC\\BackgroundJob\\JobRuns' => $baseDir . '/lib/private/BackgroundJob/JobRuns.php',
|
||||
'OC\\BinaryFinder' => $baseDir . '/lib/private/BinaryFinder.php',
|
||||
'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => $baseDir . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php',
|
||||
'OC\\Broadcast\\Events\\BroadcastEvent' => $baseDir . '/lib/private/Broadcast/Events/BroadcastEvent.php',
|
||||
|
|
@ -1333,6 +1338,7 @@ return array(
|
|||
'OC\\Core\\Command\\Background\\JobWorker' => $baseDir . '/core/Command/Background/JobWorker.php',
|
||||
'OC\\Core\\Command\\Background\\ListCommand' => $baseDir . '/core/Command/Background/ListCommand.php',
|
||||
'OC\\Core\\Command\\Background\\Mode' => $baseDir . '/core/Command/Background/Mode.php',
|
||||
'OC\\Core\\Command\\Background\\RunningJobs' => $baseDir . '/core/Command/Background/RunningJobs.php',
|
||||
'OC\\Core\\Command\\Base' => $baseDir . '/core/Command/Base.php',
|
||||
'OC\\Core\\Command\\Broadcast\\Test' => $baseDir . '/core/Command/Broadcast/Test.php',
|
||||
'OC\\Core\\Command\\Check' => $baseDir . '/core/Command/Check.php',
|
||||
|
|
@ -1607,6 +1613,8 @@ return array(
|
|||
'OC\\Core\\Migrations\\Version33000Date20260126120000' => $baseDir . '/core/Migrations/Version33000Date20260126120000.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260318095645' => $baseDir . '/core/Migrations/Version34000Date20260318095645.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260415161745' => $baseDir . '/core/Migrations/Version34000Date20260415161745.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260518163022' => $baseDir . '/core/Migrations/Version34000Date20260518163022.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260521110333' => $baseDir . '/core/Migrations/Version34000Date20260521110333.php',
|
||||
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
|
||||
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
|
||||
'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php',
|
||||
|
|
|
|||
|
|
@ -241,8 +241,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OCP\\AutoloadNotAllowedException' => __DIR__ . '/../../..' . '/lib/public/AutoloadNotAllowedException.php',
|
||||
'OCP\\BackgroundJob\\IJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IJob.php',
|
||||
'OCP\\BackgroundJob\\IJobList' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IJobList.php',
|
||||
'OCP\\BackgroundJob\\IJobRuns' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IJobRuns.php',
|
||||
'OCP\\BackgroundJob\\IParallelAwareJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IParallelAwareJob.php',
|
||||
'OCP\\BackgroundJob\\Job' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/Job.php',
|
||||
'OCP\\BackgroundJob\\JobRun' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/JobRun.php',
|
||||
'OCP\\BackgroundJob\\JobStatus' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/JobStatus.php',
|
||||
'OCP\\BackgroundJob\\QueuedJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/QueuedJob.php',
|
||||
'OCP\\BackgroundJob\\TimedJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/TimedJob.php',
|
||||
'OCP\\BeforeSabrePubliclyLoadedEvent' => __DIR__ . '/../../..' . '/lib/public/BeforeSabrePubliclyLoadedEvent.php',
|
||||
|
|
@ -1291,7 +1294,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OC\\Avatar\\GuestAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/GuestAvatar.php',
|
||||
'OC\\Avatar\\PlaceholderAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/PlaceholderAvatar.php',
|
||||
'OC\\Avatar\\UserAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/UserAvatar.php',
|
||||
'OC\\BackgroundJob\\JobClassesRegistry' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobClassesRegistry.php',
|
||||
'OC\\BackgroundJob\\JobList' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobList.php',
|
||||
'OC\\BackgroundJob\\JobRuns' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobRuns.php',
|
||||
'OC\\BinaryFinder' => __DIR__ . '/../../..' . '/lib/private/BinaryFinder.php',
|
||||
'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => __DIR__ . '/../../..' . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php',
|
||||
'OC\\Broadcast\\Events\\BroadcastEvent' => __DIR__ . '/../../..' . '/lib/private/Broadcast/Events/BroadcastEvent.php',
|
||||
|
|
@ -1374,6 +1379,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OC\\Core\\Command\\Background\\JobWorker' => __DIR__ . '/../../..' . '/core/Command/Background/JobWorker.php',
|
||||
'OC\\Core\\Command\\Background\\ListCommand' => __DIR__ . '/../../..' . '/core/Command/Background/ListCommand.php',
|
||||
'OC\\Core\\Command\\Background\\Mode' => __DIR__ . '/../../..' . '/core/Command/Background/Mode.php',
|
||||
'OC\\Core\\Command\\Background\\RunningJobs' => __DIR__ . '/../../..' . '/core/Command/Background/RunningJobs.php',
|
||||
'OC\\Core\\Command\\Base' => __DIR__ . '/../../..' . '/core/Command/Base.php',
|
||||
'OC\\Core\\Command\\Broadcast\\Test' => __DIR__ . '/../../..' . '/core/Command/Broadcast/Test.php',
|
||||
'OC\\Core\\Command\\Check' => __DIR__ . '/../../..' . '/core/Command/Check.php',
|
||||
|
|
@ -1648,6 +1654,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OC\\Core\\Migrations\\Version33000Date20260126120000' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20260126120000.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260318095645' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260318095645.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260415161745' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260415161745.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260518163022' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260518163022.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260521110333' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260521110333.php',
|
||||
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
|
||||
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
|
||||
'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php',
|
||||
|
|
|
|||
99
lib/private/BackgroundJob/JobClassesRegistry.php
Normal file
99
lib/private/BackgroundJob/JobClassesRegistry.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OC\BackgroundJob;
|
||||
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use InvalidArgumentException;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Snowflake\ISnowflakeGenerator;
|
||||
|
||||
/**
|
||||
* Map background job classes and their ID in database
|
||||
*/
|
||||
final class JobClassesRegistry {
|
||||
/**
|
||||
* @var array<string,string>
|
||||
*/
|
||||
private array $registry = [];
|
||||
|
||||
private const TABLE = 'job_classes_registry';
|
||||
|
||||
public function __construct(
|
||||
private readonly IDBConnection $connection,
|
||||
private readonly ISnowflakeGenerator $snowflakeGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
private function loadRegistry(): void {
|
||||
if ($this->registry !== []) {
|
||||
return;
|
||||
}
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
$result = $qb->select('class_id', 'class_name')->from(self::TABLE)->executeQuery();
|
||||
foreach ($result->iterateAssociative() as $row) {
|
||||
$this->registry[$row['class_name']] = (string)$row['class_id'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve current ID or generates a new one
|
||||
*/
|
||||
public function getId(string $className): string {
|
||||
$this->loadRegistry();
|
||||
if (isset($this->registry[$className])) {
|
||||
return $this->registry[$className];
|
||||
}
|
||||
|
||||
if (!class_exists($className)) {
|
||||
throw new InvalidArgumentException('Class ' . $className . ' doesn’t exists');
|
||||
}
|
||||
if (!is_a($className, IJob::class, true)) {
|
||||
throw new InvalidArgumentException('Class ' . $className . ' isn’t an instance of ' . IJob::class);
|
||||
}
|
||||
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
try {
|
||||
$classId = $this->snowflakeGenerator->nextId();
|
||||
$qb
|
||||
->insert(self::TABLE)
|
||||
->values([
|
||||
'class_id' => $qb->createNamedParameter($classId),
|
||||
'class_name' => $qb->createNamedParameter($className),
|
||||
])
|
||||
->executeStatement();
|
||||
$this->registry[$className] = $classId;
|
||||
|
||||
return $classId;
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Class was probably added by a concurrent process
|
||||
// Try to load it
|
||||
$result = $qb->select('class_id')->from(self::TABLE)->where($qb->expr()->eq('class_name', $className))->executeQuery();
|
||||
if ($classId = $result->fetchOne()) {
|
||||
$classId = (string)$classId;
|
||||
$this->registry[$className] = $classId;
|
||||
|
||||
return $classId;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception('Fail to retrieve ' . $className . ' ID', previous: $e);
|
||||
}
|
||||
|
||||
public function getName(string|int $classId): string {
|
||||
$this->loadRegistry();
|
||||
$classId = (string)$classId;
|
||||
$className = array_search($classId, $this->registry, true);
|
||||
if ($className === false) {
|
||||
throw new InvalidArgumentException('Class ID ' . $classId . ' doesn’t match any class name');
|
||||
}
|
||||
|
||||
return $className;
|
||||
}
|
||||
}
|
||||
81
lib/private/BackgroundJob/JobRuns.php
Normal file
81
lib/private/BackgroundJob/JobRuns.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OC\BackgroundJob;
|
||||
|
||||
use OCP\BackgroundJob\IJobRuns;
|
||||
use OCP\BackgroundJob\JobRun;
|
||||
use OCP\BackgroundJob\JobStatus;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Snowflake\ISnowflakeDecoder;
|
||||
use OCP\Snowflake\ISnowflakeGenerator;
|
||||
use Override;
|
||||
|
||||
final readonly class JobRuns implements IJobRuns {
|
||||
private const TABLE = 'job_runs';
|
||||
|
||||
public function __construct(
|
||||
private IDBConnection $connection,
|
||||
private ISnowflakeGenerator $snowflakeGenerator,
|
||||
private ISnowflakeDecoder $snowflakeDecoder,
|
||||
private JobClassesRegistry $classesRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
// TODO Move it to runner when refactoring
|
||||
public function started(int|string $classId): string {
|
||||
$id = $this->snowflakeGenerator->nextId();
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
$qb
|
||||
->insert(self::TABLE)
|
||||
->setValue('run_id', $id)
|
||||
->setValue('class_id', $qb->createNamedParameter($classId))
|
||||
->setValue('pid', $qb->createNamedParameter(posix_getpid()))
|
||||
->setValue('status', $qb->createNamedParameter(JobStatus::RUNNING->value))
|
||||
->executeStatement();
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
// TODO Move it to runner when refactoring
|
||||
public function finished(int|string $runId, int $duration, int $memoryPeakUsage, JobStatus $status = JobStatus::SUCCEEDED): bool {
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
$result = $qb
|
||||
->update(self::TABLE)
|
||||
->set('status', $qb->createNamedParameter($status->value))
|
||||
->set('duration', $qb->createNamedParameter($duration))
|
||||
->set('ram_peak_usage', $qb->createNamedParameter($memoryPeakUsage))
|
||||
->where($qb->expr()->eq('run_id', $qb->createNamedParameter($runId)))
|
||||
->executeStatement();
|
||||
|
||||
return $result === 1;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function runningJobs(int $limit = 200): \Generator {
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
$result = $qb
|
||||
->select('run_id', 'class_id', 'pid', 'status')
|
||||
->from(self::TABLE)
|
||||
->where($qb->expr()->eq('status', $qb->createNamedParameter(JobStatus::RUNNING->value)))
|
||||
->setMaxResults($limit)
|
||||
->executeQuery();
|
||||
|
||||
foreach ($result->iterateAssociative() as $row) {
|
||||
$snowflakeInfo = $this->snowflakeDecoder->decode((string)$row['run_id']);
|
||||
yield new JobRun(
|
||||
$row['run_id'],
|
||||
$this->classesRegistry->getName($row['class_id']),
|
||||
$snowflakeInfo->getServerId(),
|
||||
$row['pid'],
|
||||
$snowflakeInfo->getCreatedAt(),
|
||||
JobStatus::from($row['status']),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ use OC\Authentication\Token\IProvider;
|
|||
use OC\Authentication\TwoFactorAuth\Registry;
|
||||
use OC\Avatar\AvatarManager;
|
||||
use OC\BackgroundJob\JobList;
|
||||
use OC\BackgroundJob\JobRuns;
|
||||
use OC\Blurhash\Listener\GenerateBlurhashMetadata;
|
||||
use OC\Collaboration\Collaborators\GroupPlugin;
|
||||
use OC\Collaboration\Collaborators\MailByMailPlugin;
|
||||
|
|
@ -170,6 +171,7 @@ use OCP\Authentication\Token\IProvider as OCPIProvider;
|
|||
use OCP\Authentication\TwoFactorAuth\IRegistry;
|
||||
use OCP\AutoloadNotAllowedException;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\BackgroundJob\IJobRuns;
|
||||
use OCP\Collaboration\Collaborators\ISearch;
|
||||
use OCP\Collaboration\Collaborators\ISearchResult;
|
||||
use OCP\Collaboration\Reference\IReferenceManager;
|
||||
|
|
@ -1319,6 +1321,7 @@ class Server extends ServerContainer implements IServerContainer {
|
|||
return $c->get(FileSequence::class);
|
||||
}, false);
|
||||
$this->registerAlias(ISnowflakeDecoder::class, SnowflakeDecoder::class);
|
||||
$this->registerAlias(IJobRuns::class, JobRuns::class);
|
||||
|
||||
$this->connectDispatcher();
|
||||
}
|
||||
|
|
|
|||
25
lib/public/BackgroundJob/IJobRuns.php
Normal file
25
lib/public/BackgroundJob/IJobRuns.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCP\BackgroundJob;
|
||||
|
||||
/**
|
||||
* List executed jobs
|
||||
*
|
||||
* Keep track of background jobs: start time, resource used, exit status…
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
interface IJobRuns {
|
||||
/**
|
||||
* List of currently running jobs
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
public function runningJobs(int $limit = 200): \Generator;
|
||||
}
|
||||
45
lib/public/BackgroundJob/JobRun.php
Normal file
45
lib/public/BackgroundJob/JobRun.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCP\BackgroundJob;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Job run
|
||||
*
|
||||
* Information about the execution of a single job
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
final readonly class JobRun {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
public function __construct(
|
||||
/** Run ID (Snowflake ID) */
|
||||
public int $runId,
|
||||
/** Class name */
|
||||
public string $className,
|
||||
/** Server ID */
|
||||
public int $serverId,
|
||||
/** Process ID on server */
|
||||
public int $pid,
|
||||
/** Job start time */
|
||||
public DateTimeImmutable $startedAt,
|
||||
/** Job status (running, fail…) */
|
||||
public JobStatus $status,
|
||||
/** Job duration in milliseconds */
|
||||
public ?int $duration = null,
|
||||
/** Job memory usage peak in kilobytes (base 10) */
|
||||
public ?int $memoryPeak = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
44
lib/public/BackgroundJob/JobStatus.php
Normal file
44
lib/public/BackgroundJob/JobStatus.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCP\BackgroundJob;
|
||||
|
||||
/**
|
||||
* Background job statuses
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
enum JobStatus: int {
|
||||
/**
|
||||
* Background job is still running
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
case RUNNING = 0;
|
||||
|
||||
/**
|
||||
* Background job completed sucessfully
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
case SUCCEEDED = 1;
|
||||
|
||||
/**
|
||||
* Background job failed
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
case FAILED = 2;
|
||||
|
||||
/**
|
||||
* Background job crashed the PHP process
|
||||
*
|
||||
* @since 34.0.0
|
||||
*/
|
||||
case CRASHED = 3;
|
||||
}
|
||||
37
tests/lib/BackgroundJob/DummyJob.php
Normal file
37
tests/lib/BackgroundJob/DummyJob.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\BackgroundJob;
|
||||
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
|
||||
/**
|
||||
* Dummy Job fo tests only
|
||||
*/
|
||||
class DummyJob implements IJob {
|
||||
public function start(IJobList $jobList): void {
|
||||
}
|
||||
|
||||
public function setId(string $id): void {
|
||||
}
|
||||
|
||||
public function setLastRun(int $lastRun): void {
|
||||
}
|
||||
|
||||
public function setArgument(mixed $argument): void {
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
}
|
||||
|
||||
public function getLastRun(): int {
|
||||
}
|
||||
|
||||
public function getArgument(): mixed {
|
||||
}
|
||||
}
|
||||
82
tests/lib/BackgroundJob/JobClassesRegistryTest.php
Normal file
82
tests/lib/BackgroundJob/JobClassesRegistryTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\BackgroundJob;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OC\BackgroundJob\JobClassesRegistry;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Server;
|
||||
use OCP\Snowflake\ISnowflakeGenerator;
|
||||
use Override;
|
||||
use Test\TestCase;
|
||||
|
||||
/**
|
||||
* @package Test\BackgroundJob
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\Group('DB')]
|
||||
class JobClassesRegistryTest extends TestCase {
|
||||
private readonly IDBConnection $connection;
|
||||
private readonly ISnowflakeGenerator $snowflakeGenerator;
|
||||
private JobClassesRegistry $registry;
|
||||
|
||||
#[Override]
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->connection = Server::get(IDBConnection::class);
|
||||
$this->snowflakeGenerator = Server::get(ISnowflakeGenerator::class);
|
||||
$this->registry = new JobClassesRegistry($this->connection, $this->snowflakeGenerator);
|
||||
}
|
||||
|
||||
public function testResolveNonExistingClass() {
|
||||
$className = 'invalid_class_name_122278';
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Class ' . $className . ' doesn’t exists');
|
||||
$this->registry->getId($className);
|
||||
}
|
||||
|
||||
public function testResolveInvalidClass() {
|
||||
$className = self::class;
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Class ' . $className . ' isn’t an instance of OCP\BackgroundJob\IJob');
|
||||
$this->registry->getId($className);
|
||||
}
|
||||
|
||||
public function testResolveValidClass() {
|
||||
$className = DummyJob::class;
|
||||
|
||||
$classId = $this->registry->getId($className);
|
||||
$this->assertIsString($classId);
|
||||
$this->assertGreaterThan(0, $classId);
|
||||
|
||||
// Renew register. ID should stay the same
|
||||
$this->registry = new JobClassesRegistry($this->connection, $this->snowflakeGenerator);
|
||||
$newId = $this->registry->getId($className);
|
||||
$this->assertEquals($classId, $newId);
|
||||
}
|
||||
|
||||
public function testResolveValidId() {
|
||||
$className = DummyJob::class;
|
||||
|
||||
$classId = $this->registry->getId($className);
|
||||
$resolvedClass = $this->registry->getName($classId);
|
||||
|
||||
$this->assertEquals($className, $resolvedClass);
|
||||
}
|
||||
|
||||
public function testResolveInvalidId() {
|
||||
$classId = PHP_INT_MAX;
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Class ID ' . $classId . ' doesn’t match any class name');
|
||||
$this->registry->getName($classId);
|
||||
}
|
||||
}
|
||||
90
tests/lib/BackgroundJob/JobRunsTest.php
Normal file
90
tests/lib/BackgroundJob/JobRunsTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\BackgroundJob;
|
||||
|
||||
use OC\BackgroundJob\JobClassesRegistry;
|
||||
use OC\BackgroundJob\JobRuns;
|
||||
use OCP\BackgroundJob\JobRun;
|
||||
use OCP\BackgroundJob\JobStatus;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Server;
|
||||
use OCP\Snowflake\ISnowflakeDecoder;
|
||||
use OCP\Snowflake\ISnowflakeGenerator;
|
||||
use Override;
|
||||
use Test\TestCase;
|
||||
|
||||
/**
|
||||
* @package Test\BackgroundJob
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\Group('DB')]
|
||||
class JobRunsTest extends TestCase {
|
||||
private IDBConnection $connection;
|
||||
private JobClassesRegistry $registry;
|
||||
private JobRuns $runs;
|
||||
|
||||
#[Override]
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->connection = Server::get(IDBConnection::class);
|
||||
$this->registry = Server::get(JobClassesRegistry::class);
|
||||
$this->runs = new JobRuns(
|
||||
$this->connection,
|
||||
Server::get(ISnowflakeGenerator::class),
|
||||
Server::get(ISnowflakeDecoder::class),
|
||||
$this->registry,
|
||||
);
|
||||
}
|
||||
|
||||
public function testJobStarted(): void {
|
||||
$myPid = 1337;
|
||||
$myClass = DummyJob::class;
|
||||
|
||||
$runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid);
|
||||
|
||||
$this->assertGreaterThan(0, $runId);
|
||||
}
|
||||
|
||||
public function testJobSucceeded(): void {
|
||||
$myPid = 1337;
|
||||
$myClass = DummyJob::class;
|
||||
|
||||
$runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid);
|
||||
|
||||
$result = $this->runs->finished($runId, 12, 9876543);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testJobFailed(): void {
|
||||
$myPid = 1337;
|
||||
$myClass = DummyJob::class;
|
||||
|
||||
$runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid);
|
||||
|
||||
$result = $this->runs->finished($runId, 13, 87654321, JobStatus::FAILED);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testRunningJobs(): void {
|
||||
$myPid = 1337;
|
||||
$myClass = DummyJob::class;
|
||||
|
||||
$runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid);
|
||||
|
||||
$runningJobs = 0;
|
||||
foreach ($this->runs->runningJobs() as $job) {
|
||||
$this->assertInstanceOf(JobRun::class, $job);
|
||||
++$runningJobs;
|
||||
}
|
||||
$this->assertGreaterThan(0, $runningJobs);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue