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,
mapping::SyncMode,
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 {
#[command(flatten)]
lake: LakeArgs,
#[command(flatten)]
mapping: MappingArgs,
#[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)]
once: bool,
#[arg(long, default_value_t = 15)]
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,
},
#[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>,
},
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
}
}
}
#[derive(clap::Args, Clone, Default)]
struct TlsArgs {
#[arg(long, value_name = "PEM", requires = "tls_key")]
tls_cert: Option<PathBuf>,
#[arg(long, value_name = "PEM", requires = "tls_cert")]
tls_key: Option<PathBuf>,
#[arg(long, value_name = "PEM")]
tls_ca: Option<PathBuf>,
}
impl TlsArgs {
fn identity(&self) -> Result<Option<graphar_falkordb::FalkorTls>, Box<dyn std::error::Error>> {
match (&self.tls_cert, &self.tls_key) {
(Some(c), Some(k)) => Ok(Some(graphar_falkordb::FalkorTls::from_files(c, k, self.tls_ca.as_deref())?)),
_ => Ok(None),
}
}
}
#[derive(clap::Args, Clone, Debug)]
struct MappingArgs {
#[arg(long)]
mapping: PathBuf,
#[arg(long, default_value = "cdc", value_parser = parse_mode)]
mode: SyncMode,
}
impl MappingArgs {
fn load(&self) -> Result<MappingSpec, Box<dyn std::error::Error>> {
Ok(knut_bifrost::mapping::load_mapping(&self.mapping, self.mode)?)
}
}
fn parse_mode(s: &str) -> Result<SyncMode, String> {
serde_yaml::from_str(s).map_err(|_| format!("{s:?}: one of full, incremental, cdc"))
}
#[derive(clap::Args, Clone, Debug)]
#[group(skip)]
struct LakeArgs {
#[arg(
long,
value_name = "DIR",
conflicts_with_all = ["s3_root", "rest_uri", "grpc_uri"]
)]
warehouse: Option<PathBuf>,
#[arg(long, value_name = "S3_URL", conflicts_with_all = ["rest_uri", "grpc_uri"])]
s3_root: Option<String>,
#[arg(long, value_name = "URL", conflicts_with = "grpc_uri")]
rest_uri: Option<String>,
#[arg(long, value_name = "URL")]
grpc_uri: Option<String>,
#[arg(long, value_name = "NS", requires = "grpc_uri")]
grpc_namespace: Option<String>,
#[arg(long, requires = "rest_uri")]
rest_warehouse: Option<String>,
#[arg(long, value_name = "NS", requires = "rest_uri")]
rest_namespace: Option<String>,
#[arg(long, value_name = "SERVICE", requires = "rest_uri")]
sigv4: Option<String>,
#[arg(long, value_name = "URL")]
s3_endpoint: Option<String>,
#[arg(long, default_value = "us-east-1")]
s3_region: String,
#[arg(long)]
s3_bucket: Option<String>,
#[arg(long, value_name = "PEM")]
s3_ca: Option<PathBuf>,
}
impl LakeArgs {
#[cfg(feature = "skade")]
fn default_state(&self) -> PathBuf {
match &self.warehouse {
Some(dir) => dir.join(".bifrost-state.json"),
None => PathBuf::from(".bifrost-state.json"),
}
}
#[cfg(feature = "skade")]
#[cfg_attr(not(feature = "skade-s3"), allow(unused_variables))]
fn lake(&self, state_dir: &std::path::Path) -> Result<knut_bifrost::delivery::IcebergLake, Box<dyn std::error::Error>> {
use knut_bifrost::delivery::IcebergLake;
if let Some(dir) = &self.warehouse {
return Ok(IcebergLake::Warehouse(dir.clone()));
}
#[cfg(feature = "skade-s3")]
let s3 = |root_bucket: Option<&str>| -> Result<skade::S3Config, Box<dyn std::error::Error>> {
use knut_bifrost::delivery::{s3_ca, s3_config};
let endpoint = self.s3_endpoint.clone().ok_or("--s3-endpoint is required with --s3-root / --rest-uri")?;
let bucket = self
.s3_bucket
.clone()
.or_else(|| root_bucket.map(str::to_string))
.or_else(|| self.rest_warehouse.clone())
.ok_or("--s3-bucket is required (no bucket in --s3-root or --rest-warehouse)")?;
let ca = s3_ca(self.s3_ca.as_deref(), state_dir)?;
Ok(s3_config(endpoint, self.s3_region.clone(), bucket, ca)?)
};
if let Some(root) = &self.s3_root {
#[cfg(feature = "skade-s3")]
{
let bucket = knut_bifrost::delivery::bucket_of(root).ok_or("--s3-root must be an s3://bucket/… URL")?;
return Ok(IcebergLake::S3 { s3: s3(Some(bucket))?, root: root.clone() });
}
#[cfg(not(feature = "skade-s3"))]
return Err(format!("--s3-root {root}: this build has no S3 reader - rebuild with --features skade-s3").into());
}
if let Some(uri) = &self.rest_uri {
#[cfg(feature = "skade-rest")]
{
let s3 = s3(None)?;
let catalog = skade::connect::IcebergRest {
uri: uri.clone(),
warehouse: self.rest_warehouse.clone(),
prefix_or_ref: None,
token: None,
tls: s3.tls.clone(),
s3,
read_only: true,
sigv4: self.sigv4.clone().map(|service| skade::connect::SigV4 { service }),
};
return Ok(IcebergLake::Catalog { namespace: self.rest_namespace.clone(), connector: std::sync::Arc::new(catalog) });
}
#[cfg(not(feature = "skade-rest"))]
return Err(format!("--rest-uri {uri}: this build has no REST catalog client - rebuild with --features skade-rest").into());
}
if let Some(uri) = &self.grpc_uri {
#[cfg(feature = "skade-grpc")]
{
let s3 = match &self.s3_endpoint {
Some(_) => Some(s3(None)?),
None => None,
};
let catalog = skade::connect::SkadeGrpc { uri: uri.clone(), s3, read_only: true };
return Ok(IcebergLake::Catalog { namespace: self.grpc_namespace.clone(), connector: std::sync::Arc::new(catalog) });
}
#[cfg(not(feature = "skade-grpc"))]
return Err(format!("--grpc-uri {uri}: this build has no skade-katalog gRPC client - rebuild with --features skade-grpc").into());
}
Err("name the lake: --warehouse DIR, --s3-root s3://…, --rest-uri URL, or --grpc-uri URL".into())
}
}
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()),
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 {
lake,
mapping,
falkor,
tls,
graph,
state,
once,
interval,
max_backoff,
metrics_file,
metrics_addr,
dry_run,
} => watch_pipeline(
lake,
mapping,
falkor,
graph,
state,
once,
interval,
max_backoff,
metrics_file,
metrics_addr,
dry_run,
tls.identity()?,
),
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,
})
}
}