Skip to content

Commit 071c1b6

Browse files
Live Cloud Python replay mistakes a condition timer for an activity and never completes
1 parent 7cbd354 commit 071c1b6

5 files changed

Lines changed: 434 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9-
The Python SDK advances to the synchronized Durable Workflow `2.0.0-rc.6`
10-
product train. PyPI displays the normalized PEP 440 identity `2.0.0rc6`. Earlier SDK
9+
The Python SDK advances to the synchronized Durable Workflow `2.0.0-rc.7`
10+
product train. PyPI displays the normalized PEP 440 identity `2.0.0rc7`. Earlier SDK
1111
versions remain historical releases and are not alternate supported 2.0
1212
baselines; no prerelease compatibility shim is provided.
1313

@@ -50,6 +50,9 @@ baselines; no prerelease compatibility shim is provided.
5050
`NamespaceDescription.deleted`.
5151

5252
### Fixed
53+
- Condition-timeout history is now identified by its durable wait identity as
54+
well as its timer kind, keeping internal timeout rows out of the ordinary
55+
activity cursor when updates and signals resolve a timed condition.
5356
- Worker registration now advertises each workflow's declared signal, query,
5457
and update contract with typed parameter metadata, allowing the server to
5558
admit declared Python workflow updates while preserving typed refusals.

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
A Python SDK for the [Durable Workflow server](https://github.com/durable-workflow/server). Speaks the server's language-neutral HTTP/JSON worker protocol — no PHP runtime required.
44

55
Status: **Beta** — this SDK is part of the synchronized Durable Workflow
6-
`2.0.0-rc.6` product train. Core features include workflows, activities,
6+
`2.0.0-rc.7` product train. Core features include workflows, activities,
77
schedules, signals, timers, child workflows, continue-as-new, side effects,
88
version markers, worker-applied accepted updates, replay verification, the
99
in-process `WorkflowEnvironment` test harness, and invocable activity carriers.
1010

1111
## Install
1212

1313
```bash
14-
pip install durable-workflow==2.0.0-rc.6
14+
pip install durable-workflow==2.0.0-rc.7
1515
```
1616

1717
Or for development:
@@ -585,7 +585,7 @@ Full documentation is available at
585585

586586
## Compatibility
587587

588-
SDK version `2.0.0-rc.6` is supported with server `2.0.0-rc.6`. The server
588+
SDK version `2.0.0-rc.7` is supported with server `2.0.0-rc.7`. The server
589589
must advertise these protocol manifests from `GET /api/cluster/info`:
590590

591591
- `control_plane.version: "2"`

‎pyproject.toml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "durable-workflow"
7-
version = "2.0.0-rc.6"
7+
version = "2.0.0-rc.7"
88
description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -64,9 +64,9 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
6464
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
6565

6666
[tool.durable-workflow]
67-
product-train = "2.0.0-rc.6"
68-
registry-version = "2.0.0rc6"
69-
supported-server-versions = "2.0.0-rc.6"
67+
product-train = "2.0.0-rc.7"
68+
registry-version = "2.0.0rc7"
69+
supported-server-versions = "2.0.0-rc.7"
7070
worker-protocol-version = "1.1"
7171
control-plane-version = "2"
7272

‎src/durable_workflow/workflow.py‎

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2080,6 +2080,42 @@ def _workflow_sequence(payload: Mapping[str, Any]) -> int | None:
20802080
return None
20812081

20822082

2083+
def _internal_timeout_timer_kind(
2084+
payload: Mapping[str, Any],
2085+
condition_wait_ids_by_sequence: Mapping[int, str] | None = None,
2086+
) -> str | None:
2087+
"""Identify internal wait timers without adding them to the command cursor."""
2088+
timer_kind = payload.get("timer_kind")
2089+
if timer_kind in ("condition_timeout", "signal_timeout"):
2090+
return str(timer_kind)
2091+
if _optional_str(payload.get("condition_wait_id")) is not None:
2092+
return "condition_timeout"
2093+
if _optional_str(payload.get("signal_wait_id")) is not None:
2094+
return "signal_timeout"
2095+
2096+
sequence = _workflow_sequence(payload)
2097+
if (
2098+
sequence is not None
2099+
and condition_wait_ids_by_sequence is not None
2100+
and sequence in condition_wait_ids_by_sequence
2101+
):
2102+
return "condition_timeout"
2103+
return None
2104+
2105+
2106+
def _condition_timeout_wait_id(
2107+
payload: Mapping[str, Any],
2108+
condition_wait_ids_by_sequence: Mapping[int, str],
2109+
) -> str | None:
2110+
if _internal_timeout_timer_kind(payload, condition_wait_ids_by_sequence) != "condition_timeout":
2111+
return None
2112+
wait_id = _optional_str(payload.get("condition_wait_id"))
2113+
if wait_id is not None:
2114+
return wait_id
2115+
sequence = _workflow_sequence(payload)
2116+
return condition_wait_ids_by_sequence.get(sequence) if sequence is not None else None
2117+
2118+
20832119
def _activity_type_from_payload(payload: Mapping[str, Any]) -> str | None:
20842120
activity_type = _optional_str(payload.get("activity_type"))
20852121
if activity_type is not None:
@@ -2121,7 +2157,11 @@ def _recorded_step_details(payload: Mapping[str, Any]) -> dict[str, Any]:
21212157
return details
21222158

21232159

2124-
def _is_resolved_step_event(event_type: str | None, payload: Mapping[str, Any]) -> bool:
2160+
def _is_resolved_step_event(
2161+
event_type: str | None,
2162+
payload: Mapping[str, Any],
2163+
condition_wait_ids_by_sequence: Mapping[int, str] | None = None,
2164+
) -> bool:
21252165
if event_type in (
21262166
"ActivityCompleted",
21272167
"ActivityFailed",
@@ -2136,7 +2176,7 @@ def _is_resolved_step_event(event_type: str | None, payload: Mapping[str, Any])
21362176
):
21372177
return True
21382178
if event_type == "TimerFired":
2139-
return payload.get("timer_kind") not in ("condition_timeout", "signal_timeout")
2179+
return _internal_timeout_timer_kind(payload, condition_wait_ids_by_sequence) is None
21402180
return False
21412181

21422182

@@ -2347,13 +2387,24 @@ def _replay_state(
23472387
if event_type is not None:
23482388
event_types_by_sequence.setdefault(sequence, []).append(event_type)
23492389
details_by_sequence.setdefault(sequence, {}).update(_recorded_step_details(payload))
2350-
if _is_resolved_step_event(event_type, payload):
2351-
resolved_sequences.add(sequence)
23522390
if event_type == "ConditionWaitOpened":
23532391
wait_id = payload.get("condition_wait_id")
23542392
if isinstance(wait_id, str) and wait_id:
23552393
condition_wait_ids_by_sequence[sequence] = wait_id
23562394

2395+
for event in events:
2396+
event_type = _history_event_type(event)
2397+
payload = event.get("payload") or {}
2398+
if not isinstance(payload, Mapping):
2399+
payload = {}
2400+
sequence = _workflow_sequence(payload)
2401+
if sequence is not None and _is_resolved_step_event(
2402+
event_type,
2403+
payload,
2404+
condition_wait_ids_by_sequence,
2405+
):
2406+
resolved_sequences.add(sequence)
2407+
23572408
workflow_start_time: datetime | None = None
23582409
for ev in events:
23592410
etype = _history_event_type(ev)
@@ -2395,6 +2446,7 @@ def _state(commands: list[Command]) -> _ReplayState:
23952446
# in history, future server-recorded) or 'timed_out' (from a matching
23962447
# condition_timeout TimerFired event).
23972448
wait_resolutions: dict[str, str] = {}
2449+
23982450
def _append_resolved_result(value: Any, shape: str, event: Mapping[str, Any]) -> None:
23992451
payload = event.get("payload") or {}
24002452
if not isinstance(payload, Mapping):
@@ -2519,7 +2571,11 @@ def _receiver_binding_boundary_kind(event_type: str | None, payload: Mapping[str
25192571
return "condition"
25202572
if (
25212573
event_type in ("TimerFired", "TimerCancelled")
2522-
and payload.get("timer_kind") == "condition_timeout"
2574+
and _internal_timeout_timer_kind(
2575+
payload,
2576+
condition_wait_ids_by_sequence,
2577+
)
2578+
== "condition_timeout"
25232579
):
25242580
return "condition"
25252581
if event_type in (
@@ -2533,8 +2589,24 @@ def _receiver_binding_boundary_kind(event_type: str | None, payload: Mapping[str
25332589
):
25342590
return "step"
25352591
if event_type == "TimerScheduled":
2536-
return "step" if payload.get("timer_kind") not in ("condition_timeout", "signal_timeout") else None
2537-
return "step" if _is_resolved_step_event(event_type, payload) else None
2592+
return (
2593+
"step"
2594+
if _internal_timeout_timer_kind(
2595+
payload,
2596+
condition_wait_ids_by_sequence,
2597+
)
2598+
is None
2599+
else None
2600+
)
2601+
return (
2602+
"step"
2603+
if _is_resolved_step_event(
2604+
event_type,
2605+
payload,
2606+
condition_wait_ids_by_sequence,
2607+
)
2608+
else None
2609+
)
25382610

25392611
def _receiver_condition_wait_bindings() -> dict[int, str | None]:
25402612
bindings: dict[int, str | None] = {}
@@ -2635,18 +2707,27 @@ def _receiver_condition_wait_bindings() -> dict[int, str | None]:
26352707
elif etype in ("ActivityScheduled", "ActivityStarted"):
26362708
_append_pending_step("activity", ev)
26372709
elif etype == "TimerFired":
2638-
timer_kind = payload.get("timer_kind")
2710+
timer_kind = _internal_timeout_timer_kind(
2711+
payload,
2712+
condition_wait_ids_by_sequence,
2713+
)
26392714
if timer_kind == "condition_timeout":
2640-
wait_id = payload.get("condition_wait_id")
2641-
if isinstance(wait_id, str) and wait_id:
2715+
wait_id = _condition_timeout_wait_id(
2716+
payload,
2717+
condition_wait_ids_by_sequence,
2718+
)
2719+
if wait_id is not None:
26422720
wait_resolutions[wait_id] = "timed_out"
26432721
continue
26442722
if timer_kind == "signal_timeout":
26452723
continue
26462724
_append_resolved_result(None, "timer", ev)
26472725
elif etype == "TimerScheduled":
2648-
timer_kind = payload.get("timer_kind")
2649-
if timer_kind not in ("condition_timeout", "signal_timeout"):
2726+
timer_kind = _internal_timeout_timer_kind(
2727+
payload,
2728+
condition_wait_ids_by_sequence,
2729+
)
2730+
if timer_kind is None:
26502731
_append_pending_step("timer", ev)
26512732
elif etype == "ConditionWaitOpened":
26522733
wait_id = payload.get("condition_wait_id")
@@ -3054,12 +3135,12 @@ def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
30543135
wait_yield_count = next_wait_index
30553136
return _state(pending)
30563137
continue
3057-
if isinstance(cmd, (ScheduleActivity, StartTimer, StartChildWorkflow)):
3138+
if isinstance(cmd, ScheduleActivity | StartTimer | StartChildWorkflow):
30583139
if result_cursor < len(resolved_results):
30593140
_assert_next_step_matches(cmd)
30603141
val = resolved_results[result_cursor]
30613142
result_cursor += 1
3062-
if isinstance(val, (ActivityFailed, ChildWorkflowFailed)):
3143+
if isinstance(val, ActivityFailed | ChildWorkflowFailed):
30633144
try:
30643145
advanced_cmd = gen.throw(val)
30653146
continue

0 commit comments

Comments
 (0)