durable-workflow 2.5.4__tar.gz → 2.5.6__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.4/src/durable_workflow.egg-info → durable_workflow-2.5.6}/PKG-INFO +1 -1
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/pyproject.toml +3 -3
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/client.py +42 -1
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/nexus.py +15 -2
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/worker.py +12 -7
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/workflow.py +18 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_client.py +3 -2
- durable_workflow-2.5.6/tests/test_nexus_namespace.py +160 -0
- durable_workflow-2.5.6/tests/test_nexus_wire.py +38 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay_regression_corpus.py +53 -1
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_runtime_external_payload_transport.py +46 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_worker.py +62 -4
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/LICENSE +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/README.md +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/setup.cfg +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.6}/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.6"
|
|
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.6"
|
|
75
|
+
registry-version = "2.5.6"
|
|
76
76
|
supported-server-versions = "2.5.16"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -1462,6 +1462,7 @@ class Worker:
|
|
|
1462
1462
|
idempotency_key=command.idempotency_key,
|
|
1463
1463
|
payload_codec=command.payload_codec,
|
|
1464
1464
|
caller_namespace=command.caller_namespace,
|
|
1465
|
+
target_namespace=command.target_namespace,
|
|
1465
1466
|
caller_workflow_instance_id=_string_or_none(task.get("workflow_id")),
|
|
1466
1467
|
caller_workflow_run_id=_string_or_none(task.get("run_id")),
|
|
1467
1468
|
service_sdk_language=command.service_sdk_language,
|
|
@@ -4076,11 +4077,12 @@ class Worker:
|
|
|
4076
4077
|
try:
|
|
4077
4078
|
if self._stop.is_set():
|
|
4078
4079
|
return
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4080
|
+
loops = [asyncio.create_task(self._heartbeat_loop())]
|
|
4081
|
+
if self.workflows:
|
|
4082
|
+
loops.append(asyncio.create_task(self._poll_workflow_tasks()))
|
|
4083
|
+
if self.activities:
|
|
4084
|
+
loops.append(asyncio.create_task(self._poll_activity_tasks()))
|
|
4085
|
+
if self.workflows and self._query_tasks_supported:
|
|
4084
4086
|
if self._can_clone_client_for_query_tasks():
|
|
4085
4087
|
self._start_query_task_thread()
|
|
4086
4088
|
else:
|
|
@@ -4242,7 +4244,7 @@ class Worker:
|
|
|
4242
4244
|
if self._stop.is_set():
|
|
4243
4245
|
raise asyncio.CancelledError
|
|
4244
4246
|
background_tasks.append(asyncio.create_task(self._heartbeat_loop()))
|
|
4245
|
-
if self._query_tasks_supported:
|
|
4247
|
+
if self.workflows and self._query_tasks_supported:
|
|
4246
4248
|
if self._can_clone_client_for_query_tasks():
|
|
4247
4249
|
self._start_query_task_thread()
|
|
4248
4250
|
else:
|
|
@@ -4294,7 +4296,7 @@ class Worker:
|
|
|
4294
4296
|
f"workflow {workflow_id} not terminal after {timeout}s (status={desc.status})"
|
|
4295
4297
|
)
|
|
4296
4298
|
|
|
4297
|
-
if next_task_kind == "workflow":
|
|
4299
|
+
if self.workflows and (next_task_kind == "workflow" or not self.activities):
|
|
4298
4300
|
await self._reserve_workflow_capacity()
|
|
4299
4301
|
try:
|
|
4300
4302
|
poll_start = time.perf_counter()
|
|
@@ -4346,6 +4348,9 @@ class Worker:
|
|
|
4346
4348
|
next_task_kind = "workflow"
|
|
4347
4349
|
continue
|
|
4348
4350
|
|
|
4351
|
+
if not self.activities:
|
|
4352
|
+
await asyncio.sleep(poll_interval)
|
|
4353
|
+
continue
|
|
4349
4354
|
self._validate_cooperative_activity_handlers()
|
|
4350
4355
|
if len(self._remote_activity_processes) >= self.max_concurrent_activity_tasks:
|
|
4351
4356
|
next_task_kind = "workflow"
|
|
@@ -1196,6 +1196,7 @@ class NexusServiceCall:
|
|
|
1196
1196
|
memo: dict[str, Any] | None = None
|
|
1197
1197
|
search_attributes: dict[str, Any] | None = None
|
|
1198
1198
|
duplicate_start_policy: str | None = None
|
|
1199
|
+
target_namespace: str | None = None
|
|
1199
1200
|
|
|
1200
1201
|
def to_server_command(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
1201
1202
|
raise RuntimeError(
|
|
@@ -1209,6 +1210,7 @@ class NexusServiceCall:
|
|
|
1209
1210
|
"service_name": self.service_name,
|
|
1210
1211
|
"operation_name": self.operation_name,
|
|
1211
1212
|
"idempotency_key": self.idempotency_key,
|
|
1213
|
+
"target_namespace": self.target_namespace,
|
|
1212
1214
|
}
|
|
1213
1215
|
|
|
1214
1216
|
def recorded_result(self, value: Any) -> NexusOperationResult:
|
|
@@ -2402,6 +2404,7 @@ class WorkflowContext:
|
|
|
2402
2404
|
wait_for: str | None = "completed",
|
|
2403
2405
|
wait_timeout_seconds: int | None = None,
|
|
2404
2406
|
caller_namespace: str | None = None,
|
|
2407
|
+
target_namespace: str | None = None,
|
|
2405
2408
|
service_sdk_language: str | None = None,
|
|
2406
2409
|
artifact_tuple: Mapping[str, Any] | None = None,
|
|
2407
2410
|
published_artifact_worker_execution: bool | None = None,
|
|
@@ -2420,7 +2423,12 @@ class WorkflowContext:
|
|
|
2420
2423
|
If the service reports a typed failure, replay raises
|
|
2421
2424
|
:class:`~durable_workflow.errors.NexusOperationFailed` at the yield
|
|
2422
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.
|
|
2423
2429
|
"""
|
|
2430
|
+
if target_namespace is not None and not target_namespace.strip():
|
|
2431
|
+
raise ValueError("target_namespace must be nonempty")
|
|
2424
2432
|
args = list(arguments) if arguments is not None else []
|
|
2425
2433
|
if idempotency_key is None:
|
|
2426
2434
|
idempotency_key = stable_nexus_idempotency_key(
|
|
@@ -2431,6 +2439,7 @@ class WorkflowContext:
|
|
|
2431
2439
|
service_name=service_name,
|
|
2432
2440
|
operation_name=operation_name,
|
|
2433
2441
|
arguments=args,
|
|
2442
|
+
target_namespace=target_namespace,
|
|
2434
2443
|
)
|
|
2435
2444
|
self._nexus_call_counter += 1
|
|
2436
2445
|
return NexusServiceCall(
|
|
@@ -2444,6 +2453,7 @@ class WorkflowContext:
|
|
|
2444
2453
|
wait_for=wait_for,
|
|
2445
2454
|
wait_timeout_seconds=wait_timeout_seconds,
|
|
2446
2455
|
caller_namespace=caller_namespace,
|
|
2456
|
+
target_namespace=target_namespace,
|
|
2447
2457
|
service_sdk_language=service_sdk_language,
|
|
2448
2458
|
artifact_tuple=dict(artifact_tuple) if artifact_tuple is not None else None,
|
|
2449
2459
|
published_artifact_worker_execution=published_artifact_worker_execution,
|
|
@@ -6589,6 +6599,14 @@ def _replay_state(
|
|
|
6589
6599
|
if result_cursor < len(resolved_results):
|
|
6590
6600
|
_assert_next_step_matches(cmd)
|
|
6591
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
|
+
)
|
|
6592
6610
|
result_cursor += 1
|
|
6593
6611
|
try:
|
|
6594
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
|
|
@@ -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"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from durable_workflow import Client, serializer, workflow
|
|
8
|
+
from durable_workflow.errors import NonDeterministicReplayError
|
|
9
|
+
from durable_workflow.nexus import NexusOperationResult, stable_nexus_idempotency_key
|
|
10
|
+
from durable_workflow.workflow import CompleteWorkflow, NexusServiceCall, WorkflowContext, replay
|
|
11
|
+
from tests.test_runtime_external_payload_transport import FakeRuntimePayloadServer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.asyncio
|
|
15
|
+
async def test_target_requests_and_concurrent_worker_heartbeat_keep_separate_scopes():
|
|
16
|
+
requests = []
|
|
17
|
+
|
|
18
|
+
async def handler(request):
|
|
19
|
+
requests.append(request)
|
|
20
|
+
await asyncio.sleep(0)
|
|
21
|
+
if request.url.path == "/api/cluster/info":
|
|
22
|
+
return httpx.Response(200, json={"namespace": {"name": request.headers["x-namespace"]}})
|
|
23
|
+
return httpx.Response(200, json={"accepted": True, "id": "call-1", "status": "started"})
|
|
24
|
+
|
|
25
|
+
async with Client(
|
|
26
|
+
"https://runtime.example", namespace="tenant-b", control_token="control", worker_token="worker",
|
|
27
|
+
) as client:
|
|
28
|
+
await client._http.aclose()
|
|
29
|
+
client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
|
|
30
|
+
client._cluster_info = {"namespace": {"name": "tenant-b"}}
|
|
31
|
+
result, _ = await asyncio.gather(
|
|
32
|
+
client.execute_nexus_operation("greeter", "shared", "greet", ["Ada"], target_namespace="shared",
|
|
33
|
+
caller_workflow_instance_id="caller", caller_workflow_run_id="original-run"),
|
|
34
|
+
client.heartbeat_worker(worker_id="caller-worker"),
|
|
35
|
+
)
|
|
36
|
+
assert client.namespace == "tenant-b"
|
|
37
|
+
assert client._cluster_info == {"namespace": {"name": "tenant-b"}}
|
|
38
|
+
|
|
39
|
+
nexus = next(request for request in requests if request.url.path.endswith("/execute"))
|
|
40
|
+
heartbeat = next(request for request in requests if request.url.path == "/api/worker/heartbeat")
|
|
41
|
+
assert nexus.headers["x-namespace"] == "shared"
|
|
42
|
+
assert nexus.headers["authorization"] == "Bearer control"
|
|
43
|
+
assert heartbeat.headers["x-namespace"] == "tenant-b"
|
|
44
|
+
assert heartbeat.headers["authorization"] == "Bearer worker"
|
|
45
|
+
body = json.loads(nexus.content)
|
|
46
|
+
assert body["caller_namespace"] == "tenant-b"
|
|
47
|
+
assert body["caller_workflow_instance_id"] == "caller"
|
|
48
|
+
assert body["caller_workflow_run_id"] == "original-run"
|
|
49
|
+
assert result.target_namespace == "shared"
|
|
50
|
+
assert result.to_recorded_payload()["target_namespace"] == "shared"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_cross_namespace_payload_discovery_upload_and_admission_use_target_scope():
|
|
55
|
+
server = FakeRuntimePayloadServer(threshold_bytes=1)
|
|
56
|
+
requests = []
|
|
57
|
+
|
|
58
|
+
def handler(request):
|
|
59
|
+
requests.append(request)
|
|
60
|
+
assert request.headers["x-namespace"] == "shared"
|
|
61
|
+
assert request.headers["authorization"] == "Bearer control"
|
|
62
|
+
if request.url.path == "/api/cluster/info":
|
|
63
|
+
info = server.cluster_info()
|
|
64
|
+
info["namespace"]["name"] = "shared"
|
|
65
|
+
return httpx.Response(200, json=info)
|
|
66
|
+
if request.url.path == "/api/external-payloads/v1":
|
|
67
|
+
reference = server.seed(request.content.decode())
|
|
68
|
+
assert int(request.headers["x-durable-workflow-payload-size"]) == reference["size_bytes"]
|
|
69
|
+
assert request.headers["x-durable-workflow-payload-sha256"] == reference["sha256"]
|
|
70
|
+
return httpx.Response(201, json={
|
|
71
|
+
"schema": "durable-workflow.v2.runtime-external-payload-upload.v1",
|
|
72
|
+
"transport_version": 1, "reference": reference,
|
|
73
|
+
})
|
|
74
|
+
return httpx.Response(200, json={"accepted": True, "id": "call-1", "status": "started"})
|
|
75
|
+
|
|
76
|
+
async with Client(
|
|
77
|
+
"https://runtime.example", namespace="tenant-b", control_token="control", worker_token="worker",
|
|
78
|
+
) as client:
|
|
79
|
+
await client._http.aclose()
|
|
80
|
+
client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
|
|
81
|
+
client._cluster_info = {"namespace": {"name": "tenant-b"}}
|
|
82
|
+
client._runtime_external_payload_transport_resolved = True
|
|
83
|
+
result = await client.execute_nexus_operation(
|
|
84
|
+
"greeter", "shared", "greet", ["Ada" * 128], target_namespace="shared",
|
|
85
|
+
)
|
|
86
|
+
assert client.namespace == "tenant-b"
|
|
87
|
+
assert client._runtime_external_payload_transport_cache is None
|
|
88
|
+
|
|
89
|
+
assert [request.url.path for request in requests] == [
|
|
90
|
+
"/api/cluster/info", "/api/external-payloads/v1",
|
|
91
|
+
"/api/service-endpoints/greeter/services/shared/operations/greet/execute",
|
|
92
|
+
]
|
|
93
|
+
body = json.loads(requests[-1].content)
|
|
94
|
+
reference = body["arguments"]["external_payload"]
|
|
95
|
+
assert serializer.decode(server.payloads[reference["reference_id"]].decode(), codec="avro") == ["Ada" * 128]
|
|
96
|
+
assert body["caller_namespace"] == "tenant-b"
|
|
97
|
+
assert serializer.decode(result.request_payload["arguments"], codec="avro") == ["Ada" * 128]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_target_scope_discards_caller_discovery_payload_cache_and_provider():
|
|
102
|
+
client = Client("https://runtime.example", namespace="tenant-b", token="shared-token")
|
|
103
|
+
client._cluster_info = {"namespace": {"name": "tenant-b"}}
|
|
104
|
+
client._runtime_external_payload_transport_resolved = True
|
|
105
|
+
target = client._nexus_target_client("shared")
|
|
106
|
+
assert target._http is client._http
|
|
107
|
+
assert target.namespace == "shared"
|
|
108
|
+
assert target._cluster_info is None
|
|
109
|
+
assert target._cluster_info_lock is not client._cluster_info_lock
|
|
110
|
+
assert not target._runtime_external_payload_transport_resolved
|
|
111
|
+
assert target.external_storage is None
|
|
112
|
+
assert target.external_storage_cache is not client.external_storage_cache
|
|
113
|
+
await client.aclose()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@workflow.defn(name="nexus-target-caller")
|
|
117
|
+
class NexusTargetCaller:
|
|
118
|
+
def run(self, ctx: WorkflowContext, target: str | None):
|
|
119
|
+
result = yield ctx.call_nexus_service("greeter", "shared", "greet", ["Ada"],
|
|
120
|
+
target_namespace=target, idempotency_key="original-call")
|
|
121
|
+
return result.service_call_id
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.parametrize("target", [None, "shared"])
|
|
125
|
+
def test_original_target_replays_and_changed_target_is_refused(target):
|
|
126
|
+
command = replay(NexusTargetCaller, [], [target], workflow_id="caller", run_id="original-run").commands[0]
|
|
127
|
+
assert isinstance(command, NexusServiceCall)
|
|
128
|
+
result = NexusOperationResult(accepted=True, service_call_id="call-1", endpoint_name="greeter",
|
|
129
|
+
service_name="shared", operation_name="greet", target_namespace=target,
|
|
130
|
+
request_payload={"idempotency_key": command.idempotency_key}, status="completed")
|
|
131
|
+
history = [{"event_type": "SideEffectRecorded", "payload": {
|
|
132
|
+
"sequence": 1, "payload_codec": "avro", "result": serializer.encode(result.to_recorded_payload(), codec="avro"),
|
|
133
|
+
}}]
|
|
134
|
+
outcome = replay(
|
|
135
|
+
NexusTargetCaller, history, [target], workflow_id="caller", run_id="original-run", payload_codec="avro",
|
|
136
|
+
)
|
|
137
|
+
assert isinstance(outcome.commands[0], CompleteWorkflow)
|
|
138
|
+
assert outcome.commands[0].result == "call-1"
|
|
139
|
+
changed = "other" if target else "shared"
|
|
140
|
+
with pytest.raises(NonDeterministicReplayError, match="nexus_target_namespace_changed"):
|
|
141
|
+
replay(NexusTargetCaller, history, [changed], workflow_id="caller", run_id="original-run", payload_codec="avro")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_explicit_target_participates_in_call_identity_and_default_stays_compatible():
|
|
145
|
+
identity = dict(workflow_id="caller", run_id="original-run", ordinal=0, endpoint_name="greeter",
|
|
146
|
+
service_name="shared", operation_name="greet", arguments=["Ada"])
|
|
147
|
+
legacy = stable_nexus_idempotency_key(**identity)
|
|
148
|
+
assert stable_nexus_idempotency_key(**identity, target_namespace=None) == legacy
|
|
149
|
+
assert stable_nexus_idempotency_key(**identity, target_namespace="shared") != legacy
|
|
150
|
+
assert stable_nexus_idempotency_key(**identity, target_namespace="shared") != stable_nexus_idempotency_key(
|
|
151
|
+
**identity, target_namespace="other")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_empty_target_is_refused_before_network_and_worker_credentials_are_not_reused():
|
|
156
|
+
async with Client("https://runtime.example", namespace="tenant-b", worker_token="worker-only") as client:
|
|
157
|
+
with pytest.raises(ValueError, match="nonempty"):
|
|
158
|
+
await client.execute_nexus_operation("greeter", "shared", "greet", target_namespace=" ")
|
|
159
|
+
with pytest.raises(ValueError, match="control-plane requests require"):
|
|
160
|
+
await client.execute_nexus_operation("greeter", "shared", "greet", target_namespace="shared")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from durable_workflow import serializer
|
|
7
|
+
from durable_workflow.nexus import nexus_request_payload, stable_nexus_idempotency_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_nexus_request_matches_the_regression_corpus_wire():
|
|
11
|
+
fixture = json.loads((Path(__file__).parent / "fixtures" / "codec_regressions"
|
|
12
|
+
/ "nexus-world-arguments-avro.json").read_text())
|
|
13
|
+
request = nexus_request_payload(arguments=["world"])
|
|
14
|
+
assert request["arguments"] == fixture["framing"]["wire_base64"]
|
|
15
|
+
assert serializer.decode(request["arguments"], codec=request["payload_codec"]) == ["world"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize("arguments", [[], ["world"], [None, True, 42, {"nested": [b"\x00\xff", 1.5]}]])
|
|
19
|
+
def test_nexus_arguments_use_the_portable_avro_payload(arguments):
|
|
20
|
+
request = nexus_request_payload(arguments=arguments)
|
|
21
|
+
assert request["payload_codec"] == "avro"
|
|
22
|
+
assert isinstance(request["arguments"], str)
|
|
23
|
+
assert serializer.decode(request["arguments"], codec="avro") == arguments
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_nexus_refuses_json_as_a_workflow_payload_codec():
|
|
27
|
+
with pytest.raises(ValueError, match="unsupported_payload_codec"):
|
|
28
|
+
nexus_request_payload(arguments=["world"], payload_codec="json")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_encoding_does_not_change_the_existing_workflow_call_identity():
|
|
32
|
+
arguments = ["world", {"value": 42}]
|
|
33
|
+
identity = dict(workflow_id="caller", run_id="original-run", ordinal=0,
|
|
34
|
+
endpoint_name="endpoint", service_name="service", operation_name="greet")
|
|
35
|
+
key = stable_nexus_idempotency_key(**identity, arguments=arguments)
|
|
36
|
+
request = nexus_request_payload(arguments=arguments, idempotency_key=key)
|
|
37
|
+
decoded = serializer.decode(request["arguments"], codec=request["payload_codec"])
|
|
38
|
+
assert request["idempotency_key"] == stable_nexus_idempotency_key(**identity, arguments=decoded)
|
|
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
11
|
from durable_workflow import Replayer, Worker, serializer, workflow
|
|
12
|
-
from durable_workflow.client import Client, WorkflowStreamAppendItem
|
|
12
|
+
from durable_workflow.client import PROTOCOL_VERSION, Client, WorkflowExecution, WorkflowStreamAppendItem
|
|
13
13
|
from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled, WorkflowPayloadDecodeError
|
|
14
14
|
from durable_workflow.workflow import (
|
|
15
15
|
LocalActivityExecutionAborted,
|
|
@@ -31,6 +31,7 @@ from tests.test_update_signal_condition_replay import (
|
|
|
31
31
|
PostConditionReceiversWorkflow,
|
|
32
32
|
UpdateSignalConditionTimerWorkflow,
|
|
33
33
|
)
|
|
34
|
+
from tests.test_worker import compatible_cluster_info
|
|
34
35
|
|
|
35
36
|
FIXTURE_SCHEMA = "durable-workflow.replay-regression/v1"
|
|
36
37
|
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "replay_regressions"
|
|
@@ -259,7 +260,18 @@ class UnsupportedAsyncWorkflow:
|
|
|
259
260
|
return "body was never executed"
|
|
260
261
|
|
|
261
262
|
|
|
263
|
+
@workflow.defn(name="tests.replay.nexus-target")
|
|
264
|
+
class NexusTargetWorkflow:
|
|
265
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
266
|
+
result = yield ctx.call_nexus_service(
|
|
267
|
+
"greeter", "shared", "greet", ["Ada"],
|
|
268
|
+
target_namespace="other", idempotency_key="original-call",
|
|
269
|
+
)
|
|
270
|
+
return result.service_call_id
|
|
271
|
+
|
|
272
|
+
|
|
262
273
|
WORKFLOWS = [
|
|
274
|
+
NexusTargetWorkflow,
|
|
263
275
|
UnsupportedAsyncWorkflow,
|
|
264
276
|
ChildPolicyAuthorWorkflow,
|
|
265
277
|
ColdReplacementSatisfiedConditionWorkflow,
|
|
@@ -478,6 +490,46 @@ def test_checked_in_replay_regression_corpus_uses_official_replayer(
|
|
|
478
490
|
_execute_fixture(fixture)
|
|
479
491
|
|
|
480
492
|
|
|
493
|
+
@pytest.mark.asyncio
|
|
494
|
+
async def test_workflow_only_driver_resumes_external_activity_without_activity_polling() -> None:
|
|
495
|
+
fixture = json.loads(
|
|
496
|
+
(FIXTURE_DIR / "workflow-only-worker-resumes-external-activity.json").read_text(encoding="utf-8")
|
|
497
|
+
)
|
|
498
|
+
client = AsyncMock(spec=Client)
|
|
499
|
+
client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={"version": PROTOCOL_VERSION})
|
|
500
|
+
client.register_worker.return_value = {"registered": True}
|
|
501
|
+
client.deregister_worker_registration.return_value = {"outcome": "deregistered"}
|
|
502
|
+
client.poll_activity_task.side_effect = AssertionError("workflow-only worker must not poll activities")
|
|
503
|
+
client.poll_workflow_task.side_effect = [None, {
|
|
504
|
+
"task_id": "external-activity-resume-task", "workflow_id": "external-activity-caller",
|
|
505
|
+
"run_id": "original-run", "workflow_type": fixture["workflow"]["type"],
|
|
506
|
+
"workflow_task_attempt": 1, "history_events": fixture["history"],
|
|
507
|
+
"arguments": serializer.encode(fixture["workflow"]["input"], codec="avro"),
|
|
508
|
+
"payload_codec": "avro",
|
|
509
|
+
}]
|
|
510
|
+
client.complete_workflow_task.return_value = {"outcome": "completed"}
|
|
511
|
+
|
|
512
|
+
async def describe(workflow_id: str) -> WorkflowExecution:
|
|
513
|
+
return WorkflowExecution(
|
|
514
|
+
workflow_id=workflow_id, run_id="original-run", workflow_type=fixture["workflow"]["type"],
|
|
515
|
+
status="completed" if client.complete_workflow_task.await_count else "running",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
client.describe_workflow.side_effect = describe
|
|
519
|
+
worker = Worker(client, task_queue="external-activity", workflows=[GoldenSingleActivityWorkflow], activities=[])
|
|
520
|
+
|
|
521
|
+
result = await worker.run_until(workflow_id="external-activity-caller", timeout=1, poll_interval=0.01)
|
|
522
|
+
|
|
523
|
+
assert result.status == "completed"
|
|
524
|
+
assert result.run_id == "original-run"
|
|
525
|
+
assert client.poll_workflow_task.await_count == 2
|
|
526
|
+
client.poll_activity_task.assert_not_awaited()
|
|
527
|
+
client.complete_workflow_task.assert_awaited_once()
|
|
528
|
+
commands = client.complete_workflow_task.call_args.kwargs["commands"]
|
|
529
|
+
assert [command["type"] for command in commands] == [fixture["expected"]["type"]]
|
|
530
|
+
assert serializer.decode_envelope(commands[0]["result"]) == fixture["expected"]["result"]
|
|
531
|
+
|
|
532
|
+
|
|
481
533
|
@pytest.mark.asyncio
|
|
482
534
|
async def test_worker_replays_recorded_side_effects_across_history_pages() -> None:
|
|
483
535
|
fixture = json.loads((FIXTURE_DIR / "paged-recorded-side-effects.json").read_text(encoding="utf-8"))
|