pub mod audit_cleanup;
pub mod audit_queue;
mod bounded;
mod dlq_retry;
mod processing;
pub(crate) mod trace_record;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use serde_json::Value;
use self::bounded::{BoundedWorker, DrainHandle, DrainOutcome, DrainWitness, Rejected};
use crate::metrics;
use crate::storage::repositories::trace_dlq::TraceDlqRepository;
pub mod trace_persistence;
use crate::storage::repositories::traces::{TraceRetention, TraceSink};
pub use trace_persistence::{PersistenceWorkerHandle, TracePersistenceQueue, TracePersistenceTask};
pub use dlq_retry::{DlqRetryOptions, start_dlq_retry};
async fn run_retention_job<F, Fut, R>(
job: &'static str,
interval_secs: u64,
lease_gate: Option<Arc<crate::cluster::JobLeaseGate>>,
delete: Arc<F>,
report: Arc<R>,
mut shutdown: crate::runtime::Shutdown,
) where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<u64, crate::errors::OrionError>> + Send + 'static,
R: Fn(Result<u64, crate::errors::OrionError>) + Send + Sync + 'static,
{
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
interval.tick().await;
let lease_ttl = interval_secs + 60;
loop {
tokio::select! {
_ = interval.tick() => {}
_ = shutdown.signalled() => return,
}
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(
tasks: &crate::runtime::TaskRegistry,
retention_hours: u64,
interval_secs: u64,
trace_repo: Arc<dyn TraceRetention>,
lease_gate: Option<Arc<crate::cluster::JobLeaseGate>>,
) {
if retention_hours == 0 {
tracing::info!("Trace retention disabled (retention_hours = 0)");
return;
}
supervise_retention_job(
tasks,
"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"
);
}
fn supervise_retention_job<F, Fut, R>(
tasks: &crate::runtime::TaskRegistry,
job: &'static str,
interval_secs: u64,
lease_gate: Option<Arc<crate::cluster::JobLeaseGate>>,
delete: F,
report: R,
) where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<u64, crate::errors::OrionError>> + Send + 'static,
R: Fn(Result<u64, crate::errors::OrionError>) + Send + Sync + 'static,
{
let delete = Arc::new(delete);
let report = Arc::new(report);
tasks.supervise(
job,
crate::runtime::Criticality::Optional,
move |shutdown| {
run_retention_job(
job,
interval_secs,
lease_gate.clone(),
delete.clone(),
report.clone(),
shutdown,
)
},
);
}
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 {
queue: BoundedWorker<QueuedItem>,
memory_bytes: Arc<AtomicUsize>,
max_memory_bytes: usize,
}
impl TraceQueue {
#[cfg(test)]
pub(crate) fn new_for_test(capacity: usize) -> (Self, bounded::WorkerReceiver<QueuedItem>) {
let (queue, mut receivers) =
BoundedWorker::new(1, capacity, metrics::set_trace_queue_depth);
let rx = receivers.pop().expect("one shard was requested");
(
Self {
queue,
memory_bytes: Arc::new(AtomicUsize::new(0)),
max_memory_bytes: 100_000_000,
},
rx,
)
}
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::unavailable(
crate::errors::Unavailable::AtCapacity,
format!(
"Trace queue memory limit exceeded ({} + {} > {} bytes)",
prev, payload_size, self.max_memory_bytes
),
));
}
metrics::set_trace_queue_memory_bytes(total as f64);
match self.queue.try_submit(item) {
Ok(()) => Ok(()),
Err(rejected) => {
self.memory_bytes.fetch_sub(payload_size, Ordering::AcqRel);
Err(match rejected {
Rejected::Full(_) => {
metrics::record_trace_queue_rejected("full");
crate::errors::OrionError::unavailable(
crate::errors::Unavailable::AtCapacity,
format!(
"Trace queue is full ({} messages pending)",
self.queue.depth()
),
)
}
Rejected::Closed(_) => crate::errors::OrionError::unavailable(
crate::errors::Unavailable::QueueClosed,
"Trace queue is closed",
),
})
}
}
}
}
pub struct WorkerHandle {
drain: DrainHandle<QueuedItem>,
join_handle: tokio::task::JoinHandle<()>,
shutdown_timeout_secs: u64,
}
impl WorkerHandle {
pub async fn shutdown(self) {
let timeout = Duration::from_secs(self.shutdown_timeout_secs);
match self
.drain
.drain(vec![self.join_handle], DrainWitness::TasksExit, timeout)
.await
{
DrainOutcome::Drained => {}
DrainOutcome::WorkerPanicked => {
tracing::error!("Trace queue dispatcher panicked")
}
DrainOutcome::TimedOut { .. } => {
tracing::warn!(
timeout_secs = self.shutdown_timeout_secs,
"Trace queue workers did not shut down within timeout, proceeding with exit"
);
}
}
}
}
pub struct WorkerDeps {
pub engine: Arc<crate::engine::EngineHandle>,
pub trace_repo: Arc<dyn TraceSink>,
pub dlq_repo: Option<Arc<dyn TraceDlqRepository>>,
pub channel_registry: Arc<crate::channel::ChannelRegistry>,
pub persistence_queue: TracePersistenceQueue,
pub global_trace_storage: crate::config::TraceStorageConfig,
pub rollout_sticky_header: String,
}
pub fn start_workers(
tasks: &crate::runtime::TaskRegistry,
config: &crate::config::TraceQueueConfig,
deps: WorkerDeps,
) -> (TraceQueue, WorkerHandle) {
let WorkerDeps {
engine,
trace_repo,
dlq_repo,
channel_registry,
persistence_queue,
global_trace_storage,
rollout_sticky_header,
} = deps;
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 (bounded, mut receivers) =
BoundedWorker::<QueuedItem>::new(1, buffer_size, metrics::set_trace_queue_depth);
let rx = receivers.pop().expect("one shard was requested");
let drain = bounded.drain_handle();
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 {
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 guard = tasks.guard("trace_dispatcher", crate::runtime::Criticality::Required);
let handle = tokio::spawn(guard.run(processing::dispatcher_loop(rx, dispatcher_ctx)));
let queue = TraceQueue {
queue: bounded,
memory_bytes,
max_memory_bytes: max_queue_memory_bytes,
};
let worker_handle = WorkerHandle {
drain,
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 (queue, _rx) = TraceQueue::new_for_test(1);
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 (queue, _rx) = TraceQueue::new_for_test(1);
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 (queue, rx) = TraceQueue::new_for_test(1);
drop(rx);
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 TraceRetention for MockCleanupTraceRepo {
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 TraceRetention> = Arc::new(MockCleanupTraceRepo);
let tasks = crate::runtime::TaskRegistry::new();
start_trace_cleanup(&tasks, 24, 1, repo, None);
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;
}
tasks.shutdown(Duration::from_secs(5)).await;
});
});
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}"
);
}
}