Skip to content

Commit 9ef4b7a

Browse files
committed
feat(jobs): allow to keep track of job executions
Signed-off-by: Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>
1 parent 23ed3bb commit 9ef4b7a

9 files changed

Lines changed: 399 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Core\Command\Background;
11+
12+
use OC\BackgroundJob\JobRuns;
13+
use OC\Core\Command\Base;
14+
use OCP\IConfig;
15+
use Override;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Input\InputOption;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
final class RunningJobs extends Base {
21+
public function __construct(
22+
private readonly JobRuns $jobRuns,
23+
private IConfig $config,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
#[Override]
29+
protected function configure(): void {
30+
parent::configure();
31+
32+
$help = <<<EOF
33+
Display all currently running background jobs.
34+
35+
You can find the following informations:
36+
- <info>Run ID:</info> job identifier a found in database (Snowflake ID)
37+
- <info>Class:</info> class of the job
38+
- <info>Started at:</info> start time of the job
39+
- <info>Server ID:</info> server ID as defined in <options=bold>config.php</> (see `serverid`). Highlighted if it’s running on current server.
40+
- <info>PID:</info> PID of process executing the job
41+
- <info>Running since:</info> human readable elapsed time since job started
42+
43+
EOF;
44+
45+
$this
46+
->setName('background-job:running')
47+
->setDescription('Show currently running jobs')
48+
->setHelp($help)
49+
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Maximum number of results returned by the command', 200);
50+
}
51+
52+
#[Override]
53+
protected function execute(InputInterface $input, OutputInterface $output): int {
54+
$limit = (int)$input->getOption('limit');
55+
$jobs = $this->jobRuns->runningJobs($limit);
56+
$this->writeStreamingTableInOutputFormat($input, $output, $this->formatLine($jobs), 20);
57+
58+
return Base::SUCCESS;
59+
}
60+
61+
private function formatLine(iterable $jobs): \Generator {
62+
$now = time();
63+
$currentServerId = $this->config->getSystemValueInt('serverid', -1);
64+
foreach ($jobs as $job) {
65+
yield [
66+
'Run ID' => $job->runId,
67+
'Class' => $job->className,
68+
'Started at' => $job->startedAt->format('Y-m-d H:i:s'),
69+
'Server ID' => $job->serverId === $currentServerId ? '<info>' . $job->serverId . '</info>' : $job->serverId,
70+
'PID' => $job->pid,
71+
'Running since' => $this->formatDuration($now - $job->startedAt->format('U')),
72+
];
73+
}
74+
}
75+
76+
/**
77+
* TODO Move this function to utils class with better formatting (plural, i18n…)
78+
*/
79+
private function formatDuration(int $seconds): string {
80+
if ($seconds < 60) {
81+
return sprintf('%d seconds', $seconds);
82+
}
83+
if ($seconds < 3600) {
84+
return sprintf('%d minutes', $seconds / 60);
85+
}
86+
if ($seconds < (3600 * 24)) {
87+
return sprintf('> %d hours', $seconds / 3600);
88+
}
89+
90+
return sprintf('> %d days', $seconds / (3600 * 24));
91+
}
92+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Core\Migrations;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\Attributes\AddIndex;
16+
use OCP\Migration\Attributes\CreateTable;
17+
use OCP\Migration\Attributes\IndexType;
18+
use OCP\Migration\IOutput;
19+
use OCP\Migration\SimpleMigrationStep;
20+
use Override;
21+
22+
#[CreateTable(
23+
table: 'job_runs',
24+
columns: ['class_id', 'pid', 'status', 'duration', 'ram_peak_usage'],
25+
description: 'New table to store executions of background jobs',
26+
)]
27+
#[AddIndex(table: 'job_runs', type: IndexType::PRIMARY)]
28+
#[AddIndex(table: 'job_runs', type: IndexType::INDEX, description: 'Allows to search on job status')]
29+
class Version34000Date20260521110333 extends SimpleMigrationStep {
30+
#[Override]
31+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
32+
/** @var ISchemaWrapper $schema */
33+
$schema = $schemaClosure();
34+
35+
if (!$schema->hasTable('job_runs')) {
36+
$table = $schema->createTable('job_runs');
37+
$table->addColumn('run_id', Types::BIGINT, [
38+
'notnull' => true,
39+
'unsigned' => true,
40+
]);
41+
$table->addColumn('class_id', Types::BIGINT, ['notnull' => true]);
42+
$table->addColumn('pid', Types::INTEGER, ['notnull' => true]); // Should be MEDIUMINT
43+
$table->addColumn('status', Types::SMALLINT, ['notnull' => true]); // Should be TINYINT
44+
$table->addColumn('duration', Types::INTEGER, ['notnull' => true, 'default' => 0]);
45+
$table->addColumn('ram_peak_usage', Types::INTEGER, ['notnull' => true, 'default' => 0]); // Should be MEDIUMINT
46+
$table->setPrimaryKey(['run_id']);
47+
$table->addIndex(['status'], 'status');
48+
49+
return $schema;
50+
}
51+
52+
return null;
53+
}
54+
}

core/register_command.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use OC\Core\Command\Background\JobWorker;
2121
use OC\Core\Command\Background\ListCommand;
2222
use OC\Core\Command\Background\Mode;
23+
use OC\Core\Command\Background\RunningJobs;
2324
use OC\Core\Command\Broadcast\Test;
2425
use OC\Core\Command\Check;
2526
use OC\Core\Command\Config\App\DeleteConfig;
@@ -148,6 +149,7 @@
148149
$application->add(Server::get(ListCommand::class));
149150
$application->add(Server::get(Delete::class));
150151
$application->add(Server::get(JobWorker::class));
152+
$application->add(Server::get(RunningJobs::class));
151153

152154
$application->add(Server::get(Test::class));
153155

lib/composer/composer/autoload_classmap.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,10 @@
200200
'OCP\\AutoloadNotAllowedException' => $baseDir . '/lib/public/AutoloadNotAllowedException.php',
201201
'OCP\\BackgroundJob\\IJob' => $baseDir . '/lib/public/BackgroundJob/IJob.php',
202202
'OCP\\BackgroundJob\\IJobList' => $baseDir . '/lib/public/BackgroundJob/IJobList.php',
203+
'OCP\\BackgroundJob\\IJobRuns' => $baseDir . '/lib/public/BackgroundJob/IJobRuns.php',
203204
'OCP\\BackgroundJob\\IParallelAwareJob' => $baseDir . '/lib/public/BackgroundJob/IParallelAwareJob.php',
204205
'OCP\\BackgroundJob\\Job' => $baseDir . '/lib/public/BackgroundJob/Job.php',
206+
'OCP\\BackgroundJob\\JobRun' => $baseDir . '/lib/public/BackgroundJob/JobRun.php',
205207
'OCP\\BackgroundJob\\JobStatus' => $baseDir . '/lib/public/BackgroundJob/JobStatus.php',
206208
'OCP\\BackgroundJob\\QueuedJob' => $baseDir . '/lib/public/BackgroundJob/QueuedJob.php',
207209
'OCP\\BackgroundJob\\TimedJob' => $baseDir . '/lib/public/BackgroundJob/TimedJob.php',
@@ -1253,6 +1255,7 @@
12531255
'OC\\Avatar\\UserAvatar' => $baseDir . '/lib/private/Avatar/UserAvatar.php',
12541256
'OC\\BackgroundJob\\JobClassesRegistry' => $baseDir . '/lib/private/BackgroundJob/JobClassesRegistry.php',
12551257
'OC\\BackgroundJob\\JobList' => $baseDir . '/lib/private/BackgroundJob/JobList.php',
1258+
'OC\\BackgroundJob\\JobRuns' => $baseDir . '/lib/private/BackgroundJob/JobRuns.php',
12561259
'OC\\BinaryFinder' => $baseDir . '/lib/private/BinaryFinder.php',
12571260
'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => $baseDir . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php',
12581261
'OC\\Broadcast\\Events\\BroadcastEvent' => $baseDir . '/lib/private/Broadcast/Events/BroadcastEvent.php',
@@ -1335,6 +1338,7 @@
13351338
'OC\\Core\\Command\\Background\\JobWorker' => $baseDir . '/core/Command/Background/JobWorker.php',
13361339
'OC\\Core\\Command\\Background\\ListCommand' => $baseDir . '/core/Command/Background/ListCommand.php',
13371340
'OC\\Core\\Command\\Background\\Mode' => $baseDir . '/core/Command/Background/Mode.php',
1341+
'OC\\Core\\Command\\Background\\RunningJobs' => $baseDir . '/core/Command/Background/RunningJobs.php',
13381342
'OC\\Core\\Command\\Base' => $baseDir . '/core/Command/Base.php',
13391343
'OC\\Core\\Command\\Broadcast\\Test' => $baseDir . '/core/Command/Broadcast/Test.php',
13401344
'OC\\Core\\Command\\Check' => $baseDir . '/core/Command/Check.php',
@@ -1614,6 +1618,7 @@
16141618
'OC\\Core\\Migrations\\Version34000Date20260318095645' => $baseDir . '/core/Migrations/Version34000Date20260318095645.php',
16151619
'OC\\Core\\Migrations\\Version34000Date20260415161745' => $baseDir . '/core/Migrations/Version34000Date20260415161745.php',
16161620
'OC\\Core\\Migrations\\Version34000Date20260518163022' => $baseDir . '/core/Migrations/Version34000Date20260518163022.php',
1621+
'OC\\Core\\Migrations\\Version34000Date20260521110333' => $baseDir . '/core/Migrations/Version34000Date20260521110333.php',
16171622
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
16181623
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
16191624
'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php',

lib/composer/composer/autoload_static.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
241241
'OCP\\AutoloadNotAllowedException' => __DIR__ . '/../../..' . '/lib/public/AutoloadNotAllowedException.php',
242242
'OCP\\BackgroundJob\\IJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IJob.php',
243243
'OCP\\BackgroundJob\\IJobList' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IJobList.php',
244+
'OCP\\BackgroundJob\\IJobRuns' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IJobRuns.php',
244245
'OCP\\BackgroundJob\\IParallelAwareJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IParallelAwareJob.php',
245246
'OCP\\BackgroundJob\\Job' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/Job.php',
247+
'OCP\\BackgroundJob\\JobRun' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/JobRun.php',
246248
'OCP\\BackgroundJob\\JobStatus' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/JobStatus.php',
247249
'OCP\\BackgroundJob\\QueuedJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/QueuedJob.php',
248250
'OCP\\BackgroundJob\\TimedJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/TimedJob.php',
@@ -1294,6 +1296,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
12941296
'OC\\Avatar\\UserAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/UserAvatar.php',
12951297
'OC\\BackgroundJob\\JobClassesRegistry' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobClassesRegistry.php',
12961298
'OC\\BackgroundJob\\JobList' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobList.php',
1299+
'OC\\BackgroundJob\\JobRuns' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobRuns.php',
12971300
'OC\\BinaryFinder' => __DIR__ . '/../../..' . '/lib/private/BinaryFinder.php',
12981301
'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => __DIR__ . '/../../..' . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php',
12991302
'OC\\Broadcast\\Events\\BroadcastEvent' => __DIR__ . '/../../..' . '/lib/private/Broadcast/Events/BroadcastEvent.php',
@@ -1376,6 +1379,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
13761379
'OC\\Core\\Command\\Background\\JobWorker' => __DIR__ . '/../../..' . '/core/Command/Background/JobWorker.php',
13771380
'OC\\Core\\Command\\Background\\ListCommand' => __DIR__ . '/../../..' . '/core/Command/Background/ListCommand.php',
13781381
'OC\\Core\\Command\\Background\\Mode' => __DIR__ . '/../../..' . '/core/Command/Background/Mode.php',
1382+
'OC\\Core\\Command\\Background\\RunningJobs' => __DIR__ . '/../../..' . '/core/Command/Background/RunningJobs.php',
13791383
'OC\\Core\\Command\\Base' => __DIR__ . '/../../..' . '/core/Command/Base.php',
13801384
'OC\\Core\\Command\\Broadcast\\Test' => __DIR__ . '/../../..' . '/core/Command/Broadcast/Test.php',
13811385
'OC\\Core\\Command\\Check' => __DIR__ . '/../../..' . '/core/Command/Check.php',
@@ -1655,6 +1659,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
16551659
'OC\\Core\\Migrations\\Version34000Date20260318095645' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260318095645.php',
16561660
'OC\\Core\\Migrations\\Version34000Date20260415161745' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260415161745.php',
16571661
'OC\\Core\\Migrations\\Version34000Date20260518163022' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260518163022.php',
1662+
'OC\\Core\\Migrations\\Version34000Date20260521110333' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260521110333.php',
16581663
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
16591664
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
16601665
'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OC\BackgroundJob;
10+
11+
use OCP\BackgroundJob\IJobRuns;
12+
use OCP\BackgroundJob\JobRun;
13+
use OCP\BackgroundJob\JobStatus;
14+
use OCP\IDBConnection;
15+
use OCP\Snowflake\ISnowflakeDecoder;
16+
use OCP\Snowflake\ISnowflakeGenerator;
17+
use Override;
18+
19+
final readonly class JobRuns implements IJobRuns {
20+
private const TABLE = 'job_runs';
21+
22+
public function __construct(
23+
private IDBConnection $connection,
24+
private ISnowflakeGenerator $snowflakeGenerator,
25+
private ISnowflakeDecoder $snowflakeDecoder,
26+
private JobClassesRegistry $classesRegistry,
27+
) {
28+
}
29+
30+
#[Override]
31+
public function started(int|string $classId): string {
32+
$id = $this->snowflakeGenerator->nextId();
33+
$qb = $this->connection->getQueryBuilder();
34+
$qb
35+
->insert(self::TABLE)
36+
->setValue('run_id', $id)
37+
->setValue('class_id', $qb->createNamedParameter($classId))
38+
->setValue('pid', $qb->createNamedParameter(posix_getpid()))
39+
->setValue('status', $qb->createNamedParameter(JobStatus::RUNNING->value))
40+
->executeStatement();
41+
42+
return $id;
43+
}
44+
45+
#[Override]
46+
public function finished(int|string $runId, int $duration, int $memoryPeakUsage, JobStatus $status = JobStatus::SUCCEEDED): bool {
47+
$qb = $this->connection->getQueryBuilder();
48+
$result = $qb
49+
->update(self::TABLE)
50+
->set('status', $qb->createNamedParameter($status->value))
51+
->set('duration', $qb->createNamedParameter($duration))
52+
->set('ram_peak_usage', $qb->createNamedParameter($memoryPeakUsage))
53+
->where($qb->expr()->eq('run_id', $qb->createNamedParameter($runId)))
54+
->executeStatement();
55+
56+
return $result === 1;
57+
}
58+
59+
#[Override]
60+
public function runningJobs(int $limit = 200): \Generator {
61+
$qb = $this->connection->getQueryBuilder();
62+
$result = $qb
63+
->select('run_id', 'class_id', 'pid', 'status')
64+
->from(self::TABLE)
65+
->where($qb->expr()->eq('status', $qb->createNamedParameter(JobStatus::RUNNING->value)))
66+
->setMaxResults($limit)
67+
->executeQuery();
68+
69+
foreach ($result->iterateAssociative() as $row) {
70+
$snowflakeInfo = $this->snowflakeDecoder->decode((string)$row['run_id']);
71+
yield new JobRun(
72+
$row['run_id'],
73+
$this->classesRegistry->getName($row['class_id']),
74+
$snowflakeInfo->getServerId(),
75+
$row['pid'],
76+
$snowflakeInfo->getCreatedAt(),
77+
JobStatus::from($row['status']),
78+
);
79+
}
80+
}
81+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCP\BackgroundJob;
10+
11+
/**
12+
* List executed jobs
13+
*
14+
* Keep track of background jobs: start time, resource used, exit status…
15+
*
16+
* @since 34.0.0
17+
*/
18+
interface IJobRuns {
19+
/**
20+
* List of currently running jobs
21+
*
22+
* @since 34.0.0
23+
*/
24+
public function runningJobs(int $limit = 200): \Generator;
25+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCP\BackgroundJob;
10+
11+
use DateTimeImmutable;
12+
13+
/**
14+
* Job run
15+
*
16+
* Information about the execution of a single job
17+
*
18+
* @since 34.0.0
19+
*/
20+
final readonly class JobRun {
21+
/**
22+
* Constructor
23+
*
24+
* @since 34.0.0
25+
*/
26+
public function __construct(
27+
/** Run ID (Snowflake ID) */
28+
public int $runId,
29+
/** Class name */
30+
public string $className,
31+
/** Server ID */
32+
public int $serverId,
33+
/** Process ID on server */
34+
public int $pid,
35+
/** Job start time */
36+
public DateTimeImmutable $startedAt,
37+
/** Job status (running, fail…) */
38+
public JobStatus $status,
39+
/** Job duration in milliseconds */
40+
public ?int $duration = null,
41+
/** Job memory usage peak in kilobytes (base 10) */
42+
public ?int $memoryPeak = null,
43+
) {
44+
}
45+
}

0 commit comments

Comments
 (0)