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:
- 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).
- 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/).
Summary
Two sequential
merge_insertoperations against the same target row (or set of rows) fail on the second call with: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
Workarounds that suppress the symptom
Either of these on the second
MergeInsertBuilderis sufficient (cross-validated downstream — each alone suppresses the bug; we ship both as defense in depth):Internal analysis (best guess; please correct)
Two Lance internals appear to be involved; we have not fully disambiguated which is causal:
create_indexed_scan_joined_stream(src/dataset/write/merge_insert.rs:616+) appears to UNION a BTREE-on-idlookup with a full scan of unindexed fragments. After the firstmerge_insertrewrites 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(sinceenable_stable_row_ids = true).HashSet.processed_row_ids: Mutex<HashSet<u64>>(src/dataset/write/merge_insert.rs:2099) reports the spurious duplicate. SettingSourceDedupeBehavior::FirstSeensilences it; settinguse_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
_rowids don't reach the join.SourceDedupeBehavior::FirstSeenfor datasets with stable row IDs enabled — the dedup is then a no-op invariant of the row-id machinery.Operation::Updatetransactions (it currently tracks compactionRewriteonly — https://lance.org/format/table/index/system/frag_reuse/).