Skip to content

Commit 7da889c

Browse files
authored
Merge pull request #122 from durable-workflow/fix-stable-python-task-polls
Publish Python 2.5.6 task-family polling fix
2 parents 095a00d + a3c80fb commit 7da889c

6 files changed

Lines changed: 240 additions & 12 deletions

File tree

‎pyproject.toml‎

Lines changed: 3 additions & 3 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.5.5"
7+
version = "2.5.6"
88
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
7171
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
7272

7373
[tool.durable-workflow]
74-
product-train = "2.5.5"
75-
registry-version = "2.5.5"
74+
product-train = "2.5.6"
75+
registry-version = "2.5.6"
7676
supported-server-versions = "2.5.16"
7777
worker-protocol-version = "1.19"
7878
control-plane-version = "2"

‎src/durable_workflow/worker.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4077,11 +4077,12 @@ async def run(self) -> None:
40774077
try:
40784078
if self._stop.is_set():
40794079
return
4080-
wf_loop = asyncio.create_task(self._poll_workflow_tasks())
4081-
act_loop = asyncio.create_task(self._poll_activity_tasks())
4082-
hb_loop = asyncio.create_task(self._heartbeat_loop())
4083-
loops = [wf_loop, act_loop, hb_loop]
4084-
if self._query_tasks_supported:
4080+
loops = [asyncio.create_task(self._heartbeat_loop())]
4081+
if self.workflows:
4082+
loops.append(asyncio.create_task(self._poll_workflow_tasks()))
4083+
if self.activities:
4084+
loops.append(asyncio.create_task(self._poll_activity_tasks()))
4085+
if self.workflows and self._query_tasks_supported:
40854086
if self._can_clone_client_for_query_tasks():
40864087
self._start_query_task_thread()
40874088
else:
@@ -4243,7 +4244,7 @@ async def run_until(
42434244
if self._stop.is_set():
42444245
raise asyncio.CancelledError
42454246
background_tasks.append(asyncio.create_task(self._heartbeat_loop()))
4246-
if self._query_tasks_supported:
4247+
if self.workflows and self._query_tasks_supported:
42474248
if self._can_clone_client_for_query_tasks():
42484249
self._start_query_task_thread()
42494250
else:
@@ -4295,7 +4296,7 @@ async def _run_until_loop(
42954296
f"workflow {workflow_id} not terminal after {timeout}s (status={desc.status})"
42964297
)
42974298

4298-
if next_task_kind == "workflow":
4299+
if self.workflows and (next_task_kind == "workflow" or not self.activities):
42994300
await self._reserve_workflow_capacity()
43004301
try:
43014302
poll_start = time.perf_counter()
@@ -4347,6 +4348,9 @@ async def _run_until_loop(
43474348
next_task_kind = "workflow"
43484349
continue
43494350

4351+
if not self.activities:
4352+
await asyncio.sleep(poll_interval)
4353+
continue
43504354
self._validate_cooperative_activity_handlers()
43514355
if len(self._remote_activity_processes) >= self.max_concurrent_activity_tasks:
43524356
next_task_kind = "workflow"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
3+
"fixture_schema": "durable-workflow.replay-regression/v1",
4+
"id": "python-workflow-only-worker-resumes-external-activity",
5+
"protocol_version": "1.19",
6+
"bindings": ["python"],
7+
"workflow": {
8+
"type": "golden.single-activity",
9+
"input": ["external-worker"],
10+
"payload_codec": "avro"
11+
},
12+
"history": [
13+
{"event_type": "WorkflowStarted", "payload": {"payload_codec": "avro"}},
14+
{"event_type": "ActivityScheduled", "payload": {"sequence": 1, "activity_type": "golden.greet"}},
15+
{"event_type": "ActivityCompleted", "payload": {
16+
"sequence": 1,
17+
"payload_codec": "avro",
18+
"result": "wwHioz3/VYAiNwooZWNobzpleHRlcm5hbC13b3JrZXI="
19+
}}
20+
],
21+
"expected": {"type": "complete_workflow", "result": "echo:external-worker"}
22+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Exercise ordinary workers and count their real HTTP poll requests."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import importlib.metadata
7+
import json
8+
import uuid
9+
from collections import Counter
10+
from typing import Any
11+
12+
import httpx
13+
import pytest
14+
15+
from durable_workflow import Client, Worker, activity, workflow
16+
17+
18+
@activity.defn(name="tests.task-family.echo")
19+
async def echo(value: str) -> str:
20+
return f"echo:{value}"
21+
22+
23+
@workflow.defn(name="tests.task-family.activity")
24+
class ActivityWorkflow:
25+
def run(self, ctx: Any, value: str) -> Any:
26+
return (yield ctx.schedule_activity("tests.task-family.echo", [value]))
27+
28+
29+
@workflow.defn(name="tests.task-family.timer")
30+
class TimerWorkflow:
31+
def run(self, ctx: Any, value: str) -> Any:
32+
yield ctx.sleep(2)
33+
return f"timer:{value}"
34+
35+
36+
def record_polls(client: Client) -> Counter[str]:
37+
counts: Counter[str] = Counter()
38+
39+
async def record(request: httpx.Request) -> None:
40+
for family in ("workflow", "activity", "query"):
41+
if request.url.path.endswith(f"/worker/{family}-tasks/poll"):
42+
counts[family] += 1
43+
44+
client._http.event_hooks["request"].append(record)
45+
return counts
46+
47+
48+
@pytest.mark.asyncio
49+
@pytest.mark.parametrize("mode", ["split", "mixed", "timer"])
50+
@pytest.mark.parametrize("entry_point", ["run", "run_until"])
51+
async def test_workers_execute_without_polling_empty_task_families(
52+
server_url: str, server_token: str, mode: str, entry_point: str,
53+
) -> None:
54+
suffix = uuid.uuid4().hex[:12]
55+
queue = f"task-family-{suffix}"
56+
async with (
57+
Client(server_url, token=server_token, namespace="default") as control,
58+
Client(server_url, token=server_token, namespace="default") as workflow_client,
59+
Client(server_url, token=server_token, namespace="default") as activity_client,
60+
):
61+
workflow_polls = record_polls(workflow_client)
62+
activity_polls = record_polls(activity_client)
63+
workflow_definition = TimerWorkflow if mode == "timer" else ActivityWorkflow
64+
worker = Worker(
65+
workflow_client, task_queue=queue,
66+
workflows=[workflow_definition],
67+
activities=[echo] if mode == "mixed" else [],
68+
poll_timeout=0.5, shutdown_timeout=10,
69+
)
70+
workers = [worker]
71+
if mode == "split":
72+
workers.append(Worker(
73+
activity_client, task_queue=queue, workflows=[], activities=[echo],
74+
poll_timeout=0.5, shutdown_timeout=10,
75+
))
76+
77+
handle = await control.start_workflow(
78+
workflow_type="tests.task-family.timer" if mode == "timer" else "tests.task-family.activity",
79+
task_queue=queue, workflow_id=f"task-family-{suffix}", input=[suffix],
80+
)
81+
runners = [asyncio.create_task(
82+
selected.run() if entry_point == "run" else selected.run_until(
83+
workflow_id=handle.workflow_id, timeout=30, poll_interval=0.05,
84+
)
85+
) for selected in workers]
86+
try:
87+
expected = f"timer:{suffix}" if mode == "timer" else f"echo:{suffix}"
88+
assert await handle.result(timeout=30, poll_interval=0.1) == expected
89+
if entry_point == "run_until":
90+
terminal = await asyncio.wait_for(asyncio.gather(*runners), timeout=10)
91+
assert all((result.status or "").lower() == "completed" for result in terminal)
92+
finally:
93+
await asyncio.gather(*(selected.stop() for selected in workers))
94+
await asyncio.wait_for(asyncio.gather(*runners), timeout=10)
95+
96+
assert workflow_polls["workflow"] > 0
97+
assert (workflow_polls["activity"] > 0) is (mode == "mixed")
98+
if mode == "split":
99+
assert activity_polls["activity"] > 0
100+
assert activity_polls["workflow"] == 0
101+
assert activity_polls["query"] == 0
102+
print(json.dumps({
103+
"sdk_version": importlib.metadata.version("durable-workflow"),
104+
"mode": mode, "entry_point": entry_point, "workflow_id": handle.workflow_id,
105+
"workflow_worker_polls": dict(workflow_polls),
106+
"activity_worker_polls": dict(activity_polls), "outcome": "pass",
107+
}), flush=True)

‎tests/test_replay_regression_corpus.py‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010

1111
from durable_workflow import Replayer, Worker, serializer, workflow
12-
from durable_workflow.client import Client, WorkflowStreamAppendItem
12+
from durable_workflow.client import PROTOCOL_VERSION, Client, WorkflowExecution, WorkflowStreamAppendItem
1313
from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled, WorkflowPayloadDecodeError
1414
from durable_workflow.workflow import (
1515
LocalActivityExecutionAborted,
@@ -31,6 +31,7 @@
3131
PostConditionReceiversWorkflow,
3232
UpdateSignalConditionTimerWorkflow,
3333
)
34+
from tests.test_worker import compatible_cluster_info
3435

3536
FIXTURE_SCHEMA = "durable-workflow.replay-regression/v1"
3637
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "replay_regressions"
@@ -489,6 +490,46 @@ def test_checked_in_replay_regression_corpus_uses_official_replayer(
489490
_execute_fixture(fixture)
490491

491492

493+
@pytest.mark.asyncio
494+
async def test_workflow_only_driver_resumes_external_activity_without_activity_polling() -> None:
495+
fixture = json.loads(
496+
(FIXTURE_DIR / "workflow-only-worker-resumes-external-activity.json").read_text(encoding="utf-8")
497+
)
498+
client = AsyncMock(spec=Client)
499+
client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={"version": PROTOCOL_VERSION})
500+
client.register_worker.return_value = {"registered": True}
501+
client.deregister_worker_registration.return_value = {"outcome": "deregistered"}
502+
client.poll_activity_task.side_effect = AssertionError("workflow-only worker must not poll activities")
503+
client.poll_workflow_task.side_effect = [None, {
504+
"task_id": "external-activity-resume-task", "workflow_id": "external-activity-caller",
505+
"run_id": "original-run", "workflow_type": fixture["workflow"]["type"],
506+
"workflow_task_attempt": 1, "history_events": fixture["history"],
507+
"arguments": serializer.encode(fixture["workflow"]["input"], codec="avro"),
508+
"payload_codec": "avro",
509+
}]
510+
client.complete_workflow_task.return_value = {"outcome": "completed"}
511+
512+
async def describe(workflow_id: str) -> WorkflowExecution:
513+
return WorkflowExecution(
514+
workflow_id=workflow_id, run_id="original-run", workflow_type=fixture["workflow"]["type"],
515+
status="completed" if client.complete_workflow_task.await_count else "running",
516+
)
517+
518+
client.describe_workflow.side_effect = describe
519+
worker = Worker(client, task_queue="external-activity", workflows=[GoldenSingleActivityWorkflow], activities=[])
520+
521+
result = await worker.run_until(workflow_id="external-activity-caller", timeout=1, poll_interval=0.01)
522+
523+
assert result.status == "completed"
524+
assert result.run_id == "original-run"
525+
assert client.poll_workflow_task.await_count == 2
526+
client.poll_activity_task.assert_not_awaited()
527+
client.complete_workflow_task.assert_awaited_once()
528+
commands = client.complete_workflow_task.call_args.kwargs["commands"]
529+
assert [command["type"] for command in commands] == [fixture["expected"]["type"]]
530+
assert serializer.decode_envelope(commands[0]["result"]) == fixture["expected"]["result"]
531+
532+
492533
@pytest.mark.asyncio
493534
async def test_worker_replays_recorded_side_effects_across_history_pages() -> None:
494535
fixture = json.loads((FIXTURE_DIR / "paged-recorded-side-effects.json").read_text(encoding="utf-8"))

‎tests/test_worker.py‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4403,6 +4403,8 @@ async def request(method: str, path: str, **kwargs: object) -> httpx.Response:
44034403
client,
44044404
task_queue="orders",
44054405
worker_id="worker-only",
4406+
workflows=[TestWorkflow],
4407+
activities=[echo_activity],
44064408
poll_timeout=0.01,
44074409
heartbeat_interval=0.01,
44084410
)
@@ -4597,7 +4599,7 @@ async def test_worker_loop_error_remains_primary_when_deregistration_fails(self,
45974599
},
45984600
)
45994601
mock_client.deregister_worker_registration.side_effect = cleanup_error
4600-
worker = Worker(mock_client, task_queue="q1")
4602+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow])
46014603
worker._poll_workflow_tasks = AsyncMock( # type: ignore[method-assign]
46024604
side_effect=RuntimeError("worker loop failed")
46034605
)
@@ -4729,6 +4731,31 @@ async def poll_query_task(self, **_: object) -> None:
47294731

47304732

47314733
class TestPollLoops:
4734+
@pytest.mark.asyncio
4735+
@pytest.mark.parametrize("has_workflows,has_activities", [(True, False), (False, True), (False, False)])
4736+
async def test_run_polls_only_registered_task_families(
4737+
self, mock_client: AsyncMock, has_workflows: bool, has_activities: bool,
4738+
) -> None:
4739+
worker = Worker(
4740+
mock_client,
4741+
task_queue="q1",
4742+
workflows=[TestWorkflow] if has_workflows else [],
4743+
activities=[echo_activity] if has_activities else [],
4744+
poll_timeout=0.01,
4745+
)
4746+
run_task = asyncio.create_task(worker.run())
4747+
try:
4748+
await asyncio.sleep(0.05)
4749+
finally:
4750+
await worker.stop()
4751+
await run_task
4752+
4753+
assert bool(mock_client.poll_workflow_task.await_count) is has_workflows
4754+
assert bool(mock_client.poll_activity_task.await_count) is has_activities
4755+
assert bool(mock_client.poll_query_task.await_count) is has_workflows
4756+
mock_client.register_worker.assert_awaited_once()
4757+
mock_client.deregister_worker_registration.assert_awaited_once_with(worker.worker_id)
4758+
47324759
@pytest.mark.asyncio
47334760
async def test_run_starts_both_loops(self, mock_client: AsyncMock) -> None:
47344761
worker = Worker(
@@ -4990,6 +5017,33 @@ def test_process_metrics_memory_bytes_is_current_resident_set(
49905017

49915018

49925019
class TestRunUntil:
5020+
@pytest.mark.asyncio
5021+
@pytest.mark.parametrize("has_workflows,has_activities", [(True, False), (False, True), (False, False)])
5022+
async def test_run_until_polls_only_registered_task_families(
5023+
self, mock_client: AsyncMock, has_workflows: bool, has_activities: bool,
5024+
) -> None:
5025+
mock_client.describe_workflow = AsyncMock(side_effect=[
5026+
WorkflowExecution(workflow_id="wf-1", run_id="run-1", workflow_type="test-wf", status="running"),
5027+
WorkflowExecution(workflow_id="wf-1", run_id="run-1", workflow_type="test-wf", status="running"),
5028+
WorkflowExecution(workflow_id="wf-1", run_id="run-1", workflow_type="test-wf", status="completed"),
5029+
])
5030+
worker = Worker(
5031+
mock_client,
5032+
task_queue="q1",
5033+
workflows=[TestWorkflow] if has_workflows else [],
5034+
activities=[echo_activity] if has_activities else [],
5035+
poll_timeout=0.01,
5036+
)
5037+
5038+
result = await worker.run_until(workflow_id="wf-1", timeout=1.0, poll_interval=0.01)
5039+
5040+
assert result.status == "completed"
5041+
assert bool(mock_client.poll_workflow_task.await_count) is has_workflows
5042+
assert bool(mock_client.poll_activity_task.await_count) is has_activities
5043+
if not has_workflows:
5044+
mock_client.poll_query_task.assert_not_awaited()
5045+
mock_client.deregister_worker_registration.assert_awaited_once_with(worker.worker_id)
5046+
49935047
@pytest.mark.asyncio
49945048
async def test_run_until_returns_terminal_description(self, mock_client: AsyncMock) -> None:
49955049
mock_client.describe_workflow = AsyncMock(

0 commit comments

Comments
 (0)