Skip to content

Commit f35f4a1

Browse files
Add workflow.uuid7() (#1733)
* Add workflow.uuid7() * Suppress unreachable-code warning on pre-3.14 type checks --------- Co-authored-by: Tim Conley <tconley1428@gmail.com>
1 parent da04dc9 commit f35f4a1

6 files changed

Lines changed: 112 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ to include examples, links to docs, or any other relevant information.
2020

2121
### Added
2222

23+
- `temporalio.workflow.uuid7()` generates a determinism-safe, time-sortable
24+
UUIDv7 (RFC 9562) from workflow time and the workflow's deterministic random
25+
generator, complementing the existing `workflow.uuid4()`
26+
([#1450](https://github.com/temporalio/sdk-python/issues/1450)). The
27+
workflow sandbox now also restricts the non-deterministic `uuid.uuid7()`
28+
added to the standard library in Python 3.14, matching the existing
29+
`uuid.uuid1()`/`uuid.uuid4()` restrictions.
30+
2331
### Changed
2432

2533
- `temporalio.contrib.pydantic` converters now reuse Pydantic type adapters

‎temporalio/worker/workflow_sandbox/_restrictions.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,9 @@ def _public_callables(parent: Any, *, exclude: set[str] = set()) -> set[str]:
769769
"urllib": SandboxMatcher(
770770
children={"request": SandboxMatcher.all_uses},
771771
),
772-
"uuid": SandboxMatcher(use={"uuid1", "uuid4"}, only_runtime=True),
772+
# uuid7 only exists in the stdlib on Python 3.14+; matching a
773+
# nonexistent attribute is harmless on older versions
774+
"uuid": SandboxMatcher(use={"uuid1", "uuid4", "uuid7"}, only_runtime=True),
773775
"webbrowser": SandboxMatcher.all_uses,
774776
"xmlrpc": SandboxMatcher.all_uses,
775777
"zipfile": SandboxMatcher(

‎temporalio/workflow/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
upsert_memo,
9595
upsert_search_attributes,
9696
uuid4,
97+
uuid7,
9798
wait_condition,
9899
)
99100
from ._definition import (
@@ -225,6 +226,7 @@
225226
"upsert_memo",
226227
"upsert_search_attributes",
227228
"uuid4",
229+
"uuid7",
228230
"wait_condition",
229231
"DynamicWorkflowConfig",
230232
"defn",

‎temporalio/workflow/_context.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"upsert_memo",
6666
"upsert_search_attributes",
6767
"uuid4",
68+
"uuid7",
6869
"wait_condition",
6970
]
7071

@@ -901,6 +902,33 @@ def uuid4() -> uuid.UUID:
901902
return uuid.UUID(bytes=random().getrandbits(16 * 8).to_bytes(16, "big"), version=4)
902903

903904

905+
def uuid7() -> uuid.UUID:
906+
"""Get a new, determinism-safe v7 UUID based on :py:func:`time_ns` and
907+
:py:func:`random`.
908+
909+
Per RFC 9562, the UUID's leading 48 bits are the current workflow time as
910+
milliseconds since the epoch, so UUIDs from successive workflow tasks sort
911+
by creation time. The remaining 74 bits are random. UUIDs generated within
912+
the same workflow task share the same workflow time and are not guaranteed
913+
to be monotonically ordered with respect to one another.
914+
915+
Note, this UUID is not cryptographically safe and should not be used for
916+
security purposes.
917+
918+
Returns:
919+
A deterministically-seeded v7 UUID.
920+
"""
921+
# uuid.UUID's version parameter only accepts 1-5 before Python 3.14, so
922+
# the version and variant bits are set manually.
923+
unix_ts_ms = (time_ns() // 1_000_000) & 0xFFFF_FFFF_FFFF
924+
rand = random()
925+
rand_a = rand.getrandbits(12)
926+
rand_b = rand.getrandbits(62)
927+
return uuid.UUID(
928+
int=(unix_ts_ms << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
929+
)
930+
931+
904932
async def sleep(duration: float | timedelta, *, summary: str | None = None) -> None:
905933
"""Sleep for the given duration.
906934

‎tests/worker/test_workflow.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3934,6 +3934,73 @@ async def test_workflow_uuid(client: Client):
39343934
assert handle2_query_result == await handle2.query(UUIDWorkflow.result)
39353935

39363936

3937+
@workflow.defn
3938+
class UUID7Workflow:
3939+
def __init__(self) -> None:
3940+
self._result = "<unset>"
3941+
self._time_ms = -1
3942+
3943+
@workflow.run
3944+
async def run(self) -> None:
3945+
self._time_ms = workflow.time_ns() // 1_000_000
3946+
self._result = str(workflow.uuid7())
3947+
3948+
@workflow.query
3949+
def result(self) -> str:
3950+
return self._result
3951+
3952+
@workflow.query
3953+
def time_ms(self) -> int:
3954+
return self._time_ms
3955+
3956+
3957+
async def test_workflow_uuid7(client: Client):
3958+
task_queue = str(uuid.uuid4())
3959+
async with new_worker(
3960+
client, UUID7Workflow, task_queue=task_queue, max_cached_workflows=0
3961+
):
3962+
# Get two handle UUID results. Need to disable workflow cache since we
3963+
# restart the worker and don't want to pay the sticky queue penalty.
3964+
handle1 = await client.start_workflow(
3965+
UUID7Workflow.run, id=f"workflow-{uuid.uuid4()}", task_queue=task_queue
3966+
)
3967+
await handle1.result()
3968+
handle1_query_result = await handle1.query(UUID7Workflow.result)
3969+
3970+
handle2 = await client.start_workflow(
3971+
UUID7Workflow.run,
3972+
id=f"workflow-{uuid.uuid4()}",
3973+
task_queue=task_queue,
3974+
)
3975+
await handle2.result()
3976+
handle2_query_result = await handle2.query(UUID7Workflow.result)
3977+
3978+
# Confirm they aren't equal to each other but they are equal to retries
3979+
# of the same query
3980+
assert handle1_query_result != handle2_query_result
3981+
assert handle1_query_result == await handle1.query(UUID7Workflow.result)
3982+
assert handle2_query_result == await handle2.query(UUID7Workflow.result)
3983+
3984+
# Confirm RFC 9562 shape: version 7, RFC variant, and the leading 48
3985+
# bits are the workflow time in milliseconds at generation
3986+
for handle, query_result in (
3987+
(handle1, handle1_query_result),
3988+
(handle2, handle2_query_result),
3989+
):
3990+
result_uuid = uuid.UUID(query_result)
3991+
assert result_uuid.version == 7
3992+
assert result_uuid.variant == uuid.RFC_4122
3993+
workflow_time_ms = await handle.query(UUID7Workflow.time_ms)
3994+
assert int(result_uuid) >> 80 == workflow_time_ms
3995+
3996+
# Now confirm those results are the same even on a new worker
3997+
async with new_worker(
3998+
client, UUID7Workflow, task_queue=task_queue, max_cached_workflows=0
3999+
):
4000+
assert handle1_query_result == await handle1.query(UUID7Workflow.result)
4001+
assert handle2_query_result == await handle2.query(UUID7Workflow.result)
4002+
4003+
39374004
@activity.defn(name="custom-name")
39384005
class CallableClassActivity:
39394006
def __init__(self, orig_field1: str) -> None:

‎tests/worker/workflow_sandbox/test_runner.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ async def test_workflow_sandbox_restrictions(client: Client):
197197
if sys.version_info < (3, 14):
198198
invalid_code_to_check.append("import os.path\nos.path.abspath('foo')") # type: ignore[reportUnreachable]
199199

200+
# uuid7 was only added to the stdlib in 3.14
201+
if sys.version_info >= (3, 14):
202+
invalid_code_to_check.append("import uuid\nuuid.uuid7()") # type: ignore[reportUnreachable]
203+
200204
for code in invalid_code_to_check:
201205
with pytest.raises(WorkflowFailureError) as err:
202206
await client.execute_workflow(

0 commit comments

Comments
 (0)