|
12 | 12 | from contextvars import ContextVar |
13 | 13 | from dataclasses import dataclass |
14 | 14 | from datetime import timedelta |
| 15 | +from decimal import ROUND_CEILING, Decimal, localcontext |
15 | 16 | from functools import partial |
16 | 17 | from typing import Any |
17 | 18 | from weakref import ReferenceType, ref |
@@ -399,6 +400,65 @@ async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None: |
399 | 400 | assert (deadline - datetime(1970, 1, 1)).total_seconds() >= observed[0] |
400 | 401 |
|
401 | 402 |
|
| 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 | + |
402 | 462 | def test_async_sleep_uses_shared_long_timer_chunking() -> None: |
403 | 463 | async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> str: |
404 | 464 | await asyncio.sleep(7 * 86400) |
|
0 commit comments