Custom Durable Operations¶
Third-party packages can build reusable durable operations with the public
extension-author interface in async_durable_execution.extension. The interface
allocates stable operation identities and delegates checkpointing, suspension,
replay, serialization, and failures to SDK-owned primitive state machines.
Extension packages do not register with the SDK and do not send raw checkpoint updates. An extension is ordinary Python code that composes reserved STEP, WAIT, CHAINED_INVOKE, CALLBACK, and CONTEXT primitives.
All SDK-provided operation helpers, including step(), wait(), invoke(),
create_callback(), and run_in_child_context(), use this same reservation
interface internally. Their canonical private implementation package is
async_durable_execution._operation; backend checkpoint state machines remain
isolated under async_durable_execution._primitive. The SPI delegates to those
internal executors rather than duplicating primitive lifecycle behavior. The former
async_durable_execution._extension modules remain import aliases for
compatibility, but both underscore-prefixed packages are private; third-party
operations should depend only on async_durable_execution.extension and the
top-level public exports.
Basic Extension¶
This extension starts two reserved steps and combines their results:
import asyncio
from collections.abc import Awaitable, Callable
from async_durable_execution import (
ExtensionStepResult,
get_extension_context,
)
async def pair(
left: Callable[[], Awaitable[str]],
right: Callable[[], Awaitable[str]],
*,
name: str,
) -> str:
extension = get_extension_context()
left_operation = extension.reserve(f"{name}-left")
right_operation = extension.reserve(f"{name}-right")
async def run_left(_state: None):
return ExtensionStepResult.succeed(await left())
async def run_right(_state: None):
return ExtensionStepResult.succeed(await right())
left_task = left_operation.step(run_left, sub_type="AcmePairStep")
right_task = right_operation.step(run_right, sub_type="AcmePairStep")
left_value, right_value = await asyncio.gather(left_task, right_task)
return left_value + right_value
Application code imports and awaits the extension like any other async helper:
No automatic child context surrounds an extension. Reserve primitives in the current scope unless the extension specifically requires an isolated child context.
Reservations¶
ExtensionContext.reserve() immediately consumes an operation identity and
returns an opaque, one-shot ExtensionOperation. Reserve operations before
launching them when launch order may vary:
extension = get_extension_context()
first = extension.reserve("first")
second = extension.reserve("second")
# Launch order may differ from reservation order.
second_task = second.step(run_second, sub_type="AcmeStep")
first_task = first.step(run_first, sub_type="AcmeStep")
Sequential reservations must occur in the same order on every replay. Reordering,
inserting, or removing them is a workflow compatibility change.
ExtensionOperation instances cannot be constructed directly; obtain them only
through ExtensionContext.reserve().
Schedulers whose registration order may change can use stable local IDs:
operation = get_extension_context().reserve(
"process-node",
local_operation_id="node-a",
)
result = await operation.step(
process_node,
sub_type="AcmeNode",
initial_state={"node": "a"},
)
A local ID must be a nonblank string and unique within the current durable context. Reserve every local-ID operation before selecting any reserved primitive; late local-ID reservations are rejected because future hashed IDs cannot be discovered from replay history. Local IDs are namespaced by the SDK; extension code never receives the backend operation ID. Changing or reusing a local ID is a workflow compatibility change.
Reservation names must be nonblank strings when provided. An
ExtensionContext may only reserve operations, and a reservation may only be
claimed, while the durable handler or child context that created it is active.
They cannot create operations from inside a step function or another durable
scope.
Each reservation can create exactly one primitive. Reusing it raises
RuntimeError.
Primitive Methods¶
An ExtensionOperation exposes one explicit method per backend primitive:
| Method | Backend type | Result |
|---|---|---|
step() |
STEP |
Stateful checkpointed result |
wait() |
WAIT |
Completion after a durable delay |
invoke() |
CHAINED_INVOKE |
Invoked durable function result |
create_callback() |
CALLBACK |
Callback handle |
run_in_child_context() |
CONTEXT |
Child-context result |
Every method requires a subtype. A subtype may be an existing
OperationSubType or a nonblank extension-owned string such as "AcmePoll".
An extension-owned string must not match a reserved OperationSubType value.
Pass the enum member explicitly when selecting an SDK subtype.
The method selects the backend operation type; the subtype only identifies the
logical operation in history, plugins, logs, and replay validation. Changing a
subtype is a workflow compatibility change.
Extensions cannot define new backend state machines or emit raw lifecycle actions.
Stateful Steps¶
An extension step receives checkpointed state and returns either
ExtensionStepResult.succeed(value) or
ExtensionStepResult.retry(state, delay):
from async_durable_execution import (
ExtensionStepResult,
get_extension_context,
)
async def poll(state: dict | None):
current = state or {"attempts": 0, "ready": False}
refreshed = await refresh_status(current)
if refreshed["ready"]:
return ExtensionStepResult.succeed(refreshed)
return ExtensionStepResult.retry(refreshed, delay=5)
result = await get_extension_context().reserve("poll").step(
poll,
sub_type="AcmePoll",
initial_state={"attempts": 0, "ready": False},
)
Retry state is serialized into the STEP checkpoint and restored on the next
attempt. get_step_context().attempt remains available inside the function.
The optional serdes applies to both retry state and the final result.
Thrown exceptions are terminal unless retry_strategy returns
ExtensionStepResult.retry(...):
def retry_error(error, state, attempt):
if attempt >= 3:
return None
return ExtensionStepResult.retry(state, delay=attempt)
Use step_semantics to select the same at-least-once or at-most-once attempt
behavior as a normal durable step.
Child Contexts¶
Create a custom child context only when the extension needs an isolated operation namespace:
async def run_batch():
child_extension = get_extension_context()
item = child_extension.reserve(
"item",
local_operation_id="item-1",
)
return await item.step(process_item, sub_type="AcmeBatchItem")
result = await get_extension_context().reserve("batch").run_in_child_context(
run_batch,
sub_type="AcmeBatch",
)
Inside run_batch, get_extension_context() returns the child scope. Nested
local IDs are automatically namespaced beneath the child context.
Replay Contract¶
Extension code is replayed under the same rules as handler code:
- Reserve the same logical operations on every replay.
- Keep sequential reservation order deterministic.
- Local IDs use a separate identity namespace and do not consume sequential reservation positions.
- Keep local IDs, primitive choices, subtypes, names, and parent scope stable.
- Put nondeterministic work and side effects inside reserved steps.
- Keep retry strategies deterministic and side-effect free.
- Treat changing an extension's checkpoint topology as a workflow migration.
The SDK validates reserved operation type, subtype, name, and parent metadata against existing checkpoints before replaying them.
Testing Extensions¶
Use create_local_runner() to exercise first execution, suspension, and replay.
Tests should cover changed launch order after reservation, custom local IDs,
stateful retries, nested contexts, bounded concurrent children that suspend and
resume, and compatibility with checkpoints produced by the previous extension
version.