use fraiseql_error::{FraiseQLError, Result};
use fraiseql_observers::{
CursorSnapshot, LeaseGuardedRunner, ObserverError, RunOutcome, SourceCursorStore,
};
use super::ingest::{PullBatch, PullContext, PullSource};
#[cfg(test)]
mod tests;
#[allow(async_fn_in_trait)] pub trait IngestSink {
async fn ingest(
&self,
source_name: &str,
batch: PullBatch,
from: &CursorSnapshot,
) -> Result<bool>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceOutcome {
SkippedNotLeader,
NoData,
Ingested {
messages: usize,
},
CursorRaceLost,
}
#[allow(clippy::future_not_send)]
pub async fn run_source_once<S, P, K>(
runner: &LeaseGuardedRunner,
store: &S,
source: &P,
sink: &K,
) -> Result<SourceOutcome>
where
S: SourceCursorStore,
P: PullSource,
K: IngestSink,
{
let name = runner.source_name();
let outcome = runner
.run(|| ingest_tick(store, source, sink, name))
.await
.map_err(|e| obs_err(&e))?;
match outcome {
RunOutcome::SkippedNotLeader => Ok(SourceOutcome::SkippedNotLeader),
RunOutcome::Ran(result) => result,
}
}
#[allow(clippy::future_not_send)] async fn ingest_tick<S, P, K>(store: &S, source: &P, sink: &K, name: &str) -> Result<SourceOutcome>
where
S: SourceCursorStore,
P: PullSource,
K: IngestSink,
{
let cursor = store.load(name).await.map_err(|e| obs_err(&e))?;
let batch = source
.poll(&PullContext {
cursor: cursor.value.clone(),
})
.await
.map_err(|error| {
FraiseQLError::internal(format!("source '{name}' poll failed: {error}"))
})?;
let current = cursor.value.clone().unwrap_or(serde_json::Value::Null);
let progressed = batch.next_cursor != current;
if batch.messages.is_empty() && !progressed {
return Ok(SourceOutcome::NoData);
}
let message_count = batch.messages.len();
if sink.ingest(name, batch, &cursor).await? {
Ok(SourceOutcome::Ingested {
messages: message_count,
})
} else {
Ok(SourceOutcome::CursorRaceLost)
}
}
fn obs_err(error: &ObserverError) -> FraiseQLError {
FraiseQLError::database(format!("source coordination: {error}"))
}