What happened
Fork a branch, insert the same edge on both sides (for example Knows Diana -> Alice on the branch and on main), merge back. The merge succeeds and the store now holds TWO physical rows for one logical relationship.
Mechanism, verified in source: the three-way streaming merge keys rows on the primary key id (stage_streaming_table_merge, crates/omnigraph/src/exec/merge.rs:2372 at ac620ee). Each insert mints a fresh ULID, so the two rows never meet in the cursor walk; each side reads as "born on one side only, take it", and MergeConflictKind::DivergentInsert (merge.rs:2700) only fires when the SAME id diverges. Nodes are protected: a Person inserted on both sides converges on its @key. Edges have no logical key, so the duplicate lands silently.
Why nobody sees it: plain traversal (match { $a: Person $a knows $b }) deduplicates in its visited gate and returns the pair once. The bound-edge pattern ($a $e:knows $b), counts, aggregates, and exports all see 2 rows. Nothing errors; results are silently wrong after any convergent-edit merge.
Expected: a convergent edge merges to one row, or the merge surfaces a conflict. Two stored rows for one logical relationship should not be reachable.
Steps to reproduce
Public API only. Reproduces identically on the local file:// backend and the in-memory object-store backend. The test uses the existing tests/helpers module and runs on local FS.
- Save the test below as crates/omnigraph/tests/merge_duplicate_edge_repro.rs.
mod helpers;
use omnigraph::db::Omnigraph;
use omnigraph::loader::{LoadMode, load_jsonl};
use helpers::*;
const TEST_SCHEMA: &str = "node Person {\n name: String @key\n age: I32?\n}\n\nedge Knows: Person -> Person\n";
const TEST_DATA: &str = r#"{"type": "Person", "data": {"name": "Alice", "age": 30}}
{"type": "Person", "data": {"name": "Bob", "age": 25}}
{"type": "Person", "data": {"name": "Charlie", "age": 35}}
{"type": "Person", "data": {"name": "Diana", "age": 28}}
{"edge": "Knows", "from": "Alice", "to": "Bob"}
{"edge": "Knows", "from": "Alice", "to": "Charlie"}
{"edge": "Knows", "from": "Bob", "to": "Diana"}
"#;
const QUERIES: &str = r#"
query add_friend($from: String, $to: String) {
insert Knows { from: $from, to: $to }
}
query all_knows() {
match {
$a: Person
$a knows $b
}
return { $a.name, $b.name }
}
query all_knows_bound() {
match {
$a: Person
$a $e:knows $b
}
return { $a.name, $b.name }
}
"#;
#[tokio::test]
async fn merge_duplicates_edge_added_on_both_sides() {
let dir = tempfile::tempdir().unwrap();
let mut db = Omnigraph::init(dir.path().to_str().unwrap(), TEST_SCHEMA)
.await
.expect("init");
load_jsonl(&db, TEST_DATA, LoadMode::Overwrite).await.expect("load");
db.branch_create("b0").await.expect("branch create");
db.mutate("b0", QUERIES, "add_friend",
&mixed_params(&[("$from", "Diana"), ("$to", "Alice")], &[]))
.await.expect("add on branch");
db.mutate("main", QUERIES, "add_friend",
&mixed_params(&[("$from", "Diana"), ("$to", "Alice")], &[]))
.await.expect("add on main");
Box::pin(db.branch_merge("b0", "main")).await.expect("merge accepts");
let gated = query_main(&mut db, QUERIES, "all_knows", &Default::default())
.await.expect("plain traversal");
let bound = query_main(&mut db, QUERIES, "all_knows_bound", &Default::default())
.await.expect("bound traversal");
println!("plain traversal rows: {}, bound rows: {}", gated.num_rows(), bound.num_rows());
assert_eq!(gated.num_rows(), 4);
assert_eq!(bound.num_rows(), 5, "4 logical edges stored as 5 rows: the duplicate");
}
-
Run: cargo test -p omnigraph-engine --test merge_duplicate_edge_repro -- --nocapture
-
Observed: merge succeeds; plain traversal returns 4 rows, bound-edge traversal returns 5, with Diana -> Alice twice. Expected: both return 4, or the merge reports a conflict. The trigger needs the edge added on BOTH sides after the fork; one side only merges correctly.
Version
omnigraph 0.10.0, built from source (Lance 10.0.0). Repro verified at 34341e9; source anchors re-verified at ac620ee. Re-found by the in-tree DST nightly: run #5, seed 217004, byte-identical on two deterministic arms, fault-free universe.
Environment
macOS (Darwin 25.5, arm64). Backend-independent, measured both ways (local file:// and in-memory object store).
Logs / output
plain traversal rows: 4, bound rows: 5
What happened
Fork a branch, insert the same edge on both sides (for example Knows Diana -> Alice on the branch and on main), merge back. The merge succeeds and the store now holds TWO physical rows for one logical relationship.
Mechanism, verified in source: the three-way streaming merge keys rows on the primary key id (stage_streaming_table_merge, crates/omnigraph/src/exec/merge.rs:2372 at ac620ee). Each insert mints a fresh ULID, so the two rows never meet in the cursor walk; each side reads as "born on one side only, take it", and MergeConflictKind::DivergentInsert (merge.rs:2700) only fires when the SAME id diverges. Nodes are protected: a Person inserted on both sides converges on its
@key. Edges have no logical key, so the duplicate lands silently.Why nobody sees it: plain traversal (match { $a: Person $a knows $b }) deduplicates in its visited gate and returns the pair once. The bound-edge pattern ($a $e:knows $b), counts, aggregates, and exports all see 2 rows. Nothing errors; results are silently wrong after any convergent-edit merge.
Expected: a convergent edge merges to one row, or the merge surfaces a conflict. Two stored rows for one logical relationship should not be reachable.
Steps to reproduce
Public API only. Reproduces identically on the local file:// backend and the in-memory object-store backend. The test uses the existing tests/helpers module and runs on local FS.
Run: cargo test -p omnigraph-engine --test merge_duplicate_edge_repro -- --nocapture
Observed: merge succeeds; plain traversal returns 4 rows, bound-edge traversal returns 5, with Diana -> Alice twice. Expected: both return 4, or the merge reports a conflict. The trigger needs the edge added on BOTH sides after the fork; one side only merges correctly.
Version
omnigraph 0.10.0, built from source (Lance 10.0.0). Repro verified at 34341e9; source anchors re-verified at ac620ee. Re-found by the in-tree DST nightly: run #5, seed 217004, byte-identical on two deterministic arms, fault-free universe.
Environment
macOS (Darwin 25.5, arm64). Backend-independent, measured both ways (local file:// and in-memory object store).
Logs / output