mirror of
https://github.com/nextcloud/server.git
synced 2026-02-03 20:41:22 -05:00
feat(database): introduce Snowflake IDs generator
Signed-off-by: Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>
This commit is contained in:
parent
7a3ed09822
commit
c9b055a0d0
15 changed files with 570 additions and 9 deletions
12
.github/workflows/phpunit-32bits.yml
vendored
12
.github/workflows/phpunit-32bits.yml
vendored
|
|
@ -5,9 +5,10 @@ name: PHPUnit 32bits
|
|||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'version.php'
|
||||
- '.github/workflows/phpunit-32bits.yml'
|
||||
- 'tests/phpunit-autotest.xml'
|
||||
- "version.php"
|
||||
- ".github/workflows/phpunit-32bits.yml"
|
||||
- "tests/phpunit-autotest.xml"
|
||||
- "lib/private/Snowflake/*"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "15 1 * * 1-6"
|
||||
|
|
@ -30,7 +31,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-versions: ['8.2', '8.3', '8.4']
|
||||
php-versions: ["8.2", "8.3", "8.4"]
|
||||
|
||||
steps:
|
||||
- name: Checkout server
|
||||
|
|
@ -51,8 +52,7 @@ jobs:
|
|||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, imagick, intl, json, libxml, mbstring, openssl, pcntl, posix, redis, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite, apcu, ldap
|
||||
coverage: none
|
||||
ini-file: development
|
||||
ini-values:
|
||||
apc.enabled=on, apc.enable_cli=on, disable_functions= # https://github.com/shivammathur/setup-php/discussions/573
|
||||
ini-values: apc.enabled=on, apc.enable_cli=on, disable_functions= # https://github.com/shivammathur/setup-php/discussions/573
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class PhpModules implements ISetupCheck {
|
|||
'zlib',
|
||||
];
|
||||
protected const RECOMMENDED_MODULES = [
|
||||
'apcu',
|
||||
'exif',
|
||||
'gmp',
|
||||
'intl',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
}
|
||||
},
|
||||
"autoload": {
|
||||
"exclude-from-classmap": ["**/bamarni/composer-bin-plugin/**"],
|
||||
"exclude-from-classmap": [
|
||||
"**/bamarni/composer-bin-plugin/**"
|
||||
],
|
||||
"files": [
|
||||
"lib/public/Log/functions.php"
|
||||
],
|
||||
|
|
@ -25,6 +27,7 @@
|
|||
},
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-apcu": "*",
|
||||
"ext-ctype": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-dom": "*",
|
||||
|
|
|
|||
48
core/Command/SnowflakeDecodeId.php
Normal file
48
core/Command/SnowflakeDecodeId.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
namespace OC\Core\Command;
|
||||
|
||||
use OC\Snowflake\Decoder;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class SnowflakeDecodeId extends Base {
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('decode-snowflake')
|
||||
->setDescription('Decode Snowflake IDs used by Nextcloud')
|
||||
->addArgument('snowflake-id', InputArgument::REQUIRED, 'Nextcloud Snowflake ID to decode');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$snowflakeId = $input->getArgument('snowflake-id');
|
||||
$data = (new Decoder)->decode($snowflakeId);
|
||||
|
||||
$rows = [
|
||||
['Snowflake ID', $snowflakeId],
|
||||
['Seconds', $data['seconds']],
|
||||
['Milliseconds', $data['milliseconds']],
|
||||
['Created from CLI', $data['isCli'] ? 'yes' : 'no'],
|
||||
['Server ID', $data['serverId']],
|
||||
['Sequence ID', $data['sequenceId']],
|
||||
['Creation timestamp', $data['createdAt']->format('U.v')],
|
||||
['Creation date', $data['createdAt']->format('Y-m-d H:i:s.v')],
|
||||
];
|
||||
|
||||
$table = new Table($output);
|
||||
$table->setRows($rows);
|
||||
$table->render();
|
||||
|
||||
return Base::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ use OC\Core\Command\Security\ImportCertificate;
|
|||
use OC\Core\Command\Security\ListCertificates;
|
||||
use OC\Core\Command\Security\RemoveCertificate;
|
||||
use OC\Core\Command\SetupChecks;
|
||||
use OC\Core\Command\SnowflakeDecodeId;
|
||||
use OC\Core\Command\Status;
|
||||
use OC\Core\Command\SystemTag\Edit;
|
||||
use OC\Core\Command\TaskProcessing\EnabledCommand;
|
||||
|
|
@ -246,6 +247,7 @@ if ($config->getSystemValueBool('installed', false)) {
|
|||
$application->add(Server::get(BruteforceAttempts::class));
|
||||
$application->add(Server::get(BruteforceResetAttempts::class));
|
||||
$application->add(Server::get(SetupChecks::class));
|
||||
$application->add(Server::get(SnowflakeDecodeId::class));
|
||||
$application->add(Server::get(Get::class));
|
||||
|
||||
$application->add(Server::get(GetCommand::class));
|
||||
|
|
|
|||
|
|
@ -822,6 +822,8 @@ return array(
|
|||
'OCP\\Share_Backend' => $baseDir . '/lib/public/Share_Backend.php',
|
||||
'OCP\\Share_Backend_Collection' => $baseDir . '/lib/public/Share_Backend_Collection.php',
|
||||
'OCP\\Share_Backend_File_Dependent' => $baseDir . '/lib/public/Share_Backend_File_Dependent.php',
|
||||
'OCP\\Snowflake\\IDecoder' => $baseDir . '/lib/public/Snowflake/IDecoder.php',
|
||||
'OCP\\Snowflake\\IGenerator' => $baseDir . '/lib/public/Snowflake/IGenerator.php',
|
||||
'OCP\\SpeechToText\\Events\\AbstractTranscriptionEvent' => $baseDir . '/lib/public/SpeechToText/Events/AbstractTranscriptionEvent.php',
|
||||
'OCP\\SpeechToText\\Events\\TranscriptionFailedEvent' => $baseDir . '/lib/public/SpeechToText/Events/TranscriptionFailedEvent.php',
|
||||
'OCP\\SpeechToText\\Events\\TranscriptionSuccessfulEvent' => $baseDir . '/lib/public/SpeechToText/Events/TranscriptionSuccessfulEvent.php',
|
||||
|
|
@ -1352,6 +1354,7 @@ return array(
|
|||
'OC\\Core\\Command\\Security\\ListCertificates' => $baseDir . '/core/Command/Security/ListCertificates.php',
|
||||
'OC\\Core\\Command\\Security\\RemoveCertificate' => $baseDir . '/core/Command/Security/RemoveCertificate.php',
|
||||
'OC\\Core\\Command\\SetupChecks' => $baseDir . '/core/Command/SetupChecks.php',
|
||||
'OC\\Core\\Command\\SnowflakeDecodeId' => $baseDir . '/core/Command/SnowflakeDecodeId.php',
|
||||
'OC\\Core\\Command\\Status' => $baseDir . '/core/Command/Status.php',
|
||||
'OC\\Core\\Command\\SystemTag\\Add' => $baseDir . '/core/Command/SystemTag/Add.php',
|
||||
'OC\\Core\\Command\\SystemTag\\Delete' => $baseDir . '/core/Command/SystemTag/Delete.php',
|
||||
|
|
@ -2103,6 +2106,8 @@ return array(
|
|||
'OC\\Share\\Constants' => $baseDir . '/lib/private/Share/Constants.php',
|
||||
'OC\\Share\\Helper' => $baseDir . '/lib/private/Share/Helper.php',
|
||||
'OC\\Share\\Share' => $baseDir . '/lib/private/Share/Share.php',
|
||||
'OC\\Snowflake\\Decoder' => $baseDir . '/lib/private/Snowflake/Decoder.php',
|
||||
'OC\\Snowflake\\Generator' => $baseDir . '/lib/private/Snowflake/Generator.php',
|
||||
'OC\\SpeechToText\\SpeechToTextManager' => $baseDir . '/lib/private/SpeechToText/SpeechToTextManager.php',
|
||||
'OC\\SpeechToText\\TranscriptionJob' => $baseDir . '/lib/private/SpeechToText/TranscriptionJob.php',
|
||||
'OC\\StreamImage' => $baseDir . '/lib/private/StreamImage.php',
|
||||
|
|
|
|||
|
|
@ -863,6 +863,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OCP\\Share_Backend' => __DIR__ . '/../../..' . '/lib/public/Share_Backend.php',
|
||||
'OCP\\Share_Backend_Collection' => __DIR__ . '/../../..' . '/lib/public/Share_Backend_Collection.php',
|
||||
'OCP\\Share_Backend_File_Dependent' => __DIR__ . '/../../..' . '/lib/public/Share_Backend_File_Dependent.php',
|
||||
'OCP\\Snowflake\\IDecoder' => __DIR__ . '/../../..' . '/lib/public/Snowflake/IDecoder.php',
|
||||
'OCP\\Snowflake\\IGenerator' => __DIR__ . '/../../..' . '/lib/public/Snowflake/IGenerator.php',
|
||||
'OCP\\SpeechToText\\Events\\AbstractTranscriptionEvent' => __DIR__ . '/../../..' . '/lib/public/SpeechToText/Events/AbstractTranscriptionEvent.php',
|
||||
'OCP\\SpeechToText\\Events\\TranscriptionFailedEvent' => __DIR__ . '/../../..' . '/lib/public/SpeechToText/Events/TranscriptionFailedEvent.php',
|
||||
'OCP\\SpeechToText\\Events\\TranscriptionSuccessfulEvent' => __DIR__ . '/../../..' . '/lib/public/SpeechToText/Events/TranscriptionSuccessfulEvent.php',
|
||||
|
|
@ -1393,6 +1395,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OC\\Core\\Command\\Security\\ListCertificates' => __DIR__ . '/../../..' . '/core/Command/Security/ListCertificates.php',
|
||||
'OC\\Core\\Command\\Security\\RemoveCertificate' => __DIR__ . '/../../..' . '/core/Command/Security/RemoveCertificate.php',
|
||||
'OC\\Core\\Command\\SetupChecks' => __DIR__ . '/../../..' . '/core/Command/SetupChecks.php',
|
||||
'OC\\Core\\Command\\SnowflakeDecodeId' => __DIR__ . '/../../..' . '/core/Command/SnowflakeDecodeId.php',
|
||||
'OC\\Core\\Command\\Status' => __DIR__ . '/../../..' . '/core/Command/Status.php',
|
||||
'OC\\Core\\Command\\SystemTag\\Add' => __DIR__ . '/../../..' . '/core/Command/SystemTag/Add.php',
|
||||
'OC\\Core\\Command\\SystemTag\\Delete' => __DIR__ . '/../../..' . '/core/Command/SystemTag/Delete.php',
|
||||
|
|
@ -2144,6 +2147,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OC\\Share\\Constants' => __DIR__ . '/../../..' . '/lib/private/Share/Constants.php',
|
||||
'OC\\Share\\Helper' => __DIR__ . '/../../..' . '/lib/private/Share/Helper.php',
|
||||
'OC\\Share\\Share' => __DIR__ . '/../../..' . '/lib/private/Share/Share.php',
|
||||
'OC\\Snowflake\\Decoder' => __DIR__ . '/../../..' . '/lib/private/Snowflake/Decoder.php',
|
||||
'OC\\Snowflake\\Generator' => __DIR__ . '/../../..' . '/lib/private/Snowflake/Generator.php',
|
||||
'OC\\SpeechToText\\SpeechToTextManager' => __DIR__ . '/../../..' . '/lib/private/SpeechToText/SpeechToTextManager.php',
|
||||
'OC\\SpeechToText\\TranscriptionJob' => __DIR__ . '/../../..' . '/lib/private/SpeechToText/TranscriptionJob.php',
|
||||
'OC\\StreamImage' => __DIR__ . '/../../..' . '/lib/private/StreamImage.php',
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
|
||||
$issues = array();
|
||||
|
||||
if (!(PHP_VERSION_ID >= 80100)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.';
|
||||
if (!(PHP_VERSION_ID >= 80200)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.';
|
||||
}
|
||||
|
||||
if ($issues) {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ use OC\Settings\DeclarativeManager;
|
|||
use OC\SetupCheck\SetupCheckManager;
|
||||
use OC\Share20\ProviderFactory;
|
||||
use OC\Share20\ShareHelper;
|
||||
use OC\Snowflake\Decoder;
|
||||
use OC\Snowflake\Generator;
|
||||
use OC\SpeechToText\SpeechToTextManager;
|
||||
use OC\SystemTag\ManagerFactory as SystemTagManagerFactory;
|
||||
use OC\Talk\Broker;
|
||||
|
|
@ -222,6 +224,8 @@ use OCP\Settings\IDeclarativeManager;
|
|||
use OCP\SetupCheck\ISetupCheckManager;
|
||||
use OCP\Share\IProviderFactory;
|
||||
use OCP\Share\IShareHelper;
|
||||
use OCP\Snowflake\IDecoder;
|
||||
use OCP\Snowflake\IGenerator;
|
||||
use OCP\SpeechToText\ISpeechToTextManager;
|
||||
use OCP\SystemTag\ISystemTagManager;
|
||||
use OCP\SystemTag\ISystemTagObjectMapper;
|
||||
|
|
@ -1245,6 +1249,9 @@ class Server extends ServerContainer implements IServerContainer {
|
|||
|
||||
$this->registerAlias(ISignatureManager::class, SignatureManager::class);
|
||||
|
||||
$this->registerAlias(IGenerator::class, Generator::class);
|
||||
$this->registerAlias(IDecoder::class, Decoder::class);
|
||||
|
||||
$this->connectDispatcher();
|
||||
}
|
||||
|
||||
|
|
|
|||
122
lib/private/Snowflake/Decoder.php
Normal file
122
lib/private/Snowflake/Decoder.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
namespace OC\Snowflake;
|
||||
|
||||
use OCP\Snowflake\IDecoder;
|
||||
use OCP\Snowflake\IGenerator;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Nextcloud Snowflake ID
|
||||
*
|
||||
* Get information about Snowflake Id
|
||||
*
|
||||
* @since 33.0.0
|
||||
*/
|
||||
final class Decoder implements IDecoder {
|
||||
#[Override]
|
||||
public function decode(string $snowflakeId): array {
|
||||
if (!ctype_digit($snowflakeId)) {
|
||||
throw new \Exception('Invalid Snowflake ID: ' . $snowflakeId);
|
||||
}
|
||||
|
||||
/** @var array{seconds: positive-int, milliseconds: int<0,999>, serverId: int<0, 1023>, sequenceId: int<0,4095>, isCli: bool} $data */
|
||||
$data = PHP_INT_SIZE === 8
|
||||
? $this->decode64bits((int)$snowflakeId)
|
||||
: $this->decode32bits($snowflakeId);
|
||||
|
||||
$data['createdAt'] = new \DateTimeImmutable(
|
||||
sprintf(
|
||||
'@%d.%03d',
|
||||
$data['seconds'] + IGenerator::TS_OFFSET + intdiv($data['milliseconds'], 1000),
|
||||
$data['milliseconds'] % 1000,
|
||||
)
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function decode64bits(int $snowflakeId): array {
|
||||
$firstHalf = $snowflakeId >> 32;
|
||||
$secondHalf = $snowflakeId & 0xFFFFFFFF;
|
||||
|
||||
$seconds = $firstHalf & 0x7FFFFFFF;
|
||||
$milliseconds = $secondHalf >> 22;
|
||||
|
||||
return [
|
||||
'seconds' => $seconds,
|
||||
'milliseconds' => $milliseconds,
|
||||
'serverId' => ($secondHalf >> 13) & 0x1FF,
|
||||
'sequenceId' => $secondHalf & 0xFFF,
|
||||
'isCli' => (bool)(($secondHalf >> 12) & 0x1),
|
||||
];
|
||||
}
|
||||
|
||||
private function decode32bits(string $snowflakeId): array {
|
||||
$id = $this->convertBase16($snowflakeId);
|
||||
|
||||
$firstQuarter = (int)hexdec(substr($id, 0, 4));
|
||||
$secondQuarter = (int)hexdec(substr($id, 4, 4));
|
||||
$thirdQuarter = (int)hexdec(substr($id, 8, 4));
|
||||
$fourthQuarter = (int)hexdec(substr($id, 12, 4));
|
||||
|
||||
$seconds = (($firstQuarter & 0x7FFF) << 16) | ($secondQuarter & 0xFFFF);
|
||||
$milliseconds = ($thirdQuarter >> 6) & 0x3FF;
|
||||
|
||||
return [
|
||||
'seconds' => $seconds,
|
||||
'milliseconds' => $milliseconds,
|
||||
'serverId' => (($thirdQuarter & 0x3F) << 3) | (($fourthQuarter >> 13) & 0x7),
|
||||
'sequenceId' => $fourthQuarter & 0xFFF,
|
||||
'isCli' => (bool)(($fourthQuarter >> 12) & 0x1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base 10 number to base 16, padded to 16 characters
|
||||
*
|
||||
* Required on 32 bits systems as base_convert will lose precision with large numbers
|
||||
*/
|
||||
private function convertBase16(string $decimal): string {
|
||||
$hex = '';
|
||||
$digits = '0123456789ABCDEF';
|
||||
|
||||
while (strlen($decimal) > 0 && $decimal !== '0') {
|
||||
$remainder = 0;
|
||||
$newDecimal = '';
|
||||
|
||||
// Perform division by 16 manually for arbitrary precision
|
||||
for ($i = 0; $i < strlen($decimal); $i++) {
|
||||
$digit = (int)$decimal[$i];
|
||||
$current = $remainder * 10 + $digit;
|
||||
|
||||
if ($current >= 16) {
|
||||
$quotient = (int)($current / 16);
|
||||
$remainder = $current % 16;
|
||||
$newDecimal .= chr(ord('0') + $quotient);
|
||||
} else {
|
||||
$remainder = $current;
|
||||
// Only add quotient digit if we already have some digits in result
|
||||
if (strlen($newDecimal) > 0) {
|
||||
$newDecimal .= '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the remainder (0-15) as hex digit
|
||||
$hex = $digits[$remainder] . $hex;
|
||||
|
||||
// Update decimal for next iteration
|
||||
$decimal = ltrim($newDecimal, '0');
|
||||
}
|
||||
|
||||
return str_pad($hex, 16, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
138
lib/private/Snowflake/Generator.php
Normal file
138
lib/private/Snowflake/Generator.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
namespace OC\Snowflake;
|
||||
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Snowflake\IGenerator;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Nextcloud Snowflake ID generator
|
||||
*
|
||||
* Generates unique ID for database
|
||||
*
|
||||
* @since 33.0.0
|
||||
*/
|
||||
final class Generator implements IGenerator {
|
||||
public function __construct(
|
||||
private readonly ITimeFactory $timeFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function nextId(): string {
|
||||
// Time related
|
||||
[$seconds, $milliseconds] = $this->getCurrentTime();
|
||||
|
||||
$serverId = $this->getServerId() & 0x1FF; // Keep 9 bits
|
||||
$isCli = (int)$this->isCli(); // 1 bit
|
||||
$sequenceId = $this->getSequenceId($seconds, $milliseconds, $serverId); // 12 bits
|
||||
if ($sequenceId > 0xFFF || $sequenceId === false) {
|
||||
// Throttle a bit, wait for next millisecond
|
||||
usleep(1000);
|
||||
return $this->nextId();
|
||||
}
|
||||
|
||||
if (PHP_INT_SIZE === 8) {
|
||||
$firstHalf = $seconds & 0x7FFFFFFF;
|
||||
$secondHalf = (($milliseconds & 0x3FF) << 22) | ($serverId << 13) | ($isCli << 12) | $sequenceId;
|
||||
return (string)($firstHalf << 32 | $secondHalf);
|
||||
}
|
||||
|
||||
// Fallback for 32 bits systems
|
||||
$firstQuarter = ($seconds >> 16) & 0x7FFF;
|
||||
$secondQuarter = $seconds & 0xFFFF;
|
||||
$thirdQuarter = ($milliseconds & 0x3FF) << 6 | ($serverId >> 3) & 0x3F;
|
||||
$fourthQuarter = ($serverId & 0x7) << 13 | ($isCli & 0x1) << 12 | $sequenceId & 0xFFF;
|
||||
|
||||
$bin = pack('n*', $firstQuarter, $secondQuarter, $thirdQuarter, $fourthQuarter);
|
||||
|
||||
$bytes = unpack('C*', $bin);
|
||||
if ($bytes === false) {
|
||||
throw new \Exception('Fail to unpack');
|
||||
}
|
||||
|
||||
return $this->convertToDecimal(array_values($bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostly copied from Symfony:
|
||||
* https://github.com/symfony/symfony/blob/v7.3.4/src/Symfony/Component/Uid/BinaryUtil.php#L49
|
||||
*/
|
||||
private function convertToDecimal(array $bytes): string {
|
||||
$base = 10;
|
||||
$digits = '';
|
||||
|
||||
while ($count = \count($bytes)) {
|
||||
$quotient = [];
|
||||
$remainder = 0;
|
||||
|
||||
for ($i = 0; $i !== $count; ++$i) {
|
||||
$carry = $bytes[$i] + ($remainder << (PHP_INT_SIZE === 8 ? 16 : 8));
|
||||
$digit = intdiv($carry, $base);
|
||||
$remainder = $carry % $base;
|
||||
|
||||
if ($digit || $quotient) {
|
||||
$quotient[] = $digit;
|
||||
}
|
||||
}
|
||||
|
||||
$digits = $remainder . $digits;
|
||||
$bytes = $quotient;
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
|
||||
private function getCurrentTime(): array {
|
||||
$time = $this->timeFactory->now();
|
||||
return [
|
||||
$time->getTimestamp() - self::TS_OFFSET,
|
||||
(int)$time->format('v'),
|
||||
];
|
||||
}
|
||||
|
||||
private function getServerId(): int {
|
||||
return crc32(gethostname() ?: random_bytes(8));
|
||||
}
|
||||
|
||||
private function isCli(): bool {
|
||||
return PHP_SAPI === 'cli';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates sequence ID from APCu (general case) or random if APCu disabled or CLI
|
||||
*
|
||||
* @return int|false Sequence ID or false if APCu not ready
|
||||
* @throws \Exception if there is an error with APCu
|
||||
*/
|
||||
private function getSequenceId(int $seconds, int $milliseconds, int $serverId): int|false {
|
||||
$key = 'seq:' . $seconds . ':' . $milliseconds;
|
||||
|
||||
// Use APCu as fastest local cache, but not shared between processes in CLI
|
||||
if (!$this->isCli() && function_exists('apcu_enabled') && apcu_enabled()) {
|
||||
if ((int)apcu_cache_info(true)['creation_time'] === $seconds) {
|
||||
// APCu cache was just started
|
||||
// It means a sequence was maybe deleted
|
||||
return false;
|
||||
}
|
||||
|
||||
$sequenceId = apcu_inc($key, success: $success, ttl: 1);
|
||||
if ($success === true) {
|
||||
return $sequenceId;
|
||||
}
|
||||
|
||||
throw new \Exception('Failed to generate SnowflakeId with APCu');
|
||||
}
|
||||
|
||||
// Otherwise, just return a random number
|
||||
return random_int(0, 0xFFF - 1);
|
||||
}
|
||||
}
|
||||
35
lib/public/Snowflake/IDecoder.php
Normal file
35
lib/public/Snowflake/IDecoder.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
namespace OCP\Snowflake;
|
||||
|
||||
use OCP\AppFramework\Attribute\Consumable;
|
||||
|
||||
/**
|
||||
* Nextcloud Snowflake ID decoder
|
||||
*
|
||||
* @see \OCP\Snowflake\IGenerator for format
|
||||
* @since 33.0.0
|
||||
*/
|
||||
#[Consumable(since: '33.0.0')]
|
||||
interface IDecoder {
|
||||
/**
|
||||
* Decode information contained into Snowflake ID
|
||||
*
|
||||
* It includes:
|
||||
* - server ID: identify server on which ID was generated
|
||||
* - sequence ID: sequence number (number of snowflakes generated in the same second)
|
||||
* - createdAt: timestamp at which ID was generated
|
||||
* - isCli: if ID was generated using CLI or not
|
||||
*
|
||||
* @return array{createdAt: \DateTimeImmutable, serverId: int<0,1023>, sequenceId: int<0,4095>, isCli: bool, seconds: positive-int, milliseconds: int<0,999>}
|
||||
* @since 33.0
|
||||
*/
|
||||
public function decode(string $snowflakeId): array;
|
||||
}
|
||||
46
lib/public/Snowflake/IGenerator.php
Normal file
46
lib/public/Snowflake/IGenerator.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
namespace OCP\Snowflake;
|
||||
|
||||
use OCP\AppFramework\Attribute\Consumable;
|
||||
|
||||
/**
|
||||
* Nextcloud Snowflake ID generator
|
||||
*
|
||||
* Customized version of Snowflake IDs for Nextcloud:
|
||||
* 1 bit : Unused, always 0, avoid issue with PHP signed integers.
|
||||
* 31 bits: Timestamp from 2025-10-01. Allows to store a bit more than 68 years. Allows to find creation time.
|
||||
* 10 bits: Milliseconds (between 0 and 999)
|
||||
* 9 bits: Server ID, identify server which generated the ID (between 0 and 1023)
|
||||
* 1 bit : CLI or Web (0 or 1)
|
||||
* 12 bits: Sequence ID, usually a serial number of objects created in the same number on same server (between 0 and 4095)
|
||||
*
|
||||
* @since 33.0.0
|
||||
*/
|
||||
#[Consumable(since: '33.0.0')]
|
||||
interface IGenerator {
|
||||
|
||||
/**
|
||||
* Offset applied on timestamps to keep it short
|
||||
* Start from 2025-10-01 at 00:00:00
|
||||
*
|
||||
* @since 33.0
|
||||
*/
|
||||
public const TS_OFFSET = 1759276800;
|
||||
|
||||
/**
|
||||
* Get a new Snowflake ID.
|
||||
*
|
||||
* Each call to this method is guaranteed to return a different ID.
|
||||
*
|
||||
* @since 33.0
|
||||
*/
|
||||
public function nextId(): string;
|
||||
}
|
||||
69
tests/lib/Snowflake/DecoderTest.php
Normal file
69
tests/lib/Snowflake/DecoderTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Snowflake;
|
||||
|
||||
use OC\Snowflake\Decoder;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Test\TestCase;
|
||||
|
||||
/**
|
||||
* @package Test
|
||||
*/
|
||||
class DecoderTest extends TestCase {
|
||||
private Decoder $decoder;
|
||||
|
||||
public function setUp():void {
|
||||
$this->decoder = new Decoder();
|
||||
}
|
||||
|
||||
#[DataProvider('provideSnowflakeIds')]
|
||||
public function testDecode(
|
||||
string $snowflakeId,
|
||||
float $timestamp,
|
||||
int $serverId,
|
||||
int $sequenceId,
|
||||
bool $isCli,
|
||||
): void {
|
||||
$data = $this->decoder->decode($snowflakeId);
|
||||
|
||||
$this->assertEquals($timestamp, (float)$data['createdAt']->format('U.v'));
|
||||
$this->assertEquals($serverId, $data['serverId']);
|
||||
$this->assertEquals($sequenceId, $data['sequenceId']);
|
||||
$this->assertEquals($isCli, $data['isCli']);
|
||||
}
|
||||
|
||||
public static function provideSnowflakeIds(): array {
|
||||
$data = [
|
||||
['4688076898113587', 1760368327.984, 392, 2099, true],
|
||||
// Max milliseconds
|
||||
['4190109696', 1759276800.999, 0, 0, false],
|
||||
// Max serverId
|
||||
['4186112', 1759276800.0, 511, 0, false],
|
||||
// Max sequenceId
|
||||
['4095', 1759276800.0, 0, 4095, false],
|
||||
// Max isCli
|
||||
['4096', 1759276800.0, 0, 0, true],
|
||||
// Min
|
||||
['0', 1759276800, 0, 0, false],
|
||||
// Other
|
||||
['250159983611680096', 1817521710, 392, 1376, true],
|
||||
];
|
||||
|
||||
// 32 bits can't handle large timestamps correctly
|
||||
if (PHP_INT_SIZE === 8) {
|
||||
// Max all (can't happen because ms are up to 999)
|
||||
$data[] = ['9223372036854775807', 3906760448.023, 511, 4095, true];
|
||||
// Max all (real)
|
||||
$data[] = ['9223372036754112511', 3906760447.999, 511, 4095, true];
|
||||
// Max seconds
|
||||
$data[] = ['9223372032559808512', 3906760447, 0, 0, false];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
80
tests/lib/Snowflake/GeneratorTest.php
Normal file
80
tests/lib/Snowflake/GeneratorTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Snowflake;
|
||||
|
||||
use OC\AppFramework\Utility\TimeFactory;
|
||||
use OC\Snowflake\Decoder;
|
||||
use OC\Snowflake\Generator;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Snowflake\IGenerator;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Test\TestCase;
|
||||
|
||||
/**
|
||||
* @package Test
|
||||
*/
|
||||
class GeneratorTest extends TestCase {
|
||||
private Decoder $decoder;
|
||||
|
||||
public function setUp():void {
|
||||
$this->decoder = new Decoder();
|
||||
}
|
||||
public function testGenerator(): void {
|
||||
$generator = new Generator(new TimeFactory());
|
||||
$snowflakeId = $generator->nextId();
|
||||
$data = $this->decoder->decode($generator->nextId());
|
||||
|
||||
$this->assertIsString($snowflakeId);
|
||||
// Check timestamp
|
||||
$this->assertGreaterThan(time() - 30, $data['createdAt']->format('U'));
|
||||
|
||||
// Check serverId
|
||||
$this->assertGreaterThanOrEqual(0, $data['serverId']);
|
||||
$this->assertLessThanOrEqual(1023, $data['serverId']);
|
||||
|
||||
// Check sequenceId
|
||||
$this->assertGreaterThanOrEqual(0, $data['sequenceId']);
|
||||
$this->assertLessThanOrEqual(4095, $data['sequenceId']);
|
||||
|
||||
// Check CLI
|
||||
$this->assertTrue($data['isCli']);
|
||||
}
|
||||
|
||||
#[DataProvider('provideSnowflakeData')]
|
||||
public function testGeneratorWithFixedTime(string $date, int $expectedSeconds, int $expectedMilliseconds): void {
|
||||
$dt = new \DateTimeImmutable($date);
|
||||
$timeFactory = $this->createMock(ITimeFactory::class);
|
||||
$timeFactory->method('now')->willReturn($dt);
|
||||
|
||||
$generator = new Generator($timeFactory);
|
||||
$data = $this->decoder->decode($generator->nextId());
|
||||
|
||||
$this->assertEquals($expectedSeconds, ($data['createdAt']->format('U') - IGenerator::TS_OFFSET));
|
||||
$this->assertEquals($expectedMilliseconds, (int)$data['createdAt']->format('v'));
|
||||
}
|
||||
|
||||
public static function provideSnowflakeData(): array {
|
||||
$tests = [
|
||||
['2025-10-01 00:00:00.000000', 0, 0],
|
||||
['2025-10-01 00:00:01.000000', 1, 0],
|
||||
['2025-10-01 00:00:00.001000', 0, 1],
|
||||
['2027-08-06 03:08:30.000975', 58244910, 0],
|
||||
['2030-06-21 12:59:33.100875', 149000373, 100],
|
||||
['2038-01-18 13:33:37.666666', 388157617, 666],
|
||||
];
|
||||
// Timestamp in 32 bits can't go after 2038. Add few cases for 64 bits.
|
||||
if (PHP_INT_SIZE === 8) {
|
||||
$tests[] = ['2039-12-31 23:59:59.999999', 449711999, 999];
|
||||
$tests[] = ['2086-06-21 12:59:33.010875', 1916225973, 10];
|
||||
}
|
||||
|
||||
return $tests;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue