feat(database): introduce Snowflake IDs generator

Signed-off-by: Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>
This commit is contained in:
Benjamin Gaussorgues 2025-10-13 17:41:45 +02:00 committed by Carl Schwan
parent 7a3ed09822
commit c9b055a0d0
15 changed files with 570 additions and 9 deletions

View file

@ -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 }}

View file

@ -31,6 +31,7 @@ class PhpModules implements ISetupCheck {
'zlib',
];
protected const RECOMMENDED_MODULES = [
'apcu',
'exif',
'gmp',
'intl',

View file

@ -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": "*",

View 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;
}
}

View file

@ -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));

View file

@ -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',

View file

@ -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',

View file

@ -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) {

View file

@ -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();
}

View 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);
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}