durable-workflow 2.5.4__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.4/src/durable_workflow.egg-info → durable_workflow-2.5.5}/PKG-INFO +1 -1
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/pyproject.toml +3 -3
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/client.py +42 -1
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/nexus.py +15 -2
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/worker.py +1 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/workflow.py +18 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- {durable_workflow-2.5.4 → 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.4 → durable_workflow-2.5.5}/tests/test_replay_regression_corpus.py +11 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_runtime_external_payload_transport.py +46 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_worker.py +7 -3
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/LICENSE +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/README.md +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/setup.cfg +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.4 → 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"
|
|
@@ -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,
|
|
@@ -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)
|
|
@@ -259,7 +259,18 @@ class UnsupportedAsyncWorkflow:
|
|
|
259
259
|
return "body was never executed"
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
@workflow.defn(name="tests.replay.nexus-target")
|
|
263
|
+
class NexusTargetWorkflow:
|
|
264
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
265
|
+
result = yield ctx.call_nexus_service(
|
|
266
|
+
"greeter", "shared", "greet", ["Ada"],
|
|
267
|
+
target_namespace="other", idempotency_key="original-call",
|
|
268
|
+
)
|
|
269
|
+
return result.service_call_id
|
|
270
|
+
|
|
271
|
+
|
|
262
272
|
WORKFLOWS = [
|
|
273
|
+
NexusTargetWorkflow,
|
|
263
274
|
UnsupportedAsyncWorkflow,
|
|
264
275
|
ChildPolicyAuthorWorkflow,
|
|
265
276
|
ColdReplacementSatisfiedConditionWorkflow,
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_runtime_external_payload_transport.py
RENAMED
|
@@ -309,6 +309,52 @@ def runtime_envelope(server: FakeRuntimePayloadServer, value: Any) -> dict[str,
|
|
|
309
309
|
return {"codec": "avro", "external_payload": server.seed(blob)}
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
@pytest.mark.asyncio
|
|
313
|
+
@pytest.mark.parametrize("threshold_bytes", [1, 4096])
|
|
314
|
+
async def test_nexus_arguments_offload_without_changing_recorded_arguments(threshold_bytes: int) -> None:
|
|
315
|
+
server = FakeRuntimePayloadServer(threshold_bytes=threshold_bytes)
|
|
316
|
+
path = "/api/service-endpoints/greeter/services/shared/operations/greet/execute"
|
|
317
|
+
server.responses[("POST", path)] = {"accepted": True, "id": "call-1", "status": "started"}
|
|
318
|
+
client = runtime_client(server)
|
|
319
|
+
arguments = ["hello" * 32, {"binary": b"\x00\xff"}]
|
|
320
|
+
try:
|
|
321
|
+
result = await client.execute_nexus_operation(
|
|
322
|
+
"greeter", "shared", "greet", arguments, caller_namespace="caller",
|
|
323
|
+
idempotency_key="original-call",
|
|
324
|
+
)
|
|
325
|
+
finally:
|
|
326
|
+
await client.aclose()
|
|
327
|
+
|
|
328
|
+
body = next(body for method, route, body in server.requests if method == "POST" and route == path)
|
|
329
|
+
if threshold_bytes == 1:
|
|
330
|
+
reference = body["arguments"]["external_payload"]
|
|
331
|
+
assert body["arguments"]["codec"] == "avro"
|
|
332
|
+
assert serializer.decode(server.payloads[reference["reference_id"]].decode(), codec="avro") == arguments
|
|
333
|
+
assert server.upload_count == 1
|
|
334
|
+
assert "external_storage" not in json.dumps(body)
|
|
335
|
+
else:
|
|
336
|
+
assert serializer.decode(body["arguments"], codec="avro") == arguments
|
|
337
|
+
assert server.upload_count == 0
|
|
338
|
+
assert body["caller_namespace"] == "caller"
|
|
339
|
+
assert body["idempotency_key"] == "original-call"
|
|
340
|
+
assert client.namespace == "billing"
|
|
341
|
+
assert serializer.decode(result.request_payload["arguments"], codec="avro") == arguments
|
|
342
|
+
assert result.request_payload["idempotency_key"] == "original-call"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@pytest.mark.asyncio
|
|
346
|
+
async def test_nexus_oversized_arguments_fail_before_upload_or_admission() -> None:
|
|
347
|
+
server = FakeRuntimePayloadServer(threshold_bytes=1, max_payload_bytes=32)
|
|
348
|
+
client = runtime_client(server)
|
|
349
|
+
try:
|
|
350
|
+
with pytest.raises(ExternalPayloadOversized):
|
|
351
|
+
await client.execute_nexus_operation("greeter", "shared", "greet", ["x" * 128])
|
|
352
|
+
finally:
|
|
353
|
+
await client.aclose()
|
|
354
|
+
assert server.upload_count == 0
|
|
355
|
+
assert not server.requests
|
|
356
|
+
|
|
357
|
+
|
|
312
358
|
@pytest.mark.asyncio
|
|
313
359
|
async def test_client_start_and_result_use_runtime_credentials_without_provider_setup() -> None:
|
|
314
360
|
server = FakeRuntimePayloadServer()
|
|
@@ -124,12 +124,13 @@ class TwoCrossQueueWorkflow:
|
|
|
124
124
|
|
|
125
125
|
@workflow.defn(name="nexus-worker-wf")
|
|
126
126
|
class NexusWorkerWorkflow:
|
|
127
|
-
def run(self, ctx): # type: ignore[no-untyped-def]
|
|
127
|
+
def run(self, ctx, target=None): # type: ignore[no-untyped-def]
|
|
128
128
|
result = yield ctx.call_nexus_service(
|
|
129
129
|
"greeter",
|
|
130
130
|
"shared",
|
|
131
131
|
"greet",
|
|
132
132
|
["Ada"],
|
|
133
|
+
target_namespace=target,
|
|
133
134
|
service_sdk_language="workflow-php",
|
|
134
135
|
artifact_tuple={"sdk-python": "0.4.95"},
|
|
135
136
|
published_artifact_worker_execution=True,
|
|
@@ -1823,8 +1824,9 @@ class TestWorkflowTaskExecution:
|
|
|
1823
1824
|
assert serializer.decode(commands[0]["result"]["blob"], codec="avro") == "done"
|
|
1824
1825
|
|
|
1825
1826
|
@pytest.mark.asyncio
|
|
1827
|
+
@pytest.mark.parametrize("target", [None, "shared"])
|
|
1826
1828
|
async def test_workflow_nexus_service_call_records_side_effect_and_resumes(
|
|
1827
|
-
self, mock_client: AsyncMock
|
|
1829
|
+
self, mock_client: AsyncMock, target: str | None,
|
|
1828
1830
|
) -> None:
|
|
1829
1831
|
async def fake_execute(endpoint_name: str, service_name: str, operation_name: str, arguments: list, **kwargs):
|
|
1830
1832
|
return NexusOperationResult(
|
|
@@ -1836,6 +1838,7 @@ class TestWorkflowTaskExecution:
|
|
|
1836
1838
|
caller_workflow_instance_id=kwargs["caller_workflow_instance_id"],
|
|
1837
1839
|
caller_workflow_run_id=kwargs["caller_workflow_run_id"],
|
|
1838
1840
|
service_sdk_language=kwargs["service_sdk_language"],
|
|
1841
|
+
target_namespace=kwargs["target_namespace"],
|
|
1839
1842
|
request_payload={
|
|
1840
1843
|
"arguments": list(arguments),
|
|
1841
1844
|
"idempotency_key": kwargs["idempotency_key"],
|
|
@@ -1858,7 +1861,7 @@ class TestWorkflowTaskExecution:
|
|
|
1858
1861
|
"workflow_type": "nexus-worker-wf",
|
|
1859
1862
|
"workflow_task_attempt": 1,
|
|
1860
1863
|
"history_events": [],
|
|
1861
|
-
"arguments": serializer.encode([], codec="avro"),
|
|
1864
|
+
"arguments": serializer.encode([target], codec="avro"),
|
|
1862
1865
|
"payload_codec": "avro",
|
|
1863
1866
|
}
|
|
1864
1867
|
|
|
@@ -1869,6 +1872,7 @@ class TestWorkflowTaskExecution:
|
|
|
1869
1872
|
assert execute_call.args[:4] == ("greeter", "shared", "greet", ["Ada"])
|
|
1870
1873
|
assert execute_call.kwargs["caller_workflow_instance_id"] == "wf-caller"
|
|
1871
1874
|
assert execute_call.kwargs["caller_workflow_run_id"] == "run-caller"
|
|
1875
|
+
assert execute_call.kwargs["target_namespace"] == target
|
|
1872
1876
|
assert execute_call.kwargs["service_sdk_language"] == "workflow-php"
|
|
1873
1877
|
assert execute_call.kwargs["raise_on_failure"] is False
|
|
1874
1878
|
assert execute_call.kwargs["idempotency_key"].startswith("dw-py-nexus-")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_cancellation_scope.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_cooperative_cancellation.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_prepared_local_activity.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/_sticky_workflow_cache.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow/replay_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_activity_cancellation_policies.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_scope_admission.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cancellation_scope_authoring.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_history.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_policies.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_run_cancellation_scope_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_scoped_run_cancellation_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_workflow_entrypoint_validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.4 → durable_workflow-2.5.5}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|