mirror of
https://github.com/nextcloud/server.git
synced 2026-06-12 18:21:40 -04:00
Merge pull request #60972 from nextcloud/feat/clean_crashed_jobs
Job run history cleanup
This commit is contained in:
commit
e1eea9bee7
17 changed files with 309 additions and 43 deletions
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
86
core/BackgroundJobs/CleanupBackgroundJobsJob.php
Normal file
86
core/BackgroundJobs/CleanupBackgroundJobsJob.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Core\BackgroundJobs;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use OC\BackgroundJob\JobRuns;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\JobStatus;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
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);
|
||||
$this->setInterval(60 * 60);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function run($argument): void {
|
||||
$this->reapCrashedJobs();
|
||||
$this->cleanOldestRuns();
|
||||
}
|
||||
|
||||
private function reapCrashedJobs(): void {
|
||||
$currentServerId = $this->serverInfo->getServerId();
|
||||
|
||||
foreach ($this->jobRuns->runningJobs(1000) as $job) {
|
||||
if ($job->serverId !== $currentServerId) {
|
||||
continue;
|
||||
}
|
||||
$output = [];
|
||||
$result = 0;
|
||||
exec('ps -p ' . escapeshellarg((string)$job->pid) . ' -o cmd', $output, $result);
|
||||
if (count($output) === 1 && current($output) === 'CMD' && $result === 1) {
|
||||
// Process doesn't exists anymore
|
||||
$maxDuration = (new DateTimeImmutable())->diff($job->startedAt);
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -1311,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',
|
||||
|
|
@ -2051,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',
|
||||
|
|
@ -2173,6 +2176,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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -1352,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',
|
||||
|
|
@ -2092,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',
|
||||
|
|
@ -2214,6 +2217,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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
33
lib/private/Repair/AddCleanupBackgroundJobsJob.php
Normal file
33
lib/private/Repair/AddCleanupBackgroundJobsJob.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Repair;
|
||||
|
||||
use OC\Core\BackgroundJobs\CleanupBackgroundJobsJob;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\IRepairStep;
|
||||
use Override;
|
||||
|
||||
class AddCleanupBackgroundJobsJob implements IRepairStep {
|
||||
public function __construct(
|
||||
private readonly IJobList $jobList,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getName(): string {
|
||||
return 'Cleanup completed background jobs';
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function run(IOutput $output): void {
|
||||
$this->jobList->add(CleanupBackgroundJobsJob::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
48
lib/private/ServerInfo.php
Normal file
48
lib/private/ServerInfo.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC;
|
||||
|
||||
use OCP\IConfig;
|
||||
use OCP\IServerInfo;
|
||||
use Override;
|
||||
|
||||
readonly class ServerInfo implements IServerInfo {
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getServerId(): int {
|
||||
$serverid = $this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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;
|
||||
|
|
@ -102,24 +119,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';
|
||||
}
|
||||
|
|
|
|||
33
lib/public/IServerInfo.php
Normal file
33
lib/public/IServerInfo.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCP;
|
||||
|
||||
use OCP\AppFramework\Attribute\Consumable;
|
||||
|
||||
/**
|
||||
* @since 34.0.1
|
||||
*/
|
||||
#[Consumable(since: '34.0.1')]
|
||||
interface IServerInfo {
|
||||
/**
|
||||
* Returns configured Server ID or use default fallback
|
||||
*
|
||||
* @return int<0,511>
|
||||
* @since 34.0.1
|
||||
*/
|
||||
public function getServerId(): int;
|
||||
|
||||
/**
|
||||
* Returns current server hostname
|
||||
*
|
||||
* @since 34.0.1
|
||||
*/
|
||||
public function getHostname(): string;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,26 @@ class GeneratorTest extends TestCase {
|
|||
$this->assertTrue($data->isCli());
|
||||
|
||||
// Check serverId
|
||||
$this->assertEquals(42, $data->getServerId());
|
||||
$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')]
|
||||
|
|
@ -72,12 +91,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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue