Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions docs/src/format/table/row_id_lineage.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,11 @@ This protocol mirrors fragment ID assignment and ensures row IDs are unique acro

Stable row IDs are a dataset-level feature recorded in the table manifest.

- Stable row IDs **must be enabled when the dataset is first created**.
- Currently, they **cannot be turned on later** for an existing dataset. Attempts to write with `enable_stable_row_ids = true` against a dataset that was created without stable row IDs will not change the dataset's configuration.
- Stable row IDs may be enabled when a dataset is created or by migrating an existing dataset.
- An ordinary write with `enable_stable_row_ids = true` does not migrate an existing dataset. Use the stable row ID migration operation instead; the Rust API exposes it as `Dataset::migrate_to_stable_row_ids`.
- Before migration, stop all index builds and index commits, drop every secondary index so no index entry remains in the dataset metadata, and keep index creation quiesced until migration completes. An in-flight index commit from a pre-migration snapshot can otherwise attach stale physical row addresses after activation. Recreate indices after migration.
- Quiesce data-modifying writers during migration. The migration uses a single atomic merge commit and does not retry when a concurrent write causes a conflict; the caller must retry the migration.

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.

Index creation must be quiesced too. An index build started on the pre-migration snapshot stores physical row addresses. If migration commits first, CreateIndex explicitly rebases over a non-MemWAL Merge, so that stale index can attach to the stable-ID manifest; searches then treat address values as stable IDs and silently miss rows. The current wording allows this because an index build is not a data-modifying writer. Require no in-flight index builds or commits during migration and state that every index entry must be absent; the implementation follow-up should make migration activation conflict with CreateIndex in either order.

Reproducer run on ad36407

I added this regression to dataset_migrations.rs and ran it against the observed head:

use crate::dataset::transaction::{Operation, Transaction};
use crate::dataset::write::CommitBuilder;

#[tokio::test]
async fn test_migration_rebases_stale_index_after_activation() {
    let mut dataset = make_simple_dataset("memory://migrate_stale_index", 10).await;
    let schema = Arc::new(ArrowSchema::from(dataset.schema()));
    let batch = RecordBatch::try_new(
        schema,
        vec![Arc::new(Int64Array::from_iter_values(10..20))],
    ).unwrap();
    dataset = InsertBuilder::new(Arc::new(dataset))
        .with_params(&WriteParams { mode: WriteMode::Append, ..Default::default() })
        .execute(vec![batch]).await.unwrap();

    dataset.create_index(
        &["id"], IndexType::BTree, Some("stale_btree".to_string()),
        &ScalarIndexParams::default(), true,
    ).await.unwrap();
    let stale_index = dataset.load_indices().await.unwrap()[0].clone();
    dataset.drop_index("stale_btree").await.unwrap();

    let stale_reader = Arc::new(dataset.clone());
    let stale_commit = Transaction::new(
        dataset.manifest.version,
        Operation::CreateIndex {
            new_indices: vec![stale_index],
            removed_indices: vec![],
        },
        None,
    );
    dataset.migrate_to_stable_row_ids().await.unwrap();
    let dataset = CommitBuilder::new(stale_reader)
        .execute(stale_commit).await.unwrap();

    let results = dataset.scan().filter("id = 15").unwrap()
        .try_into_batch().await.unwrap();
    assert_eq!(results.num_rows(), 1);
}

Command:

cargo test -p lance test_migration_rebases_stale_index_after_activation -- --nocapture

Expected one row; observed zero, so the assertion failed with left 0 and right 1.

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.

Addressed in 6682e7d: migration documentation now requires stopping index builds and commits, removing every index entry, and keeping index creation quiesced until migration completes.

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.

Fixed in 6682e7ddc: the migration procedure now stops index builds and commits, requires every secondary-index entry to be removed, and keeps index creation quiesced through activation, excluding the reproduced stale-index rebase.

- Migration assigns an ID to every physical row position, including deleted positions, and atomically enables the feature and advances `next_row_id`. Migrating a dataset that already uses stable row IDs is a no-op.
- When stable row IDs are disabled, the `_rowid` column (if requested) is not stable and should not be used as a persistent identifier.

Row-level version tracking (`_row_created_at_version`, `_row_last_updated_at_version`) and the row ID index described below are only available when stable row IDs are enabled.
Expand Down Expand Up @@ -181,11 +184,14 @@ The implementation selects the most compact encoding based on the value range, c

</details>

#### Inline vs External Storage
#### Inline and External Storage

Row ID sequences are stored either inline in the fragment metadata or in external files.
Sequences smaller than ~200KB are stored inline to avoid additional I/O, while larger sequences are written to external files referenced by path and offset.
This threshold balances manifest size against the overhead of separate file reads.
`DataFragment` defines inline and external metadata fields as valid wire alternatives for row ID sequences and row version sequences.
These fields do not currently imply a size-based switching threshold.
Current Lance writers store all three sequence types inline in the fragment metadata regardless of their encoded size and do not emit the external alternatives.

Current Lance readers can load externally stored row ID sequences.
The format also permits external created-at and last-updated-at version sequences, but current Lance readers cannot load them; this is an implementation limitation, not an invalid encoding.

<details>
<summary>DataFragment row_id_sequence field</summary>
Expand Down Expand Up @@ -360,4 +366,3 @@ WHERE _row_created_at_version <= {begin_version}
```

This query excludes newly inserted rows by requiring `_row_created_at_version <= {begin_version}`, ensuring only pre-existing rows that were subsequently updated are returned.

14 changes: 8 additions & 6 deletions protos/table.proto
Original file line number Diff line number Diff line change
Expand Up @@ -372,23 +372,25 @@ message DataFragment {
// That is, if a fragment has 3 rows, and the row ids are [1, 42, 3], then the
// first row is row 1, the second row is row 42, and the third row is row 3.
oneof row_id_sequence {
// If small (< 200KB), the row ids are stored inline.
// Current Lance writers store row ids inline regardless of encoded size.
bytes inline_row_ids = 5;
// Otherwise, stored as part of a file.
// Supported by current Lance readers, but not emitted by current Lance writers.
ExternalFile external_row_ids = 6;
} // row_id_sequence

oneof last_updated_at_version_sequence {
// If small (< 200KB), the row latest updated versions are stored inline.
// Current Lance writers store last-updated versions inline regardless of encoded size.
bytes inline_last_updated_at_versions = 7;
// Otherwise, stored as part of a file.
// Valid external alternative. Current Lance writers do not emit this field,
// and current Lance readers cannot load it.
ExternalFile external_last_updated_at_versions = 8;

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.

Fields 8 and 10 cannot be reclassified as reserved. Commit 85d44b6 introduced them as valid external alternatives, and that commit is an ancestor of the released v10.0.0 format; these fields are governed by stable feature flag bit 2, not an unstable flag. This wording makes a previously conforming external-version manifest forbidden, violating the stable persisted-format contract. The current RowDatasetVersionMeta::load_sequence TODO is an implementation gap, not permission to retreat from that contract. Keep both external alternatives valid while documenting that built-in writers choose inline storage and current Lance readers lack support; compatible reader support can follow separately.

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.

Addressed in 6682e7d: external version references remain valid wire alternatives, while the documentation now distinguishes current inline writer behavior and unsupported reader loading from format validity.

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.

Fixed in 6682e7ddc: fields 8 and 10 remain valid external wire alternatives, while the comments now distinguish that contract from current writer and reader limitations.

} // last_updated_at_version_sequence

oneof created_at_version_sequence {
// If small (< 200KB), the row created at versions are stored inline.
// Current Lance writers store created-at versions inline regardless of encoded size.
bytes inline_created_at_versions = 9;
// Otherwise, stored as part of a file.
// Valid external alternative. Current Lance writers do not emit this field,
// and current Lance readers cannot load it.
ExternalFile external_created_at_versions = 10;
} // created_at_version_sequence

Expand Down
Loading