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.
Files changed (42) hide show
  1. chrono_daemon/__init__.py +53 -0
  2. chrono_daemon/_logging.py +32 -0
  3. chrono_daemon/_types.py +38 -0
  4. chrono_daemon/channel.py +212 -0
  5. chrono_daemon/clock.py +190 -0
  6. chrono_daemon/context.py +38 -0
  7. chrono_daemon/daemon.py +80 -0
  8. chrono_daemon/recipes/__init__.py +35 -0
  9. chrono_daemon/recipes/_flow.py +44 -0
  10. chrono_daemon/recipes/batcher.py +5 -0
  11. chrono_daemon/recipes/cooperative_every.py +5 -0
  12. chrono_daemon/recipes/coordination/__init__.py +7 -0
  13. chrono_daemon/recipes/coordination/batcher.py +166 -0
  14. chrono_daemon/recipes/coordination/cooperative_every.py +17 -0
  15. chrono_daemon/recipes/coordination/select.py +48 -0
  16. chrono_daemon/recipes/fanout.py +5 -0
  17. chrono_daemon/recipes/hosting/__init__.py +6 -0
  18. chrono_daemon/recipes/hosting/supervisor_host.py +107 -0
  19. chrono_daemon/recipes/hosting/sync_bridge.py +92 -0
  20. chrono_daemon/recipes/latest.py +5 -0
  21. chrono_daemon/recipes/load_balance.py +5 -0
  22. chrono_daemon/recipes/lossy.py +5 -0
  23. chrono_daemon/recipes/merge.py +5 -0
  24. chrono_daemon/recipes/routing/__init__.py +8 -0
  25. chrono_daemon/recipes/routing/fanout.py +29 -0
  26. chrono_daemon/recipes/routing/load_balance.py +49 -0
  27. chrono_daemon/recipes/routing/merge.py +53 -0
  28. chrono_daemon/recipes/routing/worker_pool.py +89 -0
  29. chrono_daemon/recipes/select.py +5 -0
  30. chrono_daemon/recipes/state/__init__.py +6 -0
  31. chrono_daemon/recipes/state/latest.py +24 -0
  32. chrono_daemon/recipes/state/lossy.py +102 -0
  33. chrono_daemon/recipes/supervisor_host.py +5 -0
  34. chrono_daemon/recipes/sync_bridge.py +5 -0
  35. chrono_daemon/recipes/worker_pool.py +5 -0
  36. chrono_daemon/supervisor.py +376 -0
  37. chrono_daemon/transports/__init__.py +5 -0
  38. chrono_daemon/transports/zmq.py +345 -0
  39. chrono_daemon-0.1.0.dist-info/METADATA +126 -0
  40. chrono_daemon-0.1.0.dist-info/RECORD +42 -0
  41. chrono_daemon-0.1.0.dist-info/WHEEL +4 -0
  42. 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
@@ -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."""
@@ -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
@@ -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()
@@ -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
+ """