use std::num::NonZeroUsize;
use aion_core::{ActivityEventKind, ActivityId, MessageRole, RunId, WorkflowId};
use aion_store::InMemoryObservabilityStore;
use chrono::Utc;
use futures::StreamExt;
use uuid::Uuid;
use super::*;
fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
}
fn publisher(cap: usize) -> Result<ActivityEventPublisher, Box<dyn std::error::Error>> {
let store = Arc::new(InMemoryObservabilityStore::default());
Ok(ActivityEventPublisher::new(store, capacity(cap)?))
}
fn generation_one() -> RunId {
RunId::new(Uuid::from_u128(0x11))
}
fn generation_two() -> RunId {
RunId::new(Uuid::from_u128(0x22))
}
fn event(attempt: u32, worker_seq: u64, ephemeral: bool, text: &str) -> ActivityEvent {
ActivityEvent {
workflow_id: WorkflowId::new(Uuid::from_u128(1)),
run_id: generation_one(),
activity_id: ActivityId::from_sequence_position(3),
attempt,
agent_id: Uuid::from_u128(9),
agent_role: "orchestrator".to_owned(),
emitted_at: Utc::now(),
worker_seq,
store_seq: None,
ephemeral,
kind: if ephemeral {
ActivityEventKind::Delta {
message_id: "m1".to_owned(),
text_fragment: text.to_owned(),
}
} else {
ActivityEventKind::Message {
role: MessageRole::Assistant,
text: text.to_owned(),
}
},
}
}
fn key(attempt: u32) -> ActivityStreamKey {
ActivityStreamKey::new(
WorkflowId::new(Uuid::from_u128(1)),
generation_one(),
ActivityId::from_sequence_position(3),
attempt,
)
}
#[tokio::test]
async fn publish_assigns_commit_allocated_monotonic_store_seq()
-> Result<(), Box<dyn std::error::Error>> {
let publisher = publisher(16)?;
assert_eq!(publisher.publish(&event(0, 1, false, "a")).await?, Some(0));
assert_eq!(publisher.publish(&event(0, 2, false, "b")).await?, Some(1));
assert_eq!(publisher.publish(&event(0, 3, false, "c")).await?, Some(2));
let tail = publisher.replay_from(&key(0), 0).await?;
assert_eq!(
tail.iter().map(|r| r.store_seq).collect::<Vec<_>>(),
vec![0, 1, 2]
);
Ok(())
}
#[tokio::test]
async fn ephemeral_events_are_never_persisted() -> Result<(), Box<dyn std::error::Error>> {
let publisher = publisher(16)?;
let mut live = publisher.subscribe(key(0), None);
assert_eq!(publisher.publish(&event(0, 1, true, "wor")).await?, None);
assert_eq!(
publisher.publish(&event(0, 2, false, "word")).await?,
Some(0)
);
let tail = publisher.replay_from(&key(0), 0).await?;
assert_eq!(tail.len(), 1);
assert!(matches!(
tail[0].event.kind,
ActivityEventKind::Message { .. }
));
let first = live.next().await.ok_or("missing ephemeral")??;
assert!(first.ephemeral);
assert_eq!(first.store_seq, None);
let second = live.next().await.ok_or("missing message")??;
assert!(!second.ephemeral);
assert_eq!(second.store_seq, Some(0));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_writers_produce_gapless_monotonic_store_seq()
-> Result<(), Box<dyn std::error::Error>> {
let publisher = publisher(256)?;
let writers = 32u64;
let mut handles = Vec::new();
for worker_seq in 0..writers {
let publisher = publisher.clone();
handles.push(tokio::spawn(async move {
publisher
.publish(&event(0, worker_seq, false, "concurrent"))
.await
}));
}
let mut assigned = Vec::new();
for handle in handles {
if let Some(store_seq) = handle.await?? {
assigned.push(store_seq);
}
}
assigned.sort_unstable();
assert_eq!(
assigned,
(0..writers).collect::<Vec<_>>(),
"concurrent writers must produce a gapless, duplicate-free monotonic sequence"
);
let tail = publisher.replay_from(&key(0), 0).await?;
assert_eq!(
tail.iter().map(|r| r.store_seq).collect::<Vec<_>>(),
(0..writers).collect::<Vec<_>>()
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn failover_double_emit_dedupes_to_one_monotonic_stream()
-> Result<(), Box<dyn std::error::Error>> {
let store = Arc::new(InMemoryObservabilityStore::default());
let dying = ActivityEventPublisher::new(store.clone(), capacity(256)?);
let adopting = ActivityEventPublisher::new(store, capacity(256)?);
let mut handles = Vec::new();
for worker_seq in 0..16u64 {
let dying = dying.clone();
let adopting = adopting.clone();
handles.push(tokio::spawn(async move {
let a = dying.publish(&event(0, worker_seq, false, "dying")).await;
let b = adopting
.publish(&event(0, worker_seq, false, "adopting"))
.await;
(a, b)
}));
}
let mut assigned = Vec::new();
for handle in handles {
let (a, b) = handle.await?;
if let Some(seq) = a? {
assigned.push(seq);
}
if let Some(seq) = b? {
assigned.push(seq);
}
}
assigned.sort_unstable();
assert_eq!(
assigned,
(0..32).collect::<Vec<_>>(),
"failover double-emit must land a gapless monotonic store_seq stream"
);
let tail = dying.replay_from(&key(0), 0).await?;
let seqs: Vec<u64> = tail.iter().map(|r| r.store_seq).collect();
assert_eq!(seqs, (0..32).collect::<Vec<_>>());
Ok(())
}
#[tokio::test]
async fn process_local_atomic_counter_collides_across_survivors()
-> Result<(), Box<dyn std::error::Error>> {
use std::sync::atomic::{AtomicU64, Ordering};
let dying = AtomicU64::new(0);
let adopting = AtomicU64::new(0);
let dying_seq = dying.fetch_add(1, Ordering::SeqCst);
let adopting_seq = adopting.fetch_add(1, Ordering::SeqCst);
assert_eq!(
dying_seq, adopting_seq,
"process-local counters collide across survivors (the forbidden pattern)"
);
let store = Arc::new(InMemoryObservabilityStore::default());
let a = ActivityEventPublisher::new(store.clone(), capacity(8)?);
let b = ActivityEventPublisher::new(store, capacity(8)?);
let sa = a.publish(&event(0, 1, false, "a")).await?;
let sb = b.publish(&event(0, 2, false, "b")).await?;
assert_ne!(
sa, sb,
"commit-allocated store_seq must be distinct across survivors"
);
assert_eq!((sa, sb), (Some(0), Some(1)));
Ok(())
}
#[tokio::test]
async fn live_stream_then_resume_by_store_seq_has_no_gap() -> Result<(), Box<dyn std::error::Error>>
{
let publisher = publisher(64)?;
for seq in 0..3u64 {
publisher.publish(&event(0, seq, false, "early")).await?;
}
let mut live = publisher.subscribe(key(0), Some(1));
let replay = publisher.replay_from(&key(0), 2).await?;
assert_eq!(
replay.iter().map(|r| r.store_seq).collect::<Vec<_>>(),
vec![2]
);
publisher.publish(&event(0, 10, false, "live-3")).await?;
publisher.publish(&event(0, 11, false, "live-4")).await?;
let first = live.next().await.ok_or("missing live-3")??;
assert_eq!(first.store_seq, Some(3));
let second = live.next().await.ok_or("missing live-4")??;
assert_eq!(second.store_seq, Some(4));
Ok(())
}
#[tokio::test]
async fn subscribe_filters_out_other_attempt_streams() -> Result<(), Box<dyn std::error::Error>> {
let publisher = publisher(64)?;
let mut live = publisher.subscribe(key(0), None);
publisher
.publish(&event(1, 1, false, "other-attempt"))
.await?;
publisher.publish(&event(0, 1, false, "mine")).await?;
let received = live.next().await.ok_or("missing my event")??;
assert_eq!(received.attempt, 0);
assert!(matches!(
received.kind,
ActivityEventKind::Message { text, .. } if text == "mine"
));
Ok(())
}
#[tokio::test]
async fn lagged_subscriber_yields_typed_skip_count() -> Result<(), Box<dyn std::error::Error>> {
let publisher = publisher(2)?;
let mut live = publisher.subscribe(key(0), None);
for seq in 0..5u64 {
publisher.publish(&event(0, seq, false, "flood")).await?;
}
let lagged = live.next().await.ok_or("missing lag item")?;
assert!(matches!(lagged, Err(TranscriptStreamLagged { .. })));
Ok(())
}
fn bounded_publisher(
cap: usize,
bounds: TranscriptBounds,
) -> Result<ActivityEventPublisher, Box<dyn std::error::Error>> {
Ok(publisher(cap)?.with_bounds(bounds))
}
#[tokio::test]
async fn stream_cap_appends_one_marker_then_stops_persisting()
-> Result<(), Box<dyn std::error::Error>> {
let publisher = bounded_publisher(
64,
TranscriptBounds {
max_event_bytes: 256 * 1024,
max_stream_events: 3,
},
)?;
let mut assigned = Vec::new();
for worker_seq in 0..6u64 {
assigned.push(
publisher
.publish(&event(0, worker_seq, false, "chatty"))
.await?,
);
}
assert_eq!(
assigned,
vec![Some(0), Some(1), Some(2), None, None, None],
"publishes past the cap return Ok(None)"
);
let tail = publisher.replay_from(&key(0), 0).await?;
assert_eq!(
tail.iter().map(|r| r.store_seq).collect::<Vec<_>>(),
vec![0, 1, 2, 3],
"exactly the capped records plus the one marker"
);
let ActivityEventKind::Progress {
detail: ProgressDetail::Note { text },
} = &tail[3].event.kind
else {
return Err("record 3 must be the retention-cap marker note".into());
};
assert!(
text.contains("retention cap"),
"the marker names the cap: {text}"
);
assert!(text.contains("3 events"), "the marker names the value");
Ok(())
}
#[tokio::test]
async fn capped_stream_still_fans_out_live_without_store_seq()
-> Result<(), Box<dyn std::error::Error>> {
let publisher = bounded_publisher(
64,
TranscriptBounds {
max_event_bytes: 256 * 1024,
max_stream_events: 1,
},
)?;
let mut live = publisher.subscribe(key(0), None);
assert_eq!(
publisher.publish(&event(0, 1, false, "kept")).await?,
Some(0)
);
assert_eq!(publisher.publish(&event(0, 2, false, "over")).await?, None);
assert_eq!(
publisher.publish(&event(0, 3, false, "way-over")).await?,
None
);
let kept = live.next().await.ok_or("missing kept")??;
assert_eq!(kept.store_seq, Some(0));
let marker = live.next().await.ok_or("missing marker")??;
assert_eq!(
marker.store_seq,
Some(1),
"the marker carries its store_seq"
);
assert!(matches!(marker.kind, ActivityEventKind::Progress { .. }));
let over = live.next().await.ok_or("missing over")??;
assert_eq!(over.store_seq, None, "past-cap events carry no store_seq");
assert!(!over.ephemeral, "past-cap events are NOT ephemeral");
assert!(matches!(
over.kind,
ActivityEventKind::Message { text, .. } if text == "over"
));
let way_over = live.next().await.ok_or("missing way-over")??;
assert_eq!(way_over.store_seq, None);
assert!(!way_over.ephemeral);
Ok(())
}
#[tokio::test]
async fn oversized_event_is_truncated_before_persist() -> Result<(), Box<dyn std::error::Error>> {
let publisher = bounded_publisher(
16,
TranscriptBounds {
max_event_bytes: 512,
max_stream_events: 20_000,
},
)?;
let huge = "x".repeat(10_000);
assert_eq!(
publisher.publish(&event(0, 1, false, &huge)).await?,
Some(0)
);
let tail = publisher.replay_from(&key(0), 0).await?;
assert_eq!(tail.len(), 1);
let ActivityEventKind::Message { text, .. } = &tail[0].event.kind else {
return Err("expected the truncated message".into());
};
assert!(
text.ends_with("bytes by observability.max_event_bytes]"),
"the retained text ends with the truncation marker: {text}"
);
assert!(
serde_json::to_vec(&tail[0].event)?.len() <= 1024,
"the retained record is bounded (512 + marker slack)"
);
assert!(
!text.contains(&huge),
"the original oversized text is not retained in full"
);
Ok(())
}
#[tokio::test]
async fn two_generations_of_one_chain_are_sequenced_independently()
-> Result<(), Box<dyn std::error::Error>> {
let publisher = publisher(16)?;
let ordinal_zero = ActivityId::from_sequence_position(0);
let first = ActivityEvent {
activity_id: ordinal_zero.clone(),
attempt: 1,
..event(1, 1, false, "generation one")
};
let second = ActivityEvent {
run_id: generation_two(),
..first.clone()
};
assert_eq!(publisher.publish(&first).await?, Some(0));
assert_eq!(
publisher.publish(&second).await?,
Some(0),
"generation two's first event is its own stream head, not generation one's second record"
);
let first_key = ActivityStreamKey::of(&first);
let second_key = ActivityStreamKey::of(&second);
assert_ne!(first_key, second_key);
assert_eq!(first_key.workflow_id, second_key.workflow_id);
assert_eq!(first_key.activity_id, second_key.activity_id);
assert_eq!(first_key.attempt, second_key.attempt);
let second_generation = publisher.replay_from(&second_key, 0).await?;
assert_eq!(
second_generation.len(),
1,
"a read scoped to generation two must return exactly its own event"
);
assert_eq!(second_generation[0].event.run_id, generation_two());
let first_generation = publisher.replay_from(&first_key, 0).await?;
assert_eq!(first_generation.len(), 1);
assert_eq!(first_generation[0].event.run_id, generation_one());
Ok(())
}
#[tokio::test]
async fn live_tail_does_not_leak_across_generations() -> Result<(), Box<dyn std::error::Error>> {
let publisher = publisher(16)?;
let ordinal_zero = ActivityId::from_sequence_position(0);
let first = ActivityEvent {
activity_id: ordinal_zero.clone(),
attempt: 1,
..event(1, 1, false, "generation one")
};
let second = ActivityEvent {
run_id: generation_two(),
..ActivityEvent {
worker_seq: 2,
..first.clone()
}
};
let mut tail = publisher.subscribe(ActivityStreamKey::of(&second), None);
publisher.publish(&first).await?;
publisher.publish(&second).await?;
let delivered = tail.next().await.ok_or("the live tail closed early")??;
assert_eq!(
delivered.run_id,
generation_two(),
"the sibling generation's event must not reach a run-scoped subscriber"
);
assert_eq!(delivered.worker_seq, 2);
Ok(())
}
#[tokio::test]
async fn the_retention_cap_is_measured_per_run_not_per_chain()
-> Result<(), Box<dyn std::error::Error>> {
let publisher = bounded_publisher(
64,
TranscriptBounds {
max_event_bytes: 256 * 1024,
max_stream_events: 2,
},
)?;
let ordinal_zero = ActivityId::from_sequence_position(0);
let generation = |run_id: RunId, worker_seq: u64| ActivityEvent {
run_id,
activity_id: ordinal_zero.clone(),
attempt: 1,
..event(1, worker_seq, false, "x")
};
assert_eq!(
publisher.publish(&generation(generation_one(), 1)).await?,
Some(0)
);
assert_eq!(
publisher.publish(&generation(generation_one(), 2)).await?,
Some(1)
);
assert_eq!(
publisher.publish(&generation(generation_one(), 3)).await?,
None,
"the third event crosses the cap and is live-only"
);
assert_eq!(
publisher.publish(&generation(generation_two(), 1)).await?,
Some(0),
"a new generation must not inherit its predecessor's exhausted retention budget"
);
assert_eq!(
publisher.publish(&generation(generation_two(), 2)).await?,
Some(1)
);
Ok(())
}