durable-workflow 2.5.3__tar.gz → 2.5.5__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-2.5.3/src/durable_workflow.egg-info → durable_workflow-2.5.5}/PKG-INFO +1 -1
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/pyproject.toml +3 -3
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/__init__.py +2 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/client.py +42 -1
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/errors.py +24 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/nexus.py +15 -2
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/testing.py +5 -1
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/worker.py +14 -2
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/workflow.py +47 -23
- {durable_workflow-2.5.3 → durable_workflow-2.5.5/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_client.py +3 -2
- durable_workflow-2.5.5/tests/test_nexus_namespace.py +160 -0
- durable_workflow-2.5.5/tests/test_nexus_wire.py +38 -0
- durable_workflow-2.5.5/tests/test_registration_ambiguity.py +193 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay_regression_corpus.py +22 -1
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_runtime_external_payload_transport.py +46 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_worker.py +7 -3
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/LICENSE +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/README.md +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/setup.cfg +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.5.
|
|
7
|
+
version = "2.5.5"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
71
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
72
|
|
|
73
73
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.5.
|
|
75
|
-
registry-version = "2.5.
|
|
74
|
+
product-train = "2.5.5"
|
|
75
|
+
registry-version = "2.5.5"
|
|
76
76
|
supported-server-versions = "2.5.16"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -70,6 +70,7 @@ from .errors import (
|
|
|
70
70
|
ChildWorkflowCancelled,
|
|
71
71
|
ChildWorkflowFailed,
|
|
72
72
|
ChildWorkflowTerminated,
|
|
73
|
+
DuplicateRegistrationError,
|
|
73
74
|
DurableOperationCancelled,
|
|
74
75
|
DurableWorkflowError,
|
|
75
76
|
ExternalPayloadError,
|
|
@@ -370,6 +371,7 @@ __all__ = [
|
|
|
370
371
|
"NexusOperationResult",
|
|
371
372
|
"NexusServiceCall",
|
|
372
373
|
"NonDeterministicReplayError",
|
|
374
|
+
"DuplicateRegistrationError",
|
|
373
375
|
"InvalidWorkflowDefinition",
|
|
374
376
|
"NoopMetrics",
|
|
375
377
|
"QueryFailed",
|
|
@@ -19,6 +19,7 @@ methods without repeating the id on every call.
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import asyncio
|
|
22
|
+
import copy
|
|
22
23
|
import hashlib
|
|
23
24
|
import json as json_module
|
|
24
25
|
import math
|
|
@@ -2672,6 +2673,26 @@ class Client:
|
|
|
2672
2673
|
"""
|
|
2673
2674
|
return WorkflowHandle(self, workflow_id=workflow_id, run_id=run_id, workflow_type=workflow_type)
|
|
2674
2675
|
|
|
2676
|
+
def _nexus_target_client(self, target_namespace: str | None) -> Client:
|
|
2677
|
+
if target_namespace is not None and not target_namespace.strip():
|
|
2678
|
+
raise ValueError("target_namespace must be nonempty")
|
|
2679
|
+
if target_namespace is None or target_namespace == self.namespace:
|
|
2680
|
+
return self
|
|
2681
|
+
self._auth_token(worker=False)
|
|
2682
|
+
scoped = copy.copy(self)
|
|
2683
|
+
scoped.namespace = target_namespace
|
|
2684
|
+
# Share the HTTP pool, never the caller's namespace discovery or
|
|
2685
|
+
# provider credentials. The worker keeps using the original client.
|
|
2686
|
+
scoped.worker_token = None
|
|
2687
|
+
scoped._cluster_info = None
|
|
2688
|
+
scoped._cluster_info_lock = asyncio.Lock()
|
|
2689
|
+
scoped._runtime_external_payload_transport_cache = None
|
|
2690
|
+
scoped._runtime_external_payload_transport_resolved = False
|
|
2691
|
+
scoped.external_storage = None
|
|
2692
|
+
scoped.external_storage_threshold_bytes = None
|
|
2693
|
+
scoped.external_storage_cache = ExternalPayloadCache()
|
|
2694
|
+
return scoped
|
|
2695
|
+
|
|
2675
2696
|
async def execute_nexus_operation(
|
|
2676
2697
|
self,
|
|
2677
2698
|
endpoint_name: str,
|
|
@@ -2685,6 +2706,7 @@ class Client:
|
|
|
2685
2706
|
idempotency_key: str | None = None,
|
|
2686
2707
|
payload_codec: str | None = None,
|
|
2687
2708
|
caller_namespace: str | None = None,
|
|
2709
|
+
target_namespace: str | None = None,
|
|
2688
2710
|
caller_workflow_instance_id: str | None = None,
|
|
2689
2711
|
caller_workflow_run_id: str | None = None,
|
|
2690
2712
|
service_sdk_language: str | None = None,
|
|
@@ -2708,7 +2730,11 @@ class Client:
|
|
|
2708
2730
|
the worker uses this client method to make the single durable
|
|
2709
2731
|
execute request and records the returned service-call surface into
|
|
2710
2732
|
workflow history.
|
|
2733
|
+
|
|
2734
|
+
``target_namespace`` selects the service catalog and its payload
|
|
2735
|
+
storage. The caller identity and worker namespace are preserved.
|
|
2711
2736
|
"""
|
|
2737
|
+
execution_client = self._nexus_target_client(target_namespace)
|
|
2712
2738
|
request_body = nexus_request_payload(
|
|
2713
2739
|
arguments=list(arguments) if arguments is not None else [],
|
|
2714
2740
|
payload_codec=payload_codec,
|
|
@@ -2736,8 +2762,21 @@ class Client:
|
|
|
2736
2762
|
)
|
|
2737
2763
|
context = f"{endpoint_name}/{service_name}/{operation_name}"
|
|
2738
2764
|
|
|
2765
|
+
wire_body = request_body
|
|
2766
|
+
transport = await execution_client._runtime_external_payload_transport()
|
|
2767
|
+
if transport is not None:
|
|
2768
|
+
arguments_envelope = await execution_client._externalize_runtime_payloads(
|
|
2769
|
+
{"codec": request_body["payload_codec"], "blob": request_body["arguments"]},
|
|
2770
|
+
worker=False,
|
|
2771
|
+
transport=transport,
|
|
2772
|
+
uploaded={},
|
|
2773
|
+
slot=("arguments",),
|
|
2774
|
+
)
|
|
2775
|
+
if "external_payload" in arguments_envelope:
|
|
2776
|
+
wire_body = {**request_body, "arguments": arguments_envelope}
|
|
2777
|
+
|
|
2739
2778
|
try:
|
|
2740
|
-
data = await
|
|
2779
|
+
data = await execution_client._request("POST", path, json=wire_body, context=context)
|
|
2741
2780
|
except ServerError as exc:
|
|
2742
2781
|
if exc.status != 409 or not isinstance(exc.body, dict):
|
|
2743
2782
|
raise
|
|
@@ -2747,6 +2786,7 @@ class Client:
|
|
|
2747
2786
|
service_name=service_name,
|
|
2748
2787
|
operation_name=operation_name,
|
|
2749
2788
|
request_payload=request_body,
|
|
2789
|
+
target_namespace=target_namespace,
|
|
2750
2790
|
service_sdk_language=service_sdk_language,
|
|
2751
2791
|
artifact_tuple=artifact_tuple,
|
|
2752
2792
|
published_artifact_worker_execution=published_artifact_worker_execution,
|
|
@@ -2770,6 +2810,7 @@ class Client:
|
|
|
2770
2810
|
service_name=service_name,
|
|
2771
2811
|
operation_name=operation_name,
|
|
2772
2812
|
request_payload=request_body,
|
|
2813
|
+
target_namespace=target_namespace,
|
|
2773
2814
|
service_sdk_language=service_sdk_language,
|
|
2774
2815
|
artifact_tuple=artifact_tuple,
|
|
2775
2816
|
published_artifact_worker_execution=published_artifact_worker_execution,
|
|
@@ -31,6 +31,30 @@ class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
|
|
|
31
31
|
"""Workflow authoring uses an unsupported entry point or result."""
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
class DuplicateRegistrationError(DurableWorkflowError, ValueError):
|
|
35
|
+
"""One registration scope assigns a wire name to multiple definitions."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, registration_kind: str, wire_name: str,
|
|
39
|
+
first_definition: object, second_definition: object,
|
|
40
|
+
) -> None:
|
|
41
|
+
def label(definition: object) -> str:
|
|
42
|
+
if isinstance(definition, str):
|
|
43
|
+
return definition
|
|
44
|
+
module = getattr(definition, "__module__", type(definition).__module__)
|
|
45
|
+
name = getattr(definition, "__qualname__", type(definition).__qualname__)
|
|
46
|
+
return f"{module}.{name}"
|
|
47
|
+
|
|
48
|
+
self.registration_kind = registration_kind
|
|
49
|
+
self.wire_name = wire_name
|
|
50
|
+
self.first_definition = label(first_definition)
|
|
51
|
+
self.second_definition = label(second_definition)
|
|
52
|
+
super().__init__(
|
|
53
|
+
f"duplicate {registration_kind} name {wire_name!r}: "
|
|
54
|
+
f"{self.first_definition} conflicts with {self.second_definition}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
34
58
|
class DurableOperationCancelled(DurableWorkflowError):
|
|
35
59
|
"""A durable selection member was explicitly cancelled."""
|
|
36
60
|
|
|
@@ -8,6 +8,7 @@ from collections.abc import Mapping, Sequence
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from . import serializer
|
|
11
12
|
from .errors import NexusOperationFailed
|
|
12
13
|
|
|
13
14
|
NEXUS_OPERATION_RESULT_SCHEMA = "durable-workflow.v2.sdk-python.nexus-operation-result"
|
|
@@ -49,6 +50,7 @@ def stable_nexus_idempotency_key(
|
|
|
49
50
|
service_name: str,
|
|
50
51
|
operation_name: str,
|
|
51
52
|
arguments: Sequence[Any],
|
|
53
|
+
target_namespace: str | None = None,
|
|
52
54
|
) -> str:
|
|
53
55
|
"""Return a deterministic idempotency key for one workflow-side Nexus call."""
|
|
54
56
|
source = {
|
|
@@ -61,6 +63,8 @@ def stable_nexus_idempotency_key(
|
|
|
61
63
|
"operation_name": operation_name,
|
|
62
64
|
"arguments": list(arguments),
|
|
63
65
|
}
|
|
66
|
+
if target_namespace is not None:
|
|
67
|
+
source["target_namespace"] = target_namespace
|
|
64
68
|
encoded = json.dumps(source, sort_keys=True, separators=(",", ":"), default=_json_default)
|
|
65
69
|
return f"dw-py-nexus-{hashlib.sha256(encoded.encode()).hexdigest()}"
|
|
66
70
|
|
|
@@ -87,9 +91,10 @@ def nexus_request_payload(
|
|
|
87
91
|
duplicate_start_policy: str | None = None,
|
|
88
92
|
) -> dict[str, Any]:
|
|
89
93
|
"""Build the server execute-operation request body."""
|
|
94
|
+
codec = payload_codec or serializer.AVRO_CODEC
|
|
90
95
|
return _clean_dict({
|
|
91
|
-
"arguments": list(arguments),
|
|
92
|
-
"payload_codec":
|
|
96
|
+
"arguments": serializer.encode(list(arguments), codec=codec),
|
|
97
|
+
"payload_codec": codec,
|
|
93
98
|
"mode_override": mode,
|
|
94
99
|
"wait_for": wait_for,
|
|
95
100
|
"wait_timeout_seconds": wait_timeout_seconds,
|
|
@@ -134,6 +139,7 @@ class NexusOperationResult:
|
|
|
134
139
|
caller_observed_error_type: str | None = None
|
|
135
140
|
typed_error_message: str | None = None
|
|
136
141
|
result: Any = None
|
|
142
|
+
target_namespace: str | None = None
|
|
137
143
|
|
|
138
144
|
@classmethod
|
|
139
145
|
def from_response(
|
|
@@ -147,6 +153,7 @@ class NexusOperationResult:
|
|
|
147
153
|
service_sdk_language: str | None = None,
|
|
148
154
|
artifact_tuple: Mapping[str, Any] | None = None,
|
|
149
155
|
published_artifact_worker_execution: bool | None = None,
|
|
156
|
+
target_namespace: str | None = None,
|
|
150
157
|
) -> NexusOperationResult:
|
|
151
158
|
call = data.get("service_call")
|
|
152
159
|
source = call if isinstance(call, Mapping) else data
|
|
@@ -188,6 +195,7 @@ class NexusOperationResult:
|
|
|
188
195
|
typed_error_message=_optional_str(source.get("typed_error_message"))
|
|
189
196
|
or _optional_str(data.get("typed_error_message")),
|
|
190
197
|
result=response_value,
|
|
198
|
+
target_namespace=target_namespace,
|
|
191
199
|
)
|
|
192
200
|
|
|
193
201
|
@classmethod
|
|
@@ -199,6 +207,9 @@ class NexusOperationResult:
|
|
|
199
207
|
if value.get("version") != NEXUS_OPERATION_RESULT_VERSION:
|
|
200
208
|
raise ValueError("recorded Nexus operation result version is not supported")
|
|
201
209
|
|
|
210
|
+
if value.get("target_namespace") != expected.get("target_namespace"):
|
|
211
|
+
raise ValueError("recorded Nexus target namespace does not match the current workflow command")
|
|
212
|
+
|
|
202
213
|
for key in ("endpoint_name", "service_name", "operation_name", "idempotency_key"):
|
|
203
214
|
expected_value = expected.get(key)
|
|
204
215
|
recorded_value = value.get(key)
|
|
@@ -235,6 +246,7 @@ class NexusOperationResult:
|
|
|
235
246
|
caller_observed_error_type=_optional_str(value.get("caller_observed_error_type")),
|
|
236
247
|
typed_error_message=_optional_str(value.get("typed_error_message")),
|
|
237
248
|
result=value.get("result"),
|
|
249
|
+
target_namespace=_optional_str(value.get("target_namespace")),
|
|
238
250
|
)
|
|
239
251
|
if result.is_failure:
|
|
240
252
|
raise result.to_failure()
|
|
@@ -287,6 +299,7 @@ class NexusOperationResult:
|
|
|
287
299
|
"endpoint_name": self.endpoint_name,
|
|
288
300
|
"service_name": self.service_name,
|
|
289
301
|
"operation_name": self.operation_name,
|
|
302
|
+
"target_namespace": self.target_namespace,
|
|
290
303
|
"idempotency_key": self.request_payload.get("idempotency_key"),
|
|
291
304
|
"caller_workflow_instance_id": self.caller_workflow_instance_id,
|
|
292
305
|
"caller_workflow_run_id": self.caller_workflow_run_id,
|
|
@@ -39,7 +39,7 @@ from pathlib import Path
|
|
|
39
39
|
from typing import Any
|
|
40
40
|
|
|
41
41
|
from . import serializer
|
|
42
|
-
from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
|
|
42
|
+
from .errors import DuplicateRegistrationError, WorkflowCancelled, WorkflowFailed, WorkflowTerminated
|
|
43
43
|
from .workflow import (
|
|
44
44
|
CompleteWorkflow,
|
|
45
45
|
ContinueAsNew,
|
|
@@ -52,6 +52,7 @@ from .workflow import (
|
|
|
52
52
|
StartTimer,
|
|
53
53
|
UpsertSearchAttributes,
|
|
54
54
|
_validate_workflow_entrypoint,
|
|
55
|
+
_workflow_handler_registries,
|
|
55
56
|
registry,
|
|
56
57
|
replay,
|
|
57
58
|
)
|
|
@@ -136,6 +137,9 @@ class WorkflowEnvironment:
|
|
|
136
137
|
"decorate it with @workflow.defn(name=...) first."
|
|
137
138
|
)
|
|
138
139
|
_validate_workflow_entrypoint(workflow_cls)
|
|
140
|
+
_workflow_handler_registries(workflow_cls)
|
|
141
|
+
if name in self._registered_workflows and self._registered_workflows[name] is not workflow_cls:
|
|
142
|
+
raise DuplicateRegistrationError("workflow", name, self._registered_workflows[name], workflow_cls)
|
|
139
143
|
self._registered_workflows[name] = workflow_cls
|
|
140
144
|
|
|
141
145
|
def signal(
|
|
@@ -66,6 +66,7 @@ from .errors import (
|
|
|
66
66
|
ActivityCancelled,
|
|
67
67
|
ActivityFailed,
|
|
68
68
|
AvroNotInstalledError,
|
|
69
|
+
DuplicateRegistrationError,
|
|
69
70
|
DurableWorkflowError,
|
|
70
71
|
ExternalPayloadError,
|
|
71
72
|
InvalidArgument,
|
|
@@ -106,6 +107,7 @@ from .workflow import (
|
|
|
106
107
|
StartChildWorkflow,
|
|
107
108
|
UpsertMemo,
|
|
108
109
|
_validate_workflow_entrypoint,
|
|
110
|
+
_workflow_handler_registries,
|
|
109
111
|
apply_update,
|
|
110
112
|
commands_to_server_commands,
|
|
111
113
|
query_state,
|
|
@@ -1028,7 +1030,11 @@ class Worker:
|
|
|
1028
1030
|
self.workflows: dict[str, type] = {}
|
|
1029
1031
|
for definition in workflows:
|
|
1030
1032
|
_validate_workflow_entrypoint(definition)
|
|
1031
|
-
|
|
1033
|
+
_workflow_handler_registries(definition)
|
|
1034
|
+
name = _workflow_name(definition)
|
|
1035
|
+
if name in self.workflows:
|
|
1036
|
+
raise DuplicateRegistrationError("workflow", name, self.workflows[name], definition)
|
|
1037
|
+
self.workflows[name] = definition
|
|
1032
1038
|
self.workflow_definition_fingerprints = {
|
|
1033
1039
|
workflow_type: _workflow_definition_fingerprint(workflow_cls)
|
|
1034
1040
|
for workflow_type, workflow_cls in self.workflows.items()
|
|
@@ -1037,7 +1043,12 @@ class Worker:
|
|
|
1037
1043
|
workflow_type: _workflow_command_contract(workflow_cls)
|
|
1038
1044
|
for workflow_type, workflow_cls in self.workflows.items()
|
|
1039
1045
|
}
|
|
1040
|
-
self.activities
|
|
1046
|
+
self.activities: dict[str, Callable[..., Any]] = {}
|
|
1047
|
+
for activity_definition in activities:
|
|
1048
|
+
name = _activity_name(activity_definition)
|
|
1049
|
+
if name in self.activities:
|
|
1050
|
+
raise DuplicateRegistrationError("activity", name, self.activities[name], activity_definition)
|
|
1051
|
+
self.activities[name] = activity_definition
|
|
1041
1052
|
self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
|
|
1042
1053
|
self._sticky_cache = StickyWorkflowCache(
|
|
1043
1054
|
sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,
|
|
@@ -1451,6 +1462,7 @@ class Worker:
|
|
|
1451
1462
|
idempotency_key=command.idempotency_key,
|
|
1452
1463
|
payload_codec=command.payload_codec,
|
|
1453
1464
|
caller_namespace=command.caller_namespace,
|
|
1465
|
+
target_namespace=command.target_namespace,
|
|
1454
1466
|
caller_workflow_instance_id=_string_or_none(task.get("workflow_id")),
|
|
1455
1467
|
caller_workflow_run_id=_string_or_none(task.get("run_id")),
|
|
1456
1468
|
service_sdk_language=command.service_sdk_language,
|
|
@@ -58,6 +58,7 @@ from .errors import (
|
|
|
58
58
|
ChildWorkflowCancelled,
|
|
59
59
|
ChildWorkflowFailed,
|
|
60
60
|
ChildWorkflowTerminated,
|
|
61
|
+
DuplicateRegistrationError,
|
|
61
62
|
DurableOperationCancelled,
|
|
62
63
|
InvalidWorkflowDefinition,
|
|
63
64
|
NexusOperationFailed,
|
|
@@ -124,6 +125,29 @@ def _validate_workflow_result(value: Any, cls: type) -> None:
|
|
|
124
125
|
)
|
|
125
126
|
|
|
126
127
|
|
|
128
|
+
def _workflow_handler_registries(
|
|
129
|
+
cls: type,
|
|
130
|
+
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
|
|
131
|
+
handlers: dict[str, dict[str, str]] = {kind: {} for kind in ("signal", "query", "update", "update_validator")}
|
|
132
|
+
for attr in dir(cls):
|
|
133
|
+
if attr.startswith("_"):
|
|
134
|
+
continue
|
|
135
|
+
member = getattr(cls, attr, None)
|
|
136
|
+
for kind, registry in handlers.items():
|
|
137
|
+
wire_name = getattr(member, f"__{kind}_name__", None)
|
|
138
|
+
if not isinstance(wire_name, str) or not wire_name:
|
|
139
|
+
continue
|
|
140
|
+
if kind == "signal" and wire_name == MESSAGE_STREAM_SIGNAL:
|
|
141
|
+
raise ValueError(f"signal name {wire_name!r} is reserved by the workflow runtime")
|
|
142
|
+
if wire_name in registry:
|
|
143
|
+
owner = f"{cls.__module__}.{cls.__qualname__}"
|
|
144
|
+
raise DuplicateRegistrationError(
|
|
145
|
+
kind, wire_name, f"{owner}.{registry[wire_name]}", f"{owner}.{attr}",
|
|
146
|
+
)
|
|
147
|
+
registry[wire_name] = attr
|
|
148
|
+
return handlers["signal"], handlers["query"], handlers["update"], handlers["update_validator"]
|
|
149
|
+
|
|
150
|
+
|
|
127
151
|
def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
|
|
128
152
|
"""Register a class as a workflow type under a language-neutral name.
|
|
129
153
|
|
|
@@ -136,28 +160,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
|
|
|
136
160
|
def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
|
|
137
161
|
cls.__workflow_name__ = name # type: ignore[attr-defined]
|
|
138
162
|
_validate_workflow_entrypoint(cls)
|
|
139
|
-
signals
|
|
140
|
-
queries: dict[str, str] = {}
|
|
141
|
-
updates: dict[str, str] = {}
|
|
142
|
-
update_validators: dict[str, str] = {}
|
|
143
|
-
for attr in dir(cls):
|
|
144
|
-
if attr.startswith("_"):
|
|
145
|
-
continue
|
|
146
|
-
member = getattr(cls, attr, None)
|
|
147
|
-
signal_name = getattr(member, "__signal_name__", None)
|
|
148
|
-
if isinstance(signal_name, str) and signal_name:
|
|
149
|
-
if signal_name == MESSAGE_STREAM_SIGNAL:
|
|
150
|
-
raise ValueError(f"signal name {signal_name!r} is reserved by the workflow runtime")
|
|
151
|
-
signals[signal_name] = attr
|
|
152
|
-
query_name = getattr(member, "__query_name__", None)
|
|
153
|
-
if isinstance(query_name, str) and query_name:
|
|
154
|
-
queries[query_name] = attr
|
|
155
|
-
update_name = getattr(member, "__update_name__", None)
|
|
156
|
-
if isinstance(update_name, str) and update_name:
|
|
157
|
-
updates[update_name] = attr
|
|
158
|
-
update_validator_name = getattr(member, "__update_validator_name__", None)
|
|
159
|
-
if isinstance(update_validator_name, str) and update_validator_name:
|
|
160
|
-
update_validators[update_validator_name] = attr
|
|
163
|
+
signals, queries, updates, update_validators = _workflow_handler_registries(cls)
|
|
161
164
|
cls.__workflow_signals__ = signals # type: ignore[attr-defined]
|
|
162
165
|
cls.__workflow_queries__ = queries # type: ignore[attr-defined]
|
|
163
166
|
cls.__workflow_updates__ = updates # type: ignore[attr-defined]
|
|
@@ -1193,6 +1196,7 @@ class NexusServiceCall:
|
|
|
1193
1196
|
memo: dict[str, Any] | None = None
|
|
1194
1197
|
search_attributes: dict[str, Any] | None = None
|
|
1195
1198
|
duplicate_start_policy: str | None = None
|
|
1199
|
+
target_namespace: str | None = None
|
|
1196
1200
|
|
|
1197
1201
|
def to_server_command(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
1198
1202
|
raise RuntimeError(
|
|
@@ -1206,6 +1210,7 @@ class NexusServiceCall:
|
|
|
1206
1210
|
"service_name": self.service_name,
|
|
1207
1211
|
"operation_name": self.operation_name,
|
|
1208
1212
|
"idempotency_key": self.idempotency_key,
|
|
1213
|
+
"target_namespace": self.target_namespace,
|
|
1209
1214
|
}
|
|
1210
1215
|
|
|
1211
1216
|
def recorded_result(self, value: Any) -> NexusOperationResult:
|
|
@@ -2399,6 +2404,7 @@ class WorkflowContext:
|
|
|
2399
2404
|
wait_for: str | None = "completed",
|
|
2400
2405
|
wait_timeout_seconds: int | None = None,
|
|
2401
2406
|
caller_namespace: str | None = None,
|
|
2407
|
+
target_namespace: str | None = None,
|
|
2402
2408
|
service_sdk_language: str | None = None,
|
|
2403
2409
|
artifact_tuple: Mapping[str, Any] | None = None,
|
|
2404
2410
|
published_artifact_worker_execution: bool | None = None,
|
|
@@ -2417,7 +2423,12 @@ class WorkflowContext:
|
|
|
2417
2423
|
If the service reports a typed failure, replay raises
|
|
2418
2424
|
:class:`~durable_workflow.errors.NexusOperationFailed` at the yield
|
|
2419
2425
|
point so workflow code can compensate or let the workflow fail.
|
|
2426
|
+
|
|
2427
|
+
``target_namespace`` addresses a service in another namespace and
|
|
2428
|
+
participates in the durable call identity and replay checks.
|
|
2420
2429
|
"""
|
|
2430
|
+
if target_namespace is not None and not target_namespace.strip():
|
|
2431
|
+
raise ValueError("target_namespace must be nonempty")
|
|
2421
2432
|
args = list(arguments) if arguments is not None else []
|
|
2422
2433
|
if idempotency_key is None:
|
|
2423
2434
|
idempotency_key = stable_nexus_idempotency_key(
|
|
@@ -2428,6 +2439,7 @@ class WorkflowContext:
|
|
|
2428
2439
|
service_name=service_name,
|
|
2429
2440
|
operation_name=operation_name,
|
|
2430
2441
|
arguments=args,
|
|
2442
|
+
target_namespace=target_namespace,
|
|
2431
2443
|
)
|
|
2432
2444
|
self._nexus_call_counter += 1
|
|
2433
2445
|
return NexusServiceCall(
|
|
@@ -2441,6 +2453,7 @@ class WorkflowContext:
|
|
|
2441
2453
|
wait_for=wait_for,
|
|
2442
2454
|
wait_timeout_seconds=wait_timeout_seconds,
|
|
2443
2455
|
caller_namespace=caller_namespace,
|
|
2456
|
+
target_namespace=target_namespace,
|
|
2444
2457
|
service_sdk_language=service_sdk_language,
|
|
2445
2458
|
artifact_tuple=dict(artifact_tuple) if artifact_tuple is not None else None,
|
|
2446
2459
|
published_artifact_worker_execution=published_artifact_worker_execution,
|
|
@@ -2578,9 +2591,12 @@ class Replayer:
|
|
|
2578
2591
|
def __init__(self, *, workflows: Iterable[type]) -> None:
|
|
2579
2592
|
self._workflows: dict[str, type] = {}
|
|
2580
2593
|
for workflow_cls in workflows:
|
|
2594
|
+
_workflow_handler_registries(workflow_cls)
|
|
2581
2595
|
workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
|
|
2582
2596
|
if workflow_type in self._workflows:
|
|
2583
|
-
raise
|
|
2597
|
+
raise DuplicateRegistrationError(
|
|
2598
|
+
"workflow", workflow_type, self._workflows[workflow_type], workflow_cls,
|
|
2599
|
+
)
|
|
2584
2600
|
self._workflows[workflow_type] = workflow_cls
|
|
2585
2601
|
if not self._workflows:
|
|
2586
2602
|
raise ValueError("Replayer requires at least one workflow class")
|
|
@@ -6583,6 +6599,14 @@ def _replay_state(
|
|
|
6583
6599
|
if result_cursor < len(resolved_results):
|
|
6584
6600
|
_assert_next_step_matches(cmd)
|
|
6585
6601
|
recorded_value = resolved_results[result_cursor]
|
|
6602
|
+
if (
|
|
6603
|
+
isinstance(recorded_value, Mapping)
|
|
6604
|
+
and recorded_value.get("target_namespace") != cmd.target_namespace
|
|
6605
|
+
):
|
|
6606
|
+
raise NonDeterministicReplayError(
|
|
6607
|
+
current_call_sequence, "NexusServiceCall", ["SideEffectRecorded"],
|
|
6608
|
+
detail="nexus_target_namespace_changed: recorded target differs from the workflow command",
|
|
6609
|
+
)
|
|
6586
6610
|
result_cursor += 1
|
|
6587
6611
|
try:
|
|
6588
6612
|
next_value = cmd.recorded_result(recorded_value)
|
|
@@ -80,6 +80,8 @@ tests/test_history_event_contract.py
|
|
|
80
80
|
tests/test_invocable.py
|
|
81
81
|
tests/test_message_streams.py
|
|
82
82
|
tests/test_metrics.py
|
|
83
|
+
tests/test_nexus_namespace.py
|
|
84
|
+
tests/test_nexus_wire.py
|
|
83
85
|
tests/test_order_processing_example.py
|
|
84
86
|
tests/test_parallel_completion_order_replay.py
|
|
85
87
|
tests/test_parallel_saga_authoring.py
|
|
@@ -95,6 +97,7 @@ tests/test_python_conformance.py
|
|
|
95
97
|
tests/test_queries.py
|
|
96
98
|
tests/test_readme_quickstart.py
|
|
97
99
|
tests/test_redrive_replay.py
|
|
100
|
+
tests/test_registration_ambiguity.py
|
|
98
101
|
tests/test_release_metadata.py
|
|
99
102
|
tests/test_replay.py
|
|
100
103
|
tests/test_replay_conformance.py
|
|
@@ -289,7 +289,8 @@ class TestNexusOperations:
|
|
|
289
289
|
"/api/service-endpoints/greeter/services/shared/operations/greet/execute",
|
|
290
290
|
)
|
|
291
291
|
body = mock.call_args.kwargs["json"]
|
|
292
|
-
assert body["
|
|
292
|
+
assert body["payload_codec"] == "avro"
|
|
293
|
+
assert serializer.decode(body["arguments"], codec=body["payload_codec"]) == ["Ada"]
|
|
293
294
|
assert body["mode_override"] == "sync"
|
|
294
295
|
assert body["wait_for"] == "completed"
|
|
295
296
|
assert body["idempotency_key"] == "idem-1"
|
|
@@ -303,7 +304,7 @@ class TestNexusOperations:
|
|
|
303
304
|
assert recorded["caller_sdk_language"] == "sdk-python"
|
|
304
305
|
assert recorded["service_sdk_language"] == "workflow-php"
|
|
305
306
|
assert recorded["operation_name"] == "greet"
|
|
306
|
-
assert recorded["request_payload"]["arguments"] == ["Ada"]
|
|
307
|
+
assert serializer.decode(recorded["request_payload"]["arguments"], codec="avro") == ["Ada"]
|
|
307
308
|
assert recorded["response_or_failure_surface"]["result"] == {"greeting": "hello Ada"}
|
|
308
309
|
assert recorded["service_call_id"] == "svc-call-1"
|
|
309
310
|
assert recorded["artifact_tuple"]["sdk-python"] == "0.4.95"
|