Skip to main content

iceberg/transaction/
append.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::{HashMap, HashSet};
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use uuid::Uuid;
23
24use crate::error::Result;
25use crate::spec::{DataFile, ManifestEntry, ManifestFile, Operation};
26use crate::table::Table;
27use crate::transaction::snapshot::{
28    DefaultManifestProcess, SnapshotProduceOperation, SnapshotProducer,
29};
30use crate::transaction::{ActionCommit, TransactionAction};
31
32/// FastAppendAction is a transaction action for fast append data files to the table.
33pub struct FastAppendAction {
34    check_duplicate: bool,
35    // below are properties used to create SnapshotProducer when commit
36    commit_uuid: Option<Uuid>,
37    snapshot_properties: HashMap<String, String>,
38    added_data_files: Vec<DataFile>,
39}
40
41impl FastAppendAction {
42    pub(crate) fn new() -> Self {
43        Self {
44            check_duplicate: true,
45            commit_uuid: None,
46            snapshot_properties: HashMap::default(),
47            added_data_files: vec![],
48        }
49    }
50
51    /// Set whether to check duplicate files
52    pub fn with_check_duplicate(mut self, v: bool) -> Self {
53        self.check_duplicate = v;
54        self
55    }
56
57    /// Add data files to the snapshot.
58    pub fn add_data_files(mut self, data_files: impl IntoIterator<Item = DataFile>) -> Self {
59        self.added_data_files.extend(data_files);
60        self
61    }
62
63    /// Set commit UUID for the snapshot.
64    pub fn set_commit_uuid(mut self, commit_uuid: Uuid) -> Self {
65        self.commit_uuid = Some(commit_uuid);
66        self
67    }
68
69    /// Set snapshot summary properties.
70    pub fn set_snapshot_properties(mut self, snapshot_properties: HashMap<String, String>) -> Self {
71        self.snapshot_properties = snapshot_properties;
72        self
73    }
74
75    /// Collapse files sharing a path to their first occurrence, so a single
76    /// manifest never references the same file twice. Always runs (unlike the
77    /// `check_duplicate`-gated cross-snapshot check) since it is in-memory only.
78    fn dedupe_added_files(&self) -> Vec<DataFile> {
79        let mut seen = HashSet::with_capacity(self.added_data_files.len());
80        self.added_data_files
81            .iter()
82            .filter(|data_file| seen.insert(data_file.file_path.as_str()))
83            .cloned()
84            .collect()
85    }
86}
87
88#[async_trait]
89impl TransactionAction for FastAppendAction {
90    async fn commit(self: Arc<Self>, table: &Table) -> Result<ActionCommit> {
91        let snapshot_producer = SnapshotProducer::new(
92            table,
93            self.commit_uuid.unwrap_or_else(Uuid::now_v7),
94            self.snapshot_properties.clone(),
95            self.dedupe_added_files(),
96        );
97
98        // validate added files
99        snapshot_producer.validate_added_data_files()?;
100
101        // Checks duplicate files
102        if self.check_duplicate {
103            snapshot_producer.validate_duplicate_files().await?;
104        }
105
106        snapshot_producer
107            .commit(FastAppendOperation, DefaultManifestProcess)
108            .await
109    }
110}
111
112struct FastAppendOperation;
113
114impl SnapshotProduceOperation for FastAppendOperation {
115    fn operation(&self) -> Operation {
116        Operation::Append
117    }
118
119    async fn delete_entries(
120        &self,
121        _snapshot_produce: &SnapshotProducer<'_>,
122    ) -> Result<Vec<ManifestEntry>> {
123        Ok(vec![])
124    }
125
126    async fn existing_manifest(
127        &self,
128        snapshot_produce: &SnapshotProducer<'_>,
129    ) -> Result<Vec<ManifestFile>> {
130        let Some(snapshot) = snapshot_produce.table.metadata().current_snapshot() else {
131            return Ok(vec![]);
132        };
133
134        let manifest_list = snapshot_produce
135            .table
136            .manifest_list_reader(snapshot)
137            .load()
138            .await?;
139
140        Ok(manifest_list
141            .entries()
142            .iter()
143            .filter(|entry| {
144                // Keep delete-only manifests too: they record which files were removed and
145                // must persist across snapshots until `expire_snapshots` cleans them up.
146                // Dropping them lets the removed files reappear as live data (see #2148).
147                entry.has_added_files() || entry.has_existing_files() || entry.has_deleted_files()
148            })
149            .cloned()
150            .collect())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use std::collections::HashMap;
157    use std::fs;
158    use std::sync::Arc;
159
160    use minijinja::{AutoEscape, Environment, Value, context};
161    use tempfile::TempDir;
162    use uuid::Uuid;
163
164    use crate::encryption::kms::MemoryKeyManagementClient;
165    use crate::encryption::{SensitiveBytes, StandardKeyMetadata};
166    use crate::io::FileIO;
167    use crate::spec::{
168        DataContentType, DataFile, DataFileBuilder, DataFileFormat, Literal, MAIN_BRANCH,
169        ManifestEntry, ManifestListWriter, ManifestStatus, ManifestWriterBuilder, SnapshotRef,
170        Struct, TableMetadata,
171    };
172    use crate::table::Table;
173    use crate::test_utils::{make_encrypted_table, test_runtime};
174    use crate::transaction::tests::make_v2_minimal_table;
175    use crate::transaction::{Transaction, TransactionAction};
176    use crate::{TableIdent, TableRequirement, TableUpdate};
177
178    fn render_template(template: &str, ctx: Value) -> String {
179        let mut env = Environment::new();
180        env.set_auto_escape_callback(|_| AutoEscape::None);
181        env.render_str(template, ctx).unwrap()
182    }
183
184    /// Builds a table whose current snapshot's manifest list contains a data manifest
185    /// followed by a delete-only manifest (one entry with `ManifestStatus::Deleted`,
186    /// so `deleted_files_count > 0` while `added_files_count == existing_files_count == 0`).
187    ///
188    /// Returns the table plus the `manifest_path` of the delete-only manifest so callers
189    /// can assert whether a subsequent append carries it forward.
190    async fn make_table_with_delete_only_manifest() -> (Table, TempDir, String) {
191        let tmp_dir = TempDir::new().unwrap();
192        let table_location = tmp_dir.path().join("table1");
193        let manifest_list_location = table_location.join("metadata/manifests_list_1.avro");
194        let table_metadata_location = table_location.join("metadata/v1.json");
195
196        let file_io = FileIO::new_with_fs();
197
198        let template = fs::read_to_string(format!(
199            "{}/testdata/example_table_metadata_v2.json",
200            env!("CARGO_MANIFEST_DIR")
201        ))
202        .unwrap();
203        // The template has two snapshots; point the current one at our manifest list.
204        let metadata_json = render_template(&template, context! {
205            table_location => &table_location,
206            manifest_list_1_location => &manifest_list_location,
207            manifest_list_2_location => &manifest_list_location,
208            table_metadata_1_location => &table_metadata_location,
209        });
210        let table_metadata = serde_json::from_str::<TableMetadata>(&metadata_json).unwrap();
211
212        let table = Table::builder()
213            .metadata(table_metadata)
214            .identifier(TableIdent::from_strs(["db", "table1"]).unwrap())
215            .file_io(file_io)
216            .metadata_location(table_metadata_location.to_str().unwrap())
217            .runtime(test_runtime())
218            .build()
219            .unwrap();
220
221        let current_snapshot = table.metadata().current_snapshot().unwrap();
222        let schema = current_snapshot.schema(table.metadata()).unwrap();
223        let partition_spec = table.metadata().default_partition_spec();
224
225        let next_manifest_file = |location: &str| {
226            table
227                .file_io()
228                .new_output(format!(
229                    "{}/metadata/manifest_{}.avro",
230                    location,
231                    Uuid::new_v4()
232                ))
233                .unwrap()
234        };
235        let table_location_str = table_location.to_str().unwrap().to_string();
236
237        // Data manifest: one Added data file.
238        let mut data_writer = ManifestWriterBuilder::new(
239            next_manifest_file(&table_location_str),
240            Some(current_snapshot.snapshot_id()),
241            schema.clone(),
242            partition_spec.as_ref().clone(),
243        )
244        .build_v2_data();
245        data_writer
246            .add_entry(
247                ManifestEntry::builder()
248                    .status(ManifestStatus::Added)
249                    .data_file(
250                        DataFileBuilder::default()
251                            .partition_spec_id(0)
252                            .content(DataContentType::Data)
253                            .file_path(format!("{table_location_str}/data.parquet"))
254                            .file_format(DataFileFormat::Parquet)
255                            .file_size_in_bytes(100)
256                            .record_count(1)
257                            .partition(Struct::from_iter([Some(Literal::long(100))]))
258                            .build()
259                            .unwrap(),
260                    )
261                    .build(),
262            )
263            .unwrap();
264        let data_manifest = data_writer.write_manifest_file().await.unwrap();
265
266        // Delete-only manifest: a single Deleted entry, nothing added or existing.
267        let mut delete_writer = ManifestWriterBuilder::new(
268            next_manifest_file(&table_location_str),
269            Some(current_snapshot.snapshot_id()),
270            schema.clone(),
271            partition_spec.as_ref().clone(),
272        )
273        .build_v2_data();
274        delete_writer
275            .add_delete_entry(
276                ManifestEntry::builder()
277                    .status(ManifestStatus::Deleted)
278                    .sequence_number(0)
279                    .file_sequence_number(0)
280                    .data_file(
281                        DataFileBuilder::default()
282                            .partition_spec_id(0)
283                            .content(DataContentType::Data)
284                            .file_path(format!("{table_location_str}/removed.parquet"))
285                            .file_format(DataFileFormat::Parquet)
286                            .file_size_in_bytes(100)
287                            .record_count(1)
288                            .partition(Struct::from_iter([Some(Literal::long(100))]))
289                            .build()
290                            .unwrap(),
291                    )
292                    .build(),
293            )
294            .unwrap();
295        let delete_manifest = delete_writer.write_manifest_file().await.unwrap();
296        let delete_manifest_path = delete_manifest.manifest_path.clone();
297
298        // Sanity: the delete manifest really is delete-only.
299        assert!(delete_manifest.has_deleted_files());
300        assert!(!delete_manifest.has_added_files());
301        assert!(!delete_manifest.has_existing_files());
302
303        let mut manifest_list_writer = ManifestListWriter::v2(
304            table
305                .file_io()
306                .new_output(current_snapshot.manifest_list())
307                .unwrap()
308                .writer()
309                .await
310                .unwrap(),
311            current_snapshot.snapshot_id(),
312            current_snapshot.parent_snapshot_id(),
313            current_snapshot.sequence_number(),
314        );
315        manifest_list_writer
316            .add_manifests(vec![data_manifest, delete_manifest].into_iter())
317            .unwrap();
318        manifest_list_writer.close().await.unwrap();
319
320        (table, tmp_dir, delete_manifest_path)
321    }
322
323    /// Regression test for #2148: a `fast_append` must carry delete-only manifests
324    /// forward into the new snapshot. Dropping them lets the files they mark as
325    /// removed reappear as live data on the next append.
326    #[tokio::test]
327    async fn test_fast_append_preserves_delete_only_manifest() {
328        let (table, _tmp_dir, delete_manifest_path) = make_table_with_delete_only_manifest().await;
329
330        // Append a new data file via the public transaction API.
331        let new_file = DataFileBuilder::default()
332            .content(DataContentType::Data)
333            .file_path(format!("{}/appended.parquet", table.metadata().location()))
334            .file_format(DataFileFormat::Parquet)
335            .file_size_in_bytes(100)
336            .record_count(1)
337            .partition_spec_id(table.metadata().default_partition_spec_id())
338            .partition(Struct::from_iter([Some(Literal::long(100))]))
339            .build()
340            .unwrap();
341
342        let tx = Transaction::new(&table);
343        let action = tx.fast_append().add_data_files(vec![new_file]);
344        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
345        let updates = action_commit.take_updates();
346
347        let new_snapshot: SnapshotRef = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
348            SnapshotRef::new(snapshot.clone())
349        } else {
350            unreachable!("first update of a fast append should be AddSnapshot")
351        };
352
353        let manifest_list = table
354            .manifest_list_reader(&new_snapshot)
355            .load()
356            .await
357            .unwrap();
358
359        assert!(
360            manifest_list
361                .entries()
362                .iter()
363                .any(|m| m.manifest_path == delete_manifest_path),
364            "delete-only manifest {delete_manifest_path} was dropped from the new snapshot's \
365             manifest list; the files it removed would reappear as live data"
366        );
367    }
368
369    /// Load the data files written by a single-manifest fast-append commit.
370    async fn committed_data_files(table: &Table, updates: &[TableUpdate]) -> Vec<DataFile> {
371        let TableUpdate::AddSnapshot { snapshot } = &updates[0] else {
372            unreachable!("first update is always AddSnapshot")
373        };
374        let manifest_list = table
375            .manifest_list_reader(&SnapshotRef::new(snapshot.clone()))
376            .load()
377            .await
378            .unwrap();
379        assert_eq!(1, manifest_list.entries().len());
380        table
381            .manifest_reader()
382            .read(&manifest_list.entries()[0])
383            .await
384            .unwrap()
385            .entries()
386            .iter()
387            .map(|entry| entry.data_file().clone())
388            .collect()
389    }
390
391    #[tokio::test]
392    async fn test_fast_append_writes_encrypted_manifest() {
393        let table = make_encrypted_table().await;
394        assert!(
395            table.encryption_manager().is_some(),
396            "fixture table should have an EncryptionManager"
397        );
398
399        let new_file = DataFileBuilder::default()
400            .content(DataContentType::Data)
401            .file_path("memory:///table/data/00000.parquet".to_string())
402            .file_format(DataFileFormat::Parquet)
403            .partition(Struct::empty())
404            .record_count(100)
405            .file_size_in_bytes(4096)
406            .partition_spec_id(table.metadata().default_partition_spec_id())
407            .build()
408            .unwrap();
409
410        let tx = Transaction::new(&table);
411        let action = tx.fast_append().add_data_files(vec![new_file]);
412        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
413        let updates = action_commit.take_updates();
414
415        let new_snapshot: SnapshotRef = updates
416            .iter()
417            .find_map(|u| match u {
418                TableUpdate::AddSnapshot { snapshot } => Some(SnapshotRef::new(snapshot.clone())),
419                _ => None,
420            })
421            .expect("a fast append should emit an AddSnapshot update");
422
423        let manifest_list = table
424            .manifest_list_reader(&new_snapshot)
425            .load()
426            .await
427            .unwrap();
428        let manifest_file = manifest_list
429            .entries()
430            .iter()
431            .find(|m| m.added_files_count.unwrap_or(0) > 0)
432            .expect("new snapshot should carry the appended data manifest");
433
434        // The manifest list entry must carry decodable key metadata.
435        let key_metadata_bytes = manifest_file
436            .key_metadata
437            .as_ref()
438            .expect("encrypted manifest must record key metadata");
439        StandardKeyMetadata::decode(key_metadata_bytes)
440            .expect("recorded key metadata must decode as StandardKeyMetadata");
441
442        // The reader self-decrypts using the recorded key metadata and must
443        // recover the entry we appended. Because the read goes through the
444        // decryption path, this succeeding also proves the bytes
445        // on disk were genuinely encrypted (not silently written as plaintext).
446        let manifest = table.manifest_reader().read(manifest_file).await.unwrap();
447        assert_eq!(manifest.entries().len(), 1);
448        assert_eq!(
449            manifest.entries()[0].data_file().file_path(),
450            "memory:///table/data/00000.parquet"
451        );
452    }
453
454    #[tokio::test]
455    async fn test_empty_data_append_action() {
456        let table = make_v2_minimal_table();
457        let tx = Transaction::new(&table);
458        let action = tx.fast_append().add_data_files(vec![]);
459        assert!(Arc::new(action).commit(&table).await.is_err());
460    }
461
462    /// A `fast_append` must write the manifest list and the manifest
463    /// files under the `write.metadata.path` prefix when configured,
464    /// rather than the default `<location>/metadata` directory.
465    #[tokio::test]
466    async fn test_fast_append_honors_write_metadata_path() {
467        let base = make_v2_minimal_table();
468        let metadata_root = format!("{}/custom-meta", base.metadata().location());
469        let metadata = base
470            .metadata()
471            .clone()
472            .into_builder(None)
473            .set_properties(HashMap::from([(
474                "write.metadata.path".to_string(),
475                metadata_root.clone(),
476            )]))
477            .unwrap()
478            .build()
479            .unwrap()
480            .metadata;
481        let table = base.with_metadata(Arc::new(metadata));
482
483        let data_file = DataFileBuilder::default()
484            .content(DataContentType::Data)
485            .file_path(format!("{}/data/1.parquet", table.metadata().location()))
486            .file_format(DataFileFormat::Parquet)
487            .file_size_in_bytes(100)
488            .record_count(1)
489            .partition_spec_id(table.metadata().default_partition_spec_id())
490            .partition(Struct::from_iter([Some(Literal::long(300))]))
491            .build()
492            .unwrap();
493
494        let tx = Transaction::new(&table);
495        let action = tx.fast_append().add_data_files(vec![data_file]);
496        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
497        let updates = action_commit.take_updates();
498
499        let new_snapshot: SnapshotRef = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
500            SnapshotRef::new(snapshot.clone())
501        } else {
502            unreachable!("first update of a fast append should be AddSnapshot")
503        };
504
505        let prefix = format!("{metadata_root}/");
506
507        // Manifest list
508        assert!(
509            new_snapshot.manifest_list().starts_with(prefix.as_str()),
510            "manifest list {} not under configured write.metadata.path {metadata_root}",
511            new_snapshot.manifest_list()
512        );
513
514        // Manifest files
515        let manifest_list = table
516            .manifest_list_reader(&new_snapshot)
517            .load()
518            .await
519            .unwrap();
520        assert!(
521            !manifest_list.entries().is_empty(),
522            "expected at least one manifest entry"
523        );
524        for entry in manifest_list.entries() {
525            assert!(
526                entry.manifest_path.starts_with(prefix.as_str()),
527                "manifest {} not under configured write.metadata.path {metadata_root}",
528                entry.manifest_path
529            );
530        }
531    }
532
533    #[tokio::test]
534    async fn test_set_snapshot_properties() {
535        let table = make_v2_minimal_table();
536        let tx = Transaction::new(&table);
537
538        let mut snapshot_properties = HashMap::new();
539        snapshot_properties.insert("key".to_string(), "val".to_string());
540
541        let data_file = DataFileBuilder::default()
542            .content(DataContentType::Data)
543            .file_path("test/1.parquet".to_string())
544            .file_format(DataFileFormat::Parquet)
545            .file_size_in_bytes(100)
546            .record_count(1)
547            .partition_spec_id(table.metadata().default_partition_spec_id())
548            .partition(Struct::from_iter([Some(Literal::long(300))]))
549            .build()
550            .unwrap();
551
552        let action = tx
553            .fast_append()
554            .set_snapshot_properties(snapshot_properties)
555            .add_data_files(vec![data_file]);
556        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
557        let updates = action_commit.take_updates();
558
559        // Check customized properties is contained in snapshot summary properties.
560        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
561            snapshot
562        } else {
563            unreachable!()
564        };
565        assert_eq!(
566            new_snapshot
567                .summary()
568                .additional_properties
569                .get("key")
570                .unwrap(),
571            "val"
572        );
573    }
574
575    /// See `testdata/manifests_lists/README.md`.
576    const FIXTURE_MASTER_KEY_ID: &str = "master-1";
577    const FIXTURE_MASTER_KEY_BYTES: [u8; 16] = [
578        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
579        0x0f,
580    ];
581
582    async fn make_v3_encrypted_table() -> Table {
583        let json = fs::read_to_string(format!(
584            "{}/testdata/table_metadata/TableMetadataV3ValidEncryption.json",
585            env!("CARGO_MANIFEST_DIR")
586        ))
587        .unwrap();
588        let metadata = serde_json::from_str::<TableMetadata>(&json).unwrap();
589
590        let kms = MemoryKeyManagementClient::new();
591        kms.add_master_key_bytes(
592            FIXTURE_MASTER_KEY_ID,
593            SensitiveBytes::new(FIXTURE_MASTER_KEY_BYTES),
594        )
595        .unwrap();
596
597        let file_io = FileIO::new_with_memory();
598
599        let manifest_list_bytes = fs::read(format!(
600            "{}/testdata/manifests_lists/manifest-list-v3-encrypted.avro",
601            env!("CARGO_MANIFEST_DIR")
602        ))
603        .unwrap();
604        let parent_manifest_list = metadata.current_snapshot().unwrap().manifest_list();
605        file_io
606            .new_output(parent_manifest_list)
607            .unwrap()
608            .write(manifest_list_bytes.into())
609            .await
610            .unwrap();
611
612        Table::builder()
613            .metadata(metadata)
614            .metadata_location("memory:///table/metadata/v1.json")
615            .identifier(TableIdent::from_strs(["ns1", "enc"]).unwrap())
616            .file_io(file_io)
617            .kms_client(Arc::new(kms))
618            .runtime(test_runtime())
619            .build()
620            .unwrap()
621    }
622
623    #[tokio::test]
624    async fn test_commit_with_encryption_adds_keys_and_records_snapshot_key_id() {
625        let table = make_v3_encrypted_table().await;
626
627        let data_file = DataFileBuilder::default()
628            .content(DataContentType::Data)
629            .file_path("test/1.parquet".to_string())
630            .file_format(DataFileFormat::Parquet)
631            .file_size_in_bytes(100)
632            .record_count(1)
633            .partition_spec_id(table.metadata().default_partition_spec_id())
634            .partition(Struct::empty())
635            .build()
636            .unwrap();
637
638        let tx = Transaction::new(&table);
639        let action = tx.fast_append().add_data_files(vec![data_file]);
640        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
641        let updates = action_commit.take_updates();
642
643        let added_key_ids: Vec<String> = updates
644            .iter()
645            .filter_map(|u| match u {
646                TableUpdate::AddEncryptionKey { encryption_key } => {
647                    Some(encryption_key.key_id().to_string())
648                }
649                _ => None,
650            })
651            .collect();
652        assert!(!added_key_ids.is_empty(), "got {updates:?}");
653
654        // Encryption keys are added before the snapshot, so it isn't updates[0] here.
655        let new_snapshot = updates
656            .iter()
657            .find_map(|u| match u {
658                TableUpdate::AddSnapshot { snapshot } => Some(snapshot),
659                _ => None,
660            })
661            .expect("commit should add a snapshot");
662
663        let snapshot_key_id = new_snapshot
664            .encryption_key_id()
665            .expect("encrypted snapshot should record its manifest-list key id");
666        assert!(
667            added_key_ids.iter().any(|id| id == snapshot_key_id),
668            "snapshot key id {snapshot_key_id} not in added keys {added_key_ids:?}"
669        );
670
671        let new_snapshot_ref: SnapshotRef = Arc::new(new_snapshot.clone());
672        let manifest_list = table
673            .manifest_list_reader(&new_snapshot_ref)
674            .load()
675            .await
676            .expect("newly written encrypted manifest list should decrypt and parse");
677        assert_eq!(
678            manifest_list.entries().len(),
679            1,
680            "append should record exactly the one new data manifest"
681        );
682    }
683
684    #[tokio::test]
685    async fn test_snapshot_properties_cannot_override_computed_metrics() {
686        // A user-supplied snapshot property must not shadow a computed metric key
687        // such as `added-data-files`. Matching iceberg-java, the computed value
688        // wins, so the summary reflects the real count and a bad value can neither
689        // corrupt the summary nor panic total computation (see #2184-adjacent fix).
690        let table = make_v2_minimal_table();
691        let tx = Transaction::new(&table);
692
693        let mut snapshot_properties = HashMap::new();
694        // Both a benign-but-wrong value and a non-integer value collide with
695        // computed metric keys; neither should reach the final summary.
696        snapshot_properties.insert("added-data-files".to_string(), "9999".to_string());
697        snapshot_properties.insert("added-records".to_string(), "not-a-number".to_string());
698
699        let data_file = DataFileBuilder::default()
700            .content(DataContentType::Data)
701            .file_path("test/1.parquet".to_string())
702            .file_format(DataFileFormat::Parquet)
703            .file_size_in_bytes(100)
704            .record_count(1)
705            .partition_spec_id(table.metadata().default_partition_spec_id())
706            .partition(Struct::from_iter([Some(Literal::long(300))]))
707            .build()
708            .unwrap();
709
710        let action = tx
711            .fast_append()
712            .set_snapshot_properties(snapshot_properties)
713            .add_data_files(vec![data_file]);
714        // Must not panic during total computation.
715        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
716        let updates = action_commit.take_updates();
717
718        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
719            snapshot
720        } else {
721            unreachable!()
722        };
723        let props = &new_snapshot.summary().additional_properties;
724
725        // Computed metric wins over the user's colliding values.
726        assert_eq!(
727            props.get("added-data-files").unwrap(),
728            "1",
729            "computed added-data-files must override the user-supplied value"
730        );
731        assert_eq!(
732            props.get("added-records").unwrap(),
733            "1",
734            "computed added-records must override the user-supplied non-integer value"
735        );
736    }
737
738    #[tokio::test]
739    async fn test_append_snapshot_properties() {
740        let table = make_v2_minimal_table();
741        let tx = Transaction::new(&table);
742
743        let mut snapshot_properties = HashMap::new();
744        snapshot_properties.insert("key".to_string(), "val".to_string());
745
746        let action = tx
747            .fast_append()
748            .set_snapshot_properties(snapshot_properties);
749        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
750        let updates = action_commit.take_updates();
751
752        // Check customized properties is contained in snapshot summary properties.
753        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
754            snapshot
755        } else {
756            unreachable!()
757        };
758        assert_eq!(
759            new_snapshot
760                .summary()
761                .additional_properties
762                .get("key")
763                .unwrap(),
764            "val"
765        );
766    }
767
768    #[tokio::test]
769    async fn test_fast_append_file_with_incompatible_partition_value() {
770        let table = make_v2_minimal_table();
771        let tx = Transaction::new(&table);
772        let action = tx.fast_append();
773
774        // check add data file with incompatible partition value
775        let data_file = DataFileBuilder::default()
776            .content(DataContentType::Data)
777            .file_path("test/3.parquet".to_string())
778            .file_format(DataFileFormat::Parquet)
779            .file_size_in_bytes(100)
780            .record_count(1)
781            .partition_spec_id(table.metadata().default_partition_spec_id())
782            .partition(Struct::from_iter([Some(Literal::string("test"))]))
783            .build()
784            .unwrap();
785
786        let action = action.add_data_files(vec![data_file.clone()]);
787
788        assert!(Arc::new(action).commit(&table).await.is_err());
789    }
790
791    #[tokio::test]
792    async fn test_fast_append_dedupes_intra_batch_duplicate_paths() {
793        let table = make_v2_minimal_table();
794        let tx = Transaction::new(&table);
795
796        let make_file = |size: u64, records: u64| {
797            DataFileBuilder::default()
798                .content(DataContentType::Data)
799                .file_path("test/dup.parquet".to_string())
800                .file_format(DataFileFormat::Parquet)
801                .file_size_in_bytes(size)
802                .record_count(records)
803                .partition_spec_id(table.metadata().default_partition_spec_id())
804                .partition(Struct::from_iter([Some(Literal::long(1))]))
805                .build()
806                .unwrap()
807        };
808
809        // Same path three times: the manifest keeps a single entry, the first one.
810        let action = tx.fast_append().add_data_files(vec![
811            make_file(100, 10),
812            make_file(200, 20),
813            make_file(300, 30),
814        ]);
815        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
816        let files = committed_data_files(&table, &action_commit.take_updates()).await;
817        assert_eq!(1, files.len());
818        assert_eq!(100, files[0].file_size_in_bytes());
819    }
820
821    #[tokio::test]
822    async fn test_fast_append_dedupes_regardless_of_check_duplicate_flag() {
823        let table = make_v2_minimal_table();
824        let tx = Transaction::new(&table);
825
826        let make_file = || {
827            DataFileBuilder::default()
828                .content(DataContentType::Data)
829                .file_path("test/dup.parquet".to_string())
830                .file_format(DataFileFormat::Parquet)
831                .file_size_in_bytes(100)
832                .record_count(10)
833                .partition_spec_id(table.metadata().default_partition_spec_id())
834                .partition(Struct::from_iter([Some(Literal::long(1))]))
835                .build()
836                .unwrap()
837        };
838
839        // `check_duplicate` only gates the cross-snapshot check; intra-batch dedupe runs regardless.
840        let action = tx
841            .fast_append()
842            .with_check_duplicate(false)
843            .add_data_files(vec![make_file(), make_file()]);
844        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
845        let files = committed_data_files(&table, &action_commit.take_updates()).await;
846        assert_eq!(1, files.len());
847    }
848
849    #[tokio::test]
850    async fn test_fast_append() {
851        let table = make_v2_minimal_table();
852        let tx = Transaction::new(&table);
853        let action = tx.fast_append();
854
855        let data_file = DataFileBuilder::default()
856            .content(DataContentType::Data)
857            .file_path("test/3.parquet".to_string())
858            .file_format(DataFileFormat::Parquet)
859            .file_size_in_bytes(100)
860            .record_count(1)
861            .partition_spec_id(table.metadata().default_partition_spec_id())
862            .partition(Struct::from_iter([Some(Literal::long(300))]))
863            .build()
864            .unwrap();
865
866        let action = action.add_data_files(vec![data_file.clone()]);
867        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
868        let updates = action_commit.take_updates();
869        let requirements = action_commit.take_requirements();
870
871        // check updates and requirements
872        assert!(
873            matches!((&updates[0],&updates[1]), (TableUpdate::AddSnapshot { snapshot },TableUpdate::SetSnapshotRef { reference,ref_name }) if snapshot.snapshot_id() == reference.snapshot_id && ref_name == MAIN_BRANCH)
874        );
875        assert_eq!(
876            vec![
877                TableRequirement::UuidMatch {
878                    uuid: table.metadata().uuid()
879                },
880                TableRequirement::RefSnapshotIdMatch {
881                    r#ref: MAIN_BRANCH.to_string(),
882                    snapshot_id: table.metadata().current_snapshot_id
883                }
884            ],
885            requirements
886        );
887
888        // check manifest list
889        let new_snapshot: SnapshotRef = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
890            SnapshotRef::new(snapshot.clone())
891        } else {
892            unreachable!()
893        };
894        let manifest_list = table
895            .manifest_list_reader(&new_snapshot)
896            .load()
897            .await
898            .unwrap();
899        assert_eq!(1, manifest_list.entries().len());
900        assert_eq!(
901            manifest_list.entries()[0].sequence_number,
902            new_snapshot.sequence_number()
903        );
904
905        // check manifest
906        let manifest = table
907            .manifest_reader()
908            .read(&manifest_list.entries()[0])
909            .await
910            .unwrap();
911        assert_eq!(1, manifest.entries().len());
912        assert_eq!(
913            new_snapshot.sequence_number(),
914            manifest.entries()[0]
915                .sequence_number()
916                .expect("Inherit sequence number by load manifest")
917        );
918
919        assert_eq!(
920            new_snapshot.snapshot_id(),
921            manifest.entries()[0].snapshot_id().unwrap()
922        );
923        assert_eq!(data_file, *manifest.entries()[0].data_file());
924    }
925}