Files
2026-09-02 18:40:00 -04:00

1429 lines
48 KiB
Python

"""Python ports of the fixtures in `workbench/example/workflows/99_e2e.ts`.
The TypeScript e2e suite (`packages/core/e2e/e2e.test.ts`) is the single source
of truth for cross-language conformance: the driver stays in TypeScript, and the
app side gets reimplemented per language. This file is the Python app side.
Two conventions that look wrong and are not:
- **The module is named `99_e2e.py`**, matching the TS fixture file. That is not
importable with an `import` statement, so `app.py` loads it with
`importlib.import_module`. The name has to match because the harness looks
fixtures up by file path (`workflows/99_e2e.ts`) and matches manifest keys by
suffix.
- **Functions keep the TS fixtures' camelCase names** instead of being
snake_cased. This keeps the manifest emitter mechanical — it can publish
`qualname -> workflow_id` straight from the registry, with no TS-name to
Python-name table to maintain and let rot. The camelCase names *are* the
conformance contract.
Ported fixtures are listed in `../e2e-conformance.json`; a fixture missing from
that list is skipped by the suite. Only add a name there once its test passes.
"""
import asyncio
import dataclasses
import json
import random
import re
import time
from typing import Any, Awaitable, TypeVar
import pydantic
from vercel.workflow import (
BaseHook,
FatalError,
RetryableError,
Run,
WorkflowWritable,
Workflows,
get_step_metadata,
get_writable,
set_attributes,
sleep,
start,
time_ns,
)
from vercel.workflow._internal.core import Step
app = Workflows()
##########################################################
# nullByteWorkflow
#
# A NUL byte surviving the step return is a real cross-language signal: it has
# to round-trip the devalue codec and whatever the world writes to disk without
# being treated as a string terminator.
@app.step
async def nullByteStep() -> str:
return "null byte \0"
@app.workflow
async def nullByteWorkflow() -> str:
return await nullByteStep()
##########################################################
# addTenWorkflow
#
# The suite starts this one with a positional input array (`[123]`), and the
# step takes two positional parameters. Both directions of the argument
# encoding are in play: a TS-written `[123]` decoded into a Python call, and
# Python's own `add(a, 2)` written back out the way JS writes it.
@app.step
async def add(a: int, b: int) -> int:
return a + b
@app.workflow
async def addTenWorkflow(input: int) -> int:
a = await add(input, 2)
b = await add(a, 3)
return await add(b, 5)
##########################################################
# promiseAllWorkflow
#
# `asyncio.gather` is Python's `Promise.all`, and the interesting part is the
# same in both: three steps suspend in a single turn, so the orchestrator has to
# create three pending events before the replay yields.
@app.step
async def randomDelay(v: str) -> str:
await asyncio.sleep(random.random() * 3)
return v.upper()
@app.workflow
async def promiseAllWorkflow() -> str:
a, b, c = await asyncio.gather(
randomDelay("a"),
randomDelay("b"),
randomDelay("c"),
)
return a + b + c
##########################################################
# sleepingWorkflow
# parallelSleepWorkflow
# sleepInLoopWorkflow
#
# `sleep` is the one workflow primitive with no step behind it: the orchestrator
# suspends on a `wait_created` event and the world redelivers the run when the
# timer fires. Porting it checks that Python emits a wait the TypeScript driver
# recognises — `cancelRun` waits for exactly that event type before cancelling.
# TypeScript fixture inputs are milliseconds; numeric Python durations are seconds.
#
# The clock needs care. The tests do arithmetic on the returned timestamps and
# compare against millisecond thresholds, so these return `time_ns() // 1e6`
# rather than `now()`: `vercel.workflow.now()` hands back a `datetime`, which is
# the right Python type and the wrong wire type for `endTime - startTime`.
@app.workflow
async def sleepingWorkflow(durationMs: int = 10_000) -> dict:
startTime = time_ns() // 1_000_000
await sleep(durationMs / 1_000)
endTime = time_ns() // 1_000_000
return {"startTime": startTime, "endTime": endTime}
@app.workflow
async def parallelSleepWorkflow() -> dict:
startTime = time_ns() // 1_000_000
await asyncio.gather(*(sleep("1s") for _ in range(10)))
endTime = time_ns() // 1_000_000
return {"startTime": startTime, "endTime": endTime}
@app.step
async def noopStep(iteration: int) -> dict:
# Wall clock, not the deterministic workflow clock: this runs in the step
# context, and the test reads these timestamps to prove the sleeps between
# iterations were really honoured rather than replayed away.
return {"iteration": iteration, "ts": time.time_ns() // 1_000_000}
@app.workflow
async def sleepInLoopWorkflow() -> dict:
iterations = 3
sleepMs = 3_000
timestamps = []
for i in range(iterations):
result = await noopStep(i)
timestamps.append(result["ts"])
if i < iterations - 1:
await sleep(sleepMs / 1_000)
return {"timestamps": timestamps, "totalElapsed": timestamps[-1] - timestamps[0]}
##########################################################
# Racing suspensions
#
# `Promise.race` and `Promise.any` have no asyncio spelling that takes bare
# awaitables, so the five race fixtures below share these two helpers. Both
# resolve ties by the order the awaitables were passed rather than by set
# iteration order — `asyncio.wait` returns a `set`, and a workflow body has to
# be deterministic across replays, so picking `next(iter(done))` would be a
# latent replay divergence the moment two steps land in the same turn.
#
# Nothing cancels the losers, matching JS: a race that resolves leaves the
# other steps running, the body returns, and `_run_in_loop` cancels the
# orphaned tasks on its way out. Their step invocations are already in flight
# and complete against a run that has finished, exactly as they do on the
# TypeScript side.
_T = TypeVar("_T")
async def _race(*awaitables: Awaitable[_T]) -> _T:
"""`Promise.race`: settle with the first to settle, error included."""
tasks = [asyncio.ensure_future(a) for a in awaitables]
done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in tasks:
if task in done:
return task.result()
raise AssertionError("asyncio.wait returned no completed task")
async def _any(*awaitables: Awaitable[_T]) -> _T:
"""`Promise.any`: the first to *succeed*; failures are skipped."""
tasks = [asyncio.ensure_future(a) for a in awaitables]
pending = set(tasks)
while pending:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
for task in tasks:
if task in done and task.exception() is None:
return task.result()
raise RuntimeError("all awaitables rejected")
##########################################################
# promiseRaceWorkflow
# promiseAnyWorkflow
#
# The counterpart to `promiseAllWorkflow`: three steps suspend in one turn, but
# the body resumes on the first one instead of all three. That is the case
# where replay and completion overlap — the run finishes while two step
# invocations are still outstanding.
#
# `promiseAnyWorkflow` additionally needs a step failure to be *skippable*
# rather than fatal. Python surfaces a failed step to the body as a
# `RuntimeError` carrying the step's error text (see the note on
# `errorRetryDisabled` below), which is all `_any` needs — it only asks whether
# the task raised.
@app.step
async def specificDelay(delay: int, v: str) -> str:
await asyncio.sleep(delay / 1000)
return v.upper()
@app.workflow
async def promiseRaceWorkflow() -> str:
return await _race(
specificDelay(10_000, "a"),
specificDelay(100, "b"), # "b" should always win
specificDelay(20_000, "c"),
)
@app.step
async def stepThatFails() -> str:
raise FatalError("step failed")
@app.workflow
async def promiseAnyWorkflow() -> str:
return await _any(
stepThatFails(),
specificDelay(100, "b"), # "b" should always win
specificDelay(6_000, "c"),
)
##########################################################
# sleepWinsRaceWorkflow
# stepWinsRaceWorkflow
#
# A race between the two suspension kinds. `sleep` resumes from a timer the
# world owns and a step resumes from an event the step handler writes, so these
# assert that the orchestrator resolves whichever lands first without waiting
# for the other — the driver bounds `durationMs` at 5s against a 10s loser.
@app.step
async def delayMsStep(ms: int, label: str) -> str:
await asyncio.sleep(ms / 1000)
return label
async def _sleepThen(duration: str, label: str) -> str:
"""`sleep(...).then(() => label)` — a plain coroutine, not a step."""
await sleep(duration)
return label
@app.workflow
async def sleepWinsRaceWorkflow() -> dict:
startTime = time_ns() // 1_000_000
winner = await _race(delayMsStep(10_000, "step"), _sleepThen("1s", "sleep"))
endTime = time_ns() // 1_000_000
return {"winner": winner, "durationMs": endTime - startTime}
@app.workflow
async def stepWinsRaceWorkflow() -> dict:
startTime = time_ns() // 1_000_000
winner = await _race(delayMsStep(1_000, "step"), _sleepThen("10s", "sleep"))
endTime = time_ns() // 1_000_000
return {"winner": winner, "durationMs": endTime - startTime}
##########################################################
# promiseRaceStressTestWorkflow
#
# Five steps 5s apart, raced and retired one at a time, so the body re-enters
# `_race` over a shrinking set across five separate replays. Each replay has to
# rebuild the same suspensions in the same order and match them to the events
# already in the log; a single misaligned slot shows up as a missing or
# duplicated entry in the returned list.
@app.step
async def promiseRaceStressTestDelayStep(dur: int, resp: int) -> int:
await asyncio.sleep(dur / 1000)
return resp
@app.workflow
async def promiseRaceStressTestWorkflow() -> list:
# Tasks rather than coroutines: unlike a JS promise, a coroutine can only
# be awaited once, and every loop iteration races the survivors again.
promises = {
i: asyncio.ensure_future(promiseRaceStressTestDelayStep(1000 * 5 * i, i))
for i in range(5)
}
done = []
while promises:
res = await _race(*promises.values())
done.append(res)
del promises[res]
return done
##########################################################
# errorRetrySuccess
# errorRetryDisabled
#
# The two halves of the step retry policy: the default (`DEFAULT_MAX_RETRIES`,
# 3 in both SDKs) and `max_retries=0`. `get_step_metadata().attempt` is what
# makes them observable from inside the step, and it is the same 1-based
# counter the driver reads back off the step entity through the CLI.
#
# These two work because neither inspects the exception. `errorRetryDisabled`
# reads its attempt number back out of the message *text*, which is the one part
# of a thrown error that survives the event log — see the note on
# `errorFatalCatchable` below for what does not.
@app.step
async def retryUntilAttempt3() -> int:
attempt = get_step_metadata().attempt
if attempt < 3:
raise RuntimeError(f"Failed on attempt {attempt}")
return attempt
@app.workflow
async def errorRetrySuccess() -> dict:
return {"finalAttempt": await retryUntilAttempt3()}
@app.step(max_retries=0)
async def throwWithNoRetries() -> None:
raise RuntimeError(f"Failed on attempt {get_step_metadata().attempt}")
@app.workflow
async def errorRetryDisabled() -> dict:
try:
await throwWithNoRetries()
return {"failed": False, "attempt": None}
except Exception as e:
match = re.search(r"attempt (\d+)", str(e))
return {"failed": True, "attempt": int(match.group(1)) if match else None}
##########################################################
# outputStreamWorkflow
# outputStreamInsideStepWorkflow
# utf8StreamWorkflow
#
# Run-scoped streams, which are the one part of the protocol that does not
# travel through the event log: chunks are appended to a separate per-run log
# that a reader tails live. The driver reads them with `run.getReadable()`, so
# these fixtures assert that Python's framing and payload encoding are the ones
# `@workflow/core`'s `getDeserializeStream` expects.
#
# Both spellings of the same API are covered, because they take different paths
# through the SDK. `get_writable()` in the *workflow body* returns a
# `WorkflowStreamHandle` — the body replays and has no network, so it cannot
# write — and passing that handle into a step's arguments is what turns it into
# a writer, via the serialization layer. `get_writable()` in a *step* returns
# the writer directly. The two have to name the same stream for the workflow to
# be able to hand one to a step at all.
#
# A `bytes` chunk arrives on the TypeScript side as a `Uint8Array` and anything
# else as its devalue value, which is why the driver reads chunk 0 as binary
# and chunk 1 as an object.
#
# Nothing closes a stream implicitly in either SDK, and the driver asserts the
# reader sees `done` — hence the explicit `stepCloseOutputStream` at the end of
# each fixture.
@app.step
async def stepWithOutputStreamBinary(writable: WorkflowWritable, text: str) -> None:
await writable.write(text.encode())
@app.step
async def stepWithOutputStreamObject(writable: WorkflowWritable, obj: Any) -> None:
await writable.write(obj)
@app.step
async def stepCloseOutputStream(writable: WorkflowWritable) -> None:
await writable.close()
@app.workflow
async def outputStreamWorkflow() -> str:
writable = get_writable()
namedWritable = get_writable(namespace="test")
await sleep("1s")
await stepWithOutputStreamBinary(writable, "Hello, world!")
await sleep("1s")
await stepWithOutputStreamBinary(namedWritable, "Hello, named stream!")
await sleep("1s")
await stepWithOutputStreamObject(writable, {"foo": "test"})
await sleep("1s")
await stepWithOutputStreamObject(namedWritable, {"foo": "bar"})
await sleep("1s")
await stepCloseOutputStream(writable)
await stepCloseOutputStream(namedWritable)
return "done"
@app.step
async def stepWithOutputStreamInsideStep(text: str) -> None:
await get_writable().write(text.encode())
@app.step
async def stepWithNamedOutputStreamInsideStep(namespace: str, obj: Any) -> None:
await get_writable(namespace=namespace).write(obj)
@app.step
async def stepCloseOutputStreamInsideStep(namespace: str | None = None) -> None:
await get_writable(namespace=namespace).close()
@app.workflow
async def outputStreamInsideStepWorkflow() -> str:
await sleep("1s")
await stepWithOutputStreamInsideStep("Hello from step!")
await sleep("1s")
await stepWithNamedOutputStreamInsideStep(
"step-ns", {"message": "Hello from named stream in step!"}
)
await sleep("1s")
await stepWithOutputStreamInsideStep("Second message")
await sleep("1s")
await stepWithNamedOutputStreamInsideStep("step-ns", {"counter": 42})
await sleep("1s")
await stepCloseOutputStreamInsideStep()
await stepCloseOutputStreamInsideStep("step-ns")
return "done"
@app.step
async def stepWriteUtf8Text(writable: WorkflowWritable, text: str) -> None:
await writable.write(text.encode())
@app.step
async def stepWriteUtf8Json(writable: WorkflowWritable, value: Any) -> None:
await writable.write(json.dumps(value, ensure_ascii=False).encode())
@app.workflow
async def utf8StreamWorkflow() -> str:
writable = get_writable()
await sleep("1s")
await stepWriteUtf8Text(writable, "Hello, world!")
await stepWriteUtf8Text(writable, "Café — naïve résumé")
await stepWriteUtf8Text(writable, "你好,世界!🌍✨")
await stepWriteUtf8Text(writable, "مرحبا بالعالم")
await stepWriteUtf8Json(writable, {"greeting": "안녕하세요", "emoji": "🎉"})
await stepCloseOutputStream(writable)
return "done"
##########################################################
# errorRetryFatal
# errorFatalCatchable
# errorStepThrowFatalRoundTrip
# errorWorkflowThrowFatalRoundTrip
#
# `FatalError` is a retry-control error the step handler honours: `fatal or
# attempt >= max_retries + 1` is what decides whether to write `step_failed`
# instead of `step_retrying`, so a step that raises it burns exactly one attempt.
#
# All four turn on the error surviving the event log, which vercel-py now does
# the way upstream does — the thrown value goes through the serialization
# pipeline onto `step_failed` / `run_failed`, on the same devalue tags
# `@workflow/core` uses. So the `except` below catches the `FatalError` the step
# raised, `__cause__` is the `TypeError` it was raised from, and the run carries
# `errorCode: USER_ERROR` rather than the name of whichever class reached the
# handler.
#
# Two notes on the Python spelling of the round-trip fixture. `isFatal` and
# `isInstanceOf` are one check here: TypeScript distinguishes `FatalError.is()`
# (a name check, for errors from another realm) from `instanceof`, and Python's
# sandbox shares the host's class object so there is nothing to distinguish. And
# the cause is attached with `raise ... from ...`, which is `__cause__` — the
# explicit attribution JavaScript's `cause` also means.
@app.step
async def throwFatalError() -> None:
raise FatalError("Fatal step error")
@app.workflow
async def errorRetryFatal() -> str:
await throwFatalError()
return "never reached"
@app.workflow
async def errorFatalCatchable() -> dict:
try:
await throwFatalError()
return {"caught": False, "isFatal": False}
except Exception as e:
return {"caught": True, "isFatal": isinstance(e, FatalError)}
@app.step
async def throwFatalErrorWithCause() -> None:
raise FatalError("fatal with cause") from TypeError("underlying type error")
@app.workflow
async def errorStepThrowFatalRoundTrip() -> dict:
try:
await throwFatalErrorWithCause()
return {"caught": False}
except Exception as e:
cause = e.__cause__
return {
"caught": True,
"isFatal": isinstance(e, FatalError),
"isInstanceOf": isinstance(e, FatalError),
"message": str(e),
"name": type(e).__name__,
"hasFatalProp": getattr(e, "fatal", None) is True,
"causeIsTypeError": isinstance(cause, TypeError),
"causeName": type(cause).__name__ if cause is not None else None,
"causeMessage": str(cause) if cause is not None else None,
}
@app.workflow
async def errorWorkflowThrowFatalRoundTrip() -> str:
# `ValueError` where the TypeScript fixture throws a `RangeError`: they are
# the pair on the wire, so a JavaScript reader of this run's `run_failed`
# gets a real `RangeError` back out of the cause.
raise FatalError("workflow exploded") from ValueError("out of bounds")
##########################################################
# metadataFromHelperWorkflow
#
# Upstream's #1577 regression test: the metadata accessors have to work from a
# helper defined at module level rather than inline in the step body. The
# mechanism differs — `AsyncLocalStorage` there, a `contextvars.ContextVar`
# here — but the failure mode it guards against is the same one, a context
# that only propagates as far as the decorated function.
#
# Only the step half is checked. `getStepMetadata()`'s counterpart
# `getWorkflowMetadata()` has no Python equivalent, so `workflowRunId` comes
# off `StepInfo.run_id`, which is the same run id the TypeScript fixture reads
# out of the workflow metadata. That also makes `workflowAndStepMetadataWorkflow`
# — which asserts the two metadata objects against each other — unportable for
# now, so it is not in this file.
async def _withStrictMetadataCheck(fn):
stepMetadata = get_step_metadata()
return await fn(), stepMetadata
@app.step
async def metadataHelperStep(label: str) -> dict:
async def _produce() -> str:
return label
_result, stepMetadata = await _withStrictMetadataCheck(_produce)
return {
"label": label,
"workflowRunId": stepMetadata.run_id,
"stepId": stepMetadata.step_id,
"attempt": stepMetadata.attempt,
}
@app.workflow
async def metadataFromHelperWorkflow(label: str) -> dict:
return await metadataHelperStep(label)
##########################################################
# spawnWorkflowFromStepWorkflow
#
# A run that starts another run. `start()` is a world write, so it can only
# happen in a step — the workflow body replays and its sandbox has no network,
# which is the same restriction the TypeScript fixture states in a comment.
# Waiting for the child is a step for the same reason.
#
# `Run(run_id).return_value()` polls the child's status; the TypeScript
# `getRun(runId).returnValue` is the same shape. Both hold the parent's step
# open for as long as the child takes, which is the caveat the TS fixture's
# `fibonacciWorkflow` neighbour documents at length — worth remembering before
# porting that one, since its recursion needs the worker pool to be deep enough
# for every waiting parent.
@app.step
async def doubleValue(value: int) -> int:
return value * 2
@app.workflow
async def childWorkflow(value: int) -> dict:
return {"childResult": await doubleValue(value), "originalValue": value}
@app.step
async def spawnChildWorkflow(value: int) -> str:
childRun = await start(childWorkflow, value)
return childRun.run_id
@app.step
async def awaitWorkflowResult(runId: str) -> Any:
return await Run(runId).return_value()
@app.workflow
async def spawnWorkflowFromStepWorkflow(inputValue: int) -> dict:
childRunId = await spawnChildWorkflow(inputValue)
childResult = await awaitWorkflowResult(childRunId)
return {
"parentInput": inputValue,
"childRunId": childRunId,
"childResult": childResult,
}
##########################################################
# stepNotRegisteredCatchable
# stepNotRegisteredUncaught
#
# These fixtures deliberately construct Step wrappers without registering them
# with `app`. TypeScript reaches the same otherwise-impossible deployment shape
# through its internal WORKFLOW_USE_STEP symbol. The private constructor is
# confined to this conformance fixture; application code should use `@app.step`.
async def nonExistentStep() -> None:
raise AssertionError("an unregistered step body must never execute")
async def anotherNonExistentStep() -> None:
raise AssertionError("an unregistered step body must never execute")
_nonExistentStep = Step(nonExistentStep)
_anotherNonExistentStep = Step(anotherNonExistentStep)
@app.workflow
async def stepNotRegisteredCatchable() -> dict:
try:
await _nonExistentStep()
return {"caught": False, "error": None}
except Exception as error:
return {"caught": True, "error": str(error)}
@app.workflow
async def stepNotRegisteredUncaught() -> None:
await _anotherNonExistentStep()
##########################################################
# hookWithSleepWorkflow
# hookWithSleepFinalStepWorkflow
# hookTokenReuseLoopWorkflow
# sleepWithSequentialStepsWorkflow
#
# `HookEvent` implements both `__await__` (one payload) and `__aiter__` /
# `__anext__` (a stream of them), so `for await (const p of hook)` ports to
# `async for payload in hook` directly. `Hook.set_result` requires the
# class handed to `wait()` to be a dataclass or a pydantic model, and calls
# `hook_cls(**raw)` on the plain JSON the resumer sent — so the port is a
# dataclass with a default per optional field, and the fixture's structural
# type becomes a declared one. The declaration has to stay loose in the same
# places the TypeScript type is optional: the driver resumes with `{type, id}`
# on one payload and `{type, done}` on another, and a required field would
# raise on whichever call omitted it.
#
# Three translation details matter here:
#
# - **`using hook` is not `try/finally`.** A Python workflow body unwinds
# through a `_SuspendException` on *every* suspension, so a `finally` around
# an `await` runs once per turn rather than once at scope exit. Disposing a
# hook there deletes the suspension before the orchestrator can flush its
# `hook_created`, and the run stalls with no hook for the driver to resume.
# Dispose on the normal path only.
# - **`void sleep('1d')`** is `asyncio.ensure_future(sleep("1d"))`. The wait is
# created and never completes; the body returns first and the orphaned task
# is cancelled with the loop.
# - **A step takes the payload as a dict**, not as the dataclass: keeping the
# step signature `dict` avoids registering a serializer for a type that only
# exists to satisfy `set_result`.
#
# `sleepWithSequentialStepsWorkflow` is the cluster's control and has no hook in
# it at all — a fire-and-forget sleep plus three sequential steps.
@dataclasses.dataclass
class SleepHookPayload(BaseHook):
type: str
id: int | None = None
done: bool | None = None
@app.step
async def processPayload(payload: dict) -> dict:
return {"processed": True, "type": payload["type"], "id": payload.get("id")}
@app.workflow
async def hookWithSleepWorkflow(token: str) -> list:
hook = SleepHookPayload.wait(token=token)
# Concurrent sleep that won't complete during the test
asyncio.ensure_future(sleep("1d"))
results = []
async for payload in hook:
results.append(await processPayload(dataclasses.asdict(payload)))
if payload.done:
break
hook.dispose()
return results
@app.workflow
async def hookWithSleepFinalStepWorkflow(token: str) -> dict:
hook = SleepHookPayload.wait(token=token)
asyncio.ensure_future(sleep("1d"))
seen = []
finalResult = None
async for payload in hook:
if payload.id is not None:
seen.append(payload.id)
if payload.done:
finalResult = await processPayload(dataclasses.asdict(payload))
break
hook.dispose()
return {"seen": seen, "finalResult": finalResult}
@dataclasses.dataclass
class ReuseHookPayload(BaseHook):
message: str
@app.workflow
async def hookTokenReuseLoopWorkflow(token: str, rounds: int) -> dict:
received = []
for round_index in range(rounds):
hook = ReuseHookPayload.wait(token=token)
conflict = await hook.get_conflict()
if conflict is not None:
return {"received": received, "conflictRound": round_index}
payload = await hook
received.append(payload.message)
hook.dispose()
return {"received": received, "conflictRound": None}
@app.step
async def addNumbers(a: int, b: int) -> int:
return a + b
@app.workflow
async def sleepWithSequentialStepsWorkflow() -> dict:
shouldCancel = False
async def _cancelAfterSleep() -> None:
nonlocal shouldCancel
await sleep("1d")
shouldCancel = True
asyncio.ensure_future(_cancelAfterSleep())
a = await addNumbers(1, 2)
b = await addNumbers(a, 3)
c = await addNumbers(b, 4)
return {"a": a, "b": b, "c": c, "shouldCancel": shouldCancel}
##########################################################
# Cancellable steps
#
# These fixtures cover cancellation behavior that is shared across runtimes:
# timeout, parallel cancellation, reason propagation, and hook-triggered
# cancellation. JavaScript implements them with AbortSignal; Python opts a step
# in with `cancellable=True` and cancels the asyncio task awaiting it. The APIs
# differ, but the driver asserts only the shared behavior.
@app.step(cancellable=True)
async def cancellableLongStep() -> dict:
try:
await asyncio.sleep(30)
except asyncio.CancelledError as error:
return {
"result": "aborted",
"reason": str(error.args[0]) if error.args else None,
}
return {"result": "completed", "reason": None}
async def _cancelAndWait(task: asyncio.Task, reason: str | None = None) -> dict:
task.cancel(reason)
return await task
@app.workflow
async def abortTimeoutWorkflow() -> dict:
longStep = asyncio.ensure_future(cancellableLongStep())
winner = await _race(longStep, sleep("3s"))
if winner is None:
state = await _cancelAndWait(longStep)
return {"status": "timed out", "aborted": state["result"] == "aborted"}
return {"status": "completed", "result": winner["result"]}
@app.workflow
async def abortParallelWorkflow() -> dict:
steps = [asyncio.ensure_future(cancellableLongStep()) for _ in range(3)]
timeout = asyncio.ensure_future(sleep("3s"))
done, _pending = await asyncio.wait(
[*steps, timeout], return_when=asyncio.FIRST_COMPLETED
)
if timeout in done:
results = await asyncio.gather(*(_cancelAndWait(step) for step in steps))
return {
"status": "timed out",
"results": [result["result"] for result in results],
}
results = await asyncio.gather(*steps)
return {
"status": "completed",
"results": [result["result"] for result in results],
}
@app.workflow
async def abortReasonWorkflow() -> dict:
longStep = asyncio.ensure_future(cancellableLongStep())
winner = await _race(longStep, sleep("2s"))
if winner is None:
state = await _cancelAndWait(longStep, "custom timeout reason")
else:
state = winner
return {
"aborted": state["result"] == "aborted",
"reason": state["reason"],
}
@dataclasses.dataclass
class CancellationHookPayload(BaseHook):
reason: str
@app.workflow
async def abortViaHookWorkflow(hookToken: str) -> dict:
hook = CancellationHookPayload.wait(token=hookToken)
longStep = asyncio.ensure_future(cancellableLongStep())
winner = await _race(longStep, hook)
if isinstance(winner, CancellationHookPayload):
state = await _cancelAndWait(longStep, winner.reason)
hook.dispose()
if state["result"] == "aborted":
return {"status": "cancelled", "reason": state["reason"]}
return {"status": "completed", "result": state["result"]}
hook.dispose()
return {"status": "completed", "result": winner["result"]}
##########################################################
# writableForwardedFromWorkflowWorkflow
# writableForwardedFromStepWorkflow
#
# A stream reference crossing a *run* boundary: the parent hands its writable to
# a child run as part of that run's input, and the driver then reads the bytes
# off the **parent's** stream. So the handle has to survive `start()`'s input
# serialization, arrive in another run's body, and still name the stream it came
# from rather than the child's own — which is why `WorkflowStreamHandle` carries
# a run id instead of deriving one from the ambient run.
#
# The two variants differ in where the parent's `get_writable()` is called, and
# they are not the same path through the SDK. Variant 1 calls it in the workflow
# body, so a *handle* is serialized into a step's arguments, revived there as a
# writer, and serialized again into the child's input. Variant 2 calls it inside
# the step that also calls `start()`, so what gets forwarded is the step-context
# writer directly. Both have to land on the same stream.
#
# `start()` lives in a step for the usual reason: it is a world write, and the
# workflow body replays with no network. The TypeScript fixture says the same
# thing in a comment, which is a good sign the restriction is protocol-shaped
# rather than Python-shaped.
@app.step
async def writeBytesToWritable(writable: WorkflowWritable, payload: str) -> None:
await writable.write(payload.encode())
@app.workflow
async def writableForwardedChildWorkflow(
parentWritable: WorkflowWritable, payload: str
) -> str:
await writeBytesToWritable(parentWritable, payload)
return "child-done"
@app.step
async def startChildWithWorkflowWritable(
parentWritable: WorkflowWritable, payload: str
) -> str:
childRun = await start(writableForwardedChildWorkflow, parentWritable, payload)
# Let the child finish writing before the parent is allowed to close.
await childRun.return_value()
return childRun.run_id
@app.workflow
async def writableForwardedFromWorkflowWorkflow(payload: str) -> dict:
writable = get_writable()
childRunId = await startChildWithWorkflowWritable(writable, payload)
await stepCloseOutputStream(writable)
return {"childRunId": childRunId}
@app.step
async def startChildWithStepWritable(payload: str) -> str:
writable = get_writable()
childRun = await start(writableForwardedChildWorkflow, writable, payload)
await childRun.return_value()
await writable.close()
return childRun.run_id
@app.workflow
async def writableForwardedFromStepWorkflow(payload: str) -> dict:
return {"childRunId": await startChildWithStepWritable(payload)}
##########################################################
# retainedInterleavingWorkflow
#
# Every suspension kind this app can produce, in one body, with the exact
# composite result asserted — so a dropped, duplicated or misordered boundary
# fails loudly rather than shifting a number nobody checks.
#
# Upstream wrote it for VM retention (`WORKFLOW_RETAINED_VM`): primitive step
# arguments keep the retained VM, a non-primitive argument demotes the boundary
# to a cold replay. Python has no such VM, so `unwrapValue`'s object argument is
# just an object argument here. That does not make the fixture pointless on this
# side — what the test actually asserts is that nine values come back right
# across a step / gather / race / sleep / hook interleaving, and that is the
# same claim in any language.
#
# The hook is created with a token and no metadata, and it is awaited
# *concurrently with a step* —
# `asyncio.gather(hook, add(...))`, since `HookEvent` is awaitable. Dispose on
# the normal path only; see the hook cluster above for why `finally` would break
# it.
@app.step
async def unwrapValue(box: dict) -> int:
return box["value"]
@dataclasses.dataclass
class DeltaPayload(BaseHook):
delta: int
@app.workflow
async def retainedInterleavingWorkflow(token: str) -> dict:
a = await add(1, 2)
b = await unwrapValue({"value": a})
c, d = await asyncio.gather(add(b, 10), add(b, 20))
e, f = await asyncio.gather(unwrapValue({"value": c}), add(d, 1))
winner = await _race(delayMsStep(100, "step"), _sleepThen("30s", "sleep"))
await sleep("1s")
hook = DeltaPayload.wait(token=token)
payload, g = await asyncio.gather(hook, add(e + f, 100))
h = await add(g, payload.delta)
hook.dispose()
return {
"a": a,
"b": b,
"c": c,
"d": d,
"e": e,
"f": f,
"winner": winner,
"g": g,
"h": h,
}
##########################################################
# hookWorkflow
# hookCleanupTestWorkflow
# hookDisposeTestWorkflow
#
# The three fixtures that needed hook *metadata* and nothing else. Metadata is
# how a run tells its resumer what it is waiting for: attached once when the hook
# is registered, read back off the hook entity rather than out of a payload. The
# suite leans on it hard — `hookWorkflow`'s driver resumes with
# `customData: hook.metadata?.customData` and then asserts the workflow saw that
# exact value, so a missing metadata field does not weaken the test, it fails it.
#
# `hookWorkflow`'s payload is a **pydantic model** rather than a dataclass, and
# that is load-bearing rather than a style choice. The driver sends `done` only
# on the last payload, and the test asserts the first two come back with `done`
# *absent* — `undefined`, not `false` and not `null`. A dataclass materializes
# every optional field, so `dataclasses.asdict` would report `done: None` and the
# assertion would fail on the difference between "not sent" and "sent as null".
# `model_dump(exclude_unset=True)` reproduces what the resumer actually sent,
# which is the property the test is really about. The other two fixtures have no
# optional fields and stay dataclasses.
#
# `using hook` becomes an explicit `dispose()` on the normal path — never a
# `finally`; see the hook cluster above for why. Where that dispose lands matters
# only in `hookDisposeTestWorkflow`, and there it is the whole point: it releases
# the token *before* the 5s sleep, so another run can claim it while this one is
# still going. In the other two the run completes right after, which frees the
# token anyway, so the call is a formality kept for symmetry with the fixture.
class HookPayload(BaseHook, pydantic.BaseModel):
message: str
customData: str
done: bool | None = None
@app.workflow
async def hookWorkflow(token: str, customData: str) -> list:
hook = HookPayload.wait(token=token, metadata={"customData": customData})
payloads = []
async for payload in hook:
payloads.append(payload.model_dump(exclude_unset=True))
if payload.done:
break
hook.dispose()
return payloads
@dataclasses.dataclass
class MessagePayload(BaseHook):
message: str
customData: str
@app.workflow
async def hookCleanupTestWorkflow(token: str, customData: str) -> dict:
hook = MessagePayload.wait(token=token, metadata={"customData": customData})
payload = await hook
hook.dispose()
return {
"message": payload.message,
"customData": payload.customData,
"hookCleanupTestData": "workflow_completed",
}
##########################################################
# hookGetConflict* and run-idempotency fixtures
#
# `get_conflict()` is a distinct suspension from awaiting a hook payload: it
# commits the hook registration, then resumes with either `None` or a `Run` for
# the current owner. Python's Run methods perform world I/O directly rather than
# becoming durable step proxies, so owner status/result lookups live in steps —
# the same split `spawnWorkflowFromStepWorkflow` uses above.
@dataclasses.dataclass
class ConflictPayload(BaseHook):
pass
@dataclasses.dataclass
class AdoptPayload(BaseHook):
value: str
@dataclasses.dataclass
class SignalPayload(BaseHook):
message: str
@app.step
async def hookGetConflictStep(customData: str) -> dict:
return {
"customData": customData,
"hookGetConflictStepData": "step_completed",
}
@app.step
async def hookGetConflictTimedStep(label: str, delayMs: int) -> dict:
startedAt = int(get_step_metadata().step_started_at.timestamp() * 1000)
await asyncio.sleep(delayMs / 1000)
return {
"label": label,
"startedAt": startedAt,
"endedAt": time.time_ns() // 1_000_000,
}
@app.step
async def getRunStatus(runId: str) -> str:
return await Run(runId).status()
@app.workflow
async def hookGetConflictWorkflow(token: str, customData: str) -> dict:
hook = ConflictPayload.wait(token=token, metadata={"customData": customData})
conflict = await hook.get_conflict()
if conflict is not None:
return {
"token": token,
"customData": customData,
"conflictRunId": conflict.run_id,
"conflictStatus": await getRunStatus(conflict.run_id),
"hookGetConflictTestData": "hook_token_conflict_detected",
}
return {
"token": token,
"customData": customData,
"conflictRunId": None,
"hookGetConflictTestData": "hook_registered_without_payload",
}
@app.workflow
async def hookGetConflictWithPriorStepWorkflow(token: str, customData: str) -> dict:
hook = ConflictPayload.wait(token=token, metadata={"customData": customData})
stepTask = asyncio.ensure_future(hookGetConflictStep(customData))
conflict = await hook.get_conflict()
return {
"token": token,
"customData": customData,
"conflictRunId": None if conflict is None else conflict.run_id,
"stepResult": await stepTask,
"hookGetConflictTestData": "prior_step_completed_after_registration",
}
@app.workflow
async def hookGetConflictWithParallelStepWorkflow(token: str, customData: str) -> dict:
hook = ConflictPayload.wait(token=token, metadata={"customData": customData})
stepResult, conflict = await asyncio.gather(
hookGetConflictStep(customData), hook.get_conflict()
)
return {
"token": token,
"customData": customData,
"conflictRunId": None if conflict is None else conflict.run_id,
"stepResult": stepResult,
"hookGetConflictTestData": "parallel_step_completed_with_registration",
}
@app.workflow
async def hookGetConflictThenStepParallelWorkflow(token: str, customData: str) -> dict:
hook = ConflictPayload.wait(token=token, metadata={"customData": customData})
async def runStepB() -> dict:
await hook.get_conflict()
return await hookGetConflictTimedStep("B", 100)
stepBTask = asyncio.ensure_future(runStepB())
stepAResult = await hookGetConflictTimedStep("A", 10_000)
stepBResult = await stepBTask
return {
"token": token,
"customData": customData,
"stepAResult": stepAResult,
"stepBResult": stepBResult,
"hookGetConflictTestData": "registration_then_step_runs_in_parallel",
}
@app.workflow
async def hookClaimOnlyMutexWorkflow(token: str, holdMs: int) -> dict:
hook = ConflictPayload.wait(token=token)
conflict = await hook.get_conflict()
if conflict is not None:
return {"role": "duplicate", "conflictRunId": conflict.run_id}
work = await hookGetConflictTimedStep("A", holdMs)
return {"role": "owner", "workEndedAt": work["endedAt"]}
@app.workflow
async def hookAdoptOwnerResultWorkflow(token: str, marker: str) -> dict:
hook = AdoptPayload.wait(token=token)
conflict = await hook.get_conflict()
if conflict is not None:
adopted = await awaitWorkflowResult(conflict.run_id)
return {
"role": "duplicate",
"conflictRunId": conflict.run_id,
"adopted": adopted,
}
payload = await hook
return {"role": "owner", "marker": marker, "value": payload.value}
@app.step
async def forwardPayloadToOwner(token: str, message: str) -> None:
await SignalPayload(message=message).resume(token)
@app.workflow
async def hookSignalOwnerWorkflow(token: str, message: str) -> dict:
hook = SignalPayload.wait(token=token)
conflict = await hook.get_conflict()
if conflict is not None:
await forwardPayloadToOwner(token, message)
return {"role": "duplicate", "forwardedTo": conflict.run_id}
payload = await hook
return {"role": "owner", "received": payload.message}
@app.workflow
async def hookDisposeTestWorkflow(token: str, customData: str) -> dict:
hook = MessagePayload.wait(token=token, metadata={"customData": customData})
payload = await hook
message, customDataResult = payload.message, payload.customData
# Releases the token here rather than at run completion, which is what lets
# the test's second run claim it while this one is still sleeping.
hook.dispose()
await sleep("5s")
return {
"message": message,
"customData": customDataResult,
"disposed": True,
"hookDisposeTestData": "workflow_completed",
}
##########################################################
# errorRetryCustomDelay
#
# The third of the TypeScript retry block's fixtures, and the one that needed
# two things at once: `RetryableError(retry_after=…)` to steer the wait, and
# `StepInfo.step_started_at` to measure it. `step_started_at` is when the
# *first* attempt began, so `now - step_started_at` on attempt 2 is how long the
# step has been going across the retry — which is what the test bounds at 10s.
#
# Wall clock rather than the workflow clock, because this runs in a step: the
# deterministic clock is a replay construct and would report the same instant on
# both attempts.
@app.step
async def throwRetryableError() -> dict:
metadata = get_step_metadata()
if metadata.attempt == 1:
raise RetryableError("Retryable error", retry_after="10s")
startedAt = int(metadata.step_started_at.timestamp() * 1000)
return {
"attempt": metadata.attempt,
"duration": time.time_ns() // 1_000_000 - startedAt,
}
@app.workflow
async def errorRetryCustomDelay() -> dict:
return await throwRetryableError()
##########################################################
# setAttributesWorkflow
# setAttributesInsideStepWorkflow
# setAttributesFireAndForgetWorkflow
# setAttributesParallelWorkflow
# setAttributesThrowsAfterWorkflow
# setAttributesValidationWorkflow
#
# Plaintext key/value metadata on the run, and the whole `setAttributes` block in
# one go now that `set_attributes()` exists. Six fixtures covering the axes the
# implementation can get wrong independently: from the workflow body, from a step
# body, unawaited, concurrently over disjoint keys, on a run that then fails, and
# with every input the validator is supposed to reject.
#
# Two Python-shaped details, neither of them a workaround:
#
# - **`undefined` is `None`.** `setAttributes({ source: undefined })` removes the
# key; the Python spelling is `{"source": None}`, which is what the SDK's
# `Mapping[str, str | None]` signature already says.
# - **Fire-and-forget is a scheduled task.** `set_attributes()` is an async
# function, so Python has to schedule the coroutine to start it without
# awaiting its completion. The next workflow suspension gives those tasks a
# turn to register their writes, which is the `void setAttributes(...)` shape
# this fixture exercises.
@app.workflow
async def setAttributesWorkflow(input: int) -> int:
await set_attributes({"phase": "init", "source": "workflow-body"})
tripled = input * 3
await set_attributes({"phase": "done"})
# `None` removes the key, the way `undefined` does on the TypeScript side.
await set_attributes({"source": None})
return tripled
@app.step
async def setAttributesFromStep(input: int) -> int:
await set_attributes(
{"phase": "step-started", "source": "step-body", "input": str(input)}
)
await set_attributes({"phase": "step-done"})
return input * 4
@app.workflow
async def setAttributesInsideStepWorkflow(input: int) -> int:
return await setAttributesFromStep(input)
@app.workflow
async def setAttributesFireAndForgetWorkflow() -> str:
# Deliberately scheduled and not awaited: the write starts now and lands at
# the next suspension, matching `void setAttributes(...)` in TypeScript.
asyncio.create_task(set_attributes({"phase": "init", "mode": "fire-and-forget"}))
await sleep("100ms")
asyncio.create_task(set_attributes({"phase": "mid"}))
await sleep("100ms")
# This final scheduled write is the unsupported edge: the workflow body
# returns before the coroutine starts, so the runtime has nothing to drain.
asyncio.create_task(set_attributes({"phase": "done"}))
return "completed"
@app.workflow
async def setAttributesParallelWorkflow() -> str:
await asyncio.gather(
set_attributes({"a": "1"}),
set_attributes({"b": "2"}),
set_attributes({"c": "3"}),
)
return "done"
@app.workflow
async def setAttributesThrowsAfterWorkflow() -> None:
await set_attributes({"phase": "about-to-fail", "reason": "intentional"})
raise FatalError("intentional failure to test attribute persistence")
@app.workflow
async def setAttributesValidationWorkflow() -> dict:
outcomes = {}
async def attempt(label: str, attrs) -> None:
try:
await set_attributes(attrs)
outcomes[label] = "no-error"
except Exception as e:
outcomes[label] = f"{type(e).__name__}: {e}"
await attempt("reserved", {"$system": "nope"})
await attempt("emptyKey", {"": "v"})
await attempt("keyTooLong", {"k" * 257: "v"})
await attempt("valueTooLong", {"note": "v" * 257})
# The cap is bytes, not characters: 200 two-byte characters is 400 bytes.
await attempt("valueTooManyBytes", {"note": "é" * 200})
await attempt("overCap", {f"k{i}": "v" for i in range(65)})
await attempt("nonObject", "phase=init")
# The run must remain healthy after every rejected call.
await set_attributes({"phase": "validated"})
return outcomes