Skip to content

feat: expose staged index segment transactions#6806

Open
ragnorc wants to merge 7 commits into
lance-format:mainfrom
ModernRelay:ragnorc/two-phase-index-segments
Open

feat: expose staged index segment transactions#6806
ragnorc wants to merge 7 commits into
lance-format:mainfrom
ModernRelay:ragnorc/two-phase-index-segments

Conversation

@ragnorc

@ragnorc ragnorc commented May 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Dataset::build_existing_index_segments_transaction, which builds the
Operation::CreateIndex transaction for existing physical index segments
without committing it. Callers commit the returned Transaction via
CommitBuilder for a strict stage-then-commit workflow, mirroring
InsertBuilder::execute_uncommitted / DeleteBuilder::execute_uncommitted.
commit_existing_index_segments now delegates to it.

Fixes #6666.

Design notes

  • Inherent method on Dataset, not a new required method on the public
    DatasetIndexExt trait, so it is non-breaking for downstream trait
    implementors (addresses the earlier review feedback). The trait has a single
    in-tree implementor and is not object-safe, and the staging convention
    (execute_uncommitted) is otherwise inherent on builders/Dataset — so an
    inherent method is the most consistent, lowest-risk placement.
  • Returns a bare Transaction (no wrapper struct): CreateIndex carries no
    affected_rows/side-channel, so there is nothing extra to reconcile at commit.
  • Concurrency is enforced in two layers. The conflict resolver rejects any two
    index transactions that touch a common index name, in either direction, with
    a retryable conflict — creating over a concurrent removal must not resurrect
    a dropped index, and removing over a concurrent replacement must not silently
    no-op the drop. build_manifest then enforces the apply-time invariants that
    pairwise checks cannot enumerate: indexed fields must still exist in the final
    schema (a drop_columns projection or an alter_columns type cast removes or
    re-ids the field) and non-system removed segments must still be present. Two
    deliberate behavior changes: a concurrent double-drop and a commit whose staged
    removals were concurrently removed now fail retryably instead of silently
    succeeding. A compaction/rewrite that defers index remapping remains
    optimistic; committed segments may cover already-compacted fragments until the
    deferred remap catches up, so callers should still commit promptly.
  • For inverted (FTS) segments, building the transaction finalizes the segment's
    on-disk files (idempotent; any files left behind on an abandoned build are
    reclaimed by cleanup_old_versions) — documented under # Side effects.

Tests

  • test_build_existing_index_segments_transaction_does_not_commit — staging
    does not publish the index; the returned transaction commits via
    CommitBuilder.
  • test_build_existing_index_segments_transaction_removes_empty_segment — the
    zero-fragment placeholder guard fires on the staged path.
  • test_build_existing_index_segments_transaction_commits_after_version_advances
    — a transaction staged at version N commits cleanly after an unrelated append
    moves HEAD past N.
  • test_build_existing_index_segments_transaction_conflicts_with_index_drop
    a staged commit over a concurrent drop_index fails with a retryable
    conflict instead of re-creating the dropped index. Fails without the fix.
  • test_build_existing_index_segments_transaction_conflicts_with_column_drop
    — a staged commit after the indexed column is dropped fails as incompatible
    instead of publishing metadata for a nonexistent field. Fails without the
    fix.
  • test_build_existing_index_segments_transaction_conflicts_with_column_cast
    — a staged commit after an alter_columns type cast (Merge with a new
    field id) fails as incompatible. Fails without the fix.
  • test_drop_index_conflicts_with_concurrent_replacement — a drop racing a
    same-name replacement fails retryably instead of silently no-op'ing, and
    succeeds on retry. Fails without the fix.
  • test_index_transactions_conflict_on_shared_names — resolver-level matrix:
    transactions on a shared name conflict in every direction (regular names and
    system singletons); disjoint names never conflict.

Verification

  • cargo fmt --all
  • cargo clippy -p lance --tests -- -D warnings
  • cargo test -p lance --lib -- build_existing_index_segments_transaction index_transactions_conflict drop_index_conflicts (8 passed)
  • cargo test -p lance --lib -- existing_index_segments conflict drop_index create_index stale frag_reuse mem_wal (638 passed)
  • cargo test -p lance --doc build_existing_index_segments_transaction

Summary by CodeRabbit

  • New Features

    • Added support for staging existing index segments as a transaction before publishing them.
    • Index metadata and segment coverage are validated before changes are committed.
  • Bug Fixes

    • Improved handling of index replacements and placeholder segments.
    • Prevented staged index creation from restoring indexes removed concurrently.
    • Added conflict detection when indexed fields or columns are changed concurrently.
    • Staged transactions now commit correctly against the snapshot they were created from.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions github-actions Bot added the enhancement New feature or request label May 16, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b0d5f35c19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rust/lance/src/index/api.rs Outdated
Comment on lines +315 to +320
async fn build_existing_index_segments_transaction(
&self,
index_name: &str,
column: &str,
segments: Vec<impl IntoIndexSegment + Send>,
) -> Result<Transaction>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add a default body for new DatasetIndexExt method

Adding build_existing_index_segments_transaction as a required method on the public DatasetIndexExt trait is a breaking API change for downstream crates that implement this trait: they will fail to compile immediately after upgrading, even if they never use this new functionality. Because DatasetIndexExt is publicly exported, this should be introduced with a default implementation (or via a new extension trait) to preserve semver compatibility.

Useful? React with 👍 / 👎.

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.

Now inherent on Dataset instead of a trait method, so no required trait method and no default body are needed.

@codecov

codecov Bot commented May 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 36 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/index.rs 94.53% 21 Missing and 7 partials ⚠️
rust/lance/src/dataset/transaction.rs 81.48% 4 Missing and 1 partial ⚠️
rust/lance/src/io/commit/conflict_resolver.rs 96.47% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

Comment thread rust/lance/src/index.rs Outdated
"Index name '{index_name}' already exists with different fields, please specify a different name"
)));
}
let removed_indices = existing_named_indices

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.

Zero-fragment placeholder index segments are still treated as disjoint, so committing real segments can leave the placeholder published and break index loading.

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.

The relocated removal logic keeps the existing_fragments.is_empty() guard (#7141), so the placeholder is still removed; asserted by test_build_existing_index_segments_transaction_removes_empty_segment.

Comment thread rust/lance/src/index/api.rs Outdated
/// without advancing the dataset version. Callers that need a strict
/// stage-then-commit workflow can pass the returned transaction to
/// [`crate::dataset::CommitBuilder`].
async fn build_existing_index_segments_transaction(

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.

Adding this required method to a public unsealed trait breaks downstream implementors, so existing crates that implement the trait will fail to compile after upgrade.

@ragnorc ragnorc Jun 23, 2026

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.

Moved off the trait - build_existing_index_segments_transaction is now an inherent Dataset method, so DatasetIndexExt is unchanged and downstream implementors are not affected.

Add `Dataset::build_existing_index_segments_transaction`, which builds the
`Operation::CreateIndex` transaction for existing physical index segments
without committing it. Callers commit the returned transaction via
`CommitBuilder` for a strict stage-then-commit workflow, mirroring
`InsertBuilder::execute_uncommitted`. `commit_existing_index_segments` now
delegates to it.

The method is inherent on `Dataset` rather than a new required method on the
public `DatasetIndexExt` trait, so it is non-breaking for downstream trait
implementors.

Fixes lance-format#6666
@ragnorc
ragnorc force-pushed the ragnorc/two-phase-index-segments branch from 53aa593 to 5cfc088 Compare June 23, 2026 16:31
@ragnorc
ragnorc requested a review from Xuanwo June 23, 2026 18:56
@ragnorc

ragnorc commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

@Xuanwo gently bumping this :)

Comment thread rust/lance/src/index.rs Outdated
/// so commit it promptly. A concurrent index creation with the same name is
/// rejected at commit time with a retryable conflict, but other concurrent
/// changes to the same index between staging and commit — a compaction/rewrite
/// that remaps it, or dropping/renaming the indexed column — are not

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.

The staged index transaction can commit after the indexed column or the same index is dropped, because those concurrent operations are treated as compatible. This can publish stale index metadata or re-create an index the user already removed.

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.

Both cases are now rejected at commit time (18effc9). drop_index commits CreateIndex with only removed_indices, so the same-name check now also matches the other transaction's removals — a staged commit over a concurrent drop fails with a retryable conflict instead of re-creating the index. A concurrent Project is checked against the indexed field ids — a staged commit after the column is dropped fails as incompatible instead of publishing metadata for a nonexistent field. Asserted by test_build_existing_index_segments_transaction_conflicts_with_index_drop / ..._conflicts_with_column_drop; both fail without the fix. The inline commit_existing_index_segments path shares the conflict resolver, so the same race there is closed as well.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a public API for building uncommitted transactions from existing index segments, refactors the existing commit path to use it, and strengthens rebasing conflicts for concurrent index removal and schema projection changes.

Changes

Index transaction staging and conflict handling

Layer / File(s) Summary
Build uncommitted index transactions
rust/lance/src/index.rs
build_existing_index_segments_transaction validates segments, derives metadata, computes removals, and returns an uncommitted CreateIndex transaction; commit_existing_index_segments delegates to it.
Rebase conflicts for index creation
rust/lance/src/io/commit/conflict_resolver.rs
Create-index rebasing detects same-name index removals and projected schemas missing indexed fields.
Validate staged transaction behavior
rust/lance/src/index.rs
Tests cover deferred publication, placeholder removal, HEAD advancement, concurrent index drops, and indexed-column removal.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dataset
  participant Transaction
  participant Commit
  participant TransactionRebase
  Dataset->>Dataset: Build index segment transaction
  Dataset->>Transaction: CreateIndex with new_indices and removed_indices
  Dataset-->>Commit: Return uncommitted transaction
  Commit->>TransactionRebase: Rebase against concurrent operations
  TransactionRebase-->>Commit: Accept or return conflict
  Commit->>Transaction: Apply transaction
Loading

Suggested labels: A-index

Suggested reviewers: wjones127, xuanwo, jackye1995

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The new public Dataset API exposes a two-phase index-segment transaction path, matching #6666's request for external commit support.
Out of Scope Changes check ✅ Passed The conflict-resolver changes support the staged index workflow, so no clear unrelated scope creep is evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly summarizes the main change: exposing staged index segment transactions.
Description check ✅ Passed The description is directly related and accurately describes the staged transaction API and commit flow changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance/src/io/commit/conflict_resolver.rs (1)

506-537: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include singleton names in concurrent-removal detection.

FRAG_REUSE_INDEX_NAME and MEM_WAL_INDEX_NAME are excluded from has_regular_name_conflict, while their dedicated checks only inspect created_indices. A concurrent removal-only transaction can therefore still be rebased and resurrect either singleton. Check other_removed_indices for these names too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/commit/conflict_resolver.rs` around lines 506 - 537, Update
the conflict checks around has_regular_name_conflict so FRAG_REUSE_INDEX_NAME
and MEM_WAL_INDEX_NAME are also compared against other_removed_indices. Preserve
the existing dedicated concurrent-creation checks, and reject rebasing when
either singleton appears in the concurrent removals to prevent resurrection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance/src/index.rs`:
- Around line 7330-7333: Update the conflict assertions in
rust/lance/src/index.rs:7330-7333 and rust/lance/src/index.rs:7389-7392 to
validate both the existing Error::RetryableCommitConflict variant and diagnostic
message content. In the first test, assert that the message identifies the
concurrent same-name index removal; in the second, assert that it identifies the
missing indexed field after projection.
- Around line 972-979: Update the removed_indices filtering in the CreateIndex
flow so every existing index with the same name is removed, regardless of its
type_url; do not exclude incompatible metadata from replacement. Preserve the
Operation::CreateIndex contract that same-name indices are fully replaced, or
explicitly reject the type mismatch before committing rather than retaining both
indexes.
- Around line 929-932: Update the error construction in the index-creation flow,
including the missing-column branch around self.schema().field(column) and the
incompatible existing-index-name branch, to use Error::invalid_input instead of
Error::index for caller-supplied argument failures. Preserve the existing error
messages and control flow.
- Around line 891-897: Update the transaction staging/commit flow documented
near the public index API to detect concurrent compaction or rewrite operations
that defer index remapping and reject the rebase with a retryable conflict
instead of allowing duplicate index metadata. Ensure the staged transaction is
not exposed as successfully committed unless it is correctly remapped, and
remove the documentation that permits this deferred-remap case.

---

Outside diff comments:
In `@rust/lance/src/io/commit/conflict_resolver.rs`:
- Around line 506-537: Update the conflict checks around
has_regular_name_conflict so FRAG_REUSE_INDEX_NAME and MEM_WAL_INDEX_NAME are
also compared against other_removed_indices. Preserve the existing dedicated
concurrent-creation checks, and reject rebasing when either singleton appears in
the concurrent removals to prevent resurrection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 49bcdbff-d26d-4152-b6bf-47a8a0a358d4

📥 Commits

Reviewing files that changed from the base of the PR and between 5266629 and 18effc9.

📒 Files selected for processing (2)
  • rust/lance/src/index.rs
  • rust/lance/src/io/commit/conflict_resolver.rs

Comment thread rust/lance/src/index.rs Outdated
Comment thread rust/lance/src/index.rs
Comment on lines +929 to +932
let Some(field) = self.schema().field(column) else {
return Err(Error::index(format!(
"CreateIndex: column '{column}' does not exist"
)));

@coderabbitai coderabbitai Bot Jul 18, 2026

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use Error::invalid_input for invalid API arguments.

Both an unknown column and an incompatible existing index name are caller-input failures, not index subsystem failures.

Proposed fix
-            return Err(Error::index(format!(
+            return Err(Error::invalid_input(format!(
                 "CreateIndex: column '{column}' does not exist"
             )));
...
-            return Err(Error::index(format!(
+            return Err(Error::invalid_input(format!(
                 "Index name '{index_name}' already exists with different fields, \
                 please specify a different name"
             )));

As per coding guidelines, “use Error::invalid_input for caller data issues.”

Also applies to: 962-970

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/index.rs` around lines 929 - 932, Update the error
construction in the index-creation flow, including the missing-column branch
around self.schema().field(column) and the incompatible existing-index-name
branch, to use Error::invalid_input instead of Error::index for caller-supplied
argument failures. Preserve the existing error messages and control flow.

Source: Coding guidelines

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.

These are moved verbatim, and CreateIndexBuilder raises Error::index for the identical messages — changing only this path would give the same failure two different variants, and since commit_existing_index_segments now delegates here it would also change a shipped API error variant. Agreed with the guideline; deferring to a follow-up that sweeps all three paths together.

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.

@ragnorc, agreed—keeping the existing Error::index behavior is the right scoped decision here. Changing only the newly extracted path would introduce inconsistency with CreateIndexBuilder and alter the existing commit_existing_index_segments API behavior.

A coordinated follow-up across all creation paths is appropriate. Would you like me to open a GitHub issue to track that cleanup?


✏️ Learnings added
Learnt from: ragnorc
Repo: lance-format/lance PR: 6806
File: rust/lance/src/index.rs:930-933
Timestamp: 2026-07-19T00:29:32.460Z
Learning: In `rust/lance/src/index.rs`, `Dataset::build_existing_index_segments_transaction` preserves validation behavior extracted from `DatasetIndexExt::commit_existing_index_segments`. The identical missing-column and conflicting-index-name errors in `CreateIndexBuilder` currently use `Error::index`; changing only the staged-segment path to `Error::invalid_input` would cause inconsistent error variants and change a shipped API behavior. Normalize these errors only in a coordinated follow-up across all index-creation paths.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread rust/lance/src/index.rs
Comment thread rust/lance/src/index.rs
- reject staged singleton (frag-reuse/MemWAL) creation over a concurrent
  removal-only transaction, with a resolver unit test
- assert conflict message content in the new staged-transaction tests
- reword the staging doc: a deferred-remap rewrite leaves stale fragment
  coverage until the remap catches up, not duplicate entries

Co-Authored-By: Claude Fable 5 <[email protected]>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
Bad credentials - https://docs.github.com/rest

ragnorc and others added 2 commits July 19, 2026 01:31
…ame 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 <[email protected]>
@ragnorc

ragnorc commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up to the review discussion: the conflict checks are now layered (c24fe74). Resolver rejects any two index transactions touching a common name in either direction - a drop racing a same-name replacement previously no-op'd silently, the mirror of the resurrection case. And build_manifest now enforces the apply-time invariants pairwise checks cannot enumerate: indexed fields must exist in the final schema (an alter_columns type cast commits Merge with a new field id and slipped past the Project-only check) and non-system removed segments must still be present

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose build_index_metadata_from_segments (or commit_existing_index_segments) for two-phase vector-index commits outside the lance crate

2 participants