Coverage for core / src / sensorkit / auto / constraint.py: 95%
255 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 json
6from abc import ABC, abstractmethod
7from collections.abc import Sequence
8from dataclasses import dataclass
9from datetime import UTC, datetime, timedelta
10from typing import Any, Literal, override
12from loguru import logger
13from pydantic import model_validator
15from sensorkit.common.aio import cleanup_future
16from sensorkit.common.condition import AnyCondition, resolve_field
17from sensorkit.common.model import ModelRegistry, RegistryBaseModel
18from sensorkit.core.client import SensorKit
20_constraint_registry = ModelRegistry(discriminator="kind")
21_details_registry = ModelRegistry(discriminator="kind")
24class Constraint(RegistryBaseModel, ABC):
25 """Abstract operating constraint that runs a background monitoring task."""
27 kind: str
28 ttl: float = 30.0
29 hold: float = 0.0
30 optional: bool = False
32 @classmethod
33 def model_registry(cls):
34 return _constraint_registry
36 # FIXME: Use Context or similar instead of **kwargs
37 @abstractmethod
38 async def check_task(self, evaluator: ConstraintEvaluator, /, **kwargs) -> None:
39 """Long-running coroutine that monitors the constraint and updates the evaluator."""
41 @model_validator(mode="before")
42 @classmethod
43 def _compat(cls, data: Any) -> Any:
44 if isinstance(data, dict):
45 if "time_to_live" in data:
46 data["ttl"] = data.pop("time_to_live")
47 if "hold_duration" in data:
48 data["hold"] = data.pop("hold_duration")
49 if "activate_on_timeout" in data:
50 data["optional"] = not data.pop("activate_on_timeout")
52 return data
55class ConstraintDetails(RegistryBaseModel):
56 """Base for structured constraint state details. Subclasses auto-register."""
58 kind: str
60 @classmethod
61 def model_registry(cls):
62 return _details_registry
65@dataclass
66class ConstraintState:
67 """Per-constraint state."""
69 kind: str
70 state: Literal["not_ready", "clear", "holding", "active"] = "not_ready"
71 reason: str = ""
72 details: ConstraintDetails | None = None
75class ConstraintEvaluator:
76 """State interface for one check_task run. Created fresh by ConstraintManager on each restart."""
78 _QUEUE_MAXSIZE = 10
80 def __init__(
81 self,
82 constraint: Constraint,
83 *,
84 timeout: asyncio.Timeout,
85 initial_hold: float = 0.0,
86 ):
87 self.constraint = constraint
88 self.holding_until: float | None = None
89 """Event loop deadline of the hold in effect, if there is one.
91 A hold runs on the monotonic event loop clock so that it cannot be cut short by a
92 wall clock adjustment. Use `holding_until_dt` to express it as an instant.
93 """
95 self._timeout = timeout
96 self._ready = asyncio.Event()
97 self._hold_task: asyncio.Task | None = None
99 if self.constraint.optional:
100 self.intended_state = ConstraintState(
101 kind=constraint.kind, state="clear", reason="optional constraint"
102 )
103 self._ready.set()
104 else:
105 self.intended_state = ConstraintState(
106 kind=constraint.kind, state="not_ready", reason="awaiting data"
107 )
109 self.actual_state = self.intended_state
110 self._state_updated = asyncio.Event()
112 if initial_hold > 0:
113 self._begin_hold(initial_hold)
115 @property
116 def is_active(self) -> bool:
117 return self.actual_state.state != "clear"
119 @property
120 def is_ready(self) -> bool:
121 return self._ready.is_set()
123 async def wait_ready(self):
124 await self._ready.wait()
126 async def next_update(self) -> ConstraintState:
127 await self._state_updated.wait()
128 state = self.actual_state
129 self._state_updated.clear()
130 return state
132 def ready(self):
133 """Mark constraint ready."""
134 self._timeout.reschedule(asyncio.get_event_loop().time() + self.constraint.ttl)
136 if not self._ready.is_set():
137 self._ready.set()
138 self._update_state()
140 def constrain(self, reason: str, *, details: ConstraintDetails | None = None):
141 """Set constraint active.
143 Returns:
144 bool: True if state changed (clear → active)
146 Raises:
147 QueueFull: if the status queue is full
148 """
149 self._timeout.reschedule(asyncio.get_event_loop().time() + self.constraint.ttl)
150 self._cancel_hold()
151 self.intended_state = ConstraintState(
152 kind=self.constraint.kind,
153 state="active",
154 reason=reason,
155 details=details,
156 )
157 self._update_state()
159 def clear(self, reason: str = "", *, details: ConstraintDetails | None = None):
160 """Set constraint inactive. If hold > 0, defers the clear until the hold expires.
162 Raises:
163 QueueFull: if the status queue is full
164 """
165 self._timeout.reschedule(asyncio.get_event_loop().time() + self.constraint.ttl)
167 if self.constraint.hold > 0 and self.intended_state.state == "active":
168 self._begin_hold(self.constraint.hold)
170 self.intended_state = ConstraintState(
171 kind=self.constraint.kind,
172 state="clear",
173 reason=reason,
174 details=details,
175 )
176 self._update_state()
178 async def cleanup(self):
179 """Stop the hold task, leaving its deadline readable by `hold_remaining`."""
180 self._stop_hold_task()
182 def hold_remaining(self) -> float:
183 """Hold a replacement evaluator should start with, or zero if there is nothing to resume.
185 A hold delays releasing a constraint, so it has to outlive the evaluator that started it.
186 Without this the replacement starts with nothing pending and releases on its first clear
187 reading, cutting the delay short.
188 """
189 if self.holding_until is not None:
190 return max(0.0, self.holding_until - asyncio.get_event_loop().time())
192 return self.constraint.hold if self.intended_state.state == "active" else 0.0
194 @property
195 def holding_until_dt(self) -> datetime | None:
196 """`holding_until` as a UTC instant, or None if no hold is in effect.
198 Projected from the current wall clock on each access rather than stored, so that a clock
199 adjustment cannot move a hold that is already running. A deadline that has already passed
200 projects into the past.
201 """
202 if self.holding_until is None:
203 return None
205 remaining = self.holding_until - asyncio.get_event_loop().time()
207 return datetime.now(UTC) + timedelta(seconds=remaining)
209 def _update_state(self):
210 if not self._ready.is_set():
211 state = ConstraintState(kind=self.constraint.kind, state="not_ready")
212 elif self.holding_until is not None:
213 state = ConstraintState(kind=self.constraint.kind, state="holding")
214 else:
215 state = self.intended_state
217 self.actual_state = state
218 self._state_updated.set()
220 async def _do_hold(self, wait_secs: float):
221 logger.debug(f"holding {self.constraint.kind} constraint for {wait_secs:.1f}s")
222 await asyncio.sleep(wait_secs)
224 # It's safe to clear the hold early here since there are no further awaits.
225 self._hold_task = None
226 self.holding_until = None
227 self._update_state()
229 def _begin_hold(self, wait_secs: float):
230 """Start a hold running for the given duration.
232 Raises:
233 RuntimeError: if a hold is already in effect.
234 """
235 if self.holding_until is not None:
236 raise RuntimeError(f"{self.constraint.kind} constraint is already holding")
238 self.holding_until = asyncio.get_event_loop().time() + wait_secs
239 self._hold_task = asyncio.create_task(self._do_hold(wait_secs))
240 self._hold_task.add_done_callback(cleanup_future)
242 def _cancel_hold(self):
243 """Abandon any hold, discarding its deadline so it cannot be carried over."""
244 self._stop_hold_task()
245 self.holding_until = None
247 def _stop_hold_task(self):
248 if self._hold_task is not None and not self._hold_task.done():
249 self._hold_task.cancel()
251 self._hold_task = None
254class ConstraintManager:
255 """Manages the lifecycle of all constraints."""
257 CONSTRAINT_RESTART_GRACE = 5.0
258 CONSTRAINT_RESTART_DELAY = 5.0
260 def __init__(self, constraints: list[Constraint]):
261 self._constraints = constraints
262 self._entries: list[ConstraintState] = [ConstraintState(kind=c.kind) for c in constraints]
263 self._constrained_set: set[int] = set()
264 self._ready_set: set[int] = set()
265 self._ready_event = asyncio.Event()
267 if not constraints:
268 self._ready_event.set()
270 @property
271 def entries(self) -> Sequence[ConstraintState]:
272 """Current constraint state in config order."""
273 return self._entries
275 def is_constrained(self) -> bool:
276 return len(self._constrained_set) > 0
278 async def start(
279 self,
280 *,
281 task_group: asyncio.TaskGroup,
282 ready_timeout: float | None = None,
283 **kwargs,
284 ) -> bool:
285 """Start all constraint monitoring tasks.
287 Creates supervisor tasks for each constraint that will monitor and update
288 their states continuously. Waits for all constraints to become ready or
289 until the ready_timeout expires.
291 Args:
292 task_group: The asyncio.TaskGroup to create constraint supervisor tasks in.
293 ready_timeout: Maximum time in seconds to wait for all constraints to become ready.
294 If None, waits indefinitely.
295 **kwargs: Additional keyword arguments passed to each constraint's check_task method.
297 Returns:
298 bool: True if all constraints became ready within the timeout, False if one or more
299 constraints remain unready.
300 """
301 logger.debug(f"ConstraintManager starting with {len(self._constraints)} constraints")
303 # Start a supervisor task to manage each constraint's evaluation loop.
304 for i, constraint in enumerate(self._constraints):
305 task_group.create_task(self._constraint_supervisor(i, constraint, **kwargs))
307 try:
308 async with asyncio.timeout(ready_timeout):
309 await self._ready_event.wait()
310 except asyncio.TimeoutError:
311 return False
313 return True
315 def _set_state(self, idx: int, current: ConstraintState):
316 prev = self._entries[idx]
317 self._entries[idx] = current
319 if current.state != prev.state:
320 state_str = current.state.replace("_", " ")
321 reason = f"(reason: {current.reason})" if current.reason else ""
322 logger.info(f"{current.kind.capitalize()} constraint is {state_str} {reason}")
324 if current.state == "not_ready":
325 self._ready_set.discard(idx)
326 self._constrained_set.add(idx)
327 else:
328 self._ready_set.add(idx)
330 if current.state == "clear":
331 self._constrained_set.discard(idx)
332 else:
333 self._constrained_set.add(idx)
335 if len(self._ready_set) == len(self._entries):
336 self._ready_event.set()
337 else:
338 self._ready_event.clear()
340 async def _constraint_supervisor(self, idx: int, constraint: Constraint, **kwargs):
341 restarting = False
342 initial_hold = 0.0
344 while True:
345 evaluator: ConstraintEvaluator | None = None
347 try:
348 initial_timeout = (
349 None
350 if constraint.ttl is None
351 else self.CONSTRAINT_RESTART_GRACE + constraint.ttl
352 )
354 async with asyncio.timeout(initial_timeout) as timeout:
355 # Create a constraint evaluator and set the initial state.
356 evaluator = ConstraintEvaluator(
357 constraint, timeout=timeout, initial_hold=initial_hold
358 )
359 self._set_state(idx, evaluator.actual_state)
361 # Sleep if this was a restart.
362 if restarting:
363 timeout.reschedule(
364 None
365 if (when := timeout.when()) is None
366 else when + self.CONSTRAINT_RESTART_DELAY
367 )
368 await asyncio.sleep(self.CONSTRAINT_RESTART_DELAY)
370 # Run the constraint and process updates.
371 await self._constraint_task(idx, constraint, evaluator, **kwargs)
372 except asyncio.CancelledError:
373 # Propagate only direct cancellation.
374 if asyncio.current_task().cancelling():
375 raise
377 logger.error(f"{constraint.kind.capitalize()} constraint cancelled unexpectedly")
378 except asyncio.TimeoutError:
379 # Only log timeouts for non-optional constraints.
380 if not constraint.optional:
381 logger.error(f"{constraint.kind.capitalize()} constraint timed out")
382 except Exception:
383 logger.exception(f"{constraint.kind.capitalize()} constraint error")
385 # A hold outlives the evaluator that started it, so the replacement resumes it.
386 initial_hold = evaluator.hold_remaining() if evaluator is not None else 0.0
387 restarting = True
389 async def _constraint_task(
390 self,
391 idx: int,
392 constraint: Constraint,
393 evaluator: ConstraintEvaluator,
394 **kwargs,
395 ):
396 if not constraint.optional:
397 logger.debug(f"starting {constraint.kind} constraint check")
399 # Start the constraint check task.
400 check_task = asyncio.create_task(constraint.check_task(evaluator, **kwargs))
401 update_task: asyncio.Task | None = None
403 try:
404 # Wait for the check task to signal that it is ready.
405 await evaluator.wait_ready()
407 # Read events emitted by the check task.
408 while True:
409 update_task: asyncio.Task = asyncio.create_task(evaluator.next_update())
410 done, _ = await asyncio.wait(
411 {update_task, check_task},
412 return_when=asyncio.FIRST_COMPLETED,
413 )
415 if update_task in done:
416 state = update_task.result()
417 self._set_state(idx, state)
419 if check_task in done:
420 # Re-raise check task exception, if any.
421 check_task.result()
422 break
423 finally:
424 aws = [evaluator.cleanup(), check_task]
425 check_task.cancel()
427 if update_task:
428 update_task.cancel()
429 aws.append(update_task)
431 await asyncio.gather(*aws, return_exceptions=True)
434class GenericConstraint(Constraint):
435 """Generic constraint driven by a Condition evaluated against any entity keyword."""
437 kind: Literal["conditional"] = "conditional"
438 entity: str
439 keyword: str
440 field: str | None = None
441 condition: AnyCondition
443 @override
444 async def check_task(self, evaluator: ConstraintEvaluator, kit: SensorKit):
445 label = f"{self.entity}.{self.keyword}"
446 if self.field:
447 label += f".{self.field}"
448 logger.debug(f"monitoring conditional constraint on {label}")
450 _NONE_YET = object()
451 previous = _NONE_YET
452 was_active = False
454 client = kit.entity(self.entity)
455 consumer = await client._stream.consume(self.keyword)
457 async for msg in consumer:
458 try:
459 data = json.loads(msg.data)
460 except Exception:
461 continue
463 current = resolve_field(data, self.field) if self.field else data
465 if previous is not _NONE_YET:
466 _, is_active = self.condition.evaluate(current, previous, was_active)
467 reason = f"{label} = {current}"
469 if is_active:
470 evaluator.constrain(reason)
471 else:
472 evaluator.clear(reason)
474 evaluator.ready()
475 was_active = is_active
477 previous = current