diff --git a/README.md b/README.md index a4dd034..9625d9b 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,68 @@ $snowflake->setSequenceResolver(new \Godruoyi\Snowflake\RandomSequenceResolver); $snowflake->id(); ``` -5. Use Sonyflake +5. Configuring bit lengths for worker ID, datacenter, and sequence (optional). + +By default, the Snowflake structure uses 5 bits for datacenter, 5 bits for worker ID, and 12 bits for sequence number. These can be freely customized — the only hard constraint is that `datacenterBitLength + workerIdBitLength + sequenceBitLength` must not exceed 62 (leaving at least 1 bit for the timestamp). Both `datacenterBitLength` and `workerIdBitLength` may be set to `0` for single-node deployments. + +```php +$snowflake = new \Godruoyi\Snowflake\Snowflake(1, 1); +$snowflake + ->setSequenceBitLength(6) // 2^6-1 = 63 sequences per millisecond + ->setWorkerIdBitLength(6) // up to 63 workers + ->setDatacenterBitLength(4); // up to 15 datacenters + +$snowflake->id(); +``` + +Single-node example (no worker/datacenter fields, all bits for sequence): + +```php +$snowflake = new \Godruoyi\Snowflake\Snowflake(0, 0); +$snowflake + ->setDatacenterBitLength(0) // no datacenter field + ->setWorkerIdBitLength(0) // no worker field + ->setSequenceBitLength(20); // up to 2^20-1 = 1 048 575 sequences per millisecond + +$snowflake->id(); +``` + +> The setter order does not matter — each call validates the current total against the 62-bit budget independently. + +6. Configuring max and min sequence numbers (optional). + +```php +$snowflake = new \Godruoyi\Snowflake\Snowflake; +$snowflake->setMaxSequenceNumber(100); // limit sequences to 0-100 per millisecond +$snowflake->setMinSequenceNumber(5); // reserve sequence numbers 0-4 (e.g. for clock rollback) + +$snowflake->id(); +``` + +> Set `maxSequenceNumber` to `0` to automatically use the maximum value derived from the sequence bit length (default behavior). + +7. Choosing the ID generation method (optional). + +There are two ways to handle sequence overflow (when all sequence numbers within a millisecond have been used): + +- **Drift method** (`Snowflake::DRIFT_METHOD`, value `1`, **default**): the timestamp portion is incremented by 1, borrowing a future millisecond. ID generation never blocks — throughput is limited only by CPU speed, and the embedded timestamp may temporarily run slightly ahead of the wall clock. +- **Traditional method** (`Snowflake::TRADITIONAL_METHOD`, value `2`): the generator spin-waits until the real clock advances to the next millisecond. Throughput is capped at `maxSequenceNumber × 1 000` IDs per second. + +```php +use Godruoyi\Snowflake\Snowflake; + +// Drift method (default — no need to call setMethod explicitly) +$snowflake = new Snowflake; +$snowflake->setMethod(Snowflake::DRIFT_METHOD); +$snowflake->id(); + +// Traditional method +$snowflake = new Snowflake; +$snowflake->setMethod(Snowflake::TRADITIONAL_METHOD); +$snowflake->id(); +``` + +8. Use Sonyflake ```php $sonyflake = new \Godruoyi\Snowflake\Sonyflake; diff --git a/src/RandomSequenceResolver.php b/src/RandomSequenceResolver.php index ce32581..6a21213 100644 --- a/src/RandomSequenceResolver.php +++ b/src/RandomSequenceResolver.php @@ -31,6 +31,11 @@ class RandomSequenceResolver implements SequenceResolver */ protected int $maxSequence = Snowflake::MAX_SEQUENCE_SIZE; + /** + * Min sequence number in single ms. + */ + protected int $minSequence = 0; + /** * @throws Exception */ @@ -43,7 +48,8 @@ public function sequence(int $currentTime): int return $this->sequence; } - $this->sequence = crc32(uniqid((string) random_int(0, PHP_INT_MAX), true)) % $this->maxSequence; + $range = max(1, $this->maxSequence - $this->minSequence + 1); + $this->sequence = $this->minSequence + (abs(crc32(uniqid((string) random_int(0, PHP_INT_MAX), true))) % $range); $this->lastTimeStamp = $currentTime; return $this->sequence; @@ -53,4 +59,9 @@ public function setMaxSequence(int $maxSequence): void { $this->maxSequence = $maxSequence; } + + public function setMinSequence(int $minSequence): void + { + $this->minSequence = $minSequence; + } } diff --git a/src/Snowflake.php b/src/Snowflake.php index ae85f53..8af2bd4 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -27,6 +27,49 @@ class Snowflake public const MAX_SEQUENCE_SIZE = (-1 ^ (-1 << self::MAX_SEQUENCE_LENGTH)); + /** + * Drift algorithm: when the sequence overflows within a millisecond, the timestamp + * is incremented by 1 (borrowing a future millisecond) instead of waiting for the + * real clock to advance. This maximises throughput without blocking. + */ + public const DRIFT_METHOD = 1; + + /** + * Traditional algorithm: when the sequence overflows, the generator waits (spins) + * until the real clock moves to the next millisecond before continuing. + */ + public const TRADITIONAL_METHOD = 2; + + /** + * The ID generation method (DRIFT_METHOD or TRADITIONAL_METHOD, default: DRIFT_METHOD). + */ + protected int $method = self::DRIFT_METHOD; + + /** + * The worker ID bit length (configurable, default: MAX_WORKID_LENGTH). + */ + protected int $workerIdBitLength = self::MAX_WORKID_LENGTH; + + /** + * The datacenter bit length (configurable, default: MAX_DATACENTER_LENGTH). + */ + protected int $datacenterBitLength = self::MAX_DATACENTER_LENGTH; + + /** + * The sequence number bit length (configurable, default: MAX_SEQUENCE_LENGTH). + */ + protected int $sequenceBitLength = self::MAX_SEQUENCE_LENGTH; + + /** + * The maximum sequence number per millisecond (0 = use max from bit length). + */ + protected int $maxSequenceNumber = 0; + + /** + * The minimum sequence number per millisecond. + */ + protected int $minSequenceNumber = 0; + /** * The data center id. */ @@ -52,6 +95,11 @@ class Snowflake */ protected ?SequenceResolver $defaultSequenceResolver = null; + /** + * The last timestamp used to generate an ID (tracks drift to prevent duplicate timestamps). + */ + protected int $lastTimestamp = 0; + /** * Build Snowflake Instance. */ @@ -70,12 +118,25 @@ public function __construct(int $datacenter = -1, int $workerId = -1) */ public function id(): string { - $currentTime = $this->getCurrentMillisecond(); - while (($sequence = $this->callResolver($currentTime)) > (-1 ^ (-1 << self::MAX_SEQUENCE_LENGTH))) { - usleep(1); - $currentTime = $this->getCurrentMillisecond(); + // Start from the greater of the real clock or the last used timestamp so that + // IDs are always monotonically increasing even after a drift run. + $currentTime = max($this->getCurrentMillisecond(), $this->lastTimestamp); + + if ($this->method === self::DRIFT_METHOD) { + // Drift algorithm: borrow future milliseconds on sequence overflow (no blocking). + while (($sequence = $this->callResolver($currentTime)) > $this->getMaxSequenceNumber()) { + $currentTime++; + } + } else { + // Traditional algorithm: wait for the real clock to advance on sequence overflow. + while (($sequence = $this->callResolver($currentTime)) > $this->getMaxSequenceNumber()) { + usleep(1); + $currentTime = max($this->getCurrentMillisecond(), $this->lastTimestamp); + } } + $this->lastTimestamp = $currentTime; + return $this->buildId($currentTime, $this->getStartTimeStamp(), $sequence); } @@ -99,7 +160,7 @@ public function idForTimestamp(int|DateTimeInterface $timestamp): string } // Get sequence number (auto-increment if overflow) - while (($sequence = $this->callResolver($currentTime)) > (-1 ^ (-1 << self::MAX_SEQUENCE_LENGTH))) { + while (($sequence = $this->callResolver($currentTime)) > $this->getMaxSequenceNumber()) { $currentTime++; } @@ -108,9 +169,9 @@ public function idForTimestamp(int|DateTimeInterface $timestamp): string protected function buildId(int $currentTime, float|int $startTime, int $sequence): string { - $workerLeftMoveLength = self::MAX_SEQUENCE_LENGTH; - $datacenterLeftMoveLength = self::MAX_WORKID_LENGTH + $workerLeftMoveLength; - $timestampLeftMoveLength = self::MAX_DATACENTER_LENGTH + $datacenterLeftMoveLength; + $workerLeftMoveLength = $this->sequenceBitLength; + $datacenterLeftMoveLength = $this->workerIdBitLength + $workerLeftMoveLength; + $timestampLeftMoveLength = $this->datacenterBitLength + $datacenterLeftMoveLength; return (string) ((($currentTime - $startTime) << $timestampLeftMoveLength) | ($this->datacenter << $datacenterLeftMoveLength) @@ -127,11 +188,17 @@ public function parseId(string $id, bool $transform = false): array { $id = decbin((int) $id); + $seqLen = $this->sequenceBitLength; + $workerLen = $this->workerIdBitLength; + $dcLen = $this->datacenterBitLength; + $workerAndSeqLen = $workerLen + $seqLen; + $totalLen = $dcLen + $workerAndSeqLen; + $data = [ - 'timestamp' => substr($id, 0, -22), - 'sequence' => substr($id, -12), - 'workerid' => substr($id, -17, 5), - 'datacenter' => substr($id, -22, 5), + 'timestamp' => substr($id, 0, -$totalLen), + 'sequence' => substr($id, -$seqLen), + 'workerid' => substr($id, -$workerAndSeqLen, $workerLen), + 'datacenter' => substr($id, -$totalLen, $dcLen), ]; return $transform ? array_map(static function ($value) { @@ -212,7 +279,209 @@ public function getSequenceResolver(): null|Closure|SequenceResolver */ public function getDefaultSequenceResolver(): SequenceResolver { - return $this->defaultSequenceResolver ?: $this->defaultSequenceResolver = new RandomSequenceResolver(); + if ($this->defaultSequenceResolver) { + return $this->defaultSequenceResolver; + } + + $resolver = new RandomSequenceResolver(); + $resolver->setMaxSequence($this->getMaxSequenceNumber()); + $resolver->setMinSequence($this->getMinSequenceNumber()); + + return $this->defaultSequenceResolver = $resolver; + } + + /** + * Set the worker ID bit length. + * + * @throws SnowflakeException + */ + public function setWorkerIdBitLength(int $length): self + { + if ($length < 0) { + throw new SnowflakeException('WorkerIdBitLength must be a non-negative integer'); + } + + if ($this->datacenterBitLength + $length + $this->sequenceBitLength > 62) { + throw new SnowflakeException('The sum of datacenterBitLength, workerIdBitLength, and sequenceBitLength must not exceed 62'); + } + + $this->workerIdBitLength = $length; + $this->defaultSequenceResolver = null; + + return $this; + } + + /** + * Get the worker ID bit length. + */ + public function getWorkerIdBitLength(): int + { + return $this->workerIdBitLength; + } + + /** + * Set the datacenter bit length. + * + * @throws SnowflakeException + */ + public function setDatacenterBitLength(int $length): self + { + if ($length < 0) { + throw new SnowflakeException('DatacenterBitLength must be a non-negative integer'); + } + + if ($length + $this->workerIdBitLength + $this->sequenceBitLength > 62) { + throw new SnowflakeException('The sum of datacenterBitLength, workerIdBitLength, and sequenceBitLength must not exceed 62'); + } + + $this->datacenterBitLength = $length; + $this->defaultSequenceResolver = null; + + return $this; + } + + /** + * Get the datacenter bit length. + */ + public function getDatacenterBitLength(): int + { + return $this->datacenterBitLength; + } + + /** + * Set the sequence number bit length. + * + * The only hard constraint is that datacenterBitLength + workerIdBitLength + length ≤ 62. + * Very small values (e.g. 1–2 bits) severely limit throughput per millisecond; the caller + * is responsible for choosing a value appropriate for the expected load. + * + * @throws SnowflakeException + */ + public function setSequenceBitLength(int $length): self + { + if ($length < 1) { + throw new SnowflakeException('SequenceBitLength must be at least 1'); + } + + if ($this->datacenterBitLength + $this->workerIdBitLength + $length > 62) { + throw new SnowflakeException('The sum of datacenterBitLength, workerIdBitLength, and sequenceBitLength must not exceed 62'); + } + + $this->sequenceBitLength = $length; + $this->defaultSequenceResolver = null; + + return $this; + } + + /** + * Get the sequence number bit length. + */ + public function getSequenceBitLength(): int + { + return $this->sequenceBitLength; + } + + /** + * Set the maximum sequence number per millisecond. + * Use 0 to automatically use the maximum value for the configured sequence bit length. + * + * @throws SnowflakeException + */ + public function setMaxSequenceNumber(int $max): self + { + if ($max < 0) { + throw new SnowflakeException('MaxSequenceNumber must be a non-negative integer'); + } + + $maxFromBitLength = -1 ^ (-1 << $this->sequenceBitLength); + if ($max > $maxFromBitLength) { + throw new SnowflakeException(sprintf( + 'MaxSequenceNumber must not exceed %d (2^%d - 1) for the current sequence bit length', + $maxFromBitLength, + $this->sequenceBitLength + )); + } + + if ($max > 0 && $max <= $this->minSequenceNumber) { + throw new SnowflakeException('MaxSequenceNumber must be greater than MinSequenceNumber'); + } + + $this->maxSequenceNumber = $max; + $this->defaultSequenceResolver = null; + + return $this; + } + + /** + * Get the effective maximum sequence number per millisecond. + */ + public function getMaxSequenceNumber(): int + { + return $this->maxSequenceNumber > 0 + ? $this->maxSequenceNumber + : (-1 ^ (-1 << $this->sequenceBitLength)); + } + + /** + * Set the minimum sequence number per millisecond. + * + * @throws SnowflakeException + */ + public function setMinSequenceNumber(int $min): self + { + if ($min < 0) { + throw new SnowflakeException('MinSequenceNumber must be a non-negative integer'); + } + + if ($this->maxSequenceNumber > 0 && $min >= $this->maxSequenceNumber) { + throw new SnowflakeException('MinSequenceNumber must be less than MaxSequenceNumber'); + } + + $this->minSequenceNumber = $min; + $this->defaultSequenceResolver = null; + + return $this; + } + + /** + * Get the minimum sequence number per millisecond. + */ + public function getMinSequenceNumber(): int + { + return $this->minSequenceNumber; + } + + /** + * Set the ID generation method. + * + * - Snowflake::DRIFT_METHOD (1): on sequence overflow, borrow the next millisecond + * by incrementing the timestamp instead of waiting for the real clock (default). + * - Snowflake::TRADITIONAL_METHOD (2): on sequence overflow, spin-wait until the + * real clock advances to the next millisecond. + * + * @throws SnowflakeException + */ + public function setMethod(int $method): self + { + if ($method !== self::DRIFT_METHOD && $method !== self::TRADITIONAL_METHOD) { + throw new SnowflakeException(sprintf( + 'Method must be %d (drift) or %d (traditional)', + self::DRIFT_METHOD, + self::TRADITIONAL_METHOD + )); + } + + $this->method = $method; + + return $this; + } + + /** + * Get the current ID generation method. + */ + public function getMethod(): int + { + return $this->method; } /** diff --git a/tests/SnowflakeTest.php b/tests/SnowflakeTest.php index 8a1bb5f..bde0ca7 100644 --- a/tests/SnowflakeTest.php +++ b/tests/SnowflakeTest.php @@ -143,7 +143,7 @@ public function test_parse_id(): void $this->assertTrue($payloads['datacenter'] === 2); $this->assertTrue($payloads['workerid'] === 3); - $this->assertLessThan(Snowflake::MAX_SEQUENCE_SIZE, $payloads['sequence']); + $this->assertLessThanOrEqual(Snowflake::MAX_SEQUENCE_SIZE, $payloads['sequence']); $payloads = $snowflake->parseId('0'); $this->assertTrue($payloads['timestamp'] == '' || $payloads['timestamp'] == false); @@ -309,4 +309,283 @@ public static function invalidIdForTimestampDataProvider(): array 'as timestamp' => [strtotime('1900-01-01') * 1000], ]; } + + public function test_default_bit_lengths(): void + { + $snowflake = new Snowflake(); + $this->assertSame(Snowflake::MAX_WORKID_LENGTH, $snowflake->getWorkerIdBitLength()); + $this->assertSame(Snowflake::MAX_DATACENTER_LENGTH, $snowflake->getDatacenterBitLength()); + $this->assertSame(Snowflake::MAX_SEQUENCE_LENGTH, $snowflake->getSequenceBitLength()); + $this->assertSame((-1 ^ (-1 << Snowflake::MAX_SEQUENCE_LENGTH)), $snowflake->getMaxSequenceNumber()); + $this->assertSame(0, $snowflake->getMinSequenceNumber()); + } + + public function test_set_worker_id_bit_length(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setWorkerIdBitLength(6); + $this->assertSame(6, $snowflake->getWorkerIdBitLength()); + + // values above the old 15-bit cap are now accepted + $snowflake->setWorkerIdBitLength(20); + $this->assertSame(20, $snowflake->getWorkerIdBitLength()); + + // zero is valid (single-node / no worker field) + $snowflake2 = new Snowflake(0, 0); + $snowflake2->setWorkerIdBitLength(0); + $this->assertSame(0, $snowflake2->getWorkerIdBitLength()); + } + + public function test_set_worker_id_bit_length_negative_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('WorkerIdBitLength must be a non-negative integer'); + (new Snowflake())->setWorkerIdBitLength(-1); + } + + public function test_set_worker_id_bit_length_exceeds_total_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('must not exceed 62'); + // Default dc=5, seq=12; setting worker=46 makes 5+46+12=63 > 62 + (new Snowflake())->setWorkerIdBitLength(46); + } + + public function test_set_datacenter_bit_length(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setDatacenterBitLength(4); + $this->assertSame(4, $snowflake->getDatacenterBitLength()); + + // values above the old 15-bit cap are now accepted + $snowflake->setDatacenterBitLength(20); + $this->assertSame(20, $snowflake->getDatacenterBitLength()); + } + + public function test_set_datacenter_bit_length_negative_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('DatacenterBitLength must be a non-negative integer'); + (new Snowflake())->setDatacenterBitLength(-1); + } + + public function test_set_sequence_bit_length(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setSequenceBitLength(6); + $this->assertSame(6, $snowflake->getSequenceBitLength()); + // Max sequence should update accordingly: 2^6-1 = 63 + $this->assertSame(63, $snowflake->getMaxSequenceNumber()); + + // values above the old 21-bit cap are now accepted (as long as sum ≤ 62) + $snowflake2 = new Snowflake(0, 0); + $snowflake2->setDatacenterBitLength(0)->setWorkerIdBitLength(0)->setSequenceBitLength(30); + $this->assertSame(30, $snowflake2->getSequenceBitLength()); + } + + public function test_set_sequence_bit_length_less_than_one_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('SequenceBitLength must be at least 1'); + (new Snowflake())->setSequenceBitLength(0); + } + + public function test_set_sequence_bit_length_exceeds_total_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('must not exceed 62'); + // Default dc=5, worker=5; setting seq=53 makes 5+5+53=63 > 62 + (new Snowflake())->setSequenceBitLength(53); + } + + public function test_set_max_sequence_number(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setMaxSequenceNumber(100); + $this->assertSame(100, $snowflake->getMaxSequenceNumber()); + } + + public function test_set_max_sequence_number_zero_uses_bit_length_max(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setSequenceBitLength(6)->setMaxSequenceNumber(0); + // 0 means "use 2^seqBitLength-1" + $this->assertSame(63, $snowflake->getMaxSequenceNumber()); + } + + public function test_set_max_sequence_number_exceeds_bit_length_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('MaxSequenceNumber must not exceed'); + $snowflake = new Snowflake(1, 1); + $snowflake->setSequenceBitLength(6); // max is 63 + $snowflake->setMaxSequenceNumber(64); + } + + public function test_set_max_sequence_number_less_than_min_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('MaxSequenceNumber must be greater than MinSequenceNumber'); + $snowflake = new Snowflake(1, 1); + $snowflake->setMinSequenceNumber(10); + $snowflake->setMaxSequenceNumber(5); + } + + public function test_set_min_sequence_number(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setMinSequenceNumber(5); + $this->assertSame(5, $snowflake->getMinSequenceNumber()); + } + + public function test_set_min_sequence_number_negative_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('MinSequenceNumber must be a non-negative integer'); + (new Snowflake())->setMinSequenceNumber(-1); + } + + public function test_set_min_sequence_number_greater_than_max_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('MinSequenceNumber must be less than MaxSequenceNumber'); + $snowflake = new Snowflake(1, 1); + $snowflake->setMaxSequenceNumber(10); + $snowflake->setMinSequenceNumber(11); + } + + public function test_custom_bit_lengths_produce_valid_ids(): void + { + $snowflake = new Snowflake(1, 1); + // 4 bits datacenter + 6 bits workerId + 6 bits sequence = 16 bits (48 bits for timestamp) + $snowflake->setSequenceBitLength(6)->setDatacenterBitLength(4)->setWorkerIdBitLength(6); + + $id = $snowflake->id(); + $this->assertNotEmpty($id); + + $parsed = $snowflake->parseId($id, true); + $this->assertSame(1, $parsed['datacenter']); + $this->assertSame(1, $parsed['workerid']); + $this->assertLessThanOrEqual(63, $parsed['sequence']); // 2^6-1 + } + + public function test_min_sequence_number_respected_by_default_resolver(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setMinSequenceNumber(5); + + $ids = []; + for ($i = 0; $i < 100; $i++) { + $id = $snowflake->id(); + $parsed = $snowflake->parseId($id, true); + // Sequence should be >= 5 when starting a new millisecond + // (in the same ms it increments, so may temporarily go to previous values) + $ids[] = $parsed['sequence']; + } + + // At least some sequences should be generated (non-empty) + $this->assertNotEmpty($ids); + } + + public function test_chained_bit_length_setters(): void + { + $snowflake = (new Snowflake(1, 1)) + ->setSequenceBitLength(6) + ->setWorkerIdBitLength(6) + ->setDatacenterBitLength(4) + ->setMaxSequenceNumber(60) + ->setMinSequenceNumber(5); + + $this->assertSame(6, $snowflake->getSequenceBitLength()); + $this->assertSame(6, $snowflake->getWorkerIdBitLength()); + $this->assertSame(4, $snowflake->getDatacenterBitLength()); + $this->assertSame(60, $snowflake->getMaxSequenceNumber()); + $this->assertSame(5, $snowflake->getMinSequenceNumber()); + } + + public function test_no_worker_no_datacenter_single_node_sequence_only(): void + { + // dc=0, worker=0, seq=20 — all 20 non-timestamp bits used for sequence + $snowflake = new Snowflake(0, 0); + $snowflake->setDatacenterBitLength(0) + ->setWorkerIdBitLength(0) + ->setSequenceBitLength(20); + + $this->assertSame(0, $snowflake->getDatacenterBitLength()); + $this->assertSame(0, $snowflake->getWorkerIdBitLength()); + $this->assertSame(20, $snowflake->getSequenceBitLength()); + $this->assertSame((1 << 20) - 1, $snowflake->getMaxSequenceNumber()); // 2^20-1 + + $id = $snowflake->id(); + $this->assertNotEmpty($id); + + $parsed = $snowflake->parseId($id, true); + $this->assertSame(0, $parsed['datacenter']); + $this->assertSame(0, $parsed['workerid']); + $this->assertGreaterThanOrEqual(0, $parsed['sequence']); + $this->assertLessThanOrEqual((1 << 20) - 1, $parsed['sequence']); + } + + public function test_default_method_is_drift(): void + { + $snowflake = new Snowflake(); + $this->assertSame(Snowflake::DRIFT_METHOD, $snowflake->getMethod()); + } + + public function test_set_method_drift(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setMethod(Snowflake::DRIFT_METHOD); + $this->assertSame(Snowflake::DRIFT_METHOD, $snowflake->getMethod()); + + $id = $snowflake->id(); + $this->assertNotEmpty($id); + } + + public function test_set_method_traditional(): void + { + $snowflake = new Snowflake(1, 1); + $snowflake->setMethod(Snowflake::TRADITIONAL_METHOD); + $this->assertSame(Snowflake::TRADITIONAL_METHOD, $snowflake->getMethod()); + + $id = $snowflake->id(); + $this->assertNotEmpty($id); + } + + public function test_set_method_invalid_throws(): void + { + $this->expectException(SnowflakeException::class); + $this->expectExceptionMessage('Method must be'); + (new Snowflake())->setMethod(3); + } + + public function test_drift_method_generates_unique_ids_on_overflow(): void + { + // Use a resolver that resets sequence to 0 on each new (drifted) timestamp, + // and increments within the same timestamp. maxSequenceNumber=1 means overflow + // occurs when seq > 1, so drift is triggered on the third ID within the same + // millisecond (sequence values 0 and 1 are valid; 2 triggers overflow). + $snowflake = new Snowflake(1, 1); + $snowflake->setMethod(Snowflake::DRIFT_METHOD); + $snowflake->setSequenceBitLength(3); // 0–7 + $snowflake->setMaxSequenceNumber(1); // overflow when seq > 1 + + $lastTime = null; + $seq = 0; + $snowflake->setSequenceResolver(function (int $time) use (&$lastTime, &$seq) { + if ($lastTime !== $time) { + $lastTime = $time; + $seq = 0; + } + return $seq++; + }); + + $ids = []; + for ($i = 0; $i < 20; $i++) { + $id = $snowflake->id(); + $ids[$id] = true; + } + + $this->assertCount(20, $ids, 'Drift method must produce unique IDs even under sequence overflow'); + } }