Skip to content

Commit 44eccfc

Browse files
andystaplesCopilot
andcommitted
Use exact microsecond ceiling for durable call_at deadlines
Convert loop deadlines with exact rational arithmetic so durable timers never fire before the requested loop time, and add exact-oracle regression tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 638f8d9 commit 44eccfc

2 files changed

Lines changed: 71 additions & 5 deletions

File tree

‎durabletask/internal/asyncio_loop.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@
1212
from collections import deque
1313
from collections.abc import Callable, Coroutine, Generator
1414
from datetime import datetime, timedelta
15+
from fractions import Fraction
1516
from typing import Any, TypeVar, cast
1617

1718
from durabletask import task
1819

1920
T = TypeVar("T")
2021

22+
_EPOCH = datetime(1970, 1, 1)
23+
_MICROSECOND = timedelta(microseconds=1)
24+
2125

2226
class _CompletionObserver(task.CompositeTask[None]):
2327
def __init__(self, durable: task.Task[Any], callback: Callable[[], None]) -> None:
@@ -133,7 +137,7 @@ def __init__(
133137

134138
def time(self) -> float:
135139
# A fixed UTC epoch avoids local-time and platform timestamp differences.
136-
return (self.ctx.current_utc_datetime - datetime(1970, 1, 1)).total_seconds()
140+
return (self.ctx.current_utc_datetime - _EPOCH).total_seconds()
137141

138142
def get_debug(self) -> bool:
139143
return False
@@ -205,14 +209,16 @@ def call_at(
205209
if not math.isfinite(when):
206210
raise ValueError("Durable deadlines must be finite.")
207211
handle = _DurableTimerHandle(when, callback, args, self, context)
208-
if when <= self.time():
212+
# Exact rational ceiling: float round-trips can emit a deadline that
213+
# precedes the requested one by a fraction of a microsecond.
214+
deadline_us = math.ceil(Fraction(when) * 1_000_000)
215+
now_us = (self.ctx.current_utc_datetime - _EPOCH) // _MICROSECOND
216+
if when <= self.time() or deadline_us <= now_us:
209217
self._ready.append(handle)
210218
else:
211219
if self._closing:
212220
raise RuntimeError("Dispatch cleanup cannot schedule durable timers.")
213-
deadline = datetime(1970, 1, 1) + timedelta(seconds=when)
214-
if (deadline - datetime(1970, 1, 1)).total_seconds() < when:
215-
deadline += timedelta(microseconds=1)
221+
deadline = _EPOCH + timedelta(microseconds=deadline_us)
216222
handle.durable_timer = self.ctx.create_timer(deadline)
217223
self._timers[handle] = _CompletionObserver(
218224
handle.durable_timer,

‎tests/durabletask/test_asyncio_orchestrators.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from contextvars import ContextVar
1313
from dataclasses import dataclass
1414
from datetime import timedelta
15+
from decimal import ROUND_CEILING, Decimal, localcontext
1516
from functools import partial
1617
from typing import Any
1718
from weakref import ReferenceType, ref
@@ -399,6 +400,65 @@ async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
399400
assert (deadline - datetime(1970, 1, 1)).total_seconds() >= observed[0]
400401

401402

403+
@pytest.mark.parametrize("offset", [0.000003, 0.0000003, 0.000001, 0.0000013, 1.0, 2.25])
404+
def test_call_at_deadline_is_exact_microsecond_ceiling(offset: float) -> None:
405+
requested: list[float] = []
406+
407+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
408+
loop = asyncio.get_running_loop()
409+
when = loop.time() + offset
410+
requested.append(when)
411+
fired = loop.create_future()
412+
loop.call_at(when, fired.set_result, None)
413+
await fired
414+
415+
actions = _execute(orchestrator, [], _start()).actions
416+
assert len(actions) == 1
417+
fire_at = actions[0].createTimer.fireAt
418+
assert fire_at.nanos % 1000 == 0
419+
emitted_us = fire_at.seconds * 1_000_000 + fire_at.nanos // 1000
420+
# Decimal(float) is exact, so this oracle shares no float arithmetic with the loop.
421+
with localcontext() as exact:
422+
exact.prec = 400
423+
requested_seconds = Decimal(requested[0])
424+
oracle_us = int((requested_seconds * 1_000_000).to_integral_value(rounding=ROUND_CEILING))
425+
assert emitted_us == oracle_us
426+
assert Decimal(emitted_us) / 1_000_000 >= requested_seconds
427+
428+
429+
def test_call_at_known_float_deadline_rounds_up_to_next_microsecond() -> None:
430+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
431+
loop = asyncio.get_running_loop()
432+
fired = loop.create_future()
433+
loop.call_at(loop.time() + 0.000003, fired.set_result, None)
434+
await fired
435+
436+
actions = _execute(orchestrator, [], _start()).actions
437+
assert actions[0].createTimer.fireAt.ToDatetime() == NOW + timedelta(microseconds=4)
438+
439+
440+
@pytest.mark.parametrize("offset", [0.0, -1.0])
441+
def test_call_at_due_deadline_runs_without_durable_timer(offset: float) -> None:
442+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> str:
443+
loop = asyncio.get_running_loop()
444+
fired = loop.create_future()
445+
loop.call_at(loop.time() + offset, fired.set_result, "fired")
446+
return await fired
447+
448+
result = _execute(orchestrator, [], _start())
449+
assert json.loads(_completion(result).result.value) == "fired"
450+
451+
452+
@pytest.mark.parametrize("when", [1e20, float(sys.float_info.max)])
453+
def test_call_at_out_of_range_deadline_fails(when: float) -> None:
454+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
455+
asyncio.get_running_loop().call_at(when, lambda: None)
456+
457+
complete = _completion(_execute(orchestrator, [], _start()))
458+
assert complete.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED
459+
assert complete.failureDetails.errorType.endswith("OverflowError")
460+
461+
402462
def test_async_sleep_uses_shared_long_timer_chunking() -> None:
403463
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> str:
404464
await asyncio.sleep(7 * 86400)

0 commit comments

Comments
 (0)