From 8db8776f2d496d2980f05b123f1cdbe407496b8a Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Mon, 18 May 2026 16:57:08 +0200 Subject: [PATCH] feat(jobs): introduce background job classes registry Signed-off-by: Benjamin Gaussorgues --- .../Version34000Date20260518163022.php | 47 +++++++++ lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + .../BackgroundJob/JobClassesRegistry.php | 99 +++++++++++++++++++ lib/public/BackgroundJob/JobStatus.php | 44 +++++++++ tests/lib/BackgroundJob/DummyJob.php | 37 +++++++ .../BackgroundJob/JobClassesRegistryTest.php | 82 +++++++++++++++ 7 files changed, 313 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 00000000000..c1b29cd9a30 --- /dev/null +++ b/core/Migrations/Version34000Date20260518163022.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 54ec6a944df..53bba3503ab 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -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', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 2b90f11fa7d..b14d673415d 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 00000000000..bfc4902f2f9 --- /dev/null +++ b/lib/private/BackgroundJob/JobClassesRegistry.php @@ -0,0 +1,99 @@ + + */ + 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(); + 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 . ' doesn’t match any class name'); + } + + return $className; + } +} diff --git a/lib/public/BackgroundJob/JobStatus.php b/lib/public/BackgroundJob/JobStatus.php new file mode 100644 index 00000000000..e24837bf2f4 --- /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); + } +}