Skip to content

Commit 87cf9e5

Browse files
Schedules conformance: complete PHP-created workflows with Python worker (#211)
1 parent c0d9d00 commit 87cf9e5

2 files changed

Lines changed: 67 additions & 6 deletions

File tree

src/durable_workflow/client.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from __future__ import annotations
2020

2121
import asyncio
22+
import math
2223
import os
2324
import time
2425
import uuid
@@ -58,6 +59,31 @@ def _default_sdk_version() -> str:
5859

5960

6061
DEFAULT_SDK_VERSION = _default_sdk_version()
62+
_WORKER_POLL_MAX_SERVER_TIMEOUT_SECONDS = 60
63+
_WORKER_POLL_HTTP_TIMEOUT_GRACE_SECONDS = 5.0
64+
65+
66+
def _worker_poll_timeout_seconds(timeout: float | None) -> int | None:
67+
if timeout is None:
68+
return None
69+
70+
value = float(timeout)
71+
if not math.isfinite(value) or value < 0:
72+
raise ValueError("timeout must be a non-negative finite number")
73+
74+
seconds = int(math.ceil(value)) if value >= 1 else 0
75+
return min(seconds, _WORKER_POLL_MAX_SERVER_TIMEOUT_SECONDS)
76+
77+
78+
def _worker_poll_http_timeout(timeout: float | None) -> float | None:
79+
timeout_seconds = _worker_poll_timeout_seconds(timeout)
80+
if timeout_seconds is None:
81+
return None
82+
83+
if timeout_seconds == 0:
84+
return max(float(timeout), 1.0)
85+
86+
return max(float(timeout), float(timeout_seconds) + _WORKER_POLL_HTTP_TIMEOUT_GRACE_SECONDS)
6187

6288

6389
def _protocol_version_from_env(name: str, default: str) -> str:
@@ -3217,22 +3243,31 @@ async def poll_workflow_task_response(
32173243
Returns the full worker-protocol response envelope, including
32183244
``poll_status`` when the server has no task to lease. Worker-plane
32193245
endpoint — most applications use :class:`~durable_workflow.Worker`
3220-
rather than calling this directly.
3246+
rather than calling this directly. ``timeout`` controls the server
3247+
long-poll window; the HTTP request uses a small grace margin.
32213248
"""
32223249
body: dict[str, Any] = {
32233250
"worker_id": worker_id,
32243251
"task_queue": task_queue,
32253252
"poll_request_id": poll_request_id or f"wf-poll-{uuid.uuid4().hex}",
32263253
}
3254+
timeout_seconds = _worker_poll_timeout_seconds(timeout)
3255+
if timeout_seconds is not None:
3256+
body["timeout_seconds"] = timeout_seconds
32273257
if build_id:
32283258
body["build_id"] = build_id
32293259
if history_page_size is not None:
32303260
body["history_page_size"] = history_page_size
3261+
http_timeout = _worker_poll_http_timeout(timeout)
32313262

32323263
for _ in range(2):
32333264
try:
32343265
data = await self._request(
3235-
"POST", "/worker/workflow-tasks/poll", worker=True, json=body, timeout=timeout
3266+
"POST",
3267+
"/worker/workflow-tasks/poll",
3268+
worker=True,
3269+
json=body,
3270+
timeout=http_timeout,
32363271
)
32373272
except httpx.TimeoutException:
32383273
continue
@@ -3255,7 +3290,8 @@ async def poll_workflow_task(
32553290
32563291
Returns the task payload, or ``None`` on poll timeout. Worker-plane
32573292
endpoint — most applications use :class:`~durable_workflow.Worker`
3258-
rather than calling this directly.
3293+
rather than calling this directly. ``timeout`` controls the server
3294+
long-poll window; the HTTP request uses a small grace margin.
32593295
"""
32603296
data = await self.poll_workflow_task_response(
32613297
worker_id=worker_id,
@@ -3363,19 +3399,29 @@ async def poll_query_task(
33633399
33643400
Query tasks are ephemeral worker-plane requests created when the server
33653401
must route a control-plane query to a non-PHP workflow runtime.
3402+
``timeout`` controls the server long-poll window; the HTTP request
3403+
uses a small grace margin.
33663404
"""
33673405
body: dict[str, Any] = {
33683406
"worker_id": worker_id,
33693407
"task_queue": task_queue,
33703408
"poll_request_id": poll_request_id or f"query-poll-{uuid.uuid4().hex}",
33713409
}
3410+
timeout_seconds = _worker_poll_timeout_seconds(timeout)
3411+
if timeout_seconds is not None:
3412+
body["timeout_seconds"] = timeout_seconds
33723413
if build_id:
33733414
body["build_id"] = build_id
3415+
http_timeout = _worker_poll_http_timeout(timeout)
33743416

33753417
for _ in range(2):
33763418
try:
33773419
data = await self._request(
3378-
"POST", "/worker/query-tasks/poll", worker=True, json=body, timeout=timeout
3420+
"POST",
3421+
"/worker/query-tasks/poll",
3422+
worker=True,
3423+
json=body,
3424+
timeout=http_timeout,
33793425
)
33803426
except httpx.TimeoutException:
33813427
continue
@@ -3457,19 +3503,29 @@ async def poll_activity_task(
34573503
34583504
Returns the task payload, or ``None`` on poll timeout. Worker-plane
34593505
endpoint — typically used by :class:`~durable_workflow.Worker`.
3506+
``timeout`` controls the server long-poll window; the HTTP request
3507+
uses a small grace margin.
34603508
"""
34613509
body: dict[str, Any] = {
34623510
"worker_id": worker_id,
34633511
"task_queue": task_queue,
34643512
"poll_request_id": poll_request_id or f"activity-poll-{uuid.uuid4().hex}",
34653513
}
3514+
timeout_seconds = _worker_poll_timeout_seconds(timeout)
3515+
if timeout_seconds is not None:
3516+
body["timeout_seconds"] = timeout_seconds
34663517
if build_id:
34673518
body["build_id"] = build_id
3519+
http_timeout = _worker_poll_http_timeout(timeout)
34683520

34693521
for _ in range(2):
34703522
try:
34713523
data = await self._request(
3472-
"POST", "/worker/activity-tasks/poll", worker=True, json=body, timeout=timeout
3524+
"POST",
3525+
"/worker/activity-tasks/poll",
3526+
worker=True,
3527+
json=body,
3528+
timeout=http_timeout,
34733529
)
34743530
except httpx.TimeoutException:
34753531
continue

tests/test_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2292,8 +2292,9 @@ async def test_poll_workflow_task_response_preserves_no_compatible_status(self,
22922292
assert request_body["task_queue"] == "queue-1"
22932293
assert request_body["build_id"] == "build-v2"
22942294
assert request_body["history_page_size"] == 100
2295+
assert request_body["timeout_seconds"] == 3
22952296
assert isinstance(request_body["poll_request_id"], str)
2296-
assert mock.await_args.kwargs["timeout"] == 3.0
2297+
assert mock.await_args.kwargs["timeout"] == 8.0
22972298

22982299
@pytest.mark.asyncio
22992300
async def test_poll_workflow_task_keeps_returning_none_for_no_compatible_status(self, client: Client) -> None:
@@ -2351,6 +2352,7 @@ async def test_poll_activity_task_sends_poll_request_id(self, client: Client) ->
23512352
assert request_body["worker_id"] == "worker-1"
23522353
assert request_body["task_queue"] == "queue-1"
23532354
assert request_body["build_id"] == "build-1"
2355+
assert request_body["timeout_seconds"] == 35
23542356
assert isinstance(request_body["poll_request_id"], str)
23552357
assert request_body["poll_request_id"] != ""
23562358

@@ -2370,6 +2372,7 @@ async def test_poll_query_task_sends_build_id(self, client: Client) -> None:
23702372
assert request_body["worker_id"] == "worker-1"
23712373
assert request_body["task_queue"] == "queue-1"
23722374
assert request_body["build_id"] == "build-1"
2375+
assert request_body["timeout_seconds"] == 35
23732376
assert isinstance(request_body["poll_request_id"], str)
23742377
assert request_body["poll_request_id"] != ""
23752378

@@ -3016,6 +3019,8 @@ async def test_poll_query_task_returns_task_payload(self, client: Client) -> Non
30163019
body = mock.call_args.kwargs["json"]
30173020
assert body["worker_id"] == "w1"
30183021
assert body["task_queue"] == "q1"
3022+
assert body["timeout_seconds"] == 5
3023+
assert mock.call_args.kwargs["timeout"] == 10.0
30193024
assert isinstance(body["poll_request_id"], str)
30203025
assert body["poll_request_id"] != ""
30213026

0 commit comments

Comments
 (0)