use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use knut_bifrost::{
Backoff, GraphMutation, Listener, MappingSpec, Result as BifrostResult, SinkReport, SyncState,
TransformRegistry,
sink::{FalkorDbSink, GraphSink},
source::FileSource,
};
#[derive(Parser)]
#[command(
name = "bifrost",
about = "Drive the file → graph lifecycle with structured logs",
version
)]
struct Cli {
#[arg(long, global = true)]
json_logs: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Command {
Run {
#[arg(long)]
mapping: PathBuf,
#[arg(long)]
files: PathBuf,
#[arg(long, default_value = "redis://127.0.0.1:6379")]
falkor: String,
#[arg(long, default_value = "bifrost")]
graph: String,
#[arg(long)]
state: Option<PathBuf>,
#[arg(long, value_name = "URL")]
state_valkey: Option<String>,
#[arg(long, default_value = "bifrost:state")]
state_key: String,
#[arg(long)]
once: bool,
#[arg(long, default_value_t = 5)]
interval: u64,
#[arg(long)]
max_backoff: Option<u64>,
#[arg(long, value_name = "PATH")]
metrics_file: Option<PathBuf>,
#[arg(long, value_name = "ADDR")]
metrics_addr: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "URL")]
emit_airflow: Option<String>,
#[arg(long, default_value = "graph://")]
asset_prefix: String,
#[arg(long, value_name = "TOKEN")]
airflow_token: Option<String>,
},
GenSample {
#[arg(long)]
files: PathBuf,
#[arg(long, default_value = "people")]
table: String,
#[arg(long, default_value_t = 1000)]
rows: usize,
#[arg(long, default_value = "chunk0.parquet")]
chunk: String,
},
SelfTest,
}
fn main() -> ExitCode {
let cli = Cli::parse();
init_tracing(cli.json_logs);
match run(cli.command) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(target: "bifrost", error = %e, "fatal");
ExitCode::FAILURE
}
}
}
fn init_tracing(json: bool) {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
if json {
fmt()
.with_env_filter(filter)
.json()
.with_current_span(false)
.init();
} else {
fmt().with_env_filter(filter).with_target(true).init();
}
}
fn run(cmd: Command) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
Command::SelfTest => {
let ok = knut_bifrost::testmatrix::self_test();
tracing::info!(target: "bifrost", ok, "self-test complete");
if ok {
Ok(())
} else {
Err("bifröst self-test failed (a core invariant did not hold)".into())
}
}
Command::GenSample {
files,
table,
rows,
chunk,
} => gen_sample(files, table, rows, chunk),
Command::Run {
mapping,
files,
falkor,
graph,
state,
state_valkey,
state_key,
once,
interval,
max_backoff,
metrics_file,
metrics_addr,
dry_run,
emit_airflow,
asset_prefix,
airflow_token,
} => run_pipeline(
mapping,
files,
falkor,
graph,
state,
state_valkey,
state_key,
once,
interval,
max_backoff,
metrics_file,
metrics_addr,
dry_run,
emit_airflow,
asset_prefix,
airflow_token,
),
}
}
#[allow(clippy::too_many_arguments)]
fn run_pipeline(
mapping: PathBuf,
files: PathBuf,
falkor: String,
graph: String,
state: Option<PathBuf>,
state_valkey: Option<String>,
state_key: String,
once: bool,
interval: u64,
max_backoff: Option<u64>,
metrics_file: Option<PathBuf>,
metrics_addr: Option<String>,
dry_run: bool,
emit_airflow: Option<String>,
asset_prefix: String,
airflow_token: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let spec = MappingSpec::load(&mapping)?;
let state_path = state.unwrap_or_else(|| files.join(".bifrost-state.json"));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async move {
let source = FileSource::new(&files);
let sync_state = match &state_valkey {
Some(url) => SyncState::open_valkey(url, &state_key).await?,
None => SyncState::open(&state_path)?,
};
let emitter: Option<(std::sync::Arc<dyn knut_bifrost::AssetEmitter>, String)> =
emit_airflow.map(|url| {
let e = knut_bifrost::airflow::AirflowEmitter::new(url, airflow_token);
(
std::sync::Arc::new(e) as std::sync::Arc<dyn knut_bifrost::AssetEmitter>,
asset_prefix,
)
});
tracing::info!(
target: "bifrost",
event = "start",
files = %files.display(),
graph = %graph,
dry_run,
once,
emit_airflow = emitter.is_some(),
metrics_http = metrics_addr.is_some(),
state = if state_valkey.is_some() { "valkey" } else { "file" },
"bifrost starting",
);
let metrics_server = start_metrics_server(metrics_addr.as_deref())?;
if dry_run {
let sink = NullSink;
let mut l = Listener::new(spec, TransformRegistry::new(), source, sink, sync_state);
if let Some((e, prefix)) = emitter {
l = l.with_asset_emitter(e, prefix);
}
drive(
l,
once,
interval,
max_backoff,
metrics_file,
&metrics_server,
)
.await
} else {
let sink = FalkorDbSink::connect(&falkor, graph.clone()).await?;
let mut l = Listener::new(spec, TransformRegistry::new(), source, sink, sync_state);
if let Some((e, prefix)) = emitter {
l = l.with_asset_emitter(e, prefix);
}
drive(
l,
once,
interval,
max_backoff,
metrics_file,
&metrics_server,
)
.await
}
})
}
#[cfg(feature = "metrics-http")]
type OptMetricsServer = Option<knut_bifrost::MetricsServer>;
#[cfg(not(feature = "metrics-http"))]
type OptMetricsServer = ();
#[cfg(feature = "metrics-http")]
fn start_metrics_server(
addr: Option<&str>,
) -> Result<OptMetricsServer, Box<dyn std::error::Error>> {
match addr {
Some(addr) => {
let server = knut_bifrost::MetricsServer::start(addr)?;
let bound = server
.local_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| addr.into());
tracing::info!(
target: "metrics",
event = "metrics_http_started",
addr = %bound,
"serving Prometheus metrics at http://{bound}/metrics",
);
Ok(Some(server))
}
None => Ok(None),
}
}
#[cfg(not(feature = "metrics-http"))]
fn start_metrics_server(
addr: Option<&str>,
) -> Result<OptMetricsServer, Box<dyn std::error::Error>> {
if addr.is_some() {
tracing::warn!(
target: "metrics",
event = "metrics_http_unavailable",
"--metrics-addr was given but this build lacks the `metrics-http` feature; \
no /metrics server started (rebuild with --features metrics-http)",
);
}
Ok(())
}
#[cfg(feature = "metrics-http")]
fn publish_metrics<Snk: GraphSink>(
server: &OptMetricsServer,
listener: &Listener<FileSource, Snk>,
) {
if let Some(server) = server {
server.handle().publish(listener.metrics());
}
}
#[cfg(not(feature = "metrics-http"))]
fn publish_metrics<Snk: GraphSink>(
_server: &OptMetricsServer,
_listener: &Listener<FileSource, Snk>,
) {
}
fn gen_sample(
files: PathBuf,
table: String,
rows: usize,
chunk: String,
) -> Result<(), Box<dyn std::error::Error>> {
use arrow_array::{Float64Array, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use std::sync::Arc;
let ids: Vec<i64> = (0..rows as i64).collect();
let names: Vec<String> = (0..rows).map(|i| format!("person_{i}")).collect();
let scores: Vec<f64> = (0..rows).map(|i| i as f64 * 0.5).collect();
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
Field::new("score", DataType::Float64, false),
])),
vec![
Arc::new(Int64Array::from(ids)),
Arc::new(StringArray::from(names)),
Arc::new(Float64Array::from(scores)),
],
)?;
let dir = files.join(&table);
std::fs::create_dir_all(&dir)?;
let path = dir.join(&chunk);
graphar::io::write_chunk(
&path,
std::slice::from_ref(&batch),
&graphar::FileType::Parquet,
)?;
println!("wrote {rows} rows → {}", path.display());
Ok(())
}
async fn drive<Snk: GraphSink>(
mut listener: Listener<FileSource, Snk>,
once: bool,
interval: u64,
max_backoff: Option<u64>,
metrics_file: Option<PathBuf>,
metrics_server: &OptMetricsServer,
) -> Result<(), Box<dyn std::error::Error>> {
if once {
listener.sync_once().await?;
publish_metrics(metrics_server, &listener);
dump_metrics(&listener, &metrics_file)?;
return Ok(());
}
let interval = Duration::from_secs(interval);
let max = max_backoff
.map(Duration::from_secs)
.unwrap_or_else(|| interval.saturating_mul(10));
let mut backoff = Backoff::new(interval, max);
loop {
let report = listener.sync_once().await?;
publish_metrics(metrics_server, &listener);
let sleep = backoff.next_sleep(report.is_failure());
if sleep > backoff.interval() {
tracing::warn!(
target: "bifrost::round",
event = "backoff",
sleep_secs = sleep.as_secs(),
interval_secs = backoff.interval().as_secs(),
"round made no progress — backing off before retry",
);
}
tokio::select! {
_ = tokio::signal::ctrl_c() => {
tracing::info!(target: "bifrost", event = "shutdown", "graceful shutdown");
dump_metrics(&listener, &metrics_file)?;
return Ok(());
}
_ = tokio::time::sleep(sleep) => {}
}
}
}
fn dump_metrics<Snk: GraphSink>(
listener: &Listener<FileSource, Snk>,
path: &Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(path) = path {
std::fs::write(path, knut_bifrost::render_prometheus(listener.metrics()))?;
tracing::info!(
target: "metrics",
event = "metrics_written",
path = %path.display(),
"wrote Prometheus metrics",
);
}
Ok(())
}
#[derive(Default)]
struct NullSink;
#[async_trait]
impl GraphSink for NullSink {
async fn apply(&mut self, mutation: &GraphMutation) -> BifrostResult<SinkReport> {
let vertices: usize = mutation.vertices.iter().map(|v| v.batch.num_rows()).sum();
let edges: usize = mutation.edges.iter().map(|e| e.batch.num_rows()).sum();
let deleted: usize = mutation.deletes.iter().map(|d| d.batch.num_rows()).sum();
tracing::info!(
target: "bifrost::sink",
event = "dry_run_mutation",
vertices,
edges,
deleted,
"dry-run: counted graph mutation (not written)",
);
Ok(SinkReport {
vertices_written: vertices,
edges_written: edges,
deleted,
})
}
}