Skip to content

Commit 4b1fce7

Browse files
committed
feat(database): introduce Snowflake IDs generator
Signed-off-by: Benjamin Gaussorgues <[email protected]>
1 parent 4174c58 commit 4b1fce7

File tree

15 files changed

+570
-9
lines changed

15 files changed

+570
-9
lines changed

.github/workflows/phpunit-32bits.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ name: PHPUnit 32bits
55
on:
66
pull_request:
77
paths:
8-
- 'version.php'
9-
- '.github/workflows/phpunit-32bits.yml'
10-
- 'tests/phpunit-autotest.xml'
8+
- "version.php"
9+
- ".github/workflows/phpunit-32bits.yml"
10+
- "tests/phpunit-autotest.xml"
11+
- "lib/private/Snowflake/*"
1112
workflow_dispatch:
1213
schedule:
1314
- cron: "15 1 * * 1-6"
@@ -30,7 +31,7 @@ jobs:
3031
strategy:
3132
fail-fast: false
3233
matrix:
33-
php-versions: ['8.2', '8.3', '8.4']
34+
php-versions: ["8.2", "8.3", "8.4"]
3435

3536
steps:
3637
- name: Checkout server
@@ -51,8 +52,7 @@ jobs:
5152
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
5253
coverage: none
5354
ini-file: development
54-
ini-values:
55-
apc.enabled=on, apc.enable_cli=on, disable_functions= # https://github.com/shivammathur/setup-php/discussions/573
55+
ini-values: apc.enabled=on, apc.enable_cli=on, disable_functions= # https://github.com/shivammathur/setup-php/discussions/573
5656
env:
5757
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5858

apps/settings/lib/SetupChecks/PhpModules.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class PhpModules implements ISetupCheck {
3131
'zlib',
3232
];
3333
protected const RECOMMENDED_MODULES = [
34+
'apcu',
3435
'exif',
3536
'gmp',
3637
'intl',

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
}
1212
},
1313
"autoload": {
14-
"exclude-from-classmap": ["**/bamarni/composer-bin-plugin/**"],
14+
"exclude-from-classmap": [
15+
"**/bamarni/composer-bin-plugin/**"
16+
],
1517
"files": [
1618
"lib/public/Log/functions.php"
1719
],
@@ -25,6 +27,7 @@
2527
},
2628
"require": {
2729
"php": "^8.2",
30+
"ext-apcu": "*",
2831
"ext-ctype": "*",
2932
"ext-curl": "*",
3033
"ext-dom": "*",

core/Command/SnowflakeDecodeId.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-only
8+
*/
9+
namespace OC\Core\Command;
10+
11+
use OC\Snowflake\Decoder;
12+
use Symfony\Component\Console\Helper\Table;
13+
use Symfony\Component\Console\Input\InputArgument;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
17+
class SnowflakeDecodeId extends Base {
18+
protected function configure(): void {
19+
parent::configure();
20+
21+
$this
22+
->setName('decode-snowflake')
23+
->setDescription('Decode Snowflake IDs used by Nextcloud')
24+
->addArgument('snowflake-id', InputArgument::REQUIRED, 'Nextcloud Snowflake ID to decode');
25+
}
26+
27+
protected function execute(InputInterface $input, OutputInterface $output): int {
28+
$snowflakeId = $input->getArgument('snowflake-id');
29+
$data = (new Decoder)->decode($snowflakeId);
30+
31+
$rows = [
32+
['Snowflake ID', $snowflakeId],
33+
['Seconds', $data['seconds']],
34+
['Milliseconds', $data['milliseconds']],
35+
['Created from CLI', $data['isCli'] ? 'yes' : 'no'],
36+
['Server ID', $data['serverId']],
37+
['Sequence ID', $data['sequenceId']],
38+
['Creation timestamp', $data['createdAt']->format('U.v')],
39+
['Creation date', $data['createdAt']->format('Y-m-d H:i:s.v')],
40+
];
41+
42+
$table = new Table($output);
43+
$table->setRows($rows);
44+
$table->render();
45+
46+
return Base::SUCCESS;
47+
}
48+
}

core/register_command.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
use OC\Core\Command\Security\ListCertificates;
8686
use OC\Core\Command\Security\RemoveCertificate;
8787
use OC\Core\Command\SetupChecks;
88+
use OC\Core\Command\SnowflakeDecodeId;
8889
use OC\Core\Command\Status;
8990
use OC\Core\Command\SystemTag\Edit;
9091
use OC\Core\Command\TaskProcessing\EnabledCommand;
@@ -246,6 +247,7 @@
246247
$application->add(Server::get(BruteforceAttempts::class));
247248
$application->add(Server::get(BruteforceResetAttempts::class));
248249
$application->add(Server::get(SetupChecks::class));
250+
$application->add(Server::get(SnowflakeDecodeId::class));
249251
$application->add(Server::get(Get::class));
250252

251253
$application->add(Server::get(GetCommand::class));

lib/composer/composer/autoload_classmap.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,8 @@
822822
'OCP\\Share_Backend' => $baseDir . '/lib/public/Share_Backend.php',
823823
'OCP\\Share_Backend_Collection' => $baseDir . '/lib/public/Share_Backend_Collection.php',
824824
'OCP\\Share_Backend_File_Dependent' => $baseDir . '/lib/public/Share_Backend_File_Dependent.php',
825+
'OCP\\Snowflake\\IDecoder' => $baseDir . '/lib/public/Snowflake/IDecoder.php',
826+
'OCP\\Snowflake\\IGenerator' => $baseDir . '/lib/public/Snowflake/IGenerator.php',
825827
'OCP\\SpeechToText\\Events\\AbstractTranscriptionEvent' => $baseDir . '/lib/public/SpeechToText/Events/AbstractTranscriptionEvent.php',
826828
'OCP\\SpeechToText\\Events\\TranscriptionFailedEvent' => $baseDir . '/lib/public/SpeechToText/Events/TranscriptionFailedEvent.php',
827829
'OCP\\SpeechToText\\Events\\TranscriptionSuccessfulEvent' => $baseDir . '/lib/public/SpeechToText/Events/TranscriptionSuccessfulEvent.php',
@@ -1352,6 +1354,7 @@
13521354
'OC\\Core\\Command\\Security\\ListCertificates' => $baseDir . '/core/Command/Security/ListCertificates.php',
13531355
'OC\\Core\\Command\\Security\\RemoveCertificate' => $baseDir . '/core/Command/Security/RemoveCertificate.php',
13541356
'OC\\Core\\Command\\SetupChecks' => $baseDir . '/core/Command/SetupChecks.php',
1357+
'OC\\Core\\Command\\SnowflakeDecodeId' => $baseDir . '/core/Command/SnowflakeDecodeId.php',
13551358
'OC\\Core\\Command\\Status' => $baseDir . '/core/Command/Status.php',
13561359
'OC\\Core\\Command\\SystemTag\\Add' => $baseDir . '/core/Command/SystemTag/Add.php',
13571360
'OC\\Core\\Command\\SystemTag\\Delete' => $baseDir . '/core/Command/SystemTag/Delete.php',
@@ -2103,6 +2106,8 @@
21032106
'OC\\Share\\Constants' => $baseDir . '/lib/private/Share/Constants.php',
21042107
'OC\\Share\\Helper' => $baseDir . '/lib/private/Share/Helper.php',
21052108
'OC\\Share\\Share' => $baseDir . '/lib/private/Share/Share.php',
2109+
'OC\\Snowflake\\Decoder' => $baseDir . '/lib/private/Snowflake/Decoder.php',
2110+
'OC\\Snowflake\\Generator' => $baseDir . '/lib/private/Snowflake/Generator.php',
21062111
'OC\\SpeechToText\\SpeechToTextManager' => $baseDir . '/lib/private/SpeechToText/SpeechToTextManager.php',
21072112
'OC\\SpeechToText\\TranscriptionJob' => $baseDir . '/lib/private/SpeechToText/TranscriptionJob.php',
21082113
'OC\\StreamImage' => $baseDir . '/lib/private/StreamImage.php',

lib/composer/composer/autoload_static.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
863863
'OCP\\Share_Backend' => __DIR__ . '/../../..' . '/lib/public/Share_Backend.php',
864864
'OCP\\Share_Backend_Collection' => __DIR__ . '/../../..' . '/lib/public/Share_Backend_Collection.php',
865865
'OCP\\Share_Backend_File_Dependent' => __DIR__ . '/../../..' . '/lib/public/Share_Backend_File_Dependent.php',
866+
'OCP\\Snowflake\\IDecoder' => __DIR__ . '/../../..' . '/lib/public/Snowflake/IDecoder.php',
867+
'OCP\\Snowflake\\IGenerator' => __DIR__ . '/../../..' . '/lib/public/Snowflake/IGenerator.php',
866868
'OCP\\SpeechToText\\Events\\AbstractTranscriptionEvent' => __DIR__ . '/../../..' . '/lib/public/SpeechToText/Events/AbstractTranscriptionEvent.php',
867869
'OCP\\SpeechToText\\Events\\TranscriptionFailedEvent' => __DIR__ . '/../../..' . '/lib/public/SpeechToText/Events/TranscriptionFailedEvent.php',
868870
'OCP\\SpeechToText\\Events\\TranscriptionSuccessfulEvent' => __DIR__ . '/../../..' . '/lib/public/SpeechToText/Events/TranscriptionSuccessfulEvent.php',
@@ -1393,6 +1395,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
13931395
'OC\\Core\\Command\\Security\\ListCertificates' => __DIR__ . '/../../..' . '/core/Command/Security/ListCertificates.php',
13941396
'OC\\Core\\Command\\Security\\RemoveCertificate' => __DIR__ . '/../../..' . '/core/Command/Security/RemoveCertificate.php',
13951397
'OC\\Core\\Command\\SetupChecks' => __DIR__ . '/../../..' . '/core/Command/SetupChecks.php',
1398+
'OC\\Core\\Command\\SnowflakeDecodeId' => __DIR__ . '/../../..' . '/core/Command/SnowflakeDecodeId.php',
13961399
'OC\\Core\\Command\\Status' => __DIR__ . '/../../..' . '/core/Command/Status.php',
13971400
'OC\\Core\\Command\\SystemTag\\Add' => __DIR__ . '/../../..' . '/core/Command/SystemTag/Add.php',
13981401
'OC\\Core\\Command\\SystemTag\\Delete' => __DIR__ . '/../../..' . '/core/Command/SystemTag/Delete.php',
@@ -2144,6 +2147,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
21442147
'OC\\Share\\Constants' => __DIR__ . '/../../..' . '/lib/private/Share/Constants.php',
21452148
'OC\\Share\\Helper' => __DIR__ . '/../../..' . '/lib/private/Share/Helper.php',
21462149
'OC\\Share\\Share' => __DIR__ . '/../../..' . '/lib/private/Share/Share.php',
2150+
'OC\\Snowflake\\Decoder' => __DIR__ . '/../../..' . '/lib/private/Snowflake/Decoder.php',
2151+
'OC\\Snowflake\\Generator' => __DIR__ . '/../../..' . '/lib/private/Snowflake/Generator.php',
21472152
'OC\\SpeechToText\\SpeechToTextManager' => __DIR__ . '/../../..' . '/lib/private/SpeechToText/SpeechToTextManager.php',
21482153
'OC\\SpeechToText\\TranscriptionJob' => __DIR__ . '/../../..' . '/lib/private/SpeechToText/TranscriptionJob.php',
21492154
'OC\\StreamImage' => __DIR__ . '/../../..' . '/lib/private/StreamImage.php',

lib/composer/composer/platform_check.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
$issues = array();
66

7-
if (!(PHP_VERSION_ID >= 80100)) {
8-
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.';
7+
if (!(PHP_VERSION_ID >= 80200)) {
8+
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.';
99
}
1010

1111
if ($issues) {

lib/private/Server.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@
114114
use OC\SetupCheck\SetupCheckManager;
115115
use OC\Share20\ProviderFactory;
116116
use OC\Share20\ShareHelper;
117+
use OC\Snowflake\Decoder;
118+
use OC\Snowflake\Generator;
117119
use OC\SpeechToText\SpeechToTextManager;
118120
use OC\SystemTag\ManagerFactory as SystemTagManagerFactory;
119121
use OC\Talk\Broker;
@@ -222,6 +224,8 @@
222224
use OCP\SetupCheck\ISetupCheckManager;
223225
use OCP\Share\IProviderFactory;
224226
use OCP\Share\IShareHelper;
227+
use OCP\Snowflake\IDecoder;
228+
use OCP\Snowflake\IGenerator;
225229
use OCP\SpeechToText\ISpeechToTextManager;
226230
use OCP\SystemTag\ISystemTagManager;
227231
use OCP\SystemTag\ISystemTagObjectMapper;
@@ -1245,6 +1249,9 @@ public function __construct($webRoot, \OC\Config $config) {
12451249

12461250
$this->registerAlias(ISignatureManager::class, SignatureManager::class);
12471251

1252+
$this->registerAlias(IGenerator::class, Generator::class);
1253+
$this->registerAlias(IDecoder::class, Decoder::class);
1254+
12481255
$this->connectDispatcher();
12491256
}
12501257

lib/private/Snowflake/Decoder.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-only
8+
*/
9+
10+
namespace OC\Snowflake;
11+
12+
use OCP\Snowflake\IDecoder;
13+
use OCP\Snowflake\IGenerator;
14+
use Override;
15+
16+
/**
17+
* Nextcloud Snowflake ID
18+
*
19+
* Get information about Snowflake Id
20+
*
21+
* @since 33.0.0
22+
*/
23+
final class Decoder implements IDecoder {
24+
#[Override]
25+
public function decode(string $snowflakeId): array {
26+
if (!ctype_digit($snowflakeId)) {
27+
throw new \Exception('Invalid Snowflake ID: ' . $snowflakeId);
28+
}
29+
30+
/** @var array{seconds: positive-int, milliseconds: int<0,999>, serverId: int<0, 1023>, sequenceId: int<0,4095>, isCli: bool} $data */
31+
$data = PHP_INT_SIZE === 8
32+
? $this->decode64bits((int)$snowflakeId)
33+
: $this->decode32bits($snowflakeId);
34+
35+
$data['createdAt'] = new \DateTimeImmutable(
36+
sprintf(
37+
'@%d.%03d',
38+
$data['seconds'] + IGenerator::TS_OFFSET + intdiv($data['milliseconds'], 1000),
39+
$data['milliseconds'] % 1000,
40+
)
41+
);
42+
43+
return $data;
44+
}
45+
46+
private function decode64bits(int $snowflakeId): array {
47+
$firstHalf = $snowflakeId >> 32;
48+
$secondHalf = $snowflakeId & 0xFFFFFFFF;
49+
50+
$seconds = $firstHalf & 0x7FFFFFFF;
51+
$milliseconds = $secondHalf >> 22;
52+
53+
return [
54+
'seconds' => $seconds,
55+
'milliseconds' => $milliseconds,
56+
'serverId' => ($secondHalf >> 13) & 0x1FF,
57+
'sequenceId' => $secondHalf & 0xFFF,
58+
'isCli' => (bool)(($secondHalf >> 12) & 0x1),
59+
];
60+
}
61+
62+
private function decode32bits(string $snowflakeId): array {
63+
$id = $this->convertBase16($snowflakeId);
64+
65+
$firstQuarter = (int)hexdec(substr($id, 0, 4));
66+
$secondQuarter = (int)hexdec(substr($id, 4, 4));
67+
$thirdQuarter = (int)hexdec(substr($id, 8, 4));
68+
$fourthQuarter = (int)hexdec(substr($id, 12, 4));
69+
70+
$seconds = (($firstQuarter & 0x7FFF) << 16) | ($secondQuarter & 0xFFFF);
71+
$milliseconds = ($thirdQuarter >> 6) & 0x3FF;
72+
73+
return [
74+
'seconds' => $seconds,
75+
'milliseconds' => $milliseconds,
76+
'serverId' => (($thirdQuarter & 0x3F) << 3) | (($fourthQuarter >> 13) & 0x7),
77+
'sequenceId' => $fourthQuarter & 0xFFF,
78+
'isCli' => (bool)(($fourthQuarter >> 12) & 0x1),
79+
];
80+
}
81+
82+
/**
83+
* Convert base 10 number to base 16, padded to 16 characters
84+
*
85+
* Required on 32 bits systems as base_convert will lose precision with large numbers
86+
*/
87+
private function convertBase16(string $decimal): string {
88+
$hex = '';
89+
$digits = '0123456789ABCDEF';
90+
91+
while (strlen($decimal) > 0 && $decimal !== '0') {
92+
$remainder = 0;
93+
$newDecimal = '';
94+
95+
// Perform division by 16 manually for arbitrary precision
96+
for ($i = 0; $i < strlen($decimal); $i++) {
97+
$digit = (int)$decimal[$i];
98+
$current = $remainder * 10 + $digit;
99+
100+
if ($current >= 16) {
101+
$quotient = (int)($current / 16);
102+
$remainder = $current % 16;
103+
$newDecimal .= chr(ord('0') + $quotient);
104+
} else {
105+
$remainder = $current;
106+
// Only add quotient digit if we already have some digits in result
107+
if (strlen($newDecimal) > 0) {
108+
$newDecimal .= '0';
109+
}
110+
}
111+
}
112+
113+
// Add the remainder (0-15) as hex digit
114+
$hex = $digits[$remainder] . $hex;
115+
116+
// Update decimal for next iteration
117+
$decimal = ltrim($newDecimal, '0');
118+
}
119+
120+
return str_pad($hex, 16, '0', STR_PAD_LEFT);
121+
}
122+
}

0 commit comments

Comments
 (0)