mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 09:42:09 -04:00
feat(jobs): introduce background job classes registry
Signed-off-by: Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>
This commit is contained in:
parent
0466856093
commit
8db8776f2d
7 changed files with 313 additions and 0 deletions
47
core/Migrations/Version34000Date20260518163022.php
Normal file
47
core/Migrations/Version34000Date20260518163022.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
99
lib/private/BackgroundJob/JobClassesRegistry.php
Normal file
99
lib/private/BackgroundJob/JobClassesRegistry.php
Normal 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 . ' 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;
|
||||
}
|
||||
}
|
||||
44
lib/public/BackgroundJob/JobStatus.php
Normal file
44
lib/public/BackgroundJob/JobStatus.php
Normal 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;
|
||||
}
|
||||
37
tests/lib/BackgroundJob/DummyJob.php
Normal file
37
tests/lib/BackgroundJob/DummyJob.php
Normal 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 {
|
||||
}
|
||||
}
|
||||
82
tests/lib/BackgroundJob/JobClassesRegistryTest.php
Normal file
82
tests/lib/BackgroundJob/JobClassesRegistryTest.php
Normal 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 . ' 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue