Skip to content

Commit c24fe74

Browse files
ragnorcclaude
andcommitted
fix(index): enforce CreateIndex apply-time invariants and symmetric name conflicts
- validate CreateIndex at manifest build time: indexed fields must still exist in the schema (closes the alter_columns type-cast gap that the pairwise Project check could not see, since a cast commits Merge with a new field id) and non-system removed segments must still be present (closes the mirror race where a drop racing a same-name replacement silently no-ops) - collapse the frag-reuse/MemWAL/regular-name conflict booleans into one symmetric name-intersection rule covering both directions - regression tests: staged commit after an alter_columns cast fails as incompatible; drop_index racing a replacement fails retryably and succeeds on retry; resolver unit test covers all conflict directions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 47c621f commit c24fe74

3 files changed

Lines changed: 258 additions & 74 deletions

File tree

rust/lance/src/dataset/transaction.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2131,6 +2131,53 @@ impl Transaction {
21312131
removed_indices,
21322132
} => {
21332133
final_fragments.extend(maybe_existing_fragments?.clone());
2134+
2135+
// The transaction may have been staged against an older version,
2136+
// and the pairwise conflict checks cannot enumerate every
2137+
// hazardous concurrent operation. Enforce the invariants that
2138+
// make it safe to apply here, where the final manifest is known:
2139+
// every indexed field must still exist in the schema (a
2140+
// concurrent projection or column cast drops or re-ids fields),
2141+
// and every non-system segment staged for removal must still be
2142+
// present (a concurrent drop or replacement would otherwise be
2143+
// silently undone). System-index removals are reconciled during
2144+
// conflict rebasing and are exempt.
2145+
let field_ids = schema
2146+
.fields_pre_order()
2147+
.map(|f| f.id)
2148+
.collect::<HashSet<_>>();
2149+
for new_index in new_indices {
2150+
if let Some(field_id) = new_index
2151+
.fields
2152+
.iter()
2153+
.find(|field_id| !field_ids.contains(*field_id))
2154+
{
2155+
return Err(Error::incompatible_transaction_source(
2156+
format!(
2157+
"CreateIndex: field {} covered by index '{}' no longer \
2158+
exists in the schema",
2159+
field_id, new_index.name
2160+
)
2161+
.into(),
2162+
));
2163+
}
2164+
}
2165+
if let Some(missing) = removed_indices.iter().find(|removed| {
2166+
!is_system_index(removed)
2167+
&& !final_indices.iter().any(|idx| idx.uuid == removed.uuid)
2168+
}) {
2169+
return Err(Error::retryable_commit_conflict_source(
2170+
current_manifest.map(|m| m.version).unwrap_or_default(),
2171+
format!(
2172+
"CreateIndex: segment {} of index '{}' staged for removal was \
2173+
removed by a concurrent transaction; please re-stage against \
2174+
the latest version",
2175+
missing.uuid, missing.name
2176+
)
2177+
.into(),
2178+
));
2179+
}
2180+
21342181
let removed_uuids = removed_indices
21352182
.iter()
21362183
.map(|old_index| old_index.uuid)

rust/lance/src/index.rs

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,11 +1119,13 @@ impl Dataset {
11191119
/// The transaction is a snapshot built against the current dataset version.
11201120
/// Conflicting concurrent changes are rejected at commit time: creating or
11211121
/// dropping a same-name index between staging and commit fails the commit
1122-
/// with a retryable conflict, and dropping the indexed column fails it as
1123-
/// incompatible. A concurrent compaction/rewrite that defers index
1124-
/// remapping is not conflict-checked; the committed segments may then
1125-
/// cover already-compacted fragments until the deferred remap catches up,
1126-
/// so commit promptly.
1122+
/// with a retryable conflict, and a schema change that drops or re-ids the
1123+
/// indexed column (e.g. [`Dataset::drop_columns`] or an
1124+
/// [`Dataset::alter_columns`] type cast) fails it as incompatible. A
1125+
/// concurrent compaction/rewrite that defers index remapping is not
1126+
/// conflict-checked; the committed segments may then cover
1127+
/// already-compacted fragments until the deferred remap catches up, so
1128+
/// commit promptly.
11271129
///
11281130
/// # Side effects
11291131
///
@@ -7663,6 +7665,145 @@ mod tests {
76637665
);
76647666
}
76657667

7668+
#[tokio::test]
7669+
async fn test_build_existing_index_segments_transaction_conflicts_with_column_cast() {
7670+
use crate::dataset::{ColumnAlteration, CommitBuilder};
7671+
use lance_datagen::{BatchCount, RowCount, array};
7672+
7673+
let test_dir = tempfile::tempdir().unwrap();
7674+
let reader = lance_datagen::gen_batch()
7675+
.col("id", array::step::<arrow_array::types::Int32Type>())
7676+
.col(
7677+
"vector",
7678+
array::rand_vec::<arrow_array::types::Float32Type>(8.into()),
7679+
)
7680+
.into_reader_rows(RowCount::from(10), BatchCount::from(1));
7681+
let mut dataset = Dataset::write(reader, test_dir.path().to_str().unwrap(), None)
7682+
.await
7683+
.unwrap();
7684+
let field_id = dataset.schema().field("id").unwrap().id;
7685+
7686+
let seg = write_vector_segment_metadata(
7687+
&dataset,
7688+
"id_idx",
7689+
field_id,
7690+
Uuid::new_v4(),
7691+
[0_u32],
7692+
b"seg",
7693+
)
7694+
.await;
7695+
let transaction = dataset
7696+
.build_existing_index_segments_transaction(
7697+
"id_idx",
7698+
"id",
7699+
vec![segment_from_metadata(&seg)],
7700+
)
7701+
.await
7702+
.unwrap();
7703+
7704+
// A type cast commits Operation::Merge and assigns the column a new
7705+
// field id, so the staged transaction's field no longer exists.
7706+
dataset
7707+
.alter_columns(&[
7708+
ColumnAlteration::new("id".into()).cast_to(arrow_schema::DataType::Int64)
7709+
])
7710+
.await
7711+
.unwrap();
7712+
assert!(dataset.schema().field_by_id(field_id).is_none());
7713+
7714+
let err = CommitBuilder::new(Arc::new(dataset))
7715+
.execute(transaction)
7716+
.await
7717+
.expect_err("staged index commit for a re-identified column must fail");
7718+
assert!(
7719+
matches!(err, Error::IncompatibleTransaction { .. }),
7720+
"expected an incompatible transaction error, got: {err}"
7721+
);
7722+
assert!(
7723+
err.to_string().contains("no longer exists in the schema"),
7724+
"conflict message must identify the missing field, got: {err}"
7725+
);
7726+
}
7727+
7728+
#[tokio::test]
7729+
async fn test_drop_index_conflicts_with_concurrent_replacement() {
7730+
use lance_datagen::{BatchCount, RowCount, array};
7731+
7732+
let test_dir = tempfile::tempdir().unwrap();
7733+
let reader = lance_datagen::gen_batch()
7734+
.col("id", array::step::<arrow_array::types::Int32Type>())
7735+
.col(
7736+
"vector",
7737+
array::rand_vec::<arrow_array::types::Float32Type>(8.into()),
7738+
)
7739+
.into_reader_rows(RowCount::from(10), BatchCount::from(1));
7740+
let mut dataset = Dataset::write(reader, test_dir.path().to_str().unwrap(), None)
7741+
.await
7742+
.unwrap();
7743+
let field_id = dataset.schema().field("vector").unwrap().id;
7744+
7745+
let initial = write_vector_segment_metadata(
7746+
&dataset,
7747+
"vector_idx",
7748+
field_id,
7749+
Uuid::new_v4(),
7750+
[0_u32],
7751+
b"initial",
7752+
)
7753+
.await;
7754+
dataset
7755+
.commit_existing_index_segments(
7756+
"vector_idx",
7757+
"vector",
7758+
vec![segment_from_metadata(&initial)],
7759+
)
7760+
.await
7761+
.unwrap();
7762+
7763+
// A stale handle prepares to drop while the index is replaced.
7764+
let mut stale = dataset.clone();
7765+
let replacement = write_vector_segment_metadata(
7766+
&dataset,
7767+
"vector_idx",
7768+
field_id,
7769+
Uuid::new_v4(),
7770+
[0_u32],
7771+
b"replacement",
7772+
)
7773+
.await;
7774+
dataset
7775+
.commit_existing_index_segments(
7776+
"vector_idx",
7777+
"vector",
7778+
vec![segment_from_metadata(&replacement)],
7779+
)
7780+
.await
7781+
.unwrap();
7782+
7783+
// The stale drop must conflict instead of silently no-op'ing (its
7784+
// removal targets a UUID the replacement already removed).
7785+
let err = stale
7786+
.drop_index("vector_idx")
7787+
.await
7788+
.expect_err("a drop racing a same-name replacement must conflict");
7789+
assert!(
7790+
matches!(err, Error::RetryableCommitConflict { .. }),
7791+
"expected a retryable commit conflict, got: {err}"
7792+
);
7793+
7794+
// Retrying against the latest version drops the replacement for real.
7795+
stale.checkout_latest().await.unwrap();
7796+
stale.drop_index("vector_idx").await.unwrap();
7797+
assert!(
7798+
stale
7799+
.load_indices_by_name("vector_idx")
7800+
.await
7801+
.unwrap()
7802+
.is_empty(),
7803+
"the retried drop must remove the replaced index"
7804+
);
7805+
}
7806+
76667807
#[tokio::test]
76677808
async fn test_resolve_index_column_error_cases() {
76687809
use lance_datagen::{BatchCount, RowCount, array};

rust/lance/src/io/commit/conflict_resolver.rs

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -592,44 +592,24 @@ impl<'a> TransactionRebase<'a> {
592592
new_indices: created_indices,
593593
removed_indices: other_removed_indices,
594594
} => {
595-
let self_has_frag_reuse = new_indices
596-
.iter()
597-
.any(|idx| idx.name == FRAG_REUSE_INDEX_NAME);
598-
// Scan the other transaction's removals too: a removal-only
599-
// transaction (e.g. drop_index) that dropped a singleton must
600-
// not be silently undone by a staged re-creation.
601-
let other_has_frag_reuse = created_indices
602-
.iter()
603-
.chain(other_removed_indices.iter())
604-
.any(|idx| idx.name == FRAG_REUSE_INDEX_NAME);
605-
let self_has_mem_wal =
606-
new_indices.iter().any(|idx| idx.name == MEM_WAL_INDEX_NAME);
607-
let other_has_mem_wal = created_indices
608-
.iter()
609-
.chain(other_removed_indices.iter())
610-
.any(|idx| idx.name == MEM_WAL_INDEX_NAME);
611-
let has_regular_name_conflict = new_indices
612-
.iter()
613-
.filter(|idx| {
614-
idx.name != FRAG_REUSE_INDEX_NAME && idx.name != MEM_WAL_INDEX_NAME
615-
})
616-
.any(|new_index| {
617-
created_indices
618-
.iter()
619-
.any(|created_index| created_index.name == new_index.name)
620-
// A concurrent transaction that removed a same-name
621-
// index (e.g. drop_index) invalidates this snapshot:
622-
// committing anyway would silently re-create the
623-
// index that was just removed.
624-
|| other_removed_indices
595+
// Two index transactions conflict when they touch any index
596+
// name in common, in either direction: committing a creation
597+
// over a concurrent same-name removal would resurrect the
598+
// dropped index, and committing a removal over a concurrent
599+
// same-name replacement would silently no-op the drop (its
600+
// stale UUIDs no longer match anything at apply time).
601+
// System singletons need no special casing under this rule.
602+
let names_intersect =
603+
new_indices
604+
.iter()
605+
.chain(removed_indices.iter())
606+
.any(|self_index| {
607+
created_indices
625608
.iter()
626-
.any(|removed_index| removed_index.name == new_index.name)
627-
});
628-
629-
if (self_has_frag_reuse && other_has_frag_reuse)
630-
|| (self_has_mem_wal && other_has_mem_wal)
631-
|| has_regular_name_conflict
632-
{
609+
.chain(other_removed_indices.iter())
610+
.any(|other_index| other_index.name == self_index.name)
611+
});
612+
if names_intersect {
633613
Err(self.retryable_conflict_err(other_transaction, other_version))
634614
} else {
635615
Ok(())
@@ -4669,7 +4649,7 @@ mod tests {
46694649
}
46704650

46714651
#[tokio::test]
4672-
async fn test_create_index_conflicts_with_concurrent_index_removal() {
4652+
async fn test_index_transactions_conflict_on_shared_names() {
46734653
fn index_meta(name: &str) -> IndexMetadata {
46744654
IndexMetadata {
46754655
uuid: Uuid::new_v4(),
@@ -4684,44 +4664,60 @@ mod tests {
46844664
files: None,
46854665
}
46864666
}
4667+
fn create_op(name: &str) -> Operation {
4668+
Operation::CreateIndex {
4669+
new_indices: vec![index_meta(name)],
4670+
removed_indices: vec![],
4671+
}
4672+
}
4673+
fn removal_op(name: &str) -> Operation {
4674+
Operation::CreateIndex {
4675+
new_indices: vec![],
4676+
removed_indices: vec![index_meta(name)],
4677+
}
4678+
}
46874679

46884680
let dataset = test_dataset(10, 1).await;
4689-
// Regular names and both system singletons: a removal-only concurrent
4690-
// transaction (e.g. drop_index) must conflict with a staged creation of
4691-
// the same index instead of letting the commit resurrect it.
4681+
// Index transactions touching the same name conflict in every
4682+
// direction: a creation over a concurrent removal must not resurrect
4683+
// the dropped index, and a removal over a concurrent replacement must
4684+
// not silently no-op the drop. Regular names and both system
4685+
// singletons behave identically.
46924686
for name in ["my_idx", FRAG_REUSE_INDEX_NAME, MEM_WAL_INDEX_NAME] {
4693-
let staged = Transaction::new(
4694-
dataset.manifest.version,
4695-
Operation::CreateIndex {
4696-
new_indices: vec![index_meta(name)],
4697-
removed_indices: vec![],
4698-
},
4699-
None,
4700-
);
4701-
let removal_only = Transaction::new(
4702-
dataset.manifest.version,
4703-
Operation::CreateIndex {
4704-
new_indices: vec![],
4705-
removed_indices: vec![index_meta(name)],
4706-
},
4707-
None,
4708-
);
4687+
for (self_op, other_op) in [
4688+
(create_op(name), removal_op(name)),
4689+
(removal_op(name), create_op(name)),
4690+
(removal_op(name), removal_op(name)),
4691+
] {
4692+
let staged = Transaction::new(dataset.manifest.version, self_op, None);
4693+
let committed = Transaction::new(dataset.manifest.version, other_op, None);
4694+
let mut rebase = TransactionRebase::try_new(&dataset, staged, None)
4695+
.await
4696+
.unwrap();
4697+
let err = rebase
4698+
.check_txn(&committed, dataset.manifest.version + 1)
4699+
.unwrap_err();
4700+
assert!(
4701+
matches!(err, Error::RetryableCommitConflict { .. }),
4702+
"{name}: expected a retryable commit conflict, got: {err}"
4703+
);
4704+
assert!(
4705+
err.to_string()
4706+
.contains("preempted by concurrent transaction CreateIndex"),
4707+
"{name}: conflict message must identify the concurrent CreateIndex, got: {err}"
4708+
);
4709+
}
47094710

4711+
// Transactions on disjoint names never conflict, singletons included.
4712+
let staged = Transaction::new(dataset.manifest.version, create_op(name), None);
4713+
let unrelated =
4714+
Transaction::new(dataset.manifest.version, removal_op("other_idx"), None);
47104715
let mut rebase = TransactionRebase::try_new(&dataset, staged, None)
47114716
.await
47124717
.unwrap();
4713-
let err = rebase
4714-
.check_txn(&removal_only, dataset.manifest.version + 1)
4715-
.unwrap_err();
4716-
assert!(
4717-
matches!(err, Error::RetryableCommitConflict { .. }),
4718-
"{name}: expected a retryable commit conflict, got: {err}"
4719-
);
4720-
assert!(
4721-
err.to_string()
4722-
.contains("preempted by concurrent transaction CreateIndex"),
4723-
"{name}: conflict message must identify the concurrent CreateIndex, got: {err}"
4724-
);
4718+
rebase
4719+
.check_txn(&unrelated, dataset.manifest.version + 1)
4720+
.unwrap_or_else(|err| panic!("{name}: disjoint names must not conflict: {err}"));
47254721
}
47264722
}
47274723
}

0 commit comments

Comments
 (0)