chrono-daemon 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.
- chrono_daemon/__init__.py +53 -0
- chrono_daemon/_logging.py +32 -0
- chrono_daemon/_types.py +38 -0
- chrono_daemon/channel.py +212 -0
- chrono_daemon/clock.py +190 -0
- chrono_daemon/context.py +38 -0
- chrono_daemon/daemon.py +80 -0
- chrono_daemon/recipes/__init__.py +35 -0
- chrono_daemon/recipes/_flow.py +44 -0
- chrono_daemon/recipes/batcher.py +5 -0
- chrono_daemon/recipes/cooperative_every.py +5 -0
- chrono_daemon/recipes/coordination/__init__.py +7 -0
- chrono_daemon/recipes/coordination/batcher.py +166 -0
- chrono_daemon/recipes/coordination/cooperative_every.py +17 -0
- chrono_daemon/recipes/coordination/select.py +48 -0
- chrono_daemon/recipes/fanout.py +5 -0
- chrono_daemon/recipes/hosting/__init__.py +6 -0
- chrono_daemon/recipes/hosting/supervisor_host.py +107 -0
- chrono_daemon/recipes/hosting/sync_bridge.py +92 -0
- chrono_daemon/recipes/latest.py +5 -0
- chrono_daemon/recipes/load_balance.py +5 -0
- chrono_daemon/recipes/lossy.py +5 -0
- chrono_daemon/recipes/merge.py +5 -0
- chrono_daemon/recipes/routing/__init__.py +8 -0
- chrono_daemon/recipes/routing/fanout.py +29 -0
- chrono_daemon/recipes/routing/load_balance.py +49 -0
- chrono_daemon/recipes/routing/merge.py +53 -0
- chrono_daemon/recipes/routing/worker_pool.py +89 -0
- chrono_daemon/recipes/select.py +5 -0
- chrono_daemon/recipes/state/__init__.py +6 -0
- chrono_daemon/recipes/state/latest.py +24 -0
- chrono_daemon/recipes/state/lossy.py +102 -0
- chrono_daemon/recipes/supervisor_host.py +5 -0
- chrono_daemon/recipes/sync_bridge.py +5 -0
- chrono_daemon/recipes/worker_pool.py +5 -0
- chrono_daemon/supervisor.py +376 -0
- chrono_daemon/transports/__init__.py +5 -0
- chrono_daemon/transports/zmq.py +345 -0
- chrono_daemon-0.1.0.dist-info/METADATA +126 -0
- chrono_daemon-0.1.0.dist-info/RECORD +42 -0
- chrono_daemon-0.1.0.dist-info/WHEEL +4 -0
- chrono_daemon-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Tiny anyio-based concurrency primitives.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- :class:`Channel`, :class:`SendStream`, :class:`ReceiveStream`, :func:`open_channel`,
|
|
6
|
+
:exc:`EndOfStream`, :exc:`ChannelClosed`, :exc:`ChannelInUse`
|
|
7
|
+
- :class:`Clock`, :class:`WallClock`, :class:`SimClock`
|
|
8
|
+
- :class:`Context`
|
|
9
|
+
- :class:`Daemon`, :func:`daemon`
|
|
10
|
+
- :class:`Supervisor`, :class:`RestartPolicy`, :data:`OnError`
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version as _package_version
|
|
16
|
+
|
|
17
|
+
from chrono_daemon._types import ChannelClosed, ChannelInUse, DaemonError, EndOfStream, OnError, WouldBlock
|
|
18
|
+
from chrono_daemon.channel import Channel, ChannelStats, ReceiveStream, SendStream, open_channel
|
|
19
|
+
from chrono_daemon.clock import Clock, SimClock, WallClock
|
|
20
|
+
from chrono_daemon.context import Context
|
|
21
|
+
from chrono_daemon.daemon import Daemon, daemon
|
|
22
|
+
from chrono_daemon.supervisor import DaemonFailurePhase, DaemonHealth, DaemonState, RestartPolicy, Supervisor
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
__version__ = _package_version("chrono-daemon")
|
|
26
|
+
except PackageNotFoundError:
|
|
27
|
+
__version__ = "0.0.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Channel",
|
|
31
|
+
"ChannelClosed",
|
|
32
|
+
"ChannelInUse",
|
|
33
|
+
"ChannelStats",
|
|
34
|
+
"Clock",
|
|
35
|
+
"Context",
|
|
36
|
+
"Daemon",
|
|
37
|
+
"DaemonError",
|
|
38
|
+
"DaemonFailurePhase",
|
|
39
|
+
"DaemonHealth",
|
|
40
|
+
"DaemonState",
|
|
41
|
+
"EndOfStream",
|
|
42
|
+
"OnError",
|
|
43
|
+
"ReceiveStream",
|
|
44
|
+
"RestartPolicy",
|
|
45
|
+
"SendStream",
|
|
46
|
+
"SimClock",
|
|
47
|
+
"Supervisor",
|
|
48
|
+
"WallClock",
|
|
49
|
+
"WouldBlock",
|
|
50
|
+
"__version__",
|
|
51
|
+
"daemon",
|
|
52
|
+
"open_channel",
|
|
53
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Internal: clock-aware logging adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import MutableMapping
|
|
10
|
+
|
|
11
|
+
from chrono_daemon.clock import Clock
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["ClockAwareLoggerAdapter"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClockAwareLoggerAdapter(logging.LoggerAdapter):
|
|
18
|
+
"""LoggerAdapter that adds ``clock.now()`` as ``sim_time``."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, logger: logging.Logger, clock: Clock) -> None:
|
|
21
|
+
super().__init__(logger, {})
|
|
22
|
+
self._clock = clock
|
|
23
|
+
|
|
24
|
+
def process(
|
|
25
|
+
self,
|
|
26
|
+
msg: Any,
|
|
27
|
+
kwargs: MutableMapping[str, Any],
|
|
28
|
+
) -> tuple[Any, MutableMapping[str, Any]]:
|
|
29
|
+
extra = kwargs.setdefault("extra", {})
|
|
30
|
+
# Don't overwrite an explicit sim_time the user already passed.
|
|
31
|
+
extra.setdefault("sim_time", self._clock.now())
|
|
32
|
+
return msg, kwargs
|
chrono_daemon/_types.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Internal type aliases and exceptions for chrono_daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ChannelClosed",
|
|
9
|
+
"ChannelInUse",
|
|
10
|
+
"DaemonError",
|
|
11
|
+
"EndOfStream",
|
|
12
|
+
"OnError",
|
|
13
|
+
"WouldBlock",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EndOfStream(Exception):
|
|
18
|
+
"""Raised after the send side is closed and the buffer is drained."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChannelClosed(Exception):
|
|
22
|
+
"""Raised after the receive side has closed."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChannelInUse(Exception):
|
|
26
|
+
"""Raised when a channel endpoint is used concurrently."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WouldBlock(Exception):
|
|
30
|
+
"""Raised when a nowait operation cannot complete immediately."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DaemonError(Exception):
|
|
34
|
+
"""Wraps an exception that escaped a daemon lifecycle method."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
OnError = Literal["shutdown", "restart", "ignore"]
|
|
38
|
+
"""Supervisor policy for uncaught daemon exceptions."""
|
chrono_daemon/channel.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Typed single-producer / single-consumer channel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, Protocol, TypeVar
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
11
|
+
|
|
12
|
+
from chrono_daemon._types import ChannelClosed, ChannelInUse, EndOfStream, WouldBlock
|
|
13
|
+
|
|
14
|
+
__all__ = ["Channel", "ChannelStats", "ReceiveStream", "SendStream", "open_channel"]
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ChannelStats:
|
|
21
|
+
"""Transport-agnostic channel diagnostics."""
|
|
22
|
+
|
|
23
|
+
current_buffer_used: int
|
|
24
|
+
"""Items currently buffered in the channel."""
|
|
25
|
+
|
|
26
|
+
max_buffer_size: float
|
|
27
|
+
"""Configured buffer capacity. ``math.inf`` if unbounded; ``0`` for rendezvous."""
|
|
28
|
+
|
|
29
|
+
open_send_streams: int
|
|
30
|
+
"""How many ``SendStream`` instances are still open."""
|
|
31
|
+
|
|
32
|
+
open_receive_streams: int
|
|
33
|
+
"""How many ``ReceiveStream`` instances are still open."""
|
|
34
|
+
|
|
35
|
+
waiters_send: int
|
|
36
|
+
"""Tasks currently blocked in ``send()`` waiting for buffer space."""
|
|
37
|
+
|
|
38
|
+
waiters_receive: int
|
|
39
|
+
"""Tasks currently blocked in ``receive()`` waiting for an item."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SendStream(Protocol[T]):
|
|
43
|
+
"""Sender side of a channel. Intended to be owned by one active producer."""
|
|
44
|
+
|
|
45
|
+
async def send(self, item: T) -> None:
|
|
46
|
+
"""Send an item. Blocks (under the channel's backpressure) until a slot is free.
|
|
47
|
+
|
|
48
|
+
Raises ``ChannelClosed`` if the receive side has been closed.
|
|
49
|
+
"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def send_nowait(self, item: T) -> None:
|
|
53
|
+
"""Send an item without blocking. Raises ``WouldBlock`` if the buffer is full.
|
|
54
|
+
|
|
55
|
+
Raises ``ChannelClosed`` if the receive side has been closed.
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
async def aclose(self) -> None:
|
|
60
|
+
"""Close the send side. Waiting receivers get ``EndOfStream`` after the buffer drains."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def statistics(self) -> ChannelStats:
|
|
64
|
+
"""Snapshot the channel's runtime state for diagnostics. Read-only and cheap."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ReceiveStream(Protocol[T]):
|
|
69
|
+
"""Receiver side of a channel. Intended to be owned by one active consumer."""
|
|
70
|
+
|
|
71
|
+
async def receive(self) -> T:
|
|
72
|
+
"""Receive one item. Blocks until an item is available.
|
|
73
|
+
|
|
74
|
+
Raises ``EndOfStream`` if the send side has been closed and the buffer is drained.
|
|
75
|
+
"""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
def receive_nowait(self) -> T:
|
|
79
|
+
"""Receive one item without blocking. Raises ``WouldBlock`` if the buffer is empty.
|
|
80
|
+
|
|
81
|
+
Raises ``EndOfStream`` if the send side has closed and the buffer is
|
|
82
|
+
drained.
|
|
83
|
+
"""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
87
|
+
"""Iterate until ``EndOfStream`` (which is swallowed by the iterator protocol)."""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
async def aclose(self) -> None:
|
|
91
|
+
"""Close the receive side. Producers calling ``send()`` get ``ChannelClosed``."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def statistics(self) -> ChannelStats:
|
|
95
|
+
"""Snapshot the channel's runtime state for diagnostics. Read-only and cheap."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Channel(Generic[T]):
|
|
101
|
+
"""A typed bounded channel.
|
|
102
|
+
|
|
103
|
+
The ``send`` and ``recv`` attributes are independent endpoints; you typically pass
|
|
104
|
+
one into a producer daemon and the other into a consumer daemon.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
send: SendStream[T]
|
|
108
|
+
recv: ReceiveStream[T]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _stats_from_anyio(inner: MemoryObjectSendStream[T] | MemoryObjectReceiveStream[T]) -> ChannelStats:
|
|
112
|
+
s = inner.statistics()
|
|
113
|
+
return ChannelStats(
|
|
114
|
+
current_buffer_used=s.current_buffer_used,
|
|
115
|
+
max_buffer_size=s.max_buffer_size,
|
|
116
|
+
open_send_streams=s.open_send_streams,
|
|
117
|
+
open_receive_streams=s.open_receive_streams,
|
|
118
|
+
waiters_send=s.tasks_waiting_send,
|
|
119
|
+
waiters_receive=s.tasks_waiting_receive,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _Send(Generic[T]):
|
|
124
|
+
"""In-process ``SendStream`` wrapping an anyio memory object send stream."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, inner: MemoryObjectSendStream[T]) -> None:
|
|
127
|
+
self._inner = inner
|
|
128
|
+
self._busy = False
|
|
129
|
+
|
|
130
|
+
async def send(self, item: T) -> None:
|
|
131
|
+
if self._busy:
|
|
132
|
+
raise ChannelInUse("send endpoint already has an active sender")
|
|
133
|
+
self._busy = True
|
|
134
|
+
try:
|
|
135
|
+
await self._inner.send(item)
|
|
136
|
+
except anyio.BrokenResourceError as e:
|
|
137
|
+
raise ChannelClosed("receive side closed") from e
|
|
138
|
+
except anyio.ClosedResourceError as e:
|
|
139
|
+
raise ChannelClosed("send side already closed") from e
|
|
140
|
+
finally:
|
|
141
|
+
self._busy = False
|
|
142
|
+
|
|
143
|
+
def send_nowait(self, item: T) -> None:
|
|
144
|
+
try:
|
|
145
|
+
self._inner.send_nowait(item)
|
|
146
|
+
except anyio.WouldBlock as e:
|
|
147
|
+
raise WouldBlock("channel buffer full") from e
|
|
148
|
+
except anyio.BrokenResourceError as e:
|
|
149
|
+
raise ChannelClosed("receive side closed") from e
|
|
150
|
+
except anyio.ClosedResourceError as e:
|
|
151
|
+
raise ChannelClosed("send side already closed") from e
|
|
152
|
+
|
|
153
|
+
async def aclose(self) -> None:
|
|
154
|
+
await self._inner.aclose()
|
|
155
|
+
|
|
156
|
+
def statistics(self) -> ChannelStats:
|
|
157
|
+
return _stats_from_anyio(self._inner)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _Recv(Generic[T]):
|
|
161
|
+
"""In-process ``ReceiveStream`` wrapping an anyio memory object receive stream."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, inner: MemoryObjectReceiveStream[T]) -> None:
|
|
164
|
+
self._inner = inner
|
|
165
|
+
self._busy = False
|
|
166
|
+
|
|
167
|
+
async def receive(self) -> T:
|
|
168
|
+
if self._busy:
|
|
169
|
+
raise ChannelInUse("receive endpoint already has an active receiver")
|
|
170
|
+
self._busy = True
|
|
171
|
+
try:
|
|
172
|
+
return await self._inner.receive()
|
|
173
|
+
except anyio.EndOfStream as e:
|
|
174
|
+
raise EndOfStream from e
|
|
175
|
+
except anyio.ClosedResourceError as e:
|
|
176
|
+
raise EndOfStream from e
|
|
177
|
+
finally:
|
|
178
|
+
self._busy = False
|
|
179
|
+
|
|
180
|
+
def receive_nowait(self) -> T:
|
|
181
|
+
try:
|
|
182
|
+
return self._inner.receive_nowait()
|
|
183
|
+
except anyio.WouldBlock as e:
|
|
184
|
+
raise WouldBlock("channel empty") from e
|
|
185
|
+
except anyio.EndOfStream as e:
|
|
186
|
+
raise EndOfStream from e
|
|
187
|
+
except anyio.ClosedResourceError as e:
|
|
188
|
+
raise EndOfStream from e
|
|
189
|
+
|
|
190
|
+
async def __aiter__(self) -> AsyncIterator[T]: # type: ignore[override]
|
|
191
|
+
# Route through receive() for our EndOfStream conversion.
|
|
192
|
+
while True:
|
|
193
|
+
try:
|
|
194
|
+
yield await self.receive()
|
|
195
|
+
except EndOfStream:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
async def aclose(self) -> None:
|
|
199
|
+
await self._inner.aclose()
|
|
200
|
+
|
|
201
|
+
def statistics(self) -> ChannelStats:
|
|
202
|
+
return _stats_from_anyio(self._inner)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def open_channel(maxsize: int = 0) -> Channel[T]:
|
|
206
|
+
"""Open a new in-process channel.
|
|
207
|
+
|
|
208
|
+
``maxsize=0`` is rendezvous. Positive values allow buffering. Concurrent
|
|
209
|
+
blocking use of one endpoint raises ``ChannelInUse``.
|
|
210
|
+
"""
|
|
211
|
+
send_inner, recv_inner = anyio.create_memory_object_stream[T](max_buffer_size=maxsize)
|
|
212
|
+
return Channel(send=_Send(send_inner), recv=_Recv(recv_inner))
|
chrono_daemon/clock.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Clock abstractions for real time and deterministic replay.
|
|
2
|
+
|
|
3
|
+
Daemons should use ``ctx.clock`` instead of ``anyio.sleep`` so ``SimClock`` can
|
|
4
|
+
control time during tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import heapq
|
|
10
|
+
import itertools
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
import anyio
|
|
17
|
+
import anyio.lowlevel
|
|
18
|
+
|
|
19
|
+
__all__ = ["Clock", "SimClock", "WallClock"]
|
|
20
|
+
|
|
21
|
+
# Checkpoints yielded between heap inspections so woken tasks can register
|
|
22
|
+
# follow-up sleeps. anyio has no portable "run until idle" primitive.
|
|
23
|
+
_DEFAULT_SETTLE_ROUNDS = 8
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Clock(Protocol):
|
|
27
|
+
"""Minimal time interface used by chrono-daemon daemons."""
|
|
28
|
+
|
|
29
|
+
def now(self) -> float:
|
|
30
|
+
"""Return current time in seconds. Monotonic; the units (epoch vs zero-based) are clock-specific."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def sleep(self, seconds: float) -> None:
|
|
34
|
+
"""Sleep for ``seconds`` of clock-time. Non-positive values yield once and return."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
async def wait_until(self, deadline: float) -> None:
|
|
38
|
+
"""Sleep until absolute clock-time ``deadline``. Past deadlines yield once and return."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def every(self, period: float) -> AsyncIterator[float]:
|
|
42
|
+
"""Yield clock-time stamps every ``period`` seconds.
|
|
43
|
+
|
|
44
|
+
The first tick is at ``now() + period``. If the consumer falls behind,
|
|
45
|
+
missed ticks are skipped.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WallClock:
|
|
51
|
+
"""Real-time clock delegating to anyio's monotonic clock and sleep."""
|
|
52
|
+
|
|
53
|
+
def now(self) -> float:
|
|
54
|
+
return time.monotonic()
|
|
55
|
+
|
|
56
|
+
async def sleep(self, seconds: float) -> None:
|
|
57
|
+
if seconds <= 0:
|
|
58
|
+
await anyio.lowlevel.checkpoint()
|
|
59
|
+
return
|
|
60
|
+
await anyio.sleep(seconds)
|
|
61
|
+
|
|
62
|
+
async def wait_until(self, deadline: float) -> None:
|
|
63
|
+
await self.sleep(deadline - self.now())
|
|
64
|
+
|
|
65
|
+
async def every(self, period: float) -> AsyncIterator[float]:
|
|
66
|
+
if period <= 0:
|
|
67
|
+
raise ValueError(f"every() period must be positive, got {period}")
|
|
68
|
+
next_t = self.now() + period
|
|
69
|
+
while True:
|
|
70
|
+
now = self.now()
|
|
71
|
+
if next_t > now:
|
|
72
|
+
await self.sleep(next_t - now)
|
|
73
|
+
yield next_t
|
|
74
|
+
# Skip missed ticks to maintain monotonic target schedule.
|
|
75
|
+
now = self.now()
|
|
76
|
+
while next_t <= now:
|
|
77
|
+
next_t += period
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class _Waiter:
|
|
82
|
+
"""One sleeper registered against a SimClock."""
|
|
83
|
+
|
|
84
|
+
deadline: float
|
|
85
|
+
seq: int
|
|
86
|
+
event: anyio.Event = field(compare=False)
|
|
87
|
+
cancelled: bool = field(default=False, compare=False)
|
|
88
|
+
|
|
89
|
+
def __lt__(self, other: _Waiter) -> bool:
|
|
90
|
+
return (self.deadline, self.seq) < (other.deadline, other.seq)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SimClock:
|
|
94
|
+
"""Deterministic virtual clock.
|
|
95
|
+
|
|
96
|
+
Time only moves when a driver task calls ``advance(dt)`` or ``advance_to(t)``.
|
|
97
|
+
All sleepers are released exactly at their registered deadline, in deadline order,
|
|
98
|
+
with insertion order as the tiebreaker.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, t0: float = 0.0, *, settle_rounds: int = _DEFAULT_SETTLE_ROUNDS) -> None:
|
|
102
|
+
if settle_rounds < 1:
|
|
103
|
+
raise ValueError(f"settle_rounds must be >= 1, got {settle_rounds}")
|
|
104
|
+
self._t = float(t0)
|
|
105
|
+
self._settle_rounds = settle_rounds
|
|
106
|
+
# Min-heap of pending sleepers ordered by (deadline, seq). seq breaks
|
|
107
|
+
# ties stably. Cancelled entries stay in the heap with cancelled=True
|
|
108
|
+
# and are skipped on pop.
|
|
109
|
+
self._waiters: list[_Waiter] = []
|
|
110
|
+
self._seq = itertools.count()
|
|
111
|
+
|
|
112
|
+
def now(self) -> float:
|
|
113
|
+
return self._t
|
|
114
|
+
|
|
115
|
+
async def sleep(self, seconds: float) -> None:
|
|
116
|
+
# Checkpoint first so a freshly-cancelled caller exits before enqueueing.
|
|
117
|
+
await anyio.lowlevel.checkpoint()
|
|
118
|
+
if seconds <= 0:
|
|
119
|
+
return
|
|
120
|
+
await self.wait_until(self._t + seconds)
|
|
121
|
+
|
|
122
|
+
async def wait_until(self, deadline: float) -> None:
|
|
123
|
+
# Checkpoint first so a freshly-cancelled caller exits before enqueueing.
|
|
124
|
+
await anyio.lowlevel.checkpoint()
|
|
125
|
+
if deadline <= self._t:
|
|
126
|
+
return
|
|
127
|
+
waiter = _Waiter(deadline=deadline, seq=next(self._seq), event=anyio.Event())
|
|
128
|
+
heapq.heappush(self._waiters, waiter)
|
|
129
|
+
try:
|
|
130
|
+
await waiter.event.wait()
|
|
131
|
+
finally:
|
|
132
|
+
# Lazy delete: skip cancelled entries on pop instead of removing
|
|
133
|
+
# them from the middle of the heap.
|
|
134
|
+
if not waiter.event.is_set():
|
|
135
|
+
waiter.cancelled = True
|
|
136
|
+
while self._waiters and self._waiters[0].cancelled:
|
|
137
|
+
heapq.heappop(self._waiters)
|
|
138
|
+
|
|
139
|
+
async def advance(self, dt: float) -> None:
|
|
140
|
+
"""Advance virtual time by ``dt`` seconds."""
|
|
141
|
+
if dt < 0:
|
|
142
|
+
raise ValueError(f"cannot advance backwards (dt={dt})")
|
|
143
|
+
await self.advance_to(self._t + dt)
|
|
144
|
+
|
|
145
|
+
async def advance_to(self, t: float) -> None:
|
|
146
|
+
"""Advance virtual time to absolute ``t``."""
|
|
147
|
+
if t < self._t:
|
|
148
|
+
return
|
|
149
|
+
while True:
|
|
150
|
+
self._drop_cancelled_tops()
|
|
151
|
+
if not self._waiters or self._waiters[0].deadline > t:
|
|
152
|
+
if not await self._poll_for_inrange(t):
|
|
153
|
+
break
|
|
154
|
+
continue
|
|
155
|
+
waiter = heapq.heappop(self._waiters)
|
|
156
|
+
if waiter.cancelled:
|
|
157
|
+
# Raced with cancellation between top-skip and pop; just retry.
|
|
158
|
+
continue
|
|
159
|
+
self._t = waiter.deadline
|
|
160
|
+
waiter.event.set()
|
|
161
|
+
for _ in range(self._settle_rounds):
|
|
162
|
+
await anyio.lowlevel.checkpoint()
|
|
163
|
+
self._t = max(self._t, t)
|
|
164
|
+
|
|
165
|
+
def _drop_cancelled_tops(self) -> None:
|
|
166
|
+
"""Pop any cancelled entries sitting at the top of the heap."""
|
|
167
|
+
while self._waiters and self._waiters[0].cancelled:
|
|
168
|
+
heapq.heappop(self._waiters)
|
|
169
|
+
|
|
170
|
+
async def _poll_for_inrange(self, t: float) -> bool:
|
|
171
|
+
"""Settle woken tasks and report whether a due sleeper appeared."""
|
|
172
|
+
for _ in range(self._settle_rounds):
|
|
173
|
+
await anyio.lowlevel.checkpoint()
|
|
174
|
+
self._drop_cancelled_tops()
|
|
175
|
+
return bool(self._waiters) and self._waiters[0].deadline <= t
|
|
176
|
+
|
|
177
|
+
async def every(self, period: float) -> AsyncIterator[float]:
|
|
178
|
+
if period <= 0:
|
|
179
|
+
raise ValueError(f"every() period must be positive, got {period}")
|
|
180
|
+
next_t = self._t + period
|
|
181
|
+
while True:
|
|
182
|
+
delta = next_t - self._t
|
|
183
|
+
if delta > 0:
|
|
184
|
+
await self.wait_until(next_t)
|
|
185
|
+
yield next_t
|
|
186
|
+
# Skip missed ticks instead of catching up in a burst.
|
|
187
|
+
next_t += period
|
|
188
|
+
now = self._t
|
|
189
|
+
while next_t <= now:
|
|
190
|
+
next_t += period
|
chrono_daemon/context.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Per-daemon execution context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from chrono_daemon._logging import ClockAwareLoggerAdapter
|
|
13
|
+
from chrono_daemon.clock import Clock
|
|
14
|
+
from chrono_daemon.supervisor import Supervisor
|
|
15
|
+
|
|
16
|
+
__all__ = ["Context"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Context:
|
|
21
|
+
"""Handle passed to each daemon's ``run(ctx)``.
|
|
22
|
+
|
|
23
|
+
Use ``ctx.clock`` for sleeps. ``ctx.cancel_scope`` is per-daemon; use
|
|
24
|
+
``ctx.supervisor.signal_stop()`` to request whole-supervisor shutdown.
|
|
25
|
+
``ctx.stopping`` and ``ctx.stop_event`` expose that stop signal.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
clock: Clock
|
|
29
|
+
cancel_scope: anyio.CancelScope
|
|
30
|
+
logger: ClockAwareLoggerAdapter | logging.Logger
|
|
31
|
+
name: str
|
|
32
|
+
supervisor: Supervisor
|
|
33
|
+
stop_event: anyio.Event
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def stopping(self) -> bool:
|
|
37
|
+
"""``True`` once ``supervisor.signal_stop()`` (or ``stop()``) has been called."""
|
|
38
|
+
return self.stop_event.is_set()
|
chrono_daemon/daemon.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Daemon lifecycle base class and decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable, Coroutine
|
|
8
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from chrono_daemon.context import Context
|
|
12
|
+
|
|
13
|
+
__all__ = ["Daemon", "daemon"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Daemon(ABC):
|
|
17
|
+
"""Abstract long-running async unit."""
|
|
18
|
+
|
|
19
|
+
name: str = ""
|
|
20
|
+
|
|
21
|
+
async def on_start(self, ctx: Context) -> None:
|
|
22
|
+
"""Called once before ``run``. Defaults to no-op."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def run(self, ctx: Context) -> None:
|
|
26
|
+
"""Main body. Use ``ctx.clock`` for sleeps."""
|
|
27
|
+
|
|
28
|
+
async def on_stop(self, ctx: Context) -> None:
|
|
29
|
+
"""Called once after ``run`` completes (or after a final restart failure). Defaults to no-op."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _FnDaemon(Daemon):
|
|
33
|
+
"""Internal adapter wrapping a function into a Daemon. Returned by ``@daemon``."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
fn: Callable[..., Coroutine[Any, Any, None]],
|
|
38
|
+
args: tuple[Any, ...],
|
|
39
|
+
kwargs: dict[str, Any],
|
|
40
|
+
name: str,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._fn = fn
|
|
43
|
+
self._args = args
|
|
44
|
+
self._kwargs = kwargs
|
|
45
|
+
self.name = name
|
|
46
|
+
|
|
47
|
+
async def run(self, ctx: Context) -> None:
|
|
48
|
+
await self._fn(ctx, *self._args, **self._kwargs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_AsyncFn = Callable[..., Coroutine[Any, Any, None]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@overload
|
|
55
|
+
def daemon(fn: _AsyncFn, /) -> Callable[..., Daemon]: ...
|
|
56
|
+
@overload
|
|
57
|
+
def daemon(*, name: str | None = None) -> Callable[[_AsyncFn], Callable[..., Daemon]]: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def daemon( # type: ignore[misc]
|
|
61
|
+
fn: Callable[..., Coroutine[Any, Any, None]] | None = None,
|
|
62
|
+
*,
|
|
63
|
+
name: str | None = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""Turn ``async def fn(ctx, *args, **kwargs)`` into a Daemon factory."""
|
|
66
|
+
|
|
67
|
+
def wrap(f: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., Daemon]:
|
|
68
|
+
chosen_name = name or f.__name__
|
|
69
|
+
|
|
70
|
+
@functools.wraps(f)
|
|
71
|
+
def factory(*args: Any, **kwargs: Any) -> Daemon:
|
|
72
|
+
return _FnDaemon(f, args, kwargs, chosen_name)
|
|
73
|
+
|
|
74
|
+
# Marker for debug/repr tooling; not part of the public API.
|
|
75
|
+
factory._chrono_daemon_factory = True # type: ignore[attr-defined]
|
|
76
|
+
return factory
|
|
77
|
+
|
|
78
|
+
if fn is not None:
|
|
79
|
+
return wrap(fn)
|
|
80
|
+
return wrap
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Importable helpers kept outside the core surface.
|
|
2
|
+
|
|
3
|
+
- The core primitives in ``chrono_daemon`` follow strict semver: breaking changes
|
|
4
|
+
require an ADR and a major bump.
|
|
5
|
+
- ``chrono_daemon.recipes`` are best-effort references. Their signatures may
|
|
6
|
+
change between minor releases without an ADR. If you depend on one in
|
|
7
|
+
production code, pin a version or vendor it.
|
|
8
|
+
|
|
9
|
+
Use them by importing directly:
|
|
10
|
+
|
|
11
|
+
# Routing
|
|
12
|
+
from chrono_daemon.recipes.fanout import tee
|
|
13
|
+
from chrono_daemon.recipes.merge import merge
|
|
14
|
+
from chrono_daemon.recipes.load_balance import load_balance
|
|
15
|
+
from chrono_daemon.recipes.worker_pool import worker_pool
|
|
16
|
+
|
|
17
|
+
# Coordination
|
|
18
|
+
from chrono_daemon.recipes.select import select
|
|
19
|
+
from chrono_daemon.recipes.batcher import batcher_loop, submit
|
|
20
|
+
from chrono_daemon.recipes.cooperative_every import cooperative_every
|
|
21
|
+
|
|
22
|
+
# State and buffering
|
|
23
|
+
from chrono_daemon.recipes.latest import Latest
|
|
24
|
+
from chrono_daemon.recipes.lossy import DropOldestSend, DropNewestSend, CoalesceSend
|
|
25
|
+
|
|
26
|
+
# Hosting
|
|
27
|
+
from chrono_daemon.recipes.supervisor_host import SupervisorHost
|
|
28
|
+
from chrono_daemon.recipes.sync_bridge import host_async_dispatcher
|
|
29
|
+
|
|
30
|
+
The implementation is grouped into ``routing``, ``coordination``, ``state``,
|
|
31
|
+
and ``hosting`` subpackages. Top-level recipe modules remain compatibility
|
|
32
|
+
imports.
|
|
33
|
+
|
|
34
|
+
The corresponding design notes live in ``docs/recipes.md``.
|
|
35
|
+
"""
|