Skip to content

bug: merge duplicates an edge added on both sides of the fork, and plain traversal hides the extra row #583

Description

@azimafroozeh

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.

  1. 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");
}
  1. Run: cargo test -p omnigraph-engine --test merge_duplicate_edge_repro -- --nocapture

  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P-highHigh priorityacceptedTriaged and validated; open for a PRbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions