From a3db335fe0f96cbb8f94192cd1237823f3a9886c Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Mon, 18 May 2026 16:57:08 +0200 Subject: [PATCH 1/5] feat(jobs): introduce background job classes register Signed-off-by: Benjamin Gaussorgues --- .../Version34000Date20260518163022.php | 52 ++++++++ lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + .../BackgroundJob/JobClassesRegistry.php | 112 ++++++++++++++++++ lib/public/BackgroundJob/JobStatus.php | 44 +++++++ tests/lib/BackgroundJob/DummyJob.php | 37 ++++++ .../BackgroundJob/JobClassesRegistryTest.php | 82 +++++++++++++ 7 files changed, 331 insertions(+) create mode 100644 core/Migrations/Version34000Date20260518163022.php create mode 100644 lib/private/BackgroundJob/JobClassesRegistry.php create mode 100644 lib/public/BackgroundJob/JobStatus.php create mode 100644 tests/lib/BackgroundJob/DummyJob.php create mode 100644 tests/lib/BackgroundJob/JobClassesRegistryTest.php diff --git a/core/Migrations/Version34000Date20260518163022.php b/core/Migrations/Version34000Date20260518163022.php new file mode 100644 index 0000000000000..9a59e3e2737d7 --- /dev/null +++ b/core/Migrations/Version34000Date20260518163022.php @@ -0,0 +1,52 @@ +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->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/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 54ec6a944dff3..53bba3503abfa 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1250,6 +1250,7 @@ '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\\BinaryFinder' => $baseDir . '/lib/private/BinaryFinder.php', 'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => $baseDir . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php', @@ -1611,6 +1612,7 @@ '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\\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..b14d673415dfc 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1291,6 +1291,7 @@ 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\\BinaryFinder' => __DIR__ . '/../../..' . '/lib/private/BinaryFinder.php', 'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => __DIR__ . '/../../..' . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php', @@ -1652,6 +1653,7 @@ 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\\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/public/BackgroundJob/JobStatus.php b/lib/public/BackgroundJob/JobStatus.php new file mode 100644 index 0000000000000..e24837bf2f472 --- /dev/null +++ b/lib/public/BackgroundJob/JobStatus.php @@ -0,0 +1,44 @@ +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); + } +} From e5354fa8083d538c3f8f6a66a32ca996f973e1bc Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Wed, 27 May 2026 11:23:29 +0200 Subject: [PATCH 2/5] feat(jobs): introduce JobStatus for job running state Signed-off-by: Benjamin Gaussorgues --- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 53bba3503abfa..5519e61f2a0be 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -202,6 +202,7 @@ 'OCP\\BackgroundJob\\IJobList' => $baseDir . '/lib/public/BackgroundJob/IJobList.php', 'OCP\\BackgroundJob\\IParallelAwareJob' => $baseDir . '/lib/public/BackgroundJob/IParallelAwareJob.php', 'OCP\\BackgroundJob\\Job' => $baseDir . '/lib/public/BackgroundJob/Job.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', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index b14d673415dfc..16666c438b749 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -243,6 +243,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\BackgroundJob\\IJobList' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IJobList.php', 'OCP\\BackgroundJob\\IParallelAwareJob' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/IParallelAwareJob.php', 'OCP\\BackgroundJob\\Job' => __DIR__ . '/../../..' . '/lib/public/BackgroundJob/Job.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', From 91dc2323cb51701cfdcc9b6602e38e2c2d664af7 Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Wed, 27 May 2026 11:28:00 +0200 Subject: [PATCH 3/5] feat(jobs): allow to keep track of job executions Signed-off-by: Benjamin Gaussorgues --- core/Command/Background/RunningJobs.php | 92 +++++++++++++++++++ .../Version34000Date20260521110333.php | 54 +++++++++++ core/register_command.php | 2 + lib/composer/composer/autoload_classmap.php | 5 + lib/composer/composer/autoload_static.php | 5 + lib/private/BackgroundJob/JobRuns.php | 81 ++++++++++++++++ lib/public/BackgroundJob/IJobRuns.php | 45 +++++++++ lib/public/BackgroundJob/JobRun.php | 45 +++++++++ tests/lib/BackgroundJob/JobRunsTest.php | 90 ++++++++++++++++++ 9 files changed, 419 insertions(+) create mode 100644 core/Command/Background/RunningJobs.php create mode 100644 core/Migrations/Version34000Date20260521110333.php create mode 100644 lib/private/BackgroundJob/JobRuns.php create mode 100644 lib/public/BackgroundJob/IJobRuns.php create mode 100644 lib/public/BackgroundJob/JobRun.php create mode 100644 tests/lib/BackgroundJob/JobRunsTest.php 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/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/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 5519e61f2a0be..23c77bf3ec6ad 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -200,8 +200,10 @@ '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', @@ -1253,6 +1255,7 @@ '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', @@ -1335,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', @@ -1614,6 +1618,7 @@ '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 16666c438b749..8d0e74d1cdf3c 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -241,8 +241,10 @@ 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', @@ -1294,6 +1296,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 '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', @@ -1376,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', @@ -1655,6 +1659,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 '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/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/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->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); + } +} From 283a0be843d60273786ed9efa7eef949dfc179ef Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Wed, 27 May 2026 12:16:42 +0200 Subject: [PATCH 4/5] feat(jobs): log job execution in CronService Signed-off-by: Benjamin Gaussorgues --- core/Service/CronService.php | 17 ++++++++++++++--- lib/private/Server.php | 3 +++ 2 files changed, 17 insertions(+), 3 deletions(-) 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/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(); } From 3dda7b1ec60fa49491565fd29c54221f51c8a50d Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Thu, 28 May 2026 09:22:11 +0200 Subject: [PATCH 5/5] fix(jobs): Postfix needs larger BIGINT to store unsigned INT Signed-off-by: Benjamin Gaussorgues --- core/Migrations/Version34000Date20260518163022.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/Migrations/Version34000Date20260518163022.php b/core/Migrations/Version34000Date20260518163022.php index 9a59e3e2737d7..1658a37d1f4ef 100644 --- a/core/Migrations/Version34000Date20260518163022.php +++ b/core/Migrations/Version34000Date20260518163022.php @@ -10,6 +10,7 @@ namespace OC\Core\Migrations; use Closure; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use OCP\DB\ISchemaWrapper; use OCP\DB\Types; use OCP\Migration\Attributes\AddIndex; @@ -40,7 +41,13 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 'unsigned' => true, ]); $table->addColumn('class_name', Types::STRING, ['notnull' => true, 'length' => 255]); - $table->addColumn('class_hash', Types::INTEGER, ['notnull' => true, 'unsigned' => true]); + $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');