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
301 changes: 296 additions & 5 deletions rust/lance/src/dataset/fragment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,23 @@ use arrow_array::types::UInt64Type;
use arrow_array::{
Array, RecordBatch, RecordBatchReader, StructArray, UInt32Array, UInt64Array, new_null_array,
};
use arrow_schema::Schema as ArrowSchema;
use arrow_schema::{DataType, Field as ArrowField, Fields as ArrowFields, Schema as ArrowSchema};
use datafusion::logical_expr::Expr;
use datafusion::scalar::ScalarValue;
use futures::future::{BoxFuture, try_join_all};
use futures::{FutureExt, StreamExt, TryFutureExt, TryStreamExt, join, stream};
use futures::{FutureExt, Stream, StreamExt, TryFutureExt, TryStreamExt, join, stream};
use lance_arrow::json::{convert_json_columns, has_json_fields, is_arrow_json_field};
use lance_arrow::{RecordBatchExt, SchemaExt};
use lance_core::datatypes::{OnMissing, OnTypeMismatch, SchemaCompareOptions};
use lance_core::datatypes::{
NullabilityComparison, OnMissing, OnTypeMismatch, SchemaCompareOptions,
};
use lance_core::utils::address::RowAddress;
use lance_core::utils::deletion::DeletionVector;
use lance_core::utils::tokio::get_num_compute_intensive_cpus;
use lance_core::{
Error, Result,
cache::{CacheKey, CacheKeySchema, KeyBuilder},
datatypes::Schema,
datatypes::{Field, Schema, Schema as LanceSchema},
};
use lance_core::{
ROW_ADDR, ROW_ADDR_FIELD, ROW_CREATED_AT_VERSION_FIELD, ROW_ID, ROW_ID_FIELD,
Expand All @@ -56,6 +58,7 @@ use lance_table::utils::stream::{
ReadBatchFutStream, ReadBatchTask, ReadBatchTaskStream, RowIdAndDeletesConfig,
wrap_with_row_id_and_delete,
};
use object_store::path::Path;
use roaring::RoaringBitmap;

use self::write::FragmentCreateBuilder;
Expand All @@ -65,7 +68,7 @@ use super::rowids::load_row_id_sequence;
use super::scanner::Scanner;

use super::updater::Updater;
use super::{NewColumnTransform, WriteParams, schema_evolution};
use super::{NewColumnTransform, WriteParams, schema_evolution, versions};
use crate::dataset::Dataset;
use crate::dataset::fragment::session::FragmentSession;
use crate::dataset::overlay::{
Expand Down Expand Up @@ -732,6 +735,80 @@ enum MetadataMode {
Full,
}

/// The first path in `fields` that names a sibling twice. Projection picks
/// children by name, so a duplicate makes that choice arbitrary, and the
/// name-set comparison the schema check uses cannot see one at all.
fn duplicate_field_path(fields: &ArrowFields, path: &str) -> Option<String> {
let mut seen = HashSet::new();
for field in fields {
let qualified = if path.is_empty() {
field.name().clone()
} else {
format!("{path}.{}", field.name())
};
if !seen.insert(field.name()) {
return Some(qualified);
}
if let Some(nested) = duplicate_nested_path(field.data_type(), &qualified) {
return Some(nested);
}
}
None
}

fn duplicate_nested_path(data_type: &DataType, path: &str) -> Option<String> {
match data_type {
DataType::Struct(children) => duplicate_field_path(children, path),
DataType::List(item)
| DataType::LargeList(item)
| DataType::FixedSizeList(item, _)
| DataType::Map(item, _) => {
duplicate_nested_path(item.data_type(), &format!("{path}.item"))
}
_ => None,
}
}

/// The first id in `field`'s subtree that `schema` already defines elsewhere.
fn borrowed_field_id(field: &Field, schema: &Schema) -> Option<i32> {
if schema.field_by_id(field.id).is_some() {
return Some(field.id);
}
field
.children
.iter()
.find_map(|child| borrowed_field_id(child, schema))
}

/// `field` with nullability dropped at every level: the projector rebuilds
/// arrays against its target and panics rather than reports on a constraint,
/// so it gets a shape that cannot fail and the writer objects instead.
fn relax_nullability(field: &ArrowField) -> ArrowField {
let relax = |field: &Arc<ArrowField>| Arc::new(relax_nullability(field));
let data_type = match field.data_type() {
DataType::Struct(children) => DataType::Struct(children.iter().map(relax).collect()),
DataType::List(item) => DataType::List(relax(item)),
DataType::LargeList(item) => DataType::LargeList(relax(item)),
DataType::FixedSizeList(item, width) => DataType::FixedSizeList(relax(item), *width),
// A Map's entries struct and its key stay required -- Arrow rejects a
// map whose entries or keys are nullable -- so only the value relaxes.
DataType::Map(entries, sorted) => match entries.data_type() {
DataType::Struct(kv) if kv.len() == 2 => {
let value = Arc::new(relax_nullability(&kv[1]));
let entries = ArrowField::new(

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.

Rebuilding the Map entries field with ArrowField::new drops entries.metadata(), which Lance preserves as part of the supported schema. The outer projection then rejects an otherwise valid Map because its array still carries that metadata. Preserve the entries field metadata while relaxing only the value nullability.

Reproducer

On exact head 0790f20d108bc94836e225043eccbebdcdcf65e1, I added repro_map_entries_metadata_projection and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate-target-b cargo test -p lance --lib dataset::tests::fragment_write_column::repro_map_entries_metadata_projection -- --exact --nocapture

The manifest and incoming Map both had entry-semantic=kept on the entries field. Expected: staging succeeds. Observed: projection returned Incorrect datatype: the expected Map had no entries metadata while the incoming Map retained it.

entries.name(),
DataType::Struct(vec![kv[0].clone(), value].into()),
false,
);
DataType::Map(Arc::new(entries), *sorted)
}
_ => field.data_type().clone(),
},
other => other.clone(),

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.

relax_nullability stops before Map, so valid Map data is rejected solely because the incoming value field declares looser nullability. Add Map-aware recursion and projection while preserving the required entries/key invariants, so actual values—not a looser Arrow declaration—decide acceptance.

Executed regression

On exact head 16281a38c7264e2a58fbe3162154d0f4106ee6e2, I added gate_map_looser_declared_nullability_with_valid_data_is_accepted and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-null-schema-0CatjW/target cargo test -p lance gate_ --no-fail-fast -- --nocapture

The V2.2 target Map has a non-nullable values child; the incoming Map declares that child nullable but contains only [10, 20]. Expected: staging succeeds. Observed: the test exits 101 because write_column returns Incorrect datatype ... expected Map(... values: non-null Int32) got Map(... values: Int32).

};
ArrowField::new(field.name(), data_type, true).with_metadata(field.metadata().clone())
}

impl FileFragment {
/// Creates a new FileFragment.
pub fn new(dataset: Arc<Dataset>, metadata: Fragment) -> Self {
Expand Down Expand Up @@ -2011,6 +2088,220 @@ impl FileFragment {
Ok((fragments.into_iter().next().unwrap(), schema))
}

fn schema_mismatch(&self, detail: impl std::fmt::Display) -> Error {
Error::invalid_input(format!(
"column data for fragment {} does not match the requested schema: {detail}",
self.id()
))
}

/// Remove a staged file that will not be returned. Best effort: it is
/// unreachable either way, and must not mask the error that caused it.
async fn discard_staged_file(&self, path: &Path) {
// Blob v2 spills sidecars into data/<file-stem>/ beside the file, and
// those are the large ones; leaving them is what makes a routine
// rejection expensive.
if let Some(stem) = path
.filename()
.and_then(|name| name.strip_suffix(".lance"))
.map(|stem| self.dataset.data_dir().join(stem))
&& let Err(delete_error) = self.dataset.object_store.remove_dir_all(stem.clone()).await
{
log::warn!("failed to delete staged blob sidecars '{stem}': {delete_error}");
}
if let Err(delete_error) = self.dataset.object_store.delete(path).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.

Deleting only the staged .lance path leaves Blob V2 sidecars under data/<file-stem>/, so a routine row-count or write failure can leak arbitrarily large unreferenced objects. Use a failure guard that removes the main file and its sidecar directory on every exit, including stream and finish() errors, matching the existing orphan cleanup contract.

Executed regression

On exact head 16281a38c7264e2a58fbe3162154d0f4106ee6e2, I added probe_blob_row_mismatch_cleans_sidecars and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-impl-riwaOz/target cargo test --locked -p lance probe_ --lib -- --nocapture

The test stages three 70 KiB blobs against a two-row V2.2 fragment. Expected: the row-count error leaves no staged artifacts. Observed: the .lance file is deleted, but data/<staged-key>/10000000000000000000000000000000.blob remains.

log::warn!("failed to delete staged column file '{path}': {delete_error}");
}
}

/// Write new data for a column of this fragment as a standalone data file,
/// without committing it, and return the
/// [`DataReplacementGroup`](super::transaction::DataReplacementGroup)
/// describing it.
///
/// Unlike [`Self::add_columns`], the staged file may answer for a field that
/// already exists, so this recomputes a column rather than only appending.
///
/// `schema` names the fields being written. A field the dataset already
/// defines must match its manifest definition and appear at the path the
/// manifest gives it; anything else is a new column, and nothing beneath it
/// may reuse an existing field's id. Physical layout comes from the
/// manifest, so staging cannot change a field's storage encoding. Batches
/// are matched by name at every level, so struct children may arrive in any
/// order, but a batch not describing exactly that tree is rejected.
///
/// `data` must produce exactly the fragment's physical row count, nulls
/// included: the file is positionally aligned with the fragment and no
/// deletion vector is applied on the way in. Batches are pulled one at a
/// time, so the full column need not be held in memory.
///
/// Staging does not bind the result to the version it was computed from.
/// Concurrent replacements of the same field conflict at commit, but only
/// for a caller that commits with the version it actually read; a caller
/// that supplies a newer one publishes stale values unchallenged.
pub async fn write_column(
&self,
data: impl Stream<Item = Result<RecordBatch>> + Send,
schema: &Schema,
) -> Result<super::transaction::DataReplacementGroup> {

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 staged result has no protection from concurrent schema evolution, while the conflict resolver treats Project and DataReplacement as compatible in both directions. Even with the correct read version, a nullable file can therefore commit after Project makes the manifest field non-nullable, leaving a dataset that cannot be scanned. Make these operations conflict symmetrically, or revalidate the staged field against the commit-time manifest.

Executed regression

I added reproduce_data_replacement_conflicts_with_projected_nullability on this head and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-delta-60690216-target cargo test --locked -p lance reproduce_data_replacement_conflicts_with_projected_nullability -- --nocapture

The test stages nullable data at version 1, commits a valid nullability-tightening Project as version 2, then commits the replacement using read version 1. Expected: Error::RetryableCommitConflict. Observed: version 3 commits, then scanning fails with Found unmasked nulls for non-nullable StructArray field "value".

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 staged result is not bound to the commit-time schema, while the conflict resolver treats Project and DataReplacement as compatible in both directions. A nullable file can therefore commit after Project makes the manifest field non-nullable, leaving a committed dataset that cannot be scanned. Make these operations conflict symmetrically, or revalidate the staged field against the final manifest during commit.

Executed regression

On exact head e21601bd4f4bd925b46d93f964678c62fbd30137 I added gate_repro_data_replacement_conflicts_with_nullability_project and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-main-target cargo test -p lance gate_repro_data_replacement_conflicts_with_nullability_project -- --nocapture

The test stages [10, null] at version 1, commits a valid nullability-tightening Project at version 2, then commits the replacement using read version 1. Expected: a retryable conflict. Observed: the replacement commits, and scanning fails with Found unmasked nulls for non-nullable StructArray field "value".

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 staged result carries no commit-time schema witness, while the conflict resolver treats Project and DataReplacement as compatible in both directions. Even with the correct read version, a nullable replacement can commit after Project makes the field non-nullable, producing a committed dataset that cannot be scanned. Make these operations conflict symmetrically, or revalidate every staged field and its physical layout against the live manifest during commit.

Executed regression

On exact head 15a79a1c04e37291934e157cd4d1b29786ec02d0 I added probe_data_replacement_rebases_across_nullability_project beside the existing fragment-write tests and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate-8313-verify-epo2jl/target cargo test -p lance probe_data_replacement_rebases_across_nullability_project --lib -- --nocapture

The test stages [Some(10), None] at version 1, commits a valid nullability-tightening Project as version 2, then commits the replacement using read version 1. Expected: a retryable conflict. Observed: version 3 commits, then scan fails with Found unmasked nulls for non-nullable StructArray field "value".

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 result is validated only against the staging snapshot, while Project and DataReplacement remain compatible in both transaction directions. A nullable file staged at version 1 can therefore commit after version 2 tightens that field to non-nullable, producing a committed dataset that cannot be scanned. Either conflict these operations symmetrically, or carry the exact recursive target schema in the staged transaction and revalidate it against the live manifest on every build/retry.

Executed regression

On exact head 46a0ab104cc2228c8462237cc47a12a915b421a8, I added gate_repro_data_replacement_rejects_concurrent_nullability_project beside the fragment-write tests and ran:

RUST_BACKTRACE=0 CARGO_TARGET_DIR=/home/agent/tmp/gate8313-current-target cargo test --locked -p lance gate_repro_ --lib -- --nocapture

The case stages [10, null] at version 1, commits a valid non-null Project as version 2, then commits the replacement using read version 1. Expected: reject the replacement. Observed: version 3 commits, then scanning fails with Found unmasked nulls for non-nullable StructArray field "value".

let expected_rows = self.physical_rows().await? as u64;

// Readers take everything but the field id from the manifest, so a
// staged field reusing an id is decoded as the manifest's version rather
// than rejected. Compare full identity, not just the storage type.
let compare_options = SchemaCompareOptions {
compare_field_ids: true,
..Default::default()

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.

Default::default() leaves compare_metadata false. That is unsafe at this file-writing boundary: lance-encoding:packed is field metadata, but it changes physical field coverage. Removing that marker while preserving names, IDs, types, nullability, and children is accepted; the writer then advertises child fields [1, 2] instead of the packed parent [0], so DataReplacement can classify the file as disjoint/all-null coverage rather than replacing the packed field. Validate every layout-defining attribute here, or derive the writer schema from the matched dataset field.

Executed regression

I added reproduce_fragment_write_column_rejects_packed_metadata_mismatch on this head and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-verify-531-target cargo test --locked -p lance reproduce_fragment_write_column_rejects_packed_metadata_mismatch -- --nocapture

The test stages an otherwise-identical V2.1 packed struct after removing only lance-encoding:packed. Expected: write_column rejects the schema. Observed: the assertion result.is_err() fails because it returns Ok(DataReplacementGroup(... DataFile { fields: [1, 2], column_indices: [0, 1], ... })).

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.

Default::default() leaves compare_metadata false. That is unsafe at this file-writing boundary: lance-encoding:packed is metadata, but it changes physical field coverage. Removing only that marker from an otherwise identical packed struct is accepted, and the staged DataFile advertises child coverage instead of the packed parent, so DataReplacement can classify it as disjoint/all-null coverage rather than replacing the field. Validate every layout-defining attribute here, or derive the writer schema from the matched dataset field.

Executed regression

I added reproduce_fragment_write_column_rejects_packed_metadata_mismatch on this head and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-delta-60690216-target cargo test --locked -p lance reproduce_fragment_write_column_rejects_packed_metadata_mismatch -- --nocapture

Expected: write_column rejects the V2.1 schema after only lance-encoding:packed is removed. Observed: the assertion fails because it returns Ok(DataReplacementGroup(... DataFile { fields: [1], column_indices: [0], ... })).

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.

Default::default() leaves compare_metadata false, so this check accepts a schema after the layout-defining lance-encoding:packed marker is removed. The writer then derives different physical field coverage from an otherwise identical field, so a replacement can be classified against the wrong layout. Derive the writer schema from the matched manifest field, or validate every layout-defining attribute before writing.

Executed regression

On exact head e21601bd4f4bd925b46d93f964678c62fbd30137 I added gate_repro_write_column_rejects_packed_metadata_mismatch beside the existing fragment-write tests and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-main-target cargo test -p lance gate_repro_write_column_rejects_packed_metadata_mismatch -- --nocapture

The test creates a packed struct, removes only lance-encoding:packed while preserving IDs, types, children, and nullability, then expects write_column to reject it. Observed: the command exits 101 because write_column returns Ok and the rejection assertion fails.

};
// Top-level requests match top-level manifest fields only: resolving an
// id from anywhere lets a caller reuse a field at a path the dataset
// never gave it, staging a file covering the borrowed field. Layout then
// comes from the manifest, since the metadata the identity check ignores
// -- packed structs, blob encoding -- decides physical field coverage.
let dataset_schema = self.dataset.schema();
let mut writer_fields = Vec::with_capacity(schema.fields.len());
for field in &schema.fields {
// The scanner injects these itself; a stored copy collides with it
// at projection time. Same boundary the ordinary insert path draws.
if lance_core::is_system_column(&field.name) {
return Err(Error::invalid_input(format!(
"column data for fragment {} names reserved column '{}'",
self.id(),
field.name
)));
}
match dataset_schema
.fields
.iter()
.find(|existing| existing.id == field.id)
{
Some(existing) => {
// `explain_difference` recurses, covering the whole subtree.
if let Some(difference) = field.explain_difference(existing, &compare_options) {
return Err(Error::invalid_input(format!(
"column data for fragment {} does not match dataset field id {}: {}",
self.id(),
field.id,
difference
)));
}
writer_fields.push(existing.clone());
}
None => {
// A new column: nothing beneath it may reuse an existing id.
if let Some(borrowed) = borrowed_field_id(field, dataset_schema) {
return Err(Error::invalid_input(format!(
"column data for fragment {} puts dataset field id {} under new column '{}': \
a field must be written at the path the dataset gives it",
self.id(),
borrowed,
field.name
)));
}
writer_fields.push(field.clone());

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.

The new-field branch accepts reserved virtual-column names. A caller can stage and publish a stored _rowid; the scanner then injects its synthetic _rowid beside it and projection fails with a duplicate field. Reject every top-level lance_core::is_system_column name before opening the writer, matching the ordinary insert boundary.

Reproducer

On exact head 7413b8713cec37224b128d45bfbe8276bd90574f, I added repro_rejects_reserved_system_name. It stages _rowid: UInt64 under a fresh ID, commits the returned DataReplacement, then commits a Project containing that field.

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-impl-2I5HBz/target cargo test --locked -p lance repro_rejects_reserved_system_name --lib -- --nocapture

Expected: staging rejects the reserved name. Observed: both commits succeed, then scan.project(&["_rowid"]) fails with Duplicate field name "_rowid" in schema; the command exits 101.

}
}
}
let writer_schema = Schema {
fields: writer_fields,
metadata: schema.metadata.clone(),
};
let batch_schema = ArrowSchema::from(&writer_schema);
let projection_schema = ArrowSchema::new(
batch_schema
.fields()
.iter()
.map(|field| relax_nullability(field))
.collect::<Vec<_>>(),
);

let file_version = ConcreteFileVersion::from(
self.dataset
.manifest
.data_storage_format
.lance_file_version()?,
);

if file_version == ConcreteFileVersion::V1 {
// The legacy reader pairs a fragment's files by batch boundary, so a
// staged file chunked to the caller's batches leaves the fragment
// unreadable. Rechunking is the legacy update path's job, not this
// one's.
return Err(Error::not_supported(format!(
"write_column is not supported for fragment {} in the legacy file format",
self.id()
)));
}

// The update writer, not a raw file writer: that boundary carries the
// version's write policies (blob v2 columns arrive logical and must be
// prepared for the encoders) and returns a populated `DataFile`.
let mut writer =
versions::open_update_writer(file_version, self.dataset.as_ref(), &writer_schema)
.await?;
let staged_path = {
let (file_name, _) = writer.data_file_path();
self.dataset.data_dir().join(file_name)
};

let mut data = std::pin::pin!(data);
while let Some(batch_result) = data.next().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.

Forwarding caller batch boundaries unchanged can commit a V1 replacement whose batch count differs from sibling files, after which the fragment is unreadable. Rechunk V1 input to the existing reader batch layout, as the legacy update path does, or reject V1 before staging.

Executed regression

On exact head 16281a38c7264e2a58fbe3162154d0f4106ee6e2, I added probe_legacy_write_column_preserves_fragment_batch_alignment and ran:

RUST_BACKTRACE=0 CARGO_TARGET_DIR=/home/agent/tmp/gate8313-impl-riwaOz/target cargo test --locked -p lance probe_legacy_write_column_preserves_fragment_batch_alignment --lib -- --nocapture

A four-row V1 fragment has one four-row batch; the replacement arrives as two two-row batches. Expected: staging preserves a readable fragment or rejects the layout. Observed: staging and commit succeed, then scan fails with InvalidInput: Cannot create FragmentReader from data files with different number of batches.

let batch = batch_result?;

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 ? exits after prior writes without calling discard_staged_file; writer.finish().await? has the same gap. Once a Blob pack rolls and finalizes, a later stream error leaves that sidecar orphaned. Put the writer/path behind one failure cleanup path, drop the writer before deletion, and reuse the shared data/sidecar cleanup so stream, write, finish, and validation errors have the same ownership behavior.

Reproducer

On exact head 0790f20d108bc94836e225043eccbebdcdcf65e1, I added repro_discards_blob_sidecars_on_stream_error and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate-target-b cargo test -p lance --lib dataset::tests::fragment_write_column::repro_discards_blob_sidecars_on_stream_error -- --exact --nocapture

With a 128 KiB Blob pack threshold, the stream yielded one successful two-blob batch and then a synthetic error. Expected: the artifact count remains 3. Observed: it became 4; the main .lance count stayed 1 while Blob sidecars increased from 2 to 3.

// Struct encoders consume children positionally, so a batch ordered
// differently from the manifest lands under the wrong field ids.
// Projection fixes that by name, but it downcasts by shape, so the
// whole tree is compared first. Nullability is the writer's to
// enforce, against the data rather than the declared schema.
if let Some(duplicate) = duplicate_field_path(batch.schema_ref().fields(), "") {
self.discard_staged_file(&staged_path).await;
return Err(self.schema_mismatch(format!("column '{duplicate}' appears twice")));
}
if let Err(mismatch) =
LanceSchema::try_from(batch.schema_ref().as_ref()).and_then(|staged| {
staged.check_compatible(
&writer_schema,
&SchemaCompareOptions {
compare_nullability: NullabilityComparison::Ignore,
ignore_field_order: true,

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 order-insensitive comparison does not establish the documented exact tree: duplicate nested sibling names are collapsed during compatibility checking, and projection silently chooses the first child. Reject duplicate names recursively before any name-based reorder.

Executed regression

On exact head 16281a38c7264e2a58fbe3162154d0f4106ee6e2, I added gate_duplicate_nested_name_is_rejected and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-null-schema-0CatjW/target cargo test -p lance gate_ --no-fail-fast -- --nocapture

The input is Struct{x=[10,20], x=[30,40]} against target Struct{x}. Expected: staging rejects the ambiguous tree. Observed: staging and commit succeed, and readback silently keeps the first child [10, 20].

..Default::default()
},
)
})
{
self.discard_staged_file(&staged_path).await;
return Err(self.schema_mismatch(mismatch));
}
let batch = match batch.project_by_schema(&projection_schema) {

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 projection is documented as name-based at every level, but lance_arrow::project_array has no DataType::Map arm. Compatibility accepts a Map value Struct<b, a> against manifest Struct<a, b>, then this call rejects it instead of reordering by name. Rebuild MapArray recursively through its entries/value field while preserving offsets, validity, and the required key/entries invariants.

Reproducer

On exact head 0790f20d108bc94836e225043eccbebdcdcf65e1, I added repro_map_struct_value_reordered_by_name and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate-target-b cargo test -p lance --lib dataset::tests::fragment_write_column::repro_map_struct_value_reordered_by_name -- --exact --nocapture

Expected: the Map value children are reordered and staging succeeds. Observed: projection returned Incorrect datatype, reporting manifest Struct(a, b) versus incoming Struct(b, a).

Ok(batch) => batch,
Err(err) => {
self.discard_staged_file(&staged_path).await;
return Err(self.schema_mismatch(err));
}
};
// The writer applies the manifest's nullability rule to the data;
// a batch it turns down leaves a file nothing will ever reference.
if let Err(err) = writer.write(std::slice::from_ref(&batch)).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.

The writer still checks each required child with its raw null_count() and does not carry ancestor visibility, so valid placeholders beneath null parents are rejected. Propagate validity and container spans through writer-side validation, or normalize hidden slots before writing, while retaining rejection for visible nulls.

Executed regression

On exact head 16281a38c7264e2a58fbe3162154d0f4106ee6e2, I added gate_masked_required_nested_value_is_accepted and ran:

RUST_BACKTRACE=0 CARGO_TARGET_DIR=/home/agent/tmp/gate8313-null-schema-0CatjW/target cargo test -p lance --lib gate_masked_required_nested_value_is_accepted -- --nocapture

Expected: masked required descendants stage successfully. Observed: all four Struct, List, LargeList, and V2.2 FixedSizeList cases fail at lance-file/src/writer/structural.rs:451 with required-child null errors. The corresponding visible-null List/LargeList controls are still rejected correctly.

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.

The relaxed projection schema does not change the manifest schema held by StructuralWriter, whose recursive check still uses each child's raw null_count(). A required child null hidden by a null nullable parent is therefore rejected even though that is a valid Arrow value. Carry ancestor visibility through writer validation, or normalize hidden placeholders before this call while retaining rejection for visible nulls.

Reproducer

On exact head 0790f20d108bc94836e225043eccbebdcdcf65e1, I added repro_masked_required_child_is_rejected and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate-target-a cargo test -p lance --lib dataset::tests::fragment_write_column -- --nocapture

The staged nullable struct was [{required: 10}, null]; its hidden child slot was null while the manifest child was required. Expected: staging succeeds. Observed: write_column returned the writer's marked non-null error.

self.discard_staged_file(&staged_path).await;
return Err(err);
}
}
let (num_rows, data_file) = writer.finish().await?;
if num_rows as u64 != expected_rows {
self.discard_staged_file(&staged_path).await;
return Err(Error::invalid_input(format!(
"column data for fragment {} has {} rows but the fragment has {} physical rows",
self.id(),
num_rows,
expected_rows
)));
}

Ok(super::transaction::DataReplacementGroup(

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.

Returning only the fragment ID and data file discards the logical-update witness. Operation::DataReplacement does not advance stable-row-ID last_updated_at_version metadata, so delta reads can omit rows whose values this API changed. Route recomputation through the shared update/Merge machinery that records matched offsets, or extend this staged result and commit path to update row-lineage metadata atomically.

Executed regression

I added reproduce_fragment_write_column_does_not_advance_row_lineage and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-main-target cargo test --locked -p lance reproduce_ -- --nocapture

The test creates a version-1 dataset with stable row IDs, replaces both value rows, and commits version 2. Expected: _row_last_updated_at_version is [2, 2]. Observed: the values change, but the lineage column remains [1, 1].

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.

Returning only the fragment ID and data file discards the logical-update witness. Operation::DataReplacement does not advance stable-row-ID last_updated_at_version metadata, so delta reads can omit rows whose values this API changed. Route recomputation through the shared Update/Merge lineage machinery that records matched offsets, or extend this staged result and commit path to update row-lineage metadata atomically.

Executed regression

I added a current-head stable-row-ID case beside the fragment write_column tests and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-main-target cargo test --locked -p lance reproduce_fragment_write_column -- --nocapture

The test created a version-1 dataset with stable row IDs, replaced value from [1, 2] to [30, 40], and committed version 2. Expected: _row_last_updated_at_version becomes [2, 2]. Observed: the values change, but the lineage column remains [1, 1].

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.

Returning only the fragment ID and data file still discards the logical-update witness. The DataReplacement manifest path changes files and indices but never advances stable-row-ID last_updated_at_version, so delta reads can omit rows whose values this API recomputed. Route this through the shared Update/Merge lineage machinery, or carry affected offsets in the staged result and update lineage atomically at commit.

Executed regression

I added reproduce_fragment_write_column_advances_row_lineage on this head and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-verify-531-target cargo test --locked -p lance reproduce_fragment_write_column_advances_row_lineage -- --nocapture

The test creates a version-1 dataset with stable row IDs, writes [30, 40] through write_column, and commits DataReplacement as version 2. Expected: _row_last_updated_at_version is [2, 2]. Observed: the assertion fails because it remains [1, 1].

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.

Returning only the fragment ID and data file discards the logical-update witness. The DataReplacement manifest path changes files and indices but never advances stable-row-ID last_updated_at_version, so delta reads can omit rows whose values this API recomputed. Route this through the shared Update/Merge lineage machinery, or carry affected offsets in the staged result and update lineage atomically at commit.

Executed regression

I added reproduce_fragment_write_column_advances_row_lineage on this head and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-delta-60690216-target cargo test --locked -p lance reproduce_fragment_write_column_advances_row_lineage -- --nocapture

The test creates a version-1 dataset with stable row IDs, writes [30, 40] through write_column, and commits DataReplacement as version 2. Expected: _row_last_updated_at_version is [2, 2]. Observed: the assertion fails because it remains [1, 1].

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 result carries no schema witness, so DataReplacement can append a field absent from the live manifest and commit an invalid fragment; a compatible concurrent Project drop reaches the same state for a field that was valid when staged. Reject fields missing from the commit-time schema and conflict field-removing projections, or carry schema evolution in the transaction and apply it atomically.

Executed regressions

On exact head 16281a38c7264e2a58fbe3162154d0f4106ee6e2, I added and ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate8313-replacement-dLetU2/target cargo test -p lance --lib gate_new_field_replacement_commits_invisibly -- --nocapture
CARGO_TARGET_DIR=/home/agent/tmp/gate8313-replacement-dLetU2/target cargo test -p lance --lib gate_replacement_rebased_over_drop_commits_invalid_manifest -- --nocapture

In the first case, a fresh field stages and commits but remains absent from the schema. In the second, a staged existing field rebases over a concurrent drop and commits. Expected: atomic schema evolution or rejection/retry. Observed in both: commit succeeds and validate() returns CorruptFile: ... did not have any fields in common with the dataset schema.

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 result still drops the schema witness required at commit. A fresh field commits while absent from the manifest, and a staged existing field can rebase over a concurrent Project drop because DataReplacement and Project remain compatible; both produce a fragment whose file has no field in the live schema. Either reject undeclared fields and conflict field-removing projections, or carry the exact recursive field projection in DataReplacementGroup and revalidate it against the commit-time manifest.

Reproducer

On exact head 0790f20d108bc94836e225043eccbebdcdcf65e1, I added repro_fresh_field_commits_invalid_manifest and repro_project_drop_races_replacement_into_invalid_manifest, then ran:

CARGO_TARGET_DIR=/home/agent/tmp/gate-target-a cargo test -p lance --lib dataset::tests::fragment_write_column -- --nocapture

Expected: either staging or commit rejects the schema-absent field. Observed: both commits succeeded; the live schema lacked the field and validate() failed because the fragment file referenced no live schema field.

self.id() as u64,
data_file,
))
}

/// Delete rows from the fragment.
///
/// If all rows are deleted, returns `Ok(None)`. Otherwise, returns a new
Expand Down
Loading
Loading