|
| 1 | +# EventService |
| 2 | + |
| 3 | +## What it is |
| 4 | +A durable event log with live broadcast. Each `publish(event)` is: |
| 5 | +1. **Persisted** to the secondary adapter (durability contract). |
| 6 | +2. **Broadcast** on the `BusService` topic derived from the event's class IRI (live signaling). |
| 7 | + |
| 8 | +Events are instances of `LogProcess` subclasses — Pydantic classes generated from TTL via `onto2py`. The class IRI (`_class_uri`) acts as the event type; the instance IRI (`_uri`) is the event id. |
| 9 | + |
| 10 | +Subscriptions are live-only. Historical reads go through `query()` (filter-based) or `query_for_consumer()` (per-consumer cursor with auto-advance). |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## Public API |
| 15 | + |
| 16 | +### `class EventService(ServiceBase, IEventService)` |
| 17 | + |
| 18 | +- `__init__(adapter: IEventAdapter, bus: BusService | None = None)` |
| 19 | + - Requires an adapter. A bus is optional but required for `subscribe()`. |
| 20 | +- `publish(event: LogProcess) -> StoredEvent` |
| 21 | + - Persists via the adapter, then broadcasts on the bus topic `class_iri_to_topic(event._class_uri)`. If the bus broadcast fails, the event is still persisted (warning logged). |
| 22 | + - **Populates `event.created_at = datetime.now()` if the caller didn't set it**, before serializing — so the timestamp round-trips through `query`/`iter_query`. |
| 23 | + - Raises `InvalidEventError` if `event` is not a `LogProcess` subclass instance. |
| 24 | +- `query(event_class=None, since_seq=None, until_seq=None, since_timestamp=None, until_timestamp=None, limit=None) -> list[LogProcess]` |
| 25 | + - Eager read. Reconstructs each row back into an instance of `event_class` (or `LogProcess` if no class hint is given). Use `since_seq` for stable seq-based pagination. |
| 26 | +- `iter_query(event_class=None, since_seq=None, since_timestamp=None, until_timestamp=None, limit=None, batch_size=500) -> Iterator[LogProcess]` |
| 27 | + - Streaming read with **snapshot semantics**: captures the current `max(seq)` at the first call and stops once it has yielded everything up to that point. Events appended during iteration are not included — call again to pick them up. Caller does not have to manage pagination. |
| 28 | + - `limit` caps the total number of events yielded; `batch_size` is the per-round-trip SQL fetch size (defaults to 500). |
| 29 | +- `query_for_consumer(consumer_id: str, event_class: type, limit=None) -> list[LogProcess]` |
| 30 | + - Eager. Returns undelivered events for `(consumer_id, event_class._class_uri)` and advances the cursor in the same transaction. |
| 31 | +- `iter_query_for_consumer(consumer_id, event_class, limit=None, batch_size=500) -> Iterator[LogProcess]` |
| 32 | + - Drains pending events for `consumer_id` in batches, advancing the cursor per batch. Stops when caught up or when `limit` events have been yielded. The cursor only advances over events actually returned, so a stopped iteration leaves the remainder pending for the next call. |
| 33 | +- `subscribe(event_class: type, callback: Callable[[LogProcess], None], routing_key: str = "#") -> Thread` |
| 34 | + - Subscribes to the bus topic for `event_class._class_uri`. The callback receives a reconstructed instance of `event_class`. Requires a `BusService`; raises `RuntimeError` otherwise. |
| 35 | + |
| 36 | +### Module functions |
| 37 | +- `class_iri_to_topic(class_iri: str) -> str` |
| 38 | + - Maps a class IRI to a bus-safe topic name: `"evt." + sha256(class_iri)[:32]`. Deterministic; avoids characters that RabbitMQ topic names dislike (`://`, `#`). |
| 39 | + |
| 40 | +--- |
| 41 | + |
| 42 | +## Configuration/Dependencies |
| 43 | +- `IEventAdapter` implementation (defaults: `EventSQLiteAdapter`). |
| 44 | +- `BusService` for live broadcast and subscription (optional but required for `subscribe()`). |
| 45 | +- `naas_abi_core.services.event.event_ontology.LogProcess` (generated from `services/event/event.ttl` via `onto2py`). |
| 46 | +- Logging via `naas_abi_core.logger`. |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## Usage |
| 51 | + |
| 52 | +### Define an event type (TTL → onto2py) |
| 53 | +```turtle |
| 54 | +# my_events.ttl |
| 55 | +@prefix abi: <http://ontology.naas.ai/abi/> . |
| 56 | +@prefix ex: <http://example.org/> . |
| 57 | +
|
| 58 | +ex:UserAuthenticated a owl:Class ; |
| 59 | + rdfs:subClassOf abi:LogProcess ; |
| 60 | + rdfs:label "user authenticated"@en . |
| 61 | +``` |
| 62 | + |
| 63 | +Generate the Python module: |
| 64 | +```bash |
| 65 | +python -m naas_abi_core.utils.onto2py my_events.ttl my_events.py |
| 66 | +``` |
| 67 | + |
| 68 | +### Publish, query, subscribe |
| 69 | +```python |
| 70 | +from naas_abi_core.services.event.EventFactory import EventFactory |
| 71 | +from my_events import UserAuthenticated |
| 72 | + |
| 73 | +events = EventFactory.EventSQLite_find_storage(bus=bus_service) |
| 74 | + |
| 75 | +# Live subscriber (live-only, no history replay) |
| 76 | +def on_user_auth(evt: UserAuthenticated) -> None: |
| 77 | + print("auth:", evt.user_id) |
| 78 | +events.subscribe(UserAuthenticated, on_user_auth) |
| 79 | + |
| 80 | +# Publish |
| 81 | +events.publish(UserAuthenticated(user_id="alice")) |
| 82 | + |
| 83 | +# Historical query (eager) |
| 84 | +recent = events.query(event_class=UserAuthenticated, limit=100) |
| 85 | + |
| 86 | +# Streaming over all history without managing pagination (snapshot) |
| 87 | +for evt in events.iter_query(event_class=UserAuthenticated, batch_size=500): |
| 88 | + process(evt) |
| 89 | + |
| 90 | +# Catch-up for a durable consumer (eager, one batch) |
| 91 | +batch = events.query_for_consumer("worker-1", UserAuthenticated) |
| 92 | + |
| 93 | +# Catch-up streaming until the consumer is up to date |
| 94 | +for evt in events.iter_query_for_consumer("worker-1", UserAuthenticated): |
| 95 | + process(evt) |
| 96 | +``` |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +## Reconstructed events |
| 101 | + |
| 102 | +`query`, `iter_query`, `query_for_consumer`, and `iter_query_for_consumer` all return **instances of the requested `event_class`** (a Pydantic model), not a wrapper. Domain fields you set at publish-time are populated; storage metadata is attached as private attributes: |
| 103 | + |
| 104 | +```python |
| 105 | +for evt in events.iter_query(event_class=UserAuthenticated): |
| 106 | + evt.user_id # domain field set at publish-time |
| 107 | + evt.created_at # always populated (auto-set by publish if unset) |
| 108 | + evt._uri # original instance IRI |
| 109 | + evt._seq # global monotonic sequence (storage metadata) |
| 110 | + evt._stored_at # ISO timestamp persisted by the adapter |
| 111 | +``` |
| 112 | + |
| 113 | +## Caveats |
| 114 | +- **Live-only subscribe**: events published before a subscriber connects are not delivered to it. Use `query_for_consumer` for catch-up. |
| 115 | +- **At-most-once cursor**: `query_for_consumer` advances on read, before the caller processes. If the caller crashes after return, those events will not be returned again. |
| 116 | +- **Bus failure does not lose events**: persistence happens first; bus failures are logged and swallowed. |
| 117 | +- **Reconstruction without hint class**: `query()` without an `event_class` returns instances of the most-specific Python class found by walking `LogProcess.__subclasses__()`. If the event's class is not imported anywhere in the running process, the row is reconstructed as a bare `LogProcess`. |
0 commit comments