feat(mem_wal): validate maintained indexes against the writer's rules - #8360
Conversation
`is_maintainable_index_type` only inspects an index's protobuf type url. That is not enough to decide whether the MemWAL can maintain an index: every vector index sub-type carries `VectorIndexDetails` and so maps to `MemIndexKind::Hnsw`, but the memtable's HNSW needs a `FixedSizeList<Float32>` column. A caller filtering a maintained set on the type url alone can therefore commit an index that every shard-writer open then rejects, leaving the table unwritable — for example an IVF-PQ index over `FixedSizeList<Float64>`. The rules that actually decide this already exist in `validate_index_configs`, but they only run at `ShardWriter::open`, far from the call that chose the set. Extract the config-building loop out of `mem_wal_writer` into `build_index_configs` and add `validate_maintained_indexes`, which builds the same configs and runs the same validation against the same shard schema (base + `_tombstone`). Both the writer and the new entry point go through that one path, so a set that validates is a set the writer can open, and the two cannot drift. The check is all-or-nothing: it reports the first index it cannot maintain rather than returning a usable subset, so a caller inferring a set surfaces the error instead of silently dropping an index the caller believes is maintained. Also correct the `is_maintainable_index_type` docs, which advised filtering a maintained set on it — the advice that makes the above unwritable table reachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`validate_maintained_indexes` answers the question this was added for, and answers it correctly, leaving no caller in either lance or lancedb. Keeping it is a hazard rather than a convenience: a public predicate that looks like it decides whether an index can be maintained, sitting beside one that actually does, invites the same wrong choice that made an IVF-PQ index over `FixedSizeList<Float64>` commit into a maintained set and leave the table unwritable. `MemIndexKind::from_type_url` stays and is unchanged, so callers that want the kind a type url resolves to still have it. BREAKING CHANGE: `is_maintainable_index_type` is removed. Use `validate_maintained_indexes` to decide what to commit as a maintained set, or `MemIndexKind::from_type_url` for the type-url mapping alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The maintained-index check needs to be enforced at the transaction that persists the set, not left as an optional preflight. A viable revision should run the shared validation from InitializeMemWalBuilder::execute, ensure a commit rebase cannot invalidate the selected index/schema, and keep writer-open revalidation for later changes.
Please mark this PR with the breaking-change label.
The error carried only the type url, so a caller validating a maintained set learned that some index was unmaintainable but not which one — the one thing it needs in order to drop the offender. `unsupported_index_type` now takes the index name. Its one remaining caller is `build_index_configs`; the doc claiming it is shared with a detection path is stale, as that path was `is_maintainable_index_type`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inferring the maintained set filtered on Lance's `is_maintainable_index_type`, which reads only an index's protobuf type url. That is not enough to decide: every vector sub-type maps to the memtable's HNSW, which needs a `FixedSizeList<Float32>` column. An IVF-PQ index over `FixedSizeList<Float64>` was therefore inferred into the maintained set, committed, and then failed every shard-writer open — leaving the table unwritable through the LSM path. Delegate to Lance's `validate_maintained_indexes`, which resolves each index against the dataset schema and applies the rules its shard writer applies, so a spec that installs is one the MemWAL can open. Both the inferred and the explicit selection go through it, and the type-url filtering here is gone. Inference is now all-or-nothing: an index the MemWAL cannot maintain fails `set_lsm_write_spec` instead of being dropped from the set. Dropping it silently left the caller believing an index was maintained that the writer never touched. The error names the offending index and says to set `maintained_indexes` explicitly to install anyway. `resolve_maintained_indexes` is no longer public: it needs a `lance::Dataset` now, and a server sharing these rules should call Lance's function directly. Requires lance-format/lance#8360. BREAKING CHANGE: `set_lsm_write_spec` with an inferred maintained set now fails on a table carrying an index the MemWAL cannot maintain (bitmap, labelList, FM, or a vector index on an incompatible column) instead of silently maintaining a subset. Pass `maintained_indexes` explicitly to choose the set. `lancedb::table::resolve_maintained_indexes` is no longer exported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The maintained-index validation this branch calls does not exist in v11.0.0-beta.2, so the workspace does not build against the released pin. Point the lance deps at the head of lance-format/lance#8360 to get CI compiling and produce test signal on the behavior change. The rev is reachable as refs/pull/8360/head on the public upstream repo, so cargo resolves it without a fork — the git URL stays lance-format/lance. This pin is temporary and must move to a release tag before merge; the manifest says so above the dependency block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The mandatory validation fixes the ordinary initialization path, but validity is still not atomic with commit rebasing. A viable revision should either revalidate the maintained set against the rebased manifest before _mem_wal lands, or make changes to every referenced index/schema dependency a retryable conflict; writer-open validation should remain as the later safety net.
|
|
||
| // Gate the commit, not just a preflight a caller may skip: a set the | ||
| // writer cannot open leaves the table unwritable. | ||
| validate_maintained_indexes(dataset, &maintained_indexes).await?; |
There was a problem hiding this comment.
This validates only the current Dataset snapshot. CommitBuilder can subsequently rebase this CreateIndex transaction over a concurrent drop_index: conflict resolution checks concurrently created names but ignores removed_indices, and no validation reruns against the rebased manifest. The result can still persist _mem_wal with a missing maintained index, after which every writer open fails. Concurrent replacement or projection can invalidate the same dependency.
Revalidate _mem_wal details against the latest manifest during finish_create_index, or make removal/replacement/projection of a referenced index or field conflict and retry from a refreshed snapshot.
Reproducer run on this head
#[tokio::test]
async fn gate_reproducer_initialization_rebases_over_index_drop() {
let tmp = tempfile::tempdir().unwrap();
let uri = format!("{}/base", tmp.path().to_str().unwrap());
let schema = id_v_schema();
let reader = RecordBatchIterator::new(
[Ok(id_v_batch(&schema, &[1, 2, 3]))], schema.clone());
let mut stale = Dataset::write(reader, &uri, Some(WriteParams::default()))
.await.unwrap();
stale.create_index(
&["id"], IndexType::BTree, Some("id_idx".to_string()),
&ScalarIndexParams::default(), true).await.unwrap();
let maintained_indexes = vec!["id_idx".to_string()];
validate_maintained_indexes(&stale, &maintained_indexes).await.unwrap();
let details = MemWalIndexDetails {
num_shards: 1,
sharding_specs: vec![unsharded_sharding_spec()],
maintained_indexes,
..Default::default()
};
let index_meta = new_mem_wal_index_meta(stale.manifest.version, details).unwrap();
let transaction = Transaction::new(
stale.manifest.version,
Operation::CreateIndex { new_indices: vec![index_meta], removed_indices: vec![] },
None);
let mut concurrent = stale.clone();
concurrent.drop_index("id_idx").await.unwrap();
let rebased = CommitBuilder::new(Arc::new(stale))
.execute(transaction).await
.expect("the stale MemWAL transaction currently rebases over the drop");
assert!(rebased.load_indices_by_name("id_idx").await.unwrap().is_empty());
assert!(rebased.mem_wal_index_details().await.unwrap().is_some());
let error = match rebased
.mem_wal_writer(Uuid::new_v4(), ShardWriterConfig::default()).await
{
Ok(_) => panic!("the rebased set unexpectedly opened a writer"),
Err(error) => error,
};
assert!(error.to_string().contains("id_idx")
&& error.to_string().contains("not found"));
}cargo test -p lance dataset::mem_wal::api::tests::gate_reproducer_initialization_rebases_over_index_drop -- --nocapture passed, reproducing the accepted stale commit and deterministic writer-open failure. A fixed regression should instead assert that the stale commit is rejected and no MemWAL index is persisted.
There was a problem hiding this comment.
WAL does not currently guard against changes made the to the schema after it has been enabled. So things like index changes (dropping + changing types) or column updates (same) are not tracked. We will address this work as a follow up and have added a "limitation" comment in the code regarding it.
…anges `maintained_indexes` is validated against the dataset as it stands at initialization. Dropping or replacing a maintained index, or projecting away its column, leaves the set naming something the writer cannot build; a change that races the initialization commit lands the same way, since the commit rebases and nothing revalidates against the rebased manifest. Both surface as a failing `mem_wal_writer` rather than being prevented. Recorded as a known limitation on the API module, with pointers from `execute` and `validate_maintained_indexes`; handling dataset updates after initialization is follow-up work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The new documentation accurately describes the symptom, but the initialization race is still an atomicity failure rather than a post-initialization limitation: the invalidating transaction can commit first and _mem_wal can land afterward already unusable.
A viable revision should revalidate the writer-open invariant on the rebased manifest, or make referenced index/schema changes retryable conflicts. The limitation can remain for genuinely later mutations.
| //! MemWAL does not track dataset changes made after it is initialized: dropping | ||
| //! or replacing a maintained index, or projecting away its column, leaves | ||
| //! `maintained_indexes` naming something the writer cannot build. A change that | ||
| //! races the initialization commit lands the same way. Both surface as a failing |
There was a problem hiding this comment.
Documenting this race does not make the initialization result safe. In the reproduced ordering, drop_index commits at N+1 while execute is in progress; the stale _mem_wal transaction then rebases and commits at N+2. The dependency change therefore precedes initialization in committed history, yet execute returns success with metadata that no writer can open.
Keep this limitation for genuinely later mutations, but make initialization revalidate maintained_indexes against the rebased manifest in finish_create_index, or conflict and retry whenever a referenced index or schema field changes.
Reproducer run on this head
#[tokio::test]
async fn gate_reproducer_initialization_rebases_over_index_drop() {
let tmp = tempfile::tempdir().unwrap();
let uri = format!("{}/base", tmp.path().to_str().unwrap());
let schema = id_v_schema();
let reader = RecordBatchIterator::new(
[Ok(id_v_batch(&schema, &[1, 2, 3]))], schema.clone());
let mut stale = Dataset::write(reader, &uri, Some(WriteParams::default()))
.await.unwrap();
stale.create_index(
&["id"], IndexType::BTree, Some("id_idx".to_string()),
&ScalarIndexParams::default(), true).await.unwrap();
let maintained_indexes = vec!["id_idx".to_string()];
validate_maintained_indexes(&stale, &maintained_indexes).await.unwrap();
let details = MemWalIndexDetails {
num_shards: 1,
sharding_specs: vec![unsharded_sharding_spec()],
maintained_indexes,
..Default::default()
};
let index_meta = new_mem_wal_index_meta(stale.manifest.version, details).unwrap();
let transaction = Transaction::new(
stale.manifest.version,
Operation::CreateIndex { new_indices: vec![index_meta], removed_indices: vec![] },
None);
let mut concurrent = stale.clone();
concurrent.drop_index("id_idx").await.unwrap();
let rebased = CommitBuilder::new(Arc::new(stale))
.execute(transaction).await
.expect("the stale MemWAL transaction currently rebases over the drop");
assert!(rebased.load_indices_by_name("id_idx").await.unwrap().is_empty());
assert!(rebased.mem_wal_index_details().await.unwrap().is_some());
let error = match rebased
.mem_wal_writer(Uuid::new_v4(), ShardWriterConfig::default()).await
{
Ok(_) => panic!("the rebased set unexpectedly opened a writer"),
Err(error) => error,
};
assert!(error.to_string().contains("id_idx")
&& error.to_string().contains("not found"));
}cargo test -p lance dataset::mem_wal::api::tests::gate_reproducer_initialization_rebases_over_index_drop -- --nocapture passed on 05a3463f573623a68a9b419080d2dc837565c61c, reproducing the accepted stale commit and deterministic writer-open failure. A fixed regression should instead assert that the stale commit is rejected and no MemWAL index is persisted.
There was a problem hiding this comment.
WAL does not currently guard against changes made the to the schema after it has been enabled. So things like index changes (dropping + changing types) or column updates (same) are not tracked. We will address this work as a follow up and have added a "limitation" comment in the code regarding it.
…icies Picks up lance v11.0.0-beta.3 (#3896), which released the `validate_maintained_indexes` API this branch calls. Resolves the Cargo.toml/Cargo.lock conflict in favor of main: the temporary `rev = "f0652582..."` pin at lance-format/lance#8360 from ea2af18 is gone, along with the "do not merge with this pin" comment that guarded it. beta.3 carries the function at rust/lance/src/dataset/mem_wal/api.rs, so the pin has served its purpose. Took main's Cargo.lock wholesale — the only differences from this branch's lock were the lance version bumps and main's own futures feature change (#3800), as the branch added no other dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
is_maintainable_index_typeinspects only an index's protobuf type url, which cannot decide whether the MemWAL can maintain that index. Every vector index sub-type carriesVectorIndexDetailsand so maps toMemIndexKind::Hnsw, but the memtable's HNSW requires aFixedSizeList<Float32>column.A caller that filters a maintained set on the type url alone can therefore commit an index that every shard-writer open then rejects, leaving the table unwritable. Concretely: an IVF-PQ index over
FixedSizeList<Float64>is admitted byis_maintainable_index_type, gets committed intomaintained_indexes, and then the firstmerge_insertfails opening the shard writer withHNSW index requires a FixedSizeList<Float32> column.The rules that actually decide this already exist in
validate_index_configs— they just only run atShardWriter::open, far from the call that chose the set.is_maintainable_index_type's own doc comment made this reachable by advising callers to "filter on this before committing a maintained set".Change
mem_wal_writerintobuild_index_configs.validate_maintained_indexes(dataset, index_names), which builds the same configs and runsvalidate_index_configsagainst the same shard schema (base +_tombstone) with the same derived PK columns.mem_wal_writerand the new entry point go through that one path, so a set that validates is a set the writer can open, and the two cannot drift as more index kinds become maintainable.is_maintainable_index_typedocs to say it is necessary but not sufficient, and point at the new function.The check is all-or-nothing by design: it reports the first index it cannot maintain rather than returning a usable subset. A caller inferring a set surfaces that error rather than silently dropping an index the caller believes is maintained and the writer never touches.
Motivation
lancedb is adding an inferred default for
maintained_indexes(lancedb/lancedb#3748). Inference needs to ask "can the MemWAL maintain this index?" and there is currently no API that answers it correctly. Keeping the rule here, next to the code that enforces it, means every future widening of what the MemWAL can maintain reaches callers for free instead of being re-derived downstream.Testing
Four new tests in
mem_wal::api:test_validate_maintained_indexes_rejects_non_f32_vector_column— asserts both halves of the bug:is_maintainable_index_typeadmits an IVF index overFixedSizeList<Float64>, andvalidate_maintained_indexesrejects it.test_validate_maintained_indexes_accepts_f32_vector_columntest_validate_maintained_indexes_accepts_btree— guards the shard-schema plumbing (field ids resolve against base +_tombstone).test_validate_maintained_indexes_rejects_unknown_nameFull
dataset::mem_walsuite: 582 passed, 1 ignored, 0 failed.🤖 Generated with Claude Code