Skip to content

Commit 246bf49

Browse files
committed
Add TimeOrderedUUIDGenerator and GeneratesTimeOrderedIds trait
Sortable 20-char IDs with timestamp-first byte layout and sortable base64 encoding. Lexicographic sort = chronological order across processes at millisecond granularity. Includes fork-safe initialization, timestamp extraction helpers (getRecordTimestamp/getRecordDate), and null-safe handling for pre-existing non-time-ordered IDs.
1 parent ce6b1d0 commit 246bf49

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PDPhilip\Elasticsearch\Eloquent;
6+
7+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
8+
use Illuminate\Support\Carbon;
9+
use PDPhilip\Elasticsearch\Utils\TimeOrderedUUIDGenerator;
10+
11+
/**
12+
* Generates time-ordered, sortable IDs for Elasticsearch models.
13+
*
14+
* Use this trait when you need IDs that sort chronologically across
15+
* multiple processes/workers — ideal for high-volume APIs where
16+
* time-sequenced analytics matter.
17+
*
18+
* IDs are 20 characters, URL-safe, and sort lexicographically in
19+
* the same order they were created (at millisecond granularity).
20+
*
21+
* Usage:
22+
* class TrackingEvent extends Model {
23+
* use GeneratesTimeOrderedIds;
24+
* }
25+
*
26+
* $event->getRecordTimestamp(); // 1771160093773 (ms)
27+
* $event->getRecordDate(); // Carbon instance
28+
*/
29+
trait GeneratesTimeOrderedIds
30+
{
31+
use HasUuids;
32+
33+
public function initializeGeneratesTimeOrderedIds(): void
34+
{
35+
$this->generatesUniqueIds = true;
36+
}
37+
38+
public function newUniqueId(): string
39+
{
40+
return TimeOrderedUUIDGenerator::generate();
41+
}
42+
43+
public function getRecordTimestamp(): ?int
44+
{
45+
return TimeOrderedUUIDGenerator::extractTimestamp($this->id);
46+
}
47+
48+
public function getRecordDate(): ?Carbon
49+
{
50+
$ms = $this->getRecordTimestamp();
51+
52+
if ($ms === null) {
53+
return null;
54+
}
55+
56+
return Carbon::createFromTimestampMs($ms);
57+
}
58+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PDPhilip\Elasticsearch\Utils;
6+
7+
/**
8+
* Generates time-ordered, sortable 20-character IDs for Elasticsearch.
9+
*
10+
* Produces 15-byte IDs encoded with a sortable base64 alphabet where
11+
* lexicographic string comparison matches chronological order — both
12+
* within a single process and across multiple concurrent processes
13+
* at millisecond granularity.
14+
*
15+
* Byte layout (15 bytes):
16+
* [0-5] Timestamp in ms, big-endian (most significant first)
17+
* [6-8] Monotonic sequence counter (3 bytes, wraps at 0xFFFFFF)
18+
* [9-14] Process identifier (6 random bytes, fixed per process)
19+
*
20+
* Encoding: Sortable base64 using ASCII-ordered alphabet:
21+
* -0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz
22+
*
23+
* Properties:
24+
* - 20 characters, URL-safe (same length as ES native IDs)
25+
* - Lexicographic sort = chronological order across processes
26+
* - Zero collisions: timestamp + sequence + random process ID
27+
* - ~16M IDs per ms per process before sequence wraps
28+
* - Timestamp extractable for analytics (bytes 0-5)
29+
*
30+
* @internal
31+
*/
32+
class TimeOrderedUUIDGenerator
33+
{
34+
private static int $sequenceNumber;
35+
36+
private static int $lastTimestamp = 0;
37+
38+
private static string $processId;
39+
40+
private static int $initPid = 0;
41+
42+
private const STANDARD_B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
43+
44+
private const SORTABLE_B64 = '-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
45+
46+
private static function initialize(): void
47+
{
48+
if (self::$initPid === getmypid()) {
49+
return;
50+
}
51+
52+
self::$sequenceNumber = random_int(0, 0xFFFFFF);
53+
self::$processId = random_bytes(6);
54+
self::$lastTimestamp = 0;
55+
self::$initPid = getmypid();
56+
}
57+
58+
public static function generate(): string
59+
{
60+
self::initialize();
61+
62+
$sequenceId = ++self::$sequenceNumber & 0xFFFFFF;
63+
64+
$timestamp = max(self::$lastTimestamp, self::getTimestampMillis());
65+
66+
if ($sequenceId === 0) {
67+
$timestamp++;
68+
}
69+
70+
self::$lastTimestamp = $timestamp;
71+
72+
// 15-byte structure: timestamp BE (6) + sequence BE (3) + process ID (6)
73+
$bytes = '';
74+
$bytes .= chr(($timestamp >> 40) & 0xFF);
75+
$bytes .= chr(($timestamp >> 32) & 0xFF);
76+
$bytes .= chr(($timestamp >> 24) & 0xFF);
77+
$bytes .= chr(($timestamp >> 16) & 0xFF);
78+
$bytes .= chr(($timestamp >> 8) & 0xFF);
79+
$bytes .= chr($timestamp & 0xFF);
80+
$bytes .= chr(($sequenceId >> 16) & 0xFF);
81+
$bytes .= chr(($sequenceId >> 8) & 0xFF);
82+
$bytes .= chr($sequenceId & 0xFF);
83+
$bytes .= self::$processId;
84+
85+
return self::encodeSortableBase64($bytes);
86+
}
87+
88+
public static function isValid(string $id): bool
89+
{
90+
if (strlen($id) !== 20) {
91+
return false;
92+
}
93+
94+
$ms = self::extractTimestampRaw($id);
95+
96+
// 2020-01-01 to 2100-01-01 in ms
97+
return $ms >= 1577836800000 && $ms <= 4102444800000;
98+
}
99+
100+
public static function extractTimestamp(string $id): ?int
101+
{
102+
if (! self::isValid($id)) {
103+
return null;
104+
}
105+
106+
return self::extractTimestampRaw($id);
107+
}
108+
109+
public static function extractDateTime(string $id): ?\DateTimeImmutable
110+
{
111+
$ms = self::extractTimestamp($id);
112+
113+
if ($ms === null) {
114+
return null;
115+
}
116+
117+
$seconds = intdiv($ms, 1000);
118+
$microseconds = ($ms % 1000) * 1000;
119+
120+
$dt = \DateTimeImmutable::createFromFormat('U', (string) $seconds);
121+
122+
return $dt->modify("+{$microseconds} microseconds");
123+
}
124+
125+
private static function extractTimestampRaw(string $id): int
126+
{
127+
$bytes = self::decodeSortableBase64($id);
128+
129+
return (ord($bytes[0]) << 40)
130+
| (ord($bytes[1]) << 32)
131+
| (ord($bytes[2]) << 24)
132+
| (ord($bytes[3]) << 16)
133+
| (ord($bytes[4]) << 8)
134+
| ord($bytes[5]);
135+
}
136+
137+
private static function getTimestampMillis(): int
138+
{
139+
return (int) (microtime(true) * 1000);
140+
}
141+
142+
private static function encodeSortableBase64(string $bytes): string
143+
{
144+
$encoded = rtrim(base64_encode($bytes), '=');
145+
$urlSafe = strtr($encoded, '+/', '-_');
146+
147+
return strtr($urlSafe, self::STANDARD_B64, self::SORTABLE_B64);
148+
}
149+
150+
private static function decodeSortableBase64(string $encoded): string
151+
{
152+
$standard = strtr($encoded, self::SORTABLE_B64, self::STANDARD_B64);
153+
154+
return base64_decode(strtr($standard, '-_', '+/'));
155+
}
156+
}

0 commit comments

Comments
 (0)