Skip to main content

khive_runtime/events_split/
stores.rs

1use super::*;
2
3/// [`EventStore`] implementation the runtime hands out when the events split
4/// runs in daemon mode. Appends are fire-and-forget through the client's
5/// bounded queue; the ADR-133 idempotent lane and all reads are synchronous
6/// round-trips to the events daemon.
7#[cfg(unix)]
8#[derive(Debug)]
9pub struct ForwardingEventStore {
10    pub(super) namespace: String,
11    pub(super) client: Arc<EventsSplitClient>,
12}
13
14#[cfg(unix)]
15impl ForwardingEventStore {
16    pub fn new(namespace: impl Into<String>, client: Arc<EventsSplitClient>) -> Self {
17        Self {
18            namespace: namespace.into(),
19            client,
20        }
21    }
22
23    pub(super) fn unexpected_count(
24        &self,
25        op: &'static str,
26        response: EventsResponse,
27    ) -> StorageError {
28        match restore_count_read_failure(response) {
29            Err(error) => error,
30            Ok(other) => self.unexpected(op, other),
31        }
32    }
33
34    pub(super) fn unexpected(&self, op: &'static str, response: EventsResponse) -> StorageError {
35        match response {
36            EventsResponse::Error {
37                message,
38                retryable,
39                writer_task_failure,
40                sqlite_write_failure,
41                read_failure,
42            } => {
43                if read_failure.is_some() {
44                    return events_count_read_budget::malformed_read_failure(
45                        "non-count response carries read failure evidence",
46                    );
47                }
48                // Reconstruct the same legacy policy first; evidence must not
49                // widen retries or turn a known rollback into task termination.
50                let source = if retryable {
51                    StorageError::Pool {
52                        operation: op.into(),
53                        message,
54                    }
55                } else {
56                    StorageError::InvalidInput {
57                        capability: khive_storage::StorageCapability::Events,
58                        operation: op.into(),
59                        message,
60                    }
61                };
62                let source = match sqlite_write_failure {
63                    Some(failure) => source.with_sqlite_write_failure(failure),
64                    None => source,
65                };
66                match writer_task_failure {
67                    Some(WireWriterTaskFailure::RequestFailed { request_state }) => {
68                        StorageError::WriterTaskRequestFailed {
69                            request_state: request_state.into(),
70                            source: Box::new(source),
71                        }
72                    }
73                    Some(WireWriterTaskFailure::TaskTerminated {
74                        request_state,
75                        sqlite_full_codes,
76                    }) => {
77                        let terminal = StorageError::WriterTaskTerminated {
78                            request_state: request_state.into(),
79                            sqlite_full_codes,
80                        };
81                        match sqlite_write_failure {
82                            Some(failure) => terminal.with_sqlite_write_failure(failure),
83                            None => terminal,
84                        }
85                    }
86                    None => source,
87                }
88            }
89            other => StorageError::Serialization {
90                capability: khive_storage::StorageCapability::Events,
91                message: format!("events daemon returned mismatched response for {op}: {other:?}"),
92            },
93        }
94    }
95}
96
97#[cfg(unix)]
98#[async_trait]
99impl EventStore for ForwardingEventStore {
100    async fn append_event(&self, event: Event) -> StorageResult<()> {
101        self.client.enqueue(&self.namespace, vec![event]);
102        Ok(())
103    }
104
105    async fn append_events(&self, events: Vec<Event>) -> StorageResult<BatchWriteSummary> {
106        let attempted = events.len() as u64;
107        self.client.enqueue(&self.namespace, events);
108        // Fire-and-forget: the hand-off succeeded or was counted as a drop;
109        // either way the caller's contract is "accepted for forwarding".
110        Ok(BatchWriteSummary {
111            attempted,
112            affected: attempted,
113            ..BatchWriteSummary::default()
114        })
115    }
116
117    async fn get_event(&self, id: Uuid) -> StorageResult<Option<Event>> {
118        let request = EventsRequest::GetEvent {
119            protocol_version: EVENTS_PROTOCOL_VERSION,
120            namespace: self.namespace.clone(),
121            id,
122        };
123        match self.client.round_trip(&request).await? {
124            EventsResponse::Event { event } => Ok(event),
125            other => Err(self.unexpected("get_event", other)),
126        }
127    }
128
129    async fn query_events(
130        &self,
131        filter: EventFilter,
132        page: PageRequest,
133    ) -> StorageResult<Page<Event>> {
134        let request = EventsRequest::QueryEvents {
135            protocol_version: EVENTS_PROTOCOL_VERSION,
136            namespace: self.namespace.clone(),
137            filter,
138            page,
139        };
140        match self.client.round_trip(&request).await? {
141            EventsResponse::Pageful { page } => Ok(page),
142            other => Err(self.unexpected("query_events", other)),
143        }
144    }
145
146    async fn count_events(&self, filter: EventFilter) -> StorageResult<u64> {
147        let budget = CountReadBudget::capture();
148        let request = EventsRequest::CountEvents {
149            protocol_version: EVENTS_PROTOCOL_VERSION,
150            namespace: self.namespace.clone(),
151            operator_read_budget_secs: Some(budget.operator_read_budget_secs),
152            filter,
153        };
154        match self.client.round_trip_count(&request, &budget).await? {
155            EventsResponse::Count { count } => Ok(count),
156            other => Err(self.unexpected_count("count_events", other)),
157        }
158    }
159
160    async fn count_events_grouped(
161        &self,
162        filter: EventFilter,
163        group_by: EventGroupBy,
164    ) -> StorageResult<BTreeMap<String, u64>> {
165        let budget = CountReadBudget::capture();
166        let request = EventsRequest::CountEventsGrouped {
167            protocol_version: EVENTS_PROTOCOL_VERSION,
168            namespace: self.namespace.clone(),
169            operator_read_budget_secs: Some(budget.operator_read_budget_secs),
170            filter,
171            group_by,
172        };
173        match self.client.round_trip_count(&request, &budget).await? {
174            EventsResponse::GroupedCount { counts } => Ok(counts),
175            other => Err(self.unexpected_count("count_events_grouped", other)),
176        }
177    }
178
179    async fn query_event_page(&self, query: EventPageQuery) -> StorageResult<EventPageWindow> {
180        crate::event_page::validate_page_query(&query)?;
181        let request = EventsRequest::QueryEventPage {
182            protocol_version: EVENTS_PROTOCOL_VERSION,
183            namespace: self.namespace.clone(),
184            query: query.clone(),
185        };
186        match self.client.round_trip(&request).await? {
187            EventsResponse::EventPageWindow { window } => {
188                crate::event_page::validate_window(&query, Some(&self.namespace), &window)?;
189                Ok(window)
190            }
191            error @ EventsResponse::Error { .. } => Err(self.unexpected("query_event_page", error)),
192            _ => Err(crate::event_page::page_error(
193                "events page response kind mismatch",
194            )),
195        }
196    }
197
198    fn preflight_event(&self, event: &Event) -> StorageResult<()> {
199        // Same validation code path as the daemon-side store, zero I/O.
200        self.client.preflight_store.preflight_event(event)
201    }
202
203    async fn append_events_idempotent(
204        &self,
205        events: Vec<Event>,
206    ) -> StorageResult<IdempotentEventBatchResult> {
207        let request = EventsRequest::AppendEventsIdempotent {
208            protocol_version: EVENTS_PROTOCOL_VERSION,
209            namespace: self.namespace.clone(),
210            events,
211        };
212        match self.client.round_trip(&request).await? {
213            EventsResponse::Idempotent { result } => Ok(result),
214            other => Err(self.unexpected("append_events_idempotent", other)),
215        }
216    }
217
218    fn supports_idempotent_audit_batch(&self) -> bool {
219        true
220    }
221}
222
223/// The store the runtime hands out when the events split is configured:
224/// routes by APPEND CLASS rather than moving the whole event plane.
225///
226/// - The ADR-133 idempotent audit-batch lane — the measured bulk of event
227///   write volume (verb-dispatch audit plus the config-lock rows that ride
228///   the same flusher) — goes to the events lane (the events database, forwarded or
229///   direct).
230/// - Plain appends stay on the legacy store, because the legacy `events`
231///   table has raw-SQL consumers whose correctness depends on finding those
232///   rows there: the schedule drain's creator-provenance fence, the kg
233///   projection worker's guarded event INSERT (transactional with main-db
234///   state), and GraphQuery's cross-substrate UNION. Those events are
235///   low-volume domain facts; the split's contention relief does not need
236///   them moved, and moving them breaks the consumers by construction.
237/// - Reads merge both stores so trait-level consumers
238///   (`brain.event_counts`, event getters) observe one event plane.
239pub struct SplitEventStore {
240    pub(super) legacy: Arc<dyn EventStore>,
241    pub(super) lane: Arc<dyn EventStore>,
242}
243
244impl std::fmt::Debug for SplitEventStore {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        f.debug_struct("SplitEventStore").finish_non_exhaustive()
247    }
248}
249
250impl SplitEventStore {
251    /// Bound on `offset + limit` for a merged `query_events` window. Offset
252    /// pagination over two stores materializes the whole prefix in memory
253    /// (see `query_events`), so an unbounded offset would let a single
254    /// request buffer both stores wholesale. The bound comfortably admits
255    /// the largest legitimate bounded window in the tree
256    /// (`brain.event_counts`' 50k page); deep walks page with a `before`
257    /// cursor at `offset: 0`, which never grows the materialized prefix.
258    pub const MAX_MERGED_WINDOW_ROWS: u64 = 100_000;
259
260    pub fn new(legacy: Arc<dyn EventStore>, lane: Arc<dyn EventStore>) -> Self {
261        Self { legacy, lane }
262    }
263}
264
265#[async_trait]
266impl EventStore for SplitEventStore {
267    async fn append_event(&self, event: Event) -> StorageResult<()> {
268        self.legacy.append_event(event).await
269    }
270
271    async fn append_events(&self, events: Vec<Event>) -> StorageResult<BatchWriteSummary> {
272        self.legacy.append_events(events).await
273    }
274
275    async fn get_event(&self, id: Uuid) -> StorageResult<Option<Event>> {
276        // Domain events (legacy) are the likelier and cheaper hit; fall back
277        // to the lane for audit-batch rows.
278        if let Some(event) = self.legacy.get_event(id).await? {
279            return Ok(Some(event));
280        }
281        self.lane.get_event(id).await
282    }
283
284    async fn query_events(
285        &self,
286        filter: EventFilter,
287        page: PageRequest,
288    ) -> StorageResult<Page<Event>> {
289        // Offset pagination cannot be split across two stores: fetch each
290        // store's prefix covering the requested window, merge in the stores'
291        // shared order (created_at DESC, id DESC), then window in memory.
292        // Cost is O(offset + limit) rows materialized from each store — the
293        // floor for offset semantics over two sources, since the split point
294        // is unknowable without both prefixes. The sort below is a single
295        // merge pass in practice (std's stable sort is adaptive on the
296        // concatenation of two sorted runs). That materialization is why the
297        // window is bounded below: without a bound, one authenticated
298        // request with a pathological offset would make both stores buffer
299        // and sort every matching row. Deep walks page with a `before`
300        // cursor (strict `created_at <` bound in `EventFilter`) at
301        // `offset: 0`, which stays inside the bound at any depth.
302        let window = page.offset.saturating_add(u64::from(page.limit));
303        if window > Self::MAX_MERGED_WINDOW_ROWS {
304            return Err(StorageError::InvalidInput {
305                capability: khive_storage::StorageCapability::Events,
306                operation: "query_events".into(),
307                message: format!(
308                    "offset+limit ({window}) exceeds the merged event plane's window bound \
309                     of {}; page deep windows with a `before` cursor at offset 0, or narrow \
310                     the filter",
311                    Self::MAX_MERGED_WINDOW_ROWS
312                ),
313            });
314        }
315        let prefix = PageRequest {
316            offset: 0,
317            limit: window.min(u64::from(u32::MAX)) as u32,
318        };
319        let legacy = self
320            .legacy
321            .query_events(filter.clone(), prefix.clone())
322            .await?;
323        let lane = self.lane.query_events(filter, prefix).await?;
324        let total = match (legacy.total, lane.total) {
325            (Some(a), Some(b)) => Some(a + b),
326            _ => None,
327        };
328        let mut items = legacy.items;
329        items.extend(lane.items);
330        items.sort_by(|a, b| {
331            b.created_at
332                .cmp(&a.created_at)
333                .then_with(|| b.id.cmp(&a.id))
334        });
335        let items = items
336            .into_iter()
337            .skip(page.offset as usize)
338            .take(page.limit as usize)
339            .collect();
340        Ok(Page { items, total })
341    }
342
343    async fn count_events(&self, filter: EventFilter) -> StorageResult<u64> {
344        let budget = CountReadBudget::capture();
345        budget
346            .scope(async {
347                let legacy = self
348                    .legacy
349                    .count_events(filter.clone())
350                    .await
351                    .map_err(|error| budget.attribute(error))?;
352                let lane = self.lane.count_events(filter).await?;
353                Ok(legacy + lane)
354            })
355            .await
356    }
357
358    async fn count_events_grouped(
359        &self,
360        filter: EventFilter,
361        group_by: EventGroupBy,
362    ) -> StorageResult<BTreeMap<String, u64>> {
363        let budget = CountReadBudget::capture();
364        budget
365            .scope(async {
366                let mut counts = self
367                    .legacy
368                    .count_events_grouped(filter.clone(), group_by)
369                    .await
370                    .map_err(|error| budget.attribute(error))?;
371                for (key, count) in self.lane.count_events_grouped(filter, group_by).await? {
372                    let total = counts.entry(key).or_default();
373                    *total =
374                        total
375                            .checked_add(count)
376                            .ok_or_else(|| StorageError::InvalidInput {
377                                capability: khive_storage::StorageCapability::Events,
378                                operation: "count_events_grouped".into(),
379                                message: "grouped event count overflow".into(),
380                            })?;
381                }
382                Ok(counts)
383            })
384            .await
385    }
386
387    async fn query_event_page(&self, query: EventPageQuery) -> StorageResult<EventPageWindow> {
388        crate::event_page::split_page(self.legacy.as_ref(), self.lane.as_ref(), query).await
389    }
390
391    fn preflight_event(&self, event: &Event) -> StorageResult<()> {
392        // Validation is store-independent; the lane's implementation is the
393        // local zero-I/O validator in forwarding mode.
394        self.lane.preflight_event(event)
395    }
396
397    async fn append_events_idempotent(
398        &self,
399        events: Vec<Event>,
400    ) -> StorageResult<IdempotentEventBatchResult> {
401        // A retried batch may have landed — fully or partially — on the
402        // legacy store before the split cutover, and merged reads stay
403        // duplicate-free only while an id lives in exactly one store. Probe
404        // the legacy store for the batch's ids and route resident rows back
405        // through the legacy store's own idempotent machinery, which compares
406        // every persisted column without re-inserting; only genuinely new
407        // rows reach the lane. Without the probe, a legacy-resident id would
408        // be inserted a second time into the lane and every merged query and
409        // count would double-count it.
410        if events.is_empty() {
411            return self.lane.append_events_idempotent(events).await;
412        }
413        let ids: Vec<Uuid> = events.iter().map(|event| event.id).collect();
414        let probe_limit = u32::try_from(ids.len()).map_err(|_| StorageError::InvalidInput {
415            capability: khive_storage::StorageCapability::Events,
416            operation: "append_events_idempotent".into(),
417            message: format!(
418                "batch of {} rows exceeds the legacy probe window",
419                ids.len()
420            ),
421        })?;
422        let existing = self
423            .legacy
424            .query_events(
425                EventFilter {
426                    ids,
427                    ..EventFilter::default()
428                },
429                PageRequest {
430                    offset: 0,
431                    limit: probe_limit,
432                },
433            )
434            .await?;
435        let legacy_resident: std::collections::HashSet<Uuid> =
436            existing.items.iter().map(|event| event.id).collect();
437        if legacy_resident.is_empty() {
438            return self.lane.append_events_idempotent(events).await;
439        }
440        let mut legacy_rows = Vec::new();
441        let mut lane_rows = Vec::new();
442        let mut routed_to_legacy = Vec::with_capacity(events.len());
443        for event in events {
444            if legacy_resident.contains(&event.id) {
445                routed_to_legacy.push(true);
446                legacy_rows.push(event);
447            } else {
448                routed_to_legacy.push(false);
449                lane_rows.push(event);
450            }
451        }
452        let legacy_expected = legacy_rows.len();
453        let lane_expected = lane_rows.len();
454        let legacy_result = self.legacy.append_events_idempotent(legacy_rows).await?;
455        let lane_result = if lane_expected == 0 {
456            IdempotentEventBatchResult { rows: Vec::new() }
457        } else {
458            self.lane.append_events_idempotent(lane_rows).await?
459        };
460        if legacy_result.rows.len() != legacy_expected || lane_result.rows.len() != lane_expected {
461            return Err(StorageError::Driver {
462                capability: khive_storage::StorageCapability::Events,
463                operation: "append_events_idempotent".into(),
464                source: format!(
465                    "idempotent sub-batch result length mismatch: legacy {}/{}, lane {}/{}",
466                    legacy_result.rows.len(),
467                    legacy_expected,
468                    lane_result.rows.len(),
469                    lane_expected
470                )
471                .into(),
472            });
473        }
474        let mut legacy_iter = legacy_result.rows.into_iter();
475        let mut lane_iter = lane_result.rows.into_iter();
476        let rows = routed_to_legacy
477            .into_iter()
478            .map(|to_legacy| {
479                if to_legacy {
480                    legacy_iter.next().expect("length checked above")
481                } else {
482                    lane_iter.next().expect("length checked above")
483                }
484            })
485            .collect();
486        Ok(IdempotentEventBatchResult { rows })
487    }
488
489    fn supports_idempotent_audit_batch(&self) -> bool {
490        // The legacy-resident probe above routes retried rows through the
491        // legacy store's idempotent path, so the split supports the audit
492        // batch only when BOTH stores do.
493        self.lane.supports_idempotent_audit_batch() && self.legacy.supports_idempotent_audit_batch()
494    }
495}