Skip to content

Commit 2e9ba2b

Browse files
committed
feat(testing): add skip-time virtual clock
- Add Clock protocol with RealClock and SkipClock implementations - Give each ExecutionWorker its own clock via the registry factory - Drive wait and step-retry timers off the clock so modeled delays complete instantly under skip while history keeps real durations - Default DurableFunctionTestRunner to skip_time=True; pass skip_time=False for real wall-clock timing - Remove the DURABLE_EXECUTION_TIME_SCALE env-var scaling
1 parent 355edf1 commit 2e9ba2b

10 files changed

Lines changed: 228 additions & 17 deletions

File tree

packages/aws-durable-execution-sdk-python-examples/test/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,11 @@ def test_hello_world(durable_runner):
230230
else:
231231
if not handler:
232232
pytest.fail("handler is required for local mode tests")
233+
skip_time = marker.kwargs.get("skip_time", True)
233234
# Create local runner (needs cleanup via context manager)
234-
runner = DurableFunctionTestRunner(handler=handler, execution_timeout=60)
235+
runner = DurableFunctionTestRunner(
236+
handler=handler, execution_timeout=60, skip_time=skip_time
237+
)
235238

236239
# Wrap in adapter and use context manager for proper cleanup
237240
with TestRunnerAdapter(runner, runner_mode) as adapter:

packages/aws-durable-execution-sdk-python-examples/test/plugin/test_plugin.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,8 @@ def test_plugin(durable_runner):
3434
lambda_function_name="Plugin Wait",
3535
)
3636
def test_plugin_on_operation_end_called_for_wait_completed_during_suspend(
37-
durable_runner, monkeypatch
37+
durable_runner,
3838
):
39-
monkeypatch.setenv("DURABLE_EXECUTION_TIME_SCALE", "0.01")
40-
4139
with durable_runner:
4240
result = durable_runner.run(input=None, timeout=30)
4341

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
from __future__ import annotations
44

5-
import logging
6-
import os
75
from datetime import UTC, datetime, timedelta
86
from typing import TYPE_CHECKING
97

@@ -43,12 +41,9 @@ def process(
4341
wait_seconds = (
4442
update.wait_options.wait_seconds if update.wait_options else 0
4543
)
46-
time_scale = float(os.getenv("DURABLE_EXECUTION_TIME_SCALE", "1.0"))
47-
logging.info("Using DURABLE_EXECUTION_TIME_SCALE: %f", time_scale)
48-
scaled_wait_seconds = wait_seconds * time_scale
4944

5045
scheduled_end_timestamp = datetime.now(UTC) + timedelta(
51-
seconds=scaled_wait_seconds
46+
seconds=wait_seconds
5247
)
5348

5449
# Create WaitDetails with scheduled timestamp
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Clock sources for scheduler-driven durable timers.
2+
3+
A clock decides how long the local runner actually waits before a
4+
scheduled wake fires. History timestamps are written from the real
5+
modeled durations regardless of the clock, so a :class:`SkipClock` run
6+
records the same event history as a :class:`RealClock` run and only the
7+
wall-clock spent waiting differs.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import datetime
13+
import threading
14+
from typing import Protocol
15+
16+
17+
class Clock(Protocol):
18+
"""Time source used to arm and evaluate durable timers."""
19+
20+
def now(self) -> datetime.datetime:
21+
"""Return the current time on this clock."""
22+
...
23+
24+
def arm(self, wake: datetime.datetime) -> float:
25+
"""Return the real seconds to wait before firing at ``wake``."""
26+
...
27+
28+
29+
class RealClock:
30+
"""Wall-clock time source. Timers wait the real modeled duration."""
31+
32+
def now(self) -> datetime.datetime:
33+
return datetime.datetime.now(datetime.UTC)
34+
35+
def arm(self, wake: datetime.datetime) -> float:
36+
return max((wake - self.now()).total_seconds(), 0.0)
37+
38+
39+
class SkipClock:
40+
"""Virtual clock that jumps to each armed timer horizon.
41+
42+
Mutable owner of one execution's virtual now. Arming a wake advances
43+
virtual now to that horizon and returns a zero delay, so the wake
44+
fires on the next event-loop turn rather than after the modeled
45+
duration. Guards its state with a lock because ``arm`` runs on the
46+
scheduling thread while ``now`` runs on the fire worker thread.
47+
"""
48+
49+
def __init__(self) -> None:
50+
self._now: datetime.datetime | None = None
51+
self._lock: threading.Lock = threading.Lock()
52+
53+
def now(self) -> datetime.datetime:
54+
with self._lock:
55+
if self._now is None:
56+
self._now = datetime.datetime.now(datetime.UTC)
57+
return self._now
58+
59+
def arm(self, wake: datetime.datetime) -> float:
60+
with self._lock:
61+
if self._now is None or wake > self._now:
62+
self._now = wake
63+
return 0.0

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/executor.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,8 +1033,7 @@ def _schedule_earliest_pending(self, execution_arn: str) -> None:
10331033
existing.cancel()
10341034
return
10351035

1036-
now = datetime.now(UTC)
1037-
delay = max((earliest - now).total_seconds(), 0.0)
1036+
delay = self._registry.get_or_create(execution_arn).clock.arm(earliest)
10381037

10391038
completion_event = self._completion_events.get(execution_arn)
10401039
# Cancel-then-arm atomically under the supervisor lock so
@@ -1064,7 +1063,7 @@ def _fire_due_operations(self, execution_arn: str) -> bool:
10641063
if execution.is_complete:
10651064
return False
10661065

1067-
now = datetime.now(UTC)
1066+
now = self._registry.get_or_create(execution_arn).clock.now()
10681067
completed_any = False
10691068
for op in list(execution.operations):
10701069
if (

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/runner.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
DEFAULT_MAX_INVOCATION_PAGE_BYTES,
3939
)
4040
from aws_durable_execution_sdk_python_testing.client import InMemoryServiceClient
41+
from aws_durable_execution_sdk_python_testing.clock import RealClock, SkipClock
4142
from aws_durable_execution_sdk_python_testing.worker.registry import ExecutionRegistry
4243
from aws_durable_execution_sdk_python_testing.exceptions import (
4344
DurableFunctionsLocalRunnerError,
@@ -605,6 +606,7 @@ def __init__(
605606
execution_timeout: int = 300,
606607
invocation_timeout: int = 900,
607608
store: ExecutionStore | None = None,
609+
skip_time: bool = True, # noqa: FBT001, FBT002
608610
):
609611
self._execution_timeout = execution_timeout
610612
self._invocation_timeout = invocation_timeout
@@ -625,7 +627,11 @@ def __init__(
625627
store=self._store,
626628
scheduler=self._scheduler,
627629
)
628-
self._registry = ExecutionRegistry(self._store, self._scheduler)
630+
self._registry = ExecutionRegistry(
631+
self._store,
632+
self._scheduler,
633+
clock_factory=SkipClock if skip_time else RealClock,
634+
)
629635
self._service_client = InMemoryServiceClient(
630636
self._checkpoint_processor, self._registry
631637
)

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/worker/execution_worker.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
if TYPE_CHECKING:
1111
from concurrent.futures import Future
1212

13+
from aws_durable_execution_sdk_python_testing.clock import Clock
1314
from aws_durable_execution_sdk_python_testing.execution import Execution
1415
from aws_durable_execution_sdk_python_testing.scheduler import Scheduler
1516
from aws_durable_execution_sdk_python_testing.stores.base import ExecutionStore
@@ -44,12 +45,14 @@ def __init__(
4445
scheduler: Scheduler,
4546
registry: ExecutionRegistry,
4647
lane: SerialTaskLane,
48+
clock: Clock,
4749
) -> None:
4850
self._arn: str = execution_arn
4951
self._store: ExecutionStore = store
5052
self._scheduler: Scheduler = scheduler
5153
self._registry: ExecutionRegistry = registry
5254
self._lane: SerialTaskLane = lane
55+
self._clock: Clock = clock
5356
self._status: InvocationState = InvocationState.PRE_INVOKE
5457

5558
@classmethod
@@ -59,12 +62,18 @@ def create(
5962
store: ExecutionStore,
6063
scheduler: Scheduler,
6164
registry: ExecutionRegistry,
65+
clock: Clock,
6266
) -> ExecutionWorker:
6367
"""Build a worker and start its lane."""
6468
lane: SerialTaskLane = SerialTaskLane.create(
6569
name=f"execution-worker-{execution_arn}"
6670
)
67-
return cls(execution_arn, store, scheduler, registry, lane)
71+
return cls(execution_arn, store, scheduler, registry, lane, clock)
72+
73+
@property
74+
def clock(self) -> Clock:
75+
"""The clock arming and evaluating this execution's timers."""
76+
return self._clock
6877

6978
@property
7079
def status(self) -> InvocationState:

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/worker/registry.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
import threading
66
from typing import TYPE_CHECKING
77

8+
from aws_durable_execution_sdk_python_testing.clock import Clock, RealClock
89
from aws_durable_execution_sdk_python_testing.worker.execution_worker import (
910
ExecutionWorker,
1011
)
1112

1213
if TYPE_CHECKING:
14+
from collections.abc import Callable
15+
1316
from aws_durable_execution_sdk_python_testing.scheduler import Scheduler
1417
from aws_durable_execution_sdk_python_testing.stores.base import ExecutionStore
1518

@@ -24,9 +27,15 @@ class ExecutionRegistry:
2427
a given ARN.
2528
"""
2629

27-
def __init__(self, store: ExecutionStore, scheduler: Scheduler) -> None:
30+
def __init__(
31+
self,
32+
store: ExecutionStore,
33+
scheduler: Scheduler,
34+
clock_factory: Callable[[], Clock] = RealClock,
35+
) -> None:
2836
self._store = store
2937
self._scheduler = scheduler
38+
self._clock_factory = clock_factory
3039
self._workers: dict[str, ExecutionWorker] = {}
3140
self._lock = threading.Lock()
3241

@@ -36,7 +45,11 @@ def get_or_create(self, execution_arn: str) -> ExecutionWorker:
3645
worker: ExecutionWorker | None = self._workers.get(execution_arn)
3746
if worker is None:
3847
worker = ExecutionWorker.create(
39-
execution_arn, self._store, self._scheduler, self
48+
execution_arn,
49+
self._store,
50+
self._scheduler,
51+
self,
52+
self._clock_factory(),
4053
)
4154
self._workers[execution_arn] = worker
4255
return worker
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Tests for the durable timer clocks."""
2+
3+
from __future__ import annotations
4+
5+
import datetime
6+
from unittest.mock import Mock
7+
8+
from aws_durable_execution_sdk_python_testing.clock import (
9+
RealClock,
10+
SkipClock,
11+
)
12+
from aws_durable_execution_sdk_python_testing.worker.registry import (
13+
ExecutionRegistry,
14+
)
15+
16+
17+
def test_real_clock_arm_returns_remaining_wall_seconds() -> None:
18+
clock = RealClock()
19+
wake = clock.now() + datetime.timedelta(seconds=30)
20+
21+
delay: float = clock.arm(wake)
22+
23+
# The real clock waits the modeled duration, minus the tiny elapsed
24+
# time between now() calls.
25+
assert 29.0 <= delay <= 30.0
26+
27+
28+
def test_real_clock_arm_never_negative_for_past_wake() -> None:
29+
clock = RealClock()
30+
past = clock.now() - datetime.timedelta(seconds=30)
31+
32+
assert clock.arm(past) == 0.0
33+
34+
35+
def test_skip_clock_arm_returns_zero_delay() -> None:
36+
clock = SkipClock()
37+
wake = clock.now() + datetime.timedelta(seconds=300)
38+
39+
assert clock.arm(wake) == 0.0
40+
41+
42+
def test_skip_clock_now_jumps_to_armed_horizon() -> None:
43+
clock = SkipClock()
44+
wake = clock.now() + datetime.timedelta(seconds=300)
45+
46+
clock.arm(wake)
47+
48+
assert clock.now() == wake
49+
50+
51+
def test_skip_clock_is_monotonic() -> None:
52+
clock = SkipClock()
53+
start = clock.now()
54+
far = start + datetime.timedelta(seconds=300)
55+
near = start + datetime.timedelta(seconds=10)
56+
57+
clock.arm(far)
58+
clock.arm(near)
59+
60+
# A later arm for an earlier horizon must not move virtual time back.
61+
assert clock.now() == far
62+
63+
64+
def test_registry_uses_clock_factory() -> None:
65+
skip_registry = ExecutionRegistry(Mock(), Mock(), clock_factory=SkipClock)
66+
real_registry = ExecutionRegistry(Mock(), Mock(), clock_factory=RealClock)
67+
68+
assert isinstance(skip_registry.get_or_create("arn").clock, SkipClock)
69+
assert isinstance(real_registry.get_or_create("arn").clock, RealClock)
70+
71+
72+
def test_registry_defaults_to_real_clock() -> None:
73+
registry = ExecutionRegistry(Mock(), Mock())
74+
75+
assert isinstance(registry.get_or_create("arn").clock, RealClock)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""End-to-end tests for skip-time behaviour on the local runner."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from aws_durable_execution_sdk_python.config import Duration
8+
from aws_durable_execution_sdk_python.context import DurableContext
9+
from aws_durable_execution_sdk_python.execution import (
10+
InvocationStatus,
11+
durable_execution,
12+
)
13+
14+
from aws_durable_execution_sdk_python_testing.runner import (
15+
DurableFunctionTestRunner,
16+
)
17+
18+
19+
_MODELED_WAIT_SECONDS = 300
20+
21+
22+
@durable_execution
23+
def _waits_then_returns(event: Any, context: DurableContext) -> str: # noqa: ARG001
24+
context.wait(Duration.from_seconds(_MODELED_WAIT_SECONDS))
25+
return "done"
26+
27+
28+
def test_skip_time_completes_long_wait_and_keeps_faithful_history() -> None:
29+
# skip_time defaults to True. A modeled 300s wait must complete well
30+
# within the 30s execution timeout, which is only possible if the
31+
# runner does not spend the modeled duration in wall-clock time.
32+
with DurableFunctionTestRunner(handler=_waits_then_returns) as runner:
33+
result = runner.run(input="x", execution_timeout=30)
34+
35+
assert result.status is InvocationStatus.SUCCEEDED
36+
37+
wait_ops = [op for op in result.operations if op.operation_type.value == "WAIT"]
38+
assert len(wait_ops) == 1
39+
wait_op = wait_ops[0]
40+
41+
assert wait_op.status.value == "SUCCEEDED"
42+
assert wait_op.start_timestamp is not None
43+
assert wait_op.scheduled_end_timestamp is not None
44+
45+
# History records the real modeled duration, not the skipped
46+
# wall-clock time.
47+
modeled = (
48+
wait_op.scheduled_end_timestamp - wait_op.start_timestamp
49+
).total_seconds()
50+
assert abs(modeled - _MODELED_WAIT_SECONDS) < 1.0

0 commit comments

Comments
 (0)