durable-workflow 0.4.94__tar.gz → 0.4.96__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-0.4.94/src/durable_workflow.egg-info → durable_workflow-0.4.96}/PKG-INFO +31 -1
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/README.md +30 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/pyproject.toml +1 -1
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/__init__.py +14 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/client.py +121 -1
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/errors.py +33 -0
- durable_workflow-0.4.96/src/durable_workflow/nexus.py +307 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/worker.py +110 -2
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/workflow.py +183 -4
- {durable_workflow-0.4.94 → durable_workflow-0.4.96/src/durable_workflow.egg-info}/PKG-INFO +31 -1
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_client.py +148 -1
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_replay.py +164 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_sync.py +22 -1
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_worker.py +86 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/LICENSE +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/setup.cfg +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_activity_context.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_auth_composition.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_errors.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_external_storage.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_external_task_input.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_external_task_result.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_invocable.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_metrics.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_python_conformance.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_queries.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_release_docs_audit_workflow.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_replay_verify.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_retry_policy.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_schedules.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_serializer.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_signals.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_sleep.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_testing_harness.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_updates.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_wait_condition.py +0 -0
- {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: durable-workflow
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.96
|
|
4
4
|
Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
|
|
5
5
|
Author: Durable Workflow Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -160,6 +160,36 @@ receipt = yield ctx.start_child_workflow(
|
|
|
160
160
|
)
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
## Nexus service calls
|
|
164
|
+
|
|
165
|
+
Workflow code can call a registered Nexus service operation through
|
|
166
|
+
`WorkflowContext.call_nexus_service(...)`. The worker executes the service
|
|
167
|
+
operation through the service-catalog API, records the response or typed
|
|
168
|
+
failure as a durable side-effect marker, and resumes replay from that marker
|
|
169
|
+
on subsequent workflow tasks.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from durable_workflow import NexusOperationFailed
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
result = yield ctx.call_nexus_service(
|
|
176
|
+
"greeter",
|
|
177
|
+
"shared",
|
|
178
|
+
"greet",
|
|
179
|
+
["Ada"],
|
|
180
|
+
service_sdk_language="workflow-php",
|
|
181
|
+
)
|
|
182
|
+
print(result.service_call_id, result.result)
|
|
183
|
+
except NexusOperationFailed as exc:
|
|
184
|
+
print(exc.service_call_id, exc.service_error_type, exc.typed_error_message)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The SDK assigns a deterministic idempotency key when one is not provided and
|
|
188
|
+
attaches the caller workflow instance id, caller run id, `sdk-python` caller
|
|
189
|
+
language, target service language, operation name, request payload,
|
|
190
|
+
service-call id, response or failure surface, and optional artifact metadata
|
|
191
|
+
to the recorded result.
|
|
192
|
+
|
|
163
193
|
## Workflow signals, queries, and updates
|
|
164
194
|
|
|
165
195
|
Signals mutate workflow state during replay:
|
|
@@ -124,6 +124,36 @@ receipt = yield ctx.start_child_workflow(
|
|
|
124
124
|
)
|
|
125
125
|
```
|
|
126
126
|
|
|
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
|
+
|
|
127
157
|
## Workflow signals, queries, and updates
|
|
128
158
|
|
|
129
159
|
Signals mutate workflow state during replay:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.96"
|
|
8
8
|
description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -60,6 +60,7 @@ from .errors import (
|
|
|
60
60
|
DurableWorkflowError,
|
|
61
61
|
InvalidArgument,
|
|
62
62
|
NamespaceNotFound,
|
|
63
|
+
NexusOperationFailed,
|
|
63
64
|
NonDeterministicReplayError,
|
|
64
65
|
NonRetryableError,
|
|
65
66
|
QueryFailed,
|
|
@@ -76,6 +77,12 @@ from .errors import (
|
|
|
76
77
|
WorkflowPayloadDecodeError,
|
|
77
78
|
WorkflowTerminated,
|
|
78
79
|
)
|
|
80
|
+
from .nexus import (
|
|
81
|
+
NEXUS_CALLER_SDK_LANGUAGE,
|
|
82
|
+
NEXUS_OPERATION_RESULT_SCHEMA,
|
|
83
|
+
NEXUS_OPERATION_RESULT_VERSION,
|
|
84
|
+
NexusOperationResult,
|
|
85
|
+
)
|
|
79
86
|
from .external_storage import (
|
|
80
87
|
EXTERNAL_PAYLOAD_REFERENCE_SCHEMA,
|
|
81
88
|
AzureBlobExternalStorage,
|
|
@@ -173,6 +180,7 @@ from .workflow import (
|
|
|
173
180
|
ActivityRetryPolicy,
|
|
174
181
|
ChildWorkflowRetryPolicy,
|
|
175
182
|
ContinueAsNew,
|
|
183
|
+
NexusServiceCall,
|
|
176
184
|
Replayer,
|
|
177
185
|
ReplayOutcome,
|
|
178
186
|
StartChildWorkflow,
|
|
@@ -197,6 +205,9 @@ __all__ = [
|
|
|
197
205
|
"ContinueAsNew",
|
|
198
206
|
"NamespaceDescription",
|
|
199
207
|
"NamespaceList",
|
|
208
|
+
"NEXUS_CALLER_SDK_LANGUAGE",
|
|
209
|
+
"NEXUS_OPERATION_RESULT_SCHEMA",
|
|
210
|
+
"NEXUS_OPERATION_RESULT_VERSION",
|
|
200
211
|
"AUTH_COMPOSITION_CONTRACT_SCHEMA",
|
|
201
212
|
"AUTH_COMPOSITION_CONTRACT_VERSION",
|
|
202
213
|
"AUTH_COMPOSITION_REQUIRED_EFFECTIVE_CONFIG_FIELDS",
|
|
@@ -275,6 +286,9 @@ __all__ = [
|
|
|
275
286
|
"LocalFilesystemExternalStorage",
|
|
276
287
|
"MetricsRecorder",
|
|
277
288
|
"NamespaceNotFound",
|
|
289
|
+
"NexusOperationFailed",
|
|
290
|
+
"NexusOperationResult",
|
|
291
|
+
"NexusServiceCall",
|
|
278
292
|
"NonDeterministicReplayError",
|
|
279
293
|
"NoopMetrics",
|
|
280
294
|
"QueryFailed",
|
|
@@ -24,7 +24,7 @@ import os
|
|
|
24
24
|
import time
|
|
25
25
|
import uuid
|
|
26
26
|
import warnings
|
|
27
|
-
from collections.abc import AsyncIterator, Callable
|
|
27
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
28
28
|
from dataclasses import dataclass
|
|
29
29
|
from importlib.metadata import PackageNotFoundError
|
|
30
30
|
from importlib.metadata import version as _pkg_version
|
|
@@ -43,6 +43,7 @@ from .errors import (
|
|
|
43
43
|
)
|
|
44
44
|
from .external_storage import ExternalPayloadCache, ExternalPayloadStoragePolicy, ExternalStorageDriver
|
|
45
45
|
from .metrics import CLIENT_REQUEST_DURATION_SECONDS, CLIENT_REQUESTS, NOOP_METRICS, MetricsRecorder
|
|
46
|
+
from .nexus import NexusOperationResult, nexus_request_payload
|
|
46
47
|
from .retry_policy import TransportRetryPolicy
|
|
47
48
|
|
|
48
49
|
PROTOCOL_VERSION = "1.1"
|
|
@@ -163,6 +164,8 @@ class WorkflowExecution:
|
|
|
163
164
|
input: Any = None
|
|
164
165
|
output: Any = None
|
|
165
166
|
payload_codec: str | None = None
|
|
167
|
+
memo: dict[str, Any] | None = None
|
|
168
|
+
search_attributes: dict[str, Any] | None = None
|
|
166
169
|
|
|
167
170
|
|
|
168
171
|
@dataclass
|
|
@@ -1563,6 +1566,112 @@ class Client:
|
|
|
1563
1566
|
"""
|
|
1564
1567
|
return WorkflowHandle(self, workflow_id=workflow_id, run_id=run_id, workflow_type=workflow_type)
|
|
1565
1568
|
|
|
1569
|
+
async def execute_nexus_operation(
|
|
1570
|
+
self,
|
|
1571
|
+
endpoint_name: str,
|
|
1572
|
+
service_name: str,
|
|
1573
|
+
operation_name: str,
|
|
1574
|
+
arguments: Sequence[Any] | None = None,
|
|
1575
|
+
*,
|
|
1576
|
+
mode: str | None = None,
|
|
1577
|
+
wait_for: str | None = None,
|
|
1578
|
+
wait_timeout_seconds: int | None = None,
|
|
1579
|
+
idempotency_key: str | None = None,
|
|
1580
|
+
payload_codec: str | None = None,
|
|
1581
|
+
caller_namespace: str | None = None,
|
|
1582
|
+
caller_workflow_instance_id: str | None = None,
|
|
1583
|
+
caller_workflow_run_id: str | None = None,
|
|
1584
|
+
service_sdk_language: str | None = None,
|
|
1585
|
+
artifact_tuple: Mapping[str, Any] | None = None,
|
|
1586
|
+
published_artifact_worker_execution: bool | None = None,
|
|
1587
|
+
target_workflow_instance_id: str | None = None,
|
|
1588
|
+
target_workflow_run_id: str | None = None,
|
|
1589
|
+
connection: str | None = None,
|
|
1590
|
+
queue: str | None = None,
|
|
1591
|
+
business_key: str | None = None,
|
|
1592
|
+
labels: Mapping[str, Any] | None = None,
|
|
1593
|
+
memo: Mapping[str, Any] | None = None,
|
|
1594
|
+
search_attributes: Mapping[str, Any] | None = None,
|
|
1595
|
+
duplicate_start_policy: str | None = None,
|
|
1596
|
+
raise_on_failure: bool = True,
|
|
1597
|
+
) -> NexusOperationResult:
|
|
1598
|
+
"""Execute a Nexus service operation through the service-catalog API.
|
|
1599
|
+
|
|
1600
|
+
Workflow code should normally yield
|
|
1601
|
+
:meth:`durable_workflow.workflow.WorkflowContext.call_nexus_service`;
|
|
1602
|
+
the worker uses this client method to make the single durable
|
|
1603
|
+
execute request and records the returned service-call surface into
|
|
1604
|
+
workflow history.
|
|
1605
|
+
"""
|
|
1606
|
+
request_body = nexus_request_payload(
|
|
1607
|
+
arguments=list(arguments) if arguments is not None else [],
|
|
1608
|
+
payload_codec=payload_codec,
|
|
1609
|
+
mode=mode,
|
|
1610
|
+
wait_for=wait_for,
|
|
1611
|
+
wait_timeout_seconds=wait_timeout_seconds,
|
|
1612
|
+
idempotency_key=idempotency_key,
|
|
1613
|
+
caller_namespace=caller_namespace or self.namespace,
|
|
1614
|
+
caller_workflow_instance_id=caller_workflow_instance_id,
|
|
1615
|
+
caller_workflow_run_id=caller_workflow_run_id,
|
|
1616
|
+
target_workflow_instance_id=target_workflow_instance_id,
|
|
1617
|
+
target_workflow_run_id=target_workflow_run_id,
|
|
1618
|
+
connection=connection,
|
|
1619
|
+
queue=queue,
|
|
1620
|
+
business_key=business_key,
|
|
1621
|
+
labels=labels,
|
|
1622
|
+
memo=memo,
|
|
1623
|
+
search_attributes=search_attributes,
|
|
1624
|
+
duplicate_start_policy=duplicate_start_policy,
|
|
1625
|
+
)
|
|
1626
|
+
path = (
|
|
1627
|
+
f"/service-endpoints/{quote(endpoint_name, safe='')}"
|
|
1628
|
+
f"/services/{quote(service_name, safe='')}"
|
|
1629
|
+
f"/operations/{quote(operation_name, safe='')}/execute"
|
|
1630
|
+
)
|
|
1631
|
+
context = f"{endpoint_name}/{service_name}/{operation_name}"
|
|
1632
|
+
|
|
1633
|
+
try:
|
|
1634
|
+
data = await self._request("POST", path, json=request_body, context=context)
|
|
1635
|
+
except ServerError as exc:
|
|
1636
|
+
if exc.status != 409 or not isinstance(exc.body, dict):
|
|
1637
|
+
raise
|
|
1638
|
+
result = NexusOperationResult.from_response(
|
|
1639
|
+
exc.body,
|
|
1640
|
+
endpoint_name=endpoint_name,
|
|
1641
|
+
service_name=service_name,
|
|
1642
|
+
operation_name=operation_name,
|
|
1643
|
+
request_payload=request_body,
|
|
1644
|
+
service_sdk_language=service_sdk_language,
|
|
1645
|
+
artifact_tuple=artifact_tuple,
|
|
1646
|
+
published_artifact_worker_execution=published_artifact_worker_execution,
|
|
1647
|
+
)
|
|
1648
|
+
if raise_on_failure:
|
|
1649
|
+
raise result.to_failure() from exc
|
|
1650
|
+
return result
|
|
1651
|
+
|
|
1652
|
+
if not isinstance(data, dict):
|
|
1653
|
+
raise ServerError(
|
|
1654
|
+
200,
|
|
1655
|
+
{
|
|
1656
|
+
"reason": "invalid_nexus_operation_response",
|
|
1657
|
+
"message": f"expected JSON object, got {type(data).__name__}",
|
|
1658
|
+
},
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
result = NexusOperationResult.from_response(
|
|
1662
|
+
data,
|
|
1663
|
+
endpoint_name=endpoint_name,
|
|
1664
|
+
service_name=service_name,
|
|
1665
|
+
operation_name=operation_name,
|
|
1666
|
+
request_payload=request_body,
|
|
1667
|
+
service_sdk_language=service_sdk_language,
|
|
1668
|
+
artifact_tuple=artifact_tuple,
|
|
1669
|
+
published_artifact_worker_execution=published_artifact_worker_execution,
|
|
1670
|
+
)
|
|
1671
|
+
if raise_on_failure and result.is_failure:
|
|
1672
|
+
raise result.to_failure()
|
|
1673
|
+
return result
|
|
1674
|
+
|
|
1566
1675
|
# ── Health ─────────────────────────────────────────────────────────
|
|
1567
1676
|
async def health(self) -> dict[str, Any]:
|
|
1568
1677
|
"""Call the server's ``/health`` endpoint and return the JSON response."""
|
|
@@ -2279,6 +2388,10 @@ class Client:
|
|
|
2279
2388
|
input=input_val,
|
|
2280
2389
|
output=output_val,
|
|
2281
2390
|
payload_codec=data.get("payload_codec"),
|
|
2391
|
+
memo=data.get("memo") if isinstance(data.get("memo"), dict) else None,
|
|
2392
|
+
search_attributes=(
|
|
2393
|
+
data.get("search_attributes") if isinstance(data.get("search_attributes"), dict) else None
|
|
2394
|
+
),
|
|
2282
2395
|
)
|
|
2283
2396
|
|
|
2284
2397
|
async def list_workflows(
|
|
@@ -2318,6 +2431,13 @@ class Client:
|
|
|
2318
2431
|
run_id=item.get("run_id"),
|
|
2319
2432
|
workflow_type=item.get("workflow_type", ""),
|
|
2320
2433
|
status=item.get("status"),
|
|
2434
|
+
namespace=item.get("namespace"),
|
|
2435
|
+
task_queue=item.get("task_queue"),
|
|
2436
|
+
payload_codec=item.get("payload_codec"),
|
|
2437
|
+
memo=item.get("memo") if isinstance(item.get("memo"), dict) else None,
|
|
2438
|
+
search_attributes=(
|
|
2439
|
+
item.get("search_attributes") if isinstance(item.get("search_attributes"), dict) else None
|
|
2440
|
+
),
|
|
2321
2441
|
)
|
|
2322
2442
|
for item in items
|
|
2323
2443
|
]
|
|
@@ -44,6 +44,39 @@ class ServerError(DurableWorkflowError):
|
|
|
44
44
|
return None
|
|
45
45
|
|
|
46
46
|
|
|
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
|
+
|
|
47
80
|
class WorkflowFailed(DurableWorkflowError):
|
|
48
81
|
"""A workflow finished in the ``failed`` state.
|
|
49
82
|
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Nexus service-operation request and replay result helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import NexusOperationFailed
|
|
12
|
+
|
|
13
|
+
NEXUS_OPERATION_RESULT_SCHEMA = "durable-workflow.v2.sdk-python.nexus-operation-result"
|
|
14
|
+
NEXUS_OPERATION_RESULT_VERSION = 1
|
|
15
|
+
NEXUS_CALLER_SDK_LANGUAGE = "sdk-python"
|
|
16
|
+
|
|
17
|
+
_FAILURE_STATUSES = {"failed", "cancelled", "canceled", "timed_out", "timeout"}
|
|
18
|
+
_FAILURE_OUTCOMES = {
|
|
19
|
+
"failed",
|
|
20
|
+
"handler_failed",
|
|
21
|
+
"cancelled",
|
|
22
|
+
"canceled",
|
|
23
|
+
"timed_out",
|
|
24
|
+
"timeout",
|
|
25
|
+
"dispatch_failed",
|
|
26
|
+
"handler_unavailable",
|
|
27
|
+
"worker_unavailable",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _optional_str(value: Any) -> str | None:
|
|
32
|
+
return value if isinstance(value, str) and value else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _json_default(value: Any) -> str:
|
|
36
|
+
return str(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _clean_dict(values: Mapping[str, Any]) -> dict[str, Any]:
|
|
40
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def stable_nexus_idempotency_key(
|
|
44
|
+
*,
|
|
45
|
+
workflow_id: str,
|
|
46
|
+
run_id: str,
|
|
47
|
+
ordinal: int,
|
|
48
|
+
endpoint_name: str,
|
|
49
|
+
service_name: str,
|
|
50
|
+
operation_name: str,
|
|
51
|
+
arguments: Sequence[Any],
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Return a deterministic idempotency key for one workflow-side Nexus call."""
|
|
54
|
+
source = {
|
|
55
|
+
"schema": "durable-workflow.v2.sdk-python.nexus-idempotency-key",
|
|
56
|
+
"workflow_id": workflow_id,
|
|
57
|
+
"run_id": run_id,
|
|
58
|
+
"ordinal": ordinal,
|
|
59
|
+
"endpoint_name": endpoint_name,
|
|
60
|
+
"service_name": service_name,
|
|
61
|
+
"operation_name": operation_name,
|
|
62
|
+
"arguments": list(arguments),
|
|
63
|
+
}
|
|
64
|
+
encoded = json.dumps(source, sort_keys=True, separators=(",", ":"), default=_json_default)
|
|
65
|
+
return f"dw-py-nexus-{hashlib.sha256(encoded.encode()).hexdigest()}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def nexus_request_payload(
|
|
69
|
+
*,
|
|
70
|
+
arguments: Sequence[Any],
|
|
71
|
+
payload_codec: str | None = None,
|
|
72
|
+
mode: str | None = None,
|
|
73
|
+
wait_for: str | None = None,
|
|
74
|
+
wait_timeout_seconds: int | None = None,
|
|
75
|
+
idempotency_key: str | None = None,
|
|
76
|
+
caller_namespace: str | None = None,
|
|
77
|
+
caller_workflow_instance_id: str | None = None,
|
|
78
|
+
caller_workflow_run_id: str | None = None,
|
|
79
|
+
target_workflow_instance_id: str | None = None,
|
|
80
|
+
target_workflow_run_id: str | None = None,
|
|
81
|
+
connection: str | None = None,
|
|
82
|
+
queue: str | None = None,
|
|
83
|
+
business_key: str | None = None,
|
|
84
|
+
labels: Mapping[str, Any] | None = None,
|
|
85
|
+
memo: Mapping[str, Any] | None = None,
|
|
86
|
+
search_attributes: Mapping[str, Any] | None = None,
|
|
87
|
+
duplicate_start_policy: str | None = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Build the server execute-operation request body."""
|
|
90
|
+
return _clean_dict({
|
|
91
|
+
"arguments": list(arguments),
|
|
92
|
+
"payload_codec": payload_codec,
|
|
93
|
+
"mode_override": mode,
|
|
94
|
+
"wait_for": wait_for,
|
|
95
|
+
"wait_timeout_seconds": wait_timeout_seconds,
|
|
96
|
+
"idempotency_key": idempotency_key,
|
|
97
|
+
"caller_namespace": caller_namespace,
|
|
98
|
+
"caller_workflow_instance_id": caller_workflow_instance_id,
|
|
99
|
+
"caller_workflow_run_id": caller_workflow_run_id,
|
|
100
|
+
"target_workflow_instance_id": target_workflow_instance_id,
|
|
101
|
+
"target_workflow_run_id": target_workflow_run_id,
|
|
102
|
+
"connection": connection,
|
|
103
|
+
"queue": queue,
|
|
104
|
+
"business_key": business_key,
|
|
105
|
+
"labels": dict(labels) if labels is not None else None,
|
|
106
|
+
"memo": dict(memo) if memo is not None else None,
|
|
107
|
+
"search_attributes": dict(search_attributes) if search_attributes is not None else None,
|
|
108
|
+
"duplicate_start_policy": duplicate_start_policy,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class NexusOperationResult:
|
|
114
|
+
"""Durable result recorded for a Nexus service operation."""
|
|
115
|
+
|
|
116
|
+
accepted: bool
|
|
117
|
+
service_call_id: str
|
|
118
|
+
endpoint_name: str
|
|
119
|
+
service_name: str
|
|
120
|
+
operation_name: str
|
|
121
|
+
caller_workflow_instance_id: str | None = None
|
|
122
|
+
caller_workflow_run_id: str | None = None
|
|
123
|
+
caller_sdk_language: str = NEXUS_CALLER_SDK_LANGUAGE
|
|
124
|
+
service_sdk_language: str | None = None
|
|
125
|
+
request_payload: dict[str, Any] = field(default_factory=dict)
|
|
126
|
+
response_or_failure_surface: dict[str, Any] = field(default_factory=dict)
|
|
127
|
+
artifact_tuple: dict[str, Any] | None = None
|
|
128
|
+
published_artifact_worker_execution: bool | None = None
|
|
129
|
+
status: str | None = None
|
|
130
|
+
outcome: str | None = None
|
|
131
|
+
reason: str | None = None
|
|
132
|
+
message: str | None = None
|
|
133
|
+
service_error_type: str | None = None
|
|
134
|
+
caller_observed_error_type: str | None = None
|
|
135
|
+
typed_error_message: str | None = None
|
|
136
|
+
result: Any = None
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_response(
|
|
140
|
+
cls,
|
|
141
|
+
data: Mapping[str, Any],
|
|
142
|
+
*,
|
|
143
|
+
endpoint_name: str,
|
|
144
|
+
service_name: str,
|
|
145
|
+
operation_name: str,
|
|
146
|
+
request_payload: Mapping[str, Any],
|
|
147
|
+
service_sdk_language: str | None = None,
|
|
148
|
+
artifact_tuple: Mapping[str, Any] | None = None,
|
|
149
|
+
published_artifact_worker_execution: bool | None = None,
|
|
150
|
+
) -> NexusOperationResult:
|
|
151
|
+
call = data.get("service_call")
|
|
152
|
+
source = call if isinstance(call, Mapping) else data
|
|
153
|
+
service_call_id = (
|
|
154
|
+
_optional_str(source.get("service_call_id"))
|
|
155
|
+
or _optional_str(source.get("id"))
|
|
156
|
+
or _optional_str(data.get("service_call_id"))
|
|
157
|
+
or _optional_str(data.get("id"))
|
|
158
|
+
or ""
|
|
159
|
+
)
|
|
160
|
+
accepted = data.get("accepted", source.get("accepted", True)) is not False
|
|
161
|
+
raw = dict(data)
|
|
162
|
+
response_value = source.get("result", data.get("result"))
|
|
163
|
+
return cls(
|
|
164
|
+
accepted=accepted,
|
|
165
|
+
service_call_id=service_call_id,
|
|
166
|
+
endpoint_name=endpoint_name,
|
|
167
|
+
service_name=service_name,
|
|
168
|
+
operation_name=_optional_str(source.get("operation_name")) or operation_name,
|
|
169
|
+
caller_workflow_instance_id=_optional_str(source.get("caller_workflow_instance_id"))
|
|
170
|
+
or _optional_str(data.get("caller_workflow_instance_id"))
|
|
171
|
+
or _optional_str(request_payload.get("caller_workflow_instance_id")),
|
|
172
|
+
caller_workflow_run_id=_optional_str(source.get("caller_workflow_run_id"))
|
|
173
|
+
or _optional_str(data.get("caller_workflow_run_id"))
|
|
174
|
+
or _optional_str(request_payload.get("caller_workflow_run_id")),
|
|
175
|
+
service_sdk_language=service_sdk_language,
|
|
176
|
+
request_payload=dict(request_payload),
|
|
177
|
+
response_or_failure_surface=raw,
|
|
178
|
+
artifact_tuple=dict(artifact_tuple) if artifact_tuple is not None else None,
|
|
179
|
+
published_artifact_worker_execution=published_artifact_worker_execution,
|
|
180
|
+
status=_optional_str(source.get("status")) or _optional_str(data.get("status")),
|
|
181
|
+
outcome=_optional_str(data.get("outcome")) or _optional_str(source.get("outcome")),
|
|
182
|
+
reason=_optional_str(data.get("reason")) or _optional_str(source.get("reason")),
|
|
183
|
+
message=_optional_str(data.get("message")) or _optional_str(source.get("message")),
|
|
184
|
+
service_error_type=_optional_str(source.get("service_error_type"))
|
|
185
|
+
or _optional_str(data.get("service_error_type")),
|
|
186
|
+
caller_observed_error_type=_optional_str(source.get("caller_observed_error_type"))
|
|
187
|
+
or _optional_str(data.get("caller_observed_error_type")),
|
|
188
|
+
typed_error_message=_optional_str(source.get("typed_error_message"))
|
|
189
|
+
or _optional_str(data.get("typed_error_message")),
|
|
190
|
+
result=response_value,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def from_recorded_payload(cls, value: Any, *, expected: Mapping[str, Any]) -> NexusOperationResult:
|
|
195
|
+
if not isinstance(value, Mapping):
|
|
196
|
+
raise ValueError("recorded Nexus operation result must be a mapping")
|
|
197
|
+
if value.get("schema") != NEXUS_OPERATION_RESULT_SCHEMA:
|
|
198
|
+
raise ValueError("recorded Nexus operation result schema is not recognized")
|
|
199
|
+
if value.get("version") != NEXUS_OPERATION_RESULT_VERSION:
|
|
200
|
+
raise ValueError("recorded Nexus operation result version is not supported")
|
|
201
|
+
|
|
202
|
+
for key in ("endpoint_name", "service_name", "operation_name", "idempotency_key"):
|
|
203
|
+
expected_value = expected.get(key)
|
|
204
|
+
recorded_value = value.get(key)
|
|
205
|
+
if expected_value is not None and recorded_value is not None and expected_value != recorded_value:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"recorded Nexus operation {key} {recorded_value!r} does not match current "
|
|
208
|
+
f"workflow command {expected_value!r}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
response_surface = value.get("response_or_failure_surface")
|
|
212
|
+
request_payload = value.get("request_payload")
|
|
213
|
+
artifact_tuple = value.get("artifact_tuple")
|
|
214
|
+
result = cls(
|
|
215
|
+
accepted=value.get("accepted") is not False,
|
|
216
|
+
service_call_id=str(value.get("service_call_id") or ""),
|
|
217
|
+
endpoint_name=str(value.get("endpoint_name") or expected.get("endpoint_name") or ""),
|
|
218
|
+
service_name=str(value.get("service_name") or expected.get("service_name") or ""),
|
|
219
|
+
operation_name=str(value.get("operation_name") or expected.get("operation_name") or ""),
|
|
220
|
+
caller_workflow_instance_id=_optional_str(value.get("caller_workflow_instance_id")),
|
|
221
|
+
caller_workflow_run_id=_optional_str(value.get("caller_workflow_run_id")),
|
|
222
|
+
caller_sdk_language=str(value.get("caller_sdk_language") or NEXUS_CALLER_SDK_LANGUAGE),
|
|
223
|
+
service_sdk_language=_optional_str(value.get("service_sdk_language")),
|
|
224
|
+
request_payload=dict(request_payload) if isinstance(request_payload, Mapping) else {},
|
|
225
|
+
response_or_failure_surface=dict(response_surface) if isinstance(response_surface, Mapping) else {},
|
|
226
|
+
artifact_tuple=dict(artifact_tuple) if isinstance(artifact_tuple, Mapping) else None,
|
|
227
|
+
published_artifact_worker_execution=value.get("published_artifact_worker_execution")
|
|
228
|
+
if isinstance(value.get("published_artifact_worker_execution"), bool)
|
|
229
|
+
else None,
|
|
230
|
+
status=_optional_str(value.get("status")),
|
|
231
|
+
outcome=_optional_str(value.get("outcome")),
|
|
232
|
+
reason=_optional_str(value.get("reason")),
|
|
233
|
+
message=_optional_str(value.get("message")),
|
|
234
|
+
service_error_type=_optional_str(value.get("service_error_type")),
|
|
235
|
+
caller_observed_error_type=_optional_str(value.get("caller_observed_error_type")),
|
|
236
|
+
typed_error_message=_optional_str(value.get("typed_error_message")),
|
|
237
|
+
result=value.get("result"),
|
|
238
|
+
)
|
|
239
|
+
if result.is_failure:
|
|
240
|
+
raise result.to_failure()
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def is_failure(self) -> bool:
|
|
245
|
+
status = (self.status or "").lower()
|
|
246
|
+
outcome = (self.outcome or "").lower()
|
|
247
|
+
return (
|
|
248
|
+
not self.accepted
|
|
249
|
+
or status in _FAILURE_STATUSES
|
|
250
|
+
or outcome in _FAILURE_OUTCOMES
|
|
251
|
+
or outcome.startswith("rejected")
|
|
252
|
+
or self.service_error_type is not None
|
|
253
|
+
or self.caller_observed_error_type is not None
|
|
254
|
+
or self.typed_error_message is not None
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def to_failure(self) -> NexusOperationFailed:
|
|
258
|
+
message = (
|
|
259
|
+
self.typed_error_message
|
|
260
|
+
or self.message
|
|
261
|
+
or self.reason
|
|
262
|
+
or self.outcome
|
|
263
|
+
or self.status
|
|
264
|
+
or "Nexus service operation failed"
|
|
265
|
+
)
|
|
266
|
+
return NexusOperationFailed(
|
|
267
|
+
message,
|
|
268
|
+
service_call_id=self.service_call_id or None,
|
|
269
|
+
endpoint_name=self.endpoint_name,
|
|
270
|
+
service_name=self.service_name,
|
|
271
|
+
operation_name=self.operation_name,
|
|
272
|
+
status=self.status,
|
|
273
|
+
outcome=self.outcome,
|
|
274
|
+
reason=self.reason,
|
|
275
|
+
service_error_type=self.service_error_type,
|
|
276
|
+
caller_observed_error_type=self.caller_observed_error_type,
|
|
277
|
+
typed_error_message=self.typed_error_message,
|
|
278
|
+
body=self.response_or_failure_surface,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def to_recorded_payload(self) -> dict[str, Any]:
|
|
282
|
+
return _clean_dict({
|
|
283
|
+
"schema": NEXUS_OPERATION_RESULT_SCHEMA,
|
|
284
|
+
"version": NEXUS_OPERATION_RESULT_VERSION,
|
|
285
|
+
"accepted": self.accepted,
|
|
286
|
+
"service_call_id": self.service_call_id,
|
|
287
|
+
"endpoint_name": self.endpoint_name,
|
|
288
|
+
"service_name": self.service_name,
|
|
289
|
+
"operation_name": self.operation_name,
|
|
290
|
+
"idempotency_key": self.request_payload.get("idempotency_key"),
|
|
291
|
+
"caller_workflow_instance_id": self.caller_workflow_instance_id,
|
|
292
|
+
"caller_workflow_run_id": self.caller_workflow_run_id,
|
|
293
|
+
"caller_sdk_language": self.caller_sdk_language,
|
|
294
|
+
"service_sdk_language": self.service_sdk_language,
|
|
295
|
+
"request_payload": self.request_payload,
|
|
296
|
+
"response_or_failure_surface": self.response_or_failure_surface,
|
|
297
|
+
"artifact_tuple": self.artifact_tuple,
|
|
298
|
+
"published_artifact_worker_execution": self.published_artifact_worker_execution,
|
|
299
|
+
"status": self.status,
|
|
300
|
+
"outcome": self.outcome,
|
|
301
|
+
"reason": self.reason,
|
|
302
|
+
"message": self.message,
|
|
303
|
+
"service_error_type": self.service_error_type,
|
|
304
|
+
"caller_observed_error_type": self.caller_observed_error_type,
|
|
305
|
+
"typed_error_message": self.typed_error_message,
|
|
306
|
+
"result": self.result,
|
|
307
|
+
})
|