use std::sync::{Arc, Mutex, PoisonError};
use std::time::Instant;
use aion_core::ActivityEvent;
use liminal_sdk::{OBSERVABILITY_CHANNEL, PushWriter};
use tokio::sync::mpsc;
use crate::runtime::liminal_redial::RedialBackoff;
#[derive(Clone, Debug, Default)]
pub struct LiveWriter {
slot: Arc<Mutex<Option<PushWriter>>>,
}
impl LiveWriter {
#[must_use]
pub fn seeded(writer: PushWriter) -> Self {
Self {
slot: Arc::new(Mutex::new(Some(writer))),
}
}
pub fn set(&self, writer: PushWriter) {
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
*slot = Some(writer);
}
fn current(&self) -> Option<PushWriter> {
self.slot
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
}
#[derive(Debug)]
enum DrainStep {
Published,
Broken {
reason: String,
},
}
trait EventSink: Send {
fn publish(&self, payload: Vec<u8>) -> DrainStep;
}
impl EventSink for LiveWriter {
fn publish(&self, payload: Vec<u8>) -> DrainStep {
match self.current() {
Some(writer) => match writer.publish(OBSERVABILITY_CHANNEL, payload) {
Ok(()) => DrainStep::Published,
Err(error) => DrainStep::Broken {
reason: error.to_string(),
},
},
None => DrainStep::Broken {
reason: "no live connection is currently available".to_owned(),
},
}
}
}
#[derive(Debug)]
struct DrainState {
broken: bool,
dropped: u64,
backoff: RedialBackoff,
next_probe: Instant,
}
impl DrainState {
fn new(backoff: RedialBackoff, now: Instant) -> Self {
Self {
broken: false,
dropped: 0,
backoff,
next_probe: now,
}
}
fn should_probe(&self, now: Instant) -> bool {
!self.broken || now >= self.next_probe
}
fn on_deferred(&mut self) {
self.dropped = self.dropped.saturating_add(1);
}
fn on_published(&mut self) -> Option<u64> {
if !self.broken {
return None;
}
let dropped = self.dropped;
self.broken = false;
self.dropped = 0;
self.backoff.reset();
Some(dropped)
}
fn on_broken(&mut self, now: Instant) -> bool {
let started = !self.broken;
self.broken = true;
self.dropped = self.dropped.saturating_add(1);
self.next_probe = now + self.backoff.current();
self.backoff.increase();
started
}
fn on_close(&self) -> Option<u64> {
(self.broken && self.dropped > 0).then_some(self.dropped)
}
}
async fn run_drain<S: EventSink>(
sink: S,
mut receiver: mpsc::UnboundedReceiver<ActivityEvent>,
backoff: RedialBackoff,
) {
let mut state = DrainState::new(backoff, Instant::now());
while let Some(event) = receiver.recv().await {
let payload = match serde_json::to_vec(&event) {
Ok(payload) => payload,
Err(error) => {
tracing::warn!(%error, "observability drain: failed to encode ActivityEvent");
continue;
}
};
let now = Instant::now();
if !state.should_probe(now) {
state.on_deferred();
continue;
}
match sink.publish(payload) {
DrainStep::Published => {
if let Some(dropped) = state.on_published() {
tracing::warn!(
dropped,
"observability drain: connection restored; resumed publishing after \
dropping transcript events during the outage"
);
}
}
DrainStep::Broken { reason } => {
if state.on_broken(now) {
tracing::warn!(
error = %reason,
"observability drain: connection lost; dropping transcript events until \
it is restored (best-effort live stream, no buffering)"
);
}
}
}
}
if let Some(dropped) = state.on_close() {
tracing::warn!(
dropped,
"observability drain: connection never restored before the activity ended; \
transcript events were dropped"
);
}
}
pub(crate) struct EventDrain {
handle: tokio::task::JoinHandle<()>,
}
impl EventDrain {
pub(crate) async fn finish(self) {
drop(self.handle.await);
}
}
#[derive(Clone, Debug)]
pub(crate) struct DrainBinding {
writer: LiveWriter,
backoff: RedialBackoff,
}
impl DrainBinding {
#[must_use]
pub(crate) fn new(slot: LiveWriter, backoff: RedialBackoff) -> Self {
Self {
writer: slot,
backoff,
}
}
}
pub(crate) fn spawn_event_drain(
binding: DrainBinding,
) -> (mpsc::UnboundedSender<ActivityEvent>, EventDrain) {
let (event_sender, event_receiver) = mpsc::unbounded_channel::<ActivityEvent>();
let handle = tokio::spawn(run_drain(binding.writer, event_receiver, binding.backoff));
(event_sender, EventDrain { handle })
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
use aion_core::{ActivityEvent, ActivityEventKind, ActivityId, MessageRole, WorkflowId};
use uuid::Uuid;
use super::{DrainState, DrainStep, EventSink, RedialBackoff, run_drain};
fn backoff() -> RedialBackoff {
RedialBackoff::new(Duration::from_millis(10), Duration::from_millis(40))
}
#[test]
fn healthy_publish_reports_no_transition() {
let now = std::time::Instant::now();
let mut state = DrainState::new(backoff(), now);
assert!(state.should_probe(now));
assert_eq!(
state.on_published(),
None,
"no recovery while already healthy"
);
assert_eq!(state.on_close(), None, "a healthy close reports nothing");
}
#[test]
fn outage_start_is_reported_exactly_once() {
let now = std::time::Instant::now();
let mut state = DrainState::new(backoff(), now);
assert!(state.on_broken(now), "the first failure starts the outage");
for _ in 0..50 {
let probe_at = state.next_probe;
assert!(
!state.on_broken(probe_at),
"a subsequent failure never re-reports the outage transition"
);
}
assert_eq!(state.on_close(), Some(51), "every failed event is counted");
}
#[test]
fn broken_defers_within_backoff_then_reprobes() {
let start = std::time::Instant::now();
let mut state = DrainState::new(backoff(), start);
assert!(state.on_broken(start));
let probe_at = state.next_probe;
assert!(
!state.should_probe(start),
"within backoff the drain defers"
);
state.on_deferred();
assert!(
state.should_probe(probe_at),
"backoff elapsed re-enables probing"
);
assert_eq!(
state.on_close(),
Some(2),
"the deferred event is still counted"
);
}
#[test]
fn recovery_reports_dropped_total_once_and_resets() {
let now = std::time::Instant::now();
let mut state = DrainState::new(backoff(), now);
assert!(state.on_broken(now));
state.on_deferred();
state.on_deferred();
assert_eq!(
state.on_published(),
Some(3),
"recovery reports the outage's dropped total"
);
assert_eq!(
state.on_published(),
None,
"a second success is not a fresh recovery"
);
assert_eq!(state.on_close(), None, "a recovered drain closes clean");
assert!(
state.on_broken(now),
"a new outage re-reports its transition"
);
}
#[test]
fn backoff_widens_across_failures_and_resets_on_recovery() {
let now = std::time::Instant::now();
let mut state = DrainState::new(backoff(), now);
state.on_broken(now);
let first_gap = state.next_probe.saturating_duration_since(now);
let second_base = state.next_probe;
state.on_broken(second_base);
let second_gap = state.next_probe.saturating_duration_since(second_base);
assert!(
second_gap > first_gap,
"the re-probe interval widens across consecutive failures"
);
assert!(state.on_published().is_some());
let fresh = std::time::Instant::now();
state.on_broken(fresh);
assert_eq!(
state.next_probe.saturating_duration_since(fresh),
Duration::from_millis(10),
"recovery reset the backoff to its initial pause"
);
}
struct FakeSink {
up: Mutex<bool>,
accepted: Mutex<Vec<Vec<u8>>>,
attempts: Mutex<u32>,
}
impl FakeSink {
fn new(up: bool) -> Self {
Self {
up: Mutex::new(up),
accepted: Mutex::new(Vec::new()),
attempts: Mutex::new(0),
}
}
fn set_up(&self, up: bool) {
*self.up.lock().unwrap_or_else(PoisonError::into_inner) = up;
}
fn accepted(&self) -> Vec<Vec<u8>> {
self.accepted
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn attempts(&self) -> u32 {
*self.attempts.lock().unwrap_or_else(PoisonError::into_inner)
}
fn do_publish(&self, payload: Vec<u8>) -> DrainStep {
*self.attempts.lock().unwrap_or_else(PoisonError::into_inner) += 1;
if *self.up.lock().unwrap_or_else(PoisonError::into_inner) {
self.accepted
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(payload);
DrainStep::Published
} else {
DrainStep::Broken {
reason: "fake outage".to_owned(),
}
}
}
}
impl EventSink for std::sync::Arc<FakeSink> {
fn publish(&self, payload: Vec<u8>) -> DrainStep {
self.do_publish(payload)
}
}
fn event(worker_seq: u64) -> ActivityEvent {
ActivityEvent {
workflow_id: WorkflowId::new(Uuid::nil()),
activity_id: ActivityId::from_sequence_position(1),
attempt: 1,
agent_id: Uuid::nil(),
agent_role: "orchestrator".to_owned(),
emitted_at: chrono::Utc::now(),
worker_seq,
store_seq: None,
ephemeral: false,
kind: ActivityEventKind::Message {
role: MessageRole::Assistant,
text: format!("event-{worker_seq}"),
},
}
}
#[tokio::test]
async fn events_resume_flowing_after_the_sink_recovers()
-> Result<(), Box<dyn std::error::Error>> {
let sink = std::sync::Arc::new(FakeSink::new(false));
let zero = RedialBackoff::new(Duration::ZERO, Duration::ZERO);
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
let outage = event(1);
let recovered_a = event(2);
let recovered_b = event(3);
let expected_a = serde_json::to_vec(&recovered_a)?;
let expected_b = serde_json::to_vec(&recovered_b)?;
let drain = tokio::spawn(run_drain(std::sync::Arc::clone(&sink), receiver, zero));
sender.send(outage)?;
while sink.attempts() == 0 {
tokio::task::yield_now().await;
}
assert!(
sink.accepted().is_empty(),
"an event sent during the outage is dropped, never published"
);
sink.set_up(true);
sender.send(recovered_a)?;
sender.send(recovered_b)?;
drop(sender);
drain.await?;
assert_eq!(
sink.accepted(),
vec![expected_a, expected_b],
"post-recovery events resume flowing; the outage event is not among them"
);
Ok(())
}
}