use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::mpsc;
use crate::config::AuditConfig;
use crate::storage::repositories::audit_logs::AuditLogRepository;
#[derive(Debug, Clone)]
pub struct AuditEvent {
pub principal: String,
pub action: String,
pub resource_type: String,
pub resource_id: String,
pub details: Option<String>,
}
#[derive(Clone)]
pub struct AuditQueue {
tx: mpsc::Sender<AuditEvent>,
pending: Arc<AtomicUsize>,
}
impl AuditQueue {
pub fn submit(&self, event: AuditEvent) {
self.pending.fetch_add(1, Ordering::AcqRel);
match self.tx.try_send(event) {
Ok(()) => {
crate::metrics::set_audit_queue_depth(self.depth() as f64);
}
Err(mpsc::error::TrySendError::Full(event)) => {
self.pending.fetch_sub(1, Ordering::AcqRel);
crate::metrics::record_audit_event_dropped("queue_full");
tracing::error!(
action = %event.action,
resource_type = %event.resource_type,
resource_id = %event.resource_id,
"Audit queue is full — this admin action was NOT recorded. \
Raise audit.max_pending or investigate why audit writes are stalled"
);
}
Err(mpsc::error::TrySendError::Closed(event)) => {
self.pending.fetch_sub(1, Ordering::AcqRel);
crate::metrics::record_audit_event_dropped("writer_stopped");
tracing::warn!(
action = %event.action,
resource_type = %event.resource_type,
"Audit writer has stopped; this admin action was not recorded"
);
}
}
}
pub fn depth(&self) -> usize {
self.pending.load(Ordering::Acquire)
}
}
pub struct AuditWriterHandle {
join: tokio::task::JoinHandle<()>,
pending: Arc<AtomicUsize>,
drain_timeout: Duration,
}
const DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(2);
impl AuditWriterHandle {
pub async fn shutdown(mut self) {
let queued = self.pending.load(Ordering::Acquire);
if queued > 0 {
tracing::info!(pending = queued, "Draining audit-log queue...");
}
let pending = self.pending.clone();
let drained = tokio::time::timeout(self.drain_timeout, async {
tokio::select! {
result = &mut self.join => result,
() = async {
while pending.load(Ordering::Acquire) > 0 {
tokio::time::sleep(DRAIN_POLL_INTERVAL).await;
}
} => Ok(()),
}
})
.await;
self.join.abort();
match drained {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::error!(error = %e, "Audit writer task panicked"),
Err(_) => {
let lost = self.pending.load(Ordering::Acquire);
crate::metrics::record_audit_events_dropped("drain_timeout", lost as u64);
tracing::error!(
lost,
drain_timeout_secs = self.drain_timeout.as_secs(),
"Audit-log drain timed out — these admin actions were NOT recorded. \
Raise audit.drain_timeout_secs or investigate the database"
);
}
}
}
}
pub fn start(
config: &AuditConfig,
repo: Arc<dyn AuditLogRepository>,
) -> (AuditQueue, AuditWriterHandle) {
let (tx, mut rx) = mpsc::channel::<AuditEvent>(config.max_pending);
let pending = Arc::new(AtomicUsize::new(0));
let worker_pending = pending.clone();
let join = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
if let Err(e) = repo
.insert(
&event.principal,
&event.action,
&event.resource_type,
&event.resource_id,
event.details.as_deref(),
)
.await
{
crate::metrics::record_audit_event_dropped("write_failed");
tracing::error!(
error = %e,
action = %event.action,
resource_type = %event.resource_type,
resource_id = %event.resource_id,
"Failed to persist audit log entry"
);
}
let remaining = worker_pending
.fetch_sub(1, Ordering::Release)
.saturating_sub(1);
crate::metrics::set_audit_queue_depth(remaining as f64);
}
crate::metrics::set_audit_queue_depth(0.0);
});
(
AuditQueue {
tx,
pending: pending.clone(),
},
AuditWriterHandle {
join,
pending,
drain_timeout: Duration::from_secs(config.drain_timeout_secs),
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::OrionError;
struct RecordingRepo {
rows: Arc<std::sync::Mutex<Vec<(String, String)>>>,
block: Option<Duration>,
fail: bool,
}
impl RecordingRepo {
fn new(rows: Arc<std::sync::Mutex<Vec<(String, String)>>>) -> Self {
Self {
rows,
block: None,
fail: false,
}
}
}
#[async_trait::async_trait]
impl AuditLogRepository for RecordingRepo {
async fn insert(
&self,
principal: &str,
action: &str,
_resource_type: &str,
_resource_id: &str,
_details: Option<&str>,
) -> Result<(), OrionError> {
if let Some(d) = self.block {
tokio::time::sleep(d).await;
}
if self.fail {
return Err(OrionError::internal("audit insert failed".to_string()));
}
self.rows
.lock()
.expect("test mutex")
.push((principal.to_string(), action.to_string()));
Ok(())
}
async fn list_paginated(
&self,
_filter: &crate::storage::repositories::audit_logs::AuditLogFilter,
) -> Result<
crate::storage::repositories::helpers::PaginatedResult<
crate::storage::models::AuditLogEntry,
>,
OrionError,
> {
unimplemented!("not exercised")
}
async fn delete_older_than(&self, _days: u64) -> Result<u64, OrionError> {
unimplemented!("not exercised")
}
}
fn event(action: &str) -> AuditEvent {
AuditEvent {
principal: "key-0123456789abcdef".to_string(),
action: action.to_string(),
resource_type: "workflow".to_string(),
resource_id: "wf-1".to_string(),
details: None,
}
}
#[tokio::test]
async fn shutdown_drains_events_submitted_at_the_last_moment() {
let rows = Arc::new(std::sync::Mutex::new(Vec::new()));
let repo = Arc::new(RecordingRepo {
block: Some(Duration::from_millis(20)),
..RecordingRepo::new(rows.clone())
});
let (queue, handle) = start(&AuditConfig::default(), repo);
for i in 0..5 {
queue.submit(event(&format!("action-{i}")));
}
drop(queue);
handle.shutdown().await;
let written = rows.lock().expect("test mutex").len();
assert_eq!(
written, 5,
"every event enqueued before shutdown must be written"
);
}
#[tokio::test]
async fn drain_is_bounded_when_writes_hang() {
let rows = Arc::new(std::sync::Mutex::new(Vec::new()));
let repo = Arc::new(RecordingRepo {
block: Some(Duration::from_secs(3600)),
..RecordingRepo::new(rows.clone())
});
let config = AuditConfig {
drain_timeout_secs: 1,
..AuditConfig::default()
};
let (queue, handle) = start(&config, repo);
queue.submit(event("stuck"));
drop(queue);
let started = tokio::time::Instant::now();
handle.shutdown().await;
assert!(
started.elapsed() < Duration::from_secs(30),
"the drain must be bounded by drain_timeout_secs, not by the database"
);
assert!(rows.lock().expect("test mutex").is_empty());
}
#[tokio::test]
async fn a_lingering_producer_does_not_stall_an_empty_drain() {
let rows = Arc::new(std::sync::Mutex::new(Vec::new()));
let repo = Arc::new(RecordingRepo::new(rows.clone()));
let config = AuditConfig {
drain_timeout_secs: 30,
..AuditConfig::default()
};
let (queue, handle) = start(&config, repo);
queue.submit(event("recorded"));
let _stray = queue.clone();
drop(queue);
let started = tokio::time::Instant::now();
handle.shutdown().await;
assert!(
started.elapsed() < Duration::from_secs(5),
"an empty queue must end the drain, not the 30s timeout"
);
assert_eq!(rows.lock().expect("test mutex").len(), 1);
}
#[tokio::test]
async fn queue_is_bounded_and_overflow_does_not_block() {
let rows = Arc::new(std::sync::Mutex::new(Vec::new()));
let repo = Arc::new(RecordingRepo {
block: Some(Duration::from_secs(3600)),
..RecordingRepo::new(rows.clone())
});
let config = AuditConfig {
max_pending: 2,
drain_timeout_secs: 1,
..AuditConfig::default()
};
let (queue, handle) = start(&config, repo);
for i in 0..50 {
queue.submit(event(&format!("a{i}")));
}
assert!(
queue.depth() <= 3,
"queue depth must stay bounded by max_pending (+1 in the writer), got {}",
queue.depth()
);
drop(queue);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn depth_is_a_sound_witness_under_a_fast_writer() {
const PRODUCERS: usize = 8;
const PER_PRODUCER: usize = 250;
let rows = Arc::new(std::sync::Mutex::new(Vec::new()));
let repo = Arc::new(RecordingRepo::new(rows.clone()));
let config = AuditConfig {
max_pending: 4096,
drain_timeout_secs: 30,
..AuditConfig::default()
};
let (queue, handle) = start(&config, repo);
let mut producers = tokio::task::JoinSet::new();
for p in 0..PRODUCERS {
let queue = queue.clone();
producers.spawn(async move {
for i in 0..PER_PRODUCER {
queue.submit(event(&format!("a{p}-{i}")));
tokio::task::yield_now().await;
}
});
}
while let Some(joined) = producers.join_next().await {
joined.expect("producer task");
}
assert!(
queue.depth() <= PRODUCERS * PER_PRODUCER,
"depth wrapped: {} submissions cannot leave a depth of {}",
PRODUCERS * PER_PRODUCER,
queue.depth()
);
drop(queue);
handle.shutdown().await;
assert_eq!(
rows.lock().expect("test mutex").len(),
PRODUCERS * PER_PRODUCER,
"a zero reading must not end the drain while rows are still buffered"
);
}
#[test]
fn a_failed_insert_is_counted_as_a_drop() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let exposition = crate::metrics::render_local(|| {
rt.block_on(async {
let rows = Arc::new(std::sync::Mutex::new(Vec::new()));
let repo = Arc::new(RecordingRepo {
fail: true,
..RecordingRepo::new(rows.clone())
});
let (queue, handle) = start(&AuditConfig::default(), repo);
queue.submit(event("delete"));
queue.submit(event("update"));
drop(queue);
handle.shutdown().await;
assert!(
rows.lock().expect("test mutex").is_empty(),
"the mock refused both inserts"
);
});
});
assert!(
exposition.contains(r#"orion_audit_events_dropped_total{reason="write_failed"} 2"#),
"a failed audit INSERT must be counted, not just logged:\n{exposition}"
);
}
#[test]
fn overflow_is_counted_as_a_drop() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let exposition = crate::metrics::render_local(|| {
rt.block_on(async {
let rows = Arc::new(std::sync::Mutex::new(Vec::new()));
let repo = Arc::new(RecordingRepo {
block: Some(Duration::from_secs(3600)),
..RecordingRepo::new(rows.clone())
});
let config = AuditConfig {
max_pending: 2,
drain_timeout_secs: 1,
..AuditConfig::default()
};
let (queue, handle) = start(&config, repo);
for i in 0..10 {
queue.submit(event(&format!("a{i}")));
}
drop(queue);
handle.shutdown().await;
});
});
assert!(
exposition.contains(r#"orion_audit_events_dropped_total{reason="queue_full"} 8"#),
"the 8 submissions past max_pending must be counted:\n{exposition}"
);
}
}