use arc_swap::ArcSwap;
#[cfg(feature = "metrics")]
use axum::{Router, http::StatusCode, routing::get};
use std::sync::Arc;
#[cfg(feature = "metrics")]
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::{Semaphore, broadcast, mpsc};
use tokio::task::JoinSet;
pub use async_trait::async_trait;
#[cfg(feature = "derive")]
pub use ingestix_derive::FlowWorker;
#[cfg(feature = "ingestors")]
pub mod http;
#[cfg(feature = "ingestors")]
pub mod syslog;
#[cfg(feature = "ingestors")]
pub mod tcp;
#[cfg(feature = "ingestors")]
pub mod udp;
#[cfg(feature = "ingestors")]
pub use http::{ApiKeyConfig, HttpConfig, HttpIngestor, HttpQueuePolicy};
#[cfg(feature = "ingestors")]
pub use syslog::{
SyslogConfig, SyslogEvent, SyslogIngestor, SyslogProtocol, SyslogStructuredDataElement,
};
#[cfg(feature = "ingestors")]
pub use tcp::{TcpConfig, TcpIngestor};
#[cfg(feature = "ingestors")]
pub use udp::{UdpConfig, UdpIngestor};
#[cfg(feature = "logging")]
#[allow(unused_macros)]
macro_rules! flow_log_info {
($($arg:tt)*) => {
tracing::info!($($arg)*);
};
}
#[cfg(not(feature = "logging"))]
#[allow(unused_macros)]
macro_rules! flow_log_info {
($($arg:tt)*) => {{};};
}
#[cfg(feature = "logging")]
#[allow(unused_macros)]
macro_rules! flow_log_debug {
($($arg:tt)*) => {
tracing::debug!($($arg)*);
};
}
#[cfg(not(feature = "logging"))]
#[allow(unused_macros)]
macro_rules! flow_log_debug {
($($arg:tt)*) => {{};};
}
#[allow(unused_imports)]
pub(crate) use flow_log_debug;
#[allow(unused_imports)]
pub(crate) use flow_log_info;
#[cfg(feature = "logging")]
#[allow(unused_macros)]
macro_rules! flow_log_warn {
($($arg:tt)*) => {
tracing::warn!($($arg)*);
};
}
#[cfg(not(feature = "logging"))]
#[allow(unused_macros)]
macro_rules! flow_log_warn {
($($arg:tt)*) => {{};};
}
#[allow(unused_imports)]
pub(crate) use flow_log_warn;
#[cfg(feature = "metrics")]
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
#[cfg(feature = "metrics")]
static PROM_HANDLE: OnceLock<Option<PrometheusHandle>> = OnceLock::new();
#[cfg(feature = "metrics")]
fn maybe_prom_handle() -> Option<&'static PrometheusHandle> {
PROM_HANDLE
.get_or_init(|| match PrometheusBuilder::new().install_recorder() {
Ok(handle) => {
metrics::counter!("flow_received_total").increment(0);
metrics::counter!("flow_ingested_total").increment(0);
metrics::counter!("flow_processed_total").increment(0);
metrics::gauge!("flow_queue_depth").set(0.0);
Some(handle)
}
Err(err) => {
crate::flow_log_warn!("Prometheus recorder not installed: {}", err);
None
}
})
.as_ref()
}
#[derive(Clone, Copy)]
pub struct FlowCounterName(&'static str);
impl FlowCounterName {
pub fn inc(&self) {
#[cfg(feature = "metrics")]
metrics::counter!(self.0).increment(1);
#[cfg(not(feature = "metrics"))]
let _ = self.0;
}
}
#[derive(Clone, Copy)]
pub struct FlowGaugeName(&'static str);
impl FlowGaugeName {
pub fn set(&self, value: i64) {
#[cfg(feature = "metrics")]
metrics::gauge!(self.0).set(value as f64);
#[cfg(not(feature = "metrics"))]
let _ = (self.0, value);
}
}
pub const INGESTED_MSGS: FlowCounterName = FlowCounterName("flow_ingested_total");
pub const PROCESSED_MSGS: FlowCounterName = FlowCounterName("flow_processed_total");
pub const RECEIVED_MSGS: FlowCounterName = FlowCounterName("flow_received_total");
pub const CURRENT_QUEUE: FlowGaugeName = FlowGaugeName("flow_queue_depth");
pub const REJECTED_INVALID_JSON: FlowCounterName =
FlowCounterName("flow_rejected_invalid_json_total");
pub const REJECTED_INVALID_SYSLOG: FlowCounterName =
FlowCounterName("flow_rejected_invalid_syslog_total");
pub const REJECTED_INVALID_API_KEY: FlowCounterName =
FlowCounterName("flow_rejected_invalid_api_key_total");
pub const REJECTED_OVERSIZED_PAYLOAD: FlowCounterName =
FlowCounterName("flow_rejected_oversized_payload_total");
pub const REJECTED_REQUEST_TIMEOUT: FlowCounterName =
FlowCounterName("flow_rejected_request_timeout_total");
pub const REJECTED_OVERLOADED_QUEUE: FlowCounterName =
FlowCounterName("flow_rejected_overloaded_queue_total");
pub struct SharedContext<C, S> {
pub config: ArcSwap<C>,
pub state: S,
pub semaphore: Arc<Semaphore>,
}
#[async_trait]
pub trait Ingestor<M, C, S>: Send + 'static {
async fn run(
self,
tx: mpsc::Sender<M>,
ctx: Arc<SharedContext<C, S>>,
shutdown: broadcast::Receiver<()>,
) -> anyhow::Result<()>;
}
#[async_trait]
pub trait Worker<M, C, S>: Send + Sync + 'static {
async fn process(&self, msg: M, ctx: Arc<SharedContext<C, S>>) -> anyhow::Result<()>;
}
#[derive(Clone, Copy)]
pub enum WorkerFailurePolicy {
BestEffort,
FailFast,
}
pub struct Ingestix<M, C, S> {
context: Arc<SharedContext<C, S>>,
buffer_size: usize,
worker_failure_policy: WorkerFailurePolicy,
worker_failures: Arc<AtomicUsize>,
readiness_failure_threshold: Option<usize>,
_marker: std::marker::PhantomData<M>,
}
impl<M, C, S> Ingestix<M, C, S>
where
M: Send + 'static,
C: Send + Sync + 'static,
S: Send + Sync + 'static,
{
pub fn new(config: C, state: S, concurrency: usize, buffer_size: usize) -> Self {
Self {
context: Arc::new(SharedContext {
config: ArcSwap::from_pointee(config),
state,
semaphore: Arc::new(Semaphore::new(concurrency)),
}),
buffer_size,
worker_failure_policy: WorkerFailurePolicy::BestEffort,
worker_failures: Arc::new(AtomicUsize::new(0)),
readiness_failure_threshold: None,
_marker: std::marker::PhantomData,
}
}
pub fn with_worker_failure_policy(mut self, policy: WorkerFailurePolicy) -> Self {
self.worker_failure_policy = policy;
self
}
pub fn with_readiness_failure_threshold(mut self, threshold: usize) -> Self {
self.readiness_failure_threshold = Some(threshold.max(1));
self
}
#[cfg(feature = "metrics")]
pub async fn spawn_monitor_server(&self, port: u16) -> anyhow::Result<()> {
self.spawn_monitor_server_on(std::net::SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
port,
))
.await
}
#[cfg(feature = "metrics")]
pub async fn spawn_monitor_server_on(&self, addr: std::net::SocketAddr) -> anyhow::Result<()> {
let worker_failures = self.worker_failures.clone();
let readiness_failure_threshold = self.readiness_failure_threshold;
let app = Router::new()
.route("/metrics", get(handle_metrics))
.route("/health/live", get(|| async { StatusCode::OK }))
.route(
"/health/ready",
get(move || {
let worker_failures = worker_failures.clone();
async move {
if let Some(threshold) = readiness_failure_threshold
&& worker_failures.load(Ordering::Relaxed) >= threshold
{
return StatusCode::SERVICE_UNAVAILABLE;
}
StatusCode::OK
}
}),
);
let listener = tokio::net::TcpListener::bind(addr).await?;
tokio::spawn(async move {
if let Err(err) = axum::serve(listener, app).await {
crate::flow_log_warn!("Monitor server exited with error: {}", err);
}
});
Ok(())
}
pub async fn launch<I, W>(self, ingestor: I, worker: W) -> anyhow::Result<()>
where
I: Ingestor<M, C, S>,
W: Worker<M, C, S>,
{
#[cfg(feature = "metrics")]
let _ = maybe_prom_handle();
let (tx, mut rx) = mpsc::channel(self.buffer_size);
let (shutdown_tx, _) = broadcast::channel(1);
let worker = Arc::new(worker);
let mut worker_tasks: JoinSet<anyhow::Result<()>> = JoinSet::new();
let worker_failure_policy = self.worker_failure_policy;
let worker_failures = self.worker_failures.clone();
let i_ctx = self.context.clone();
let i_shutdown = shutdown_tx.subscribe();
let ingestor_handle =
tokio::spawn(async move { ingestor.run(tx, i_ctx, i_shutdown).await });
let signal_shutdown_tx = shutdown_tx.clone();
let signal_task = tokio::spawn(async move {
let signal_name = wait_for_shutdown_signal().await;
crate::flow_log_info!(
"Ingestix received {} signal, broadcasting shutdown",
signal_name
);
let _ = signal_shutdown_tx.send(());
});
while let Some(msg) = rx.recv().await {
CURRENT_QUEUE.set(rx.len() as i64);
let w = worker.clone();
let c = self.context.clone();
let worker_shutdown_tx = shutdown_tx.clone();
let worker_failures_for_task = worker_failures.clone();
let permit = self
.context
.semaphore
.clone()
.acquire_owned()
.await
.map_err(|e| anyhow::anyhow!("worker semaphore unexpectedly closed: {e}"))?;
worker_tasks.spawn(async move {
let _permit = permit;
match w.process(msg, c).await {
Ok(()) => {
PROCESSED_MSGS.inc();
Ok(())
}
Err(err) => {
worker_failures_for_task.fetch_add(1, Ordering::Relaxed);
crate::flow_log_warn!("Worker failed to process message: {}", err);
if matches!(worker_failure_policy, WorkerFailurePolicy::FailFast) {
let _ = worker_shutdown_tx.send(());
}
Err(err)
}
}
});
}
signal_task.abort();
let ingestor_result = ingestor_handle
.await
.map_err(|e| anyhow::anyhow!("ingestor task failed to join: {e}"))?;
ingestor_result?;
let mut first_worker_error: Option<anyhow::Error> = None;
while let Some(result) = worker_tasks.join_next().await {
match result {
Ok(Ok(())) => {}
Ok(Err(err)) => {
if first_worker_error.is_none() {
first_worker_error = Some(err);
}
}
Err(err) => {
self.worker_failures.fetch_add(1, Ordering::Relaxed);
let join_err = anyhow::anyhow!("worker task join error: {err}");
crate::flow_log_warn!("{}", join_err);
if first_worker_error.is_none() {
first_worker_error = Some(join_err);
}
}
}
}
if matches!(worker_failure_policy, WorkerFailurePolicy::FailFast)
&& let Some(err) = first_worker_error
{
return Err(err);
}
Ok(())
}
}
async fn wait_for_shutdown_signal() -> &'static str {
tokio::select! {
_ = tokio::signal::ctrl_c() => "SIGINT",
_ = wait_for_sigterm() => "SIGTERM",
}
}
#[cfg(unix)]
async fn wait_for_sigterm() {
use tokio::signal::unix::{SignalKind, signal};
if let Ok(mut term_signal) = signal(SignalKind::terminate()) {
let _ = term_signal.recv().await;
} else {
std::future::pending::<()>().await;
}
}
#[cfg(not(unix))]
async fn wait_for_sigterm() {
std::future::pending::<()>().await;
}
#[cfg(feature = "metrics")]
async fn handle_metrics() -> String {
if let Some(handle) = maybe_prom_handle() {
handle.run_upkeep();
handle.render()
} else {
"# metrics recorder unavailable\n".to_string()
}
}