Skip to content

Commit 00cba48

Browse files
Add deterministic v2 UUID helpers
Issue: zorporation/durable-workflow#447 Loop-ID: build-02
1 parent ec20a32 commit 00cba48

7 files changed

Lines changed: 234 additions & 5 deletions

File tree

docs/api-stability.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,10 @@ guarantee:
8686
- **Static method facade** mirroring the helpers in
8787
`Workflow\V2\functions.php`: `activity`, `executeActivity`, `child`,
8888
`executeChildWorkflow`, `async`, `all`, `parallel`, `await`,
89-
`awaitWithTimeout`, `awaitSignal`, `timer`, `sideEffect`,
90-
`continueAsNew`, `getVersion`, `patched`, `deprecatePatch`,
91-
`upsertMemo`, `upsertSearchAttributes`,
92-
and the timer sugar `seconds`/`minutes`/`hours`/`days`/`weeks`/
93-
`months`/`years`.
89+
`awaitWithTimeout`, `awaitSignal`, `timer`, `sideEffect`, `uuid4`,
90+
`uuid7`, `continueAsNew`, `getVersion`, `patched`, `deprecatePatch`,
91+
`upsertMemo`, `upsertSearchAttributes`, and the timer sugar
92+
`seconds`/`minutes`/`hours`/`days`/`weeks`/`months`/`years`.
9493

9594
The namespaced helper functions under `Workflow\V2\*` remain the
9695
equivalent functional-style surface and are equally stable. Choosing
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Support;
6+
7+
use Carbon\CarbonInterface;
8+
use RuntimeException;
9+
10+
final class DeterministicUuid
11+
{
12+
private static int $lastUuid7Milliseconds = -1;
13+
14+
private static int $uuid7Sequence = 0;
15+
16+
public static function uuid4(): string
17+
{
18+
$bytes = random_bytes(16);
19+
20+
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
21+
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
22+
23+
return self::format($bytes);
24+
}
25+
26+
public static function uuid7(CarbonInterface $time): string
27+
{
28+
$milliseconds = ((int) $time->format('U')) * 1000 + (int) floor(((int) $time->format('u')) / 1000);
29+
30+
if ($milliseconds === self::$lastUuid7Milliseconds) {
31+
self::$uuid7Sequence = (self::$uuid7Sequence + 1) & 0x0fff;
32+
} else {
33+
self::$lastUuid7Milliseconds = $milliseconds;
34+
self::$uuid7Sequence = 0;
35+
}
36+
37+
$random = random_bytes(8);
38+
$randomA = self::$uuid7Sequence;
39+
40+
$bytes = '';
41+
for ($shift = 40; $shift >= 0; $shift -= 8) {
42+
$bytes .= chr(($milliseconds >> $shift) & 0xff);
43+
}
44+
45+
$bytes .= chr(0x70 | (($randomA >> 8) & 0x0f));
46+
$bytes .= chr($randomA & 0xff);
47+
$bytes .= chr((ord($random[0]) & 0x3f) | 0x80);
48+
$bytes .= substr($random, 1, 7);
49+
50+
return self::format($bytes);
51+
}
52+
53+
private static function format(string $bytes): string
54+
{
55+
if (strlen($bytes) !== 16) {
56+
throw new RuntimeException('UUID formatting requires exactly 16 bytes.');
57+
}
58+
59+
$hex = bin2hex($bytes);
60+
61+
return sprintf(
62+
'%s-%s-%s-%s-%s',
63+
substr($hex, 0, 8),
64+
substr($hex, 8, 4),
65+
substr($hex, 12, 4),
66+
substr($hex, 16, 4),
67+
substr($hex, 20, 12),
68+
);
69+
}
70+
}

src/V2/Workflow.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,26 @@ public static function sideEffect(callable $callback): mixed
325325
return sideEffect($callback);
326326
}
327327

328+
/**
329+
* Generate a replay-stable UUIDv4.
330+
*
331+
* @see uuid4()
332+
*/
333+
public static function uuid4(): mixed
334+
{
335+
return uuid4();
336+
}
337+
338+
/**
339+
* Generate a replay-stable, time-sortable UUIDv7.
340+
*
341+
* @see uuid7()
342+
*/
343+
public static function uuid7(): mixed
344+
{
345+
return uuid7();
346+
}
347+
328348
/**
329349
* Terminate the current run by starting a new one with the provided
330350
* arguments, preserving workflow instance identity.

src/V2/functions.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,30 @@ function sideEffect(callable $callback): mixed
171171
}
172172
}
173173

174+
if (! function_exists(__NAMESPACE__ . '\\uuid4')) {
175+
/**
176+
* Generate a UUIDv4 and record it as durable history so replay returns
177+
* the original value instead of reading randomness again.
178+
*/
179+
function uuid4(): mixed
180+
{
181+
return sideEffect(static fn (): string => Support\DeterministicUuid::uuid4());
182+
}
183+
}
184+
185+
if (! function_exists(__NAMESPACE__ . '\\uuid7')) {
186+
/**
187+
* Generate a time-sortable UUIDv7 from deterministic workflow time and
188+
* record it as durable history so replay returns the original value.
189+
*/
190+
function uuid7(): mixed
191+
{
192+
$time = now();
193+
194+
return sideEffect(static fn (): string => Support\DeterministicUuid::uuid7($time));
195+
}
196+
}
197+
174198
if (! function_exists(__NAMESPACE__ . '\\continueAsNew')) {
175199
function continueAsNew(...$arguments): mixed
176200
{
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature\V2;
6+
7+
use Tests\Fixtures\V2\TestUuidWorkflow;
8+
use Tests\TestCase;
9+
use Workflow\V2\Enums\HistoryEventType;
10+
use Workflow\V2\Models\WorkflowHistoryEvent;
11+
use Workflow\V2\WorkflowStub;
12+
13+
final class V2UuidWorkflowTest extends TestCase
14+
{
15+
protected function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
config()
20+
->set('queue.default', 'sync');
21+
config()
22+
->set('queue.connections.sync.driver', 'sync');
23+
}
24+
25+
public function testUuidHelpersRecordReplayStableUuid4AndUuid7Values(): void
26+
{
27+
WorkflowStub::fake();
28+
29+
$workflow = WorkflowStub::make(TestUuidWorkflow::class, 'uuid-helper-workflow');
30+
$workflow->start();
31+
32+
$this->assertSame('waiting', $workflow->refresh()->status());
33+
34+
$queriedIds = $workflow->query('ids');
35+
36+
$this->assertMatchesRegularExpression(
37+
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
38+
$queriedIds['uuid4'][0],
39+
);
40+
$this->assertMatchesRegularExpression(
41+
'/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
42+
$queriedIds['uuid7'][0],
43+
);
44+
$this->assertNotSame($queriedIds['uuid4'][0], $queriedIds['uuid4'][1]);
45+
$this->assertNotSame($queriedIds['uuid7'][0], $queriedIds['uuid7'][1]);
46+
$this->assertLessThan($queriedIds['uuid7'][1], $queriedIds['uuid7'][0]);
47+
48+
$sideEffectEventsBeforeSignal = WorkflowHistoryEvent::query()
49+
->where('workflow_run_id', $workflow->runId())
50+
->where('event_type', HistoryEventType::SideEffectRecorded)
51+
->count();
52+
53+
$this->assertSame(4, $sideEffectEventsBeforeSignal);
54+
55+
$workflow->signal('finish');
56+
$this->assertTrue($workflow->refresh()->completed());
57+
58+
$this->assertSame($queriedIds, $workflow->output());
59+
$this->assertSame(
60+
$sideEffectEventsBeforeSignal,
61+
WorkflowHistoryEvent::query()
62+
->where('workflow_run_id', $workflow->runId())
63+
->where('event_type', HistoryEventType::SideEffectRecorded)
64+
->count(),
65+
);
66+
}
67+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures\V2;
6+
7+
use Workflow\QueryMethod;
8+
use Workflow\V2\Attributes\Signal;
9+
use Workflow\V2\Attributes\Type;
10+
use Workflow\V2\Workflow;
11+
12+
#[Type('test-uuid-workflow')]
13+
#[Signal('finish')]
14+
final class TestUuidWorkflow extends Workflow
15+
{
16+
/**
17+
* @var array{uuid4: list<string>, uuid7: list<string>}
18+
*/
19+
private array $ids = [
20+
'uuid4' => [],
21+
'uuid7' => [],
22+
];
23+
24+
public function handle(): array
25+
{
26+
$this->ids = [
27+
'uuid4' => [Workflow::uuid4(), Workflow::uuid4()],
28+
'uuid7' => [Workflow::uuid7(), Workflow::uuid7()],
29+
];
30+
31+
Workflow::awaitSignal('finish');
32+
33+
return $this->ids;
34+
}
35+
36+
#[QueryMethod]
37+
public function ids(): array
38+
{
39+
return $this->ids;
40+
}
41+
}

tests/Unit/V2/WorkflowFacadeTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ public function testSideEffectReturnsASideEffectCall(): void
109109
$this->assertInstanceOf(SideEffectCall::class, $call);
110110
}
111111

112+
public function testUuidHelpersReturnSideEffectCalls(): void
113+
{
114+
$this->assertInstanceOf(SideEffectCall::class, Workflow::uuid4());
115+
$this->assertInstanceOf(SideEffectCall::class, Workflow::uuid7());
116+
}
117+
112118
public function testContinueAsNewReturnsAContinueAsNewCall(): void
113119
{
114120
$call = Workflow::continueAsNew('arg1', 'arg2');
@@ -203,6 +209,8 @@ public function testEveryFacadeMethodIsStatic(): void
203209
'awaitSignal',
204210
'timer',
205211
'sideEffect',
212+
'uuid4',
213+
'uuid7',
206214
'continueAsNew',
207215
'getVersion',
208216
'patched',

0 commit comments

Comments
 (0)