use std::io::{self, BufWriter, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
use crate::daemon_core::audit::{AuditConfig, AuditEvent, AuditLevel, AuditMode, AuditSinkPolicy};
const WRITER_QUEUE_CAPACITY: usize = 4096;
#[derive(Debug, thiserror::Error)]
pub enum AuditSinkError {
#[error("audit sink io error: {0}")]
Io(#[from] io::Error),
#[error("audit sink requires output_root when mode > Off")]
MissingOutputRoot,
#[error("audit sink backpressure under FailLossless policy")]
Backpressure,
#[error("audit sink is shut down")]
Closed,
}
enum Command {
Event(Box<AuditEvent>),
Flush(oneshot::Sender<()>),
Shutdown(oneshot::Sender<()>),
}
#[derive(Clone, Debug)]
pub struct AuditSink {
sender: mpsc::Sender<Command>,
lost_events: Arc<AtomicU64>,
policy: AuditSinkPolicy,
degraded: Arc<std::sync::atomic::AtomicBool>,
}
impl AuditSink {
pub fn start(
config: &AuditConfig,
runtime_handle: Option<tokio::runtime::Handle>,
) -> Result<Option<Self>, AuditSinkError> {
if matches!(config.mode, AuditMode::Off) {
return Ok(None);
}
let output_root = config
.output_root
.as_deref()
.ok_or(AuditSinkError::MissingOutputRoot)?;
let output_root = PathBuf::from(output_root);
std::fs::create_dir_all(&output_root)?;
let path = output_root.join(config.event_log.as_deref().unwrap_or("audit.jsonl"));
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
let mut writer = BufWriter::with_capacity(64 * 1024, file);
let (sender, mut receiver) = mpsc::channel::<Command>(WRITER_QUEUE_CAPACITY);
let lost_events = Arc::new(AtomicU64::new(0));
let degraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
let writer_task = async move {
while let Some(cmd) = receiver.recv().await {
match cmd {
Command::Event(event) => {
if let Ok(line) = serde_json::to_string(&event) {
let _ = writer.write_all(line.as_bytes());
let _ = writer.write_all(b"\n");
}
}
Command::Flush(reply) => {
let _ = writer.flush();
let _ = reply.send(());
}
Command::Shutdown(reply) => {
let _ = writer.flush();
let _ = reply.send(());
break;
}
}
}
let _ = writer.flush();
};
match runtime_handle {
Some(handle) => {
handle.spawn(writer_task);
}
None => {
tokio::spawn(writer_task);
}
}
Ok(Some(Self {
sender,
lost_events,
policy: config.sink_policy,
degraded,
}))
}
pub fn emit(&self, event: AuditEvent) -> Result<(), AuditSinkError> {
if self.degraded.load(Ordering::Acquire) {
return Ok(());
}
let level = event.level;
match self.sender.try_send(Command::Event(Box::new(event))) {
Ok(()) => Ok(()),
Err(mpsc::error::TrySendError::Full(_cmd)) => match self.policy {
AuditSinkPolicy::Block => {
self.lost_events.fetch_add(1, Ordering::Relaxed);
Err(AuditSinkError::Backpressure)
}
AuditSinkPolicy::DropLowPriority => {
if matches!(level, AuditLevel::Warn | AuditLevel::Error) {
self.lost_events.fetch_add(1, Ordering::Relaxed);
} else {
self.lost_events.fetch_add(1, Ordering::Relaxed);
}
Ok(())
}
AuditSinkPolicy::Degrade => {
self.degraded.store(true, Ordering::Release);
self.lost_events.fetch_add(1, Ordering::Relaxed);
Ok(())
}
AuditSinkPolicy::FailLossless => Err(AuditSinkError::Backpressure),
},
Err(mpsc::error::TrySendError::Closed(_)) => Err(AuditSinkError::Closed),
}
}
pub async fn flush(&self) -> Result<(), AuditSinkError> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Command::Flush(tx))
.await
.map_err(|_| AuditSinkError::Closed)?;
let _ = rx.await;
Ok(())
}
pub async fn shutdown(&self) -> Result<(), AuditSinkError> {
let (tx, rx) = oneshot::channel();
match self.sender.send(Command::Shutdown(tx)).await {
Ok(()) => {
let _ = rx.await;
}
Err(_) => return Ok(()),
}
Ok(())
}
pub fn lost_events(&self) -> u64 {
self.lost_events.load(Ordering::Acquire)
}
pub fn is_degraded(&self) -> bool {
self.degraded.load(Ordering::Acquire)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon_core::audit::{AuditCategory, AuditContext, AuditEventName, AuditId, AuditLevel};
use tempfile::TempDir;
fn fixture_event(level: AuditLevel, idx: u64) -> AuditEvent {
let context = AuditContext::new(
AuditId::new(format!("run-{idx}")).expect("run id"),
AuditId::new(format!("trace-{idx}")).expect("trace id"),
);
let mut event = AuditEvent::new(
AuditId::new(format!("event-{idx}")).expect("event id"),
context,
AuditId::new(format!("span-{idx}")).expect("span id"),
AuditCategory::new("zccache.compile").expect("category"),
AuditEventName::new("compile.finished").expect("event name"),
"0",
);
event.level = level;
event
}
#[tokio::test]
async fn off_mode_returns_no_sink() {
let temp = TempDir::new().expect("tempdir");
let config = AuditConfig {
mode: AuditMode::Off,
output_root: Some(temp.path().to_string_lossy().into_owned()),
..AuditConfig::default()
};
let sink = AuditSink::start(&config, None).expect("start ok");
assert!(
sink.is_none(),
"Off mode must return None so the embedded service skips emission entirely"
);
}
#[tokio::test]
async fn normal_mode_writes_jsonl_to_disk() {
let temp = TempDir::new().expect("tempdir");
let output_root = temp.path().join("audit");
let config = AuditConfig {
mode: AuditMode::Normal,
output_root: Some(output_root.to_string_lossy().into_owned()),
event_log: Some("audit.jsonl".to_string()),
..AuditConfig::default()
};
let sink = AuditSink::start(&config, None)
.expect("start ok")
.expect("sink present in Normal mode");
sink.emit(fixture_event(AuditLevel::Info, 1))
.expect("emit ok");
sink.emit(fixture_event(AuditLevel::Info, 2))
.expect("emit ok");
sink.flush().await.expect("flush ok");
sink.shutdown().await.expect("shutdown ok");
let path = output_root.join("audit.jsonl");
let contents = std::fs::read_to_string(&path).expect("file readable");
let lines: Vec<_> = contents.lines().collect();
assert_eq!(lines.len(), 2, "two events should produce two JSONL rows");
for line in lines {
let parsed: AuditEvent =
serde_json::from_str(line).expect("each line is valid AuditEvent");
assert_eq!(parsed.schema, crate::daemon_core::audit::AUDIT_SCHEMA);
assert_eq!(parsed.schema_version, crate::daemon_core::audit::AUDIT_SCHEMA_VERSION);
}
}
#[tokio::test]
async fn missing_output_root_in_normal_mode_errors() {
let config = AuditConfig {
mode: AuditMode::Normal,
output_root: None,
..AuditConfig::default()
};
let err = AuditSink::start(&config, None).expect_err("must error");
assert!(matches!(err, AuditSinkError::MissingOutputRoot));
}
#[tokio::test]
async fn lost_events_counter_starts_at_zero() {
let temp = TempDir::new().expect("tempdir");
let config = AuditConfig {
mode: AuditMode::Normal,
output_root: Some(temp.path().to_string_lossy().into_owned()),
..AuditConfig::default()
};
let sink = AuditSink::start(&config, None)
.expect("start ok")
.expect("sink");
assert_eq!(sink.lost_events(), 0);
assert!(!sink.is_degraded());
}
#[tokio::test]
async fn shutdown_is_idempotent() {
let temp = TempDir::new().expect("tempdir");
let config = AuditConfig {
mode: AuditMode::Normal,
output_root: Some(temp.path().to_string_lossy().into_owned()),
..AuditConfig::default()
};
let sink = AuditSink::start(&config, None)
.expect("start ok")
.expect("sink");
sink.shutdown().await.expect("first shutdown ok");
let _ = sink.shutdown().await;
}
#[tokio::test]
async fn fail_lossless_returns_backpressure_when_queue_full() {
let temp = TempDir::new().expect("tempdir");
let config = AuditConfig {
mode: AuditMode::Normal,
sink_policy: AuditSinkPolicy::FailLossless,
output_root: Some(temp.path().to_string_lossy().into_owned()),
..AuditConfig::default()
};
let sink = AuditSink::start(&config, None)
.expect("start ok")
.expect("sink");
let mut backpressure_seen = false;
for i in 0..(WRITER_QUEUE_CAPACITY * 10) {
if let Err(AuditSinkError::Backpressure) =
sink.emit(fixture_event(AuditLevel::Info, i as u64))
{
backpressure_seen = true;
break;
}
}
assert!(
backpressure_seen,
"FailLossless must return Backpressure under sustained overflow"
);
sink.flush().await.expect("flush after backpressure");
sink.shutdown().await.expect("shutdown after backpressure");
}
}