Skip to content

Commit c457674

Browse files
authored
feat: Correctly set fields on Remove action when AMT is enabled (#3318)
## What changes are proposed in this pull request? As described in the Delta RFC: https://github.com/delta-io/delta/blob/master/protocol_rfcs/iceberg-v4-metadata.md#remove-file <!-- **Uncomment** this section if there are any changes affecting public APIs. Else, **delete** this section. ### This PR affects the following public APIs If there are breaking changes, please ensure the `breaking-changes` label gets added by CI, and describe why the changes are needed. Note that _new_ public APIs are not considered breaking. --> ## How was this change tested?
1 parent c11be8a commit c457674

4 files changed

Lines changed: 173 additions & 14 deletions

File tree

kernel/src/actions/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1098,14 +1098,19 @@ pub(crate) struct Remove {
10981098
pub(crate) path: String,
10991099

11001100
/// The time this logical file was created, as milliseconds since the epoch.
1101+
///
1102+
/// Must be null when adaptiveMetadata is enabled on the table since metadata cleanup
1103+
/// uses tree reachability instead of timestamp-based expiration.
11011104
#[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))]
11021105
pub(crate) deletion_timestamp: Option<i64>,
11031106

11041107
/// When `false` the logical file must already be present in the table or the records
11051108
/// in the added file must be contained in one or more remove actions in the same version.
11061109
pub(crate) data_change: bool,
11071110

1108-
/// When true the fields `partition_values`, `size`, and `tags` are present
1111+
/// When true, the fields `partition_values` and `size` are present
1112+
///
1113+
/// Must be true when adaptiveMetadata is enabled on the table.
11091114
#[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))]
11101115
pub(crate) extended_file_metadata: Option<bool>,
11111116

@@ -1127,6 +1132,8 @@ pub(crate) struct Remove {
11271132
/// Contains [statistics] (e.g., count, min/max values for columns) about the data in this
11281133
/// logical file encoded as a JSON string.
11291134
///
1135+
/// Must be set when adaptiveMetadata is enabled on the table.
1136+
///
11301137
/// [statistics]: https://github.com/delta-io/delta/blob/master/PROTOCOL.md#Per-file-Statistics
11311138
#[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))]
11321139
pub stats: Option<String>,

kernel/src/transaction/mod.rs

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ use crate::committer::{
2121
use crate::crc::{is_incremental_safe_operation, CrcDelta, FileStatsDelta};
2222
use crate::engine_data::FilteredEngineData;
2323
use crate::error::Error;
24-
#[cfg(feature = "adaptive-metadata-in-dev")]
25-
use crate::expressions::null_lit;
2624
use crate::expressions::UnaryExpressionOp::ToJson;
2725
use crate::expressions::{
28-
col, column_name, lit, ArrayData, ColumnName, ExpressionStructPatch,
26+
col, column_name, lit, null_lit, ArrayData, ColumnName, ExpressionStructPatch,
2927
ExpressionStructPatchBuilder,
3028
};
3129
use crate::log_replay::HasSelectionVector;
@@ -1551,13 +1549,20 @@ impl<S> Transaction<S> {
15511549
.flat_map(|schema| schema.fields().map(|field| field.name().to_owned()))
15521550
.collect();
15531551

1552+
// adaptiveMetadata removes must carry a null deletionTimestamp and extendedFileMetadata =
1553+
// true (see `Remove` and `build_remove_struct_patch`).
1554+
let adaptive_metadata_enabled = self
1555+
.effective_table_config
1556+
.is_feature_enabled(&TableFeature::AdaptiveMetadataPreview);
1557+
15541558
let make_eval = |coalesce_stats_with_parsed: bool| {
15551559
let columns_to_drop: Vec<_> = columns_to_drop.iter().map(String::as_str).collect();
15561560
let patch = build_remove_struct_patch(
15571561
self.commit_timestamp,
15581562
self.data_change,
15591563
&columns_to_drop,
15601564
coalesce_stats_with_parsed,
1565+
adaptive_metadata_enabled,
15611566
)?;
15621567
let expr = Arc::new(Expression::struct_from([Expression::struct_patch(patch)?]));
15631568
evaluation_handler.new_expression_evaluator(
@@ -1603,21 +1608,32 @@ impl<S> Transaction<S> {
16031608
/// - `partitionValues_parsed`: dropped if present. Unlike stats, no reconstruction is needed: the
16041609
/// Remove action's `partitionValues` is sourced from `fileConstantValues.partitionValues`, which
16051610
/// scans always populate from `add.partitionValues`.
1611+
///
1612+
/// When `adaptive_metadata_enabled` is set, the RFC requires `deletionTimestamp` to be null
1613+
/// (cleanup uses tree reachability, not timestamp expiry), so it is emitted as a fixed literal
1614+
/// rather than derived from the input.
16061615
fn build_remove_struct_patch(
16071616
commit_timestamp: i64,
16081617
data_change: bool,
16091618
columns_to_drop: &[&str],
16101619
coalesce_stats_with_parsed: bool,
1620+
adaptive_metadata_enabled: bool,
16111621
) -> DeltaResult<ExpressionStructPatch> {
1622+
let deletion_timestamp = if adaptive_metadata_enabled {
1623+
null_lit(DataType::LONG)
1624+
} else {
1625+
lit(commit_timestamp)
1626+
};
16121627
// Note: The Delta protocol requires `partitionValues`, `size`, and `tags` when
16131628
// `extendedFileMetadata` is true. We require only `partitionValues` and `size` to match Spark.
1629+
// Under adaptiveMetadata both are guaranteed present
16141630
let extended_file_metadata = Predicate::and_from([
16151631
col!(SIZE_NAME).is_not_null(),
16161632
col!(FILE_CONSTANT_VALUES_NAME, PARTITION_VALUES_NAME).is_not_null(),
16171633
]);
16181634
let mut patch = ExpressionStructPatchBuilder::new()
16191635
// deletionTimestamp
1620-
.insert_after("path", lit(commit_timestamp))
1636+
.insert_after("path", deletion_timestamp)
16211637
// dataChange
16221638
.insert_after("path", lit(data_change))
16231639
// extended_file_metadata
@@ -2097,27 +2113,52 @@ mod tests {
20972113
Ok(())
20982114
}
20992115

2100-
#[test]
2101-
fn test_remove_action_projection_sets_extended_metadata() -> DeltaResult<()> {
2116+
/// Verifies the Remove projection's `deletionTimestamp` and `extendedFileMetadata` fields.
2117+
/// `extendedFileMetadata` is always a presence predicate; `deletionTimestamp` derives from the
2118+
/// input outside adaptiveMetadata and is fixed to null under it per the RFC.
2119+
#[rstest]
2120+
#[case::standard(false)]
2121+
#[case::adaptive_metadata(true)]
2122+
fn test_remove_action_projection_deletion_timestamp_and_extended_metadata(
2123+
#[case] adaptive_metadata_enabled: bool,
2124+
) -> DeltaResult<()> {
2125+
let commit_timestamp = 123;
21022126
let patch = build_remove_struct_patch(
2103-
0, /* commit_timestamp */
2127+
commit_timestamp,
21042128
true, /* data_change */
21052129
&[], /* columns_to_drop */
21062130
false, /* coalesce_stats_with_parsed */
2131+
adaptive_metadata_enabled,
21072132
)?;
21082133
let path_patch = patch
21092134
.field_patches
21102135
.get("path")
21112136
.expect("path should have inserted fields");
2137+
// Insertions preserve `insert_after` call order: deletionTimestamp, dataChange,
2138+
// extendedFileMetadata, partitionValues.
2139+
let deletion_timestamp = path_patch
2140+
.insertions
2141+
.first()
2142+
.expect("deletionTimestamp should be the first inserted field");
21122143
let extended_file_metadata = path_patch
21132144
.insertions
21142145
.get(2)
21152146
.expect("extendedFileMetadata should follow deletionTimestamp and dataChange");
2116-
let expected = Expression::from_pred(Predicate::and_from([
2147+
2148+
let expected_deletion_timestamp = if adaptive_metadata_enabled {
2149+
null_lit(DataType::LONG)
2150+
} else {
2151+
lit(commit_timestamp)
2152+
};
2153+
let expected_extended_file_metadata = Expression::from_pred(Predicate::and_from([
21172154
col!(SIZE_NAME).is_not_null(),
21182155
col!(FILE_CONSTANT_VALUES_NAME, PARTITION_VALUES_NAME).is_not_null(),
21192156
]));
2120-
assert_eq!(extended_file_metadata.as_ref(), &expected);
2157+
assert_eq!(deletion_timestamp.as_ref(), &expected_deletion_timestamp);
2158+
assert_eq!(
2159+
extended_file_metadata.as_ref(),
2160+
&expected_extended_file_metadata
2161+
);
21212162
Ok(())
21222163
}
21232164

kernel/tests/integration/write/remove_dv.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,76 @@ async fn test_remove_files_adds_expected_entries() -> Result<(), Box<dyn std::er
579579
Ok(())
580580
}
581581

582+
/// End-to-end check that a Remove committed to an adaptiveMetadata table conforms to the RFC:
583+
/// `deletionTimestamp` is null (cleanup uses tree reachability, not timestamp expiry) and
584+
/// `extendedFileMetadata` is true. Outside adaptiveMetadata,
585+
/// `test_remove_files_adds_expected_entries` covers the timestamped, conditionally-extended shape.
586+
#[cfg(feature = "adaptive-metadata-in-dev")]
587+
#[tokio::test]
588+
async fn remove_on_adaptive_metadata_table_nulls_deletion_timestamp_and_forces_extended_metadata(
589+
) -> Result<(), Box<dyn std::error::Error>> {
590+
let tmp_dir = tempdir()?;
591+
let tmp_dir_url = Url::from_directory_path(tmp_dir.path()).unwrap();
592+
let (store, engine, table_location) =
593+
test_utils::engine_store_setup("adaptive_remove", Some(&tmp_dir_url));
594+
let engine = Arc::new(engine);
595+
let schema = schema_ref! { nullable "number": INTEGER };
596+
597+
let table_url = test_utils::create_table_with_column_mapping_mode(
598+
store,
599+
table_location,
600+
schema,
601+
&[], // no partition columns
602+
true, // (3, 7) protocol
603+
vec!["adaptiveMetadata-preview"],
604+
vec![],
605+
"id",
606+
)
607+
.await?;
608+
609+
// v1: append a data file.
610+
let snapshot = Snapshot::builder_for(table_url.clone()).build(engine.as_ref())?;
611+
insert_data(
612+
snapshot,
613+
&engine,
614+
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
615+
)
616+
.await?
617+
.unwrap_committed();
618+
619+
// v2: remove the file.
620+
let snapshot = Snapshot::builder_for(table_url.clone()).build(engine.as_ref())?;
621+
let scan_files = snapshot
622+
.clone()
623+
.scan_builder()
624+
.build()?
625+
.scan_metadata(engine.as_ref())?
626+
.next()
627+
.expect("one scan-metadata batch")?
628+
.scan_files;
629+
let mut txn = begin_transaction(snapshot, engine.as_ref())?.with_data_change(true);
630+
txn.remove_files(scan_files);
631+
txn.ack_row_tracking_preservation();
632+
let version = txn
633+
.commit(engine.as_ref())?
634+
.unwrap_committed()
635+
.commit_version();
636+
637+
let removes = read_actions_from_commit(&table_url, version, "remove")?;
638+
assert_eq!(removes.len(), 1, "expected exactly one remove action");
639+
let remove = &removes[0];
640+
assert!(
641+
remove.get("deletionTimestamp").is_none_or(|v| v.is_null()),
642+
"deletionTimestamp must be null under adaptiveMetadata, got {remove}"
643+
);
644+
assert_eq!(
645+
remove["extendedFileMetadata"].as_bool(),
646+
Some(true),
647+
"extendedFileMetadata must be true under adaptiveMetadata, got {remove}"
648+
);
649+
Ok(())
650+
}
651+
582652
/// Verifies that `extendedFileMetadata` is true exactly when `size` and `partitionValues` are
583653
/// present; `tags` does not affect it.
584654
///

test-utils/src/lib.rs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -814,13 +814,24 @@ async fn create_table_impl(
814814
}
815815
}
816816

817+
// adaptiveMetadata auto-enables its dependencies (see `enable_adaptive_metadata_dependencies`)
818+
// so callers can pass just `adaptiveMetadata-preview` and get a loadable table.
819+
let enable_adaptive_metadata = reader_features.contains(&"adaptiveMetadata-preview")
820+
|| writer_features.contains(&"adaptiveMetadata-preview");
821+
if enable_adaptive_metadata {
822+
enable_adaptive_metadata_dependencies(&mut reader_features, &mut writer_features);
823+
}
824+
817825
// Column mapping requires per-field `id`/`physicalName` metadata, without which snapshot load
818-
// fails. Assign it here (with nested ids for iceberg v3); `max_column_id` feeds
819-
// `delta.columnMapping.maxColumnId` below.
826+
// fails. Assign it here (with nested ids for iceberg v3 / adaptiveMetadata); `max_column_id`
827+
// feeds `delta.columnMapping.maxColumnId` below.
820828
let (schema, max_column_id) = if reader_features.contains(&"columnMapping") {
821829
let mut max_id = find_max_column_id_in_schema(&schema).unwrap_or(0);
822-
let schema =
823-
assign_column_mapping_metadata(&schema, &mut max_id, enable_iceberg_compat_v3)?;
830+
let schema = assign_column_mapping_metadata(
831+
&schema,
832+
&mut max_id,
833+
enable_iceberg_compat_v3 || enable_adaptive_metadata,
834+
)?;
824835
(Arc::new(schema), max_id)
825836
} else {
826837
(schema, 0i64)
@@ -954,6 +965,36 @@ async fn create_table_impl(
954965
Ok(table_path)
955966
}
956967

968+
/// Adds the features `adaptiveMetadata-preview` depends on to `reader_features` and
969+
/// `writer_features` (each only if not already present).
970+
///
971+
/// adaptiveMetadata requires column mapping (in `id` mode, set by the caller) plus RowTracking,
972+
/// DomainMetadata, DeletionVectors, and InCommitTimestamp. The ReaderWriter dependencies are
973+
/// mirrored into both feature lists; the writer-only dependencies are added to `writer_features`.
974+
fn enable_adaptive_metadata_dependencies<'a>(
975+
reader_features: &mut Vec<&'a str>,
976+
writer_features: &mut Vec<&'a str>,
977+
) {
978+
// ReaderWriter features must appear in both reader and writer feature lists.
979+
for f in [
980+
"adaptiveMetadata-preview",
981+
"columnMapping",
982+
"deletionVectors",
983+
] {
984+
if !reader_features.contains(&f) {
985+
reader_features.push(f);
986+
}
987+
if !writer_features.contains(&f) {
988+
writer_features.push(f);
989+
}
990+
}
991+
for f in ["rowTracking", "domainMetadata", "inCommitTimestamp"] {
992+
if !writer_features.contains(&f) {
993+
writer_features.push(f);
994+
}
995+
}
996+
}
997+
957998
/// Returns a copy of `schema` with `CURRENT_DEFAULT` metadata attached to the named top-level
958999
/// fields.
9591000
///

0 commit comments

Comments
 (0)