Skip to content

Commit 5f2f506

Browse files
Nexus: add workflow-safe Python service-call caller API (#220)
1 parent 1b24be0 commit 5f2f506

12 files changed

Lines changed: 1164 additions & 7 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,36 @@ receipt = yield ctx.start_child_workflow(
124124
)
125125
```
126126

127+
## Nexus service calls
128+
129+
Workflow code can call a registered Nexus service operation through
130+
`WorkflowContext.call_nexus_service(...)`. The worker executes the service
131+
operation through the service-catalog API, records the response or typed
132+
failure as a durable side-effect marker, and resumes replay from that marker
133+
on subsequent workflow tasks.
134+
135+
```python
136+
from durable_workflow import NexusOperationFailed
137+
138+
try:
139+
result = yield ctx.call_nexus_service(
140+
"greeter",
141+
"shared",
142+
"greet",
143+
["Ada"],
144+
service_sdk_language="workflow-php",
145+
)
146+
print(result.service_call_id, result.result)
147+
except NexusOperationFailed as exc:
148+
print(exc.service_call_id, exc.service_error_type, exc.typed_error_message)
149+
```
150+
151+
The SDK assigns a deterministic idempotency key when one is not provided and
152+
attaches the caller workflow instance id, caller run id, `sdk-python` caller
153+
language, target service language, operation name, request payload,
154+
service-call id, response or failure surface, and optional artifact metadata
155+
to the recorded result.
156+
127157
## Workflow signals, queries, and updates
128158

129159
Signals mutate workflow state during replay:

docs/reference/client.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
# Client
22

3+
`Client.execute_nexus_operation(...)` calls a registered Nexus service
4+
operation through the service-catalog API and returns `NexusOperationResult`.
5+
Workflow authors should normally yield `ctx.call_nexus_service(...)`; the
6+
worker uses this client method to execute once and record the durable result
7+
or typed service failure.
8+
39
::: durable_workflow.client

docs/reference/workflow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,9 @@ Workflow retry and timeout settings are durable command budgets. Use
55
`ChildWorkflowRetryPolicy` on `ctx.start_child_workflow(...)` for child workflow
66
attempts. Use `TransportRetryPolicy` only for client HTTP retries.
77

8+
Use `ctx.call_nexus_service(...)` from workflow code to start a Nexus service
9+
operation durably. The Python worker records the accepted response or typed
10+
service failure as a side-effect marker, then replay resumes with
11+
`NexusOperationResult` or raises `NexusOperationFailed` at the yield point.
12+
813
::: durable_workflow.workflow

src/durable_workflow/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
DurableWorkflowError,
6161
InvalidArgument,
6262
NamespaceNotFound,
63+
NexusOperationFailed,
6364
NonDeterministicReplayError,
6465
NonRetryableError,
6566
QueryFailed,
@@ -76,6 +77,12 @@
7677
WorkflowPayloadDecodeError,
7778
WorkflowTerminated,
7879
)
80+
from .nexus import (
81+
NEXUS_CALLER_SDK_LANGUAGE,
82+
NEXUS_OPERATION_RESULT_SCHEMA,
83+
NEXUS_OPERATION_RESULT_VERSION,
84+
NexusOperationResult,
85+
)
7986
from .external_storage import (
8087
EXTERNAL_PAYLOAD_REFERENCE_SCHEMA,
8188
AzureBlobExternalStorage,
@@ -173,6 +180,7 @@
173180
ActivityRetryPolicy,
174181
ChildWorkflowRetryPolicy,
175182
ContinueAsNew,
183+
NexusServiceCall,
176184
Replayer,
177185
ReplayOutcome,
178186
StartChildWorkflow,
@@ -197,6 +205,9 @@
197205
"ContinueAsNew",
198206
"NamespaceDescription",
199207
"NamespaceList",
208+
"NEXUS_CALLER_SDK_LANGUAGE",
209+
"NEXUS_OPERATION_RESULT_SCHEMA",
210+
"NEXUS_OPERATION_RESULT_VERSION",
200211
"AUTH_COMPOSITION_CONTRACT_SCHEMA",
201212
"AUTH_COMPOSITION_CONTRACT_VERSION",
202213
"AUTH_COMPOSITION_REQUIRED_EFFECTIVE_CONFIG_FIELDS",
@@ -275,6 +286,9 @@
275286
"LocalFilesystemExternalStorage",
276287
"MetricsRecorder",
277288
"NamespaceNotFound",
289+
"NexusOperationFailed",
290+
"NexusOperationResult",
291+
"NexusServiceCall",
278292
"NonDeterministicReplayError",
279293
"NoopMetrics",
280294
"QueryFailed",

src/durable_workflow/client.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import time
2525
import uuid
2626
import warnings
27-
from collections.abc import AsyncIterator, Callable
27+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
2828
from dataclasses import dataclass
2929
from importlib.metadata import PackageNotFoundError
3030
from importlib.metadata import version as _pkg_version
@@ -43,6 +43,7 @@
4343
)
4444
from .external_storage import ExternalPayloadCache, ExternalPayloadStoragePolicy, ExternalStorageDriver
4545
from .metrics import CLIENT_REQUEST_DURATION_SECONDS, CLIENT_REQUESTS, NOOP_METRICS, MetricsRecorder
46+
from .nexus import NexusOperationResult, nexus_request_payload
4647
from .retry_policy import TransportRetryPolicy
4748

4849
PROTOCOL_VERSION = "1.1"
@@ -1563,6 +1564,112 @@ def get_workflow_handle(
15631564
"""
15641565
return WorkflowHandle(self, workflow_id=workflow_id, run_id=run_id, workflow_type=workflow_type)
15651566

1567+
async def execute_nexus_operation(
1568+
self,
1569+
endpoint_name: str,
1570+
service_name: str,
1571+
operation_name: str,
1572+
arguments: Sequence[Any] | None = None,
1573+
*,
1574+
mode: str | None = None,
1575+
wait_for: str | None = None,
1576+
wait_timeout_seconds: int | None = None,
1577+
idempotency_key: str | None = None,
1578+
payload_codec: str | None = None,
1579+
caller_namespace: str | None = None,
1580+
caller_workflow_instance_id: str | None = None,
1581+
caller_workflow_run_id: str | None = None,
1582+
service_sdk_language: str | None = None,
1583+
artifact_tuple: Mapping[str, Any] | None = None,
1584+
published_artifact_worker_execution: bool | None = None,
1585+
target_workflow_instance_id: str | None = None,
1586+
target_workflow_run_id: str | None = None,
1587+
connection: str | None = None,
1588+
queue: str | None = None,
1589+
business_key: str | None = None,
1590+
labels: Mapping[str, Any] | None = None,
1591+
memo: Mapping[str, Any] | None = None,
1592+
search_attributes: Mapping[str, Any] | None = None,
1593+
duplicate_start_policy: str | None = None,
1594+
raise_on_failure: bool = True,
1595+
) -> NexusOperationResult:
1596+
"""Execute a Nexus service operation through the service-catalog API.
1597+
1598+
Workflow code should normally yield
1599+
:meth:`durable_workflow.workflow.WorkflowContext.call_nexus_service`;
1600+
the worker uses this client method to make the single durable
1601+
execute request and records the returned service-call surface into
1602+
workflow history.
1603+
"""
1604+
request_body = nexus_request_payload(
1605+
arguments=list(arguments) if arguments is not None else [],
1606+
payload_codec=payload_codec,
1607+
mode=mode,
1608+
wait_for=wait_for,
1609+
wait_timeout_seconds=wait_timeout_seconds,
1610+
idempotency_key=idempotency_key,
1611+
caller_namespace=caller_namespace or self.namespace,
1612+
caller_workflow_instance_id=caller_workflow_instance_id,
1613+
caller_workflow_run_id=caller_workflow_run_id,
1614+
target_workflow_instance_id=target_workflow_instance_id,
1615+
target_workflow_run_id=target_workflow_run_id,
1616+
connection=connection,
1617+
queue=queue,
1618+
business_key=business_key,
1619+
labels=labels,
1620+
memo=memo,
1621+
search_attributes=search_attributes,
1622+
duplicate_start_policy=duplicate_start_policy,
1623+
)
1624+
path = (
1625+
f"/service-endpoints/{quote(endpoint_name, safe='')}"
1626+
f"/services/{quote(service_name, safe='')}"
1627+
f"/operations/{quote(operation_name, safe='')}/execute"
1628+
)
1629+
context = f"{endpoint_name}/{service_name}/{operation_name}"
1630+
1631+
try:
1632+
data = await self._request("POST", path, json=request_body, context=context)
1633+
except ServerError as exc:
1634+
if exc.status != 409 or not isinstance(exc.body, dict):
1635+
raise
1636+
result = NexusOperationResult.from_response(
1637+
exc.body,
1638+
endpoint_name=endpoint_name,
1639+
service_name=service_name,
1640+
operation_name=operation_name,
1641+
request_payload=request_body,
1642+
service_sdk_language=service_sdk_language,
1643+
artifact_tuple=artifact_tuple,
1644+
published_artifact_worker_execution=published_artifact_worker_execution,
1645+
)
1646+
if raise_on_failure:
1647+
raise result.to_failure() from exc
1648+
return result
1649+
1650+
if not isinstance(data, dict):
1651+
raise ServerError(
1652+
200,
1653+
{
1654+
"reason": "invalid_nexus_operation_response",
1655+
"message": f"expected JSON object, got {type(data).__name__}",
1656+
},
1657+
)
1658+
1659+
result = NexusOperationResult.from_response(
1660+
data,
1661+
endpoint_name=endpoint_name,
1662+
service_name=service_name,
1663+
operation_name=operation_name,
1664+
request_payload=request_body,
1665+
service_sdk_language=service_sdk_language,
1666+
artifact_tuple=artifact_tuple,
1667+
published_artifact_worker_execution=published_artifact_worker_execution,
1668+
)
1669+
if raise_on_failure and result.is_failure:
1670+
raise result.to_failure()
1671+
return result
1672+
15661673
# ── Health ─────────────────────────────────────────────────────────
15671674
async def health(self) -> dict[str, Any]:
15681675
"""Call the server's ``/health`` endpoint and return the JSON response."""

src/durable_workflow/errors.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@ def reason(self) -> str | None:
4444
return None
4545

4646

47+
class NexusOperationFailed(DurableWorkflowError):
48+
"""A Nexus service operation completed with a typed service failure."""
49+
50+
def __init__(
51+
self,
52+
message: str,
53+
*,
54+
service_call_id: str | None = None,
55+
endpoint_name: str | None = None,
56+
service_name: str | None = None,
57+
operation_name: str | None = None,
58+
status: str | None = None,
59+
outcome: str | None = None,
60+
reason: str | None = None,
61+
service_error_type: str | None = None,
62+
caller_observed_error_type: str | None = None,
63+
typed_error_message: str | None = None,
64+
body: object | None = None,
65+
) -> None:
66+
super().__init__(message)
67+
self.service_call_id = service_call_id
68+
self.endpoint_name = endpoint_name
69+
self.service_name = service_name
70+
self.operation_name = operation_name
71+
self.status = status
72+
self.outcome = outcome
73+
self.reason = reason
74+
self.service_error_type = service_error_type
75+
self.caller_observed_error_type = caller_observed_error_type
76+
self.typed_error_message = typed_error_message
77+
self.body = body
78+
79+
4780
class WorkflowFailed(DurableWorkflowError):
4881
"""A workflow finished in the ``failed`` state.
4982

0 commit comments

Comments
 (0)