Skip to content

Commit faa65d0

Browse files
committed
feat(database): introduce Snowflake IDs generator
Signed-off-by: Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>
1 parent 2ea30f9 commit faa65d0

File tree

6 files changed

+346
-0
lines changed

6 files changed

+346
-0
lines changed

lib/private/SnowflakeId.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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|float $id,
33+
) {
34+
}
35+
36+
private function decode(): void {
37+
if ($this->seconds !== 0) {
38+
return;
39+
}
40+
41+
// First 32 bits are timestamp
42+
$this->seconds = ($this->id >> 32) & 0xFFFFFFFF;
43+
44+
// Decode next 32 bits
45+
$raw = $this->id & 0xFFFFFFFF;
46+
// hex2bin expect even number of characters
47+
$raw = hex2bin(str_pad(dechex($raw), 8, '0', STR_PAD_LEFT));
48+
if ($raw === false) {
49+
throw new \Exception('Cannot decode Snowflake ID');
50+
}
51+
52+
$data = unpack('N', $raw);
53+
if ($data === false) {
54+
throw new \Exception('Invalid Snowflake ID');
55+
}
56+
$data = $data[1];
57+
$this->milliseconds = $data >> 22;
58+
$this->serverId = ($data >> 13) & 0x1FF;
59+
$this->isCli = (bool)(($data >> 12) & 0x1);
60+
$this->sequenceId = $data & 0xFFF;
61+
}
62+
63+
#[Override]
64+
public function isCli(): bool {
65+
return $this->isCli;
66+
}
67+
68+
#[Override]
69+
public function numeric(): int|float {
70+
return $this->id;
71+
}
72+
73+
#[Override]
74+
public function seconds(): int {
75+
$this->decode();
76+
return $this->seconds;
77+
}
78+
79+
#[Override]
80+
public function milliseconds(): int {
81+
$this->decode();
82+
return $this->milliseconds;
83+
}
84+
85+
#[Override]
86+
public function createdAt(): float {
87+
$this->decode();
88+
return $this->seconds + self::TS_OFFSET + ($this->milliseconds / 1000);
89+
}
90+
91+
#[Override]
92+
public function serverId(): int {
93+
$this->decode();
94+
return $this->serverId;
95+
}
96+
97+
#[Override]
98+
public function sequenceId(): int {
99+
$this->decode();
100+
return $this->sequenceId;
101+
}
102+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
/**
13+
* Nextcloud Snowflake ID generator
14+
*
15+
* Generates unique ID for database
16+
*
17+
* @since 33.0.0
18+
*/
19+
final class SnowflakeIdGenerator {
20+
public function __invoke(): int|float {
21+
// Time related
22+
[$currentMicrosecond, $currentSecond] = explode(' ', microtime());
23+
$seconds = $currentSecond - SnowflakeId::TS_OFFSET;
24+
$milliseconds = ((int)($currentMicrosecond * 1000)) & 0x3FF;
25+
26+
$serverId = $this->getServerId() & 0x1FF; // Keep 9 bits
27+
$isCli = (int)$this->isCli(); // 1 bit
28+
$sequenceId = $this->getSequenceId($seconds, $milliseconds); // 12 bits
29+
if ($sequenceId > 0xFFF) {
30+
// Throttle a bit, wait for next millisecond
31+
usleep(1000);
32+
return $this();
33+
}
34+
35+
36+
// Pack together
37+
return hexdec(bin2hex(pack(
38+
'NN',
39+
$seconds & 0x7FFFFFFF,
40+
(($milliseconds & 0x3FF) << 22) | ($serverId << 13) | ($isCli << 12) | $sequenceId,
41+
)));
42+
}
43+
44+
private function getServerId(): int {
45+
return crc32(gethostname() ?: random_bytes(8));
46+
}
47+
48+
private function isCli() {
49+
return PHP_SAPI === 'cli';
50+
}
51+
52+
private function getSequenceId(int $seconds, int $milliseconds): int {
53+
if ($this->isCli()) {
54+
// APCu cache isn’t shared between CLI processes
55+
return random_int(0, 0xFFF - 1);
56+
}
57+
58+
if (function_exists('apcu_inc')) {
59+
$key = 'sequence:' . $seconds . ':' . $milliseconds;
60+
return apcu_inc($key, ttl: 1);
61+
}
62+
63+
// TODO Implement fallback?
64+
throw new Exception('Failed to get sequence Id');
65+
}
66+
}

lib/public/ISnowflakeId.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 OCP;
11+
12+
/**
13+
* Nextcloud ID generator
14+
*
15+
* Generates unique ID
16+
* @since 33.0.0
17+
*/
18+
interface ISnowflakeId {
19+
public const int TS_OFFSET = 1759276800; // 2025-10-01 00:00:00
20+
21+
/**
22+
* Returns sequence ID as int (64 bits servers) or float (32 bits servers)
23+
*
24+
* This method is suitable to store Sequence Id in database.
25+
* Use BIGINT (8 bytes)
26+
*
27+
* @since 33.0
28+
*/
29+
public function numeric(): int|float;
30+
31+
/**
32+
* Returns whether the SequenceId was created in CLI or not (eg. FPM, Apache)
33+
*
34+
* @since 33.0
35+
*/
36+
public function isCli(): bool;
37+
38+
/**
39+
* Returns the number of seconds after self::TS_OFFSET
40+
*
41+
* Creation time of the sequence ID since self::TS_OFFSET
42+
*
43+
* @since 33.0
44+
*/
45+
public function seconds(): int;
46+
47+
/**
48+
* Returns the number of milliseconds of creation
49+
*
50+
* @since 33.0
51+
*/
52+
public function milliseconds(): int;
53+
54+
/**
55+
* Returns full millisecond creation timestamp
56+
*
57+
* @since 33.0
58+
*/
59+
public function createdAt(): float;
60+
61+
/**
62+
* Returns server ID (encoded on 9 bits)
63+
*
64+
* @return int<0, 511>
65+
* @since 33.0
66+
*/
67+
public function serverId(): int;
68+
69+
/**
70+
* Returns sequence ID (encoded on 12 bits)
71+
*
72+
* @return int<0, 4095>
73+
* @since 33.0
74+
*/
75+
public function sequenceId(): int;
76+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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 OCP;
11+
12+
/**
13+
* Nextcloud ID generator
14+
*
15+
* Generates unique ID
16+
* @since 33.0.0
17+
*/
18+
interface ISnowflakeIdGenerator {
19+
public function __invoke(): int|float;
20+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace Test;
9+
10+
use OC\SnowflakeIdGenerator;
11+
12+
/**
13+
* @package Test
14+
*/
15+
class SnowflakeIdGeneratorTest extends TestCase {
16+
public function testGenerator(): void {
17+
$generator = new SnowflakeIdGenerator();
18+
19+
$snowflakeId = $generator();
20+
$this->assertGreaterThan(0x100000000, $snowflakeId);
21+
if (PHP_INT_SIZE < 8) {
22+
$this->assertIsFloat($snowflakeId);
23+
} else {
24+
$this->assertIsInt($snowflakeId);
25+
}
26+
}
27+
}

tests/lib/SnowflakeIdTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace Test;
9+
10+
use OC\SnowflakeId;
11+
use PHPUnit\Framework\Attributes\DataProvider;
12+
13+
/**
14+
* @package Test
15+
*/
16+
class SnowflakeIdTest extends TestCase {
17+
#[DataProvider('provideSnowflakeIds')]
18+
public function testDecode(
19+
int|float $snowflakeId,
20+
float $timestamp,
21+
int $serverId,
22+
int $sequenceId,
23+
bool $isCli,
24+
): void {
25+
$snowflake = new SnowflakeId($snowflakeId);
26+
27+
$this->assertEquals($snowflakeId, $snowflake->numeric());
28+
$this->assertEquals($timestamp, $snowflake->createdAt());
29+
$this->assertEquals($serverId, $snowflake->serverId());
30+
$this->assertEquals($sequenceId, $snowflake->sequenceId());
31+
$this->assertEquals($isCli, $snowflake->isCli());
32+
}
33+
34+
public static function provideSnowflakeIds(): array {
35+
return [
36+
[4688076898113587, 1760368327.984, 392, 2099, true],
37+
// Max all (can't happen ms are up to 999)
38+
[0x7fffffffffffffff, 3906760448.023, 511, 4095, true],
39+
// Max all (real)
40+
[0x7ffffffff9ffffff, 3906760447.999, 511, 4095, true],
41+
// Max seconds
42+
[0x7fffffff00000000, 3906760447, 0, 0, false],
43+
// Max milliseconds
44+
[4190109696, 1759276800.999, 0, 0, false],
45+
// Max serverId
46+
[4186112, 1759276800.0, 511, 0, false],
47+
// Max sequenceId
48+
[4095, 1759276800.0, 0, 4095, false],
49+
// Max isCli
50+
[4096, 1759276800.0, 0, 0, true],
51+
// Min
52+
[0, 1759276800, 0, 0, false],
53+
];
54+
}
55+
}

0 commit comments

Comments
 (0)