diff --git a/core/Command/Background/RunningJobs.php b/core/Command/Background/RunningJobs.php
new file mode 100644
index 0000000000000..8b63ace09c5cd
--- /dev/null
+++ b/core/Command/Background/RunningJobs.php
@@ -0,0 +1,92 @@
+Run ID: job identifier a found in database (Snowflake ID)
+ - Class: class of the job
+ - Started at: start time of the job
+ - Server ID: server ID as defined in config.php> (see `serverid`). Highlighted if it’s running on current server.
+ - PID: PID of process executing the job
+ - Running since: 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 ? '' . $job->serverId . '' : $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));
+ }
+}
diff --git a/core/Migrations/Version34000Date20260518163022.php b/core/Migrations/Version34000Date20260518163022.php
new file mode 100644
index 0000000000000..1658a37d1f4ef
--- /dev/null
+++ b/core/Migrations/Version34000Date20260518163022.php
@@ -0,0 +1,59 @@
+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]);
+ $platform = $schema->getDatabasePlatform();
+ if ($platform instanceof PostgreSQLPlatform) {
+ // PostgreSQL doesn't support unsigned INT
+ $table->addColumn('class_hash', Types::BIGINT, ['notnull' => true]);
+ } else {
+ $table->addColumn('class_hash', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
+ }
+ $table->setPrimaryKey(['class_id']);
+ $table->addUniqueConstraint(['class_hash', 'class_name'], 'class_index');
+
+ return $schema;
+ }
+
+ return null;
+ }
+}
diff --git a/core/Migrations/Version34000Date20260521110333.php b/core/Migrations/Version34000Date20260521110333.php
new file mode 100644
index 0000000000000..f454020b41841
--- /dev/null
+++ b/core/Migrations/Version34000Date20260521110333.php
@@ -0,0 +1,54 @@
+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;
+ }
+}
diff --git a/core/Service/CronService.php b/core/Service/CronService.php
index 60f9cccfd86d9..3575a00ee06ba 100644
--- a/core/Service/CronService.php
+++ b/core/Service/CronService.php
@@ -13,6 +13,7 @@
use OC;
use OC\Authentication\LoginCredentials\Store;
+use OC\BackgroundJob\JobClassesRegistry;
use OC\DB\Connection;
use OC\Security\CSRF\TokenStorage\SessionStorage;
use OC\Session\CryptoWrapper;
@@ -21,6 +22,7 @@
use OCP\App\IAppManager;
use OCP\BackgroundJob\IJob;
use OCP\BackgroundJob\IJobList;
+use OCP\BackgroundJob\IJobRuns;
use OCP\Files\ISetupManager;
use OCP\IAppConfig;
use OCP\IConfig;
@@ -49,6 +51,8 @@ public function __construct(
private readonly ITempManager $tempManager,
private readonly IAppConfig $appConfig,
private readonly IJobList $jobList,
+ private readonly IJobRuns $jobRuns,
+ private readonly JobClassesRegistry $jobClassesRegistry,
private readonly ISetupManager $setupManager,
private readonly bool $isCLI,
) {
@@ -185,20 +189,27 @@ private function runCli(string $appMode, ?array $jobClasses): void {
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) {
diff --git a/core/register_command.php b/core/register_command.php
index 856894b5c4c77..38cca5781f2b2 100644
--- a/core/register_command.php
+++ b/core/register_command.php
@@ -20,6 +20,7 @@
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;
@@ -148,6 +149,7 @@
$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));
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 54ec6a944dff3..23c77bf3ec6ad 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -200,8 +200,11 @@
'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 @@
'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 @@
'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',
@@ -1611,6 +1617,8 @@
'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',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index 2b90f11fa7d3b..8d0e74d1cdf3c 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.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',
@@ -1652,6 +1658,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',
diff --git a/lib/private/BackgroundJob/JobClassesRegistry.php b/lib/private/BackgroundJob/JobClassesRegistry.php
new file mode 100644
index 0000000000000..129210bd8df54
--- /dev/null
+++ b/lib/private/BackgroundJob/JobClassesRegistry.php
@@ -0,0 +1,112 @@
+
+ */
+ 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();
+ $hashedName = $this->hashName($className);
+ try {
+ $classId = $this->snowflakeGenerator->nextId();
+ $qb
+ ->insert(self::TABLE)
+ ->values([
+ 'class_id' => $qb->createNamedParameter($classId),
+ 'class_name' => $qb->createNamedParameter($className),
+ 'class_hash' => $qb->createNamedParameter($hashedName),
+ ])
+ ->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_hash', $hashedName))
+ ->andWhere($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;
+ }
+
+ private function hashName(string $className): int {
+ return hexdec(hash('xxh32', $className));
+ }
+}
diff --git a/lib/private/BackgroundJob/JobRuns.php b/lib/private/BackgroundJob/JobRuns.php
new file mode 100644
index 0000000000000..b95bcc1d38140
--- /dev/null
+++ b/lib/private/BackgroundJob/JobRuns.php
@@ -0,0 +1,81 @@
+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;
+ }
+
+ #[Override]
+ 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']),
+ );
+ }
+ }
+}
diff --git a/lib/private/Server.php b/lib/private/Server.php
index a39d09fe3b040..7ce89644664f9 100644
--- a/lib/private/Server.php
+++ b/lib/private/Server.php
@@ -25,6 +25,7 @@
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\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 @@ public function __construct(
return $c->get(FileSequence::class);
}, false);
$this->registerAlias(ISnowflakeDecoder::class, SnowflakeDecoder::class);
+ $this->registerAlias(IJobRuns::class, JobRuns::class);
$this->connectDispatcher();
}
diff --git a/lib/public/BackgroundJob/IJobRuns.php b/lib/public/BackgroundJob/IJobRuns.php
new file mode 100644
index 0000000000000..1bf51788c9d4d
--- /dev/null
+++ b/lib/public/BackgroundJob/IJobRuns.php
@@ -0,0 +1,45 @@
+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);
+ }
+}
diff --git a/tests/lib/BackgroundJob/JobRunsTest.php b/tests/lib/BackgroundJob/JobRunsTest.php
new file mode 100644
index 0000000000000..ec1d4fe0c7f39
--- /dev/null
+++ b/tests/lib/BackgroundJob/JobRunsTest.php
@@ -0,0 +1,90 @@
+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);
+ }
+}