durable-workflow 0.4.63__tar.gz → 0.4.65__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.63/src/durable_workflow.egg-info → durable_workflow-0.4.65}/PKG-INFO +1 -1
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/pyproject.toml +1 -1
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/client.py +22 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/workflow.py +47 -8
- {durable_workflow-0.4.63 → durable_workflow-0.4.65/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_client.py +15 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_wait_condition.py +372 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/LICENSE +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/README.md +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/setup.cfg +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_activity_context.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_auth_composition.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_errors.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_external_storage.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_external_task_input.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_external_task_result.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_invocable.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_metrics.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_queries.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_replay.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_replay_verify.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_retry_policy.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_schedules.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_serializer.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_signals.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_sleep.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_sync.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_testing_harness.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_updates.py +0 -0
- {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_worker.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.65"
|
|
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"
|
|
@@ -148,6 +148,7 @@ class NamespaceDescription:
|
|
|
148
148
|
status: str | None = None
|
|
149
149
|
created_at: str | None = None
|
|
150
150
|
updated_at: str | None = None
|
|
151
|
+
deleted: dict[str, int] | None = None
|
|
151
152
|
external_payload_storage: ExternalPayloadStoragePolicy | None = None
|
|
152
153
|
|
|
153
154
|
@classmethod
|
|
@@ -155,6 +156,13 @@ class NamespaceDescription:
|
|
|
155
156
|
external_payload_storage = None
|
|
156
157
|
if isinstance(data.get("external_payload_storage"), dict):
|
|
157
158
|
external_payload_storage = ExternalPayloadStoragePolicy.from_dict(data)
|
|
159
|
+
deleted = None
|
|
160
|
+
if isinstance(data.get("deleted"), dict):
|
|
161
|
+
deleted = {
|
|
162
|
+
str(key): value
|
|
163
|
+
for key, value in data["deleted"].items()
|
|
164
|
+
if isinstance(value, int)
|
|
165
|
+
}
|
|
158
166
|
|
|
159
167
|
return cls(
|
|
160
168
|
name=str(data.get("name", "")),
|
|
@@ -163,6 +171,7 @@ class NamespaceDescription:
|
|
|
163
171
|
status=data.get("status"),
|
|
164
172
|
created_at=data.get("created_at"),
|
|
165
173
|
updated_at=data.get("updated_at"),
|
|
174
|
+
deleted=deleted,
|
|
166
175
|
external_payload_storage=external_payload_storage,
|
|
167
176
|
)
|
|
168
177
|
|
|
@@ -1572,6 +1581,19 @@ class Client:
|
|
|
1572
1581
|
)
|
|
1573
1582
|
return NamespaceDescription.from_dict(data)
|
|
1574
1583
|
|
|
1584
|
+
async def delete_namespace(self, name: str) -> NamespaceDescription:
|
|
1585
|
+
"""Delete a namespace through the server lifecycle surface."""
|
|
1586
|
+
data = await self._request("DELETE", f"/namespaces/{quote(name, safe='')}", context=name)
|
|
1587
|
+
if not isinstance(data, dict):
|
|
1588
|
+
raise ServerError(
|
|
1589
|
+
200,
|
|
1590
|
+
{
|
|
1591
|
+
"reason": "invalid_namespace_response",
|
|
1592
|
+
"message": f"expected JSON object, got {type(data).__name__}",
|
|
1593
|
+
},
|
|
1594
|
+
)
|
|
1595
|
+
return NamespaceDescription.from_dict(data)
|
|
1596
|
+
|
|
1575
1597
|
async def set_namespace_external_storage(
|
|
1576
1598
|
self,
|
|
1577
1599
|
name: str | None = None,
|
|
@@ -2296,7 +2296,7 @@ def _replay_state(
|
|
|
2296
2296
|
if _is_external_receiver_event(event_type):
|
|
2297
2297
|
explicit_sequence = _workflow_sequence(payload) is not None
|
|
2298
2298
|
if current_wait_id is None:
|
|
2299
|
-
if not explicit_sequence:
|
|
2299
|
+
if prefix_can_bind_to_first_wait or not explicit_sequence:
|
|
2300
2300
|
prefix_receivers.append(index)
|
|
2301
2301
|
continue
|
|
2302
2302
|
|
|
@@ -2562,9 +2562,31 @@ def _replay_state(
|
|
|
2562
2562
|
first = True
|
|
2563
2563
|
pending: list[Command] = []
|
|
2564
2564
|
advanced_cmd: Any = None
|
|
2565
|
+
terminal_condition_reopen_cmd: WaitCondition | None = None
|
|
2566
|
+
|
|
2567
|
+
def _condition_wait_has_pending_receivers(condition_wait_id: str | None) -> bool:
|
|
2568
|
+
if condition_wait_id is None:
|
|
2569
|
+
return False
|
|
2570
|
+
return any(receiver.condition_wait_id == condition_wait_id for receiver in pending_receivers)
|
|
2571
|
+
|
|
2572
|
+
def _consume_terminal_condition_reopens() -> None:
|
|
2573
|
+
nonlocal wait_yield_count
|
|
2574
|
+
if terminal_condition_reopen_cmd is None:
|
|
2575
|
+
return
|
|
2576
|
+
while wait_yield_count < len(wait_opened):
|
|
2577
|
+
opened = wait_opened[wait_yield_count]
|
|
2578
|
+
if not _same_logical_condition_wait(opened, terminal_condition_reopen_cmd):
|
|
2579
|
+
break
|
|
2580
|
+
opened_id = opened.get("condition_wait_id")
|
|
2581
|
+
if not isinstance(opened_id, str):
|
|
2582
|
+
break
|
|
2583
|
+
if opened_id in wait_resolutions or _condition_wait_has_pending_receivers(opened_id):
|
|
2584
|
+
break
|
|
2585
|
+
wait_yield_count += 1
|
|
2565
2586
|
|
|
2566
2587
|
def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
|
|
2567
2588
|
_apply_due_receivers()
|
|
2589
|
+
_consume_terminal_condition_reopens()
|
|
2568
2590
|
commands = list(pending) if include_pending else []
|
|
2569
2591
|
if isinstance(value, ContinueAsNew):
|
|
2570
2592
|
_assert_no_unconsumed_history("continue as new")
|
|
@@ -2652,6 +2674,9 @@ def _replay_state(
|
|
|
2652
2674
|
step = _next_unconsumed_recorded_step()
|
|
2653
2675
|
if step is not None:
|
|
2654
2676
|
_assert_step_matches(cmd, step)
|
|
2677
|
+
# Terminal cleanup may only skip a later open after this
|
|
2678
|
+
# yielded wait has already replayed at least one false reopen.
|
|
2679
|
+
consumed_reopen_for_current_wait = False
|
|
2655
2680
|
while True:
|
|
2656
2681
|
resolution: str | None = None
|
|
2657
2682
|
opened: dict[str, Any] | None = None
|
|
@@ -2666,7 +2691,15 @@ def _replay_state(
|
|
|
2666
2691
|
if isinstance(opened_id, str):
|
|
2667
2692
|
resolution = wait_resolutions.get(opened_id)
|
|
2668
2693
|
_apply_condition_wait_receivers(opened_id)
|
|
2694
|
+
next_wait_index = wait_yield_count + 1
|
|
2695
|
+
has_reopened_same_wait = (
|
|
2696
|
+
opened is not None
|
|
2697
|
+
and next_wait_index < len(wait_opened)
|
|
2698
|
+
and _same_logical_condition_wait(wait_opened[next_wait_index], cmd)
|
|
2699
|
+
)
|
|
2700
|
+
|
|
2669
2701
|
if resolution == "timed_out":
|
|
2702
|
+
terminal_condition_reopen_cmd = None
|
|
2670
2703
|
next_value = False
|
|
2671
2704
|
wait_yield_count += 1
|
|
2672
2705
|
break
|
|
@@ -2677,21 +2710,27 @@ def _replay_state(
|
|
|
2677
2710
|
message=f"wait_condition predicate raised: {exc}",
|
|
2678
2711
|
exception_type=type(exc).__name__,
|
|
2679
2712
|
)])
|
|
2680
|
-
if
|
|
2713
|
+
if resolution == "satisfied":
|
|
2714
|
+
terminal_condition_reopen_cmd = None
|
|
2715
|
+
next_value = True
|
|
2716
|
+
wait_yield_count += 1
|
|
2717
|
+
break
|
|
2718
|
+
if satisfied:
|
|
2719
|
+
terminal_condition_reopen_cmd = (
|
|
2720
|
+
cmd
|
|
2721
|
+
if has_reopened_same_wait and consumed_reopen_for_current_wait
|
|
2722
|
+
else None
|
|
2723
|
+
)
|
|
2681
2724
|
next_value = True
|
|
2682
2725
|
wait_yield_count += 1
|
|
2683
2726
|
break
|
|
2684
2727
|
|
|
2685
|
-
next_wait_index = wait_yield_count + 1
|
|
2686
2728
|
# A single logical wait can be re-opened in history after
|
|
2687
2729
|
# non-satisfying signals. Consume repeated physical opens
|
|
2688
2730
|
# with the same key/fingerprint before declaring the
|
|
2689
2731
|
# logical wait still pending.
|
|
2690
|
-
if
|
|
2691
|
-
|
|
2692
|
-
and next_wait_index < len(wait_opened)
|
|
2693
|
-
and _same_logical_condition_wait(wait_opened[next_wait_index], cmd)
|
|
2694
|
-
):
|
|
2732
|
+
if has_reopened_same_wait:
|
|
2733
|
+
consumed_reopen_for_current_wait = True
|
|
2695
2734
|
wait_yield_count = next_wait_index
|
|
2696
2735
|
continue
|
|
2697
2736
|
|
|
@@ -1080,6 +1080,21 @@ class TestNamespaces:
|
|
|
1080
1080
|
assert result.description == fixture["semantic_body"]["description"]
|
|
1081
1081
|
assert result.retention_days == fixture["semantic_body"]["retention_days"]
|
|
1082
1082
|
|
|
1083
|
+
@pytest.mark.asyncio
|
|
1084
|
+
async def test_delete_namespace_matches_polyglot_fixture(self, client: Client) -> None:
|
|
1085
|
+
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "namespace-delete-parity.json"
|
|
1086
|
+
fixture = json.loads(fixture_path.read_text())
|
|
1087
|
+
resp = _mock_response(200, fixture["response_body"])
|
|
1088
|
+
|
|
1089
|
+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
|
|
1090
|
+
result = await client.delete_namespace(**fixture["sdk_python"]["args"])
|
|
1091
|
+
|
|
1092
|
+
assert mock.call_args.args[:2] == (fixture["request"]["method"], f"/api{fixture['request']['path']}")
|
|
1093
|
+
assert result.name == fixture["semantic_body"]["name"]
|
|
1094
|
+
assert result.status == fixture["semantic_body"]["status"]
|
|
1095
|
+
assert result.deleted is not None
|
|
1096
|
+
assert sorted(result.deleted) == sorted(fixture["semantic_body"]["deleted_keys"])
|
|
1097
|
+
|
|
1083
1098
|
@pytest.mark.asyncio
|
|
1084
1099
|
async def test_set_namespace_external_storage_matches_polyglot_fixture(self, client: Client) -> None:
|
|
1085
1100
|
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "namespace-set-storage-driver-parity.json"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
3
5
|
from durable_workflow import serializer, workflow
|
|
6
|
+
from durable_workflow.errors import NonDeterministicReplayError
|
|
4
7
|
from durable_workflow.workflow import (
|
|
5
8
|
CompleteWorkflow,
|
|
6
9
|
FailWorkflow,
|
|
@@ -169,6 +172,40 @@ class SignalCounterUntilFinished:
|
|
|
169
172
|
return self.current()
|
|
170
173
|
|
|
171
174
|
|
|
175
|
+
@workflow.defn(name="two-same-key-signal-waits")
|
|
176
|
+
class TwoSameKeySignalWaits:
|
|
177
|
+
def __init__(self) -> None:
|
|
178
|
+
self.ready = False
|
|
179
|
+
self.stage = "booting"
|
|
180
|
+
self.events: list[str] = []
|
|
181
|
+
|
|
182
|
+
@workflow.signal("release")
|
|
183
|
+
def release(self, label: str) -> None:
|
|
184
|
+
self.ready = True
|
|
185
|
+
self.events.append(f"signal:{label}")
|
|
186
|
+
|
|
187
|
+
@workflow.query("state")
|
|
188
|
+
def state(self) -> dict[str, object]:
|
|
189
|
+
return {
|
|
190
|
+
"ready": self.ready,
|
|
191
|
+
"stage": self.stage,
|
|
192
|
+
"events": list(self.events),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
196
|
+
self.stage = "first"
|
|
197
|
+
yield ctx.wait_condition(lambda: self.ready, key="gate")
|
|
198
|
+
self.events.append("passed:first")
|
|
199
|
+
|
|
200
|
+
self.ready = False
|
|
201
|
+
self.stage = "second"
|
|
202
|
+
yield ctx.wait_condition(lambda: self.ready, key="gate")
|
|
203
|
+
self.events.append("passed:second")
|
|
204
|
+
|
|
205
|
+
self.stage = "completed"
|
|
206
|
+
return self.state()
|
|
207
|
+
|
|
208
|
+
|
|
172
209
|
class TestCtxWaitCondition:
|
|
173
210
|
def test_wait_condition_returns_dataclass_with_predicate_and_key(self) -> None:
|
|
174
211
|
ctx = WorkflowContext(run_id="x")
|
|
@@ -562,6 +599,341 @@ class TestReplayWaitCondition:
|
|
|
562
599
|
assert outcome.commands[0].result == expected
|
|
563
600
|
assert query_state(SignalCounterUntilFinished, history, [], "current") == expected
|
|
564
601
|
|
|
602
|
+
def test_stale_sequence_after_condition_resolution_binds_to_next_wait(self) -> None:
|
|
603
|
+
history = [
|
|
604
|
+
{
|
|
605
|
+
"event_type": "ConditionWaitOpened",
|
|
606
|
+
"payload": {
|
|
607
|
+
"condition_wait_id": "wait-advance",
|
|
608
|
+
"condition_key": "advance",
|
|
609
|
+
"sequence": 21,
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
_signal_received_event("advance", ["Ada"], workflow_sequence=21),
|
|
613
|
+
{
|
|
614
|
+
"event_type": "ConditionWaitSatisfied",
|
|
615
|
+
"payload": {
|
|
616
|
+
"condition_wait_id": "wait-advance",
|
|
617
|
+
"condition_key": "advance",
|
|
618
|
+
"sequence": 21,
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
_signal_received_event("finish", [], workflow_sequence=21),
|
|
622
|
+
{
|
|
623
|
+
"event_type": "ConditionWaitOpened",
|
|
624
|
+
"payload": {
|
|
625
|
+
"condition_wait_id": "wait-finish",
|
|
626
|
+
"condition_key": "finish",
|
|
627
|
+
"sequence": 22,
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
]
|
|
631
|
+
|
|
632
|
+
expected = {
|
|
633
|
+
"stage": "completed",
|
|
634
|
+
"name": "Ada",
|
|
635
|
+
"finished": True,
|
|
636
|
+
"events": ["started", "signal:Ada", "advanced:Ada", "signal:finish", "finish"],
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
outcome = replay(TwoStageSignalWait, history, [])
|
|
640
|
+
|
|
641
|
+
assert len(outcome.commands) == 1
|
|
642
|
+
assert isinstance(outcome.commands[0], CompleteWorkflow)
|
|
643
|
+
assert outcome.commands[0].result == expected
|
|
644
|
+
assert query_state(TwoStageSignalWait, history, [], "state") == expected
|
|
645
|
+
|
|
646
|
+
def test_terminal_pending_same_key_wait_without_prior_reopen_remains_history(self) -> None:
|
|
647
|
+
history = [
|
|
648
|
+
{
|
|
649
|
+
"event_type": "ConditionWaitOpened",
|
|
650
|
+
"payload": {
|
|
651
|
+
"condition_wait_id": "wait-before-finish",
|
|
652
|
+
"condition_key": "done",
|
|
653
|
+
"sequence": 21,
|
|
654
|
+
"timeout_seconds": 30,
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
"event_type": "TimerScheduled",
|
|
659
|
+
"payload": {
|
|
660
|
+
"timer_kind": "condition_timeout",
|
|
661
|
+
"condition_wait_id": "wait-before-finish",
|
|
662
|
+
"condition_key": "done",
|
|
663
|
+
"sequence": 21,
|
|
664
|
+
"delay_seconds": 30,
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
_signal_received_event("finish", [], workflow_sequence=21),
|
|
668
|
+
{
|
|
669
|
+
"event_type": "ConditionWaitOpened",
|
|
670
|
+
"payload": {
|
|
671
|
+
"condition_wait_id": "wait-after-finish",
|
|
672
|
+
"condition_key": "done",
|
|
673
|
+
"sequence": 22,
|
|
674
|
+
"timeout_seconds": 30,
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
"event_type": "TimerScheduled",
|
|
679
|
+
"payload": {
|
|
680
|
+
"timer_kind": "condition_timeout",
|
|
681
|
+
"condition_wait_id": "wait-after-finish",
|
|
682
|
+
"condition_key": "done",
|
|
683
|
+
"sequence": 22,
|
|
684
|
+
"delay_seconds": 30,
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
with pytest.raises(NonDeterministicReplayError) as exc_info:
|
|
690
|
+
replay(SignalCounterUntilFinished, history, [])
|
|
691
|
+
|
|
692
|
+
assert exc_info.value.workflow_sequence == 22
|
|
693
|
+
assert exc_info.value.expected_shape == "complete workflow"
|
|
694
|
+
assert "ConditionWaitOpened" in exc_info.value.recorded_event_types
|
|
695
|
+
assert "TimerScheduled" in exc_info.value.recorded_event_types
|
|
696
|
+
|
|
697
|
+
def test_terminal_stale_reopened_wait_after_false_reopen_is_consumed(self) -> None:
|
|
698
|
+
history = [
|
|
699
|
+
{
|
|
700
|
+
"event_type": "ConditionWaitOpened",
|
|
701
|
+
"payload": {
|
|
702
|
+
"condition_wait_id": "wait-count-3",
|
|
703
|
+
"condition_key": "done",
|
|
704
|
+
"sequence": 20,
|
|
705
|
+
"timeout_seconds": 30,
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
"event_type": "TimerScheduled",
|
|
710
|
+
"payload": {
|
|
711
|
+
"timer_kind": "condition_timeout",
|
|
712
|
+
"condition_wait_id": "wait-count-3",
|
|
713
|
+
"condition_key": "done",
|
|
714
|
+
"sequence": 20,
|
|
715
|
+
"delay_seconds": 30,
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
_signal_received_event("increment", [3], workflow_sequence=20),
|
|
719
|
+
{
|
|
720
|
+
"event_type": "ConditionWaitOpened",
|
|
721
|
+
"payload": {
|
|
722
|
+
"condition_wait_id": "wait-before-finish",
|
|
723
|
+
"condition_key": "done",
|
|
724
|
+
"sequence": 21,
|
|
725
|
+
"timeout_seconds": 30,
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
"event_type": "TimerScheduled",
|
|
730
|
+
"payload": {
|
|
731
|
+
"timer_kind": "condition_timeout",
|
|
732
|
+
"condition_wait_id": "wait-before-finish",
|
|
733
|
+
"condition_key": "done",
|
|
734
|
+
"sequence": 21,
|
|
735
|
+
"delay_seconds": 30,
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
_signal_received_event("finish", [], workflow_sequence=21),
|
|
739
|
+
{
|
|
740
|
+
"event_type": "ConditionWaitOpened",
|
|
741
|
+
"payload": {
|
|
742
|
+
"condition_wait_id": "wait-after-finish",
|
|
743
|
+
"condition_key": "done",
|
|
744
|
+
"sequence": 22,
|
|
745
|
+
"timeout_seconds": 30,
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
"event_type": "TimerScheduled",
|
|
750
|
+
"payload": {
|
|
751
|
+
"timer_kind": "condition_timeout",
|
|
752
|
+
"condition_wait_id": "wait-after-finish",
|
|
753
|
+
"condition_key": "done",
|
|
754
|
+
"sequence": 22,
|
|
755
|
+
"delay_seconds": 30,
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
]
|
|
759
|
+
|
|
760
|
+
expected = {
|
|
761
|
+
"count": 3,
|
|
762
|
+
"done": True,
|
|
763
|
+
"events": [
|
|
764
|
+
{"signal": "increment", "amount": 3, "count": 3},
|
|
765
|
+
{"signal": "finish", "count": 3},
|
|
766
|
+
],
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
outcome = replay(SignalCounterUntilFinished, history, [])
|
|
770
|
+
|
|
771
|
+
assert len(outcome.commands) == 1
|
|
772
|
+
assert isinstance(outcome.commands[0], CompleteWorkflow)
|
|
773
|
+
assert outcome.commands[0].result == expected
|
|
774
|
+
assert query_state(SignalCounterUntilFinished, history, [], "current") == expected
|
|
775
|
+
|
|
776
|
+
@pytest.mark.parametrize(
|
|
777
|
+
("resolution_event_type", "resolution_payload"),
|
|
778
|
+
[
|
|
779
|
+
(
|
|
780
|
+
"ConditionWaitTimedOut",
|
|
781
|
+
{
|
|
782
|
+
"condition_wait_id": "wait-after-finish",
|
|
783
|
+
"condition_key": "done",
|
|
784
|
+
"sequence": 22,
|
|
785
|
+
},
|
|
786
|
+
),
|
|
787
|
+
(
|
|
788
|
+
"ConditionWaitSatisfied",
|
|
789
|
+
{
|
|
790
|
+
"condition_wait_id": "wait-after-finish",
|
|
791
|
+
"condition_key": "done",
|
|
792
|
+
"sequence": 22,
|
|
793
|
+
},
|
|
794
|
+
),
|
|
795
|
+
(
|
|
796
|
+
"TimerFired",
|
|
797
|
+
{
|
|
798
|
+
"timer_kind": "condition_timeout",
|
|
799
|
+
"condition_wait_id": "wait-after-finish",
|
|
800
|
+
"condition_key": "done",
|
|
801
|
+
"sequence": 22,
|
|
802
|
+
},
|
|
803
|
+
),
|
|
804
|
+
],
|
|
805
|
+
)
|
|
806
|
+
def test_terminal_reopened_wait_with_resolution_remains_replay_history(
|
|
807
|
+
self,
|
|
808
|
+
resolution_event_type: str,
|
|
809
|
+
resolution_payload: dict[str, object],
|
|
810
|
+
) -> None:
|
|
811
|
+
history = [
|
|
812
|
+
{
|
|
813
|
+
"event_type": "ConditionWaitOpened",
|
|
814
|
+
"payload": {
|
|
815
|
+
"condition_wait_id": "wait-before-finish",
|
|
816
|
+
"condition_key": "done",
|
|
817
|
+
"sequence": 21,
|
|
818
|
+
"timeout_seconds": 30,
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
"event_type": "TimerScheduled",
|
|
823
|
+
"payload": {
|
|
824
|
+
"timer_kind": "condition_timeout",
|
|
825
|
+
"condition_wait_id": "wait-before-finish",
|
|
826
|
+
"condition_key": "done",
|
|
827
|
+
"sequence": 21,
|
|
828
|
+
"delay_seconds": 30,
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
_signal_received_event("finish", [], workflow_sequence=21),
|
|
832
|
+
{
|
|
833
|
+
"event_type": "ConditionWaitOpened",
|
|
834
|
+
"payload": {
|
|
835
|
+
"condition_wait_id": "wait-after-finish",
|
|
836
|
+
"condition_key": "done",
|
|
837
|
+
"sequence": 22,
|
|
838
|
+
"timeout_seconds": 30,
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
"event_type": "TimerScheduled",
|
|
843
|
+
"payload": {
|
|
844
|
+
"timer_kind": "condition_timeout",
|
|
845
|
+
"condition_wait_id": "wait-after-finish",
|
|
846
|
+
"condition_key": "done",
|
|
847
|
+
"sequence": 22,
|
|
848
|
+
"delay_seconds": 30,
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
"event_type": resolution_event_type,
|
|
853
|
+
"payload": resolution_payload,
|
|
854
|
+
},
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
with pytest.raises(NonDeterministicReplayError) as exc_info:
|
|
858
|
+
replay(SignalCounterUntilFinished, history, [])
|
|
859
|
+
|
|
860
|
+
assert exc_info.value.workflow_sequence == 22
|
|
861
|
+
assert exc_info.value.expected_shape == "complete workflow"
|
|
862
|
+
assert "ConditionWaitOpened" in exc_info.value.recorded_event_types
|
|
863
|
+
assert resolution_event_type in exc_info.value.recorded_event_types
|
|
864
|
+
|
|
865
|
+
def test_sequential_same_key_waits_do_not_consume_second_wait_as_reopen(self) -> None:
|
|
866
|
+
history = [
|
|
867
|
+
{
|
|
868
|
+
"event_type": "ConditionWaitOpened",
|
|
869
|
+
"payload": {
|
|
870
|
+
"condition_wait_id": "wait-first",
|
|
871
|
+
"condition_key": "gate",
|
|
872
|
+
"sequence": 21,
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
_signal_received_event("release", ["first"], workflow_sequence=21),
|
|
876
|
+
{
|
|
877
|
+
"event_type": "ConditionWaitOpened",
|
|
878
|
+
"payload": {
|
|
879
|
+
"condition_wait_id": "wait-second",
|
|
880
|
+
"condition_key": "gate",
|
|
881
|
+
"sequence": 22,
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
_signal_received_event("release", ["second"], workflow_sequence=22),
|
|
885
|
+
]
|
|
886
|
+
|
|
887
|
+
expected = {
|
|
888
|
+
"ready": True,
|
|
889
|
+
"stage": "completed",
|
|
890
|
+
"events": [
|
|
891
|
+
"signal:first",
|
|
892
|
+
"passed:first",
|
|
893
|
+
"signal:second",
|
|
894
|
+
"passed:second",
|
|
895
|
+
],
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
outcome = replay(TwoSameKeySignalWaits, history, [])
|
|
899
|
+
|
|
900
|
+
assert len(outcome.commands) == 1
|
|
901
|
+
assert isinstance(outcome.commands[0], CompleteWorkflow)
|
|
902
|
+
assert outcome.commands[0].result == expected
|
|
903
|
+
assert query_state(TwoSameKeySignalWaits, history, [], "state") == expected
|
|
904
|
+
|
|
905
|
+
def test_pending_sequential_same_key_wait_replays_as_pending_wait(self) -> None:
|
|
906
|
+
history = [
|
|
907
|
+
{
|
|
908
|
+
"event_type": "ConditionWaitOpened",
|
|
909
|
+
"payload": {
|
|
910
|
+
"condition_wait_id": "wait-first",
|
|
911
|
+
"condition_key": "gate",
|
|
912
|
+
"sequence": 21,
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
_signal_received_event("release", ["first"], workflow_sequence=21),
|
|
916
|
+
{
|
|
917
|
+
"event_type": "ConditionWaitOpened",
|
|
918
|
+
"payload": {
|
|
919
|
+
"condition_wait_id": "wait-second",
|
|
920
|
+
"condition_key": "gate",
|
|
921
|
+
"sequence": 22,
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
]
|
|
925
|
+
|
|
926
|
+
outcome = replay(TwoSameKeySignalWaits, history, [])
|
|
927
|
+
|
|
928
|
+
assert len(outcome.commands) == 1
|
|
929
|
+
assert isinstance(outcome.commands[0], WaitCondition)
|
|
930
|
+
assert outcome.commands[0].condition_key == "gate"
|
|
931
|
+
assert query_state(TwoSameKeySignalWaits, history, [], "state") == {
|
|
932
|
+
"ready": False,
|
|
933
|
+
"stage": "second",
|
|
934
|
+
"events": ["signal:first", "passed:first"],
|
|
935
|
+
}
|
|
936
|
+
|
|
565
937
|
def test_repeated_wait_after_activity_can_be_satisfied_by_later_signal(self) -> None:
|
|
566
938
|
history = [
|
|
567
939
|
{
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/auth_composition.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_storage.py
RENAMED
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/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.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-0.4.63 → durable_workflow-0.4.65}/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.63 → durable_workflow-0.4.65}/tests/test_standalone_activity_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|