From 60ce92a697dd357465db65f11e10c85cc5404d70 Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Wed, 3 Jun 2026 18:26:46 +0200 Subject: [PATCH 1/4] feat(utils): add getter for serverid with proper default Signed-off-by: Benjamin Gaussorgues --- .../CleanupBackgroundJobsJob.php | 62 +++++++++++++++++++ core/Command/Background/JobsHistory.php | 6 +- core/Command/Background/RunningJobs.php | 6 +- lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + .../Repair/AddCleanupBackgroundJobsJob.php | 33 ++++++++++ lib/private/Server.php | 4 ++ lib/private/ServerInfo.php | 48 ++++++++++++++ lib/private/Snowflake/SnowflakeGenerator.php | 24 +------ lib/public/IServerInfo.php | 33 ++++++++++ lib/public/Util.php | 8 +-- tests/lib/Snowflake/GeneratorTest.php | 18 +++--- 12 files changed, 205 insertions(+), 41 deletions(-) create mode 100644 core/BackgroundJobs/CleanupBackgroundJobsJob.php create mode 100644 lib/private/Repair/AddCleanupBackgroundJobsJob.php create mode 100644 lib/private/ServerInfo.php create mode 100644 lib/public/IServerInfo.php diff --git a/core/BackgroundJobs/CleanupBackgroundJobsJob.php b/core/BackgroundJobs/CleanupBackgroundJobsJob.php new file mode 100644 index 00000000000..69f4eb8fed6 --- /dev/null +++ b/core/BackgroundJobs/CleanupBackgroundJobsJob.php @@ -0,0 +1,62 @@ +setInterval(60 * 60); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + } + + #[Override] + protected function run($argument): void { + $this->reapCrashedJobs(); + + // TODO Clean oldest jobs + } + + private function reapCrashedJobs(): void { + $currentServerId = $this->serverInfo->getServerId(); + + foreach ($this->jobRuns->runningJobs(1000) as $job) { + if ($job->serverId !== $currentServerId) { + continue; + } + exec('ps -p ' . escapeshellarg((string)$job->pid) . ' -o cmd', $output, $result); + if (count($output) === 1 && $output[0] === 'CMD' && $result === 1) { + // Process doesn't exists anymore + $maxDuration = (new DateTimeImmutable())->diff($job->startedAt); + $maxDuration = + ($maxDuration->format('%a') * 24 * 60 * 60 * 1000) + + ($maxDuration->format('%h') * 60 * 60 * 1000) + + ($maxDuration->format('%m') * 60 * 1000) + + ($maxDuration->format('%s') * 1000) + + (int)($maxDuration->format('%f') / 1000); + $this->jobRuns->finished($job->runId, $maxDuration, 0, JobStatus::CRASHED); + } + } + } +} diff --git a/core/Command/Background/JobsHistory.php b/core/Command/Background/JobsHistory.php index 8837bcc10ce..566dfc550dd 100644 --- a/core/Command/Background/JobsHistory.php +++ b/core/Command/Background/JobsHistory.php @@ -12,7 +12,7 @@ namespace OC\Core\Command\Background; use OC\BackgroundJob\JobRuns; use OC\Core\Command\Base; use OCP\BackgroundJob\JobStatus; -use OCP\IConfig; +use OCP\IServerInfo; use OCP\Util; use Override; use Symfony\Component\Console\Input\InputInterface; @@ -23,7 +23,7 @@ use ValueError; final class JobsHistory extends Base { public function __construct( private readonly JobRuns $jobRuns, - private IConfig $config, + private readonly IServerInfo $serverInfo, ) { parent::__construct(); } @@ -75,7 +75,7 @@ final class JobsHistory extends Base { private function formatLine(iterable $jobs): \Generator { $jobsInfo = []; $now = time(); - $currentServerId = $this->config->getSystemValueInt('serverid', -1); + $currentServerId = $this->serverInfo->getServerId(); foreach ($jobs as $job) { $status = match ($job->status) { JobStatus::RUNNING => 'Running', diff --git a/core/Command/Background/RunningJobs.php b/core/Command/Background/RunningJobs.php index 8b63ace09c5..01c375eb306 100644 --- a/core/Command/Background/RunningJobs.php +++ b/core/Command/Background/RunningJobs.php @@ -11,7 +11,7 @@ namespace OC\Core\Command\Background; use OC\BackgroundJob\JobRuns; use OC\Core\Command\Base; -use OCP\IConfig; +use OCP\IServerInfo; use Override; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -20,7 +20,7 @@ use Symfony\Component\Console\Output\OutputInterface; final class RunningJobs extends Base { public function __construct( private readonly JobRuns $jobRuns, - private IConfig $config, + private readonly IServerInfo $serverInfo, ) { parent::__construct(); } @@ -60,7 +60,7 @@ final class RunningJobs extends Base { private function formatLine(iterable $jobs): \Generator { $now = time(); - $currentServerId = $this->config->getSystemValueInt('serverid', -1); + $currentServerId = $this->serverInfo->getServerId(); foreach ($jobs as $job) { yield [ 'Run ID' => $job->runId, diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index d799c16bc8a..7f18f4d7cc3 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -651,6 +651,7 @@ return array( 'OCP\\IRequest' => $baseDir . '/lib/public/IRequest.php', 'OCP\\IRequestId' => $baseDir . '/lib/public/IRequestId.php', 'OCP\\IServerContainer' => $baseDir . '/lib/public/IServerContainer.php', + 'OCP\\IServerInfo' => $baseDir . '/lib/public/IServerInfo.php', 'OCP\\ISession' => $baseDir . '/lib/public/ISession.php', 'OCP\\IStreamImage' => $baseDir . '/lib/public/IStreamImage.php', 'OCP\\ITagManager' => $baseDir . '/lib/public/ITagManager.php', @@ -2173,6 +2174,7 @@ return array( 'OC\\Security\\VerificationToken\\VerificationToken' => $baseDir . '/lib/private/Security/VerificationToken/VerificationToken.php', 'OC\\Server' => $baseDir . '/lib/private/Server.php', 'OC\\ServerContainer' => $baseDir . '/lib/private/ServerContainer.php', + 'OC\\ServerInfo' => $baseDir . '/lib/private/ServerInfo.php', 'OC\\ServerNotAvailableException' => $baseDir . '/lib/private/ServerNotAvailableException.php', 'OC\\ServiceUnavailableException' => $baseDir . '/lib/private/ServiceUnavailableException.php', 'OC\\Session\\CryptoSessionData' => $baseDir . '/lib/private/Session/CryptoSessionData.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 0ec2e588ab1..9f739dbbca2 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -692,6 +692,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\IRequest' => __DIR__ . '/../../..' . '/lib/public/IRequest.php', 'OCP\\IRequestId' => __DIR__ . '/../../..' . '/lib/public/IRequestId.php', 'OCP\\IServerContainer' => __DIR__ . '/../../..' . '/lib/public/IServerContainer.php', + 'OCP\\IServerInfo' => __DIR__ . '/../../..' . '/lib/public/IServerInfo.php', 'OCP\\ISession' => __DIR__ . '/../../..' . '/lib/public/ISession.php', 'OCP\\IStreamImage' => __DIR__ . '/../../..' . '/lib/public/IStreamImage.php', 'OCP\\ITagManager' => __DIR__ . '/../../..' . '/lib/public/ITagManager.php', @@ -2214,6 +2215,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Security\\VerificationToken\\VerificationToken' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/VerificationToken.php', 'OC\\Server' => __DIR__ . '/../../..' . '/lib/private/Server.php', 'OC\\ServerContainer' => __DIR__ . '/../../..' . '/lib/private/ServerContainer.php', + 'OC\\ServerInfo' => __DIR__ . '/../../..' . '/lib/private/ServerInfo.php', 'OC\\ServerNotAvailableException' => __DIR__ . '/../../..' . '/lib/private/ServerNotAvailableException.php', 'OC\\ServiceUnavailableException' => __DIR__ . '/../../..' . '/lib/private/ServiceUnavailableException.php', 'OC\\Session\\CryptoSessionData' => __DIR__ . '/../../..' . '/lib/private/Session/CryptoSessionData.php', diff --git a/lib/private/Repair/AddCleanupBackgroundJobsJob.php b/lib/private/Repair/AddCleanupBackgroundJobsJob.php new file mode 100644 index 00000000000..41478db9900 --- /dev/null +++ b/lib/private/Repair/AddCleanupBackgroundJobsJob.php @@ -0,0 +1,33 @@ +jobList->add(CleanupBackgroundJobsJob::class); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index 1a4e523147f..3d79fd87ce6 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -227,6 +227,7 @@ use OCP\IPreview; use OCP\IRequest; use OCP\IRequestId; use OCP\IServerContainer; +use OCP\IServerInfo; use OCP\ISession; use OCP\ITagManager; use OCP\ITempManager; @@ -1146,6 +1147,9 @@ class Server extends ServerContainer implements IServerContainer { return $c->get(FileSequence::class); }, false); + $this->registerAlias(ISnowflakeDecoder::class, SnowflakeDecoder::class); + $this->registerAlias(IJobRuns::class, JobRuns::class); + $this->registerAlias(IServerInfo::class, ServerInfo::class); $this->connectDispatcher(); } diff --git a/lib/private/ServerInfo.php b/lib/private/ServerInfo.php new file mode 100644 index 00000000000..bf5dec468f2 --- /dev/null +++ b/lib/private/ServerInfo.php @@ -0,0 +1,48 @@ +config->getSystemValueInt('serverid', -1); + if ($serverid < 1) { + // Fallback: generates a server ID based on hostname + /** @var int<0,max> */ + $serverid = PHP_INT_SIZE === 4 + ? hexdec(hash('xxh32', $this->getHostname())) + // Makes sure it doesn't overflow 32 bits int + : hexdec(substr(hash('xxh32', $this->getHostname()), -3)); + } + + /** @var int<0,511> */ + return $serverid & 0x1FF; + } + + #[Override] + public function getHostname(): string { + $hostname = gethostname(); + if ($hostname === false) { + // Use a random hostname + return bin2hex(random_bytes(8)); + } + + return $hostname; + } +} diff --git a/lib/private/Snowflake/SnowflakeGenerator.php b/lib/private/Snowflake/SnowflakeGenerator.php index fa07f398583..bad8a2288da 100644 --- a/lib/private/Snowflake/SnowflakeGenerator.php +++ b/lib/private/Snowflake/SnowflakeGenerator.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace OC\Snowflake; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\IConfig; +use OCP\IServerInfo; use OCP\Snowflake\ISnowflakeGenerator; use Override; @@ -24,8 +24,8 @@ use Override; final readonly class SnowflakeGenerator implements ISnowflakeGenerator { public function __construct( private ITimeFactory $timeFactory, - private IConfig $config, private ISequence $sequenceGenerator, + private IServerInfo $serverInfo, ) { } @@ -34,7 +34,7 @@ final readonly class SnowflakeGenerator implements ISnowflakeGenerator { // Relative time [$seconds, $milliseconds] = $this->getCurrentTime(); - $serverId = $this->getServerId(); // Already 9 bits + $serverId = $this->serverInfo->getServerId(); $isCli = (int)$this->isCli(); // 1 bit $sequenceId = $this->sequenceGenerator->nextId($seconds, $milliseconds, $serverId); // 12 bits if ($sequenceId > 0xFFF || $sequenceId === false) { @@ -102,24 +102,6 @@ final readonly class SnowflakeGenerator implements ISnowflakeGenerator { ]; } - /** - * Return configured serverid or generate one if not set - * - * @return int<0,511> - */ - private function getServerId(): int { - $serverid = $this->config->getSystemValueInt('serverid', -1); - if ($serverid < 1) { - // Fallback: generates a server ID based on hostname - // or random bytes if hostname isn't available - /** @var int<0,max> */ - $serverid = hexdec(hash('xxh32', gethostname() ?: random_bytes(8))); - } - - /** @var int<0,511> */ - return $serverid & 0x1FF; - } - private function isCli(): bool { return PHP_SAPI === 'cli'; } diff --git a/lib/public/IServerInfo.php b/lib/public/IServerInfo.php new file mode 100644 index 00000000000..82e3cb7e8e3 --- /dev/null +++ b/lib/public/IServerInfo.php @@ -0,0 +1,33 @@ + + * @since 34.0.1 + */ + public function getServerId(): int; + + /** + * Returns current server hostname + * + * @since 34.0.1 + */ + public function getHostname(): string; +} diff --git a/lib/public/Util.php b/lib/public/Util.php index 9fc9b9e7597..e03712d2a82 100644 --- a/lib/public/Util.php +++ b/lib/public/Util.php @@ -246,9 +246,7 @@ class Util { */ public static function linkToAbsolute($app, $file, $args = []) { $urlGenerator = Server::get(IURLGenerator::class); - return $urlGenerator->getAbsoluteURL( - $urlGenerator->linkTo($app, $file, $args) - ); + return $urlGenerator->getAbsoluteURL($urlGenerator->linkTo($app, $file, $args)); } /** @@ -483,7 +481,7 @@ class Util { * @since 4.5.0 */ public static function mb_array_change_key_case($input, $case = MB_CASE_LOWER, $encoding = 'UTF-8') { - $case = ($case !== MB_CASE_UPPER) ? MB_CASE_LOWER : MB_CASE_UPPER; + $case = $case !== MB_CASE_UPPER ? MB_CASE_LOWER : MB_CASE_UPPER; $ret = []; foreach ($input as $k => $v) { $ret[mb_convert_case($k, $case, $encoding)] = $v; @@ -518,7 +516,7 @@ class Util { $freeSpace = max($freeSpace, 0); return $freeSpace; } else { - return (INF > 0)? INF: PHP_INT_MAX; // work around https://bugs.php.net/bug.php?id=69188 + return INF > 0 ? INF : PHP_INT_MAX; // work around https://bugs.php.net/bug.php?id=69188 } } diff --git a/tests/lib/Snowflake/GeneratorTest.php b/tests/lib/Snowflake/GeneratorTest.php index c8fbc25da5f..6eac3a41078 100644 --- a/tests/lib/Snowflake/GeneratorTest.php +++ b/tests/lib/Snowflake/GeneratorTest.php @@ -14,7 +14,8 @@ use OC\Snowflake\ISequence; use OC\Snowflake\SnowflakeDecoder; use OC\Snowflake\SnowflakeGenerator; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\IConfig; +use OCP\IServerInfo; +use OCP\Server; use OCP\Snowflake\ISnowflakeGenerator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -25,8 +26,8 @@ use Test\TestCase; */ class GeneratorTest extends TestCase { private SnowflakeDecoder $decoder; - private IConfig&MockObject $config; private ISequence&MockObject $sequence; + private IServerInfo $serverInfo; #[\Override] public function setUp(): void { @@ -34,16 +35,15 @@ class GeneratorTest extends TestCase { $this->decoder = new SnowflakeDecoder(); - $this->config = $this->createMock(IConfig::class); - $this->config->method('getSystemValueInt')->with('serverid')->willReturn(42); - $this->sequence = $this->createMock(ISequence::class); $this->sequence->method('isAvailable')->willReturn(true); $this->sequence->method('nextId')->willReturn(421); + + $this->serverInfo = Server::get(IServerInfo::class); } public function testGenerator(): void { - $generator = new SnowflakeGenerator(new TimeFactory(), $this->config, $this->sequence); + $generator = new SnowflakeGenerator(new TimeFactory(), $this->sequence, $this->serverInfo); $snowflakeId = $generator->nextId(); $data = $this->decoder->decode($generator->nextId()); @@ -63,7 +63,7 @@ class GeneratorTest extends TestCase { $this->assertTrue($data->isCli()); // Check serverId - $this->assertEquals(42, $data->getServerId()); + $this->assertEquals($this->serverInfo->getServerId(), $data->getServerId()); } #[DataProvider('provideSnowflakeData')] @@ -72,12 +72,12 @@ class GeneratorTest extends TestCase { $timeFactory = $this->createMock(ITimeFactory::class); $timeFactory->method('now')->willReturn($dt); - $generator = new SnowflakeGenerator($timeFactory, $this->config, $this->sequence); + $generator = new SnowflakeGenerator($timeFactory, $this->sequence, $this->serverInfo); $data = $this->decoder->decode($generator->nextId()); $this->assertEquals($expectedSeconds, $data->getCreatedAt()->format('U') - ISnowflakeGenerator::TS_OFFSET); $this->assertEquals($expectedMilliseconds, (int)$data->getCreatedAt()->format('v')); - $this->assertEquals(42, $data->getServerId()); + $this->assertEquals($this->serverInfo->getServerId(), $data->getServerId()); } public static function provideSnowflakeData(): array { From 3956e292b4cad6d3f6f36a7fa007fbd862ffbe21 Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Wed, 3 Jun 2026 18:27:46 +0200 Subject: [PATCH 2/4] feat(jobs): add cleanup job for job run history Signed-off-by: Benjamin Gaussorgues --- .../CleanupBackgroundJobsJob.php | 24 ++++++++++++------- lib/composer/composer/autoload_classmap.php | 2 ++ lib/composer/composer/autoload_static.php | 2 ++ lib/private/Repair.php | 6 +++-- lib/private/Setup.php | 2 ++ 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/core/BackgroundJobs/CleanupBackgroundJobsJob.php b/core/BackgroundJobs/CleanupBackgroundJobsJob.php index 69f4eb8fed6..e81242fc6f0 100644 --- a/core/BackgroundJobs/CleanupBackgroundJobsJob.php +++ b/core/BackgroundJobs/CleanupBackgroundJobsJob.php @@ -18,6 +18,7 @@ use OCP\BackgroundJob\TimedJob; use OCP\IConfig; use OCP\IServerInfo; use Override; +use Psr\Log\LoggerInterface; class CleanupBackgroundJobsJob extends TimedJob { public function __construct( @@ -28,7 +29,7 @@ class CleanupBackgroundJobsJob extends TimedJob { ) { parent::__construct($time); $this->setInterval(60 * 60); - $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + $this->setTimeSensitivity(IJob::TIME_SENSITIVE); } #[Override] @@ -45,17 +46,24 @@ class CleanupBackgroundJobsJob extends TimedJob { if ($job->serverId !== $currentServerId) { continue; } + $output = []; + $result = 0; exec('ps -p ' . escapeshellarg((string)$job->pid) . ' -o cmd', $output, $result); - if (count($output) === 1 && $output[0] === 'CMD' && $result === 1) { + if (count($output) === 1 && current($output) === 'CMD' && $result === 1) { // Process doesn't exists anymore $maxDuration = (new DateTimeImmutable())->diff($job->startedAt); - $maxDuration = - ($maxDuration->format('%a') * 24 * 60 * 60 * 1000) - + ($maxDuration->format('%h') * 60 * 60 * 1000) - + ($maxDuration->format('%m') * 60 * 1000) - + ($maxDuration->format('%s') * 1000) - + (int)($maxDuration->format('%f') / 1000); + $maxDuration + = ($maxDuration->days * 24 * 60 * 60 * 1000) + + ($maxDuration->h * 60 * 60 * 1000) + + ($maxDuration->i * 60 * 1000) + + ($maxDuration->s * 1000) + + (int)($maxDuration->f * 1000); $this->jobRuns->finished($job->runId, $maxDuration, 0, JobStatus::CRASHED); + $this->logger->warning('No process matching PID {pid} found on server {serverId}. Job {runId} was marked as crashed', [ + 'pid' => $job->pid, + 'serverId' => $job->serverId, + 'runId' => $job->runId, + ]); } } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 7f18f4d7cc3..159d2b3abbf 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1312,6 +1312,7 @@ return array( 'OC\\Core\\AppInfo\\ConfigLexicon' => $baseDir . '/core/AppInfo/ConfigLexicon.php', 'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => $baseDir . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php', 'OC\\Core\\BackgroundJobs\\CheckForUserCertificates' => $baseDir . '/core/BackgroundJobs/CheckForUserCertificates.php', + 'OC\\Core\\BackgroundJobs\\CleanupBackgroundJobsJob' => $baseDir . '/core/BackgroundJobs/CleanupBackgroundJobsJob.php', 'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => $baseDir . '/core/BackgroundJobs/CleanupLoginFlowV2.php', 'OC\\Core\\BackgroundJobs\\ExpirePreviewsJob' => $baseDir . '/core/BackgroundJobs/ExpirePreviewsJob.php', 'OC\\Core\\BackgroundJobs\\GenerateMetadataJob' => $baseDir . '/core/BackgroundJobs/GenerateMetadataJob.php', @@ -2052,6 +2053,7 @@ return array( 'OC\\Repair' => $baseDir . '/lib/private/Repair.php', 'OC\\RepairException' => $baseDir . '/lib/private/RepairException.php', 'OC\\Repair\\AddBruteForceCleanupJob' => $baseDir . '/lib/private/Repair/AddBruteForceCleanupJob.php', + 'OC\\Repair\\AddCleanupBackgroundJobsJob' => $baseDir . '/lib/private/Repair/AddCleanupBackgroundJobsJob.php', 'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => $baseDir . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php', 'OC\\Repair\\AddCleanupUpdaterBackupsJob' => $baseDir . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php', 'OC\\Repair\\AddMetadataGenerationJob' => $baseDir . '/lib/private/Repair/AddMetadataGenerationJob.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 9f739dbbca2..2376057d7c3 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1353,6 +1353,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\AppInfo\\ConfigLexicon' => __DIR__ . '/../../..' . '/core/AppInfo/ConfigLexicon.php', 'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php', 'OC\\Core\\BackgroundJobs\\CheckForUserCertificates' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CheckForUserCertificates.php', + 'OC\\Core\\BackgroundJobs\\CleanupBackgroundJobsJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CleanupBackgroundJobsJob.php', 'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CleanupLoginFlowV2.php', 'OC\\Core\\BackgroundJobs\\ExpirePreviewsJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/ExpirePreviewsJob.php', 'OC\\Core\\BackgroundJobs\\GenerateMetadataJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/GenerateMetadataJob.php', @@ -2093,6 +2094,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Repair' => __DIR__ . '/../../..' . '/lib/private/Repair.php', 'OC\\RepairException' => __DIR__ . '/../../..' . '/lib/private/RepairException.php', 'OC\\Repair\\AddBruteForceCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddBruteForceCleanupJob.php', + 'OC\\Repair\\AddCleanupBackgroundJobsJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupBackgroundJobsJob.php', 'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php', 'OC\\Repair\\AddCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php', 'OC\\Repair\\AddMetadataGenerationJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddMetadataGenerationJob.php', diff --git a/lib/private/Repair.php b/lib/private/Repair.php index d25a12ccd9d..b323997f507 100644 --- a/lib/private/Repair.php +++ b/lib/private/Repair.php @@ -9,6 +9,7 @@ namespace OC; use OC\Repair\AddBruteForceCleanupJob; +use OC\Repair\AddCleanupBackgroundJobsJob; use OC\Repair\AddCleanupDeletedUsersBackgroundJob; use OC\Repair\AddCleanupUpdaterBackupsJob; use OC\Repair\AddMetadataGenerationJob; @@ -134,14 +135,14 @@ class Repair implements IOutput { } } - if (!($s instanceof IRepairStep)) { + if (!$s instanceof IRepairStep) { throw new \Exception("Repair step '$repairStep' is not of type \\OCP\\Migration\\IRepairStep"); } $repairStep = $s; } - if (($repairStep instanceof IRepairStepExpensive) && !$includeExpensive) { + if ($repairStep instanceof IRepairStepExpensive && !$includeExpensive) { $this->debug("Skipping expensive repair step '" . $repairStep::class . "'"); } else { $this->repairSteps[] = $repairStep; @@ -195,6 +196,7 @@ class Repair implements IOutput { Server::get(SanitizeAccountProperties::class), Server::get(AddMovePreviewJob::class), Server::get(ConfigKeyMigration::class), + Server::get(AddCleanupBackgroundJobsJob::class), ]; if ($includeExpensive) { diff --git a/lib/private/Setup.php b/lib/private/Setup.php index 55090a9eff8..74b0686dd7b 100644 --- a/lib/private/Setup.php +++ b/lib/private/Setup.php @@ -16,6 +16,7 @@ use InvalidArgumentException; use OC\AppFramework\Bootstrap\Coordinator; use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Authentication\Token\TokenCleanupJob; +use OC\Core\BackgroundJobs\CleanupBackgroundJobsJob; use OC\Core\BackgroundJobs\ExpirePreviewsJob; use OC\Core\BackgroundJobs\GenerateMetadataJob; use OC\Core\BackgroundJobs\PreviewMigrationJob; @@ -532,6 +533,7 @@ class Setup { $jobList->add(GenerateMetadataJob::class); $jobList->add(PreviewMigrationJob::class); $jobList->add(ExpirePreviewsJob::class); + $jobList->add(CleanupBackgroundJobsJob::class); } /** From 28d32d8fff88ff0c37938f2b7d4feb21a3e32b4e Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Thu, 4 Jun 2026 14:53:36 +0200 Subject: [PATCH 3/4] feat(snowflake): allows to generate Snowflake IDs matching a timestamp Signed-off-by: Benjamin Gaussorgues --- lib/private/Snowflake/SnowflakeGenerator.php | 17 +++++++++++++++++ lib/public/Snowflake/ISnowflakeGenerator.php | 14 ++++++++++++++ tests/lib/Snowflake/GeneratorTest.php | 19 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/lib/private/Snowflake/SnowflakeGenerator.php b/lib/private/Snowflake/SnowflakeGenerator.php index bad8a2288da..a247700ebde 100644 --- a/lib/private/Snowflake/SnowflakeGenerator.php +++ b/lib/private/Snowflake/SnowflakeGenerator.php @@ -43,6 +43,23 @@ final readonly class SnowflakeGenerator implements ISnowflakeGenerator { return $this->nextId(); } + return $this->packSnowflakeId($seconds, $milliseconds, $serverId, $isCli, $sequenceId); + } + + /** + * Return minimal snowflake ID for a given timestamp + * + * Not a real snowflake ID! + * Only use it for comparisons. For example get all snowflake IDs generated before $timestamp + * + * @since 34.0.1 + */ + #[Override] + public function minForTimeId(int $timestamp): string { + return $this->packSnowflakeId($timestamp - self::TS_OFFSET, 0, 0, 0, 0); + } + + private function packSnowflakeId($seconds, $milliseconds, $serverId, $isCli, $sequenceId): string { if (PHP_INT_SIZE === 8) { $firstHalf = $seconds & 0x7FFFFFFF; $secondHalf = (($milliseconds & 0x3FF) << 22) | ($serverId << 13) | ($isCli << 12) | $sequenceId; diff --git a/lib/public/Snowflake/ISnowflakeGenerator.php b/lib/public/Snowflake/ISnowflakeGenerator.php index 276e5dc52fa..f3a8a4cabd8 100644 --- a/lib/public/Snowflake/ISnowflakeGenerator.php +++ b/lib/public/Snowflake/ISnowflakeGenerator.php @@ -42,4 +42,18 @@ interface ISnowflakeGenerator { * @since 33.0 */ public function nextId(): string; + + /** + * Return the smallest possible Snowflake ID for a given timestamp + * + * Not a real snowflake ID! + * Only use it for comparisons. Examples: + * - find all Snowflake IDs generated from a given $timestamp + * Look for `>= minForTimeId($timestamp)` + * - delete all Snowflake IDs generated before a given $timestamp + * Delete where `id < minForTimeId($timestamp)` + * + * @since 34.0.1 + */ + public function minForTimeId(int $timestamp): string; } diff --git a/tests/lib/Snowflake/GeneratorTest.php b/tests/lib/Snowflake/GeneratorTest.php index 6eac3a41078..3323773105a 100644 --- a/tests/lib/Snowflake/GeneratorTest.php +++ b/tests/lib/Snowflake/GeneratorTest.php @@ -66,6 +66,25 @@ class GeneratorTest extends TestCase { $this->assertEquals($this->serverInfo->getServerId(), $data->getServerId()); } + public function testMinForTime(): void { + $generator = new SnowflakeGenerator(new TimeFactory(), $this->sequence, $this->serverInfo); + $now = time(); + $snowflakeId = $generator->minForTimeId($now); + $data = $this->decoder->decode($snowflakeId); + + $this->assertIsString($snowflakeId); + + // Check timestamp + $this->assertEquals($now - ISnowflakeGenerator::TS_OFFSET, $data->getSeconds()); + + // Check all other fields are at zero + $this->assertEquals(0, $data->getMilliseconds()); + $this->assertEquals(0, $data->getServerId()); + $this->assertEquals(0, $data->getSequenceId()); + $this->assertFalse($data->isCli()); + $this->assertEquals(0, $data->getServerId()); + } + #[DataProvider('provideSnowflakeData')] public function testGeneratorWithFixedTime(string $date, int $expectedSeconds, int $expectedMilliseconds): void { $dt = new \DateTimeImmutable($date); From dc5499af4699d6adfbd30f60bc2df26012f8d39d Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Thu, 4 Jun 2026 20:30:47 +0200 Subject: [PATCH 4/4] feat(jobs): clean old job runs Signed-off-by: Benjamin Gaussorgues --- config/config.sample.php | 8 ++++++++ .../CleanupBackgroundJobsJob.php | 20 +++++++++++++++++-- lib/private/BackgroundJob/JobRuns.php | 12 +++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/config/config.sample.php b/config/config.sample.php index b663f0bed47..91ac3e379bd 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -3002,4 +3002,12 @@ $CONFIG = [ * Defaults to ``0``. */ 'preview_expiration_days' => 0, + + /** + * Delete job runs older than a certain number of days. + * Less than one day is not allowed. + * + * Defaults to ``60``. + */ + 'background_jobs_expiration_days' => 60, ]; diff --git a/core/BackgroundJobs/CleanupBackgroundJobsJob.php b/core/BackgroundJobs/CleanupBackgroundJobsJob.php index e81242fc6f0..6242941eed1 100644 --- a/core/BackgroundJobs/CleanupBackgroundJobsJob.php +++ b/core/BackgroundJobs/CleanupBackgroundJobsJob.php @@ -19,12 +19,14 @@ use OCP\IConfig; use OCP\IServerInfo; use Override; use Psr\Log\LoggerInterface; +use RuntimeException; class CleanupBackgroundJobsJob extends TimedJob { public function __construct( ITimeFactory $time, private readonly JobRuns $jobRuns, private readonly IServerInfo $serverInfo, + private readonly IConfig $config, private readonly LoggerInterface $logger, ) { parent::__construct($time); @@ -35,8 +37,7 @@ class CleanupBackgroundJobsJob extends TimedJob { #[Override] protected function run($argument): void { $this->reapCrashedJobs(); - - // TODO Clean oldest jobs + $this->cleanOldestRuns(); } private function reapCrashedJobs(): void { @@ -67,4 +68,19 @@ class CleanupBackgroundJobsJob extends TimedJob { } } } + + private function cleanOldestRuns(): void { + $daysToKeep = $this->config->getSystemValueInt('background_jobs_expiration_days', 60); + if ($daysToKeep < 1) { + throw new RuntimeException('Invalid number of days'); + } + $cleanBeforeTimestamp = time() - ($daysToKeep * 24 * 3600); + + $cleanedJobs = $this->jobRuns->deleteBefore($cleanBeforeTimestamp); + if ($cleanedJobs > 0) { + $this->logger->info( + 'Cleanup of old background jobs. Number of jobs removed: ' . $cleanedJobs . 'Reason: older than ' . $daysToKeep . ' days.', + ); + } + } } diff --git a/lib/private/BackgroundJob/JobRuns.php b/lib/private/BackgroundJob/JobRuns.php index 7a19bdf001a..a2612f6655e 100644 --- a/lib/private/BackgroundJob/JobRuns.php +++ b/lib/private/BackgroundJob/JobRuns.php @@ -62,6 +62,18 @@ final readonly class JobRuns implements IJobRuns { return $result === 1; } + public function deleteBefore(int $timestamp): int { + $beforeSnowflake = $this->snowflakeGenerator->minForTimeId($timestamp); + $beforeSnowflake = '91480652934574081'; + $qb = $this->connection->getQueryBuilder(); + $result = $qb + ->delete(self::TABLE) + ->where($qb->expr()->lt('run_id', $qb->createNamedParameter($beforeSnowflake))) + ->executeStatement(); + + return $result; + } + #[Override] public function runningJobs(int $limit = 200): \Generator { $qb = $this->connection->getQueryBuilder();