durable-workflow 2.0.4__tar.gz → 2.1.0__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.0.4/src/durable_workflow.egg-info → durable_workflow-2.1.0}/PKG-INFO +1 -1
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/pyproject.toml +4 -4
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/client.py +25 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/errors.py +2 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/sync.py +16 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/workflow.py +24 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_client.py +38 -0
- durable_workflow-2.1.0/tests/test_redrive_replay.py +46 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_release_metadata.py +19 -12
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay.py +51 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_sync.py +22 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_worker.py +27 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/LICENSE +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/README.md +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/setup.cfg +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.0.4 → durable_workflow-2.1.0}/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.0
|
|
7
|
+
version = "2.1.0"
|
|
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,9 +71,9 @@ 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.0
|
|
75
|
-
registry-version = "2.0
|
|
76
|
-
supported-server-versions = "2.
|
|
74
|
+
product-train = "2.1.0"
|
|
75
|
+
registry-version = "2.1.0"
|
|
76
|
+
supported-server-versions = "2.4.0"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -600,6 +600,7 @@ class WorkflowCommandResult:
|
|
|
600
600
|
command_status: str | None = None
|
|
601
601
|
command_id: str | None = None
|
|
602
602
|
raw: dict[str, Any] | None = None
|
|
603
|
+
run_id: str | None = None
|
|
603
604
|
|
|
604
605
|
@classmethod
|
|
605
606
|
def from_dict(cls, data: dict[str, Any], *, workflow_id: str | None = None) -> WorkflowCommandResult:
|
|
@@ -609,6 +610,7 @@ class WorkflowCommandResult:
|
|
|
609
610
|
command_status=data.get("command_status"),
|
|
610
611
|
command_id=data.get("command_id"),
|
|
611
612
|
raw=data,
|
|
613
|
+
run_id=data.get("run_id"),
|
|
612
614
|
)
|
|
613
615
|
|
|
614
616
|
|
|
@@ -1325,6 +1327,12 @@ class WorkflowHandle:
|
|
|
1325
1327
|
"""Ask the server to repair this workflow. See :meth:`Client.repair_workflow`."""
|
|
1326
1328
|
return await self._client.repair_workflow(self.workflow_id)
|
|
1327
1329
|
|
|
1330
|
+
async def redrive(self, *, request_id: str | None = None) -> WorkflowCommandResult:
|
|
1331
|
+
"""Continue this failed run from its recorded activity failure boundary."""
|
|
1332
|
+
if self.run_id is None:
|
|
1333
|
+
raise ValueError("run_id is required to redrive a workflow from a handle")
|
|
1334
|
+
return await self._client.redrive_workflow(self.workflow_id, self.run_id, request_id=request_id)
|
|
1335
|
+
|
|
1328
1336
|
async def archive(self, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
1329
1337
|
"""Move this terminal workflow into the archive tier. See :meth:`Client.archive_workflow`."""
|
|
1330
1338
|
return await self._client.archive_workflow(self.workflow_id, reason=reason)
|
|
@@ -4133,6 +4141,23 @@ class Client:
|
|
|
4133
4141
|
data = await self._request("POST", f"/workflows/{workflow_id}/repair", json={}, context=workflow_id)
|
|
4134
4142
|
return WorkflowCommandResult.from_dict(data, workflow_id=workflow_id)
|
|
4135
4143
|
|
|
4144
|
+
async def redrive_workflow(
|
|
4145
|
+
self,
|
|
4146
|
+
workflow_id: str,
|
|
4147
|
+
failed_run_id: str,
|
|
4148
|
+
*,
|
|
4149
|
+
request_id: str | None = None,
|
|
4150
|
+
) -> WorkflowCommandResult:
|
|
4151
|
+
"""Continue a failed run, reusing its completed activity results."""
|
|
4152
|
+
body = {"request_id": request_id} if request_id is not None else {}
|
|
4153
|
+
data = await self._request(
|
|
4154
|
+
"POST",
|
|
4155
|
+
f"/workflows/{workflow_id}/runs/{failed_run_id}/redrive",
|
|
4156
|
+
json=body,
|
|
4157
|
+
context=workflow_id,
|
|
4158
|
+
)
|
|
4159
|
+
return WorkflowCommandResult.from_dict(data, workflow_id=workflow_id)
|
|
4160
|
+
|
|
4136
4161
|
async def archive_workflow(self, workflow_id: str, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
4137
4162
|
"""Move a terminal workflow into the archive tier, returning the command outcome."""
|
|
4138
4163
|
body: dict[str, Any] = {}
|
|
@@ -551,6 +551,7 @@ class ActivityFailed(DurableWorkflowError):
|
|
|
551
551
|
message: str,
|
|
552
552
|
*,
|
|
553
553
|
activity_type: str | None = None,
|
|
554
|
+
step_sequence: int | None = None,
|
|
554
555
|
activity_execution_id: str | None = None,
|
|
555
556
|
activity_attempt_id: str | None = None,
|
|
556
557
|
failure_id: str | None = None,
|
|
@@ -564,6 +565,7 @@ class ActivityFailed(DurableWorkflowError):
|
|
|
564
565
|
) -> None:
|
|
565
566
|
super().__init__(message)
|
|
566
567
|
self.activity_type = activity_type
|
|
568
|
+
self.step_sequence = step_sequence
|
|
567
569
|
self.activity_execution_id = activity_execution_id
|
|
568
570
|
self.activity_attempt_id = activity_attempt_id
|
|
569
571
|
self.failure_id = failure_id
|
|
@@ -134,6 +134,10 @@ class SyncWorkflowHandle:
|
|
|
134
134
|
result: WorkflowCommandResult = self._runner.run(self._handle.repair())
|
|
135
135
|
return result
|
|
136
136
|
|
|
137
|
+
def redrive(self, *, request_id: str | None = None) -> WorkflowCommandResult:
|
|
138
|
+
result: WorkflowCommandResult = self._runner.run(self._handle.redrive(request_id=request_id))
|
|
139
|
+
return result
|
|
140
|
+
|
|
137
141
|
def archive(self, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
138
142
|
result: WorkflowCommandResult = self._runner.run(self._handle.archive(reason=reason))
|
|
139
143
|
return result
|
|
@@ -525,6 +529,18 @@ class Client:
|
|
|
525
529
|
result: WorkflowCommandResult = self._runner.run(self._async.repair_workflow(workflow_id))
|
|
526
530
|
return result
|
|
527
531
|
|
|
532
|
+
def redrive_workflow(
|
|
533
|
+
self,
|
|
534
|
+
workflow_id: str,
|
|
535
|
+
failed_run_id: str,
|
|
536
|
+
*,
|
|
537
|
+
request_id: str | None = None,
|
|
538
|
+
) -> WorkflowCommandResult:
|
|
539
|
+
result: WorkflowCommandResult = self._runner.run(
|
|
540
|
+
self._async.redrive_workflow(workflow_id, failed_run_id, request_id=request_id)
|
|
541
|
+
)
|
|
542
|
+
return result
|
|
543
|
+
|
|
528
544
|
def archive_workflow(self, workflow_id: str, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
529
545
|
result: WorkflowCommandResult = self._runner.run(self._async.archive_workflow(workflow_id, reason=reason))
|
|
530
546
|
return result
|
|
@@ -621,6 +621,8 @@ class FailWorkflow:
|
|
|
621
621
|
exception_class: str | None = None
|
|
622
622
|
exception: dict[str, Any] | None = None
|
|
623
623
|
non_retryable: bool = False
|
|
624
|
+
failed_step_sequence: int | None = None
|
|
625
|
+
failed_activity_execution_id: str | None = None
|
|
624
626
|
|
|
625
627
|
def to_server_command(
|
|
626
628
|
self,
|
|
@@ -642,6 +644,9 @@ class FailWorkflow:
|
|
|
642
644
|
cmd["exception"] = self.exception
|
|
643
645
|
if self.non_retryable:
|
|
644
646
|
cmd["non_retryable"] = True
|
|
647
|
+
if self.failed_step_sequence is not None and self.failed_activity_execution_id is not None:
|
|
648
|
+
cmd["failed_step_sequence"] = self.failed_step_sequence
|
|
649
|
+
cmd["failed_activity_execution_id"] = self.failed_activity_execution_id
|
|
645
650
|
return cmd
|
|
646
651
|
|
|
647
652
|
|
|
@@ -2795,11 +2800,25 @@ def _fail_workflow_from_exception(exc: BaseException, *, prefix: str | None = No
|
|
|
2795
2800
|
if activity_failure.activity_attempt_id is not None:
|
|
2796
2801
|
exception["activity_attempt_id"] = activity_failure.activity_attempt_id
|
|
2797
2802
|
|
|
2803
|
+
failed_step_sequence = None
|
|
2804
|
+
failed_activity_execution_id = None
|
|
2805
|
+
if (
|
|
2806
|
+
isinstance(exc, ActivityFailed)
|
|
2807
|
+
and type(exc.step_sequence) is int
|
|
2808
|
+
and exc.step_sequence > 0
|
|
2809
|
+
and isinstance(exc.activity_execution_id, str)
|
|
2810
|
+
and exc.activity_execution_id
|
|
2811
|
+
):
|
|
2812
|
+
failed_step_sequence = exc.step_sequence
|
|
2813
|
+
failed_activity_execution_id = exc.activity_execution_id
|
|
2814
|
+
|
|
2798
2815
|
return FailWorkflow(
|
|
2799
2816
|
message=message,
|
|
2800
2817
|
exception_type=exception_type,
|
|
2801
2818
|
exception_class=exception_class,
|
|
2802
2819
|
exception=exception,
|
|
2820
|
+
failed_step_sequence=failed_step_sequence,
|
|
2821
|
+
failed_activity_execution_id=failed_activity_execution_id,
|
|
2803
2822
|
)
|
|
2804
2823
|
|
|
2805
2824
|
|
|
@@ -3345,6 +3364,11 @@ def _activity_failed_from_payload(payload: Mapping[str, Any]) -> ActivityFailed:
|
|
|
3345
3364
|
return ActivityFailed(
|
|
3346
3365
|
message or "activity failed",
|
|
3347
3366
|
activity_type=_activity_type_from_payload(payload),
|
|
3367
|
+
step_sequence=(
|
|
3368
|
+
payload["sequence"]
|
|
3369
|
+
if type(payload.get("sequence")) is int and payload["sequence"] > 0
|
|
3370
|
+
else None
|
|
3371
|
+
),
|
|
3348
3372
|
activity_execution_id=_optional_str(payload.get("activity_execution_id")),
|
|
3349
3373
|
activity_attempt_id=_optional_str(payload.get("activity_attempt_id")),
|
|
3350
3374
|
failure_id=_optional_str(payload.get("failure_id")),
|
|
@@ -832,14 +832,20 @@ class TestWorkflowHandleControlPlane:
|
|
|
832
832
|
async def test_maintenance_delegates_to_client(self, client: Client) -> None:
|
|
833
833
|
handle = WorkflowHandle(client, workflow_id="wf-1", run_id="r1", workflow_type="greeter")
|
|
834
834
|
client.repair_workflow = AsyncMock(return_value="repair")
|
|
835
|
+
client.redrive_workflow = AsyncMock(return_value="redrive")
|
|
835
836
|
client.archive_workflow = AsyncMock(return_value="archive")
|
|
836
837
|
|
|
837
838
|
assert await handle.repair() == "repair"
|
|
839
|
+
assert await handle.redrive(request_id="retry-1") == "redrive"
|
|
838
840
|
assert await handle.archive(reason="retention") == "archive"
|
|
839
841
|
|
|
840
842
|
client.repair_workflow.assert_awaited_once_with("wf-1")
|
|
843
|
+
client.redrive_workflow.assert_awaited_once_with("wf-1", "r1", request_id="retry-1")
|
|
841
844
|
client.archive_workflow.assert_awaited_once_with("wf-1", reason="retention")
|
|
842
845
|
|
|
846
|
+
with pytest.raises(ValueError, match="run_id is required"):
|
|
847
|
+
await WorkflowHandle(client, workflow_id="wf-1").redrive()
|
|
848
|
+
|
|
843
849
|
|
|
844
850
|
class TestSignalWorkflow:
|
|
845
851
|
@pytest.mark.asyncio
|
|
@@ -1065,6 +1071,38 @@ class TestTerminateWorkflow:
|
|
|
1065
1071
|
|
|
1066
1072
|
|
|
1067
1073
|
class TestWorkflowMaintenanceCommands:
|
|
1074
|
+
@pytest.mark.asyncio
|
|
1075
|
+
async def test_redrive_returns_successor_and_sends_optional_request_id(self, client: Client) -> None:
|
|
1076
|
+
resp = _mock_response(202, {
|
|
1077
|
+
"workflow_id": "wf-1",
|
|
1078
|
+
"run_id": "run-2",
|
|
1079
|
+
"outcome": "redriven",
|
|
1080
|
+
"command_status": "accepted",
|
|
1081
|
+
"resume_step_sequence": 2,
|
|
1082
|
+
})
|
|
1083
|
+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
|
|
1084
|
+
result = await client.redrive_workflow("wf-1", "run-1", request_id="retry-1")
|
|
1085
|
+
|
|
1086
|
+
assert mock.call_args.args == ("POST", "/api/workflows/wf-1/runs/run-1/redrive")
|
|
1087
|
+
assert mock.call_args.kwargs["json"] == {"request_id": "retry-1"}
|
|
1088
|
+
assert result.workflow_id == "wf-1"
|
|
1089
|
+
assert result.run_id == "run-2"
|
|
1090
|
+
assert result.outcome == "redriven"
|
|
1091
|
+
assert result.raw is not None and result.raw["resume_step_sequence"] == 2
|
|
1092
|
+
|
|
1093
|
+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
|
|
1094
|
+
await client.redrive_workflow("wf-1", "run-1")
|
|
1095
|
+
assert mock.call_args.kwargs["json"] == {}
|
|
1096
|
+
|
|
1097
|
+
@pytest.mark.asyncio
|
|
1098
|
+
async def test_redrive_rejection_is_not_reported_as_success(self, client: Client) -> None:
|
|
1099
|
+
resp = _mock_response(409, {"reason": "run_not_failed", "message": "Run is not failed."})
|
|
1100
|
+
with (
|
|
1101
|
+
patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp),
|
|
1102
|
+
pytest.raises(ServerError),
|
|
1103
|
+
):
|
|
1104
|
+
await client.redrive_workflow("wf-1", "run-1")
|
|
1105
|
+
|
|
1068
1106
|
@pytest.mark.asyncio
|
|
1069
1107
|
async def test_repair_request_matches_polyglot_fixture(self, client: Client) -> None:
|
|
1070
1108
|
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "workflow-repair-parity.json"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from durable_workflow import serializer, workflow
|
|
2
|
+
from durable_workflow.workflow import CompleteWorkflow, ScheduleActivity, replay
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@workflow.defn(name="tests.redrive")
|
|
6
|
+
class RedriveWorkflow:
|
|
7
|
+
def run(self, ctx): # type: ignore[no-untyped-def]
|
|
8
|
+
first = yield ctx.schedule_activity("tests.first", [])
|
|
9
|
+
second = yield ctx.schedule_activity("tests.second", [first])
|
|
10
|
+
return {"first": first, "second": second}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def completed(sequence: int, activity_type: str, result: str) -> dict[str, object]:
|
|
14
|
+
payload: dict[str, object] = {
|
|
15
|
+
"sequence": sequence,
|
|
16
|
+
"activity_type": activity_type,
|
|
17
|
+
"result": serializer.encode(result, codec="avro"),
|
|
18
|
+
"payload_codec": "avro",
|
|
19
|
+
}
|
|
20
|
+
if sequence == 1:
|
|
21
|
+
payload["reused_from_run_id"] = "failed-run"
|
|
22
|
+
payload["reused_activity_execution_id"] = "original-first"
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
"event_type": "ActivityCompleted",
|
|
26
|
+
"payload": payload,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_redriven_history_reuses_completed_prefix_and_retries_failed_step() -> None:
|
|
31
|
+
history = [completed(1, "tests.first", "recorded")]
|
|
32
|
+
|
|
33
|
+
retry = replay(RedriveWorkflow, history, []).commands
|
|
34
|
+
assert len(retry) == 1
|
|
35
|
+
assert isinstance(retry[0], ScheduleActivity)
|
|
36
|
+
assert retry[0].activity_type == "tests.second"
|
|
37
|
+
assert retry[0].arguments == ["recorded"]
|
|
38
|
+
|
|
39
|
+
completed_run = replay(
|
|
40
|
+
RedriveWorkflow,
|
|
41
|
+
history + [completed(2, "tests.second", "retried")],
|
|
42
|
+
[],
|
|
43
|
+
).commands
|
|
44
|
+
assert len(completed_run) == 1
|
|
45
|
+
assert isinstance(completed_run[0], CompleteWorkflow)
|
|
46
|
+
assert completed_run[0].result == {"first": "recorded", "second": "retried"}
|
|
@@ -41,10 +41,10 @@ def test_worker_release_identity_matches_supported_server_and_protocol() -> None
|
|
|
41
41
|
project = manifest["project"]
|
|
42
42
|
release = manifest["tool"]["durable-workflow"]
|
|
43
43
|
|
|
44
|
-
assert project["version"] == "2.0
|
|
44
|
+
assert project["version"] == "2.1.0"
|
|
45
45
|
assert release["product-train"] == project["version"]
|
|
46
|
-
assert release["registry-version"] == "2.0
|
|
47
|
-
assert release["supported-server-versions"] == "2.
|
|
46
|
+
assert release["registry-version"] == "2.1.0"
|
|
47
|
+
assert release["supported-server-versions"] == "2.4.0"
|
|
48
48
|
assert release["worker-protocol-version"] == PROTOCOL_VERSION == "1.19"
|
|
49
49
|
assert release["durable-selection"] is True
|
|
50
50
|
assert release["durable-selection-minimum-worker-protocol-version"] == "1.19"
|
|
@@ -86,24 +86,30 @@ def test_release_metadata_loader_uses_tomli_without_stdlib_tomllib(
|
|
|
86
86
|
assert imports == ["tomllib", "tomli"]
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
("version", "server_version"),
|
|
91
|
+
[("2.0.1", "2.0.0"), ("2.1.0", "2.4.0")],
|
|
92
|
+
)
|
|
89
93
|
def test_release_metadata_loader_accepts_the_authorized_stable_identity(
|
|
90
94
|
monkeypatch: pytest.MonkeyPatch,
|
|
95
|
+
version: str,
|
|
96
|
+
server_version: str,
|
|
91
97
|
) -> None:
|
|
92
98
|
commit = "b" * 40
|
|
93
|
-
pyproject =
|
|
99
|
+
pyproject = f"""
|
|
94
100
|
[project]
|
|
95
101
|
name = "durable-workflow"
|
|
96
|
-
version = "
|
|
102
|
+
version = "{version}"
|
|
97
103
|
description = "Python SDK for Durable Workflow 2.0"
|
|
98
104
|
readme = "README.md"
|
|
99
105
|
classifiers = ["Programming Language :: Python :: 3"]
|
|
100
106
|
|
|
101
107
|
[tool.durable-workflow]
|
|
102
|
-
product-train = "
|
|
103
|
-
registry-version = "
|
|
104
|
-
supported-server-versions = "
|
|
108
|
+
product-train = "{version}"
|
|
109
|
+
registry-version = "{version}"
|
|
110
|
+
supported-server-versions = "{server_version}"
|
|
105
111
|
worker-protocol-version = "1.19"
|
|
106
|
-
"""
|
|
112
|
+
""".encode()
|
|
107
113
|
readme = b"# Durable Workflow\n\nBuild durable Python workflows.\n"
|
|
108
114
|
runtime = b'PROTOCOL_VERSION = "1.19"\n'
|
|
109
115
|
|
|
@@ -120,9 +126,10 @@ worker-protocol-version = "1.19"
|
|
|
120
126
|
|
|
121
127
|
monkeypatch.setattr(check_release_metadata, "_git", git)
|
|
122
128
|
|
|
123
|
-
source = check_release_metadata.load_source_metadata(
|
|
124
|
-
assert source.version ==
|
|
125
|
-
assert source.registry_version ==
|
|
129
|
+
source = check_release_metadata.load_source_metadata(version)
|
|
130
|
+
assert source.version == version
|
|
131
|
+
assert source.registry_version == version
|
|
132
|
+
assert source.server_version == server_version
|
|
126
133
|
|
|
127
134
|
|
|
128
135
|
def test_normal_project_page_is_retained_as_rendered_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -60,6 +60,15 @@ class OneActivity:
|
|
|
60
60
|
return {"greeting": result}
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
@workflow.defn(name="translated-activity-failure")
|
|
64
|
+
class TranslatedActivityFailure:
|
|
65
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
66
|
+
try:
|
|
67
|
+
yield ctx.schedule_activity("greet", [])
|
|
68
|
+
except ActivityFailed as exc:
|
|
69
|
+
raise RuntimeError("translated failure") from exc
|
|
70
|
+
|
|
71
|
+
|
|
63
72
|
@workflow.defn(name="activity-failed-saga")
|
|
64
73
|
class ActivityFailedSaga:
|
|
65
74
|
def run(self, ctx: WorkflowContext, order_id: str): # type: ignore[no-untyped-def]
|
|
@@ -442,6 +451,48 @@ class TestPublicReplayer:
|
|
|
442
451
|
|
|
443
452
|
|
|
444
453
|
class TestOneActivity:
|
|
454
|
+
def test_uncaught_recorded_activity_failure_claims_only_its_persisted_boundary(self) -> None:
|
|
455
|
+
history = [{
|
|
456
|
+
"event_type": "ActivityFailed",
|
|
457
|
+
"payload": {
|
|
458
|
+
"sequence": 1,
|
|
459
|
+
"activity_type": "greet",
|
|
460
|
+
"activity_execution_id": "activity-1",
|
|
461
|
+
"message": "failed",
|
|
462
|
+
},
|
|
463
|
+
}]
|
|
464
|
+
|
|
465
|
+
outcome = replay(OneActivity, history, ["Ada"])
|
|
466
|
+
command = outcome.commands[0].to_server_command("workers")
|
|
467
|
+
|
|
468
|
+
assert command["type"] == "fail_workflow"
|
|
469
|
+
assert command["failed_step_sequence"] == 1
|
|
470
|
+
assert command["failed_activity_execution_id"] == "activity-1"
|
|
471
|
+
|
|
472
|
+
translated = replay(TranslatedActivityFailure, history, [])
|
|
473
|
+
translated_command = translated.commands[0].to_server_command("workers")
|
|
474
|
+
assert translated_command["type"] == "fail_workflow"
|
|
475
|
+
assert "failed_step_sequence" not in translated_command
|
|
476
|
+
assert "failed_activity_execution_id" not in translated_command
|
|
477
|
+
|
|
478
|
+
handled_history = [{
|
|
479
|
+
"event_type": "ActivityFailed",
|
|
480
|
+
"payload": {**history[0]["payload"], "activity_type": "charge-card"},
|
|
481
|
+
}]
|
|
482
|
+
handled = replay(ActivityFailedSaga, handled_history, ["order-1"])
|
|
483
|
+
assert isinstance(handled.commands[0], ScheduleActivity)
|
|
484
|
+
|
|
485
|
+
def test_activity_failure_without_complete_recorded_identity_does_not_claim_redrive(self) -> None:
|
|
486
|
+
for payload in (
|
|
487
|
+
{"sequence": 1, "activity_type": "greet", "message": "failed"},
|
|
488
|
+
{"activity_execution_id": "activity-1", "activity_type": "greet", "message": "failed"},
|
|
489
|
+
):
|
|
490
|
+
outcome = replay(OneActivity, [{"event_type": "ActivityFailed", "payload": payload}], ["Ada"])
|
|
491
|
+
command = outcome.commands[0].to_server_command("workers")
|
|
492
|
+
assert command["type"] == "fail_workflow"
|
|
493
|
+
assert "failed_step_sequence" not in command
|
|
494
|
+
assert "failed_activity_execution_id" not in command
|
|
495
|
+
|
|
445
496
|
def test_first_replay_schedules(self) -> None:
|
|
446
497
|
outcome = replay(OneActivity, [], ["world"])
|
|
447
498
|
assert len(outcome.commands) == 1
|
|
@@ -519,6 +519,25 @@ class TestSyncClientRunVisibility:
|
|
|
519
519
|
|
|
520
520
|
|
|
521
521
|
class TestSyncClientMaintenance:
|
|
522
|
+
def test_redrive_workflow(self) -> None:
|
|
523
|
+
client = Client("http://localhost:8080")
|
|
524
|
+
resp = _mock_response(
|
|
525
|
+
202,
|
|
526
|
+
{
|
|
527
|
+
"workflow_id": "wf-1",
|
|
528
|
+
"run_id": "run-2",
|
|
529
|
+
"outcome": "redriven",
|
|
530
|
+
"command_status": "accepted",
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
with patch.object(client._async._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
|
|
534
|
+
result = client.redrive_workflow("wf-1", "run-1", request_id="retry-1")
|
|
535
|
+
|
|
536
|
+
assert result.run_id == "run-2"
|
|
537
|
+
assert result.outcome == "redriven"
|
|
538
|
+
assert mock.call_args.args[:2] == ("POST", "/api/workflows/wf-1/runs/run-1/redrive")
|
|
539
|
+
assert mock.call_args.kwargs["json"] == {"request_id": "retry-1"}
|
|
540
|
+
|
|
522
541
|
def test_repair_workflow(self) -> None:
|
|
523
542
|
client = Client("http://localhost:8080")
|
|
524
543
|
resp = _mock_response(
|
|
@@ -786,6 +805,7 @@ class TestSyncWorkflowHandleControlPlane:
|
|
|
786
805
|
async_handle.export_history = AsyncMock(return_value={"schema": "durable.workflow.history.v2"}) # type: ignore[method-assign]
|
|
787
806
|
async_handle.list_runs = AsyncMock(return_value=[]) # type: ignore[method-assign]
|
|
788
807
|
async_handle.describe_run = AsyncMock(return_value={"run_id": "r1"}) # type: ignore[method-assign]
|
|
808
|
+
async_handle.redrive = AsyncMock(return_value={"run_id": "r2"}) # type: ignore[method-assign]
|
|
789
809
|
async_handle.repair = AsyncMock(return_value={"outcome": "accepted"}) # type: ignore[method-assign]
|
|
790
810
|
async_handle.archive = AsyncMock(return_value={"outcome": "completed"}) # type: ignore[method-assign]
|
|
791
811
|
with Client("http://localhost:8080") as client:
|
|
@@ -796,7 +816,9 @@ class TestSyncWorkflowHandleControlPlane:
|
|
|
796
816
|
assert handle.list_runs() == []
|
|
797
817
|
assert handle.describe_run() == {"run_id": "r1"}
|
|
798
818
|
assert handle.repair() == {"outcome": "accepted"}
|
|
819
|
+
assert handle.redrive(request_id="retry-1") == {"run_id": "r2"}
|
|
799
820
|
assert handle.archive(reason="retention") == {"outcome": "completed"}
|
|
821
|
+
async_handle.redrive.assert_awaited_once_with(request_id="retry-1")
|
|
800
822
|
async_handle.archive.assert_awaited_once_with(reason="retention")
|
|
801
823
|
|
|
802
824
|
|
|
@@ -1123,6 +1123,33 @@ class TestWorkflowTaskExecution:
|
|
|
1123
1123
|
assert commands[0]["arguments"]["codec"] == "avro"
|
|
1124
1124
|
assert serializer.decode(commands[0]["arguments"]["blob"], codec="avro") == ["hello"]
|
|
1125
1125
|
|
|
1126
|
+
@pytest.mark.asyncio
|
|
1127
|
+
async def test_uncaught_recorded_activity_failure_sends_redrive_boundary(
|
|
1128
|
+
self, mock_client: AsyncMock
|
|
1129
|
+
) -> None:
|
|
1130
|
+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
|
|
1131
|
+
await worker._run_workflow_task({
|
|
1132
|
+
"task_id": "failed-activity-task",
|
|
1133
|
+
"workflow_type": "test-wf",
|
|
1134
|
+
"workflow_task_attempt": 1,
|
|
1135
|
+
"history_events": [{
|
|
1136
|
+
"event_type": "ActivityFailed",
|
|
1137
|
+
"payload": {
|
|
1138
|
+
"sequence": 1,
|
|
1139
|
+
"activity_type": "test-act",
|
|
1140
|
+
"activity_execution_id": "activity-execution-1",
|
|
1141
|
+
"message": "failed",
|
|
1142
|
+
},
|
|
1143
|
+
}],
|
|
1144
|
+
"arguments": serializer.encode(["hello"], codec="avro"),
|
|
1145
|
+
"payload_codec": "avro",
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
commands = mock_client.complete_workflow_task.await_args.kwargs["commands"]
|
|
1149
|
+
assert commands[0]["type"] == "fail_workflow"
|
|
1150
|
+
assert commands[0]["failed_step_sequence"] == 1
|
|
1151
|
+
assert commands[0]["failed_activity_execution_id"] == "activity-execution-1"
|
|
1152
|
+
|
|
1126
1153
|
@pytest.mark.asyncio
|
|
1127
1154
|
async def test_workflow_task_ambiguous_completion_error_preserves_commands(
|
|
1128
1155
|
self, mock_client: AsyncMock
|
|
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.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/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
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/top_level.txt
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.0.4 → durable_workflow-2.1.0}/tests/test_control_plane_parity_fixtures.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
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_parallel_completion_order_replay.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
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_runtime_external_payload_transport.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.0.4 → durable_workflow-2.1.0}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|