durable-workflow 0.4.45__tar.gz → 0.4.47__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-0.4.45/src/durable_workflow.egg-info → durable_workflow-0.4.47}/PKG-INFO +1 -1
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/pyproject.toml +1 -1
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/worker.py +87 -12
- {durable_workflow-0.4.45 → durable_workflow-0.4.47/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_worker.py +179 -18
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/LICENSE +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/README.md +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/setup.cfg +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/client.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_activity_context.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_auth_composition.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_client.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_errors.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_external_storage.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_external_task_input.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_external_task_result.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_invocable.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_metrics.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_queries.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_replay.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_replay_verify.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_retry_policy.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_schedules.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_serializer.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_signals.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_sleep.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_sync.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_testing_harness.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_updates.py +0 -0
- {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_wait_condition.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.47"
|
|
8
8
|
description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -45,7 +45,15 @@ from .client import (
|
|
|
45
45
|
Client,
|
|
46
46
|
WorkflowExecution,
|
|
47
47
|
)
|
|
48
|
-
from .errors import
|
|
48
|
+
from .errors import (
|
|
49
|
+
ActivityCancelled,
|
|
50
|
+
AvroNotInstalledError,
|
|
51
|
+
DurableWorkflowError,
|
|
52
|
+
InvalidArgument,
|
|
53
|
+
NonRetryableError,
|
|
54
|
+
QueryFailed,
|
|
55
|
+
ServerError,
|
|
56
|
+
)
|
|
49
57
|
from .external_storage import ExternalPayloadCache, ExternalStorageDriver
|
|
50
58
|
from .interceptors import (
|
|
51
59
|
ActivityInterceptorContext,
|
|
@@ -74,6 +82,17 @@ _QUERY_TASK_FINAL_REJECTION_REASONS = {
|
|
|
74
82
|
"query_task_not_leased",
|
|
75
83
|
"query_task_timed_out",
|
|
76
84
|
}
|
|
85
|
+
_WORKFLOW_TASK_COMPLETION_AMBIGUOUS_REJECTION_REASONS = {
|
|
86
|
+
"lease_expired",
|
|
87
|
+
"lease_owner_mismatch",
|
|
88
|
+
"run_already_closed",
|
|
89
|
+
"run_closed",
|
|
90
|
+
"task_not_found",
|
|
91
|
+
"task_not_leased",
|
|
92
|
+
"workflow_task_attempt_mismatch",
|
|
93
|
+
}
|
|
94
|
+
_WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS = 3
|
|
95
|
+
_WORKFLOW_TASK_COMPLETION_RETRY_DELAYS = (0.05, 0.2)
|
|
77
96
|
_WORKER_WORKFLOW_FINGERPRINTS: dict[tuple[str, str], str] = {}
|
|
78
97
|
|
|
79
98
|
|
|
@@ -111,6 +130,26 @@ def _is_final_query_task_rejection(error: BaseException) -> bool:
|
|
|
111
130
|
)
|
|
112
131
|
|
|
113
132
|
|
|
133
|
+
def _should_fail_workflow_task_after_completion_error(error: BaseException) -> bool:
|
|
134
|
+
if isinstance(error, InvalidArgument):
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
if isinstance(error, ServerError):
|
|
138
|
+
if error.status >= 500 or error.status == 429:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
return error.reason() not in _WORKFLOW_TASK_COMPLETION_AMBIGUOUS_REJECTION_REASONS
|
|
142
|
+
|
|
143
|
+
return isinstance(error, DurableWorkflowError)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _should_retry_workflow_task_completion_error(error: BaseException) -> bool:
|
|
147
|
+
if isinstance(error, ServerError):
|
|
148
|
+
return error.status >= 500 or error.status == 429
|
|
149
|
+
|
|
150
|
+
return not isinstance(error, DurableWorkflowError)
|
|
151
|
+
|
|
152
|
+
|
|
114
153
|
def _signal_arguments_envelope_from_export(
|
|
115
154
|
signal: Mapping[str, Any],
|
|
116
155
|
*,
|
|
@@ -767,16 +806,16 @@ class Worker:
|
|
|
767
806
|
command["type"],
|
|
768
807
|
)
|
|
769
808
|
try:
|
|
770
|
-
await self.
|
|
809
|
+
await self._complete_workflow_task_with_retry(
|
|
771
810
|
task_id=task_id,
|
|
772
|
-
|
|
773
|
-
workflow_task_attempt=attempt,
|
|
811
|
+
attempt=attempt,
|
|
774
812
|
commands=[command],
|
|
775
813
|
)
|
|
776
814
|
except Exception as e:
|
|
777
815
|
log.warning("failed to complete workflow update task %s: %s", task_id, e)
|
|
778
|
-
|
|
779
|
-
|
|
816
|
+
if _should_fail_workflow_task_after_completion_error(e):
|
|
817
|
+
await self._report_workflow_task_after_completion_error(task_id, attempt, e)
|
|
818
|
+
return None
|
|
780
819
|
return [command]
|
|
781
820
|
|
|
782
821
|
try:
|
|
@@ -841,19 +880,55 @@ class Worker:
|
|
|
841
880
|
[c["type"] for c in commands],
|
|
842
881
|
)
|
|
843
882
|
try:
|
|
844
|
-
await self.
|
|
883
|
+
await self._complete_workflow_task_with_retry(
|
|
845
884
|
task_id=task_id,
|
|
846
|
-
|
|
847
|
-
workflow_task_attempt=attempt,
|
|
885
|
+
attempt=attempt,
|
|
848
886
|
commands=commands,
|
|
849
887
|
)
|
|
850
888
|
except Exception as e:
|
|
851
889
|
log.warning("failed to complete workflow task %s: %s", task_id, e)
|
|
852
|
-
|
|
853
|
-
|
|
890
|
+
if _should_fail_workflow_task_after_completion_error(e):
|
|
891
|
+
await self._report_workflow_task_after_completion_error(task_id, attempt, e)
|
|
892
|
+
return None
|
|
854
893
|
return commands
|
|
855
894
|
|
|
856
|
-
async def
|
|
895
|
+
async def _complete_workflow_task_with_retry(
|
|
896
|
+
self,
|
|
897
|
+
*,
|
|
898
|
+
task_id: str,
|
|
899
|
+
attempt: int,
|
|
900
|
+
commands: list[dict[str, Any]],
|
|
901
|
+
) -> None:
|
|
902
|
+
for completion_attempt in range(1, _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS + 1):
|
|
903
|
+
try:
|
|
904
|
+
await self.client.complete_workflow_task(
|
|
905
|
+
task_id=task_id,
|
|
906
|
+
lease_owner=self.worker_id,
|
|
907
|
+
workflow_task_attempt=attempt,
|
|
908
|
+
commands=commands,
|
|
909
|
+
)
|
|
910
|
+
return
|
|
911
|
+
except Exception as error:
|
|
912
|
+
if (
|
|
913
|
+
completion_attempt >= _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS
|
|
914
|
+
or not _should_retry_workflow_task_completion_error(error)
|
|
915
|
+
):
|
|
916
|
+
raise
|
|
917
|
+
|
|
918
|
+
delay_index = min(
|
|
919
|
+
completion_attempt - 1,
|
|
920
|
+
len(_WORKFLOW_TASK_COMPLETION_RETRY_DELAYS) - 1,
|
|
921
|
+
)
|
|
922
|
+
log.warning(
|
|
923
|
+
"retrying workflow task %s completion after attempt %d/%d failed: %s",
|
|
924
|
+
task_id,
|
|
925
|
+
completion_attempt,
|
|
926
|
+
_WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS,
|
|
927
|
+
error,
|
|
928
|
+
)
|
|
929
|
+
await asyncio.sleep(_WORKFLOW_TASK_COMPLETION_RETRY_DELAYS[delay_index])
|
|
930
|
+
|
|
931
|
+
async def _report_workflow_task_after_completion_error(
|
|
857
932
|
self,
|
|
858
933
|
task_id: str,
|
|
859
934
|
attempt: int,
|
|
@@ -22,7 +22,7 @@ from durable_workflow.client import (
|
|
|
22
22
|
Client,
|
|
23
23
|
WorkflowExecution,
|
|
24
24
|
)
|
|
25
|
-
from durable_workflow.errors import ServerError
|
|
25
|
+
from durable_workflow.errors import InvalidArgument, ServerError, Unauthorized, WorkflowNotFound
|
|
26
26
|
from durable_workflow.interceptors import (
|
|
27
27
|
ActivityHandler,
|
|
28
28
|
ActivityInterceptorContext,
|
|
@@ -32,7 +32,7 @@ from durable_workflow.interceptors import (
|
|
|
32
32
|
WorkflowTaskHandler,
|
|
33
33
|
WorkflowTaskInterceptorContext,
|
|
34
34
|
)
|
|
35
|
-
from durable_workflow.worker import Worker
|
|
35
|
+
from durable_workflow.worker import Worker, _should_fail_workflow_task_after_completion_error
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
@workflow.defn(name="test-wf")
|
|
@@ -185,6 +185,28 @@ def compatible_cluster_info(**overrides: object) -> dict[str, object]:
|
|
|
185
185
|
return info
|
|
186
186
|
|
|
187
187
|
|
|
188
|
+
class TestWorkflowTaskCompletionErrorClassification:
|
|
189
|
+
@pytest.mark.parametrize(
|
|
190
|
+
("error", "should_fail"),
|
|
191
|
+
[
|
|
192
|
+
(TimeoutError("completion timed out"), False),
|
|
193
|
+
(RuntimeError("connection reset"), False),
|
|
194
|
+
(ServerError(409, {"reason": "lease_expired"}), False),
|
|
195
|
+
(ServerError(409, {"reason": "workflow_task_attempt_mismatch"}), False),
|
|
196
|
+
(ServerError(429, {"reason": "rate_limited"}), False),
|
|
197
|
+
(ServerError(503, {"reason": "server_busy"}), False),
|
|
198
|
+
(ServerError(409, {"reason": "invalid_commands"}), True),
|
|
199
|
+
(InvalidArgument("invalid command payload"), True),
|
|
200
|
+
(Unauthorized("missing bearer token"), True),
|
|
201
|
+
(WorkflowNotFound("wf-missing"), True),
|
|
202
|
+
],
|
|
203
|
+
)
|
|
204
|
+
def test_classifies_definite_and_ambiguous_completion_errors(
|
|
205
|
+
self, error: BaseException, should_fail: bool
|
|
206
|
+
) -> None:
|
|
207
|
+
assert _should_fail_workflow_task_after_completion_error(error) is should_fail
|
|
208
|
+
|
|
209
|
+
|
|
188
210
|
class TestWorkerRegistration:
|
|
189
211
|
@pytest.mark.asyncio
|
|
190
212
|
async def test_register(self, mock_client: AsyncMock) -> None:
|
|
@@ -502,13 +524,97 @@ class TestWorkflowTaskExecution:
|
|
|
502
524
|
assert serializer.decode(commands[0]["arguments"]["blob"], codec="json") == ["hello"]
|
|
503
525
|
|
|
504
526
|
@pytest.mark.asyncio
|
|
505
|
-
async def
|
|
527
|
+
async def test_workflow_task_ambiguous_completion_error_preserves_commands(
|
|
528
|
+
self, mock_client: AsyncMock
|
|
529
|
+
) -> None:
|
|
530
|
+
mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "task_not_leased"})
|
|
531
|
+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
|
|
532
|
+
task = {
|
|
533
|
+
"task_id": "t-complete-not-leased",
|
|
534
|
+
"workflow_type": "test-wf",
|
|
535
|
+
"workflow_task_attempt": 2,
|
|
536
|
+
"history_events": [],
|
|
537
|
+
"arguments": '["hello"]',
|
|
538
|
+
"payload_codec": "json",
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
result = await worker._run_workflow_task(task)
|
|
542
|
+
|
|
543
|
+
assert result is not None
|
|
544
|
+
assert result[0]["type"] == "schedule_activity"
|
|
545
|
+
mock_client.complete_workflow_task.assert_awaited_once()
|
|
546
|
+
mock_client.fail_workflow_task.assert_not_called()
|
|
547
|
+
|
|
548
|
+
@pytest.mark.asyncio
|
|
549
|
+
async def test_workflow_task_retries_transient_completion_error(
|
|
550
|
+
self, mock_client: AsyncMock
|
|
551
|
+
) -> None:
|
|
552
|
+
mock_client.complete_workflow_task.side_effect = [
|
|
553
|
+
TimeoutError("completion timed out"),
|
|
554
|
+
{"outcome": "completed"},
|
|
555
|
+
]
|
|
556
|
+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
|
|
557
|
+
task = {
|
|
558
|
+
"task_id": "t-complete-retry",
|
|
559
|
+
"workflow_type": "test-wf",
|
|
560
|
+
"workflow_task_attempt": 2,
|
|
561
|
+
"history_events": [],
|
|
562
|
+
"arguments": '["hello"]',
|
|
563
|
+
"payload_codec": "json",
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
result = await worker._run_workflow_task(task)
|
|
567
|
+
|
|
568
|
+
assert result is not None
|
|
569
|
+
assert result[0]["type"] == "schedule_activity"
|
|
570
|
+
assert mock_client.complete_workflow_task.await_count == 2
|
|
571
|
+
mock_client.fail_workflow_task.assert_not_called()
|
|
572
|
+
|
|
573
|
+
@pytest.mark.asyncio
|
|
574
|
+
async def test_workflow_task_definite_completion_rejection_fails_task(
|
|
506
575
|
self, mock_client: AsyncMock
|
|
507
576
|
) -> None:
|
|
508
|
-
mock_client.complete_workflow_task.side_effect =
|
|
577
|
+
mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "invalid_commands"})
|
|
509
578
|
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
|
|
510
579
|
task = {
|
|
511
|
-
"task_id": "t-complete-
|
|
580
|
+
"task_id": "t-complete-invalid",
|
|
581
|
+
"workflow_type": "test-wf",
|
|
582
|
+
"workflow_task_attempt": 2,
|
|
583
|
+
"history_events": [],
|
|
584
|
+
"arguments": '["hello"]',
|
|
585
|
+
"payload_codec": "json",
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
result = await worker._run_workflow_task(task)
|
|
589
|
+
|
|
590
|
+
assert result is None
|
|
591
|
+
mock_client.fail_workflow_task.assert_awaited_once()
|
|
592
|
+
call_kwargs = mock_client.fail_workflow_task.await_args.kwargs
|
|
593
|
+
assert call_kwargs["task_id"] == "t-complete-invalid"
|
|
594
|
+
assert call_kwargs["workflow_task_attempt"] == 2
|
|
595
|
+
assert call_kwargs["lease_owner"] == worker.worker_id
|
|
596
|
+
assert call_kwargs["failure_type"] == "ServerError"
|
|
597
|
+
assert "invalid_commands" in call_kwargs["message"]
|
|
598
|
+
|
|
599
|
+
@pytest.mark.parametrize(
|
|
600
|
+
("completion_error", "failure_type", "message_fragment"),
|
|
601
|
+
[
|
|
602
|
+
(Unauthorized("missing bearer token"), "Unauthorized", "missing bearer token"),
|
|
603
|
+
(WorkflowNotFound("wf-typed-missing"), "WorkflowNotFound", "wf-typed-missing"),
|
|
604
|
+
],
|
|
605
|
+
)
|
|
606
|
+
@pytest.mark.asyncio
|
|
607
|
+
async def test_workflow_task_typed_completion_rejection_fails_task(
|
|
608
|
+
self,
|
|
609
|
+
mock_client: AsyncMock,
|
|
610
|
+
completion_error: Exception,
|
|
611
|
+
failure_type: str,
|
|
612
|
+
message_fragment: str,
|
|
613
|
+
) -> None:
|
|
614
|
+
mock_client.complete_workflow_task.side_effect = completion_error
|
|
615
|
+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
|
|
616
|
+
task = {
|
|
617
|
+
"task_id": "t-complete-typed-rejection",
|
|
512
618
|
"workflow_type": "test-wf",
|
|
513
619
|
"workflow_task_attempt": 2,
|
|
514
620
|
"history_events": [],
|
|
@@ -522,11 +628,33 @@ class TestWorkflowTaskExecution:
|
|
|
522
628
|
mock_client.complete_workflow_task.assert_awaited_once()
|
|
523
629
|
mock_client.fail_workflow_task.assert_awaited_once()
|
|
524
630
|
call_kwargs = mock_client.fail_workflow_task.await_args.kwargs
|
|
525
|
-
assert call_kwargs["task_id"] == "t-complete-
|
|
631
|
+
assert call_kwargs["task_id"] == "t-complete-typed-rejection"
|
|
526
632
|
assert call_kwargs["workflow_task_attempt"] == 2
|
|
527
633
|
assert call_kwargs["lease_owner"] == worker.worker_id
|
|
528
|
-
assert call_kwargs["failure_type"] ==
|
|
529
|
-
assert
|
|
634
|
+
assert call_kwargs["failure_type"] == failure_type
|
|
635
|
+
assert message_fragment in call_kwargs["message"]
|
|
636
|
+
|
|
637
|
+
@pytest.mark.asyncio
|
|
638
|
+
async def test_workflow_task_definite_completion_rejection_stays_failed_when_report_fails(
|
|
639
|
+
self, mock_client: AsyncMock
|
|
640
|
+
) -> None:
|
|
641
|
+
mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "invalid_commands"})
|
|
642
|
+
mock_client.fail_workflow_task.side_effect = RuntimeError("failure report unavailable")
|
|
643
|
+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
|
|
644
|
+
task = {
|
|
645
|
+
"task_id": "t-complete-invalid-report-fails",
|
|
646
|
+
"workflow_type": "test-wf",
|
|
647
|
+
"workflow_task_attempt": 2,
|
|
648
|
+
"history_events": [],
|
|
649
|
+
"arguments": '["hello"]',
|
|
650
|
+
"payload_codec": "json",
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
result = await worker._run_workflow_task(task)
|
|
654
|
+
|
|
655
|
+
assert result is None
|
|
656
|
+
mock_client.complete_workflow_task.assert_awaited_once()
|
|
657
|
+
mock_client.fail_workflow_task.assert_awaited_once()
|
|
530
658
|
|
|
531
659
|
@pytest.mark.asyncio
|
|
532
660
|
async def test_workflow_command_payload_warning_uses_client_policy(
|
|
@@ -701,13 +829,13 @@ class TestWorkflowTaskExecution:
|
|
|
701
829
|
mock_client.fail_workflow_task.assert_not_called()
|
|
702
830
|
|
|
703
831
|
@pytest.mark.asyncio
|
|
704
|
-
async def
|
|
832
|
+
async def test_update_task_ambiguous_completion_error_preserves_command(
|
|
705
833
|
self, mock_client: AsyncMock
|
|
706
834
|
) -> None:
|
|
707
|
-
mock_client.complete_workflow_task.side_effect =
|
|
835
|
+
mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "task_not_leased"})
|
|
708
836
|
worker = Worker(mock_client, task_queue="q1", workflows=[UpdateWorkflow], activities=[])
|
|
709
837
|
task = {
|
|
710
|
-
"task_id": "t-update-
|
|
838
|
+
"task_id": "t-update-not-leased",
|
|
711
839
|
"workflow_type": "update-wf",
|
|
712
840
|
"workflow_task_attempt": 3,
|
|
713
841
|
"workflow_update_id": "upd-worker-1",
|
|
@@ -729,14 +857,47 @@ class TestWorkflowTaskExecution:
|
|
|
729
857
|
|
|
730
858
|
result = await worker._run_workflow_task(task)
|
|
731
859
|
|
|
732
|
-
assert result is None
|
|
860
|
+
assert result is not None
|
|
861
|
+
assert result[0]["type"] == "complete_update"
|
|
733
862
|
mock_client.complete_workflow_task.assert_awaited_once()
|
|
734
|
-
mock_client.fail_workflow_task.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
863
|
+
mock_client.fail_workflow_task.assert_not_called()
|
|
864
|
+
|
|
865
|
+
@pytest.mark.asyncio
|
|
866
|
+
async def test_update_task_retries_transient_completion_error(
|
|
867
|
+
self, mock_client: AsyncMock
|
|
868
|
+
) -> None:
|
|
869
|
+
mock_client.complete_workflow_task.side_effect = [
|
|
870
|
+
ServerError(503, {"reason": "server_busy"}),
|
|
871
|
+
{"outcome": "completed"},
|
|
872
|
+
]
|
|
873
|
+
worker = Worker(mock_client, task_queue="q1", workflows=[UpdateWorkflow], activities=[])
|
|
874
|
+
task = {
|
|
875
|
+
"task_id": "t-update-retry",
|
|
876
|
+
"workflow_type": "update-wf",
|
|
877
|
+
"workflow_task_attempt": 3,
|
|
878
|
+
"workflow_update_id": "upd-worker-1",
|
|
879
|
+
"workflow_wait_kind": "update",
|
|
880
|
+
"history_events": [
|
|
881
|
+
{
|
|
882
|
+
"event_type": "UpdateAccepted",
|
|
883
|
+
"payload": {
|
|
884
|
+
"update_id": "upd-worker-1",
|
|
885
|
+
"update_name": "increment",
|
|
886
|
+
"arguments": serializer.encode([6], codec="json"),
|
|
887
|
+
"payload_codec": "json",
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
],
|
|
891
|
+
"arguments": "[]",
|
|
892
|
+
"payload_codec": "json",
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
result = await worker._run_workflow_task(task)
|
|
896
|
+
|
|
897
|
+
assert result is not None
|
|
898
|
+
assert result[0]["type"] == "complete_update"
|
|
899
|
+
assert mock_client.complete_workflow_task.await_count == 2
|
|
900
|
+
mock_client.fail_workflow_task.assert_not_called()
|
|
740
901
|
|
|
741
902
|
@pytest.mark.asyncio
|
|
742
903
|
async def test_query_task_executes_registered_query(self, mock_client: AsyncMock) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/auth_composition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_storage.py
RENAMED
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_standalone_activity_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|