Skip to content

Commit 0815707

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

File tree

6 files changed

+346
-0
lines changed

6 files changed

+346
-0
lines changed

lib/private/SnowflakeId.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
public const int TS_OFFSET = 1759276800; // 2025-10-01 00:00:00
24+
25+
private int $seconds = 0;
26+
private int $milliseconds = 0;
27+
private bool $isCli = false;
28+
/** @var int<0, 511> */
29+
private int $serverId = 0;
30+
/** @var int<0, 4095> */
31+
private int $sequenceId = 0;
32+
33+
public function __construct(
34+
private readonly int|float $id,
35+
) {
36+
}
37+
38+
private function decode(): void {
39+
if ($this->seconds !== 0) {
40+
return;
41+
}
42+
43+
// First 32 bits are timestamp
44+
$this->seconds = ($this->id >> 32) & 0xFFFFFFFF;
45+
46+
// Decode next 32 bits
47+
$raw = $this->id & 0xFFFFFFFF;
48+
// hex2bin expect even number of characters
49+
$raw = hex2bin(str_pad(dechex($raw), 8, '0', STR_PAD_LEFT));
50+
if ($raw === false) {
51+
throw new \Exception('Cannot decode Snowflake ID');
52+
}
53+
54+
$data = unpack('N', $raw);
55+
if ($data === false) {
56+
throw new \Exception('Invalid Snowflake ID');
57+
}
58+
$data = $data[1];
59+
$this->milliseconds = $data >> 22;
60+
$this->serverId = ($data >> 13) & 0x1FF;
61+
$this->isCli = (bool)(($data >> 12) & 0x1);
62+
$this->sequenceId = $data & 0xFFF;
63+
}
64+
65+
#[Override]
66+
public function isCli(): bool {
67+
return $this->isCli;
68+
}
69+
70+
#[Override]
71+
public function numeric(): int|float {
72+
return $this->id;
73+
}
74+
75+
#[Override]
76+
public function seconds(): int {
77+
$this->decode();
78+
return $this->seconds;
79+
}
80+
81+
#[Override]
82+
public function milliseconds(): int {
83+
$this->decode();
84+
return $this->milliseconds;
85+
}
86+
87+
#[Override]
88+
public function createdAt(): float {
89+
$this->decode();
90+
return $this->seconds + self::TS_OFFSET + ($this->milliseconds / 1000);
91+
}
92+
93+
#[Override]
94+
public function serverId(): int {
95+
$this->decode();
96+
return $this->serverId;
97+
}
98+
99+
#[Override]
100+
public function sequenceId(): int {
101+
$this->decode();
102+
return $this->sequenceId;
103+
}
104+
}
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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
/**
20+
* Returns sequence ID as int (64 bits servers) or float (32 bits servers)
21+
*
22+
* This method is suitable to store Sequence Id in database.
23+
* Use BIGINT (8 bytes)
24+
*
25+
* @since 33.0
26+
*/
27+
public function numeric(): int|float;
28+
29+
/**
30+
* Returns whether the SequenceId was created in CLI or not (eg. FPM, Apache)
31+
*
32+
* @since 33.0
33+
*/
34+
public function isCli(): bool;
35+
36+
/**
37+
* Returns the number of seconds stored
38+
*
39+
* Creation time of the sequence ID
40+
*
41+
* @since 33.0
42+
*/
43+
public function seconds(): int;
44+
45+
/**
46+
* Returns the number of milliseconds of creation
47+
*
48+
* @since 33.0
49+
*/
50+
public function milliseconds(): int;
51+
52+
/**
53+
* Returns full millisecond creation timestamp
54+
*
55+
* @since 33.0
56+
*/
57+
public function createdAt(): float;
58+
59+
/**
60+
* Returns server ID (encoded on 9 bits)
61+
*
62+
* @return int<0, 511>
63+
* @since 33.0
64+
*/
65+
public function serverId(): int;
66+
67+
/**
68+
* Returns sequence ID (encoded on 12 bits)
69+
*
70+
* @return int<0, 4095>
71+
* @since 33.0
72+
*/
73+
public function sequenceId(): int;
74+
}
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)