Coverage for core / src / sensorkit / core / state.py: 94%
82 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 inspect
6import threading
7from typing import ClassVar, Self, get_type_hints
9from pydantic import BaseModel
11from sensorkit.backend.base import KVError
12from sensorkit.backend.event import Event
13from sensorkit.core.entity import EntityBase, EntityClient
14from sensorkit.core.impl.entity import EntityImpl
17class EventSourcedState(BaseModel):
18 """State model that can recover values of Event-typed fields from an event stream."""
20 _event_fields: ClassVar[dict[type[Event], str]] = {}
21 _event_fields_lock: ClassVar[threading.Lock] = threading.Lock()
23 def model_post_init(self, __context):
24 """Introspect fields at init time to identify Event-typed fields."""
25 super().model_post_init(__context)
26 self._set_event_fields()
27 self._update_lock = asyncio.Lock()
29 @classmethod
30 def _set_event_fields(cls):
31 if cls._event_fields:
32 return
34 with cls._event_fields_lock:
35 # Double-check pattern to avoid redundant work if another thread already initialized
36 if cls._event_fields:
37 return
39 # Store at class level.
40 cls._event_fields = cls._introspect_event_fields()
42 @classmethod
43 def _introspect_event_fields(cls):
44 type_hints = get_type_hints(cls)
45 event_fields = {}
47 for field, field_type in type_hints.items():
48 # Check if field type is a subclass of Event
49 if inspect.isclass(field_type) and issubclass(field_type, Event):
50 if field_type in event_fields:
51 raise RuntimeError(
52 f"Duplicate event type {field_type.model_tag()}"
53 f" found at {cls.__name__}.{field}"
54 )
56 event_fields[field_type] = field
58 return event_fields
60 async def update(
61 self,
62 entity: EntityImpl,
63 *events: Event,
64 publish_state=True,
65 return_snapshot=False,
66 ):
67 """Apply events to the state, emit them on the entity's stream, and optionally publish the state to KV.
69 Returns a deep copy of the updated state if return_snapshot is True, else None.
70 """
71 async with self._update_lock:
72 for event in events:
73 field = self._event_fields.get(type(event))
75 if field is None:
76 raise KeyError(f"No {event.event_model} event field exists")
78 setattr(self, field, event)
79 await entity.emit_event(event)
81 # Ordering is crucial here: events must be emitted first since they are the source of
82 # truth.
83 if publish_state:
84 await entity.kv_put_model(self)
86 # Make a snapshot while we hold the lock,
87 snapshot = self.model_copy(deep=True) if return_snapshot else None
89 return snapshot
91 @classmethod
92 async def event_stream[T: Event](cls, entity: EntityClient, event_type: type[T]):
93 """Yield a continuous stream of events of the given type, starting from the current stored state."""
94 field = cls._event_fields[event_type]
95 stream = await entity.monitor_event(event_type)
96 state = await entity.kv_get_model(cls)
97 original: T | None = getattr(state, field)
98 original_id = original.event_id.int if original is not None else 0
100 yield original
102 # FIXME: For this to work properly, we need the backend to be enhanced to expose a method
103 # of starting the stream at a given timestamp. NATS supports this so this is just a
104 # much needed backend iteration. Below is a poor-man's substitute that kind of works
105 # only because we currently always publish a state update subsequent to every event.
106 #
107 # Skip any stream events already reflected in `original` (or that produced it), then yield
108 # everything after. We order by the full uuid7 event_id rather than timestamp(): the latter
109 # is only millisecond-resolution, so two events emitted in the same millisecond compare
110 # equal and a genuinely newer event would be silently dropped, hanging the consumer.
111 async for event in stream:
112 if event.event_id.int > original_id:
113 yield event
114 break
116 async for event in stream:
117 yield event
119 raise RuntimeError("unexpected end of stream")
121 @classmethod
122 async def recover(cls, entity: EntityBase) -> Self:
123 """Recover state from the KV store and validate Event fields are up to date."""
124 # Retrieve the stored data from KV.
125 obj = await entity.kv_get_model(cls)
127 # Verify that each cached event is the latest event of that type. If it isn't, retrieve
128 # the latest event.
129 for field in cls._event_fields.values():
130 event: Event = getattr(obj, field)
131 newer = await cls._get_latest_event_if_newer(entity, event)
133 if newer is not None:
134 setattr(obj, field, newer)
136 return obj
138 @classmethod
139 async def recover_or_init(cls, entity: EntityBase, **kwargs):
140 """Recover state from KV, or create and publish a new instance using kwargs if none exists."""
141 try:
142 return await cls.recover(entity)
143 except KVError:
144 new = cls(**kwargs)
145 await entity.kv_put_model(new)
146 return new
148 @staticmethod
149 async def _get_latest_event_if_newer(entity: EntityBase, event: Event):
150 # TODO: Implement.
151 return event or None