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.
Files changed (88) hide show
  1. {durable_workflow-2.0.4/src/durable_workflow.egg-info → durable_workflow-2.1.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/pyproject.toml +4 -4
  3. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/client.py +25 -0
  4. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/errors.py +2 -0
  5. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/sync.py +16 -0
  6. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/workflow.py +24 -0
  7. {durable_workflow-2.0.4 → durable_workflow-2.1.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  8. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  9. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_client.py +38 -0
  10. durable_workflow-2.1.0/tests/test_redrive_replay.py +46 -0
  11. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_release_metadata.py +19 -12
  12. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay.py +51 -0
  13. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_sync.py +22 -0
  14. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_worker.py +27 -0
  15. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/LICENSE +0 -0
  16. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/README.md +0 -0
  17. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/setup.cfg +0 -0
  18. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/__init__.py +0 -0
  19. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/_avro.py +0 -0
  20. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/activity.py +0 -0
  21. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/auth_composition.py +0 -0
  22. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_storage.py +0 -0
  23. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_task_input.py +0 -0
  24. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/external_task_result.py +0 -0
  25. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  26. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/interceptors.py +0 -0
  27. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/invocable.py +0 -0
  28. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/metrics.py +0 -0
  29. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/nexus.py +0 -0
  30. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/py.typed +0 -0
  31. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/python_conformance.py +0 -0
  32. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/replay_conformance.py +0 -0
  33. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/replay_verify.py +0 -0
  34. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/retry_policy.py +0 -0
  35. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/serializer.py +0 -0
  36. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/testing.py +0 -0
  37. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/worker.py +0 -0
  38. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  39. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  40. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  41. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  42. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  43. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_activity_context.py +0 -0
  44. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_auth_composition.py +0 -0
  45. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_avro_value_benchmark.py +0 -0
  46. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_avro_value_protocol.py +0 -0
  47. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_ci_checkout.py +0 -0
  48. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_ci_integration_endpoint.py +0 -0
  49. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_codec_regression_corpus.py +0 -0
  50. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  51. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_docstring_cross_references.py +0 -0
  52. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_durable_selection.py +0 -0
  53. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_errors.py +0 -0
  54. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_external_storage.py +0 -0
  55. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_external_task_input.py +0 -0
  56. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_external_task_result.py +0 -0
  57. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_golden_history_replay.py +0 -0
  58. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_history_bundle_verify.py +0 -0
  59. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_history_event_contract.py +0 -0
  60. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_invocable.py +0 -0
  61. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_message_streams.py +0 -0
  62. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_metrics.py +0 -0
  63. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_order_processing_example.py +0 -0
  64. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_parallel_completion_order_replay.py +0 -0
  65. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_parallel_saga_authoring.py +0 -0
  66. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_public_boundary_scanner.py +0 -0
  67. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_pypi_project_surface.py +0 -0
  68. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_python_conformance.py +0 -0
  69. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_queries.py +0 -0
  70. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_readme_quickstart.py +0 -0
  71. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay_conformance.py +0 -0
  72. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay_regression_corpus.py +0 -0
  73. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_replay_verify.py +0 -0
  74. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_retry_policy.py +0 -0
  75. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_runtime_external_payload_transport.py +0 -0
  76. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_schedules.py +0 -0
  77. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_serializer.py +0 -0
  78. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_signals.py +0 -0
  79. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_sleep.py +0 -0
  80. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_standalone_activity_client.py +0 -0
  81. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_storage_admission.py +0 -0
  82. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_testing_harness.py +0 -0
  83. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_update_signal_condition_replay.py +0 -0
  84. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_updates.py +0 -0
  85. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_wait_condition.py +0 -0
  86. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_workflow_result_timeout.py +0 -0
  87. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_workflow_streams.py +0 -0
  88. {durable_workflow-2.0.4 → durable_workflow-2.1.0}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.0.4
3
+ Version: 2.1.0
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.0.4"
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.4"
75
- registry-version = "2.0.4"
76
- supported-server-versions = "2.0.0"
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")),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.0.4
3
+ Version: 2.1.0
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -61,6 +61,7 @@ tests/test_pypi_project_surface.py
61
61
  tests/test_python_conformance.py
62
62
  tests/test_queries.py
63
63
  tests/test_readme_quickstart.py
64
+ tests/test_redrive_replay.py
64
65
  tests/test_release_metadata.py
65
66
  tests/test_replay.py
66
67
  tests/test_replay_conformance.py
@@ -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.4"
44
+ assert project["version"] == "2.1.0"
45
45
  assert release["product-train"] == project["version"]
46
- assert release["registry-version"] == "2.0.4"
47
- assert release["supported-server-versions"] == "2.0.0"
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 = b"""
99
+ pyproject = f"""
94
100
  [project]
95
101
  name = "durable-workflow"
96
- version = "2.0.1"
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 = "2.0.1"
103
- registry-version = "2.0.1"
104
- supported-server-versions = "2.0.0"
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("2.0.1")
124
- assert source.version == "2.0.1"
125
- assert source.registry_version == "2.0.1"
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