diff --git a/library/Tiger/Uuid.php b/library/Tiger/Uuid.php index 005c177..646a8ab 100644 --- a/library/Tiger/Uuid.php +++ b/library/Tiger/Uuid.php @@ -48,20 +48,34 @@ public static function generate() return self::v7(); } + /** @var int the last millisecond v7() minted at (process-local; -1 = none yet) */ + private static $v7LastMs = -1; + + /** @var int a 12-bit monotonic counter within $v7LastMs (fills rand_a) */ + private static $v7Counter = 0; + /** - * RFC-9562 version-7 (time-ordered) UUID. + * RFC-9562 version-7 (time-ordered) UUID, **monotonic within a millisecond**. * * Layout (128 bits): * bits 0..47 : Unix time in milliseconds, big-endian (6 bytes) * bits 48..51 : version = 0111 (7) - * bits 52..63 : rand_a (random) + * bits 52..63 : rand_a — a 12-bit MONOTONIC COUNTER (not random; see below) * bits 64..65 : variant = 10 - * bits 66..127 : rand_b (random) + * bits 66..127 : rand_b (random, 62 bits — carries uniqueness) * - * We fill the sub-millisecond bits with randomness (RFC "method 1"), which is - * sufficient for index locality and to-the-millisecond ordering. We do NOT - * guarantee strict monotonic ordering for two IDs minted within the same - * millisecond — that would need a counter and isn't worth the complexity here. + * Two IDs minted in the same millisecond by the same process sort in creation + * order: rand_a holds a counter (RFC 9562 §6.2 "monotonic random", method 2) that + * increments for each same-ms mint, so `ORDER BY id` is a stable insertion order — + * not just to-the-millisecond. This is what keeps a transcript (or any v7-keyed + * append log) from re-ordering two rows written in the same tick. The counter is + * seeded randomly per ms (so it doesn't leak an exact mint count), and if it ever + * exhausts its 4096 values in a single ms the clock is advanced one ms to stay + * strictly increasing. rand_b stays fully random, so uniqueness is unaffected. + * + * (Cross-PROCESS same-ms ordering isn't coordinated — two separate workers can tie — + * but a single conversation's turns are appended within one request, which is the + * case that matters.) * * NOTE: assumes 64-bit PHP (universal on 8.1+); the ms timestamp fits in 48 * bits until the year ~10889. @@ -72,11 +86,26 @@ public static function v7() { $ms = (int) floor(microtime(true) * 1000); + if ($ms > self::$v7LastMs) { + self::$v7LastMs = $ms; + self::$v7Counter = random_int(0, 0x0fff); // fresh random seed for this ms + } else { + // Same ms (or the clock stepped backward): hold time monotonic and bump the counter. + $ms = self::$v7LastMs; + self::$v7Counter++; + if (self::$v7Counter > 0x0fff) { // 4096 mints in one ms → roll into the next + $ms = self::$v7LastMs = self::$v7LastMs + 1; + self::$v7Counter = random_int(0, 0x0fff); + } + } + $ctr = self::$v7Counter; + // 48-bit big-endian timestamp: pack as 64-bit BE and drop the top 2 (zero) bytes. $timestamp = substr(pack('J', $ms), 2); // 6 bytes $bytes = $timestamp . random_bytes(10); // + 10 random bytes = 16 - $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x70); // version 7 + $bytes[6] = chr(0x70 | (($ctr >> 8) & 0x0f)); // version 7 (high nibble) + counter bits 8..11 + $bytes[7] = chr($ctr & 0xff); // counter bits 0..7 (rest of rand_a) $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); // variant 10 return self::format($bytes); diff --git a/tests/Unit/UuidTest.php b/tests/Unit/UuidTest.php index 09d8d9b..ede06f1 100644 --- a/tests/Unit/UuidTest.php +++ b/tests/Unit/UuidTest.php @@ -52,15 +52,22 @@ public function v4_values_are_unique(): void } #[Test] - public function v7_is_time_ordered_at_millisecond_granularity(): void + public function v7_is_strictly_monotonic_even_within_a_millisecond(): void { - // v7's guarantee is ms-granularity ordering: the embedded timestamp never goes backwards. - // (Within a single ms the low 10 bytes are random, so full-string order is NOT guaranteed — - // that's spec-correct, and exactly what this asserts instead of over-claiming.) + // The stronger contract (RFC 9562 §6.2 monotonic random): rapid mints in the SAME process are + // strictly increasing by FULL STRING, not just to-the-millisecond. 5000 tight-loop mints force + // many same-ms collisions, so this is the direct regression guard for same-tick append ordering + // (the transcript-reordering flake). The embedded timestamp also never goes backwards. + $prev = ''; $prevTs = 0.0; for ($i = 0; $i < 5000; $i++) { - $ts = Tiger_Uuid::timeOf(Tiger_Uuid::v7()); + $u = Tiger_Uuid::v7(); + if ($prev !== '') { + $this->assertGreaterThan($prev, $u, "v7 not strictly increasing at draw $i (same-ms tie)"); + } + $ts = Tiger_Uuid::timeOf($u); $this->assertGreaterThanOrEqual($prevTs, $ts, "v7 timestamp went backwards at draw $i"); + $prev = $u; $prevTs = $ts; } }