use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::Value;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use rsearch_index::{DocIdentity, IndexMapping, MappedSchema, SplitBuilder};
use rsearch_metastore::{Metastore, StreamMode};
use rsearch_storage::Storage;
use crate::error::{IngestError, IngestResult};
use crate::wal::{Wal, WalItem, WalPos, WalReplay};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StreamInfo {
pub id: i64,
pub mode: StreamMode,
}
const STREAM_INFO_TTL: Duration = Duration::from_secs(10);
#[derive(Default)]
pub struct SeqClock {
last: std::sync::atomic::AtomicI64,
}
impl SeqClock {
pub fn observe(&self, seen: i64) {
self.last.fetch_max(seen, Ordering::AcqRel);
}
pub fn next(&self) -> i64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0);
self.last
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |last| {
Some(now.max(last.saturating_add(1)))
})
.map(|prev| now.max(prev.saturating_add(1)))
.unwrap_or(now)
}
}
#[derive(Clone)]
pub struct PipelineConfig {
pub max_batch_docs: usize,
pub max_batch_secs: u64,
pub document_max_batch_secs: u64,
pub queue_capacity: usize,
pub work_dir: PathBuf,
pub memory_budget: usize,
pub node_id: String,
}
enum WorkerMsg {
Doc(WorkItem),
Flush(tokio::sync::oneshot::Sender<()>),
}
struct WorkItem {
source: Arc<str>,
identity: DocIdentity,
pos: WalPos,
}
#[derive(Default)]
pub struct IngestMetrics {
pub docs_enqueued: AtomicU64,
pub bytes_enqueued: AtomicU64,
pub docs_indexed: AtomicU64,
pub splits_published: AtomicU64,
pub flush_failures: AtomicU64,
pub queue_depth: AtomicU64,
}
struct PipelineInner {
config: PipelineConfig,
storage: Arc<dyn Storage>,
metastore: Metastore,
wal: Arc<Wal>,
workers: std::sync::RwLock<HashMap<String, mpsc::Sender<WorkerMsg>>>,
worker_create: tokio::sync::Mutex<()>,
metrics: IngestMetrics,
rules: std::sync::RwLock<Arc<Vec<rsearch_metastore::RoutingRuleRecord>>>,
seq: SeqClock,
stream_info: std::sync::RwLock<HashMap<String, (StreamInfo, std::time::Instant)>>,
}
#[derive(Clone)]
pub struct IngestPipeline {
inner: Arc<PipelineInner>,
}
fn now_datetime() -> rsearch_index::DateTime {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
rsearch_index::DateTime::from_timestamp_millis(millis)
}
impl IngestPipeline {
pub fn new(
config: PipelineConfig,
storage: Arc<dyn Storage>,
metastore: Metastore,
wal: Arc<Wal>,
) -> Self {
let pipeline = Self {
inner: Arc::new(PipelineInner {
config,
storage,
metastore,
wal,
workers: std::sync::RwLock::new(HashMap::new()),
worker_create: tokio::sync::Mutex::new(()),
metrics: IngestMetrics::default(),
rules: std::sync::RwLock::new(Arc::new(Vec::new())),
seq: SeqClock::default(),
stream_info: std::sync::RwLock::new(HashMap::new()),
}),
};
let inner = pipeline.inner.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
loop {
interval.tick().await;
match inner.metastore.list_routing_rules().await {
Ok(rules) => *inner.rules.write().unwrap() = Arc::new(rules),
Err(e) => warn!(error = %e, "routing rule refresh failed"),
}
}
});
pipeline
}
pub async fn warm_routing_rules(&self) -> IngestResult<()> {
let rules = self.inner.metastore.list_routing_rules().await?;
*self.inner.rules.write().unwrap() = Arc::new(rules);
Ok(())
}
pub fn expand_routes(&self, default_stream: &str, doc: &Value) -> Vec<String> {
let rules = self.inner.rules.read().unwrap().clone();
let mut primary = default_stream.to_string();
let mut extra: Vec<String> = Vec::new();
for rule in rules.iter() {
let matched = match (rule.op.as_str(), doc.get(&rule.field)) {
(_, None) => false,
("exists", Some(_)) => true,
("eq", Some(v)) => {
v.as_str().map(|s| s == rule.value).unwrap_or_else(|| {
v.to_string() == rule.value
})
}
("contains", Some(v)) => {
v.as_str().map(|s| s.contains(&rule.value)).unwrap_or(false)
}
_ => false,
};
if matched {
if rule.copy {
extra.push(rule.target_stream.clone());
} else {
primary = rule.target_stream.clone();
}
}
}
let mut routes = vec![primary];
for stream in extra {
if !routes.contains(&stream) {
routes.push(stream);
}
}
routes
}
pub async fn ingest_external(
&self,
default_stream: &str,
docs: Vec<Value>,
) -> IngestResult<(usize, usize)> {
let mut items: Vec<WalItem> = Vec::new();
for doc in docs {
let source: Arc<str> = Arc::from(doc.to_string());
let id = uuid::Uuid::new_v4().simple().to_string();
let seq = self.next_seq();
for stream in self.expand_routes(default_stream, &doc) {
items.push(WalItem {
stream,
id: id.clone(),
seq,
tombstone: false,
doc: source.clone(),
});
}
}
if items.is_empty() {
return Ok((0, 0));
}
let wal = self.inner.wal.clone();
let (items, positions) = tokio::task::spawn_blocking(move || {
let positions = wal.append_batch(&items);
(items, positions)
})
.await
.map_err(|e| IngestError::Wal(std::io::Error::other(e.to_string())))?;
let positions = positions?;
let mut accepted = 0;
let mut dropped = 0;
for (item, pos) in items.into_iter().zip(positions) {
let identity = DocIdentity::new(item.id, item.seq);
match self.enqueue(&item.stream, item.doc, identity, pos).await {
Ok(()) => accepted += 1,
Err(_) => {
self.inner.wal.confirm(&[pos]);
dropped += 1;
}
}
}
if dropped > 0 {
warn!(dropped, "input documents dropped (ingest saturated)");
}
Ok((accepted, dropped))
}
pub fn metrics(&self) -> &IngestMetrics {
&self.inner.metrics
}
pub fn wal(&self) -> &Arc<Wal> {
&self.inner.wal
}
pub async fn stream_info(&self, name: &str) -> IngestResult<StreamInfo> {
if let Some((info, at)) = self.inner.stream_info.read().unwrap().get(name)
&& at.elapsed() < STREAM_INFO_TTL
{
return Ok(*info);
}
let record = self.inner.metastore.ensure_stream(name).await?;
let info = StreamInfo {
id: record.id,
mode: record.mode(),
};
if info.mode == StreamMode::Document {
if let Some(max) = self.inner.metastore.stream_max_seq(info.id).await? {
self.inner.seq.observe(max);
}
}
let mut cache = self.inner.stream_info.write().unwrap();
if cache.len() > 10_000 {
cache.clear();
}
cache.insert(name.to_string(), (info, std::time::Instant::now()));
Ok(info)
}
pub async fn stream_info_if_exists(&self, name: &str) -> IngestResult<Option<StreamInfo>> {
if let Some((info, at)) = self.inner.stream_info.read().unwrap().get(name)
&& at.elapsed() < STREAM_INFO_TTL
{
return Ok(Some(*info));
}
match self.inner.metastore.get_stream(name).await {
Ok(_) => self.stream_info(name).await.map(Some),
Err(rsearch_metastore::MetastoreError::StreamNotFound(_)) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn forget_stream(&self, name: &str) {
self.inner.stream_info.write().unwrap().remove(name);
}
pub fn next_seq(&self) -> i64 {
self.inner.seq.next()
}
pub fn observe_seq(&self, seen: i64) {
self.inner.seq.observe(seen);
}
pub async fn enqueue(
&self,
stream: &str,
source: Arc<str>,
identity: DocIdentity,
pos: WalPos,
) -> IngestResult<()> {
let tx = self.worker_for(stream).await?;
let size = source.len() as u64;
match tx.try_send(WorkerMsg::Doc(WorkItem {
source,
identity,
pos,
})) {
Ok(()) => {
self.note_enqueued(size);
Ok(())
}
Err(mpsc::error::TrySendError::Full(_)) => Err(IngestError::Saturated),
Err(mpsc::error::TrySendError::Closed(item)) => {
let tx = self.worker_for(stream).await?;
match tx.try_send(item) {
Ok(()) => {
self.note_enqueued(size);
Ok(())
}
Err(_) => Err(IngestError::Saturated),
}
}
}
}
fn note_enqueued(&self, size: u64) {
self.inner.metrics.docs_enqueued.fetch_add(1, Ordering::Relaxed);
self.inner
.metrics
.bytes_enqueued
.fetch_add(size, Ordering::Relaxed);
self.inner.metrics.queue_depth.fetch_add(1, Ordering::Relaxed);
}
pub async fn replay(&self, records: WalReplay) -> IngestResult<usize> {
let mut count = 0usize;
let mut pending_tombstones: Vec<rsearch_metastore::NewTombstone> = Vec::new();
for record in records {
let record = record.map_err(IngestError::Wal)?;
count += 1;
if record.tombstone {
let info = self.stream_info(&record.stream).await?;
pending_tombstones.push(rsearch_metastore::NewTombstone {
stream_id: info.id,
doc_id: record.id.clone(),
before_seq: record.seq,
});
self.inner.seq.observe(record.seq);
if pending_tombstones.len() >= 1_000 {
self.inner
.metastore
.upsert_tombstones(&pending_tombstones)
.await?;
pending_tombstones.clear();
}
}
if serde_json::from_slice::<serde::de::IgnoredAny>(&record.doc).is_err() {
warn!(stream = %record.stream, "dropping corrupt WAL doc");
self.inner.wal.confirm(&[record.pos]);
continue;
}
let source: Arc<str> = match std::str::from_utf8(&record.doc) {
Ok(text) => Arc::from(text),
Err(_) => Arc::from(String::from_utf8_lossy(&record.doc).into_owned()),
};
let tx = self.worker_for(&record.stream).await?;
self.inner.metrics.queue_depth.fetch_add(1, Ordering::Relaxed);
if tx
.send(WorkerMsg::Doc(WorkItem {
source,
identity: DocIdentity::new(record.id, record.seq),
pos: record.pos,
}))
.await
.is_err()
{
self.inner.metrics.queue_depth.fetch_sub(1, Ordering::Relaxed);
self.inner.wal.confirm(&[record.pos]);
warn!(stream = %record.stream, "worker gone during replay; record dropped");
}
}
if !pending_tombstones.is_empty() {
self.inner
.metastore
.upsert_tombstones(&pending_tombstones)
.await?;
}
if count > 0 {
info!(count, "replayed WAL records into pipeline");
}
Ok(count)
}
pub async fn flush_stream(&self, stream: &str) -> IngestResult<()> {
let tx = match self.inner.workers.read().unwrap().get(stream) {
Some(tx) => tx.clone(),
None => return Ok(()),
};
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
if tx.send(WorkerMsg::Flush(done_tx)).await.is_err() {
return Ok(());
}
let _ = done_rx.await;
Ok(())
}
async fn worker_for(&self, stream: &str) -> IngestResult<mpsc::Sender<WorkerMsg>> {
if let Some(tx) = self.inner.workers.read().unwrap().get(stream) {
return Ok(tx.clone());
}
let _create = self.inner.worker_create.lock().await;
if let Some(tx) = self.inner.workers.read().unwrap().get(stream) {
return Ok(tx.clone());
}
let record = self.inner.metastore.ensure_stream(stream).await?;
let mapping = IndexMapping::from_json(&record.mapping).unwrap_or_default();
let schema = MappedSchema::build(mapping);
let (tx, rx) = mpsc::channel(self.inner.config.queue_capacity);
tokio::spawn(stream_worker(
self.inner.clone(),
stream.to_string(),
record.id,
record.is_document_mode(),
schema,
rx,
));
self.inner
.workers
.write()
.unwrap()
.insert(stream.to_string(), tx.clone());
Ok(tx)
}
}
const WORKER_IDLE_EXIT_SECS: u64 = 600;
async fn stream_worker(
inner: Arc<PipelineInner>,
stream: String,
stream_id: i64,
document_mode: bool,
schema: MappedSchema,
mut rx: mpsc::Receiver<WorkerMsg>,
) {
let max_docs = inner.config.max_batch_docs.max(1);
let age_secs = if document_mode {
inner.config.document_max_batch_secs
} else {
inner.config.max_batch_secs
};
let max_age = Duration::from_secs(age_secs.max(1));
let idle_exit = Duration::from_secs(WORKER_IDLE_EXIT_SECS);
let mut buffer: Vec<WorkItem> = Vec::new();
let mut waiters: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
let mut deadline = tokio::time::Instant::now() + idle_exit;
let mut schema = schema;
let mut mapping_json = schema.mapping.to_json();
loop {
let flush_now = tokio::select! {
msg = rx.recv() => match msg {
Some(WorkerMsg::Doc(item)) => {
if buffer.is_empty() {
deadline = tokio::time::Instant::now() + max_age;
}
buffer.push(item);
buffer.len() >= max_docs
}
Some(WorkerMsg::Flush(done)) => {
if buffer.is_empty() {
let _ = done.send(());
false
} else {
waiters.push(done);
true
}
}
None => {
if !buffer.is_empty() {
flush(&inner, &stream, stream_id, &schema, &mut buffer).await;
}
return;
}
},
_ = tokio::time::sleep_until(deadline) => {
if buffer.is_empty() {
inner.workers.write().unwrap().remove(&stream);
rx.close();
while let Ok(msg) = rx.try_recv() {
match msg {
WorkerMsg::Doc(item) => buffer.push(item),
WorkerMsg::Flush(done) => waiters.push(done),
}
}
if !buffer.is_empty() {
flush(&inner, &stream, stream_id, &schema, &mut buffer).await;
}
for done in waiters.drain(..) {
let _ = done.send(());
}
info!(stream, "idle stream worker retired");
return;
}
true
},
};
if flush_now {
if let Ok(record) = inner.metastore.get_stream(&stream).await
&& record.mapping != mapping_json
{
let mapping = IndexMapping::from_json(&record.mapping).unwrap_or_default();
schema = MappedSchema::build(mapping);
mapping_json = record.mapping;
}
flush(&inner, &stream, stream_id, &schema, &mut buffer).await;
for done in waiters.drain(..) {
let _ = done.send(());
}
deadline = tokio::time::Instant::now() + idle_exit;
}
}
}
async fn flush(
inner: &Arc<PipelineInner>,
stream: &str,
stream_id: i64,
schema: &MappedSchema,
buffer: &mut Vec<WorkItem>,
) {
let mut batch = std::mem::take(buffer);
let count = batch.len() as u64;
inner.metrics.queue_depth.fetch_sub(count, Ordering::Relaxed);
let positions: Vec<WalPos> = batch.iter().map(|item| item.pos).collect();
let mut backoff = Duration::from_millis(200);
let mut attempt = 0u64;
loop {
attempt += 1;
match flush_inner(inner, stream, stream_id, schema, batch).await {
Ok(Some((split_id, indexed))) => {
inner.wal.confirm(&positions);
inner.metrics.docs_indexed.fetch_add(indexed, Ordering::Relaxed);
inner.metrics.splits_published.fetch_add(1, Ordering::Relaxed);
if indexed < count {
warn!(
stream,
skipped = count - indexed,
"batch published without its invalid docs"
);
}
info!(stream, split_id = %split_id, docs = indexed, "split published");
return;
}
Ok(None) => {
inner.wal.confirm(&positions);
warn!(stream, docs = count, "batch contained no indexable docs; dropped");
return;
}
Err((e, returned)) => {
batch = returned;
inner.metrics.flush_failures.fetch_add(1, Ordering::Relaxed);
if attempt % 8 == 0 {
error!(
stream, docs = count, attempt, error = %e,
"flush still failing; docs held in WAL and retried until the backend recovers"
);
} else {
warn!(stream, attempt, error = %e, "flush failed; retrying");
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(30));
}
}
}
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str {
payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("non-string panic payload")
}
async fn flush_inner(
inner: &Arc<PipelineInner>,
stream: &str,
stream_id: i64,
schema: &MappedSchema,
batch: Vec<WorkItem>,
) -> Result<Option<(String, u64)>, (IngestError, Vec<WorkItem>)> {
let schema = schema.clone();
let stream_name = stream.to_string();
let work_dir = inner.config.work_dir.clone();
let budget = inner.config.memory_budget;
let (result, batch) = tokio::task::spawn_blocking(move || {
let build = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut builder = SplitBuilder::new(stream_name, schema, &work_dir, budget)?;
let fallback = now_datetime();
for item in &batch {
let doc = match serde_json::from_str::<Value>(&item.source) {
Ok(doc) => doc,
Err(e) => {
tracing::warn!(error = %e, "skipping unparseable buffered doc");
continue;
}
};
let added = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
builder.add_document(doc, Some(&item.source), &item.identity, fallback)
}));
match added {
Ok(Ok(())) => {}
Ok(Err(rsearch_index::IndexError::InvalidDocument(reason))) => {
tracing::warn!(reason, "skipping invalid buffered doc");
}
Ok(Err(e)) => return Err(e),
Err(panic) => {
tracing::warn!(
panic = panic_message(panic.as_ref()),
"skipping doc that panicked the indexer"
);
}
}
}
if builder.doc_count() == 0 {
return Ok(None);
}
let indexed = builder.doc_count();
builder.finish().map(|packaged| Some((packaged, indexed)))
}));
let result = match build {
Ok(result) => result,
Err(panic) => Err(rsearch_index::IndexError::InvalidDocument(format!(
"indexing task panicked: {}",
panic_message(panic.as_ref())
))),
};
(result, batch)
})
.await
.map_err(|e| {
(
IngestError::Index(rsearch_index::IndexError::InvalidDocument(format!(
"indexing task aborted: {e}"
))),
Vec::new(),
)
})?;
let (packaged, indexed) = match result {
Ok(Some((packaged, indexed))) => (packaged, indexed),
Ok(None) => return Ok(None),
Err(e) => return Err((IngestError::Index(e), batch)),
};
let key = format!("streams/{stream}/{}.split", packaged.meta.split_id);
if let Err(e) = inner.storage.put_file(&key, &packaged.file_path).await {
return Err((IngestError::Storage(e), batch));
}
if let Err(e) = inner
.metastore
.stage_split(&rsearch_metastore::NewSplit {
split_id: &packaged.meta.split_id,
stream_id,
storage_key: &key,
doc_count: packaged.meta.doc_count as i64,
size_bytes: packaged.size_bytes as i64,
time_start_millis: packaged.meta.time_start_millis,
time_end_millis: packaged.meta.time_end_millis,
footer_len: packaged.footer_len as i64,
created_by: Some(&inner.config.node_id),
seq_min: packaged.meta.seq_min,
seq_max: packaged.meta.seq_max,
tombstone_seq_applied: 0,
})
.await
{
return Err((IngestError::Metastore(e), batch));
}
if let Err(e) = inner.metastore.publish_split(&packaged.meta.split_id).await {
return Err((IngestError::Metastore(e), batch));
}
Ok(Some((packaged.meta.split_id, indexed)))
}
#[cfg(test)]
mod tests {
use super::SeqClock;
#[test]
fn seq_clock_is_monotonic_and_observes() {
let clock = SeqClock::default();
let a = clock.next();
let b = clock.next();
assert!(b > a);
let far = a + 10_000_000_000;
clock.observe(far);
assert!(clock.next() > far);
let c = clock.next();
clock.observe(a);
assert!(clock.next() > c);
}
}