Skip to content

MergeInsertBuilder produces spurious "Ambiguous merge inserts" against rows previously rewritten by merge_insert #6877

Description

@ragnorc

Summary

Two sequential merge_insert operations against the same target row (or set of rows) fail on the second call with:

Invalid user input: Ambiguous merge inserts are prohibited:
multiple source rows match the same target row on (id = "<key>")

even when the source batch has exactly one row per <key>. Reproduces on Lance 4.0.0 and 4.0.1 (byte-identical in this code path).

Minimal repro

use arrow_array::{RecordBatch, StringArray, Int32Array};
use arrow_schema::{DataType, Field, Schema};
use lance::dataset::{
    Dataset, WriteParams, WriteMode,
    MergeInsertBuilder, WhenMatched, WhenNotMatched,
};
use lance_index::{DatasetIndexExt, IndexType};
use lance_index::scalar::ScalarIndexParams;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let schema = Arc::new(Schema::new(vec![
        Field::new("id", DataType::Utf8, false),
        Field::new("value", DataType::Int32, false),
    ]));

    let initial = RecordBatch::try_new(schema.clone(), vec![
        Arc::new(StringArray::from(vec!["A", "B", "C"])),
        Arc::new(Int32Array::from(vec![1, 2, 3])),
    ])?;

    let uri = "memory://test"; // or a tmpdir path
    let mut ds = Dataset::write(
        Box::new(arrow_array::RecordBatchIterator::new(
            vec![Ok(initial)], schema.clone()
        )),
        uri,
        Some(WriteParams {
            mode: WriteMode::Overwrite,
            enable_stable_row_ids: true,
            ..Default::default()
        }),
    ).await?;

    // Build BTREE on \`id\` (mirrors what downstreams do on every commit).
    ds.create_index(
        &["id"],
        IndexType::Scalar,
        None,
        &ScalarIndexParams::default(),
        false,
    ).await?;

    // First merge_insert: update A's value from 1 to 11.
    let update_a = RecordBatch::try_new(schema.clone(), vec![
        Arc::new(StringArray::from(vec!["A"])),
        Arc::new(Int32Array::from(vec![11])),
    ])?;

    let mut builder = MergeInsertBuilder::try_new(
        Arc::new(ds.clone()),
        vec!["id".to_string()],
    )?;
    builder.when_matched(WhenMatched::UpdateAll);
    builder.when_not_matched(WhenNotMatched::DoNothing);
    let job = builder.try_build()?;
    let reader = arrow_array::RecordBatchIterator::new(
        vec![Ok(update_a.clone())], schema.clone(),
    );
    let (ds, _) = job.execute(
        lance_datafusion::utils::reader_to_stream(Box::new(reader))
    ).await?;
    // OK so far.

    // Second merge_insert: update A's value from 11 to 22.
    let update_a_again = RecordBatch::try_new(schema.clone(), vec![
        Arc::new(StringArray::from(vec!["A"])),
        Arc::new(Int32Array::from(vec![22])),
    ])?;

    let mut builder = MergeInsertBuilder::try_new(
        Arc::new(ds.clone()),
        vec!["id".to_string()],
    )?;
    builder.when_matched(WhenMatched::UpdateAll);
    builder.when_not_matched(WhenNotMatched::DoNothing);
    let job = builder.try_build()?;
    let reader = arrow_array::RecordBatchIterator::new(
        vec![Ok(update_a_again)], schema,
    );
    let (_, _) = job.execute(
        lance_datafusion::utils::reader_to_stream(Box::new(reader))
    ).await?;
    // Errors here:
    // "Ambiguous merge inserts are prohibited: multiple source rows
    //  match the same target row on (id = \"A\")"

    Ok(())
}

Workarounds that suppress the symptom

Either of these on the second MergeInsertBuilder is sufficient (cross-validated downstream — each alone suppresses the bug; we ship both as defense in depth):

builder.use_index(false);
// OR
builder.source_dedupe_behavior(SourceDedupeBehavior::FirstSeen);

Internal analysis (best guess; please correct)

Two Lance internals appear to be involved; we have not fully disambiguated which is causal:

  1. Indexed-scan join path. create_indexed_scan_joined_stream (src/dataset/write/merge_insert.rs:616+) appears to UNION a BTREE-on-id lookup with a full scan of unindexed fragments. After the first merge_insert rewrites the matched row, the BTREE's entry still points at the now-tombstoned slot in the original fragment, while the rewritten row lives in a new (unindexed) fragment. Both sides of the UNION surface the same stable _rowid (since enable_stable_row_ids = true).
  2. Source-dedup HashSet. processed_row_ids: Mutex<HashSet<u64>> (src/dataset/write/merge_insert.rs:2099) reports the spurious duplicate. Setting SourceDedupeBehavior::FirstSeen silences it; setting use_index(false) (which presumably bypasses the UNION entirely) also silences it.

These could be one bug (UNION causes the dedup HashSet to see duplicates) or two interacting bugs. We have not traced the actual data flow.

Possible fixes

  • DV-filter the indexed-scan side of the UNION so tombstoned _rowids don't reach the join.
  • Default SourceDedupeBehavior::FirstSeen for datasets with stable row IDs enabled — the dedup is then a no-op invariant of the row-id machinery.
  • Extend Fragment Reuse Index (FRI) coverage to Operation::Update transactions (it currently tracks compaction Rewrite only — https://lance.org/format/table/index/system/frag_reuse/).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions