pub mod audit_cleanup;
pub mod audit_queue;
mod dlq_retry;
mod processing;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use serde_json::Value;
use tokio::sync::mpsc;
use crate::metrics;
use crate::storage::repositories::trace_dlq::TraceDlqRepository;
pub mod trace_persistence;
use crate::storage::repositories::traces::TraceRepository;
pub use trace_persistence::{PersistenceWorkerHandle, TracePersistenceQueue, TracePersistenceTask};
pub use dlq_retry::{DlqRetryOptions, start_dlq_retry};
fn start_retention_job<F, Fut, R>(
job: &'static str,
interval_secs: u64,
lease_gate: Option<Arc<crate::cluster::JobLeaseGate>>,
delete: F,
report: R,
) -> tokio::task::JoinHandle<()>
where
F: Fn() -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<u64, crate::errors::OrionError>> + Send + 'static,
R: Fn(Result<u64, crate::errors::OrionError>) + Send + 'static,
{
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
interval.tick().await;
let lease_ttl = interval_secs + 60;
loop {
interval.tick().await;
if let Some(ref gate) = lease_gate
&& !gate.try_acquire(job, lease_ttl).await
{
continue;
}
let outcome = delete().await;
if outcome.is_ok() {
metrics::record_job_success(job);
}
report(outcome);
}
})
}
pub fn start_trace_cleanup(
retention_hours: u64,
interval_secs: u64,
trace_repo: Arc<dyn TraceRepository>,
lease_gate: Option<Arc<crate::cluster::JobLeaseGate>>,
) -> Option<tokio::task::JoinHandle<()>> {
if retention_hours == 0 {
tracing::info!("Trace retention disabled (retention_hours = 0)");
return None;
}
let handle = start_retention_job(
"trace_cleanup",
interval_secs,
lease_gate,
move || {
let repo = trace_repo.clone();
async move { repo.delete_older_than(retention_hours).await }
},
move |outcome| match outcome {
Ok(count) => {
if count > 0 {
tracing::info!(
deleted = count,
retention_hours = retention_hours,
"Trace cleanup completed"
);
}
}
Err(e) => {
tracing::error!(error = %e, "Trace cleanup failed");
}
},
);
tracing::info!(
retention_hours = retention_hours,
interval_secs = interval_secs,
"Trace cleanup task started"
);
Some(handle)
}
pub struct QueueMessage {
pub trace_id: String,
pub channel: String,
pub payload: Value,
pub metadata: Value,
pub trace_headers: std::collections::HashMap<String, String>,
pub profile_requested: bool,
pub backpressure_permit: Option<tokio::sync::OwnedSemaphorePermit>,
}
pub(crate) struct QueuedItem {
pub(crate) msg: QueueMessage,
pub(crate) dlq_retry_count: i64,
pub(crate) payload_size: usize,
}
#[derive(Clone)]
pub struct TraceQueue {
sender: mpsc::Sender<QueuedItem>,
pending_count: Arc<AtomicUsize>,
memory_bytes: Arc<AtomicUsize>,
max_memory_bytes: usize,
}
impl TraceQueue {
#[cfg(test)]
pub(crate) fn new_for_test(sender: mpsc::Sender<QueuedItem>) -> Self {
Self {
sender,
pending_count: Arc::new(AtomicUsize::new(0)),
memory_bytes: Arc::new(AtomicUsize::new(0)),
max_memory_bytes: 100_000_000,
}
}
pub async fn submit(&self, msg: QueueMessage) -> Result<(), crate::errors::OrionError> {
self.enqueue(QueuedItem {
msg,
dlq_retry_count: 0,
payload_size: 0,
})
.await
}
pub(crate) async fn submit_dlq_retry(
&self,
msg: QueueMessage,
dlq_retry_count: i64,
) -> Result<(), crate::errors::OrionError> {
self.enqueue(QueuedItem {
msg,
dlq_retry_count,
payload_size: 0,
})
.await
}
async fn enqueue(&self, mut item: QueuedItem) -> Result<(), crate::errors::OrionError> {
let payload_size = item.msg.payload.to_string().len() + item.msg.metadata.to_string().len();
item.payload_size = payload_size;
let prev = self.memory_bytes.fetch_add(payload_size, Ordering::AcqRel);
let total = prev + payload_size;
if self.max_memory_bytes > 0 && total > self.max_memory_bytes {
self.memory_bytes.fetch_sub(payload_size, Ordering::AcqRel);
metrics::record_trace_queue_rejected("memory");
return Err(crate::errors::OrionError::ServiceUnavailable(format!(
"Trace queue memory limit exceeded ({} + {} > {} bytes)",
prev, payload_size, self.max_memory_bytes
)));
}
metrics::set_trace_queue_memory_bytes(total as f64);
if let Err(err) = self.sender.try_send(item) {
self.memory_bytes.fetch_sub(payload_size, Ordering::AcqRel);
return Err(match err {
mpsc::error::TrySendError::Full(_) => {
metrics::record_trace_queue_rejected("full");
crate::errors::OrionError::ServiceUnavailable(format!(
"Trace queue is full ({} messages pending)",
self.pending_count.load(Ordering::Relaxed)
))
}
mpsc::error::TrySendError::Closed(_) => {
crate::errors::OrionError::ServiceUnavailable(
"Trace queue is closed".to_string(),
)
}
});
}
let pending = self.pending_count.fetch_add(1, Ordering::Relaxed) + 1;
metrics::set_trace_queue_depth(pending as f64);
Ok(())
}
}
pub struct WorkerHandle {
_sender: mpsc::Sender<QueuedItem>,
join_handle: tokio::task::JoinHandle<()>,
shutdown_timeout_secs: u64,
}
impl WorkerHandle {
pub async fn shutdown(self) {
drop(self._sender);
let timeout = Duration::from_secs(self.shutdown_timeout_secs);
if tokio::time::timeout(timeout, self.join_handle)
.await
.is_err()
{
tracing::warn!(
timeout_secs = self.shutdown_timeout_secs,
"Trace queue workers did not shut down within timeout, proceeding with exit"
);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn start_workers(
config: &crate::config::TraceQueueConfig,
engine: Arc<crate::engine::EngineHandle>,
trace_repo: Arc<dyn TraceRepository>,
dlq_repo: Option<Arc<dyn TraceDlqRepository>>,
channel_registry: Arc<crate::channel::ChannelRegistry>,
persistence_queue: TracePersistenceQueue,
global_trace_storage: crate::config::TraceStorageConfig,
rollout_sticky_header: String,
) -> (TraceQueue, WorkerHandle) {
let max_workers = config.workers;
let buffer_size = config.buffer_size;
let shutdown_timeout_secs = config.shutdown_timeout_secs;
let max_queue_memory_bytes = config.max_queue_memory_bytes;
let (tx, rx) = mpsc::channel::<QueuedItem>(buffer_size);
let pending_count = Arc::new(AtomicUsize::new(0));
let active_workers = Arc::new(AtomicUsize::new(0));
let memory_bytes = Arc::new(AtomicUsize::new(0));
metrics::set_trace_workers_total(max_workers as f64);
let dispatcher_ctx = processing::DispatcherContext {
max_workers,
shutdown_timeout_secs,
counters: processing::QueueCounters {
pending: pending_count.clone(),
active: active_workers,
memory_bytes: memory_bytes.clone(),
},
processing: processing::ProcessingContext {
engine,
trace_repo,
dlq_repo,
processing_timeout_ms: config.processing_timeout_ms,
max_result_size_bytes: config.max_result_size_bytes,
dlq_max_retries: config.dlq_max_retries,
rollout_sticky_header: Arc::from(rollout_sticky_header.as_str()),
channel_registry,
persistence_queue,
global_trace_storage,
},
};
let handle = tokio::spawn(processing::dispatcher_loop(rx, dispatcher_ctx));
let queue = TraceQueue {
sender: tx.clone(),
pending_count,
memory_bytes,
max_memory_bytes: max_queue_memory_bytes,
};
let worker_handle = WorkerHandle {
_sender: tx,
join_handle: handle,
shutdown_timeout_secs,
};
(queue, worker_handle)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_message(trace_id: &str) -> QueueMessage {
QueueMessage {
trace_id: trace_id.to_string(),
channel: "orders".to_string(),
payload: serde_json::json!({"a": 1}),
metadata: serde_json::json!({}),
trace_headers: std::collections::HashMap::new(),
profile_requested: false,
backpressure_permit: None,
}
}
#[tokio::test]
async fn submit_rejects_when_buffer_is_full() {
let (tx, _rx) = mpsc::channel::<QueuedItem>(1);
let queue = TraceQueue::new_for_test(tx);
queue.submit(test_message("t1")).await.expect("first fits");
let err =
tokio::time::timeout(Duration::from_millis(250), queue.submit(test_message("t2")))
.await
.expect("submit must not block on a full queue")
.expect_err("full queue must be rejected");
assert!(
matches!(err, crate::errors::OrionError::ServiceUnavailable(_)),
"expected ServiceUnavailable, got: {err:?}"
);
}
#[tokio::test]
async fn submit_rejection_releases_backpressure_permit() {
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
let permit = semaphore
.clone()
.try_acquire_owned()
.expect("permit available");
let (tx, _rx) = mpsc::channel::<QueuedItem>(1);
let queue = TraceQueue::new_for_test(tx);
queue.submit(test_message("t1")).await.expect("first fits");
let mut msg = test_message("t2");
msg.backpressure_permit = Some(permit);
assert!(queue.submit(msg).await.is_err(), "second must be shed");
assert_eq!(
semaphore.available_permits(),
1,
"a shed submission must not retain the channel's backpressure permit"
);
}
#[tokio::test]
async fn submit_reports_closed_queue_separately() {
let (tx, rx) = mpsc::channel::<QueuedItem>(1);
drop(rx);
let queue = TraceQueue::new_for_test(tx);
let err = queue
.submit(test_message("t1"))
.await
.expect_err("closed queue must be rejected");
assert!(
matches!(err, crate::errors::OrionError::ServiceUnavailable(_)),
"expected Queue error, got: {err:?}"
);
}
struct MockCleanupTraceRepo;
#[async_trait::async_trait]
impl TraceRepository for MockCleanupTraceRepo {
async fn create_pending(
&self,
_channel: &str,
_channel_id: Option<&str>,
_mode: &str,
_input_json: Option<&str>,
_access_token_hash: Option<&str>,
) -> Result<crate::storage::models::Trace, crate::errors::OrionError> {
unimplemented!("not used by trace cleanup")
}
async fn get_by_id(
&self,
_id: &str,
) -> Result<crate::storage::models::Trace, crate::errors::OrionError> {
unimplemented!("not used by trace cleanup")
}
async fn update_status(
&self,
_id: &str,
_status: &str,
_error_message: Option<&str>,
) -> Result<crate::storage::models::Trace, crate::errors::OrionError> {
unimplemented!("not used by trace cleanup")
}
async fn set_result(
&self,
_id: &str,
_result_json: &str,
_duration_ms: f64,
_task_trace_json: Option<&str>,
) -> Result<(), crate::errors::OrionError> {
unimplemented!("not used by trace cleanup")
}
async fn store_completed(
&self,
_channel: &str,
_channel_id: Option<&str>,
_mode: &str,
_input_json: Option<&str>,
_result_json: &str,
_duration_ms: f64,
_task_trace_json: Option<&str>,
) -> Result<String, crate::errors::OrionError> {
unimplemented!("not used by trace cleanup")
}
async fn list_paginated(
&self,
_filter: &crate::storage::repositories::traces::TraceFilter,
) -> Result<crate::storage::repositories::traces::TracePage, crate::errors::OrionError>
{
unimplemented!("not used by trace cleanup")
}
async fn delete_older_than(&self, _hours: u64) -> Result<u64, crate::errors::OrionError> {
Ok(0)
}
}
#[test]
fn test_successful_tick_stamps_the_job_health_gauge() {
let recorder = metrics_exporter_prometheus::PrometheusBuilder::new().build_recorder();
let handle = recorder.handle();
::metrics::with_local_recorder(&recorder, || {
crate::metrics::set_enabled(true);
tokio::runtime::Builder::new_current_thread()
.enable_time()
.start_paused(true)
.build()
.expect("test runtime")
.block_on(async {
let repo: Arc<dyn TraceRepository> = Arc::new(MockCleanupTraceRepo);
let job = start_trace_cleanup(24, 1, repo, None).expect("job started");
tokio::time::advance(Duration::from_secs(1)).await;
for _ in 0..20 {
tokio::task::yield_now().await;
}
tokio::time::advance(Duration::from_secs(1)).await;
for _ in 0..20 {
tokio::task::yield_now().await;
}
job.abort();
});
});
let out = handle.render();
assert!(
out.contains(r#"orion_job_last_success_timestamp_seconds{job="trace_cleanup"}"#),
"a successful cleanup tick must stamp the job health gauge:\n{out}"
);
}
}