Skip to content

Commit 5c540ba

Browse files
authored
Merge pull request #121 from durable-workflow/fix/119-task-family-polling
Stop polling unregistered task families
2 parents 96b8cd7 + 3cfb561 commit 5c540ba

5 files changed

Lines changed: 237 additions & 9 deletions

File tree

‎src/durable_workflow/worker.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3956,11 +3956,12 @@ async def run(self) -> None:
39563956
try:
39573957
if self._stop.is_set():
39583958
return
3959-
wf_loop = asyncio.create_task(self._poll_workflow_tasks())
3960-
act_loop = asyncio.create_task(self._poll_activity_tasks())
3961-
hb_loop = asyncio.create_task(self._heartbeat_loop())
3962-
loops = [wf_loop, act_loop, hb_loop]
3963-
if self._query_tasks_supported:
3959+
loops = [asyncio.create_task(self._heartbeat_loop())]
3960+
if self.workflows:
3961+
loops.append(asyncio.create_task(self._poll_workflow_tasks()))
3962+
if self.activities:
3963+
loops.append(asyncio.create_task(self._poll_activity_tasks()))
3964+
if self.workflows and self._query_tasks_supported:
39643965
if self._can_clone_client_for_query_tasks():
39653966
self._start_query_task_thread()
39663967
else:
@@ -4122,7 +4123,7 @@ async def run_until(
41224123
if self._stop.is_set():
41234124
raise asyncio.CancelledError
41244125
background_tasks.append(asyncio.create_task(self._heartbeat_loop()))
4125-
if self._query_tasks_supported:
4126+
if self.workflows and self._query_tasks_supported:
41264127
if self._can_clone_client_for_query_tasks():
41274128
self._start_query_task_thread()
41284129
else:
@@ -4174,7 +4175,7 @@ async def _run_until_loop(
41744175
f"workflow {workflow_id} not terminal after {timeout}s (status={desc.status})"
41754176
)
41764177

4177-
if next_task_kind == "workflow":
4178+
if self.workflows and (next_task_kind == "workflow" or not self.activities):
41784179
await self._reserve_workflow_capacity()
41794180
try:
41804181
poll_start = time.perf_counter()
@@ -4226,6 +4227,9 @@ async def _run_until_loop(
42264227
next_task_kind = "workflow"
42274228
continue
42284229

4230+
if not self.activities:
4231+
await asyncio.sleep(poll_interval)
4232+
continue
42294233
self._validate_cooperative_activity_handlers()
42304234
if len(self._remote_activity_processes) >= self.max_concurrent_activity_tasks:
42314235
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"
@@ -500,6 +501,46 @@ def test_checked_in_replay_regression_corpus_uses_official_replayer(
500501
_execute_fixture(fixture)
501502

502503

504+
@pytest.mark.asyncio
505+
async def test_workflow_only_driver_resumes_external_activity_without_activity_polling() -> None:
506+
fixture = json.loads(
507+
(FIXTURE_DIR / "workflow-only-worker-resumes-external-activity.json").read_text(encoding="utf-8")
508+
)
509+
client = AsyncMock(spec=Client)
510+
client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={"version": PROTOCOL_VERSION})
511+
client.register_worker.return_value = {"registered": True}
512+
client.deregister_worker_registration.return_value = {"outcome": "deregistered"}
513+
client.poll_activity_task.side_effect = AssertionError("workflow-only worker must not poll activities")
514+
client.poll_workflow_task.side_effect = [None, {
515+
"task_id": "external-activity-resume-task", "workflow_id": "external-activity-caller",
516+
"run_id": "original-run", "workflow_type": fixture["workflow"]["type"],
517+
"workflow_task_attempt": 1, "history_events": fixture["history"],
518+
"arguments": serializer.encode(fixture["workflow"]["input"], codec="avro"),
519+
"payload_codec": "avro",
520+
}]
521+
client.complete_workflow_task.return_value = {"outcome": "completed"}
522+
523+
async def describe(workflow_id: str) -> WorkflowExecution:
524+
return WorkflowExecution(
525+
workflow_id=workflow_id, run_id="original-run", workflow_type=fixture["workflow"]["type"],
526+
status="completed" if client.complete_workflow_task.await_count else "running",
527+
)
528+
529+
client.describe_workflow.side_effect = describe
530+
worker = Worker(client, task_queue="external-activity", workflows=[GoldenSingleActivityWorkflow], activities=[])
531+
532+
result = await worker.run_until(workflow_id="external-activity-caller", timeout=1, poll_interval=0.01)
533+
534+
assert result.status == "completed"
535+
assert result.run_id == "original-run"
536+
assert client.poll_workflow_task.await_count == 2
537+
client.poll_activity_task.assert_not_awaited()
538+
client.complete_workflow_task.assert_awaited_once()
539+
commands = client.complete_workflow_task.call_args.kwargs["commands"]
540+
assert [command["type"] for command in commands] == [fixture["expected"]["type"]]
541+
assert serializer.decode_envelope(commands[0]["result"]) == fixture["expected"]["result"]
542+
543+
503544
@pytest.mark.asyncio
504545
async def test_worker_replays_recorded_side_effects_across_history_pages() -> None:
505546
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
@@ -4362,6 +4362,8 @@ async def request(method: str, path: str, **kwargs: object) -> httpx.Response:
43624362
client,
43634363
task_queue="orders",
43644364
worker_id="worker-only",
4365+
workflows=[TestWorkflow],
4366+
activities=[echo_activity],
43654367
poll_timeout=0.01,
43664368
heartbeat_interval=0.01,
43674369
)
@@ -4556,7 +4558,7 @@ async def test_worker_loop_error_remains_primary_when_deregistration_fails(self,
45564558
},
45574559
)
45584560
mock_client.deregister_worker_registration.side_effect = cleanup_error
4559-
worker = Worker(mock_client, task_queue="q1")
4561+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow])
45604562
worker._poll_workflow_tasks = AsyncMock( # type: ignore[method-assign]
45614563
side_effect=RuntimeError("worker loop failed")
45624564
)
@@ -4688,6 +4690,31 @@ async def poll_query_task(self, **_: object) -> None:
46884690

46894691

46904692
class TestPollLoops:
4693+
@pytest.mark.asyncio
4694+
@pytest.mark.parametrize("has_workflows,has_activities", [(True, False), (False, True), (False, False)])
4695+
async def test_run_polls_only_registered_task_families(
4696+
self, mock_client: AsyncMock, has_workflows: bool, has_activities: bool,
4697+
) -> None:
4698+
worker = Worker(
4699+
mock_client,
4700+
task_queue="q1",
4701+
workflows=[TestWorkflow] if has_workflows else [],
4702+
activities=[echo_activity] if has_activities else [],
4703+
poll_timeout=0.01,
4704+
)
4705+
run_task = asyncio.create_task(worker.run())
4706+
try:
4707+
await asyncio.sleep(0.05)
4708+
finally:
4709+
await worker.stop()
4710+
await run_task
4711+
4712+
assert bool(mock_client.poll_workflow_task.await_count) is has_workflows
4713+
assert bool(mock_client.poll_activity_task.await_count) is has_activities
4714+
assert bool(mock_client.poll_query_task.await_count) is has_workflows
4715+
mock_client.register_worker.assert_awaited_once()
4716+
mock_client.deregister_worker_registration.assert_awaited_once_with(worker.worker_id)
4717+
46914718
@pytest.mark.asyncio
46924719
async def test_run_starts_both_loops(self, mock_client: AsyncMock) -> None:
46934720
worker = Worker(
@@ -4949,6 +4976,33 @@ def test_process_metrics_memory_bytes_is_current_resident_set(
49494976

49504977

49514978
class TestRunUntil:
4979+
@pytest.mark.asyncio
4980+
@pytest.mark.parametrize("has_workflows,has_activities", [(True, False), (False, True), (False, False)])
4981+
async def test_run_until_polls_only_registered_task_families(
4982+
self, mock_client: AsyncMock, has_workflows: bool, has_activities: bool,
4983+
) -> None:
4984+
mock_client.describe_workflow = AsyncMock(side_effect=[
4985+
WorkflowExecution(workflow_id="wf-1", run_id="run-1", workflow_type="test-wf", status="running"),
4986+
WorkflowExecution(workflow_id="wf-1", run_id="run-1", workflow_type="test-wf", status="running"),
4987+
WorkflowExecution(workflow_id="wf-1", run_id="run-1", workflow_type="test-wf", status="completed"),
4988+
])
4989+
worker = Worker(
4990+
mock_client,
4991+
task_queue="q1",
4992+
workflows=[TestWorkflow] if has_workflows else [],
4993+
activities=[echo_activity] if has_activities else [],
4994+
poll_timeout=0.01,
4995+
)
4996+
4997+
result = await worker.run_until(workflow_id="wf-1", timeout=1.0, poll_interval=0.01)
4998+
4999+
assert result.status == "completed"
5000+
assert bool(mock_client.poll_workflow_task.await_count) is has_workflows
5001+
assert bool(mock_client.poll_activity_task.await_count) is has_activities
5002+
if not has_workflows:
5003+
mock_client.poll_query_task.assert_not_awaited()
5004+
mock_client.deregister_worker_registration.assert_awaited_once_with(worker.worker_id)
5005+
49525006
@pytest.mark.asyncio
49535007
async def test_run_until_returns_terminal_description(self, mock_client: AsyncMock) -> None:
49545008
mock_client.describe_workflow = AsyncMock(

0 commit comments

Comments
 (0)