Added Snowflake ID generators.

This commit is contained in:
flash 2025-03-26 01:40:42 +00:00
parent 60c2130182
commit ea549dd0eb
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
7 changed files with 341 additions and 2 deletions

View file

@ -1 +1 @@
0.2503.251852
0.2503.260138

View file

@ -0,0 +1,60 @@
<?php
// BinarySnowflake.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
/**
* Implements a snowflake generator wrapper that takes user provided data.
*/
class BinarySnowflake {
/**
* Reference to the underlying generator.
*/
public private(set) SnowflakeGenerator $generator;
/**
* @param bool $littleEndian Whether to convert the given data to a little endian integer or big endian.
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
*/
public function __construct(
public private(set) bool $littleEndian = false,
?SnowflakeGenerator $generator = null,
) {
$this->generator = $generator ?? new SnowflakeGenerator;
}
/**
* Generates the next snowflake.
*
* @param string $data Data to shove in.
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int
*/
public function next(string $data, DateTimeInterface|string|int|null $at = null): int {
$length = strlen($data);
if($length <= 1) {
$format = 'C';
$data = str_pad($data, 1, "\0", STR_PAD_LEFT);
} elseif($length === 2) {
$format = $this->littleEndian ? 'v' : 'n';
} elseif($length <= 4) {
$format = $this->littleEndian ? 'V' : 'N';
$data = str_pad($data, 4, "\0", STR_PAD_LEFT);
} else {
$format = $this->littleEndian ? 'P' : 'J';
if($length < 8)
$data = str_pad($data, 8, "\0", STR_PAD_LEFT);
elseif($length > 8)
$data = substr($data, 0, 8);
}
$data = unpack(sprintf('%sdata', $format), $data);
$data = is_array($data) && isset($data['data']) && is_int($data['data']) && $data['data'] >= 0 ? $data['data'] : 0;
return $this->generator->next($data, $at);
}
}

View file

@ -0,0 +1,53 @@
<?php
// IncrementalSnowflake.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
use InvalidArgumentException;
/**
* Implements a snowflake generator wrapper that just counts up.
*/
class IncrementalSnowflake {
/**
* Reference to the underlying generator.
*/
public private(set) SnowflakeGenerator $generator;
/**
* @param int<0, max> $counter Incremental counter value.
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
* @throws InvalidArgumentException if $counter is less than 0.
*/
public function __construct(
public private(set) int $counter = 0,
?SnowflakeGenerator $generator = null,
) {
if($counter < 0)
throw new InvalidArgumentException('$counter must be a positive integer');
$this->generator = $generator ?? new SnowflakeGenerator;
}
/**
* Increments the counter, with overflow protection.
*
* @return int<0, max>
*/
public function increment(): int {
return $this->counter = ($this->counter >= PHP_INT_MAX ? 0 : ($this->counter + 1));
}
/**
* Generates the next snowflake.
*
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int
*/
public function next(DateTimeInterface|string|int|null $at = null): int {
return $this->generator->next($this->increment(), $at);
}
}

View file

@ -0,0 +1,46 @@
<?php
// RandomSnowflake.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
use Random\{Engine,Randomizer};
/**
* Implements a snowflake generator wrapper that uses a randomizer engine.
*/
class RandomSnowflake {
/**
* Underlying randomness source.
*/
public private(set) Randomizer $randomizer;
/**
* Reference to the underlying generator.
*/
public private(set) SnowflakeGenerator $generator;
/**
* @param ?Engine $engine Randomness engine to use.
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
*/
public function __construct(
?Engine $engine = null,
?SnowflakeGenerator $generator = null,
) {
$this->randomizer = new Randomizer($engine);
$this->generator = $generator ?? new SnowflakeGenerator;
}
/**
* Generates the next snowflake.
*
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int
*/
public function next(DateTimeInterface|string|int|null $at = null): int {
return $this->generator->next(abs($this->randomizer->nextInt()), $at);
}
}

View file

@ -0,0 +1,83 @@
<?php
// SnowflakeGenerator.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
use InvalidArgumentException;
use Index\XDateTime;
/**
* Base snowflake generator.
*/
final class SnowflakeGenerator {
/**
* Masks the output for a signed 64-bit integer.
*/
public const int MASK = 0x7FFFFFFFFFFFFFFF;
/**
* Default epoch value, 1st of January 2013 at 0:00.
*/
public const int EPOCH = 1356998400000;
/**
* Amount by which to shift the timestamp left and clear space for sequence data.
*/
public const int SHIFT = 16;
/**
* Cached mask for timestamps.
*/
public private(set) int $tsMask;
/**
* Cached mask for sequence data.
*/
public private(set) int $sqMask;
/**
* @param int<0, max> $epoch Timestamp epoch.
* @param int<0, 63> $shift Amount to shift the timestamp left by.
* @throws InvalidArgumentException if $epoch or $shift is not within the given boundaries.
*/
public function __construct(
public private(set) int $epoch = self::EPOCH,
public private(set) int $shift = self::SHIFT,
) {
if($epoch < 0 || $epoch > self::MASK)
throw new InvalidArgumentException('$epoch is not a valid positive integer');
if($shift < 1 || $shift > 63)
throw new InvalidArgumentException('$shift must be between 0 and 63 inclusive');
$this->tsMask = ~(~0 << (63 - $shift));
$this->sqMask = ~(~0 << $shift);
}
/**
* Current timestamp with the epoch subtracted.
*
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int<0, max>
*/
public function nowMs(DateTimeInterface|string|int|null $at = null): int {
return max(0, XDateTime::toUnixMilliseconds($at ?? XDateTime::now(), true) - $this->epoch);
}
/**
* Generates a snowflake.
*
* @param int<0, max> $sequence Sequence data, will be masked to fit within $shift in the constructor.
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @throws InvalidArgumentException if $sequence is not a positive integer.
* @return int
*/
public function next(int $sequence = 0, DateTimeInterface|string|int|null $at = null): int {
if($sequence < 0)
throw new InvalidArgumentException('$sequence must be a positive integer');
return (($this->nowMs($at) & $this->tsMask) << $this->shift) | ($sequence & $this->sqMask);
}
}

View file

@ -1,7 +1,7 @@
<?php
// XDateTime.php
// Created: 2024-07-31
// Updated: 2025-01-18
// Updated: 2025-03-26
namespace Index;
@ -24,6 +24,30 @@ final class XDateTime {
return new DateTimeImmutable('now');
}
/**
* Returns the current date and time in seconds since the 0:00 on January 1st, 1970.
*
* @param DateTimeInterface|string|int $dt
* @return int
*/
public static function toUnixSeconds(DateTimeInterface|string|int|null $dt): int {
return (int)self::format($dt, 'U');
}
/**
* Returns the current date and time in milliseconds since the 0:00 on January 1st, 1970.
*
* @param DateTimeInterface|string|int $dt
* @param bool $intDtAsMilliseconds Whether to treat $dt as milliseconds instead of seconds if it is an integer.
* @return int
*/
public static function toUnixMilliseconds(DateTimeInterface|string|int|null $dt, bool $intDtAsMilliseconds = false): int {
if($intDtAsMilliseconds && is_int($dt))
$dt = new DateTimeImmutable(sprintf('@%.3F', (float)$dt / 1000));
return (int)self::format($dt, 'Uv');
}
private static function toCompareFormat(DateTimeInterface|string|int $dt): string {
if($dt instanceof DateTimeInterface)
return $dt->format(self::COMPARE_FORMAT);

73
tests/SnowflakeTest.php Normal file
View file

@ -0,0 +1,73 @@
<?php
// SnowflakeTest.php
// Created: 2025-03-26
// Updated: 2025-03-26
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
use Index\XDateTime;
use Index\Snowflake\{BinarySnowflake,IncrementalSnowflake,RandomSnowflake,SnowflakeGenerator};
use Random\Engine\Mt19937;
#[CoversClass(IncrementalSnowflake::class)]
#[CoversClass(SnowflakeGenerator::class)]
#[UsesClass(XDateTime::class)]
final class SnowflakeTest extends TestCase {
public function testSnowflakeGenerator(): void {
// default epoch and shift
$generator = new SnowflakeGenerator;
$this->assertSame(SnowflakeGenerator::EPOCH, $generator->epoch);
$this->assertSame(SnowflakeGenerator::SHIFT, $generator->shift);
$this->assertSame(0x7FFFFFFFFFFF, $generator->tsMask);
$this->assertSame(0xFFFF, $generator->sqMask);
$snowflake = $generator->next(0x123456, 1742946997548);
$this->assertSame(0x59DC543D2C3456, $snowflake);
// copy date exactly
$generator = new SnowflakeGenerator(epoch: 0);
$this->assertSame(0, $generator->epoch);
$this->assertSame(SnowflakeGenerator::SHIFT, $generator->shift);
$this->assertSame(0x7FFFFFFFFFFF, $generator->tsMask);
$this->assertSame(0xFFFF, $generator->sqMask);
$snowflake = $generator->next(0x654321, 1742946997548);
$this->assertSame(0x195CFBC952C4321, $snowflake);
// quirky shift
$generator = new SnowflakeGenerator(shift: 14);
$this->assertSame(SnowflakeGenerator::EPOCH, $generator->epoch);
$this->assertSame(14, $generator->shift);
$this->assertSame(0x1FFFFFFFFFFFF, $generator->tsMask);
$this->assertSame(0x3FFF, $generator->sqMask);
$snowflake = $generator->next(0xFDE9, 1742947403098);
$this->assertSame(0x1677169B56BDE9, $snowflake);
// incremental
$incremental = new IncrementalSnowflake(0x10001, $generator);
$this->assertSame(0x10002, $incremental->increment());
$this->assertSame(0x16771F5C1C4003, $incremental->next(1742949697649));
// big endian binary
$big = new BinarySnowflake(generator: new SnowflakeGenerator(shift: 20));
$this->assertSame(0x59DC920D23000DE, $big->next("\xDE", 1742951048483));
$this->assertSame(0x59DC920D230DEAD, $big->next("\xDE\xAD", 1742951048483));
$this->assertSame(0x59DC920D23EADBE, $big->next("\xDE\xAD\xBE", 1742951048483));
$this->assertSame(0x59DC920D23FBEAD, $big->next("\xDE\xAF\xBE\xAD", 1742951048483));
// little endian binary
$little = new BinarySnowflake(littleEndian: true, generator: new SnowflakeGenerator(shift: 20));
$this->assertSame(0x59DC920D23000DE, $little->next("\xDE", 1742951048483));
$this->assertSame(0x59DC920D230ADDE, $little->next("\xDE\xAD", 1742951048483));
$this->assertSame(0x59DC920D23DDE00, $little->next("\xDE\xAD\xBE", 1742951048483));
$this->assertSame(0x59DC920D23EAFDE, $little->next("\xDE\xAF\xBE\xAD", 1742951048483));
// random
$random = new RandomSnowflake(new Mt19937(2525));
$this->assertSame(0x59DCAD88D22D3E, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D2CFDC, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D27EA2, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D26E0A, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D24C90, $random->next(1742952849618));
}
}