feat(jobs): introduce background job classes registry

Signed-off-by: Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>
This commit is contained in:
Benjamin Gaussorgues 2026-05-18 16:57:08 +02:00
parent 0466856093
commit 8db8776f2d
No known key found for this signature in database
7 changed files with 313 additions and 0 deletions

View 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;
}
}

View file

@ -1250,6 +1250,7 @@ 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\\BinaryFinder' => $baseDir . '/lib/private/BinaryFinder.php',
'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => $baseDir . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php',
@ -1611,6 +1612,7 @@ 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\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php',

View file

@ -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',

View 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 . ' doesnt exists');
}
if (!is_a($className, IJob::class, true)) {
throw new InvalidArgumentException('Class ' . $className . ' isnt 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 . ' doesnt match any class name');
}
return $className;
}
}

View 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;
}

View 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 {
}
}

View 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 . ' doesnt exists');
$this->registry->getId($className);
}
public function testResolveInvalidClass() {
$className = self::class;
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Class ' . $className . ' isnt 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 . ' doesnt match any class name');
$this->registry->getName($classId);
}
}