use std::collections::HashSet;
use arrow_array::{Array, RecordBatch};
use async_trait::async_trait;
use graphar_falkordb::{
BulkEdgeGroup, BulkNodeGroup, FalkorDbLoader,
cypher::{arrow_value_to_cypher, row_to_cypher_map},
loader::choose_bulk,
};
use crate::{
error::{BifrostError, Result},
mutation::{EdgeSet, GraphMutation, SinkReport, VertexDelete, VertexSet},
};
#[async_trait]
pub trait GraphSink: Send {
async fn apply(&mut self, mutation: &GraphMutation) -> Result<SinkReport>;
}
pub struct FalkorDbSink {
loader: FalkorDbLoader,
batch_size: usize,
}
impl FalkorDbSink {
pub async fn connect(url: &str, graph: impl Into<String>) -> Result<Self> {
Self::connect_with_identity(url, graph, None).await
}
pub async fn connect_with_identity(
url: &str,
graph: impl Into<String>,
tls: Option<graphar_falkordb::FalkorTls>,
) -> Result<Self> {
Ok(Self {
loader: FalkorDbLoader::connect_with_identity(url, graph, tls).await?,
batch_size: graphar_falkordb::DEFAULT_BATCH_SIZE,
})
}
pub fn with_batch_size(mut self, n: usize) -> Self {
self.batch_size = n.max(1);
self
}
async fn upsert_vertices(&mut self, set: &VertexSet) -> Result<usize> {
let rows = rows_as_cypher_maps(&set.batch)?;
let mut written = 0;
for chunk in rows.chunks(self.batch_size) {
let query = format!(
"UNWIND [{rows}] AS r MERGE (n:`{label}` {{`{id}`: r.`{id}`}}) SET n += r",
rows = chunk.join(", "),
label = set.label,
id = set.id_column,
);
self.loader.run_query(&query).await?;
written += chunk.len();
}
Ok(written)
}
async fn upsert_edges(&mut self, set: &EdgeSet) -> Result<usize> {
let rows = rows_as_cypher_maps(&set.batch)?;
let mut written = 0;
for chunk in rows.chunks(self.batch_size) {
self.loader
.run_query(&edge_upsert_query(set, chunk))
.await?;
written += chunk.len();
}
Ok(written)
}
async fn delete_vertices(
&mut self,
set: &VertexDelete,
upserted: &HashSet<(String, String)>,
) -> Result<usize> {
let ids = surviving_delete_ids(set, upserted)?;
let mut deleted = 0;
for chunk in ids.chunks(self.batch_size) {
self.loader
.run_query(&detach_delete_query(&set.label, &set.id_column, chunk))
.await?;
deleted += chunk.len();
}
Ok(deleted)
}
async fn labels_all_empty(&mut self, sets: &[VertexSet]) -> bool {
for set in sets {
if !self.loader.label_is_empty(&set.label).await {
return false;
}
}
true
}
async fn bulk_load_vertices(&mut self, sets: &[VertexSet]) -> Result<usize> {
let node_groups: Vec<BulkNodeGroup> = sets
.iter()
.map(|set| BulkNodeGroup {
label: &set.label,
batches: std::slice::from_ref(&set.batch),
})
.collect();
let edge_groups: [BulkEdgeGroup; 0] = [];
let (nodes, _relations) = self
.loader
.bulk_insert_streamed(&node_groups, &edge_groups)
.await?;
Ok(nodes)
}
}
#[async_trait]
impl GraphSink for FalkorDbSink {
async fn apply(&mut self, mutation: &GraphMutation) -> Result<SinkReport> {
let mut report = SinkReport::default();
if bulk_lane_eligible(mutation)
&& choose_bulk(self.labels_all_empty(&mutation.vertices).await)
{
let n = self.bulk_load_vertices(&mutation.vertices).await?;
report.vertices_written += n;
tracing::info!(
target: "bifrost::sink",
event = "vertices_bulk_loaded",
count = n,
"GRAPH.BULK vertices (empty-graph fast lane)",
);
tracing::info!(
target: "bifrost::sink",
event = "sink_applied",
vertices = report.vertices_written,
edges = 0,
deleted = 0,
"graph mutation applied",
);
return Ok(report);
}
let upserted = upserted_vertex_keys(mutation)?;
for set in &mutation.vertices {
let n = self.upsert_vertices(set).await?;
tracing::debug!(
target: "bifrost::sink",
event = "vertices_upserted",
label = %set.label,
count = n,
"MERGE vertices",
);
report.vertices_written += n;
}
for set in &mutation.edges {
let n = self.upsert_edges(set).await?;
tracing::debug!(
target: "bifrost::sink",
event = "edges_upserted",
edge_type = %set.edge_type,
count = n,
"MERGE edges",
);
report.edges_written += n;
}
for set in &mutation.deletes {
let n = self.delete_vertices(set, &upserted).await?;
tracing::debug!(
target: "bifrost::sink",
event = "vertices_deleted",
label = %set.label,
count = n,
"DETACH DELETE vertices",
);
report.deleted += n;
}
tracing::info!(
target: "bifrost::sink",
event = "sink_applied",
vertices = report.vertices_written,
edges = report.edges_written,
deleted = report.deleted,
"graph mutation applied",
);
Ok(report)
}
}
fn edge_upsert_query(set: &EdgeSet, rows: &[String]) -> String {
let rel_key = if set.edge_key_props.is_empty() {
String::new()
} else {
let parts: Vec<String> = set
.edge_key_props
.iter()
.map(|p| format!("`{p}`: r.props.`{p}`"))
.collect();
format!(" {{{}}}", parts.join(", "))
};
format!(
"UNWIND [{rows}] AS r \
MERGE (a:`{src_label}` {{`{src_id}`: r.src}}) \
MERGE (b:`{dst_label}` {{`{dst_id}`: r.dst}}) \
MERGE (a)-[e:`{etype}`{rel_key}]->(b) \
SET e += r.props",
rows = rows.join(", "),
src_label = set.src_label,
src_id = set.src_id_column,
dst_label = set.dst_label,
dst_id = set.dst_id_column,
etype = set.edge_type,
)
}
fn detach_delete_query(label: &str, id_column: &str, ids: &[String]) -> String {
format!(
"UNWIND [{ids}] AS r \
MATCH (n:`{label}` {{`{id}`: r}}) DETACH DELETE n",
ids = ids.join(", "),
id = id_column,
)
}
fn delete_ids_as_cypher(set: &VertexDelete) -> Result<Vec<String>> {
let schema = set.batch.schema();
let idx = schema
.index_of(&set.id_column)
.map_err(|_| BifrostError::ColumnNotFound(set.id_column.clone()))?;
let col = set.batch.column(idx).as_ref();
let mut ids = Vec::with_capacity(set.batch.num_rows());
for row in 0..set.batch.num_rows() {
ids.push(arrow_value_to_cypher(col, row).map_err(BifrostError::Falkor)?);
}
Ok(ids)
}
fn upserted_vertex_keys(mutation: &GraphMutation) -> Result<HashSet<(String, String)>> {
let mut keys = HashSet::new();
for set in &mutation.vertices {
let schema = set.batch.schema();
let Ok(idx) = schema.index_of(&set.id_column) else {
continue;
};
let col = set.batch.column(idx).as_ref();
for row in 0..set.batch.num_rows() {
let lit = arrow_value_to_cypher(col, row).map_err(BifrostError::Falkor)?;
keys.insert((set.label.clone(), lit));
}
}
Ok(keys)
}
fn surviving_delete_ids(
set: &VertexDelete,
upserted: &HashSet<(String, String)>,
) -> Result<Vec<String>> {
Ok(delete_ids_as_cypher(set)?
.into_iter()
.filter(|id| !upserted.contains(&(set.label.clone(), id.clone())))
.collect())
}
fn bulk_lane_eligible(mutation: &GraphMutation) -> bool {
if mutation.vertices.is_empty() || !mutation.edges.is_empty() || !mutation.deletes.is_empty() {
return false;
}
let mut labels = HashSet::new();
for set in &mutation.vertices {
if set.batch.schema_ref().column_with_name("_gar_id").is_none() {
return false;
}
if !labels.insert(set.label.as_str()) {
return false;
}
}
true
}
pub(crate) fn rows_as_cypher_maps(batch: &RecordBatch) -> Result<Vec<String>> {
let schema = batch.schema();
let edge_endpoints = match (schema.index_of("src"), schema.index_of("dst")) {
(Ok(s), Ok(d)) => Some((s, d)),
_ => None,
};
let cols: Vec<(&str, &dyn Array)> = schema
.fields()
.iter()
.zip(batch.columns())
.map(|(f, c)| (f.name().as_str(), c.as_ref()))
.collect();
let mut rows = Vec::with_capacity(batch.num_rows());
for row in 0..batch.num_rows() {
if let Some((src_idx, dst_idx)) = edge_endpoints {
if batch.column(src_idx).is_null(row) || batch.column(dst_idx).is_null(row) {
continue;
}
let mut src = None;
let mut dst = None;
let mut props = Vec::new();
for (name, array) in &cols {
let val = arrow_value_to_cypher(*array, row).map_err(BifrostError::Falkor)?;
match *name {
"src" => src = Some(val),
"dst" => dst = Some(val),
_ => props.push(format!("`{name}`: {val}")),
}
}
rows.push(format!(
"{{src: {}, dst: {}, props: {{{}}}}}",
src.expect("src checked above"),
dst.expect("dst checked above"),
props.join(", "),
));
} else {
rows.push(row_to_cypher_map(&cols, row).map_err(BifrostError::Falkor)?);
}
}
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use arrow_array::{Int64Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
use std::sync::Arc;
#[test]
fn vertex_rows_render_as_flat_maps() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![7])),
Arc::new(StringArray::from(vec!["alice"])),
],
)
.unwrap();
let rows = rows_as_cypher_maps(&batch).unwrap();
assert_eq!(rows, vec!["{`user_id`: 7, `name`: 'alice'}"]);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::vertex_rows_render_as_flat_maps",
rows == vec!["{`user_id`: 7, `name`: 'alice'}"],
&format!("cypher={}", rows[0]),
);
}
#[test]
fn edge_rows_separate_endpoints_from_props() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, false),
Field::new("since", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1])),
Arc::new(Int64Array::from(vec![2])),
Arc::new(StringArray::from(vec!["2024"])),
],
)
.unwrap();
let rows = rows_as_cypher_maps(&batch).unwrap();
assert_eq!(rows, vec!["{src: 1, dst: 2, props: {`since`: '2024'}}"]);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::edge_rows_separate_endpoints_from_props",
rows == vec!["{src: 1, dst: 2, props: {`since`: '2024'}}"],
&format!("cypher={}", rows[0]),
);
}
#[test]
fn edge_rows_with_null_endpoint_are_dropped() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, true), Field::new("since", DataType::Utf8, true),
])),
vec![
Arc::new(Int64Array::from(vec![1, 2, 3])),
Arc::new(Int64Array::from(vec![Some(10), None, Some(30)])),
Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])),
],
)
.unwrap();
let rows = rows_as_cypher_maps(&batch).unwrap();
assert_eq!(
rows,
vec![
"{src: 1, dst: 10, props: {`since`: 'a'}}",
"{src: 3, dst: 30, props: {`since`: 'c'}}",
],
"null-endpoint edge row dropped (null FK = no edge)"
);
let src_null = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, true),
Field::new("dst", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(vec![None, Some(5)])),
Arc::new(Int64Array::from(vec![9, 6])),
],
)
.unwrap();
assert_eq!(
rows_as_cypher_maps(&src_null).unwrap(),
vec!["{src: 5, dst: 6, props: {}}"],
"null-src edge row dropped too"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::edge_rows_with_null_endpoint_are_dropped",
rows.len() == 2,
&format!("kept={} of 3 edge rows (null FK = no edge)", rows.len()),
);
}
#[test]
fn detach_delete_query_matches_on_id_and_detaches() {
let q = detach_delete_query("Person", "user_id", &["7".into(), "8".into()]);
assert_eq!(
q,
"UNWIND [7, 8] AS r MATCH (n:`Person` {`user_id`: r}) DETACH DELETE n"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::detach_delete_query_matches_on_id_and_detaches",
q == "UNWIND [7, 8] AS r MATCH (n:`Person` {`user_id`: r}) DETACH DELETE n",
&format!("query={q}"),
);
}
fn eset(edge_key_props: Vec<String>) -> EdgeSet {
EdgeSet {
src_label: "User".into(),
edge_type: "WORKS_AT".into(),
dst_label: "Company".into(),
src_id_column: "user_id".into(),
dst_id_column: "company_id".into(),
edge_key_props,
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(vec![1])),
Arc::new(Int64Array::from(vec![2])),
],
)
.unwrap(),
}
}
#[test]
fn edge_merge_key_controls_parallel_edges() {
let plain = edge_upsert_query(&eset(vec![]), &["{src: 1, dst: 2, props: {}}".into()]);
assert_eq!(
plain,
"UNWIND [{src: 1, dst: 2, props: {}}] AS r \
MERGE (a:`User` {`user_id`: r.src}) \
MERGE (b:`Company` {`company_id`: r.dst}) \
MERGE (a)-[e:`WORKS_AT`]->(b) \
SET e += r.props",
"no merge-key props ⇒ byte-identical to the historical collapsing MERGE",
);
let keyed = edge_upsert_query(
&eset(vec!["since".into(), "role".into()]),
&["{src: 1, dst: 2, props: {`since`: '2021', `role`: 'eng'}}".into()],
);
assert!(
keyed.contains(
"MERGE (a)-[e:`WORKS_AT` {`since`: r.props.`since`, `role`: r.props.`role`}]->(b)"
),
"merge-key props fold into the relationship pattern: {keyed}"
);
assert!(
keyed.ends_with("SET e += r.props"),
"props still written: {keyed}"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::edge_merge_key_controls_parallel_edges",
plain.contains("-[e:`WORKS_AT`]->")
&& keyed.contains(
"-[e:`WORKS_AT` {`since`: r.props.`since`, `role`: r.props.`role`}]->",
),
"empty ⇒ collapse; keyed ⇒ parallel-edge merge key",
);
}
#[test]
fn delete_ids_read_only_the_id_column() {
let set = VertexDelete {
label: "Person".into(),
id_column: "user_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![7, 9])),
Arc::new(StringArray::from(vec!["stale", "gone"])),
],
)
.unwrap(),
};
let ids = delete_ids_as_cypher(&set).unwrap();
assert_eq!(ids, vec!["7", "9"]);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::delete_ids_read_only_the_id_column",
ids == vec!["7", "9"],
&format!("ids={ids:?} (stale props ignored)"),
);
}
fn vset(label: &str, with_gar: bool) -> VertexSet {
let (schema, cols): (Schema, Vec<Arc<dyn Array>>) = if with_gar {
(
Schema::new(vec![
Field::new("_gar_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
]),
vec![
Arc::new(Int64Array::from(vec![0, 1])),
Arc::new(StringArray::from(vec!["a", "b"])),
],
)
} else {
(
Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
]),
vec![
Arc::new(Int64Array::from(vec![0, 1])),
Arc::new(StringArray::from(vec!["a", "b"])),
],
)
};
VertexSet {
label: label.into(),
id_column: if with_gar {
"_gar_id".into()
} else {
"user_id".into()
},
batch: RecordBatch::try_new(Arc::new(schema), cols).unwrap(),
}
}
#[test]
fn bulk_lane_eligibility_predicate() {
let ok = GraphMutation {
vertices: vec![vset("Person", true), vset("Loan", true)],
edges: vec![],
deletes: vec![],
};
assert!(
bulk_lane_eligible(&ok),
"the demo's vertex-only `_gar_id` load qualifies"
);
assert!(!bulk_lane_eligible(&GraphMutation::default()));
let no_gar = GraphMutation {
vertices: vec![vset("Person", false)],
edges: vec![],
deletes: vec![],
};
assert!(
!bulk_lane_eligible(&no_gar),
"missing `_gar_id` → MERGE (byte-identity)"
);
let dup = GraphMutation {
vertices: vec![vset("Person", true), vset("Person", true)],
edges: vec![],
deletes: vec![],
};
assert!(!bulk_lane_eligible(&dup), "duplicate label → MERGE");
let with_edge = GraphMutation {
vertices: vec![vset("Person", true)],
edges: vec![EdgeSet {
src_label: "Person".into(),
edge_type: "KNOWS".into(),
dst_label: "Person".into(),
src_id_column: "_gar_id".into(),
dst_id_column: "_gar_id".into(),
edge_key_props: vec![],
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(vec![0])),
Arc::new(Int64Array::from(vec![1])),
],
)
.unwrap(),
}],
deletes: vec![],
};
assert!(!bulk_lane_eligible(&with_edge), "edges present → MERGE");
let with_delete = GraphMutation {
vertices: vec![vset("Person", true)],
edges: vec![],
deletes: vec![VertexDelete {
label: "Person".into(),
id_column: "_gar_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"_gar_id",
DataType::Int64,
false,
)])),
vec![Arc::new(Int64Array::from(vec![0]))],
)
.unwrap(),
}],
};
assert!(!bulk_lane_eligible(&with_delete), "deletes present → MERGE");
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::bulk_lane_eligibility_predicate",
bulk_lane_eligible(&ok)
&& !bulk_lane_eligible(&no_gar)
&& !bulk_lane_eligible(&dup)
&& !bulk_lane_eligible(&with_edge)
&& !bulk_lane_eligible(&with_delete),
"vertex-only + `_gar_id` + unique labels ⇒ bulk; else MERGE",
);
}
#[test]
fn in_place_update_does_not_delete_the_upserted_node() {
let upsert = VertexSet {
label: "Person".into(),
id_column: "user_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![7])),
Arc::new(StringArray::from(vec!["new-name"])),
],
)
.unwrap(),
};
let delete = VertexDelete {
label: "Person".into(),
id_column: "user_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"user_id",
DataType::Int64,
false,
)])),
vec![Arc::new(Int64Array::from(vec![7, 9]))],
)
.unwrap(),
};
let mutation = GraphMutation {
vertices: vec![upsert],
edges: vec![],
deletes: vec![delete.clone()],
};
let upserted = upserted_vertex_keys(&mutation).unwrap();
let surviving = surviving_delete_ids(&delete, &upserted).unwrap();
let pass = surviving == vec!["9"];
assert!(pass, "expected only id 9 to be deleted, got {surviving:?}");
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::in_place_update_does_not_delete_the_upserted_node",
pass,
&format!("surviving_deletes={surviving:?} (id 7 upsert wins over delete)"),
);
}
}