Merge pull request #60779 from nextcloud/feat/completed_jobs_list

feat(jobs): add command to list executed background jobs
This commit is contained in:
Benjamin Gaussorgues 2026-05-29 15:10:04 +02:00 committed by GitHub
commit 1b1c74e848
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 192 additions and 10 deletions

View file

@ -0,0 +1,101 @@
<?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\BackgroundJob\JobStatus;
use OCP\IConfig;
use OCP\Util;
use Override;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use ValueError;
final class JobsHistory 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 as 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 its running on current server.
- <info>PID:</info> PID of process executing the job
- <info>Duration:</info> human readable duration
- <info>Memory usage:</info> human readable memory usage peak
EOF;
$this
->setName('background-job:history')
->setDescription('Show currently running jobs')
->setHelp($help)
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Maximum number of results returned by the command', 200)
->addOption('class', 'c', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Filter by class name', [])
->addOption('status', 's', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Filter by status', []);
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$limit = (int)$input->getOption('limit');
$classesId = $input->getOption('class');
try {
$statuses = array_map(fn (string $value) => JobStatus::from((int)$value), $input->getOption('status'));
} catch (ValueError $e) {
$output->writeln('<error>Invalid status provided</error>');
$output->writeln($e->getMessage());
return Base::FAILURE;
}
$jobs = $this->jobRuns->completedJobs($statuses, $classesId, $limit);
$this->writeStreamingTableInOutputFormat($input, $output, $this->formatLine($jobs), 20);
return Base::SUCCESS;
}
private function formatLine(iterable $jobs): \Generator {
$jobsInfo = [];
$now = time();
$currentServerId = $this->config->getSystemValueInt('serverid', -1);
foreach ($jobs as $job) {
$status = match ($job->status) {
JobStatus::RUNNING => 'Running',
JobStatus::SUCCEEDED => '<info>Succeeded</info>',
JobStatus::FAILED => '<question>Failed</question>',
JobStatus::CRASHED => '<error>Crashed</error>',
default => 'Unknown',
};
yield [
'Run ID' => $job->runId,
'Status' => $status,
'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,
'Duration' => $job->duration . ' ms',
'Memory usage' => Util::humanFileSize($job->memoryPeak * 1024),
];
}
return $jobsInfo;
}
}

View file

@ -17,6 +17,7 @@ use OC\Core\Command\App\Remove;
use OC\Core\Command\App\Update;
use OC\Core\Command\Background\Delete;
use OC\Core\Command\Background\Job;
use OC\Core\Command\Background\JobsHistory;
use OC\Core\Command\Background\JobWorker;
use OC\Core\Command\Background\ListCommand;
use OC\Core\Command\Background\Mode;
@ -150,6 +151,7 @@ if ($config->getSystemValueBool('installed', false)) {
$application->add(Server::get(Delete::class));
$application->add(Server::get(JobWorker::class));
$application->add(Server::get(RunningJobs::class));
$application->add(Server::get(JobsHistory::class));
$application->add(Server::get(Test::class));

View file

@ -1336,6 +1336,7 @@ return array(
'OC\\Core\\Command\\Background\\Job' => $baseDir . '/core/Command/Background/Job.php',
'OC\\Core\\Command\\Background\\JobBase' => $baseDir . '/core/Command/Background/JobBase.php',
'OC\\Core\\Command\\Background\\JobWorker' => $baseDir . '/core/Command/Background/JobWorker.php',
'OC\\Core\\Command\\Background\\JobsHistory' => $baseDir . '/core/Command/Background/JobsHistory.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',

View file

@ -1377,6 +1377,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Command\\Background\\Job' => __DIR__ . '/../../..' . '/core/Command/Background/Job.php',
'OC\\Core\\Command\\Background\\JobBase' => __DIR__ . '/../../..' . '/core/Command/Background/JobBase.php',
'OC\\Core\\Command\\Background\\JobWorker' => __DIR__ . '/../../..' . '/core/Command/Background/JobWorker.php',
'OC\\Core\\Command\\Background\\JobsHistory' => __DIR__ . '/../../..' . '/core/Command/Background/JobsHistory.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',

View file

@ -8,13 +8,17 @@ declare(strict_types=1);
*/
namespace OC\BackgroundJob;
use Exception;
use OCP\BackgroundJob\IJobRuns;
use OCP\BackgroundJob\JobRun;
use OCP\BackgroundJob\JobStatus;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Snowflake\ISnowflakeDecoder;
use OCP\Snowflake\ISnowflakeGenerator;
use Override;
use Psr\Log\LoggerInterface;
use RuntimeException;
final readonly class JobRuns implements IJobRuns {
private const TABLE = 'job_runs';
@ -23,7 +27,8 @@ final readonly class JobRuns implements IJobRuns {
private IDBConnection $connection,
private ISnowflakeGenerator $snowflakeGenerator,
private ISnowflakeDecoder $snowflakeDecoder,
private JobClassesRegistry $classesRegistry,
private JobClassesRegistry $jobClassesRegistry,
private LoggerInterface $logger,
) {
}
@ -67,15 +72,57 @@ final readonly class JobRuns implements IJobRuns {
->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(),
(int)$row['pid'],
$snowflakeInfo->getCreatedAt(),
JobStatus::from((int)$row['status']),
);
yield $this->rowToJobRun($row);
}
}
#[Override]
public function completedJobs(array $statuses = [], array $classes = [], int $limit = 200): \Generator {
if ($statuses === []) {
// By default, list only completed jobs
$statuses = [JobStatus::SUCCEEDED, JobStatus::FAILED, JobStatus::CRASHED];
}
$dbStatuses = array_map(static fn (JobStatus $status) => $status->value, $statuses);
$qb = $this->connection->getQueryBuilder();
$qb
->select('run_id', 'class_id', 'pid', 'status', 'duration', 'ram_peak_usage')
->from(self::TABLE)
->where($qb->expr()->in('status', $qb->createNamedParameter($dbStatuses, IQueryBuilder::PARAM_INT_ARRAY)))
->setMaxResults($limit)
->orderBy('run_id', 'DESC');
if ($classes !== []) {
$classIds = [];
foreach ($classes as $class) {
try {
$classIds[] = $this->jobClassesRegistry->getId($class);
} catch (Exception $e) {
$this->logger->warning('Fail to resolve background job class {class}', ['class' => $class, 'exception' => $e]);
}
}
if ($classIds === []) {
throw new RuntimeException('No class ID found for filtering');
}
$qb->andWhere($qb->expr()->in('class_id', $qb->createNamedParameter($classIds, IQueryBuilder::PARAM_INT_ARRAY)));
}
foreach ($qb->executeQuery()->iterateAssociative() as $row) {
yield $this->rowToJobRun($row);
}
}
private function rowToJobRun(array $dbRow): JobRun {
$snowflakeInfo = $this->snowflakeDecoder->decode((string)$dbRow['run_id']);
return new JobRun(
$dbRow['run_id'],
$this->jobClassesRegistry->getName($dbRow['class_id']),
$snowflakeInfo->getServerId(),
(int)$dbRow['pid'],
$snowflakeInfo->getCreatedAt(),
JobStatus::from((int)$dbRow['status']),
isset($dbRow['duration']) ? (int)$dbRow['duration'] : null,
isset($dbRow['ram_peak_usage']) ? (int)$dbRow['ram_peak_usage'] : null,
);
}
}

View file

@ -22,4 +22,13 @@ interface IJobRuns {
* @since 34.0.0
*/
public function runningJobs(int $limit = 200): \Generator;
/**
* List of completed jobs
*
* @param list<JobStatus> $statuses
* @param list<class-string<IJob>> $classes
* @since 34.0.0
*/
public function completedJobs(array $statuses = [], array $classes = [], int $limit = 200): \Generator;
}

View file

@ -18,6 +18,7 @@ use OCP\Server;
use OCP\Snowflake\ISnowflakeDecoder;
use OCP\Snowflake\ISnowflakeGenerator;
use Override;
use Psr\Log\LoggerInterface;
use Test\TestCase;
/**
@ -40,6 +41,7 @@ class JobRunsTest extends TestCase {
Server::get(ISnowflakeGenerator::class),
Server::get(ISnowflakeDecoder::class),
$this->registry,
$this->createMock(LoggerInterface::class),
);
}
@ -87,4 +89,23 @@ class JobRunsTest extends TestCase {
}
$this->assertGreaterThan(0, $runningJobs);
}
public function testCompletedJobs(): void {
$myPid = 1337;
$myClass = DummyJob::class;
$runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid);
$this->runs->finished($runId, 12345, 67890 * 1024, JobStatus::FAILED);
$runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid);
$this->runs->finished($runId, 12345, 67890 * 1024, JobStatus::SUCCEEDED);
$runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid);
$this->runs->finished($runId, 12345, 67890 * 1024, JobStatus::CRASHED);
$completedJobs = 0;
foreach ($this->runs->runningJobs() as $job) {
$this->assertInstanceOf(JobRun::class, $job);
++$completedJobs;
}
$this->assertGreaterThan(0, $completedJobs);
}
}