Conversation
|
Important This PR touches the Lance format specification. Substantive changes to the format specification — the If this is a meaningful format change:
|
|
Do you think this will cause transactions to conflict with each other that did not before? I don't think it does today since we treat any schema change as conflicting with any other schema change. Do we know why we reused field ids in the first place? |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
wjones127
left a comment
There was a problem hiding this comment.
The spec looks a lot better. Reviewed the implementation this time.
| The writer may temporarily add `kind`, `blob_id`, `blob_size`, and `position`. A Lance data file | ||
| stores this descriptor shape: | ||
|
|
||
| ```python | ||
| pa.schema([ | ||
| pa.field( | ||
| "image", | ||
| pa.struct([ | ||
| pa.field("kind", pa.uint8(), nullable=False), | ||
| pa.field("position", pa.uint64(), nullable=False), | ||
| pa.field("size", pa.uint64(), nullable=False), | ||
| pa.field("blob_id", pa.uint32(), nullable=False), | ||
| pa.field("blob_uri", pa.string(), nullable=False), | ||
| ]), | ||
| ), | ||
| ]) | ||
| ``` |
There was a problem hiding this comment.
question(non-blocking): when these fields are stored, how is that represented in both DataFile.fields and DataFile.column_indices? Below it says DataFile.fields = [0]. Are they really missing from the DataFile metadata? If so, how does a reader know where in the Lance file to find those columns?
There was a problem hiding this comment.
The descriptor occupies one physical column: fields = [0] maps to column_indices = [k]. The reader decodes its children from column k using the Blob page layout, so they need no separate mapping entries.
| let err = validate_operation(Some(&manifest), &reused).unwrap_err(); | ||
| assert!( |
There was a problem hiding this comment.
suggestion: could we also assert which error variant this is emitting? I think it would be InvalidInput, right?
There was a problem hiding this comment.
Yes, InvalidInput; the test now checks both the variant and the message.
| let Some(restored_max_field_id) = manifest.max_allocated_field_id else { | ||
| return Err(Error::invalid_input(format!( | ||
| "Cannot restore version {version}: stable field IDs were activated after that version" | ||
| ))); | ||
| }; |
There was a problem hiding this comment.
question: remind me, why can't we restore? What bad happens if we let users restore to a previous state?
There was a problem hiding this comment.
A pre-activation version could bind ID 1 to an integer field while the activation version binds it to a string field. Retaining the current high-water mark prevents future ID reuse, but cannot resolve that existing conflict. Reading the old snapshot is still supported.
| let mut current_id = i64::from(schema_max_id.max(max_existing_id)) + 1; | ||
| let unassigned_count = self.fields_pre_order().filter(|field| field.id < 0).count() as i64; | ||
| if unassigned_count > 0 && current_id + unassigned_count - 1 > i64::from(i32::MAX) { | ||
| return Err(Error::invalid_input( | ||
| "No further field ID can be allocated because IDs are exhausted", | ||
| )); | ||
| } |
There was a problem hiding this comment.
suggestion: could we keep in the i32 domain by using saturating arithmetic?
| let mut current_id = i64::from(schema_max_id.max(max_existing_id)) + 1; | |
| let unassigned_count = self.fields_pre_order().filter(|field| field.id < 0).count() as i64; | |
| if unassigned_count > 0 && current_id + unassigned_count - 1 > i64::from(i32::MAX) { | |
| return Err(Error::invalid_input( | |
| "No further field ID can be allocated because IDs are exhausted", | |
| )); | |
| } | |
| let mut current_id = schema_max_id.max(max_existing_id).saturating_add(1); | |
| let unassigned_count = self.fields_pre_order().filter(|field| field.id < 0).count() as i32; | |
| if unassigned_count > 0 && (current_id - 1).saturating_add(unassigned_count) > i32::MAX { | |
| return Err(Error::invalid_input( | |
| "No further field ID can be allocated because IDs are exhausted", | |
| )); | |
| } |
There was a problem hiding this comment.
The wider cursor can represent i32::MAX + 1 after assigning the last valid ID, so the next checked conversion reports exhaustion; saturating arithmetic would lose that distinction.
| /// Transient schema-metadata marker used by bindings for raw Arrow input. | ||
| pub const TRANSACTION_SCHEMA_SOURCE_RAW_ARROW: &str = "lance:transaction_schema_source_raw_arrow"; |
There was a problem hiding this comment.
question: what's the purpose of this? When is this necessary?
There was a problem hiding this comment.
Arrow conversion may number [b, a] as [0, 1], even when the dataset has a = 0, b = 1. This is now resolved at the Arrow conversion boundary, so commits need no source marker or mode; Project still preserves explicit IDs for renames.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The allocator simplification preserves the current implementation contract, but the one remaining rollout requirement is still absent: explicit migration must require retiring every pre-v12.0.0-beta.4 writer before activation. Add that precondition to the Rust API and the schema/versioning operator docs so a one-way migration cannot be followed by an older writer that drops the high-water state.
| that sets only one is invalid. The reader flag for stable field IDs must remain unset because the | ||
| feature does not change read behavior. | ||
|
|
||
| A dataset changes to stable field IDs only through an explicit migration commit. |
There was a problem hiding this comment.
This migration remains unsafe while any pre-v12.0.0-beta.4 writer can still commit. Those versions predate the generic writer-feature check, so they can republish an activated manifest without max_allocated_field_id; a later add can then reuse a retired ID and violate the durable identity contract. Make writer retirement a required precondition here, in versioning.md, and in Dataset::migrate_to_stable_field_ids before the one-way commit. This succeeds the malformed predecessor and preserves the same unresolved rollout boundary.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The merge from main does not change the pull-request patch, so the remaining rollout blocker is unchanged: explicit activation must require retiring every writer older than v12.0.0-beta.4. Document that mandatory precondition in Dataset::migrate_to_stable_field_ids and the schema/versioning operator docs; otherwise a legacy writer can remove the high-water state after the one-way migration.
| that sets only one is invalid. The reader flag for stable field IDs must remain unset because the | ||
| feature does not change read behavior. | ||
|
|
||
| A dataset changes to stable field IDs only through an explicit migration commit. |
There was a problem hiding this comment.
This remains unsafe while any pre-v12.0.0-beta.4 writer can still commit. Those versions predate generic writer-feature enforcement, so they can republish an activated manifest without max_allocated_field_id; a later add can then reuse a retired ID. The current revision only merges main, so the previous finding remains unresolved. Make writer retirement a required precondition in this schema contract, versioning.md, and the Dataset::migrate_to_stable_field_ids API documentation before activation.
Decision: manifest validity as an action-design principle, and what it means for
|
…eld (#9447) Closes #9424 ## Mechanism `fix_schema` in `rust/lance/src/io/commit.rs` remaps duplicate field ids, then applies the mapping to the schema: ```rust for (old_field_id, new_field_id) in &old_field_id_mapping { let field = manifest.schema.mut_field_by_id(*old_field_id).unwrap(); field.id = *new_field_id; } ``` Only `id` is written. `Field` carries an independent `pub parent_id` (`rust/lance-core/src/datatypes/field.rs`), and the protobuf conversion in `rust/lance-file/src/datatypes.rs` writes `parent_id: field.parent_id` verbatim rather than recomputing it from the tree. `Schema::mut_field_by_id` recurses into children, so nested fields are reachable targets of this loop. The consequence is durable. Once a struct field is renumbered, its children still name the old parent id, that value is persisted into the manifest, and the read path in `rust/lance-file/src/datatypes.rs` hard-errors on reopen: ``` Field 'item' (id=8) references parent id 2, which must appear earlier in the protobuf field list ``` The tolerant branch immediately above only covers duplicate parent ids that still exist, not a parent id that was renumbered away. The commit succeeds and the table can never be read back. `fix_schema` is called unconditionally on both live commit paths; the check at the top of the function is a fast-path short-circuit, not a guard. ## Fix Propagate the new id to the field's direct children alongside the id write. `Field::set_id` already establishes this convention (it sets `parent_id` and recurses), but it is not directly reusable here because it also assigns ids from a seed for fields with `id < 0`, which is not what this loop wants. The update is order-independent. A grandchild names its own parent's id, which that parent's own mapping entry handles, so no entry depends on another having run first. New ids start at `max_field_id() + 1`, so a new id can never collide with an old id still pending in the mapping. ## Why #8658 does not cover this PR #8658 (`feat: stabilize field IDs across schema evolution`) touches `fix_schema`, but only to make it `pub(crate)`, to early-return when stable field ids are active, and to widen the id arithmetic to i64. It does not re-parent children, so it neither fixes nor obsoletes this. It is also currently CONFLICTING with CHANGES_REQUESTED. ## On the validation alternative The issue notes that the trigger requires a writer committing duplicate field coverage across two data files in one fragment, and concedes that such a writer is itself buggy. It offers an alternative of rejecting duplicate coverage at validation instead. That alternative is reasonable and this change does not preclude it. But under either approach, a fixup that runs and then emits an unopenable manifest is indefensible: if duplicate coverage is later rejected outright, this loop becomes unreachable and harmless, and until then it should not corrupt the manifest it is meant to repair. ## Tests New `test_fix_schema_nested` covers `struct s { text, struct inner { ordinal } }` with duplicate coverage on both structs, asserting children and grandchildren follow their renumbered parents, plus a closing sweep that every non-root field names a parent id that still exists. Before, on unmodified source: ``` ---- io::commit::tests::test_fix_schema_nested stdout ---- thread 'io::commit::tests::test_fix_schema_nested' panicked at rust/lance/src/io/commit.rs:2584:9: assertion `left == right` failed: child of a renumbered struct kept a stale parent_id left: 1 right: 5 test result: FAILED. 131 passed; 1 failed; 0 ignored; 0 measured; 3733 filtered out; finished in 0.34s ``` After: ``` test io::commit::tests::test_fix_schema ... ok test io::commit::tests::test_fix_schema_nested ... ok test result: ok. 132 passed; 0 failed; 0 ignored; 0 measured; 3733 filtered out; finished in 0.31s ``` `cargo fmt --all --check` and `cargo clippy -p lance --tests -- -D warnings` are both clean on the pinned 1.97.0 toolchain. Note that `cargo clippy --all` fails on `lance-core` on current main independently of this change (three `chunks_exact` findings in `utils/bloomfilter/sbbf.rs` and `utils/stable_partition.rs`), but only under a newer clippy than the pin, so it should not affect CI.
Field IDs are physical bindings in Lance, but legacy allocation derives the next ID from fields that the current snapshot still references. After a field and its files disappear, a later field can reuse the same integer. A bare field ID is therefore not a durable identity across schema evolution.
This PR adds an explicit, writer-side stable field-ID contract:
migrate_to_stable_field_ids.max_allocated_field_idandFLAG_STABLE_FIELD_IDS. Both values must be present for stable mode or absent for legacy mode; either mismatch is invalid. Stable field IDs do not set a reader flag.The migration API is Rust-only in this PR. Python and Java enforce the contract when they operate on an activated dataset, but they do not expose activation yet.
The format documentation also defines how Blob logical fields participate in dataset field identity while writer-prepared and stored descriptor children remain file-local representation details.
Validation includes Rust stable/legacy migration, schema evolution, retry/rebase, restore, clone, detached commit, Blob identity, and Arrow canonicalization coverage, plus Java Merge, Project, and Overwrite boundary coverage.