Skip to content

Commit d20274d

Browse files
authored
Merge pull request #980 from jupyter-naas/claude/eloquent-cannon-6b0db4
feat(core): add EventService for durable, queryable events
2 parents f3401c5 + f8dd1a7 commit d20274d

37 files changed

Lines changed: 3135 additions & 22 deletions

config.local.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,16 @@ services:
252252
config:
253253
rabbitmq_url: "amqp://{{ secret.RABBITMQ_USER }}:{{ secret.RABBITMQ_PASSWORD }}@rabbitmq:5672"
254254

255+
# Q: Where is the durable event log stored, and how do live listeners find each other?
256+
# A: SQLite holds every published event for query and replay; the bus above carries
257+
# live broadcasts to subscribers. Two systems, one contract: SQLite owns durability,
258+
# the bus owns delivery. Late or restarted consumers catch up with iter_query_for_consumer.
259+
event:
260+
event_adapter:
261+
adapter: "sqlite"
262+
config:
263+
db_path: "storage/events/events.sqlite"
264+
255265
# Q: Where is temporary data kept (sessions, caches, locks)?
256266
# A: Redis handles it. Swap to "python" for an in-memory dict with no Redis container.
257267
kv:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# EventFactory
2+
3+
## What it is
4+
Factory for building a default `EventService` backed by `EventSQLiteAdapter`. The database file is placed under the project's `storage/` folder (created if absent).
5+
6+
## Public API
7+
8+
### `class EventFactory`
9+
- `EventSQLite_find_storage(bus: BusService | None = None, subpath: str = "events.sqlite", needle: str = "storage") -> EventService`
10+
- Locates (or creates) the `storage/` folder via `naas_abi_core.utils.Storage.find_storage_folder`.
11+
- Places the SQLite file under `<storage>/events/<subpath>` (subpath is prefixed with `events/` if not already).
12+
- Returns an `EventService` wired to that adapter and the provided bus.
13+
14+
## Configuration/Dependencies
15+
- `EventSQLiteAdapter` (sibling adapter).
16+
- `BusService` (optional).
17+
- `naas_abi_core.utils.Storage.find_storage_folder`.
18+
19+
## Usage
20+
21+
```python
22+
from naas_abi_core.services.event.EventFactory import EventFactory
23+
24+
events = EventFactory.EventSQLite_find_storage(bus=bus_service)
25+
```
26+
27+
## Caveats
28+
- If no `bus` is passed, `events.subscribe(...)` raises `RuntimeError`. `publish` and `query` still work — the event log remains durable.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# EventPort
2+
3+
## What it is
4+
Interface (port) definitions for a durable event log with live broadcast:
5+
- A `StoredEvent` record returned by the durable log.
6+
- An adapter interface (`IEventAdapter`) for storage backends.
7+
- A service interface (`IEventService`) for higher-level event operations (publish, query, query-for-consumer, subscribe).
8+
- Event-related exceptions (`EventNotFoundError`, `InvalidEventError`).
9+
10+
Events themselves are not modeled in this port: a published event must be an instance of a `LogProcess` subclass (an `onto2py`-generated Pydantic class whose class IRI identifies the event type).
11+
12+
## Public API
13+
14+
### Exceptions
15+
- `EventNotFoundError`: missing event lookup.
16+
- `InvalidEventError`: raised when `publish()` receives an object that is not a `LogProcess` subclass instance.
17+
18+
### Models
19+
- `StoredEvent(dataclass, frozen=True)`
20+
- Fields:
21+
- `id: str` — instance IRI (the `LogProcess` instance's `_uri`).
22+
- `event_type: str` — class IRI (the `LogProcess` subclass's `_class_uri`).
23+
- `seq: int` — global monotonic sequence assigned by the adapter on append.
24+
- `timestamp: str` — ISO 8601 timestamp.
25+
- `payload: bytes` — serialized RDF graph (n-triples).
26+
27+
### Interfaces
28+
29+
#### `IEventAdapter` (secondary port — durable log)
30+
- `append(event_id, event_type, timestamp, payload) -> StoredEvent` — persist one event; returns the stored record with its assigned `seq`.
31+
- `query(event_type=None, since_seq=None, until_seq=None, since_timestamp=None, until_timestamp=None, limit=None) -> list[StoredEvent]` — filtered read, ordered by `seq` ascending.
32+
- `max_seq(event_type=None) -> int` — highest stored `seq` (0 if empty). Used by iterators to capture a snapshot upper bound.
33+
- `get_cursor(consumer_id, event_type) -> int` — return last delivered `seq` for `(consumer_id, event_type)`; `0` if unset.
34+
- `query_for_consumer(consumer_id, event_type, limit=None) -> list[StoredEvent]` — return events with `seq > cursor` and advance the cursor atomically in the same transaction.
35+
36+
#### `IEventService` (primary port)
37+
- `publish(event) -> StoredEvent` — persist a `LogProcess` subclass instance and broadcast it on the bus. Raises `InvalidEventError` if `event` is not a `LogProcess` subclass instance.
38+
- `query(event_class=None, since_seq=None, until_seq=None, since_timestamp=None, until_timestamp=None, limit=None) -> list` — eager read. Return reconstructed event instances matching the filters.
39+
- `iter_query(event_class=None, since_seq=None, since_timestamp=None, until_timestamp=None, batch_size=500) -> Iterator` — streaming read with snapshot semantics; caller doesn't manage pagination.
40+
- `query_for_consumer(consumer_id, event_class, limit=None) -> list` — eager. Return undelivered events for `consumer_id` and advance the cursor.
41+
- `iter_query_for_consumer(consumer_id, event_class, batch_size=500) -> Iterator` — drain all pending events for `consumer_id` across batches, advancing the cursor each batch.
42+
- `subscribe(event_class, callback, routing_key="#") -> Thread` — subscribe to live events via the bus. **Live-only — does not replay history.** Use `query_for_consumer` / `iter_query_for_consumer` for catch-up.
43+
44+
## Configuration/Dependencies
45+
- Standard library: `abc`, `dataclasses`, `threading`, `typing`.
46+
- No runtime configuration is defined in this module.
47+
48+
## Usage
49+
50+
```python
51+
from naas_abi_core.services.event.EventPort import IEventService
52+
from naas_abi_core.services.event.ontologies.modules.EventOntology import LogProcess
53+
54+
def emit(svc: IEventService, evt: LogProcess) -> None:
55+
svc.publish(evt)
56+
```
57+
58+
## Caveats
59+
- `subscribe(...)` is **live-only**: subscribers who are not connected at publish time will not receive the event. Late or restarted subscribers must catch up via `query_for_consumer`.
60+
- The cursor in `query_for_consumer` advances **on read**, before processing. A consumer that crashes after the call returns but before processing the events will not see those events again — this is at-most-once semantics by design (v1).
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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`.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# EventSQLiteAdapter
2+
3+
## What it is
4+
SQLite-backed `IEventAdapter`. Single file, WAL mode, two tables:
5+
- `events` — durable append-only log with a global monotonic `seq` (autoincrement primary key).
6+
- `consumer_cursors` — per-(consumer_id, event_type) read position used by `query_for_consumer`.
7+
8+
## Schema
9+
```sql
10+
CREATE TABLE events (
11+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
12+
id TEXT NOT NULL UNIQUE,
13+
event_type TEXT NOT NULL,
14+
timestamp TEXT NOT NULL,
15+
payload BLOB NOT NULL
16+
);
17+
CREATE INDEX idx_events_type_seq ON events(event_type, seq);
18+
CREATE INDEX idx_events_timestamp ON events(timestamp);
19+
20+
CREATE TABLE consumer_cursors (
21+
consumer_id TEXT NOT NULL,
22+
event_type TEXT NOT NULL,
23+
last_seq INTEGER NOT NULL DEFAULT 0,
24+
updated_at TEXT NOT NULL,
25+
PRIMARY KEY (consumer_id, event_type)
26+
);
27+
```
28+
29+
## Public API
30+
31+
### `class EventSQLiteAdapter(IEventAdapter)`
32+
- `__init__(db_path: str)`
33+
- Opens (and creates if missing) the SQLite file at `db_path`. Parent directories are created.
34+
- Sets `journal_mode=WAL` and `synchronous=NORMAL`.
35+
- `close() -> None`
36+
- `append(event_id, event_type, timestamp, payload) -> StoredEvent`
37+
- `query(event_type=None, since_seq=None, since_timestamp=None, until_timestamp=None, limit=None) -> list[StoredEvent]`
38+
- `get_cursor(consumer_id, event_type) -> int`
39+
- `query_for_consumer(consumer_id, event_type, limit=None) -> list[StoredEvent]`
40+
- Atomic: read + cursor advance happen inside `BEGIN IMMEDIATE`.
41+
42+
## Configuration/Dependencies
43+
- Standard library: `sqlite3`, `threading`, `os`, `datetime`.
44+
45+
## Caveats
46+
- One file, one writer at a time (WAL allows concurrent readers). Heavy multi-process write contention may require splitting (not v1).
47+
- `append` raises `sqlite3.IntegrityError` if `event_id` already exists. Caller (the service layer) is expected to use unique instance IRIs.
48+
- `query_for_consumer` is **at-most-once on read**: the cursor advances before the caller processes. There is no ack/nack in v1.

0 commit comments

Comments
 (0)