use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use knut_bifrost::watch_cli::{LakeArgs, MappingArgs, TlsArgs, WatchArgs};
use knut_bifrost::{
Backoff, GraphMutation, Listener, Result as BifrostResult, SinkReport, SyncState,
TransformRegistry,
sink::{FalkorDbSink, GraphSink},
source::{FileSource, TableSource},
};
#[derive(Parser)]
#[command(
name = "bifrost",
about = "Drive the file → graph lifecycle with structured logs",
// `v<pkg> (<git-sha>)`, stamped pure-fs by build.rs on every build → clap
// prints `bifrost v<ver> (<hash>)` for `--version`.
version = knut_bifrost::VERSION_LINE
)]
struct Cli {
#[arg(long, global = true)]
json_logs: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Command {
Run {
#[command(flatten)]
mapping: MappingArgs,
#[arg(long)]
files: PathBuf,
#[arg(long, default_value = "redis://127.0.0.1:6379")]
falkor: String,
#[command(flatten)]
tls: TlsArgs,
#[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,
},
IcebergDemo {
#[arg(long, default_value = "sc://localhost:15012")]
url: String,
#[arg(long, default_value = knut_bifrost::spark_demo::DEMO_CATALOG)]
catalog: String,
#[arg(long, default_value = knut_bifrost::spark_demo::DEMO_NAMESPACE)]
namespace: String,
#[arg(long, default_value = knut_bifrost::spark_demo::DEMO_TABLE)]
table: String,
#[arg(long, default_value_t = knut_bifrost::spark_demo::DEMO_ROWS)]
rows: usize,
#[arg(long)]
plan_only: bool,
},
Watch(WatchArgs),
#[cfg(feature = "service")]
Doris {
#[command(subcommand)]
cmd: knut_bifrost::doris_cli::DorisCommand,
},
#[cfg(not(feature = "service"))]
Doris {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
rest: Vec<String>,
},
#[cfg(feature = "falkordb-service")]
Falkordb {
#[command(subcommand)]
cmd: knut_bifrost::falkordb_cli::FalkordbCommand,
},
#[cfg(not(feature = "falkordb-service"))]
Falkordb {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
rest: Vec<String>,
},
SelfTest,
}
fn main() -> ExitCode {
let cli = Cli::parse();
knut_tracing::init_tracing(cli.json_logs);
match run(cli.command) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(target: "bifrost", error = %e, "fatal");
ExitCode::FAILURE
}
}
}
fn run(cmd: Command) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
#[cfg(feature = "service")]
Command::Doris { cmd } => {
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
Ok(rt.block_on(knut_bifrost::doris_cli::dispatch(cmd))?)
}
#[cfg(not(feature = "service"))]
Command::Doris { .. } => Err("`bifrost doris` needs a build with `--features service`".into()),
#[cfg(feature = "falkordb-service")]
Command::Falkordb { cmd } => Ok(knut_bifrost::falkordb_cli::dispatch(cmd)?),
#[cfg(not(feature = "falkordb-service"))]
Command::Falkordb { .. } => Err("`bifrost falkordb` needs a build with `--features falkordb-service`".into()),
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::IcebergDemo {
url,
catalog,
namespace,
table,
rows,
plan_only,
} => iceberg_demo(url, catalog, namespace, table, rows, plan_only),
Command::Watch(w) => {
let falkor = w.falkor_url();
let tls = w.tls.identity()?;
watch_pipeline(
w.lake,
w.mapping,
falkor,
w.graph,
w.state,
w.once,
w.interval,
w.max_backoff,
w.metrics_file,
w.metrics_addr,
w.dry_run,
tls,
)
}
Command::Run {
mapping,
files,
falkor,
tls,
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,
tls.identity()?,
),
}
}
fn iceberg_demo(
url: String,
catalog: String,
namespace: String,
table: String,
rows: usize,
plan_only: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use knut_bifrost::spark_demo;
let plan = spark_demo::iceberg_demo_plan_for(&url, &catalog, &namespace, &table, rows);
tracing::info!(
target: "bifrost",
event = "iceberg_demo_plan",
url = %plan.url,
table = %plan.table,
rows = plan.rows,
spark_client_linked = spark_demo::spark_client_linked(),
"knut -> Spark 4.1 + Iceberg demo plan",
);
for (i, stmt) in plan.program().iter().enumerate() {
tracing::info!(target: "bifrost", event = "iceberg_demo_sql", step = i, sql = %stmt);
}
if plan_only {
return Ok(());
}
let landed = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(spark_demo::run_iceberg_demo(&plan))?;
tracing::info!(
target: "bifrost",
event = "iceberg_demo_complete",
table = %plan.table,
rows = landed,
"knut landed {landed} rows in {} (Iceberg 1.11.0 on Spark 4.1)",
plan.table,
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_pipeline(
mapping: MappingArgs,
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>,
tls: Option<graphar_falkordb::FalkorTls>,
) -> Result<(), Box<dyn std::error::Error>> {
let spec = mapping.load()?;
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_with_identity(&falkor, graph.clone(), tls.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 = "skade")]
#[allow(clippy::too_many_arguments)]
fn watch_pipeline(
lake: LakeArgs,
mapping: MappingArgs,
falkor: String,
graph: String,
state: Option<PathBuf>,
once: bool,
interval: u64,
max_backoff: Option<u64>,
metrics_file: Option<PathBuf>,
metrics_addr: Option<String>,
dry_run: bool,
tls: Option<graphar_falkordb::FalkorTls>,
) -> Result<(), Box<dyn std::error::Error>> {
use knut_bifrost::source::IcebergSource;
let spec = mapping.load()?;
let state_path = state.unwrap_or_else(|| lake.default_state());
let state_dir = state_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let lake = lake.lake(state_dir)?;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async move {
let lake_desc = lake.describe();
let source = IcebergSource::from_lake(lake);
let sync_state = SyncState::open(&state_path)?;
tracing::info!(
target: "bifrost",
event = "start",
lake = %lake_desc,
graph = %graph,
dry_run,
once,
source = "iceberg",
metrics_http = metrics_addr.is_some(),
"bifrost watch starting (Iceberg lake → graph)",
);
let metrics_server = start_metrics_server(metrics_addr.as_deref())?;
if dry_run {
let sink = NullSink;
let l = Listener::new(spec, TransformRegistry::new(), source, sink, sync_state);
drive(
l,
once,
interval,
max_backoff,
metrics_file,
&metrics_server,
)
.await
} else {
let sink = FalkorDbSink::connect_with_identity(&falkor, graph.clone(), tls.clone()).await?;
let l = Listener::new(spec, TransformRegistry::new(), source, sink, sync_state);
drive(
l,
once,
interval,
max_backoff,
metrics_file,
&metrics_server,
)
.await
}
})
}
#[cfg(not(feature = "skade"))]
#[allow(clippy::too_many_arguments)]
fn watch_pipeline(
_lake: LakeArgs,
_mapping: MappingArgs,
_falkor: String,
_graph: String,
_state: Option<PathBuf>,
_once: bool,
_interval: u64,
_max_backoff: Option<u64>,
_metrics_file: Option<PathBuf>,
_metrics_addr: Option<String>,
_dry_run: bool,
_tls: Option<graphar_falkordb::FalkorTls>,
) -> Result<(), Box<dyn std::error::Error>> {
Err(
"bifrost watch needs the `skade` feature (the Iceberg warehouse source); \
this build linked no IcebergSource, so it cannot stream a warehouse — \
rebuild with --features skade"
.into(),
)
}
#[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<Src: TableSource, Snk: GraphSink>(
server: &OptMetricsServer,
listener: &Listener<Src, Snk>,
) {
if let Some(server) = server {
server.handle().publish(listener.metrics());
}
}
#[cfg(not(feature = "metrics-http"))]
fn publish_metrics<Src: TableSource, Snk: GraphSink>(
_server: &OptMetricsServer,
_listener: &Listener<Src, 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<Src: TableSource, Snk: GraphSink>(
mut listener: Listener<Src, 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<Src: TableSource, Snk: GraphSink>(
listener: &Listener<Src, 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,
})
}
}