Skip to content

Commit 17069ff

Browse files
Add deterministic Python workflow UUIDv7 helper
1 parent 65c0c4d commit 17069ff

3 files changed

Lines changed: 54 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ restart the worker process with a new id before serving changed workflow code.
158158
- **Codec envelopes**: Avro payloads by default, with JSON decode compatibility for existing history
159159
- **Payload-size warnings**: Structured warnings before oversized workflow, activity, schedule, signal, update, query, or search-attribute payloads reach the server
160160
- **Workflow definition guard**: Worker registration refuses same-id hot reloads when a workflow class definition changed
161+
- **Deterministic workflow helpers**: `ctx.now()`, `ctx.random()`, `ctx.uuid4()`, and `ctx.uuid7()` replay from workflow state
161162
- **Worker interceptors**: Typed hooks around workflow tasks, activity calls, and query tasks for tracing, logging, and custom metrics
162163
- **Metrics hooks**: Pluggable counters and histograms, with an optional Prometheus adapter
163164

src/durable_workflow/workflow.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
1010
Determinism-sensitive helpers live on the :class:`WorkflowContext` passed to
1111
``run``: :meth:`WorkflowContext.random`, :meth:`WorkflowContext.uuid4`,
12-
:meth:`WorkflowContext.now`, :meth:`WorkflowContext.patched`,
13-
:meth:`WorkflowContext.deprecate_patch`, and :meth:`WorkflowContext.side_effect` all
14-
produce values that are recorded on first execution and replayed verbatim
15-
on every subsequent replay of the same history.
12+
:meth:`WorkflowContext.uuid7`, :meth:`WorkflowContext.now`,
13+
:meth:`WorkflowContext.patched`, :meth:`WorkflowContext.deprecate_patch`, and
14+
:meth:`WorkflowContext.side_effect` all produce values that are recorded on
15+
first execution and replayed verbatim on every subsequent replay of the same
16+
history.
1617
"""
1718

1819
from __future__ import annotations
@@ -724,6 +725,7 @@ def __init__(self, *, run_id: str = "", current_time: datetime | None = None) ->
724725
self._current_time = current_time or datetime.now(timezone.utc)
725726
seed = int(hashlib.sha256(run_id.encode()).hexdigest()[:16], 16)
726727
self._rng = random.Random(seed)
728+
self._uuid7_counter = 0
727729
self.logger = _ReplayLogger(_REPLAY_LOGGER)
728730

729731
def schedule_activity(
@@ -871,6 +873,24 @@ def uuid4(self) -> uuid.UUID:
871873
rand_bytes = self._rng.getrandbits(128).to_bytes(16, "big")
872874
return uuid.UUID(bytes=rand_bytes, version=4)
873875

876+
def uuid7(self) -> uuid.UUID:
877+
timestamp_ms = int(self._current_time.timestamp() * 1000)
878+
if timestamp_ms < 0 or timestamp_ms >= (1 << 48):
879+
raise ValueError("ctx.uuid7() requires ctx.now() within the UUIDv7 timestamp range")
880+
881+
rand_a = self._uuid7_counter & 0xFFF
882+
self._uuid7_counter += 1
883+
rand_b = self._rng.getrandbits(62)
884+
885+
value = (
886+
(timestamp_ms << 80)
887+
| (0x7 << 76)
888+
| (rand_a << 64)
889+
| (0x2 << 62)
890+
| rand_b
891+
)
892+
return uuid.UUID(int=value)
893+
874894

875895
# ── Replay ───────────────────────────────────────────────────────────
876896
@dataclass

tests/test_replay.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
from datetime import datetime, timezone
45

56
from durable_workflow import serializer, workflow
67
from durable_workflow.errors import ChildWorkflowFailed
@@ -376,7 +377,6 @@ def test_server_command_uses_payload_codec(self) -> None:
376377

377378
class TestWorkflowContext:
378379
def test_now_returns_deterministic_time(self) -> None:
379-
from datetime import datetime, timezone
380380
t = datetime(2026, 1, 1, tzinfo=timezone.utc)
381381
ctx = WorkflowContext(run_id="r1", current_time=t)
382382
assert ctx.now() == t
@@ -401,6 +401,34 @@ def test_uuid4_is_version_4(self) -> None:
401401
u = ctx.uuid4()
402402
assert u.version == 4
403403

404+
def test_uuid7_deterministic(self) -> None:
405+
t = datetime(2026, 1, 1, 12, 30, 15, 123000, tzinfo=timezone.utc)
406+
ctx1 = WorkflowContext(run_id="run-v7", current_time=t)
407+
ctx2 = WorkflowContext(run_id="run-v7", current_time=t)
408+
409+
assert ctx1.uuid7() == ctx2.uuid7()
410+
assert ctx1.uuid7() == ctx2.uuid7()
411+
412+
def test_uuid7_is_version_7_and_uses_context_time(self) -> None:
413+
t = datetime(2026, 1, 1, 12, 30, 15, 123000, tzinfo=timezone.utc)
414+
ctx = WorkflowContext(run_id="run-v7", current_time=t)
415+
416+
u = ctx.uuid7()
417+
418+
assert u.version == 7
419+
assert (u.int >> 80) == int(t.timestamp() * 1000)
420+
421+
def test_uuid7_orders_same_tick_calls_by_sequence(self) -> None:
422+
t = datetime(2026, 1, 1, 12, 30, 15, 123000, tzinfo=timezone.utc)
423+
ctx = WorkflowContext(run_id="run-v7", current_time=t)
424+
425+
first = ctx.uuid7()
426+
second = ctx.uuid7()
427+
428+
assert first < second
429+
assert ((first.int >> 64) & 0xFFF) == 0
430+
assert ((second.int >> 64) & 0xFFF) == 1
431+
404432
def test_logger_silent_during_replay(self) -> None:
405433
import logging
406434
import logging.handlers

0 commit comments

Comments
 (0)