Coverage for core / src / sensorkit / api / entrypoint.py: 85%
151 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2import asyncio
3import concurrent.futures
4import contextlib
5import os
6import signal
7import time
8from collections.abc import Callable, Coroutine, Mapping
9from random import random
10from typing import Any
12from loguru import logger
14from sensorkit.api.declarative import Service
15from sensorkit.common.aio import cleanup_future, scoped_waiter
16from sensorkit.common.importutil import obj_from_spec
18type ServiceEntrypointFunc = Callable[[Service], Coroutine[Any, Any, None]]
20FORCE_QUIT_INTERRUPTS = 3
23class ServiceEntrypoint:
24 """Proxy for a service entrypoint function."""
26 @classmethod
27 def from_spec(cls, spec: str, load_file: bool = False):
28 """Finds a service entrypoint in a module or python file."""
29 return obj_from_spec(spec=spec, base=ServiceEntrypoint, load_file=load_file)
31 def __init__(self, func: ServiceEntrypointFunc, version: str):
32 self._func = func
33 self._version = version
35 async def run(self, name: str):
36 """Start the service with *name* and return `(service, task)` once it is running."""
37 # Invoke the user entrypoint function passing in a new Service instance.
38 service = Service(name, self._version)
39 task = asyncio.create_task(self._func(service))
41 try:
42 # Wait for the service to start up or for the task to fail.
43 await asyncio.wait(
44 [service.running, task],
45 return_when=asyncio.FIRST_COMPLETED,
46 )
48 if not service.running.done():
49 if e := task.exception():
50 logger.debug(f"Service '{name}' failed to start: {type(e).__name__}: {e}")
51 raise e
52 else:
53 raise RuntimeError(f"Entrypoint '{self._func.__name__}' did not start the service!")
55 # If the service raised, propagate the exception.
56 await service.running
58 return service, task
59 except asyncio.CancelledError:
60 task.cancel()
62 try:
63 await task
64 except asyncio.CancelledError:
65 pass
66 except Exception as e:
67 logger.opt(exception=e).debug("exception during service shutdown")
69 for fut in (service.running, service.shutdown):
70 cleanup_future(fut)
72 raise
74 def __call__(self, *args, **kwargs):
75 raise RuntimeError("Service entrypoint cannot be called directly")
78def service_entrypoint(*, version: str):
79 """Returns a decorator for defining a service entrypoint."""
80 def decorator(func: ServiceEntrypointFunc):
81 return ServiceEntrypoint(func, version=version)
83 return decorator
86class ShutdownSignal(Exception):
87 """Raised to signal an orderly service shutdown across threads."""
90async def run_services(
91 entrypoints: Mapping[str, ServiceEntrypoint],
92 trap_signals: bool = True,
93 **kwargs,
94):
95 """Run a set of service entrypoints."""
96 interrupt_count = 0
98 # We have to use a `concurrent.futures.Future` here so it can be used by different event loops
99 # running on different threads.
100 shutdown = concurrent.futures.Future()
102 def signal_shutdown(sig=None, _frame=None):
103 nonlocal interrupt_count
105 match sig:
106 case signal.SIGTERM:
107 logger.info("Service process received shutdown signal")
108 case signal.SIGINT:
109 interrupt_count += 1
110 remaining = FORCE_QUIT_INTERRUPTS - interrupt_count
112 if interrupt_count == 1:
113 logger.warning("Service process received interrupt signal")
114 elif remaining > 0:
115 times = "one more time" if remaining == 1 else f"{remaining} more times"
116 logger.warning(f"Interrupt {times} to force exit")
117 else:
118 logger.error("Forced exit")
119 os._exit(130)
121 if not shutdown.done():
122 shutdown.set_exception(ShutdownSignal())
124 if trap_signals:
125 signal.signal(signal.SIGINT, signal_shutdown)
126 signal.signal(signal.SIGTERM, signal_shutdown)
128 # Run all services, each in its own thread with its own event loop per invocation.
129 try:
130 await asyncio.gather(
131 asyncio.shield(asyncio.wrap_future(shutdown)),
132 *(
133 asyncio.to_thread(_service_loop, name, entrypoint, shutdown, **kwargs)
134 for name, entrypoint in entrypoints.items()
135 )
136 )
137 except Exception:
138 # Shut down other services.
139 signal_shutdown()
140 raise
143def _restart_loop(
144 wait_fn: Callable[[float], bool],
145 *,
146 max_restarts: int | None = None,
147 startup_failure_threshold: float = 30.0,
148 restart_backoff_min: float = 5.0,
149 restart_backoff_max: float = 300.0,
150 restart_backoff_random: float = 5.0,
151 restart_backoff_factor: float = 1.8,
152):
153 """Restart loop generator. Yields once per iteration; caller runs the service body.
155 wait_fn(seconds) must return True to continue or False for graceful shutdown, and
156 raise to signal an abnormal stop.
157 """
158 restarts = 0
159 backoff = 0.0
161 while max_restarts is None or restarts <= max_restarts:
162 if not wait_fn(backoff):
163 return
165 start = time.monotonic()
166 yield
167 elapsed = time.monotonic() - start
169 if elapsed < startup_failure_threshold:
170 restarts += 1
171 backoff = backoff * restart_backoff_factor + random() * restart_backoff_random
172 backoff = min(max(backoff, restart_backoff_min), restart_backoff_max)
173 else:
174 restarts = 1
175 backoff = 0.0
178def _service_loop(
179 name: str,
180 entrypoint: ServiceEntrypoint,
181 shutdown_signal: concurrent.futures.Future,
182 startup_timeout: float = 300.0,
183 shutdown_timeout: float = 5.0,
184 **kwargs,
185):
186 last_err: Exception | None = None
188 def _wait_for_shutdown(timeout: float) -> bool:
189 if timeout > 0.0 and not shutdown_signal.done():
190 logger.info(f"Waiting {timeout:1.0f} sec before restarting {name} ...")
191 try:
192 shutdown_signal.result(timeout=timeout)
193 return False
194 except concurrent.futures.TimeoutError:
195 return True
197 try:
198 for _ in _restart_loop(_wait_for_shutdown, **kwargs):
199 logger.info(f"Starting {name}")
200 last_err = None
202 try:
203 asyncio.run(
204 _service_proc(
205 name, entrypoint, shutdown_signal, startup_timeout, shutdown_timeout
206 )
207 )
208 except Exception as e:
209 logger.error(f"Service {name} exited due to {type(e).__name__}")
210 last_err = e
211 except ShutdownSignal as e:
212 last_err = e
213 except Exception as e:
214 logger.error(f"Terminating {name} service loop due to {type(e).__name__}")
215 last_err = e
216 finally:
217 logger.debug(f"service loop for {name} exiting (last_err is {type(last_err).__name__}))")
219 # Make sure all other services exit, too.
220 if not shutdown_signal.done():
221 shutdown_signal.set_result(None)
223 if last_err:
224 raise last_err
227async def _service_proc(
228 name: str,
229 entrypoint: ServiceEntrypoint,
230 shutdown_signal: concurrent.futures.Future,
231 startup_timeout: float,
232 shutdown_timeout: float,
233):
234 service: Service | None = None
235 service_task: asyncio.Task | None = None
237 # Bind the cross-thread shutdown signal to this event loop.
238 shutdown = asyncio.wrap_future(shutdown_signal)
240 try:
241 async with scoped_waiter(asyncio.shield(shutdown)) as shutdown_wait:
242 startup = asyncio.create_task(entrypoint.run(name))
244 await asyncio.wait(
245 [startup, shutdown_wait],
246 return_when=asyncio.FIRST_COMPLETED,
247 timeout=startup_timeout,
248 )
250 if shutdown_wait.done():
251 startup.cancel()
253 with contextlib.suppress(asyncio.CancelledError):
254 await startup
256 return
258 if not startup.done():
259 raise TimeoutError(f"Service failed to start within {startup_timeout:.0f} seconds")
261 service, service_task = startup.result()
263 await asyncio.wait(
264 [service_task, shutdown_wait],
265 return_when=asyncio.FIRST_COMPLETED,
266 )
268 if not shutdown_wait.done():
269 await service_task
270 finally:
271 if service and not service.shutdown.done():
272 logger.info(f"Shutting down {name}")
274 # Try an orderly shutdown first.
275 with contextlib.suppress(Exception):
276 async with asyncio.timeout(shutdown_timeout):
277 await service.stop()
279 # Kill the user entrypoint task.
280 if service_task and not service_task.done():
281 service_task.cancel()
283 with contextlib.suppress(asyncio.CancelledError):
284 await service_task