Skip to content

feat(mem_wal): validate maintained indexes against the writer's rules - #8360

Merged
hamersaw merged 4 commits into
lance-format:mainfrom
hamersaw:refactor/wal-index-maintainability
Aug 7, 2026
Merged

feat(mem_wal): validate maintained indexes against the writer's rules#8360
hamersaw merged 4 commits into
lance-format:mainfrom
hamersaw:refactor/wal-index-maintainability

Conversation

@hamersaw

@hamersaw hamersaw commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

is_maintainable_index_type inspects only an index's protobuf type url, which cannot decide whether the MemWAL can maintain that index. Every vector index sub-type carries VectorIndexDetails and so maps to MemIndexKind::Hnsw, but the memtable's HNSW requires a FixedSizeList<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 by is_maintainable_index_type, gets committed into maintained_indexes, and then the first merge_insert fails opening the shard writer with HNSW index requires a FixedSizeList<Float32> column.

The rules that actually decide this already exist in validate_index_configs — they just only run at ShardWriter::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

  • Extract the index-config building loop out of mem_wal_writer into build_index_configs.
  • Add validate_maintained_indexes(dataset, index_names), which builds the same configs and runs validate_index_configs against the same shard schema (base + _tombstone) with the same derived PK columns.
  • Both mem_wal_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 as more index kinds become maintainable.
  • Correct the is_maintainable_index_type docs 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_type admits an IVF index over FixedSizeList<Float64>, and validate_maintained_indexes rejects it.
  • test_validate_maintained_indexes_accepts_f32_vector_column
  • test_validate_maintained_indexes_accepts_btree — guards the shard-schema plumbing (field ids resolve against base + _tombstone).
  • test_validate_maintained_indexes_rejects_unknown_name

Full dataset::mem_wal suite: 582 passed, 1 ignored, 0 failed.

🤖 Generated with Claude Code

`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>
@hamersaw
hamersaw marked this pull request as ready for review August 6, 2026 22:35
`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>

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rust/lance/src/dataset/mem_wal/api.rs
@github-actions github-actions Bot added the enhancement New feature or request label Aug 6, 2026
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>
hamersaw added a commit to lancedb/lancedb that referenced this pull request Aug 7, 2026
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>
hamersaw added a commit to lancedb/lancedb that referenced this pull request Aug 7, 2026
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>

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@hamersaw
hamersaw merged commit 1ac72d9 into lance-format:main Aug 7, 2026
39 of 40 checks passed
@hamersaw
hamersaw deleted the refactor/wal-index-maintainability branch August 7, 2026 17:11
hamersaw added a commit to lancedb/lancedb that referenced this pull request Aug 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants