1use async_trait::async_trait;
31
32use aion_core::{ActivityEvent, ActivityId, WorkflowId};
33
34use crate::StoreError;
35
36#[derive(Clone, Debug, PartialEq, Eq, Hash)]
40pub struct ActivityStreamKey {
41 pub workflow_id: WorkflowId,
43 pub activity_id: ActivityId,
45 pub attempt: u32,
50}
51
52impl ActivityStreamKey {
53 #[must_use]
55 pub const fn new(workflow_id: WorkflowId, activity_id: ActivityId, attempt: u32) -> Self {
56 Self {
57 workflow_id,
58 activity_id,
59 attempt,
60 }
61 }
62
63 #[must_use]
65 pub fn of(event: &ActivityEvent) -> Self {
66 Self {
67 workflow_id: event.workflow_id.clone(),
68 activity_id: event.activity_id.clone(),
69 attempt: event.attempt,
70 }
71 }
72}
73
74#[derive(Clone, Debug, PartialEq)]
81pub struct ActivityRecord {
82 pub store_seq: u64,
84 pub event: ActivityEvent,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct ActivityStreamSummary {
93 pub key: ActivityStreamKey,
95 pub head: u64,
97}
98
99#[async_trait]
106pub trait ObservabilityStore: Send + Sync + 'static {
107 async fn append_activity_event(
121 &self,
122 expected_seq: u64,
123 event: &ActivityEvent,
124 ) -> Result<u64, StoreError>;
125
126 async fn activity_head(&self, key: &ActivityStreamKey) -> Result<u64, StoreError>;
134
135 async fn read_activity_events_from(
144 &self,
145 key: &ActivityStreamKey,
146 from_seq: u64,
147 ) -> Result<Vec<ActivityRecord>, StoreError>;
148
149 async fn list_activity_streams(
156 &self,
157 workflow_id: &WorkflowId,
158 ) -> Result<Vec<ActivityStreamSummary>, StoreError>;
159}
160
161#[derive(Debug, Default)]
170pub struct InMemoryObservabilityStore {
171 streams:
172 std::sync::Mutex<std::collections::HashMap<ActivityStreamKeyBytes, Vec<ActivityRecord>>>,
173}
174
175type ActivityStreamKeyBytes = (uuid::Uuid, u64, u32);
177
178fn key_bytes(key: &ActivityStreamKey) -> ActivityStreamKeyBytes {
179 (
180 key.workflow_id.as_uuid(),
181 key.activity_id.sequence_position(),
182 key.attempt,
183 )
184}
185
186fn stream_head(stream: &[ActivityRecord]) -> u64 {
193 u64::try_from(stream.len()).unwrap_or(u64::MAX)
194}
195
196#[async_trait]
197impl ObservabilityStore for InMemoryObservabilityStore {
198 async fn append_activity_event(
199 &self,
200 expected_seq: u64,
201 event: &ActivityEvent,
202 ) -> Result<u64, StoreError> {
203 let key = ActivityStreamKey::of(event);
204 let mut streams = self.streams.lock().map_err(|error| {
205 StoreError::Backend(format!("observability mutex poisoned: {error}"))
206 })?;
207 let stream = streams.entry(key_bytes(&key)).or_default();
208 let head = stream_head(stream);
209 if head != expected_seq {
210 return Err(StoreError::SequenceConflict {
211 expected: expected_seq,
212 found: head,
213 });
214 }
215 let mut event = event.clone();
216 event.store_seq = Some(head);
217 stream.push(ActivityRecord {
218 store_seq: head,
219 event,
220 });
221 Ok(head)
222 }
223
224 async fn activity_head(&self, key: &ActivityStreamKey) -> Result<u64, StoreError> {
225 let streams = self.streams.lock().map_err(|error| {
226 StoreError::Backend(format!("observability mutex poisoned: {error}"))
227 })?;
228 Ok(streams
229 .get(&key_bytes(key))
230 .map_or(0, |stream| stream_head(stream)))
231 }
232
233 async fn read_activity_events_from(
234 &self,
235 key: &ActivityStreamKey,
236 from_seq: u64,
237 ) -> Result<Vec<ActivityRecord>, StoreError> {
238 let streams = self.streams.lock().map_err(|error| {
239 StoreError::Backend(format!("observability mutex poisoned: {error}"))
240 })?;
241 Ok(streams
242 .get(&key_bytes(key))
243 .map_or_else(Vec::new, |stream| {
244 stream
245 .iter()
246 .filter(|record| record.store_seq >= from_seq)
247 .cloned()
248 .collect()
249 }))
250 }
251
252 async fn list_activity_streams(
253 &self,
254 workflow_id: &WorkflowId,
255 ) -> Result<Vec<ActivityStreamSummary>, StoreError> {
256 let streams = self.streams.lock().map_err(|error| {
257 StoreError::Backend(format!("observability mutex poisoned: {error}"))
258 })?;
259 let mut summaries: Vec<ActivityStreamSummary> = streams
260 .iter()
261 .filter(|((workflow, _activity, _attempt), _records)| {
262 *workflow == workflow_id.as_uuid()
263 })
264 .map(
265 |(&(workflow, activity_seq, attempt), records)| ActivityStreamSummary {
266 key: ActivityStreamKey::new(
267 WorkflowId::new(workflow),
268 ActivityId::from_sequence_position(activity_seq),
269 attempt,
270 ),
271 head: stream_head(records),
272 },
273 )
274 .collect();
275 summaries.sort_by_key(|summary| {
276 (
277 summary.key.activity_id.sequence_position(),
278 summary.key.attempt,
279 )
280 });
281 Ok(summaries)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use aion_core::{ActivityEventKind, MessageRole};
289 use chrono::Utc;
290 use uuid::Uuid;
291
292 fn event(attempt: u32, worker_seq: u64, text: &str) -> ActivityEvent {
293 ActivityEvent {
294 workflow_id: WorkflowId::new(Uuid::from_u128(1)),
295 activity_id: ActivityId::from_sequence_position(3),
296 attempt,
297 agent_id: Uuid::from_u128(9),
298 agent_role: "orchestrator".to_owned(),
299 emitted_at: Utc::now(),
300 worker_seq,
301 store_seq: None,
302 ephemeral: false,
303 kind: ActivityEventKind::Message {
304 role: MessageRole::Assistant,
305 text: text.to_owned(),
306 },
307 }
308 }
309
310 #[tokio::test]
311 async fn append_assigns_contiguous_store_seq_from_zero() -> Result<(), StoreError> {
312 let store = InMemoryObservabilityStore::default();
313 let key = ActivityStreamKey::new(
314 WorkflowId::new(Uuid::from_u128(1)),
315 ActivityId::from_sequence_position(3),
316 0,
317 );
318 assert_eq!(store.activity_head(&key).await?, 0);
319 assert_eq!(store.append_activity_event(0, &event(0, 1, "a")).await?, 0);
320 assert_eq!(store.append_activity_event(1, &event(0, 2, "b")).await?, 1);
321 assert_eq!(store.activity_head(&key).await?, 2);
322 let records = store.read_activity_events_from(&key, 0).await?;
323 assert_eq!(records.len(), 2);
324 assert_eq!(records[0].store_seq, 0);
325 assert_eq!(records[0].event.store_seq, Some(0));
326 assert_eq!(records[1].store_seq, 1);
327 Ok(())
328 }
329
330 #[tokio::test]
331 async fn stale_expected_seq_conflicts_and_writes_nothing() -> Result<(), StoreError> {
332 let store = InMemoryObservabilityStore::default();
333 store.append_activity_event(0, &event(0, 1, "a")).await?;
334 let conflict = store.append_activity_event(0, &event(0, 2, "dup")).await;
336 assert_eq!(
337 conflict,
338 Err(StoreError::SequenceConflict {
339 expected: 0,
340 found: 1
341 })
342 );
343 let key = ActivityStreamKey::of(&event(0, 0, ""));
344 assert_eq!(store.read_activity_events_from(&key, 0).await?.len(), 1);
346 Ok(())
347 }
348
349 #[tokio::test]
350 async fn attempts_are_disjoint_streams() -> Result<(), StoreError> {
351 let store = InMemoryObservabilityStore::default();
352 store
353 .append_activity_event(0, &event(0, 1, "attempt-0"))
354 .await?;
355 store
357 .append_activity_event(0, &event(1, 1, "attempt-1"))
358 .await?;
359 let key0 = ActivityStreamKey::new(
360 WorkflowId::new(Uuid::from_u128(1)),
361 ActivityId::from_sequence_position(3),
362 0,
363 );
364 let key1 = ActivityStreamKey::new(
365 WorkflowId::new(Uuid::from_u128(1)),
366 ActivityId::from_sequence_position(3),
367 1,
368 );
369 assert_eq!(store.activity_head(&key0).await?, 1);
370 assert_eq!(store.activity_head(&key1).await?, 1);
371 Ok(())
372 }
373
374 #[tokio::test]
378 async fn list_activity_streams_orders_by_activity_then_attempt() -> Result<(), StoreError> {
379 let store = InMemoryObservabilityStore::default();
380 let event_for = |activity_seq: u64, attempt: u32, workflow: u128| {
381 let mut event = event(attempt, 1, "x");
382 event.workflow_id = WorkflowId::new(Uuid::from_u128(workflow));
383 event.activity_id = ActivityId::from_sequence_position(activity_seq);
384 event
385 };
386 store.append_activity_event(0, &event_for(5, 0, 1)).await?;
389 store.append_activity_event(0, &event_for(3, 1, 1)).await?;
390 store.append_activity_event(0, &event_for(3, 0, 1)).await?;
391 store.append_activity_event(1, &event_for(3, 0, 1)).await?;
392 store.append_activity_event(0, &event_for(3, 0, 2)).await?;
394
395 let summaries = store
396 .list_activity_streams(&WorkflowId::new(Uuid::from_u128(1)))
397 .await?;
398 let listed: Vec<(u64, u32, u64)> = summaries
399 .iter()
400 .map(|summary| {
401 (
402 summary.key.activity_id.sequence_position(),
403 summary.key.attempt,
404 summary.head,
405 )
406 })
407 .collect();
408 assert_eq!(listed, vec![(3, 0, 2), (3, 1, 1), (5, 0, 1)]);
409 Ok(())
410 }
411
412 #[tokio::test]
413 async fn list_activity_streams_is_empty_for_unknown_workflow() -> Result<(), StoreError> {
414 let store = InMemoryObservabilityStore::default();
415 store.append_activity_event(0, &event(0, 1, "a")).await?;
416 let summaries = store
417 .list_activity_streams(&WorkflowId::new(Uuid::from_u128(99)))
418 .await?;
419 assert!(summaries.is_empty(), "an unwritten workflow lists empty");
420 Ok(())
421 }
422
423 #[tokio::test]
424 async fn read_from_resumes_by_store_seq() -> Result<(), StoreError> {
425 let store = InMemoryObservabilityStore::default();
426 for seq in 0..5u64 {
427 store
428 .append_activity_event(seq, &event(0, seq, "x"))
429 .await?;
430 }
431 let key = ActivityStreamKey::of(&event(0, 0, ""));
432 let tail = store.read_activity_events_from(&key, 3).await?;
433 assert_eq!(tail.len(), 2);
434 assert_eq!(tail[0].store_seq, 3);
435 assert_eq!(tail[1].store_seq, 4);
436 Ok(())
437 }
438}