Skip to content

Commit 01e74fa

Browse files
committed
Initial commit
0 parents  commit 01e74fa

12 files changed

+324
-0
lines changed

LICENSE

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
The MIT License (MIT)
2+
Copyright © 2019 Ennexa Technologies Private Limited, https://www.ennexa.com <[email protected]>
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5+
6+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7+
8+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
### Snowflake ID Generator
2+
3+
An implementation of [Snowflake](https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html) ID generator that works without a dedicated daemon.
4+
5+
The generator can be used with `PHP-FPM` or `mod_php`.
6+
7+
### Installation
8+
9+
composer require ennexa/snowflake
10+
11+
### Usage
12+
13+
// First we need to create a store for saving the state
14+
$store = new Snowflake\Store\RedisStore(new \Redis);
15+
// $store = new Snowflake\Store\FileStore('/path/to/store/state');
16+
17+
// Create a generator with the created store
18+
$generator = new Snowflake\Generator($store, $instanceId = 0);
19+
20+
// Use Generator::nextId to generate the next unique id
21+
echo $generator->nextId();
22+
23+
24+
### Credits
25+
26+
This generator was originally created for use on [Prokerala.com](https://www.prokerala.com).

composer.json

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "ennexa/snowflake",
3+
"description": "Snowflake ID Generator that does not require a daemon",
4+
"type": "library",
5+
"require": {
6+
"php": ">=5.6.0"
7+
},
8+
"license": "MIT",
9+
"authors": [
10+
{
11+
"name": "Prokerala",
12+
"email": "[email protected]",
13+
"homepage": "https://www.prokerala.com"
14+
}
15+
],
16+
"minimum-stability": "stable",
17+
"autoload": {
18+
"psr-4": {
19+
"Ennexa\\Snowflake\\": "src/Snowflake"
20+
}
21+
}
22+
}

src/Snowflake/.DS_Store

6 KB
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Ennexa\Snowflake\Exception;
4+
5+
class InvalidArgumentException
6+
extends \InvalidArgumentException
7+
implements \Ennexa\Snowflake\ExceptionInterface {
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Ennexa\Snowflake\Exception;
4+
5+
class InvalidSystemClockException
6+
extends \Exception
7+
implements \Ennexa\Snowflake\ExceptionInterface {
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Ennexa\Snowflake\Exception;
4+
5+
class RuntimeException
6+
extends \RuntimeException
7+
implements \Ennexa\Snowflake\ExceptionInterface {
8+
9+
}

src/Snowflake/ExceptionInterface.php

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Ennexa\Snowflake;
4+
5+
interface ExceptionInterface {
6+
7+
}

src/Snowflake/Generator.php

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
namespace Ennexa\Snowflake;
4+
5+
use Exception\InvalidArgumentException;
6+
use Exception\InvalidSystemClockException;
7+
8+
class Generator {
9+
const NODE_LEN = 8;
10+
const WORKER_LEN = 8;
11+
const SEQUENCE_LEN = 8;
12+
13+
private $instanceId = 0;
14+
private $startEpoch = 1546300800000;
15+
private $sequenceMax;
16+
private $store;
17+
18+
private static function getMaxValue(int $len)
19+
{
20+
return -1 ^ (-1 << $len);
21+
}
22+
23+
public static function generateInstanceId($nodeId = 0, $workerId = 0)
24+
{
25+
$nodeIdMax = $this->getMaxValue(self::NODE_LEN);
26+
if ($nodeId < 0 || $nodeId > $nodeIdMax) {
27+
throw InvalidArgumentException("Node ID should be between 0 and $nodeIdMax");
28+
}
29+
30+
$workerIdMax = $this->getMaxValue(self::WORKER_LEN);
31+
if ($workerId < 0 || $workerId > $workerIdMax) {
32+
throw InvalidArgumentException("Worker ID should be between 0 and $workerIdMax");
33+
}
34+
35+
return $nodeId << self::WORKER_LEN | $workerId;
36+
}
37+
38+
public function __construct(StoreInterface $store, int $instanceId = 0, ?int $startEpoch = null)
39+
{
40+
$this->setInstanceId($instanceId);
41+
$this->setStore($store);
42+
43+
if (!is_null($startEpoch)) {
44+
$this->startEpoch = $startEpoch;
45+
}
46+
47+
$this->sequenceMask = -1 ^ (-1 << self::SEQUENCE_LEN);
48+
$this->sequenceMax = -1 & $this->sequenceMask;
49+
$this->tickShift = self::NODE_LEN + self::WORKER_LEN + self::SEQUENCE_LEN;
50+
}
51+
52+
/**
53+
* Set the sequence store
54+
*
55+
* @param int Instance Id
56+
* @return void
57+
*/
58+
public function setStore(StoreInterface $store)
59+
{
60+
$this->store = $store;
61+
}
62+
63+
/**
64+
* Get the current generator instance id
65+
*
66+
* @param int Instance Id
67+
* @return void
68+
*/
69+
public function getInstanceId()
70+
{
71+
return $this->instanceId >> self::SEQUENCE_LEN;
72+
}
73+
74+
/**
75+
* Set the instance id for the generator instance
76+
*
77+
* @param int Instance Id
78+
* @return void
79+
*/
80+
public function setInstanceId(int $instanceId)
81+
{
82+
$this->instanceId = $instanceId << self::SEQUENCE_LEN;
83+
}
84+
85+
/**
86+
* Get the next sequence
87+
*
88+
* @return array timestamp and sequence number
89+
*/
90+
public function nextSequence()
91+
{
92+
return $this->store->next($this->instanceId);
93+
}
94+
95+
/**
96+
* Generate a unique id based on the epoch and instance id
97+
*
98+
* @return int unique 64-bit id
99+
* @throws InvalidSystemClockException
100+
*/
101+
public function nextId()
102+
{
103+
list($timestamp, $sequence) = $this->nextSequence();
104+
105+
if ($sequence < 0) {
106+
$ticks = $timestamp - $this->startEpoch;
107+
throw new InvalidSystemClockException("Clock moved backwards or wrapped around. Refusing to generate id for $ticks ticks");
108+
}
109+
110+
$ticks = ($timestamp - $this->startEpoch) << $this->tickShift;
111+
112+
return (string)($ticks | $this->instanceId | $sequence);
113+
}
114+
}

src/Snowflake/Store/FileStore.php

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace Ennexa\Snowflake\Store;
4+
5+
use Ennexa\Snowflake\StoreInterface;
6+
use Ennexa\Snowflake\Exception\RuntimeException;
7+
8+
class FileStore implements StoreInterface
9+
{
10+
public function __construct($cacheDir)
11+
{
12+
$this->cacheDir = $cacheDir;
13+
14+
if (!file_exists($cacheDir)) {
15+
$status = mkdir($cacheDir, 0700, true);
16+
if (!$status) {
17+
throw new RuntimeException("[Snowflake] Failed to created directory - {$cacheDir}");
18+
}
19+
}
20+
}
21+
22+
public function next(int $instanceId):array
23+
{
24+
$timestamp = (int)floor(microtime(true) * 1000);
25+
$file = "last_ts_{$instanceId}.fc";
26+
27+
$fp = fopen("{$this->cacheDir}/{$file}", "c+");
28+
if (!$fp) {
29+
throw new RuntimeException('[Snowflake] Failed to open file');
30+
}
31+
32+
$counter = 100;
33+
do {
34+
// Try to acquire exclusive lock. Fails after 5ms
35+
$locked = flock($fp, LOCK_EX | LOCK_NB);
36+
usleep(50);
37+
} while($counter--);
38+
39+
if (!$locked) {
40+
throw new RuntimeException('[Snowflake] Failed to acquire lock');
41+
}
42+
43+
try {
44+
fseek($fp, 0);
45+
$content = fread($fp, 50);
46+
47+
$lastTimestamp = $sequence = 0;
48+
if ($content) {
49+
list($lastTimestamp, $sequence) = unserialize($content);
50+
}
51+
if ($lastTimestamp > $timestamp) {
52+
$sequence = -1;
53+
} else if ($lastTimestamp < $timestamp) {
54+
$sequence = 0;
55+
} else {
56+
$sequence++;
57+
}
58+
if ($sequence >= 0) {
59+
fseek($fp, 0);
60+
ftruncate($fp, 0);
61+
fwrite($fp, serialize([$timestamp, $sequence]));
62+
fflush($fp);
63+
}
64+
} finally {
65+
flock($fp, LOCK_UN);
66+
fclose($fp);
67+
}
68+
69+
return [$timestamp, $sequence];
70+
}
71+
}

src/Snowflake/Store/RedisStore.php

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace Ennexa\Snowflake\Store;
4+
5+
use Ennexa\Snowflake\StoreInterface;
6+
use Ennexa\Snowflake\Exception\RuntimeException;
7+
8+
class RedisStore implements StoreInterface
9+
{
10+
private $backend;
11+
public function __construct(\Redis $redis)
12+
{
13+
$this->backend = $redis;
14+
}
15+
16+
private function getLuaScript()
17+
{
18+
return <<<LUA
19+
redis.replicate_commands()
20+
local ts = redis.call('time')
21+
local old_ts = tonumber(redis.call('get',KEYS[1] .. '.ts')) or 0
22+
local new_ts = ts[1] * 1000 + (ts[2] - ts[2] % 1000) / 1000
23+
24+
redis.call('set', KEYS[1] .. '.ts', new_ts)
25+
redis.log(3, old_ts .. '|' .. new_ts)
26+
if (old_ts < new_ts) then
27+
redis.call('set', KEYS[1] .. '.seq', 0)
28+
return {new_ts, 0}
29+
else
30+
return {new_ts, redis.call('incr', KEYS[1] .. '.seq')}
31+
end
32+
LUA;
33+
}
34+
35+
public function next(int $instanceId):array
36+
{
37+
try {
38+
return $this->backend->eval($this->getLuaScript(), [
39+
"snowflake_{$instanceId}"
40+
], 1);
41+
} catch (\RedisException $e) {
42+
throw new RuntimeException('[Snowflake] Failed to generate sequence', null, $e);
43+
}
44+
}
45+
}

src/Snowflake/StoreInterface.php

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
namespace Ennexa\Snowflake;
3+
4+
interface StoreInterface {
5+
public function next(int $instanceId):array;
6+
}

0 commit comments

Comments
 (0)