Coverage for core / src / sensorkit / backend / event.py: 89%
74 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 itertools
8from typing import TYPE_CHECKING, ClassVar, override
10import uuid_utils.compat as uuid
11from loguru import logger
12from pydantic import Field, ValidationError
14from sensorkit.backend.base import SpecialProperty
15from sensorkit.common.model import ModelRegistry, RegistryBaseModel
17if TYPE_CHECKING:
18 from sensorkit.backend.base import StreamContext
21class Event(RegistryBaseModel):
22 """Base SensorKit event model."""
24 event_model: str | None = None
25 event_id: uuid.UUID = Field(default_factory=uuid.uuid7)
27 model_config: ClassVar[dict] = {"frozen": True}
28 registry: ClassVar[ModelRegistry[Event]] = ModelRegistry(
29 discriminator="event_model",
30 default_tag="UnknownEvent",
31 )
33 @classmethod
34 def model_registry(cls):
35 return cls.registry
37 def timestamp(self):
38 """Return the millisecond UTC timestamp encoded in the UUIDv7 event_id."""
39 return self.event_id.int >> 80
42class UnknownEvent(Event, extra="allow"):
43 """Represents an event for which no corresponding model is known."""
46class EventMultiplexer:
47 """Distributes parsed events to per-type and wildcard subscriber queues."""
49 def __init__(self):
50 self._all_queues: set[asyncio.Queue[Event]] = set()
51 self._single_queues: dict[str, set[asyncio.Queue[Event]]] = collections.defaultdict(set)
53 async def wait_ready(self):
54 """Wait until events are being received. A plain multiplexer has no source to wait for."""
56 async def parse_event(self, json: bytes):
57 """Deserialise a JSON event payload and enqueue it to all matching subscriber queues."""
58 # Parse the event and narrow its type.
59 try:
60 model = Event.model_validate_json(json)
61 except Exception:
62 logger.exception("Failed to parse event")
63 raise
65 # Notify queues registered for this event.
66 single = self._single_queues.get(model.event_model, ())
68 for queue in itertools.chain(self._all_queues, single):
69 queue.put_nowait(model)
71 @contextlib.contextmanager
72 def event_queue(self, event: type[Event]):
73 """Context manager yielding a Queue that receives events of the specified type."""
74 queue: asyncio.Queue[Event] = asyncio.Queue()
75 name = event.model_tag()
77 try:
78 self._single_queues[name].add(queue)
79 yield queue
80 finally:
81 self._single_queues[name].discard(queue)
83 @contextlib.contextmanager
84 def all_events(self):
85 """Context manager yielding a Queue that receives all events regardless of type."""
86 queue: asyncio.Queue[Event] = asyncio.Queue()
88 try:
89 self._all_queues.add(queue)
90 yield queue
91 finally:
92 self._all_queues.discard(queue)
95class EventStreamConsumer(EventMultiplexer):
96 """An EventMultiplexer that consumes events from a given stream."""
98 def __init__(self, context: StreamContext):
99 super().__init__()
100 self._context = context
101 self._startup = asyncio.get_running_loop().create_future()
103 async def start(self):
104 """Start the background event consumer task and wait until the stream is ready."""
105 self._task = asyncio.create_task(self._event_consumer_task())
106 await self._startup
108 @override
109 async def wait_ready(self):
110 """Wait until the stream subscription is live, raising if it failed to start.
112 Blocks indefinitely until `start` is called, so callers must hold a multiplexer that is
113 already starting.
114 """
115 await self._startup
117 async def _event_consumer_task(self):
118 try:
119 stream = await self._context.consume(SpecialProperty.EVENTS)
120 except BaseException as e:
121 self._startup.set_exception(e)
122 raise
123 else:
124 self._startup.set_result(None)
126 async for msg in stream:
127 try:
128 await self.parse_event(msg.data)
129 except ValidationError:
130 logger.exception("Event validation failed")