Skip to content

Commit fe73d6e

Browse files
committed
feat(database): introduce Snowflake IDs generator
Signed-off-by: Benjamin Gaussorgues <[email protected]>
1 parent 58f3ff0 commit fe73d6e

File tree

11 files changed

+617
-1
lines changed

11 files changed

+617
-1
lines changed

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.1",
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\SnowflakeId;
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+
$snowflakeId = new SnowflakeId($snowflakeId);
30+
31+
$rows = [
32+
['Snowflake ID', $snowflakeId->numeric()],
33+
['Seconds', $snowflakeId->seconds()],
34+
['Milliseconds', $snowflakeId->milliseconds()],
35+
['Created from CLI', $snowflakeId->isCli() ? 'yes' : 'no'],
36+
['Server ID', $snowflakeId->serverId()],
37+
['Sequence ID', $snowflakeId->sequenceId()],
38+
['Creation timestamp', $snowflakeId->createdAt()],
39+
['Creation date', date('Y-m-d H:i:s', (int)$snowflakeId->createdAt()) . '.' . $snowflakeId->milliseconds()],
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
@@ -628,6 +628,8 @@
628628
'OCP\\IRequestId' => $baseDir . '/lib/public/IRequestId.php',
629629
'OCP\\IServerContainer' => $baseDir . '/lib/public/IServerContainer.php',
630630
'OCP\\ISession' => $baseDir . '/lib/public/ISession.php',
631+
'OCP\\ISnowflakeId' => $baseDir . '/lib/public/ISnowflakeId.php',
632+
'OCP\\ISnowflakeIdGenerator' => $baseDir . '/lib/public/ISnowflakeIdGenerator.php',
631633
'OCP\\IStreamImage' => $baseDir . '/lib/public/IStreamImage.php',
632634
'OCP\\ITagManager' => $baseDir . '/lib/public/ITagManager.php',
633635
'OCP\\ITags' => $baseDir . '/lib/public/ITags.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',
@@ -2104,6 +2107,8 @@
21042107
'OC\\Share\\Constants' => $baseDir . '/lib/private/Share/Constants.php',
21052108
'OC\\Share\\Helper' => $baseDir . '/lib/private/Share/Helper.php',
21062109
'OC\\Share\\Share' => $baseDir . '/lib/private/Share/Share.php',
2110+
'OC\\SnowflakeId' => $baseDir . '/lib/private/SnowflakeId.php',
2111+
'OC\\SnowflakeIdGenerator' => $baseDir . '/lib/private/SnowflakeIdGenerator.php',
21072112
'OC\\SpeechToText\\SpeechToTextManager' => $baseDir . '/lib/private/SpeechToText/SpeechToTextManager.php',
21082113
'OC\\SpeechToText\\TranscriptionJob' => $baseDir . '/lib/private/SpeechToText/TranscriptionJob.php',
21092114
'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
@@ -669,6 +669,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
669669
'OCP\\IRequestId' => __DIR__ . '/../../..' . '/lib/public/IRequestId.php',
670670
'OCP\\IServerContainer' => __DIR__ . '/../../..' . '/lib/public/IServerContainer.php',
671671
'OCP\\ISession' => __DIR__ . '/../../..' . '/lib/public/ISession.php',
672+
'OCP\\ISnowflakeId' => __DIR__ . '/../../..' . '/lib/public/ISnowflakeId.php',
673+
'OCP\\ISnowflakeIdGenerator' => __DIR__ . '/../../..' . '/lib/public/ISnowflakeIdGenerator.php',
672674
'OCP\\IStreamImage' => __DIR__ . '/../../..' . '/lib/public/IStreamImage.php',
673675
'OCP\\ITagManager' => __DIR__ . '/../../..' . '/lib/public/ITagManager.php',
674676
'OCP\\ITags' => __DIR__ . '/../../..' . '/lib/public/ITags.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',
@@ -2145,6 +2148,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
21452148
'OC\\Share\\Constants' => __DIR__ . '/../../..' . '/lib/private/Share/Constants.php',
21462149
'OC\\Share\\Helper' => __DIR__ . '/../../..' . '/lib/private/Share/Helper.php',
21472150
'OC\\Share\\Share' => __DIR__ . '/../../..' . '/lib/private/Share/Share.php',
2151+
'OC\\SnowflakeId' => __DIR__ . '/../../..' . '/lib/private/SnowflakeId.php',
2152+
'OC\\SnowflakeIdGenerator' => __DIR__ . '/../../..' . '/lib/private/SnowflakeIdGenerator.php',
21482153
'OC\\SpeechToText\\SpeechToTextManager' => __DIR__ . '/../../..' . '/lib/private/SpeechToText/SpeechToTextManager.php',
21492154
'OC\\SpeechToText\\TranscriptionJob' => __DIR__ . '/../../..' . '/lib/private/SpeechToText/TranscriptionJob.php',
21502155
'OC\\StreamImage' => __DIR__ . '/../../..' . '/lib/private/StreamImage.php',

lib/private/SnowflakeId.php

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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;
11+
12+
use OCP\ISnowflakeId;
13+
use Override;
14+
15+
/**
16+
* Nextcloud Snowflake ID
17+
*
18+
* Get information about Snowflake Id
19+
*
20+
* @since 33.0.0
21+
*/
22+
final class SnowflakeId implements ISnowflakeId {
23+
private int $seconds = 0;
24+
private int $milliseconds = 0;
25+
private bool $isCli = false;
26+
/** @var int<0, 511> */
27+
private int $serverId = 0;
28+
/** @var int<0, 4095> */
29+
private int $sequenceId = 0;
30+
31+
public function __construct(
32+
private readonly int|string $id,
33+
) {
34+
if (is_string($id) && !ctype_digit($id)) {
35+
throw new \Exception('Invalid Snowflake ID: ' . $id);
36+
}
37+
}
38+
39+
private function decode(): void {
40+
if ($this->seconds !== 0) {
41+
return;
42+
}
43+
44+
PHP_INT_SIZE === 8
45+
? $this->decode64bits()
46+
: $this->decode32bits();
47+
}
48+
49+
private function decode64bits(): void {
50+
$id = (int)$this->id;
51+
$firstHalf = $id >> 32;
52+
$secondHalf = $id & 0xFFFFFFFF;
53+
54+
// First half without first bit is seconds
55+
$this->seconds = $firstHalf & 0x7FFFFFFF;
56+
57+
// Decode second half
58+
$this->milliseconds = $secondHalf >> 22;
59+
$this->serverId = ($secondHalf >> 13) & 0x1FF;
60+
$this->isCli = (bool)(($secondHalf >> 12) & 0x1);
61+
$this->sequenceId = $secondHalf & 0xFFF;
62+
}
63+
64+
private function decode32bits(): void {
65+
$id = is_int($this->id) ? number_format($this->id, 0, '', '') : $this->id;
66+
$id = $this->convertBase16($id);
67+
68+
$firstQuarter = (int)hexdec(substr($id, 0, 4));
69+
$secondQuarter = (int)hexdec(substr($id, 4, 4));
70+
$thirdQuarter = (int)hexdec(substr($id, 8, 4));
71+
$fourthQuarter = (int)hexdec(substr($id, 12, 4));
72+
73+
$this->seconds = (($firstQuarter & 0x7FFF) << 16) | ($secondQuarter & 0xFFFF);
74+
75+
$this->milliseconds = ($thirdQuarter >> 6) & 0x3FF;
76+
77+
$this->serverId = (($thirdQuarter & 0x3F) << 3) | (($fourthQuarter >> 13) & 0x7);
78+
$this->isCli = (bool)(($fourthQuarter >> 12) & 0x1);
79+
$this->sequenceId = $fourthQuarter & 0xFFF;
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+
123+
#[Override]
124+
public function isCli(): bool {
125+
return $this->isCli;
126+
}
127+
128+
#[Override]
129+
public function numeric(): int|string {
130+
return $this->id;
131+
}
132+
133+
#[Override]
134+
public function seconds(): int {
135+
$this->decode();
136+
return $this->seconds;
137+
}
138+
139+
#[Override]
140+
public function milliseconds(): int {
141+
$this->decode();
142+
return $this->milliseconds;
143+
}
144+
145+
#[Override]
146+
public function createdAt(): float {
147+
$this->decode();
148+
return $this->seconds + self::TS_OFFSET + ($this->milliseconds / 1000);
149+
}
150+
151+
#[Override]
152+
public function serverId(): int {
153+
$this->decode();
154+
return $this->serverId;
155+
}
156+
157+
#[Override]
158+
public function sequenceId(): int {
159+
$this->decode();
160+
return $this->sequenceId;
161+
}
162+
}

0 commit comments

Comments
 (0)