diff --git a/app/Services/SnowflakeService.php b/app/Services/SnowflakeService.php index 725da4b72..06f7e6e75 100644 --- a/app/Services/SnowflakeService.php +++ b/app/Services/SnowflakeService.php @@ -26,13 +26,20 @@ class SnowflakeService public static function next() { - $seq = Cache::get('snowflake:seq'); - - if (! $seq) { + /* + * Atomically obtain the next sequence value. Cache::increment() + * returns the post-increment value, so each call gets a distinct seq. + * A previous version read the value with Cache::get() and only + * incremented the store, leaving the local $seq stale — the first two + * calls both used seq=1, so two IDs generated in the same millisecond + * with the same datacenter/worker collided (UNIQUE violation). + */ + $seq = Cache::increment('snowflake:seq'); + + if (! is_int($seq)) { + // Cache miss or non-numeric store value: (re)seed the counter. Cache::put('snowflake:seq', 1); $seq = 1; - } else { - Cache::increment('snowflake:seq'); } if ($seq >= 4095) { diff --git a/tests/Unit/SnowflakeServiceTest.php b/tests/Unit/SnowflakeServiceTest.php new file mode 100644 index 000000000..0e07e2c87 --- /dev/null +++ b/tests/Unit/SnowflakeServiceTest.php @@ -0,0 +1,280 @@ +> 12) & 0x1F; +} + +/** Extract the 5-bit datacenter id (bits 17..21). */ +function datacenterOf(int $id): int +{ + return ($id >> 17) & 0x1F; +} + +beforeEach(function () { + // Remove randomness from the datacenter/worker bits so identical + // timestamp + sequence produces byte-identical IDs. This makes the + // collision detectable deterministically instead of probabilistically. + config([ + 'snowflake.datacenter_id' => 1, + 'snowflake.worker_id' => 1, + ]); + + // Start from a clean sequence counter for every test. + Cache::forget('snowflake:seq'); +}); + +it('generates positive 64-bit integer ids', function () { + $id = SnowflakeService::next(); + + expect($id)->toBeInt(); + expect($id)->toBeGreaterThan(0); +}); + +it('encodes the configured datacenter and worker ids into the id', function () { + config(['snowflake.datacenter_id' => 7, 'snowflake.worker_id' => 3]); + + $id = SnowflakeService::next(); + + expect(($id >> 17) & 0x1F)->toBe(7); + expect(($id >> 12) & 0x1F)->toBe(3); +}); + +/* +|-------------------------------------------------------------------------- +| Sequence behaviour (core regression) +|-------------------------------------------------------------------------- +*/ + +it('does not reuse the sequence on the first two ids', function () { + // REGRESSION: the old code emitted seq 1, 1 for the first two calls. + $first = seqOf(SnowflakeService::next()); + $second = seqOf(SnowflakeService::next()); + + expect($first)->not->toBe($second); +}); + +it('emits a strictly increasing sequence for consecutive calls', function () { + $seqs = []; + for ($i = 0; $i < 10; $i++) { + $seqs[] = seqOf(SnowflakeService::next()); + } + + // Expect 1, 2, 3, ... 10 — each call advances the sequence by exactly one. + expect($seqs)->toBe(range(1, 10)); +}); + +it('produces unique ids for a burst minted in the same millisecond', function () { + // Freeze time so every id in the burst shares the same timestamp bits. + // With datacenter/worker also fixed, uniqueness depends entirely on the + // sequence — exactly the condition that regressed. + Carbon::setTestNow(Carbon::create(2026, 6, 1, 12, 0, 0)); + + try { + $ids = []; + for ($i = 0; $i < 100; $i++) { + $ids[] = SnowflakeService::next(); + } + } finally { + Carbon::setTestNow(); + } + + expect(array_unique($ids))->toHaveCount(count($ids)); +}); + +it('reproduces the historical collision with the old stale-sequence logic', function () { + // This is a focused, self-contained proof of the bug the fix addresses. + // It reimplements the OLD next() sequence logic and shows the first two + // sequence values were identical (1, 1) — the collision precondition. + $oldSeq = function () { + $seq = Cache::get('snowflake:seq'); + if (! $seq) { + Cache::put('snowflake:seq', 1); + $seq = 1; + } else { + Cache::increment('snowflake:seq'); + } + + return $seq; + }; + + Cache::forget('snowflake:seq'); + $first = $oldSeq(); + $second = $oldSeq(); + + // The old logic collides here... + expect([$first, $second])->toBe([1, 1]); + + // ...while the fixed service does not. + Cache::forget('snowflake:seq'); + $fixedFirst = seqOf(SnowflakeService::next()); + $fixedSecond = seqOf(SnowflakeService::next()); + expect([$fixedFirst, $fixedSecond])->toBe([1, 2]); +}); + +/* +|-------------------------------------------------------------------------- +| Sequence wraparound +|-------------------------------------------------------------------------- +*/ + +it('wraps the sequence back to zero after 4095', function () { + // Prime the counter just below the 12-bit ceiling. + Cache::put('snowflake:seq', 4094); + + // increment -> 4095, which the service maps to 0 (wraparound). + $wrapped = seqOf(SnowflakeService::next()); + expect($wrapped)->toBe(0); + + // Next call resumes counting from 1. + $next = seqOf(SnowflakeService::next()); + expect($next)->toBe(1); +}); + +it('keeps the sequence within the 12-bit range across a wrap', function () { + Cache::put('snowflake:seq', 4090); + + for ($i = 0; $i < 20; $i++) { + $seq = seqOf(SnowflakeService::next()); + expect($seq)->toBeGreaterThanOrEqual(0); + expect($seq)->toBeLessThanOrEqual(SNOWFLAKE_SEQ_MASK); + } +}); + +/* +|-------------------------------------------------------------------------- +| Cache-store resilience +|-------------------------------------------------------------------------- +*/ + +it('reseeds the sequence when the cache counter is missing', function () { + Cache::forget('snowflake:seq'); + + $seq = seqOf(SnowflakeService::next()); + + expect($seq)->toBe(1); + expect(Cache::get('snowflake:seq'))->toBe(1); +}); + +it('reseeds when the cache counter holds a non-numeric value', function () { + // A corrupted / non-integer cache value must not throw or emit a + // malformed id; the service should fall back to reseeding at 1. + Cache::put('snowflake:seq', 'not-a-number'); + + $id = SnowflakeService::next(); + + expect($id)->toBeInt(); + expect(seqOf($id))->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Timestamp encoding +|-------------------------------------------------------------------------- +*/ + +it('encodes the current millisecond into the timestamp bits', function () { + Carbon::setTestNow(Carbon::create(2026, 6, 1, 12, 0, 0, 'UTC')); + + try { + $id = SnowflakeService::next(); + } finally { + Carbon::setTestNow(); + } + + // microtime() is not affected by Carbon::setTestNow(), so assert the + // timestamp bits decode to a plausible, positive, recent value rather + // than an exact instant. + $timestamp = $id >> 22; + expect($timestamp)->toBeGreaterThan(0); +}); + +/* +|-------------------------------------------------------------------------- +| byDate() +|-------------------------------------------------------------------------- +*/ + +it('byDate() delegates to next() when no timestamp is given', function () { + $before = Cache::get('snowflake:seq'); + + $id = SnowflakeService::byDate(); + + // Delegating to next() advances the sequence counter. + expect($id)->toBeInt(); + expect(Cache::get('snowflake:seq'))->not->toBe($before); +}); + +it('byDate() encodes the supplied timestamp with a zero sequence', function () { + $ts = Carbon::create(2026, 6, 1, 12, 0, 0, 'UTC'); + + $id = SnowflakeService::byDate($ts); + + // byDate() always sets the sequence bits to 0. + expect(seqOf($id))->toBe(0); + + // The timestamp bits must decode back to the supplied instant. + $expectedTimestamp = (int) (round($ts->timestamp * 1000) - SNOWFLAKE_EPOCH); + expect($id >> 22)->toBe($expectedTimestamp); +}); + +it('byDate() produces ordered ids for ordered timestamps', function () { + $earlier = SnowflakeService::byDate(Carbon::create(2023, 1, 1, 0, 0, 0, 'UTC')); + $later = SnowflakeService::byDate(Carbon::create(2024, 1, 1, 0, 0, 0, 'UTC')); + + // Snowflakes are time-ordered: a later timestamp yields a larger id. + expect($later)->toBeGreaterThan($earlier); +});