Skip to content

Commit 1ab068d

Browse files
committed
feat(jobs): introduce background job classes register
Signed-off-by: Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>
1 parent 50ddee1 commit 1ab068d

7 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
use Override;
18+
19+
class Version34000Date20260518163022 extends SimpleMigrationStep {
20+
#[Override]
21+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
22+
/** @var ISchemaWrapper $schema */
23+
$schema = $schemaClosure();
24+
25+
if (!$schema->hasTable('job_classes_registry')) {
26+
$table = $schema->createTable('job_classes_registry');
27+
$table->addColumn('class_id', Types::BIGINT, [
28+
'autoincrement' => true,
29+
'notnull' => true,
30+
'unsigned' => true,
31+
]);
32+
$table->addColumn('class_name', Types::STRING, ['notnull' => true, 'length' => 255]);
33+
$table->addColumn('class_hash', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
34+
$table->setPrimaryKey(['class_id']);
35+
$table->addUniqueConstraint(['class_hash', 'class_name'], 'class_index');
36+
37+
return $schema;
38+
}
39+
40+
return null;
41+
}
42+
}

lib/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,7 @@
12501250
'OC\\Avatar\\GuestAvatar' => $baseDir . '/lib/private/Avatar/GuestAvatar.php',
12511251
'OC\\Avatar\\PlaceholderAvatar' => $baseDir . '/lib/private/Avatar/PlaceholderAvatar.php',
12521252
'OC\\Avatar\\UserAvatar' => $baseDir . '/lib/private/Avatar/UserAvatar.php',
1253+
'OC\\BackgroundJob\\JobClassesRegistry' => $baseDir . '/lib/private/BackgroundJob/JobClassesRegistry.php',
12531254
'OC\\BackgroundJob\\JobList' => $baseDir . '/lib/private/BackgroundJob/JobList.php',
12541255
'OC\\BinaryFinder' => $baseDir . '/lib/private/BinaryFinder.php',
12551256
'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => $baseDir . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php',
@@ -1611,6 +1612,7 @@
16111612
'OC\\Core\\Migrations\\Version33000Date20260126120000' => $baseDir . '/core/Migrations/Version33000Date20260126120000.php',
16121613
'OC\\Core\\Migrations\\Version34000Date20260318095645' => $baseDir . '/core/Migrations/Version34000Date20260318095645.php',
16131614
'OC\\Core\\Migrations\\Version34000Date20260415161745' => $baseDir . '/core/Migrations/Version34000Date20260415161745.php',
1615+
'OC\\Core\\Migrations\\Version34000Date20260518163022' => $baseDir . '/core/Migrations/Version34000Date20260518163022.php',
16141616
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
16151617
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
16161618
'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php',

lib/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
12911291
'OC\\Avatar\\GuestAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/GuestAvatar.php',
12921292
'OC\\Avatar\\PlaceholderAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/PlaceholderAvatar.php',
12931293
'OC\\Avatar\\UserAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/UserAvatar.php',
1294+
'OC\\BackgroundJob\\JobClassesRegistry' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobClassesRegistry.php',
12941295
'OC\\BackgroundJob\\JobList' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobList.php',
12951296
'OC\\BinaryFinder' => __DIR__ . '/../../..' . '/lib/private/BinaryFinder.php',
12961297
'OC\\Blurhash\\Listener\\GenerateBlurhashMetadata' => __DIR__ . '/../../..' . '/lib/private/Blurhash/Listener/GenerateBlurhashMetadata.php',
@@ -1652,6 +1653,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
16521653
'OC\\Core\\Migrations\\Version33000Date20260126120000' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20260126120000.php',
16531654
'OC\\Core\\Migrations\\Version34000Date20260318095645' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260318095645.php',
16541655
'OC\\Core\\Migrations\\Version34000Date20260415161745' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260415161745.php',
1656+
'OC\\Core\\Migrations\\Version34000Date20260518163022' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260518163022.php',
16551657
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
16561658
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
16571659
'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php',
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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-only
8+
*/
9+
namespace OC\BackgroundJob;
10+
11+
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
12+
use InvalidArgumentException;
13+
use OCP\BackgroundJob\IJob;
14+
use OCP\IDBConnection;
15+
use OCP\Snowflake\ISnowflakeGenerator;
16+
17+
/**
18+
* Map background job classes and their ID in database
19+
*
20+
* Uses a rapid hash to speed-up lookups
21+
*/
22+
final class JobClassesRegistry {
23+
/**
24+
* @var array<string,string>
25+
*/
26+
private array $registry = [];
27+
28+
private const TABLE = 'job_classes_registry';
29+
30+
public function __construct(
31+
private readonly IDBConnection $connection,
32+
private readonly ISnowflakeGenerator $snowflakeGenerator,
33+
) {
34+
}
35+
36+
private function loadRegistry(): void {
37+
if ($this->registry !== []) {
38+
return;
39+
}
40+
$qb = $this->connection->getQueryBuilder();
41+
$result = $qb->select('class_id', 'class_name')->from(self::TABLE)->executeQuery();
42+
foreach ($result->iterateAssociative() as $row) {
43+
$this->registry[$row['class_name']] = (string)$row['class_id'];
44+
}
45+
}
46+
47+
/**
48+
* Resolve current ID or generates a new one
49+
*/
50+
public function getId(string $className): string {
51+
$this->loadRegistry();
52+
if (isset($this->registry[$className])) {
53+
return $this->registry[$className];
54+
}
55+
56+
if (!class_exists($className)) {
57+
throw new InvalidArgumentException('Class ' . $className . ' doesn’t exists');
58+
}
59+
if (!isset(class_implements($className)[IJob::class])) {
60+
throw new InvalidArgumentException('Class ' . $className . ' isn’t an instance of ' . IJob::class);
61+
}
62+
63+
$qb = $this->connection->getQueryBuilder();
64+
$hashedName = $this->hashName($className);
65+
try {
66+
$classId = $this->snowflakeGenerator->nextId();
67+
$qb
68+
->insert(self::TABLE)
69+
->values([
70+
'class_id' => $qb->createNamedParameter($classId),
71+
'class_name' => $qb->createNamedParameter($className),
72+
'class_hash' => $qb->createNamedParameter($hashedName),
73+
])
74+
->executeStatement();
75+
$this->registry[$className] = $classId;
76+
77+
return $classId;
78+
} catch (UniqueConstraintViolationException $e) {
79+
// Class was probably added by a concurrent process
80+
// Try to load it
81+
$result = $qb
82+
->select('class_id')
83+
->from(self::TABLE)
84+
->where($qb->expr()->eq('class_hash', $hashedName))
85+
->andWhere($qb->expr()->eq('class_name', $className))
86+
->executeQuery();
87+
if ($classId = $result->fetchOne()) {
88+
$classId = (string)$classId;
89+
$this->registry[$className] = $classId;
90+
91+
return $classId;
92+
}
93+
}
94+
95+
throw new \Exception('Fail to retrieve ' . $className . ' ID', previous: $e ?? null);
96+
}
97+
98+
public function getName(string|int $classId): string {
99+
$this->loadRegistry();
100+
$classId = (string)$classId;
101+
$className = array_search($classId, $this->registry, true);
102+
if ($className === false) {
103+
throw new InvalidArgumentException('Class ID ' . $classId . ' doesn’t match any class name');
104+
}
105+
106+
return $className;
107+
}
108+
109+
private function hashName(string $className): int {
110+
return hexdec(hash('xxh32', $className));
111+
}
112+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
* Background job statuses
13+
*
14+
* @since 34.0.0
15+
*/
16+
enum JobStatus: int {
17+
/**
18+
* Background job is still running
19+
*
20+
* @since 34.0.0
21+
*/
22+
case RUNNING = 0;
23+
24+
/**
25+
* Background job completed sucessfully
26+
*
27+
* @since 34.0.0
28+
*/
29+
case SUCCEEDED = 1;
30+
31+
/**
32+
* Background job failed
33+
*
34+
* @since 34.0.0
35+
*/
36+
case FAILED = 2;
37+
38+
/**
39+
* Background job crashed the PHP process
40+
*
41+
* @since 34.0.0
42+
*/
43+
case CRASHED = 3;
44+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace Test\BackgroundJob;
9+
10+
use OCP\BackgroundJob\IJob;
11+
use OCP\BackgroundJob\IJobList;
12+
13+
/**
14+
* Dummy Job fo tests only
15+
*/
16+
class DummyJob implements IJob {
17+
public function start(IJobList $jobList): void {
18+
}
19+
20+
public function setId(string $id): void {
21+
}
22+
23+
public function setLastRun(int $lastRun): void {
24+
}
25+
26+
public function setArgument(mixed $argument): void {
27+
}
28+
29+
public function getId(): string {
30+
}
31+
32+
public function getLastRun(): int {
33+
}
34+
35+
public function getArgument(): mixed {
36+
}
37+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 Test\BackgroundJob;
11+
12+
use InvalidArgumentException;
13+
use OC\BackgroundJob\JobClassesRegistry;
14+
use OCP\IDBConnection;
15+
use OCP\Server;
16+
use OCP\Snowflake\ISnowflakeGenerator;
17+
use Override;
18+
use Test\TestCase;
19+
20+
/**
21+
* @package Test\BackgroundJob
22+
*/
23+
#[\PHPUnit\Framework\Attributes\Group('DB')]
24+
class JobClassesRegistryTest extends TestCase {
25+
private readonly IDBConnection $connection;
26+
private readonly ISnowflakeGenerator $snowflakeGenerator;
27+
private JobClassesRegistry $registry;
28+
29+
#[Override]
30+
protected function setUp(): void {
31+
parent::setUp();
32+
33+
$this->connection = Server::get(IDBConnection::class);
34+
$this->snowflakeGenerator = Server::get(ISnowflakeGenerator::class);
35+
$this->registry = new JobClassesRegistry($this->connection, $this->snowflakeGenerator);
36+
}
37+
38+
public function testResolveNonExistingClass() {
39+
$className = 'invalid_class_name_122278';
40+
41+
$this->expectException(InvalidArgumentException::class);
42+
$this->expectExceptionMessage('Class ' . $className . ' doesn’t exists');
43+
$this->registry->getId($className);
44+
}
45+
46+
public function testResolveInvalidClass() {
47+
$className = self::class;
48+
49+
$this->expectException(InvalidArgumentException::class);
50+
$this->expectExceptionMessage('Class ' . $className . ' isn’t an instance of OCP\BackgroundJob\IJob');
51+
$this->registry->getId($className);
52+
}
53+
54+
public function testResolveValidClass() {
55+
$className = DummyJob::class;
56+
57+
$classId = $this->registry->getId($className);
58+
$this->assertIsString($classId);
59+
$this->assertGreaterThan(0, $classId);
60+
61+
// Renew register. ID should stay the same
62+
$this->registry = new JobClassesRegistry($this->connection, $this->snowflakeGenerator);
63+
$newId = $this->registry->getId($className);
64+
$this->assertEquals($classId, $newId);
65+
}
66+
67+
public function testResolveValidId() {
68+
$className = DummyJob::class;
69+
70+
$classId = $this->registry->getId($className);
71+
$resolvedClass = $this->registry->getName($classId);
72+
73+
$this->assertEquals($className, $resolvedClass);
74+
}
75+
76+
public function testResolveInvalidId() {
77+
$classId = PHP_INT_MAX;
78+
$this->expectException(InvalidArgumentException::class);
79+
$this->expectExceptionMessage('Class ID ' . $classId . ' doesn’t match any class name');
80+
$this->registry->getName($classId);
81+
}
82+
}

0 commit comments

Comments
 (0)