Coverage for core / src / sensorkit / core / program.py: 94%
261 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
2from __future__ import annotations
4import asyncio
5import collections
6import contextlib
7import functools
8from abc import abstractmethod
9from datetime import datetime
10from typing import TYPE_CHECKING, Any, Callable, Literal
12from intervaltree import Interval
13from loguru import logger
14from pydantic import BaseModel, Field, model_validator
16from sensorkit.backend.event import Event
17from sensorkit.backend.request import ExtendedResponse, Request
18from sensorkit.common.aio import AsyncObserver
19from sensorkit.common.keyword import declare_keyword
20from sensorkit.core.entity import EntityClient, EntityInterface, EntityRef
21from sensorkit.core.executor import TaskFactoryFunc
22from sensorkit.core.task import TaskContextMap, TaskExecution
24if TYPE_CHECKING:
25 from sensorkit.core.client import SensorKit
28class OfferInterval(Interval):
29 """An Interval subclass with typed begin/end datetimes used to represent a program offer window."""
31 begin: datetime
32 end: datetime
33 data: Any = None
36@declare_keyword
37class ProgramOffering(BaseModel):
38 """Keyword publishing the set of offer windows during which this program can task a controller."""
40 offer_windows: list[OfferInterval]
43class ProgramEnableState(Event):
44 """Event indicating the Program enable state has changed."""
45 enabled: bool = False
46 controller: str | None = None
49class ProgramActiveState(Event):
50 """Event indicating the Program active state has changed."""
51 active: bool
52 origin: Literal["request", "init", "error"]
53 stopping: bool = False
54 contexts: TaskContextMap = Field(default_factory=TaskContextMap)
57class ProgramTaskingState(Event):
58 """Event indicating the Task a Program is currently executing, if any.
60 Carries the full `TaskExecution` envelope (not just an id) so observers see the same task shape
61 the controller publishes on its `TaskExecutionState`, including the controller-minted
62 `task_id`. `None` while the program is between tasks or idle.
63 """
64 executing_task: TaskExecution | None = None
66 @model_validator(mode="before")
67 @classmethod
68 def _migrate_legacy_executing_task(cls, data: Any) -> Any:
69 """Upgrade state persisted before the executing task carried a full `TaskExecution`.
71 The pre-split `ProgramTaskingStatus` recorded only `executing_task_id` — a bare task id.
72 A bare id cannot be rehydrated into a `TaskExecution` envelope, so a legacy id collapses to
73 `executing_task=None` (equivalent to "idle"); observers of the old shape only ever saw the
74 id, which the program re-publishes the moment it next dispatches a task.
75 """
76 if not isinstance(data, dict) or "executing_task" in data:
77 return data
79 if "executing_task_id" in data:
80 data = {k: v for k, v in data.items() if k != "executing_task_id"}
81 data["executing_task"] = None
83 return data
86class ProgramState(BaseModel):
87 """Internal state snapshot of a Program."""
88 enable_state: ProgramEnableState
89 active_state: ProgramActiveState
90 tasking_state: ProgramTaskingState
92 @model_validator(mode="before")
93 @classmethod
94 def _migrate_legacy_tasking_state(cls, data: Any) -> Any:
95 """Upgrade KV snapshots persisted before `tasking_status` was renamed to `tasking_state`.
97 Versions predating the Task/TaskExecution split stored the program's executing task under a
98 `tasking_status` key (a `ProgramTaskingStatus` carrying `executing_task_id`); this
99 version expects `tasking_state`. Renaming the key here lets `ProgramTaskingState`'s own
100 `before` validator finish upgrading the inner shape, so a program starting against a NATS
101 broker holding older state no longer raises a `ValidationError`. A snapshot missing both
102 keys entirely is treated as idle.
103 """
104 if not isinstance(data, dict) or "tasking_state" in data:
105 return data
107 data = dict(data)
108 data["tasking_state"] = data.pop("tasking_status", {})
109 return data
112class ProgramEnableStateRequest(BaseModel):
113 """Request that a Program enable or disable task sourcing.
115 The `controller` field specifies the name of the target Controller to task. It must be
116 specified before activating the Program, and some Program implementations may require that it
117 be specified whenever `enable` is True.
118 """
119 enable: bool
120 controller: str | None
123set_enable_state_request = Request.define(
124 "set_enable_state",
125 payload=ProgramEnableStateRequest,
126)
127"""Control the enable state of a Program."""
130class ProgramActiveStateRequest(BaseModel):
131 """Request that a Program activate or deactivate tasking of the target Controller."""
132 action: Literal["start", "stop", "abort"]
133 contexts: TaskContextMap = Field(default_factory=TaskContextMap)
135 def active_state(self):
136 """Return True if the requested action is 'start', False otherwise."""
137 return True if self.action == "start" else False
140class ProgramActiveStateResult(BaseModel):
141 """Result of a request to change the Program active state."""
142 success: bool
145set_active_state_request = Request.define(
146 "set_active_state",
147 payload=ProgramActiveStateRequest,
148 response=ExtendedResponse,
149)
150"""Control the active state of a Program."""
153class ProgramClient(EntityClient):
154 """Object that exposes client-side functionality of a Program."""
156 async def enable(self, target_controller: str):
157 """Request that the Program enable task sourcing for the target Controller."""
158 return await self.call(
159 set_enable_state_request,
160 ProgramEnableStateRequest(
161 enable=True,
162 controller=target_controller,
163 )
164 )
166 async def disable(self):
167 """Request that the Program disable task sourcing."""
168 return await self.call(
169 set_enable_state_request,
170 ProgramEnableStateRequest(
171 enable=False,
172 controller=None,
173 )
174 )
176 async def start_tasking(self, contexts: TaskContextMap | None = None):
177 """Request that the Program begin tasking the target Controller."""
178 await self.call(
179 set_active_state_request,
180 ProgramActiveStateRequest(action="start", contexts=contexts or TaskContextMap()),
181 )
183 async def stop_tasking(self):
184 """Request that the Program stop tasking and wait for any in flight task to complete."""
185 await self.call(
186 set_active_state_request,
187 ProgramActiveStateRequest(action="stop"),
188 )
190 async def abort_tasking(self):
191 """Request that the Program stop tasking and abort any in flight task immediately."""
192 await self.call(
193 set_active_state_request,
194 ProgramActiveStateRequest(action="abort"),
195 )
197 async def wait_until_tasking_stops(self):
198 """
199 Wait until the Program tasking loop stops.
201 Returns the corresponding ProgramActiveState event, if any.
202 """
203 # TODO: This needs to detect if the hosting service goes down and raise if it doesn't
204 # come back and publish new state within a grace period.
205 stream = await self.tasking_change_events()
206 state = await self.kv_get_model(ProgramState)
208 if state.active_state.active:
209 async for event in stream:
210 if not event.active:
211 return event
213 return None
215 def tasking_change_events(self):
216 """Return an async generator that yields ProgramActiveState events as tasking starts or stops."""
217 return self.monitor_event(ProgramActiveState)
219 @functools.cache
220 def monitor_enable_state(self):
221 """Return (creating if needed) a ProgramStateMonitor tracking this program's enable state."""
222 monitor = ProgramStateMonitor(self)
223 monitor.start()
224 return monitor
227class ProgramRef(EntityRef[ProgramClient]):
228 """A serializable reference to a program client."""
230 def _get_client(self, kit: SensorKit) -> ProgramClient:
231 return kit.program(self.name)
234class ControllerOffers:
235 """Monitor published offers for all Programs associated with a Controller."""
237 def __init__(self, controller: str, program_discovery: ProgramDiscovery):
238 self.controller = controller
239 self.program_discovery = program_discovery
240 self._monitors: dict[str, asyncio.Task] = {}
241 self._offers: dict[str, list[OfferInterval]] = {}
243 async def start(self, *, task_group: Any = asyncio):
244 """Start the program-offer discovery loop, waiting until the first update is received."""
245 self._task_group = task_group
247 # Begin discovering controller offers, making sure to wait for the first set of updates.
248 ready = asyncio.Event()
249 self._discover_task = task_group.create_task(self._discover_programs(ready))
250 await ready.wait()
252 async def _discover_programs(self, initial_update: asyncio.Event):
253 prev = set()
255 async for programs in self.program_discovery.controller_programs(self.controller):
256 events = []
258 for remove in prev.difference(programs):
259 self._monitors[remove].cancel()
260 del self._monitors[remove]
262 for add in programs.difference(prev):
263 assert add not in self._monitors
264 events.append(asyncio.Event())
265 self._monitors[add] = self._task_group.create_task(
266 self._monitor_program(add, events[-1])
267 )
269 await asyncio.gather(*(event.wait() for event in events), return_exceptions=True)
270 initial_update.set()
271 prev = programs
273 async def _monitor_program(self, program: str, initial_update: asyncio.Event):
274 logger.debug(f"starting offer monitor for program {program}")
275 self._offers[program] = []
277 try:
278 # Suppress propagation of cancellation to the parent task group.
279 with contextlib.suppress(asyncio.CancelledError):
280 # Get a stream of offers for the target Program.
281 client = self.program_discovery.client.program(program)
282 stream = await client.monitor(ProgramOffering)
284 initial_update.set()
286 async for _, offering in stream:
287 self._offers[program] = sorted(offering.offer_windows)
288 finally:
289 initial_update.set()
290 del self._offers[program]
291 logger.debug(f"cancelled offer monitor for program {program}")
293 def get_offers(self):
294 """Return a snapshot of the current offer windows keyed by program name.
296 Each program's offer windows are sorted in ascending order.
297 """
298 return self._offers.copy()
301class ProgramDiscovery:
302 """Discover Programs and their associated Controllers."""
304 def __init__(self):
305 self._enabled_programs: AsyncObserver[frozenset[str]] = AsyncObserver(frozenset())
306 self._known_programs: AsyncObserver[frozenset[str]] = AsyncObserver(frozenset())
307 self._controllers: dict[str, AsyncObserver[frozenset[str]]] = collections.defaultdict(
308 lambda: AsyncObserver(frozenset())
309 )
310 self._monitor_tasks: dict[str, asyncio.Task] = {}
311 self._discover_task: asyncio.Task | None = None
313 async def start(self, client: SensorKit):
314 """Begin monitoring the backend for program enable-state changes, waiting for the first update."""
315 self.client = client
317 # Begin discovering enabled programs, making sure to wait for the first set of updates.
318 ready = asyncio.Event()
319 self._discover_task = asyncio.create_task(self._discover_programs(ready))
320 await ready.wait()
322 async def stop(self):
323 """Cancel discovery and every per-program enable-state monitor it started."""
324 if self._discover_task is None:
325 return
327 self._discover_task.cancel()
329 for task in self._monitor_tasks.values():
330 task.cancel()
332 await asyncio.gather(
333 self._discover_task, *self._monitor_tasks.values(), return_exceptions=True
334 )
336 self._monitor_tasks.clear()
337 self._discover_task = None
339 async def _discover_programs(self, initial_update: asyncio.Event):
340 tasks = self._monitor_tasks
341 kv = self.client.backend.key_value()
343 # Programs present in the initial snapshot. We're "ready" once each of them
344 # has been discovered and reported its initial enable state — or immediately
345 # if there are none.
346 pending = {
347 str(entry.key.entity())
348 for entry in await kv.get_all(deep=True)
349 if entry.key.prop == "ProgramState"
350 }
351 if not pending:
352 initial_update.set()
354 # Monitor all registered Programs.
355 # FIXME: Need an alternate way to do this without firehose. Cannot assume the backend
356 # allows prefix wildcards to capture '*.ProgramState' (and NATS, in fact, does not).
357 # This most likely means we need to define a dedicated KV prefix for system info,
358 # where we can define keys to explicitly indicate existence, like EntityLease.
359 monitor = await kv.monitor_all(deep=True)
361 async for entry in monitor:
362 if entry.key.prop != "ProgramState":
363 continue
365 program = str(entry.key.entity())
366 client = self.client.program(program)
367 events = []
369 if entry.deleted():
370 # Remove the monitor task associated with the offline program, if any.
371 if program in tasks:
372 task = tasks.pop(program)
373 task.cancel()
374 self._known_programs.notify(frozenset(tasks.keys()))
375 elif program not in tasks:
376 # Start a new enable state monitor for the newly discovered program.
377 events.append(asyncio.Event())
378 tasks[program] = asyncio.create_task(self._monitor_task(client, events[-1]))
379 self._known_programs.notify(frozenset(tasks.keys()))
381 # Wait for the initial enablement status of each discovered program.
382 await asyncio.gather(*(event.wait() for event in events), return_exceptions=True)
384 # Signal ready once every program from the initial snapshot has been seen.
385 pending.discard(program)
386 if not pending:
387 initial_update.set()
389 async def _monitor_task(self, client: ProgramClient, initial_update: asyncio.Event):
390 monitor = client.monitor_enable_state()
391 program = str(client.entity)
393 try:
394 async for state in monitor.observe():
395 observers = [self._enabled_programs]
396 logger.debug(f"discovery for {program} observed {state=}")
398 if state:
399 observers.append(self._controllers[state.controller])
401 for observer in observers:
402 if state and state.enabled:
403 new_set = observer.value.union({program})
404 else:
405 new_set = observer.value.difference({program})
407 observer.notify(new_set)
409 initial_update.set()
410 finally:
411 monitor.stop()
413 def enabled_programs(self):
414 """Return an async generator yielding the current set of enabled program names on each change."""
415 return self._enabled_programs.consume(initial_value=True)
417 def known_programs(self):
418 """Return an async generator yielding the current set of all known program names on each change."""
419 return self._known_programs.consume(initial_value=True)
421 def controller_programs(self, controller: str):
422 """Return an async generator yielding the set of programs targeting the given controller on each change."""
423 return self._controllers[controller].consume(initial_value=True)
426class ProgramStateMonitor:
427 """Monitor a Program's effective enable-state, taking into account the entity liveness."""
429 def __init__(self, client: ProgramClient):
430 self.client = client
431 self.online = False
432 self.enable_state: ProgramEnableState | None = None
433 self._observer: AsyncObserver[ProgramEnableState | None] = AsyncObserver(None)
435 def start(self):
436 """Start background tasks that monitor program liveness and enable-state."""
437 self._tasks = (
438 asyncio.create_task(self._monitor_liveness()),
439 asyncio.create_task(self._monitor_state()),
440 )
442 def stop(self):
443 """Cancel the background monitoring tasks."""
444 for t in self._tasks:
445 t.cancel()
447 def observe(self):
448 """Return an async generator yielding the current ProgramEnableState (or None if offline) on each change."""
449 return self._observer.consume(initial_value=True)
451 def _notify(self):
452 if not self.online:
453 self._observer.notify(None)
454 else:
455 self._observer.notify(self.enable_state)
457 async def _monitor_liveness(self):
458 async for is_online in self.client.observe_online_state():
459 self.online = is_online
460 self._notify()
462 async def _monitor_state(self):
463 program = str(self.client.entity)
465 try:
466 stream = await self.client.monitor_event(ProgramEnableState)
467 initial_state = await self.client.kv_get_model(ProgramState)
468 except (asyncio.CancelledError, Exception) as e:
469 logger.debug(f"Failed to monitor program {program}: {e}")
470 raise
472 logger.debug(f"monitoring discovered program {program}")
473 self.enable_state = initial_state.enable_state
474 self._notify()
476 async for event in stream:
477 if event.timestamp() > self.enable_state.timestamp():
478 self.enable_state = event
479 self._notify()
482class ProgramInterface(EntityInterface):
483 """Interface describing an implementation of a program."""
485 @abstractmethod
486 def on_enable(self, func: Callable[[], None]):
487 """Register a callback to invoke when the program is enabled."""
488 ...
490 @abstractmethod
491 def on_disable(self, func: Callable[[], None]):
492 """Register a callback to invoke when the program is disabled."""
493 ...
495 @abstractmethod
496 def task_factory(self, func: TaskFactoryFunc) -> TaskFactoryFunc:
497 """Register the task factory function that the program calls to generate tasks."""
498 ...
500 @abstractmethod
501 def get_offers(self) -> list[Any]:
502 """Return the current list of offer windows."""
503 ...
505 @abstractmethod
506 async def publish_offers(self):
507 """Publish the current offer windows to the entity's keyword stream."""
508 ...
510 @abstractmethod
511 def add_offer(self, start: datetime, end: datetime, obj: Any = None):
512 """Add an offer window covering the given time range."""
513 ...
515 @abstractmethod
516 def remove_offer(self, start: datetime, end: datetime, obj: Any = None):
517 """Remove the offer window covering the given time range."""
518 ...
520 @abstractmethod
521 def clear_offers(self):
522 """Remove all offer windows."""
523 ...