doeff-time 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
doeff_time/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Public API for doeff-time."""
2
+
3
+ from doeff_time._internals.sim_clock import SimClock as SimClock
4
+ from doeff_time.effects.time import Delay as Delay
5
+ from doeff_time.effects.time import DelayEffect as DelayEffect
6
+ from doeff_time.effects.time import GetMonotonic as GetMonotonic
7
+ from doeff_time.effects.time import GetMonotonicEffect as GetMonotonicEffect
8
+ from doeff_time.effects.time import GetTime as GetTime
9
+ from doeff_time.effects.time import GetTimeEffect as GetTimeEffect
10
+ from doeff_time.effects.time import RaceOutcome as RaceOutcome
11
+ from doeff_time.effects.time import RaceWithin as RaceWithin
12
+ from doeff_time.effects.time import RaceWithinEffect as RaceWithinEffect
13
+ from doeff_time.effects.time import ScheduleAt as ScheduleAt
14
+ from doeff_time.effects.time import ScheduleAtEffect as ScheduleAtEffect
15
+ from doeff_time.effects.time import SetTime as SetTime
16
+ from doeff_time.effects.time import SetTimeEffect as SetTimeEffect
17
+ from doeff_time.effects.time import TicksOutcome as TicksOutcome
18
+ from doeff_time.effects.time import WaitTicks as WaitTicks
19
+ from doeff_time.effects.time import WaitTicksEffect as WaitTicksEffect
20
+ from doeff_time.effects.time import WaitUntil as WaitUntil
21
+ from doeff_time.effects.time import WaitUntilEffect as WaitUntilEffect
22
+ from doeff_time.effects.time import WaitWithin as WaitWithin
23
+ from doeff_time.effects.time import WaitWithinEffect as WaitWithinEffect
24
+ from doeff_time.effects.time import delay as delay
25
+ from doeff_time.effects.time import get_monotonic as get_monotonic
26
+ from doeff_time.effects.time import get_time as get_time
27
+ from doeff_time.effects.time import schedule_at as schedule_at
28
+ from doeff_time.effects.time import set_time as set_time
29
+ from doeff_time.effects.time import wait_until as wait_until
30
+ from doeff_time.effects.time import wait_within as wait_within
31
+ from doeff_time.epoch_ms import epoch_ms_of as epoch_ms_of
32
+ from doeff_time.handlers.async_time import async_time_handler as async_time_handler
33
+ from doeff_time.handlers.sim_time import sim_time_handler as sim_time_handler
34
+ from doeff_time.handlers.sync_time import sync_time_handler as sync_time_handler
@@ -0,0 +1,10 @@
1
+ """Internal helpers for simulated time."""
2
+
3
+ from .sim_clock import SimClock
4
+ from .time_queue import TimeQueue, TimeQueueEntry
5
+
6
+ __all__ = [
7
+ "SimClock",
8
+ "TimeQueue",
9
+ "TimeQueueEntry",
10
+ ]
@@ -0,0 +1,14 @@
1
+ """The value a timed wait's deadline completes with."""
2
+
3
+
4
+ class _DeadlinePassed:
5
+ """The deadline of a timed wait passed. Told apart from every future's value by identity (a future may
6
+ complete with None — RaceWithin), so the clock handlers complete their deadline with this, never None."""
7
+
8
+ __slots__ = ()
9
+
10
+ def __repr__(self) -> str:
11
+ return "DEADLINE_PASSED"
12
+
13
+
14
+ DEADLINE_PASSED = _DeadlinePassed()
@@ -0,0 +1,24 @@
1
+ """Virtual monotonic clock for simulation handlers."""
2
+
3
+
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+
7
+ from .validation import ensure_aware_datetime
8
+
9
+
10
+ @dataclass
11
+ class SimClock:
12
+ current_time: datetime = datetime(1970, 1, 1, tzinfo=timezone.utc)
13
+
14
+ def __post_init__(self) -> None:
15
+ self.current_time = ensure_aware_datetime(self.current_time, name="current_time")
16
+
17
+ def advance_to(self, target_time: datetime) -> datetime:
18
+ target = ensure_aware_datetime(target_time, name="target_time")
19
+ self.current_time = max(self.current_time, target)
20
+ return self.current_time
21
+
22
+ def set_time(self, new_time: datetime) -> datetime:
23
+ self.current_time = ensure_aware_datetime(new_time, name="new_time")
24
+ return self.current_time
@@ -0,0 +1,68 @@
1
+ """Min-heap queue of time-ordered promises."""
2
+
3
+ import heapq
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from doeff_core_effects.scheduler import Promise
9
+
10
+ from doeff_time._internals.validation import ensure_aware_datetime
11
+
12
+ # ``Promise`` is generic to the type checker but not subscriptable at runtime.
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TimeQueueEntry:
17
+ time: datetime
18
+ sequence: int
19
+ promise: "Promise[Any]"
20
+ # The value the clock completes ``promise`` with (a RaceWithin deadline's DEADLINE_PASSED, else None).
21
+ answer: Any = None
22
+
23
+
24
+ class TimeQueue:
25
+ def __init__(self) -> None:
26
+ self._mut_sequence = 0
27
+ self._mut_items: "list[tuple[datetime, int, Promise[Any], Any]]" = [] # noqa: UP037
28
+ # Sequences of entries withdrawn before their time (a timed wait whose future won —
29
+ # agora-redesign #2618). Dropped lazily when they reach the head, so withdrawing is O(1)
30
+ # and the clock never advances to a deadline nobody waits for.
31
+ self._mut_withdrawn: set[int] = set()
32
+ # Sequences still in the heap and not withdrawn — answers "is it still queued?" in O(1).
33
+ self._mut_queued: set[int] = set()
34
+
35
+ def push(self, time: datetime, promise: "Promise[Any]", answer: Any = None) -> int:
36
+ """Queue ``promise`` to be completed with ``answer`` at ``time``; answers the entry's sequence (for ``withdraw``)."""
37
+ target_time = ensure_aware_datetime(time, name="time")
38
+ self._mut_sequence += 1
39
+ heapq.heappush(self._mut_items, (target_time, self._mut_sequence, promise, answer))
40
+ self._mut_queued.add(self._mut_sequence)
41
+ return self._mut_sequence
42
+
43
+ def withdraw(self, sequence: int) -> None:
44
+ """Drop a queued entry before its time (no-op when it was already popped)."""
45
+ if sequence in self._mut_queued:
46
+ self._mut_queued.discard(sequence)
47
+ self._mut_withdrawn.add(sequence)
48
+ self._drop_withdrawn_head()
49
+
50
+ def _drop_withdrawn_head(self) -> None:
51
+ """Keep the head a live entry, so ``pop`` / ``empty`` never see a withdrawn deadline."""
52
+ while self._mut_items and self._mut_items[0][1] in self._mut_withdrawn:
53
+ _, sequence, _, _ = heapq.heappop(self._mut_items)
54
+ self._mut_withdrawn.discard(sequence)
55
+
56
+ def pop(self) -> TimeQueueEntry:
57
+ self._drop_withdrawn_head()
58
+ time, sequence, promise, answer = heapq.heappop(self._mut_items)
59
+ self._mut_queued.discard(sequence)
60
+ self._drop_withdrawn_head()
61
+ return TimeQueueEntry(time=time, sequence=sequence, promise=promise, answer=answer)
62
+
63
+ def empty(self) -> bool:
64
+ self._drop_withdrawn_head()
65
+ return not self._mut_items
66
+
67
+ def __len__(self) -> int:
68
+ return len(self._mut_queued)
@@ -0,0 +1,12 @@
1
+ """Internal validation helpers for doeff-time."""
2
+
3
+
4
+ from datetime import datetime
5
+
6
+
7
+ def ensure_aware_datetime(value: datetime, *, name: str) -> datetime:
8
+ if not isinstance(value, datetime):
9
+ raise TypeError(f"{name} must be datetime, got {type(value).__name__}")
10
+ if value.tzinfo is None:
11
+ raise ValueError(f"{name} must be timezone-aware datetime")
12
+ return value
@@ -0,0 +1,29 @@
1
+ """Public time effects for doeff-time."""
2
+
3
+ from doeff_time.effects.time import Delay as Delay
4
+ from doeff_time.effects.time import DelayEffect as DelayEffect
5
+ from doeff_time.effects.time import GetMonotonic as GetMonotonic
6
+ from doeff_time.effects.time import GetMonotonicEffect as GetMonotonicEffect
7
+ from doeff_time.effects.time import GetTime as GetTime
8
+ from doeff_time.effects.time import GetTimeEffect as GetTimeEffect
9
+ from doeff_time.effects.time import RaceOutcome as RaceOutcome
10
+ from doeff_time.effects.time import RaceWithin as RaceWithin
11
+ from doeff_time.effects.time import RaceWithinEffect as RaceWithinEffect
12
+ from doeff_time.effects.time import ScheduleAt as ScheduleAt
13
+ from doeff_time.effects.time import ScheduleAtEffect as ScheduleAtEffect
14
+ from doeff_time.effects.time import SetTime as SetTime
15
+ from doeff_time.effects.time import SetTimeEffect as SetTimeEffect
16
+ from doeff_time.effects.time import TicksOutcome as TicksOutcome
17
+ from doeff_time.effects.time import WaitTicks as WaitTicks
18
+ from doeff_time.effects.time import WaitTicksEffect as WaitTicksEffect
19
+ from doeff_time.effects.time import WaitUntil as WaitUntil
20
+ from doeff_time.effects.time import WaitUntilEffect as WaitUntilEffect
21
+ from doeff_time.effects.time import WaitWithin as WaitWithin
22
+ from doeff_time.effects.time import WaitWithinEffect as WaitWithinEffect
23
+ from doeff_time.effects.time import delay as delay
24
+ from doeff_time.effects.time import get_monotonic as get_monotonic
25
+ from doeff_time.effects.time import get_time as get_time
26
+ from doeff_time.effects.time import schedule_at as schedule_at
27
+ from doeff_time.effects.time import set_time as set_time
28
+ from doeff_time.effects.time import wait_until as wait_until
29
+ from doeff_time.effects.time import wait_within as wait_within
@@ -0,0 +1,261 @@
1
+ """Provider-agnostic time effects."""
2
+
3
+
4
+ import math
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
8
+
9
+ from doeff import EffectBase
10
+ from doeff_time._internals.validation import ensure_aware_datetime
11
+
12
+ if TYPE_CHECKING:
13
+ from doeff_core_effects.scheduler import Future, Task
14
+
15
+ _T = TypeVar("_T")
16
+
17
+
18
+ def _coerce_finite_float(value: float, *, name: str) -> float:
19
+ if not isinstance(value, (int, float)):
20
+ raise TypeError(f"{name} must be float, got {type(value).__name__}")
21
+ coerced = float(value)
22
+ if math.isnan(coerced) or math.isinf(coerced):
23
+ raise ValueError(f"{name} must be finite, got {value!r}")
24
+ return coerced
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DelayEffect(EffectBase):
29
+ """Sleep for a duration in seconds."""
30
+
31
+ seconds: float
32
+
33
+ def __post_init__(self) -> None:
34
+ seconds = _coerce_finite_float(self.seconds, name="seconds")
35
+ if seconds < 0.0:
36
+ raise ValueError("seconds must be >= 0.0")
37
+ object.__setattr__(self, "seconds", seconds)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class WaitUntilEffect(EffectBase):
42
+ """Wait until a specific timezone-aware datetime."""
43
+
44
+ target: datetime
45
+
46
+ def __post_init__(self) -> None:
47
+ object.__setattr__(self, "target", ensure_aware_datetime(self.target, name="target"))
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class GetTimeEffect(EffectBase):
52
+ """Read current timezone-aware datetime."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class GetMonotonicEffect(EffectBase):
57
+ """Read monotonic seconds as a float.
58
+
59
+ Only the difference between two readings is meaningful (durations,
60
+ deadlines inside one process). Unlike ``GetTime`` it never jumps backwards
61
+ with wall-clock adjustments. The origin is handler-defined: wall-clock
62
+ handlers answer ``time.monotonic()``; ``sim_time_handler`` answers the
63
+ virtual clock's POSIX timestamp.
64
+ """
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ScheduleAtEffect(EffectBase):
69
+ """Schedule a program for execution at a specific timezone-aware datetime."""
70
+
71
+ time: datetime
72
+ program: Any
73
+
74
+ def __post_init__(self) -> None:
75
+ object.__setattr__(self, "time", ensure_aware_datetime(self.time, name="time"))
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class WaitWithinEffect(EffectBase, Generic[_T]):
80
+ """Wait for a scheduler future for at most ``seconds``.
81
+
82
+ Answers the future's value, or ``None`` when ``seconds`` pass first — so the
83
+ future's producer must not complete it with ``None``. One effect replaces the
84
+ "spawn a sleeping timer task, Race it, cancel it" pattern: the clock handler
85
+ owns the deadline (``sim_time_handler`` puts it on its virtual time queue and
86
+ drops it when the future wins — no task is spawned), which keeps a timed wait
87
+ as cheap as a Delay (agora-redesign #2618).
88
+
89
+ ``park`` is for an *external* promise that in-run code completes (doeff-records'
90
+ memory-store bell, rung by a synchronous write): the race against the deadline
91
+ parks like ``Wait(future, priority=PRIORITY_IDLE)`` instead of shielding the sim
92
+ clock, so virtual time can reach the deadline while the promise is pending. With
93
+ the default (``False``) a pending external promise holds the clock, and the
94
+ deadline cannot pass before it completes (agora-redesign #3054).
95
+ """
96
+
97
+ future: "Future[_T]"
98
+ seconds: float
99
+ park: bool = False
100
+
101
+ def __post_init__(self) -> None:
102
+ seconds = _coerce_finite_float(self.seconds, name="seconds")
103
+ if seconds < 0.0:
104
+ raise ValueError("seconds must be >= 0.0")
105
+ if not isinstance(self.park, bool):
106
+ raise TypeError(f"park must be bool, got {type(self.park).__name__}")
107
+ object.__setattr__(self, "seconds", seconds)
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class RaceOutcome(Generic[_T]):
112
+ """The answer of RaceWithin: ``value`` = the value of the first of the futures to complete (None when the
113
+ deadline passed first) and ``timed_out`` = whether the deadline passed first."""
114
+
115
+ value: "_T | None"
116
+ timed_out: bool
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class RaceWithinEffect(EffectBase, Generic[_T]):
121
+ """Wait for the first of several scheduler futures or tasks for at most ``seconds``.
122
+
123
+ WaitWithin for a Race: answers ``RaceOutcome(value, False)`` with the value of the first of ``futures``
124
+ to complete (a failure or a cancellation is raised, as ``Race`` does), or ``RaceOutcome(None, True)`` when
125
+ ``seconds`` pass first. A future may complete with ``None``: the deadline is told apart by ``timed_out``,
126
+ not by the value. One effect replaces "spawn a sleeping timer task, Race it against the futures, cancel
127
+ it": ``sim_time_handler`` puts the deadline on its virtual time queue and withdraws it when a future wins
128
+ — no task is spawned (doeff-cluster's worker tick pause races its wake bells against the next due time
129
+ this way). ``park`` is WaitWithin's.
130
+ """
131
+
132
+ futures: "tuple[Task[_T] | Future[_T], ...]"
133
+ seconds: float
134
+ park: bool = False
135
+
136
+ def __post_init__(self) -> None:
137
+ if not isinstance(self.futures, tuple):
138
+ raise TypeError(f"futures must be a tuple, got {type(self.futures).__name__}")
139
+ if not self.futures:
140
+ raise ValueError("futures must hold at least one Task or Future (a bare deadline is a Delay)")
141
+ seconds = _coerce_finite_float(self.seconds, name="seconds")
142
+ if seconds < 0.0:
143
+ raise ValueError("seconds must be >= 0.0")
144
+ if not isinstance(self.park, bool):
145
+ raise TypeError(f"park must be bool, got {type(self.park).__name__}")
146
+ object.__setattr__(self, "seconds", seconds)
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class TicksOutcome(Generic[_T]):
151
+ """The answer of WaitTicks: ``value`` = the future's value (None when the ticks ran out first) and
152
+ ``passed`` = how many ticks passed before the answer (0 to ``count``)."""
153
+
154
+ value: "_T | None"
155
+ passed: int
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class WaitTicksEffect(EffectBase, Generic[_T]):
160
+ """Wait for a scheduler future across a run of ``count`` ticks spaced ``every`` seconds, without waking
161
+ at each tick.
162
+
163
+ The ticks are at ``every``, ``2 * every`` … ``count * every`` seconds from now. Answers
164
+ ``TicksOutcome(value, passed)``: the future's value when it completes first (``passed`` = the ticks
165
+ that passed before it), or ``TicksOutcome(None, count)`` when the last tick passes first.
166
+
167
+ The order at a tick instant is that of a waiter that re-registers ``WaitWithin(future, every)`` at
168
+ each tick (agora-redesign #3066): tick k counts as passed before any other timer due at the same
169
+ instant that was registered after tick k-1 passed, and after one registered before it.
170
+ ``sim_time_handler`` keeps that order by re-queueing tick k+1 on its virtual time queue the moment
171
+ tick k is taken off it — the waiting task is not woken at the ticks. ``park`` is WaitWithin's.
172
+ """
173
+
174
+ future: "Future[_T]"
175
+ every: float
176
+ count: int
177
+ park: bool = False
178
+
179
+ def __post_init__(self) -> None:
180
+ every = _coerce_finite_float(self.every, name="every")
181
+ if every <= 0.0:
182
+ raise ValueError("every must be > 0.0")
183
+ if isinstance(self.count, bool) or not isinstance(self.count, int):
184
+ raise TypeError(f"count must be int, got {type(self.count).__name__}")
185
+ if self.count < 1:
186
+ raise ValueError("count must be >= 1")
187
+ if not isinstance(self.park, bool):
188
+ raise TypeError(f"park must be bool, got {type(self.park).__name__}")
189
+ object.__setattr__(self, "every", every)
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class SetTimeEffect(EffectBase):
194
+ """Set current timezone-aware datetime (simulation handlers may support this effect)."""
195
+
196
+ time: datetime
197
+
198
+ def __post_init__(self) -> None:
199
+ object.__setattr__(self, "time", ensure_aware_datetime(self.time, name="time"))
200
+
201
+
202
+ def delay(seconds: float) -> DelayEffect:
203
+ return DelayEffect(seconds=seconds)
204
+
205
+
206
+ def wait_until(target: datetime) -> WaitUntilEffect:
207
+ return WaitUntilEffect(target=target)
208
+
209
+
210
+ def get_time() -> GetTimeEffect:
211
+ return GetTimeEffect()
212
+
213
+
214
+ def get_monotonic() -> GetMonotonicEffect:
215
+ return GetMonotonicEffect()
216
+
217
+
218
+ def schedule_at(time: datetime, program: Any) -> ScheduleAtEffect:
219
+ return ScheduleAtEffect(time=time, program=program)
220
+
221
+
222
+ def set_time(time: datetime) -> SetTimeEffect:
223
+ return SetTimeEffect(time=time)
224
+
225
+
226
+ def wait_within(future: "Future[_T]", seconds: float, *, park: bool = False) -> "WaitWithinEffect[_T]":
227
+ return WaitWithinEffect(future=future, seconds=seconds, park=park)
228
+
229
+
230
+ def Delay(seconds: float) -> EffectBase: # noqa: N802
231
+ return DelayEffect(seconds=seconds)
232
+
233
+
234
+ def WaitUntil(target: datetime) -> EffectBase: # noqa: N802
235
+ return WaitUntilEffect(target=target)
236
+
237
+
238
+ def GetTime() -> EffectBase: # noqa: N802
239
+ return GetTimeEffect()
240
+
241
+
242
+ def GetMonotonic() -> EffectBase: # noqa: N802
243
+ return GetMonotonicEffect()
244
+
245
+
246
+ def ScheduleAt(time: datetime, program: Any) -> EffectBase: # noqa: N802
247
+ return ScheduleAtEffect(time=time, program=program)
248
+
249
+
250
+ def SetTime(time: datetime) -> EffectBase: # noqa: N802
251
+ return SetTimeEffect(time=time)
252
+
253
+
254
+ def WaitWithin(future: "Future[_T]", seconds: float, *, park: bool = False) -> EffectBase: # noqa: N802
255
+ return WaitWithinEffect(future=future, seconds=seconds, park=park)
256
+
257
+
258
+ # The effects' constructors under the names the other waits use (the classes themselves — a typed answer, no wrapper).
259
+ WaitTicks = WaitTicksEffect
260
+ RaceWithin = RaceWithinEffect
261
+
doeff_time/epoch_ms.py ADDED
@@ -0,0 +1,21 @@
1
+ """時刻を epoch ミリ秒の整数へ換算する式の唯一の定義(GetTime の答えを、他の process や記録の刻と並べる物差しへ)。
2
+
3
+ 同じ刻を 2 か所が別の丸め方(四捨五入と床)で綴ると、329.6 ms に書いた刻が 329.9 ms に刻んだ後の刻より 1 ms 後に見える
4
+ (#3855)。丸めはここの 1 つだけにし、刻を並べる所はすべてこれで綴る。
5
+ """
6
+
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ from doeff_time._internals.validation import ensure_aware_datetime
10
+
11
+ _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
12
+ _ONE_MS = timedelta(milliseconds=1)
13
+
14
+
15
+ def epoch_ms_of(at: datetime) -> int:
16
+ """timezone つきの時刻 → epoch ミリ秒(床へ丸める)。
17
+
18
+ timedelta の整数の割り算で求め、float を経ない(float の timestamp を 1000 倍すると 1 ms ずれることがある)。起点より前の
19
+ 時刻も床へ丸める(1969-12-31T23:59:59.9995Z → -1)。時差の付き方が違う 2 つの時刻も同じ物差しに乗る。
20
+ """
21
+ return (ensure_aware_datetime(at, name="at") - _EPOCH) // _ONE_MS
@@ -0,0 +1,13 @@
1
+ """Public handlers for doeff-time."""
2
+
3
+
4
+ from .async_time import ProtocolHandler, async_time_handler
5
+ from .sim_time import sim_time_handler
6
+ from .sync_time import sync_time_handler
7
+
8
+ __all__ = [
9
+ "ProtocolHandler",
10
+ "async_time_handler",
11
+ "sim_time_handler",
12
+ "sync_time_handler",
13
+ ]
@@ -0,0 +1,59 @@
1
+ """The three timed waits on a wall clock — WaitWithin, WaitTicks (agora-redesign #3066) and RaceWithin.
2
+
3
+ All race the futures against one deadline timer that completes with DEADLINE_PASSED; they differ only in
4
+ which futures, how long the deadline is and in the answer. The async and sync handlers share this, so their
5
+ one clause answers the three waits the same way.
6
+ """
7
+
8
+ from doeff_core_effects.scheduler import Future, Task
9
+
10
+ from doeff_time._internals.deadline import DEADLINE_PASSED
11
+ from doeff_time.effects import (
12
+ RaceOutcome,
13
+ RaceWithinEffect,
14
+ TicksOutcome,
15
+ WaitTicksEffect,
16
+ WaitWithinEffect,
17
+ )
18
+
19
+ TimedWait = WaitWithinEffect[object] | WaitTicksEffect[object] | RaceWithinEffect[object]
20
+
21
+
22
+ def timed_wait_futures(effect: TimedWait) -> "tuple[Task[object] | Future[object], ...]":
23
+ """What ``effect`` races against its deadline: RaceWithin's ``futures``, or the one ``future``."""
24
+ match effect:
25
+ case RaceWithinEffect():
26
+ return effect.futures
27
+ case WaitWithinEffect() | WaitTicksEffect():
28
+ return (effect.future,)
29
+
30
+
31
+ def timed_wait_seconds(effect: TimedWait) -> float:
32
+ """How long the deadline of ``effect`` is on the wall clock: WaitWithin's and RaceWithin's ``seconds``, or
33
+ the time to WaitTicks' last tick."""
34
+ match effect:
35
+ case WaitWithinEffect() | RaceWithinEffect():
36
+ return max(0.0, effect.seconds)
37
+ case WaitTicksEffect():
38
+ return effect.every * effect.count
39
+
40
+
41
+ def timed_wait_answer(effect: TimedWait, first: object, elapsed: float) -> object:
42
+ """The answer of ``effect`` once the race gave ``first`` (DEADLINE_PASSED when the deadline won) after
43
+ ``elapsed`` seconds: WaitWithin answers the future's value or None; RaceWithin answers it as a RaceOutcome;
44
+ WaitTicks answers it with the ticks that passed — a wall clock does not observe them one by one, so all of
45
+ them when the ticks ran out first, else the whole spacings in ``elapsed``, at most ``count``."""
46
+ timed_out = first is DEADLINE_PASSED
47
+ value = None if timed_out else first
48
+ match effect:
49
+ case WaitWithinEffect():
50
+ return value
51
+ case RaceWithinEffect():
52
+ return RaceOutcome(value=value, timed_out=timed_out)
53
+ case WaitTicksEffect():
54
+ passed = (
55
+ effect.count
56
+ if timed_out
57
+ else min(effect.count, max(0, int(elapsed // effect.every)))
58
+ )
59
+ return TicksOutcome(value=value, passed=passed)
@@ -0,0 +1,142 @@
1
+ """Asyncio-backed wall-clock handler for doeff-time effects."""
2
+
3
+
4
+ import asyncio
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from doeff_core_effects import Await
11
+ from doeff_core_effects.scheduler import PRIORITY_IDLE, Cancel, Race, Spawn
12
+
13
+ from doeff import Pass, Transfer, do
14
+ from doeff import handler as _program_handler
15
+ from doeff.program import ProgramHandler
16
+ from doeff_time._internals.deadline import DEADLINE_PASSED
17
+ from doeff_time.effects import (
18
+ DelayEffect,
19
+ GetMonotonicEffect,
20
+ GetTimeEffect,
21
+ RaceWithinEffect,
22
+ ScheduleAtEffect,
23
+ WaitTicksEffect,
24
+ WaitUntilEffect,
25
+ WaitWithinEffect,
26
+ )
27
+ from doeff_time.handlers._wall_ticks import (
28
+ timed_wait_answer,
29
+ timed_wait_futures,
30
+ timed_wait_seconds,
31
+ )
32
+
33
+ ProtocolHandler = Callable[[Any, Any], Any]
34
+
35
+ # The effects AsyncTimeRuntime.handle answers.
36
+ AsyncTimeEffect = (
37
+ DelayEffect
38
+ | WaitUntilEffect
39
+ | GetTimeEffect
40
+ | GetMonotonicEffect
41
+ | WaitWithinEffect
42
+ | WaitTicksEffect
43
+ | RaceWithinEffect
44
+ | ScheduleAtEffect
45
+ )
46
+
47
+
48
+ def _utc_now() -> datetime:
49
+ return datetime.now(timezone.utc)
50
+
51
+
52
+ def _clock_wait(sleep: Callable[[float], Awaitable[Any]], seconds: float) -> Await:
53
+ """An Await whose completion time is known: the scheduler does not count
54
+ it as stalled until ``seconds`` have passed (agora-redesign #765). The
55
+ deadline is on the scheduler's clock (``time.monotonic``), not on the
56
+ injectable ``monotonic`` that answers GetMonotonic."""
57
+ return Await(sleep(seconds), deadline=time.monotonic() + seconds)
58
+
59
+
60
+ class AsyncTimeRuntime:
61
+ """Runtime container for async wall-clock time effects."""
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ now: Callable[[], datetime],
67
+ sleep: Callable[[float], Awaitable[Any]],
68
+ monotonic: Callable[[], float],
69
+ ) -> None:
70
+ self._now = now
71
+ self._sleep = sleep
72
+ self._monotonic = monotonic
73
+
74
+ @do
75
+ def _expire_after(self, seconds: float):
76
+ """The deadline of a timed wait (WaitWithin・WaitTicks・RaceWithin): sleep ``seconds`` on the wall clock, then
77
+ answer DEADLINE_PASSED (a future may complete with None — RaceWithin)."""
78
+ yield _clock_wait(self._sleep, max(0.0, seconds))
79
+ return DEADLINE_PASSED
80
+
81
+ @do
82
+ def handle(self, effect: AsyncTimeEffect, k: Any):
83
+ # The annotation lets the VM skip this handler for every other effect, instead of
84
+ # calling it to Pass each one (agora card ki-1841b724d061 — every effect a records
85
+ # request sends past the clock cost a call here).
86
+ # Every clause performs its final Transfer/Pass from THIS frame.
87
+ # Delegating to a sub-@do that transfers (the pre-2026-07-14 shape)
88
+ # leaves this frame suspended mid-`yield` forever, pinning the
89
+ # Task handle and defeating the scheduler's terminal-entry sweep
90
+ # (ADR-DOE-CORE-EFFECTS-002).
91
+ if isinstance(effect, DelayEffect):
92
+ yield _clock_wait(self._sleep, max(0.0, effect.seconds))
93
+ return (yield Transfer(k, None))
94
+ if isinstance(effect, WaitUntilEffect):
95
+ wait_seconds = max(0.0, (effect.target - self._now()).total_seconds())
96
+ yield _clock_wait(self._sleep, wait_seconds)
97
+ return (yield Transfer(k, None))
98
+ if isinstance(effect, GetTimeEffect):
99
+ return (yield Transfer(k, self._now()))
100
+ if isinstance(effect, GetMonotonicEffect):
101
+ return (yield Transfer(k, float(self._monotonic())))
102
+ if isinstance(effect, (WaitWithinEffect, WaitTicksEffect, RaceWithinEffect)):
103
+ # The deadline is a daemon task sleeping on the wall clock, raced against the future(s) and
104
+ # cancelled afterwards (daemon: abandoning it at root return is its lifecycle — #501).
105
+ # WaitTicks' deadline is its last tick (agora-redesign #3066).
106
+ started = float(self._monotonic())
107
+ timer = yield Spawn(self._expire_after(timed_wait_seconds(effect)), daemon=True)
108
+ try:
109
+ first = yield Race(*timed_wait_futures(effect), timer, priority=PRIORITY_IDLE if effect.park else None)
110
+ finally:
111
+ yield Cancel(timer)
112
+ return (yield Transfer(k, timed_wait_answer(effect, first, float(self._monotonic()) - started)))
113
+ if isinstance(effect, ScheduleAtEffect):
114
+ wait_seconds = max(0.0, (effect.time - self._now()).total_seconds())
115
+ sleep = self._sleep
116
+
117
+ @do
118
+ def deferred():
119
+ yield _clock_wait(sleep, wait_seconds)
120
+ # Wait(task) answers the scheduled program's value (agora-redesign #1159).
121
+ return (yield effect.program)
122
+
123
+ # Resume the caller with the spawned Task (same contract as
124
+ # sim_time_handler) so failures of the deferred program can be
125
+ # observed via Wait/Gather instead of vanishing on an unwatched
126
+ # task (#503).
127
+ task = yield Spawn(deferred())
128
+ return (yield Transfer(k, task))
129
+ yield Pass(effect, k)
130
+
131
+
132
+ def async_time_handler(
133
+ *,
134
+ now: Callable[[], datetime] = _utc_now,
135
+ sleep: Callable[[float], Awaitable[Any]] = asyncio.sleep,
136
+ monotonic: Callable[[], float] = time.monotonic,
137
+ ) -> ProgramHandler:
138
+ """Return a protocol handler for wall-clock async time semantics."""
139
+
140
+ runtime = AsyncTimeRuntime(now=now, sleep=sleep, monotonic=monotonic)
141
+ return _program_handler(runtime.handle)
142
+
@@ -0,0 +1,328 @@
1
+ """Virtual-clock simulation handler for doeff-time effects."""
2
+
3
+
4
+ from collections.abc import Callable
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any
7
+
8
+ from doeff_core_effects import WriterTellEffect
9
+ from doeff_core_effects.scheduler import (
10
+ PRIORITY_IDLE,
11
+ CompletePromise,
12
+ CreatePromise,
13
+ Future,
14
+ Race,
15
+ Spawn,
16
+ Task,
17
+ Wait,
18
+ )
19
+
20
+ from doeff import Pass, Transfer, do
21
+ from doeff import handler as _program_handler
22
+ from doeff.program import ProgramHandler
23
+ from doeff_time._internals import SimClock, TimeQueue
24
+ from doeff_time._internals.deadline import DEADLINE_PASSED
25
+ from doeff_time.effects import (
26
+ DelayEffect,
27
+ GetMonotonicEffect,
28
+ GetTimeEffect,
29
+ RaceOutcome,
30
+ RaceWithinEffect,
31
+ ScheduleAtEffect,
32
+ SetTimeEffect,
33
+ TicksOutcome,
34
+ WaitTicksEffect,
35
+ WaitUntilEffect,
36
+ WaitWithinEffect,
37
+ )
38
+
39
+ ProtocolHandler = Callable[[Any, Any], Any]
40
+ LogFormatter = Callable[[datetime, Any], str]
41
+ EPOCH_UTC = datetime(1970, 1, 1, tzinfo=timezone.utc)
42
+ #: The virtual clock's resolution (datetime keeps microseconds).
43
+ CLOCK_TICK = timedelta(microseconds=1)
44
+
45
+
46
+ def _delay_span(seconds: float) -> timedelta:
47
+ """How far a Delay moves the virtual clock: a positive delay moves it by at least one tick.
48
+
49
+ ``timedelta(seconds=s)`` rounds to whole microseconds, so a positive delay below half a
50
+ microsecond became zero and the clock did not move. A wait loop that sleeps "the rest of
51
+ its timeout" (e.g. doeff_records.watching.wait-for-changes) then slept the same sub-tick
52
+ remainder forever without advancing virtual time (agora-redesign #675, 2026-09-26: the
53
+ remainder 7.2e-08 s after float drift). A real sleep of a positive duration always lets at
54
+ least that much time pass, so the sim rounds such a delay up to one tick.
55
+ """
56
+ span = timedelta(seconds=seconds)
57
+ if seconds > 0 and span <= timedelta(0):
58
+ return CLOCK_TICK
59
+ return span
60
+
61
+
62
+ class _TickRun:
63
+ """A WaitTicks waiter's ticks on the virtual time queue (agora-redesign #3066): the spacing and the number
64
+ of ticks, how many have passed, and the sequence of the tick queued now (withdrawn when the future wins).
65
+ The clock driver advances it as each tick is taken off the queue; the waiting task reads ``passed``
66
+ once it is answered."""
67
+
68
+ def __init__(self, every: timedelta, count: int) -> None:
69
+ self.every = every
70
+ self.count = count
71
+ self._mut_passed = 0
72
+ self._mut_sequence = 0
73
+
74
+ @property
75
+ def passed(self) -> int:
76
+ return self._mut_passed
77
+
78
+ @property
79
+ def sequence(self) -> int:
80
+ return self._mut_sequence
81
+
82
+ def queued(self, sequence: int) -> None:
83
+ """Remember the sequence of the tick just queued."""
84
+ self._mut_sequence = sequence
85
+
86
+ def tick(self) -> bool:
87
+ """Count a tick taken off the queue; answers whether it was the last one."""
88
+ self._mut_passed += 1
89
+ return self._mut_passed >= self.count
90
+
91
+
92
+ class SimTimeRuntime:
93
+ """Runtime state for virtual-clock interpretation."""
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ clock: SimClock,
99
+ log_formatter: LogFormatter | None,
100
+ ) -> None:
101
+ self._clock = clock
102
+ self._time_queue = TimeQueue()
103
+ # Queued ticks of WaitTicks waiters, by queue sequence: such an entry's promise is the waiter's
104
+ # deadline, completed only at its last tick (agora-redesign #3066).
105
+ self._mut_tick_runs: dict[int, _TickRun] = {}
106
+ self._mut_driver_running = False
107
+ self._log_formatter = log_formatter
108
+ self._mut_forwarding_tell = False
109
+ self._handler: ProtocolHandler = self.handle
110
+
111
+ @do
112
+ def _clock_driver(self):
113
+ """Idle-priority daemon that advances time when normal tasks are parked.
114
+
115
+ A WaitTicks tick that is not the last is not completed: the next tick is queued the moment
116
+ this one is taken off — where a waiter re-registering WaitWithin at each tick would queue it,
117
+ since no other task runs between the pop and that waiter's re-registration — and the waiting
118
+ task is not woken (agora-redesign #3066)."""
119
+
120
+ try:
121
+ while not self._time_queue.empty():
122
+ entry = self._time_queue.pop()
123
+ self._clock.advance_to(entry.time)
124
+ if entry.sequence in self._mut_tick_runs:
125
+ run = self._mut_tick_runs.pop(entry.sequence)
126
+ if not run.tick():
127
+ sequence = self._time_queue.push(entry.time + run.every, entry.promise)
128
+ run.queued(sequence)
129
+ self._mut_tick_runs[sequence] = run
130
+ continue
131
+ yield CompletePromise(entry.promise, entry.answer)
132
+ finally:
133
+ self._mut_driver_running = False
134
+
135
+ @do
136
+ def _ensure_clock_driver(self):
137
+ if self._mut_driver_running:
138
+ return None
139
+ self._mut_driver_running = True
140
+ # daemon=True carries two contracts:
141
+ # - #501: the driver's final IDLE resume (queued right after its
142
+ # last CompletePromise) is routinely abandoned when the root body
143
+ # returns first — that is this daemon's lifecycle, not lost work,
144
+ # so it must not trip the root close-out diagnostic.
145
+ # - #505: daemon tasks are the only tasks the scheduler's
146
+ # PRIORITY_EXTERNAL_WAIT shield may starve, which is exactly what
147
+ # keeps this driver from advancing sim time past a pending
148
+ # external completion.
149
+ yield Spawn(self._clock_driver(), priority=PRIORITY_IDLE, daemon=True)
150
+ return None
151
+
152
+ @do
153
+ def _wait_for_time(self, target_time: datetime):
154
+ promise = yield CreatePromise()
155
+ self._time_queue.push(target_time, promise)
156
+ _ = yield self._ensure_clock_driver()
157
+ yield Wait(promise.future)
158
+
159
+ @do
160
+ def _wait_within(self, future: "Future[object]", seconds: float, park: bool):
161
+ """Answer ``future``'s value, or None once ``seconds`` of virtual time pass first.
162
+
163
+ The deadline is a promise on this handler's time queue (like a Delay) raced against the
164
+ future — no timer task is spawned, and a deadline the future beat is withdrawn from the
165
+ queue so the clock driver never advances to it (agora-redesign #2618). ``park`` races
166
+ an external promise without shielding the clock driver, so the deadline can pass while
167
+ the promise is pending (agora-redesign #3054).
168
+ """
169
+ deadline = yield CreatePromise()
170
+ sequence = self._time_queue.push(self._clock.current_time + _delay_span(seconds), deadline)
171
+ _ = yield self._ensure_clock_driver()
172
+ try:
173
+ first = yield Race(future, deadline.future, priority=PRIORITY_IDLE if park else None)
174
+ finally:
175
+ self._time_queue.withdraw(sequence)
176
+ return first
177
+
178
+ @do
179
+ def _race_within(self, futures: "tuple[Task[object] | Future[object], ...]", seconds: float, park: bool):
180
+ """Answer the first of ``futures`` to complete, or the deadline once ``seconds`` of virtual time pass
181
+ first — as a RaceOutcome.
182
+
183
+ WaitWithin's deadline (no timer task — withdrawn when a future wins) raced against several futures.
184
+ The clock driver completes it with DEADLINE_PASSED, so a future that completes with None is not
185
+ taken for the deadline.
186
+ """
187
+ deadline = yield CreatePromise()
188
+ sequence = self._time_queue.push(self._clock.current_time + _delay_span(seconds), deadline, answer=DEADLINE_PASSED)
189
+ _ = yield self._ensure_clock_driver()
190
+ try:
191
+ first = yield Race(*futures, deadline.future, priority=PRIORITY_IDLE if park else None)
192
+ finally:
193
+ self._time_queue.withdraw(sequence)
194
+ timed_out = first is DEADLINE_PASSED
195
+ return RaceOutcome(value=None if timed_out else first, timed_out=timed_out)
196
+
197
+ @do
198
+ def _wait_ticks(self, future: "Future[object]", every: float, count: int, park: bool):
199
+ """Answer ``future``'s value, or None once ``count`` ticks spaced ``every`` seconds pass first,
200
+ with how many ticks passed (agora-redesign #3066).
201
+
202
+ One tick at a time is on the time queue: the clock driver takes it off and queues the next
203
+ one at once, without waking this task (see _clock_driver), so a tick instant orders against
204
+ the other timers as if this task re-registered WaitWithin at each tick. The last tick
205
+ completes the deadline. A tick the future beat is withdrawn, as WaitWithin's deadline.
206
+ """
207
+ deadline = yield CreatePromise()
208
+ run = _TickRun(_delay_span(every), count)
209
+ sequence = self._time_queue.push(self._clock.current_time + run.every, deadline)
210
+ run.queued(sequence)
211
+ self._mut_tick_runs[sequence] = run
212
+ _ = yield self._ensure_clock_driver()
213
+ try:
214
+ first = yield Race(future, deadline.future, priority=PRIORITY_IDLE if park else None)
215
+ finally:
216
+ self._time_queue.withdraw(run.sequence)
217
+ if run.sequence in self._mut_tick_runs:
218
+ del self._mut_tick_runs[run.sequence]
219
+ return TicksOutcome(value=first, passed=run.passed)
220
+
221
+ def _timed_wait(self, effect: WaitWithinEffect[object] | WaitTicksEffect[object] | RaceWithinEffect[object]):
222
+ """The Program that answers one of the timed waits (a sub-program that completes before the Transfer)."""
223
+ match effect:
224
+ case WaitWithinEffect():
225
+ return self._wait_within(effect.future, effect.seconds, effect.park)
226
+ case WaitTicksEffect():
227
+ return self._wait_ticks(effect.future, effect.every, effect.count, effect.park)
228
+ case RaceWithinEffect():
229
+ return self._race_within(effect.futures, effect.seconds, effect.park)
230
+
231
+ @do
232
+ def handle(
233
+ self,
234
+ effect: WriterTellEffect
235
+ | DelayEffect
236
+ | WaitUntilEffect
237
+ | GetTimeEffect
238
+ | GetMonotonicEffect
239
+ | ScheduleAtEffect
240
+ | SetTimeEffect
241
+ | WaitWithinEffect
242
+ | WaitTicksEffect
243
+ | RaceWithinEffect,
244
+ k: Any,
245
+ ):
246
+ # The annotation is the clause list below: the VM skips this handler for
247
+ # every other effect without entering Python (doeff_vm._effect_types),
248
+ # the same as the trailing Pass.
249
+ # Every clause performs its final Transfer/Pass from THIS frame.
250
+ # Delegating to a sub-@do that transfers (the pre-2026-07-14 shape)
251
+ # leaves this frame suspended mid-`yield` forever, pinning the
252
+ # Task handle and defeating the scheduler's terminal-entry sweep
253
+ # (ADR-DOE-CORE-EFFECTS-002). Sub-programs that COMPLETE before
254
+ # the Transfer (_wait_for_time, _ensure_clock_driver) are fine.
255
+ if (
256
+ isinstance(effect, WriterTellEffect)
257
+ and self._log_formatter is not None
258
+ and not self._mut_forwarding_tell
259
+ ):
260
+ formatted = self._log_formatter(self._clock.current_time, effect.msg)
261
+ self._mut_forwarding_tell = True
262
+ try:
263
+ result = yield WriterTellEffect(formatted)
264
+ finally:
265
+ self._mut_forwarding_tell = False
266
+ return (yield Transfer(k, result))
267
+ if isinstance(effect, DelayEffect):
268
+ target_time = self._clock.current_time + _delay_span(effect.seconds)
269
+ _ = yield self._wait_for_time(target_time)
270
+ return (yield Transfer(k, None))
271
+ if isinstance(effect, WaitUntilEffect):
272
+ target_time = max(self._clock.current_time, effect.target)
273
+ _ = yield self._wait_for_time(target_time)
274
+ return (yield Transfer(k, None))
275
+ if isinstance(effect, (GetTimeEffect, GetMonotonicEffect)):
276
+ now = self._clock.current_time
277
+ # GetMonotonic reads the virtual clock's POSIX timestamp: virtual
278
+ # time only moves through Delay/WaitUntil/SetTime, and callers use
279
+ # differences between readings.
280
+ reading = now if isinstance(effect, GetTimeEffect) else now.timestamp()
281
+ return (yield Transfer(k, reading))
282
+ if isinstance(effect, (WaitWithinEffect, WaitTicksEffect, RaceWithinEffect)):
283
+ answer = yield self._timed_wait(effect)
284
+ return (yield Transfer(k, answer))
285
+ if isinstance(effect, ScheduleAtEffect):
286
+
287
+ @do
288
+ def deferred():
289
+ _ = yield self._wait_for_time(effect.time)
290
+ # Wait(task) answers the scheduled program's value (agora-redesign #1159).
291
+ return (yield effect.program)
292
+
293
+ task = yield Spawn(deferred())
294
+ return (yield Transfer(k, task))
295
+ if isinstance(effect, SetTimeEffect):
296
+ self._clock.set_time(effect.time)
297
+ if not self._time_queue.empty():
298
+ _ = yield self._ensure_clock_driver()
299
+ return (yield Transfer(k, None))
300
+ yield Pass(effect, k)
301
+
302
+
303
+ def sim_time_handler(
304
+ *,
305
+ start_time: datetime | None = None,
306
+ clock: SimClock | None = None,
307
+ log_formatter: LogFormatter | None = None,
308
+ ) -> ProgramHandler:
309
+ """Return a virtual-clock handler that delegates core concurrency effects.
310
+
311
+ Install it inside ``scheduled`` and outside every ``Spawn`` whose tasks
312
+ sleep on it: spawned tasks inherit the handler, so all of them share one
313
+ virtual clock. Time advances to the earliest pending wake-up only when no
314
+ normal-priority task is runnable; wake-ups at the same instant resume in
315
+ the order they were requested.
316
+
317
+ ``clock`` lets the caller own the ``SimClock`` so tests can read
318
+ ``clock.current_time`` synchronously (or move it with ``set_time`` between
319
+ runs). Pass either ``start_time`` or ``clock``, not both.
320
+ """
321
+
322
+ if clock is not None and start_time is not None:
323
+ raise ValueError("pass either start_time or clock, not both")
324
+ if clock is None:
325
+ clock = SimClock(EPOCH_UTC if start_time is None else start_time)
326
+ runtime = SimTimeRuntime(clock=clock, log_formatter=log_formatter)
327
+ return _program_handler(runtime._handler)
328
+
@@ -0,0 +1,127 @@
1
+ """Blocking wall-clock handler for doeff-time effects."""
2
+
3
+
4
+ import threading
5
+ import time
6
+ from collections.abc import Callable
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from doeff_core_effects.scheduler import PRIORITY_IDLE, CreateExternalPromise, Race, Spawn
11
+ from doeff_core_effects.scheduler import Wait as WaitTask
12
+
13
+ from doeff import Pass, Transfer, do
14
+ from doeff import handler as _program_handler
15
+ from doeff.program import ProgramHandler
16
+ from doeff_time._internals.deadline import DEADLINE_PASSED
17
+ from doeff_time.effects import (
18
+ DelayEffect,
19
+ GetMonotonicEffect,
20
+ GetTimeEffect,
21
+ RaceWithinEffect,
22
+ ScheduleAtEffect,
23
+ WaitTicksEffect,
24
+ WaitUntilEffect,
25
+ WaitWithinEffect,
26
+ )
27
+ from doeff_time.handlers._wall_ticks import (
28
+ timed_wait_answer,
29
+ timed_wait_futures,
30
+ timed_wait_seconds,
31
+ )
32
+
33
+ ProtocolHandler = Callable[[Any, Any], Any]
34
+
35
+
36
+ def _utc_now() -> datetime:
37
+ return datetime.now(timezone.utc)
38
+
39
+
40
+ class SyncTimeRuntime:
41
+ """Runtime container for sync wall-clock time effects."""
42
+
43
+ def __init__(
44
+ self,
45
+ *,
46
+ now: Callable[[], datetime],
47
+ sleep: Callable[[float], None],
48
+ monotonic: Callable[[], float],
49
+ ) -> None:
50
+ self._now = now
51
+ self._sleep = sleep
52
+ self._monotonic = monotonic
53
+
54
+ @do
55
+ def handle(self, effect: Any, k: Any):
56
+ # Every clause performs its final Transfer/Pass from THIS frame.
57
+ # Delegating to a sub-@do that transfers (the pre-2026-07-14 shape)
58
+ # leaves this frame suspended mid-`yield` forever, pinning the
59
+ # Task handle and defeating the scheduler's terminal-entry sweep
60
+ # (ADR-DOE-CORE-EFFECTS-002).
61
+ if isinstance(effect, DelayEffect):
62
+ self._sleep(max(0.0, effect.seconds))
63
+ return (yield Transfer(k, None))
64
+ if isinstance(effect, WaitUntilEffect):
65
+ self._sleep(max(0.0, (effect.target - self._now()).total_seconds()))
66
+ return (yield Transfer(k, None))
67
+ if isinstance(effect, GetTimeEffect):
68
+ return (yield Transfer(k, self._now()))
69
+ if isinstance(effect, GetMonotonicEffect):
70
+ return (yield Transfer(k, float(self._monotonic())))
71
+ if isinstance(effect, (WaitWithinEffect, WaitTicksEffect, RaceWithinEffect)):
72
+ # The deadline is an external promise a wall-clock timer completes (the scheduler knows
73
+ # when it wakes — #765), raced against the future(s); the timer is stopped afterwards.
74
+ # WaitTicks' deadline is its last tick (agora-redesign #3066).
75
+ started = float(self._monotonic())
76
+ seconds = timed_wait_seconds(effect)
77
+ deadline = yield CreateExternalPromise(deadline=time.monotonic() + seconds)
78
+
79
+ def _deadline_passed():
80
+ deadline.complete(DEADLINE_PASSED)
81
+
82
+ timer = threading.Timer(seconds, _deadline_passed)
83
+ timer.daemon = True
84
+ timer.start()
85
+ try:
86
+ first = yield Race(*timed_wait_futures(effect), deadline.future, priority=PRIORITY_IDLE if effect.park else None)
87
+ finally:
88
+ timer.cancel()
89
+ return (yield Transfer(k, timed_wait_answer(effect, first, float(self._monotonic()) - started)))
90
+ if isinstance(effect, ScheduleAtEffect):
91
+ wait_seconds = max(0.0, (effect.time - self._now()).total_seconds())
92
+
93
+ @do
94
+ def deferred():
95
+ # A timer wait: the scheduler knows when it wakes (#765).
96
+ ep = yield CreateExternalPromise(deadline=time.monotonic() + wait_seconds)
97
+
98
+ def _timer_done():
99
+ ep.complete(None)
100
+
101
+ timer = threading.Timer(wait_seconds, _timer_done)
102
+ timer.daemon = True
103
+ timer.start()
104
+ yield WaitTask(ep.future)
105
+ # Wait(task) answers the scheduled program's value (agora-redesign #1159).
106
+ return (yield effect.program)
107
+
108
+ # Resume the caller with the spawned Task (same contract as
109
+ # sim_time_handler) so failures of the deferred program can be
110
+ # observed via Wait/Gather instead of vanishing on an unwatched
111
+ # task (#503).
112
+ task = yield Spawn(deferred())
113
+ return (yield Transfer(k, task))
114
+ yield Pass(effect, k)
115
+
116
+
117
+ def sync_time_handler(
118
+ *,
119
+ now: Callable[[], datetime] = _utc_now,
120
+ sleep: Callable[[float], None] = time.sleep,
121
+ monotonic: Callable[[], float] = time.monotonic,
122
+ ) -> ProgramHandler:
123
+ """Return a protocol handler for blocking wall-clock time semantics."""
124
+
125
+ runtime = SyncTimeRuntime(now=now, sleep=sleep, monotonic=monotonic)
126
+ return _program_handler(runtime.handle)
127
+
doeff_time/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.5
2
+ Name: doeff-time
3
+ Version: 0.1.0
4
+ Summary: Provider-agnostic time effects for doeff
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: doeff-core-effects<0.9,>=0.8.0
7
+ Requires-Dist: doeff<0.9,>=0.8.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # doeff-time
11
+
12
+ Provider-agnostic time effects for `doeff`.
13
+
14
+ ## Effects
15
+
16
+ - `Delay(seconds)`
17
+ - `WaitUntil(target: datetime)` (`target` must be timezone-aware)
18
+ - `GetTime()`
19
+ - `GetMonotonic()` — monotonic seconds (`float`); only differences are meaningful
20
+ - `ScheduleAt(time: datetime, program)` (`time` must be timezone-aware) — answers the spawned `Task`;
21
+ `Wait(task)` answers `program`'s value, or raises its failure
22
+ - `SetTime(time: datetime)` (for simulation handlers, `time` must be timezone-aware)
23
+
24
+ ## Conversions
25
+
26
+ - `epoch_ms_of(at: datetime) -> int` — timezone-aware time → epoch milliseconds, floored by integer
27
+ `timedelta` division (no float on the way). This is the one rounding for every stamp that is laid
28
+ next to another stamp: two places rounding the same instant differently (round vs floor) make a
29
+ write at 329.6 ms look 1 ms later than a read at 329.9 ms.
30
+
31
+ ## Handlers
32
+
33
+ - `async_time_handler()` for `asyncio` runtimes
34
+ - `sync_time_handler()` for blocking runtimes
35
+ - `sim_time_handler(start_time=...)` for deterministic virtual time (`start_time` is timezone-aware
36
+ `datetime`), or `sim_time_handler(clock=SimClock(start))` when the caller wants to read or move
37
+ the virtual clock outside the program (tests)
38
+
39
+ ### Shared contract (all three handlers)
40
+
41
+ `tests/test_time_contract.hy` runs the same deftests under each handler (`:interpreters
42
+ ["async" "sync" "sim"]`): readings never go back, `Delay(d)` / `WaitUntil(t)` advance the clock by
43
+ `d` / up to `t` (exactly on the virtual clock, with a small jitter bound on the wall clock), past
44
+ targets return at once, other effects pass through, and `ScheduleAt` tasks run in time order and
45
+ answer their program's value or failure through `Wait`. `SetTime` is simulation-only and stays out
46
+ of the shared contract. Note: the scheduled program runs under the handlers outside the time
47
+ handler, so it cannot itself perform time effects.
48
+
49
+ ### Virtual time contract (`sim_time_handler`)
50
+
51
+ - Install inside `scheduled(...)` and outside every `Spawn` whose tasks sleep on it; spawned tasks
52
+ inherit the handler and share one clock.
53
+ - Time advances to the earliest pending wake-up only when no normal-priority task is runnable, so a
54
+ task woken at `t` reads `t` however many scheduler steps it takes.
55
+ - Wake-ups at the same instant resume in the order they were requested; the trace is identical on
56
+ the Rust scheduler (the default) and the Python one (`DOEFF_SCHEDULER=python`).
57
+ - The run ends when the root program returns, even while daemon services are still sleeping.
58
+ - `GetMonotonic()` answers the virtual clock's POSIX timestamp.
@@ -0,0 +1,18 @@
1
+ doeff_time/__init__.py,sha256=NYdLE7Fq_wHdsXRlRZYMMYNekBdyVOkVRSU1yk7-xzU,2110
2
+ doeff_time/epoch_ms.py,sha256=Bo_QO4pCYjyxiQkGK1j5xQKig26uhMu1kaz930hIjDc,1116
3
+ doeff_time/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ doeff_time/_internals/__init__.py,sha256=x6CLm0HU6ekQPaxG-hG9lYr3AR_t5gw6NGg-2rcR4AE,196
5
+ doeff_time/_internals/deadline.py,sha256=fFeKYDnV8tYa0-IcrNtep1pU9ptNat339xmfWCOh9Kw,423
6
+ doeff_time/_internals/sim_clock.py,sha256=3rYp861I3W292Vvtv8PbOSIXj4ccX4ef5C7TCRMlHqU,799
7
+ doeff_time/_internals/time_queue.py,sha256=G-5KmcXJkFeteg_aeSWheOGzETv-ZisY33Uq4RYPn5k,2829
8
+ doeff_time/_internals/validation.py,sha256=QOCZVDP42fsiRNxzTqinegiHK3TVTQ6-6_LaBcxFH3c,388
9
+ doeff_time/effects/__init__.py,sha256=3Ih-0f41D_81jsLmlmSrF05zxhSfKrT05FhJJNItUr4,1752
10
+ doeff_time/effects/time.py,sha256=qZrhy1WWlxsCAb-YGa5MV_785IzzEn4GTp0Y8-DQAdA,9542
11
+ doeff_time/handlers/__init__.py,sha256=Qu3t9f1iJw4sqMYoD1coAR7PgkEHsB_l2DFaSx_ttGU,293
12
+ doeff_time/handlers/_wall_ticks.py,sha256=-ONTYUbmd_FUFKa0rjehPJWV_vEbmzWmiEu78nTdcJY,2522
13
+ doeff_time/handlers/async_time.py,sha256=kGIBFM_eMLQzumHuIiOfOV2QXbuehl3I0ll0ggvaEaQ,5557
14
+ doeff_time/handlers/sim_time.py,sha256=V879gET0oJFSK2oeV4UMiFv8aVftZlxZgRwyn0M5KHM,14222
15
+ doeff_time/handlers/sync_time.py,sha256=_a2C4cQiHBKxqpxxLKUbTnSdxTX6uUa33d5qlAwycnY,4773
16
+ doeff_time-0.1.0.dist-info/METADATA,sha256=rNgXXz4ihxbclo7-NQP1J1enZWwbmpWeOLNMy7CZ5K8,2854
17
+ doeff_time-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ doeff_time-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any