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};
19
20use async_trait::async_trait;
21use uuid::Uuid;
22
23use crate::error::{Result, invalid_data};
24use crate::spec::{DataContentType, DataFile};
25use crate::table::Table;
26use crate::transaction::action::CommitStatus;
27use crate::transaction::snapshot::simple::{AppendIntent, SimpleSnapshotProducer};
28use crate::transaction::snapshot::validate_files_not_live;
29use crate::transaction::{ActionCommit, TransactionAction};
30use crate::{Error, ErrorKind};
31
32/// FastAppendAction is a transaction action for fast append data files to the table.
33#[derive(Clone)]
34pub struct FastAppendAction {
35    check_duplicate: bool,
36    /// Overrides the commit UUID of the producer created in `new_state`.
37    commit_uuid: Option<Uuid>,
38    snapshot_properties: HashMap<String, String>,
39    /// Free of duplicate paths; see [`Self::add_data_files`].
40    added_data_files: Vec<DataFile>,
41    /// Paths already in `added_data_files`.
42    added_paths: HashSet<String>,
43}
44
45impl FastAppendAction {
46    pub(crate) fn new() -> Self {
47        Self {
48            check_duplicate: true,
49            commit_uuid: None,
50            snapshot_properties: HashMap::default(),
51            added_data_files: vec![],
52            added_paths: HashSet::new(),
53        }
54    }
55
56    /// Set whether to check duplicate files
57    pub fn with_check_duplicate(mut self, v: bool) -> Self {
58        self.check_duplicate = v;
59        self
60    }
61
62    /// Add data files to the snapshot.
63    ///
64    /// A path added more than once keeps its first occurrence, so a manifest
65    /// never references the same file twice. This is independent of the
66    /// `check_duplicate`-gated check against files already in the table.
67    pub fn add_data_files(mut self, data_files: impl IntoIterator<Item = DataFile>) -> Self {
68        for data_file in data_files {
69            if self.added_paths.insert(data_file.file_path.clone()) {
70                self.added_data_files.push(data_file);
71            }
72        }
73        self
74    }
75
76    /// Set commit UUID for the snapshot.
77    pub fn set_commit_uuid(mut self, commit_uuid: Uuid) -> Self {
78        self.commit_uuid = Some(commit_uuid);
79        self
80    }
81
82    /// Set snapshot summary properties.
83    pub fn set_snapshot_properties(mut self, snapshot_properties: HashMap<String, String>) -> Self {
84        self.snapshot_properties = snapshot_properties;
85        self
86    }
87
88    /// Checks the parts of this action that do not depend on the table.
89    fn validate(&self) -> Result<()> {
90        // TODO: Allowing snapshot property setup with no added data files is a workaround.
91        // We should clean it up after all necessary actions are supported.
92        // For details, please refer to https://github.com/apache/iceberg-rust/issues/1548
93        if self.added_data_files.is_empty() && self.snapshot_properties.is_empty() {
94            return Err(Error::new(
95                ErrorKind::PreconditionFailed,
96                "No added data files or added snapshot properties found when write a manifest file",
97            ));
98        }
99        if let Some(data_file) = self
100            .added_data_files
101            .iter()
102            .find(|f| f.content_type() != DataContentType::Data)
103        {
104            return Err(invalid_data!(
105                "Only data content type is allowed for fast append, got {:?} for {}",
106                data_file.content_type(),
107                data_file.file_path()
108            ));
109        }
110        Ok(())
111    }
112}
113
114#[async_trait]
115impl TransactionAction for FastAppendAction {
116    type State = SimpleSnapshotProducer;
117
118    fn new_state(&self) -> SimpleSnapshotProducer {
119        SimpleSnapshotProducer::new(AppendIntent {
120            commit_uuid: self.commit_uuid,
121            snapshot_properties: self.snapshot_properties.clone(),
122            data_files: self.added_data_files.clone(),
123        })
124    }
125
126    async fn commit(
127        &self,
128        state: &mut SimpleSnapshotProducer,
129        table: &Table,
130    ) -> Result<ActionCommit> {
131        self.validate()?;
132        if self.check_duplicate {
133            validate_files_not_live(table, &self.added_data_files).await?;
134        }
135        state.apply(table).await
136    }
137
138    async fn cleanup(self, _state: SimpleSnapshotProducer, _table: &Table, _status: CommitStatus) {
139        // TODO(#3417): remove the producer's uncommitted artifacts once terminal
140        // cleanup is wired up.
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use std::collections::HashMap;
147    use std::fs;
148    use std::sync::Arc;
149    use std::sync::atomic::{AtomicU32, Ordering};
150
151    use minijinja::{AutoEscape, Environment, Value, context};
152    use tempfile::TempDir;
153    use uuid::Uuid;
154
155    use crate::catalog::MockCatalog;
156    use crate::encryption::kms::MemoryKeyManagementClient;
157    use crate::encryption::{SensitiveBytes, StandardKeyMetadata};
158    use crate::io::FileIO;
159    use crate::spec::{
160        DataContentType, DataFile, DataFileBuilder, DataFileFormat, Literal, MAIN_BRANCH,
161        ManifestEntry, ManifestListWriter, ManifestStatus, ManifestWriterBuilder, SnapshotRef,
162        Struct, TableMetadata,
163    };
164    use crate::table::Table;
165    use crate::test_utils::{commit_with_fresh_state, make_encrypted_table, test_runtime};
166    use crate::transaction::tests::{
167        make_v2_minimal_table, make_v3_minimal_table, with_retry_properties,
168    };
169    use crate::transaction::{ApplyTransactionAction, FastAppendAction, Transaction};
170    use crate::{ErrorKind, TableCommit, TableIdent, TableRequirement, TableUpdate};
171
172    fn render_template(template: &str, ctx: Value) -> String {
173        let mut env = Environment::new();
174        env.set_auto_escape_callback(|_| AutoEscape::None);
175        env.render_str(template, ctx).unwrap()
176    }
177
178    /// Builds a table whose current snapshot's manifest list contains a data manifest
179    /// followed by a delete-only manifest (one entry with `ManifestStatus::Deleted`,
180    /// so `deleted_files_count > 0` while `added_files_count == existing_files_count == 0`).
181    ///
182    /// Returns the table plus the `manifest_path` of the delete-only manifest so callers
183    /// can assert whether a subsequent append carries it forward.
184    async fn make_table_with_delete_only_manifest() -> (Table, TempDir, String) {
185        let tmp_dir = TempDir::new().unwrap();
186        let table_location = tmp_dir.path().join("table1");
187        let manifest_list_location = table_location.join("metadata/manifests_list_1.avro");
188        let table_metadata_location = table_location.join("metadata/v1.json");
189
190        let file_io = FileIO::new_with_fs();
191
192        let template = fs::read_to_string(format!(
193            "{}/testdata/example_table_metadata_v2.json",
194            env!("CARGO_MANIFEST_DIR")
195        ))
196        .unwrap();
197        // The template has two snapshots; point the current one at our manifest list.
198        let metadata_json = render_template(&template, context! {
199            table_location => &table_location,
200            manifest_list_1_location => &manifest_list_location,
201            manifest_list_2_location => &manifest_list_location,
202            table_metadata_1_location => &table_metadata_location,
203        });
204        let table_metadata = serde_json::from_str::<TableMetadata>(&metadata_json).unwrap();
205
206        let table = Table::builder()
207            .metadata(table_metadata)
208            .identifier(TableIdent::from_strs(["db", "table1"]).unwrap())
209            .file_io(file_io)
210            .metadata_location(table_metadata_location.to_str().unwrap())
211            .runtime(test_runtime())
212            .build()
213            .unwrap();
214
215        let current_snapshot = table.metadata().current_snapshot().unwrap();
216        let schema = current_snapshot.schema(table.metadata()).unwrap();
217        let partition_spec = table.metadata().default_partition_spec();
218
219        let next_manifest_file = |location: &str| {
220            table
221                .file_io()
222                .new_output(format!(
223                    "{}/metadata/manifest_{}.avro",
224                    location,
225                    Uuid::new_v4()
226                ))
227                .unwrap()
228        };
229        let table_location_str = table_location.to_str().unwrap().to_string();
230
231        // Data manifest: one Added data file.
232        let mut data_writer = ManifestWriterBuilder::new(
233            next_manifest_file(&table_location_str),
234            Some(current_snapshot.snapshot_id()),
235            schema.clone(),
236            partition_spec.as_ref().clone(),
237        )
238        .build_v2_data();
239        data_writer
240            .add_entry(
241                ManifestEntry::builder()
242                    .status(ManifestStatus::Added)
243                    .data_file(
244                        DataFileBuilder::default()
245                            .partition_spec_id(0)
246                            .content(DataContentType::Data)
247                            .file_path(format!("{table_location_str}/data.parquet"))
248                            .file_format(DataFileFormat::Parquet)
249                            .file_size_in_bytes(100)
250                            .record_count(1)
251                            .partition(Struct::from_iter([Some(Literal::long(100))]))
252                            .build()
253                            .unwrap(),
254                    )
255                    .build(),
256            )
257            .unwrap();
258        let data_manifest = data_writer.write_manifest_file().await.unwrap();
259
260        // Delete-only manifest: a single Deleted entry, nothing added or existing.
261        let mut delete_writer = ManifestWriterBuilder::new(
262            next_manifest_file(&table_location_str),
263            Some(current_snapshot.snapshot_id()),
264            schema.clone(),
265            partition_spec.as_ref().clone(),
266        )
267        .build_v2_data();
268        delete_writer
269            .add_delete_entry(
270                ManifestEntry::builder()
271                    .status(ManifestStatus::Deleted)
272                    .sequence_number(0)
273                    .file_sequence_number(0)
274                    .data_file(
275                        DataFileBuilder::default()
276                            .partition_spec_id(0)
277                            .content(DataContentType::Data)
278                            .file_path(format!("{table_location_str}/removed.parquet"))
279                            .file_format(DataFileFormat::Parquet)
280                            .file_size_in_bytes(100)
281                            .record_count(1)
282                            .partition(Struct::from_iter([Some(Literal::long(100))]))
283                            .build()
284                            .unwrap(),
285                    )
286                    .build(),
287            )
288            .unwrap();
289        let delete_manifest = delete_writer.write_manifest_file().await.unwrap();
290        let delete_manifest_path = delete_manifest.manifest_path.clone();
291
292        // Sanity: the delete manifest really is delete-only.
293        assert!(delete_manifest.has_deleted_files());
294        assert!(!delete_manifest.has_added_files());
295        assert!(!delete_manifest.has_existing_files());
296
297        let mut manifest_list_writer = ManifestListWriter::v2(
298            table
299                .file_io()
300                .new_output(current_snapshot.manifest_list())
301                .unwrap()
302                .writer()
303                .await
304                .unwrap(),
305            current_snapshot.snapshot_id(),
306            current_snapshot.parent_snapshot_id(),
307            current_snapshot.sequence_number(),
308        );
309        manifest_list_writer
310            .add_manifests(vec![data_manifest, delete_manifest].into_iter())
311            .unwrap();
312        manifest_list_writer.close().await.unwrap();
313
314        (table, tmp_dir, delete_manifest_path)
315    }
316
317    /// Regression test for #2148: a `fast_append` must carry delete-only manifests
318    /// forward into the new snapshot. Dropping them lets the files they mark as
319    /// removed reappear as live data on the next append.
320    #[tokio::test]
321    async fn test_fast_append_preserves_delete_only_manifest() {
322        let (table, _tmp_dir, delete_manifest_path) = make_table_with_delete_only_manifest().await;
323
324        // Append a new data file via the public transaction API.
325        let new_file = DataFileBuilder::default()
326            .content(DataContentType::Data)
327            .file_path(format!("{}/appended.parquet", table.metadata().location()))
328            .file_format(DataFileFormat::Parquet)
329            .file_size_in_bytes(100)
330            .record_count(1)
331            .partition_spec_id(table.metadata().default_partition_spec_id())
332            .partition(Struct::from_iter([Some(Literal::long(100))]))
333            .build()
334            .unwrap();
335
336        let tx = Transaction::new(&table);
337        let action = tx.fast_append().add_data_files(vec![new_file]);
338        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
339        let updates = action_commit.take_updates();
340
341        let new_snapshot: SnapshotRef = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
342            SnapshotRef::new(snapshot.clone())
343        } else {
344            unreachable!("first update of a fast append should be AddSnapshot")
345        };
346
347        let manifest_list = table
348            .manifest_list_reader(&new_snapshot)
349            .load()
350            .await
351            .unwrap();
352
353        assert!(
354            manifest_list
355                .entries()
356                .iter()
357                .any(|m| m.manifest_path == delete_manifest_path),
358            "delete-only manifest {delete_manifest_path} was dropped from the new snapshot's \
359             manifest list; the files it removed would reappear as live data"
360        );
361    }
362
363    /// Load the data files written by a single-manifest fast-append commit.
364    async fn committed_data_files(table: &Table, updates: &[TableUpdate]) -> Vec<DataFile> {
365        let TableUpdate::AddSnapshot { snapshot } = &updates[0] else {
366            unreachable!("first update is always AddSnapshot")
367        };
368        let manifest_list = table
369            .manifest_list_reader(&SnapshotRef::new(snapshot.clone()))
370            .load()
371            .await
372            .unwrap();
373        assert_eq!(1, manifest_list.entries().len());
374        table
375            .manifest_reader()
376            .read(&manifest_list.entries()[0])
377            .await
378            .unwrap()
379            .entries()
380            .iter()
381            .map(|entry| entry.data_file().clone())
382            .collect()
383    }
384
385    #[tokio::test]
386    async fn test_fast_append_writes_encrypted_manifest() {
387        let table = make_encrypted_table().await;
388        assert!(
389            table.encryption_manager().is_some(),
390            "fixture table should have an EncryptionManager"
391        );
392
393        let new_file = DataFileBuilder::default()
394            .content(DataContentType::Data)
395            .file_path("memory:///table/data/00000.parquet".to_string())
396            .file_format(DataFileFormat::Parquet)
397            .partition(Struct::empty())
398            .record_count(100)
399            .file_size_in_bytes(4096)
400            .partition_spec_id(table.metadata().default_partition_spec_id())
401            .build()
402            .unwrap();
403
404        let tx = Transaction::new(&table);
405        let action = tx.fast_append().add_data_files(vec![new_file]);
406        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
407        let updates = action_commit.take_updates();
408
409        let new_snapshot: SnapshotRef = updates
410            .iter()
411            .find_map(|u| match u {
412                TableUpdate::AddSnapshot { snapshot } => Some(SnapshotRef::new(snapshot.clone())),
413                _ => None,
414            })
415            .expect("a fast append should emit an AddSnapshot update");
416
417        let manifest_list_key_metadata = table
418            .encryption_manager()
419            .unwrap()
420            .decrypt_manifest_list_key_metadata(new_snapshot.encryption_key_id().unwrap())
421            .await
422            .unwrap();
423        let manifest_list_size = table
424            .file_io()
425            .new_input(new_snapshot.manifest_list())
426            .unwrap()
427            .metadata()
428            .await
429            .unwrap()
430            .size;
431        assert_eq!(
432            manifest_list_key_metadata.file_length(),
433            Some(manifest_list_size)
434        );
435
436        let manifest_list = table
437            .manifest_list_reader(&new_snapshot)
438            .load()
439            .await
440            .unwrap();
441        let manifest_file = manifest_list
442            .entries()
443            .iter()
444            .find(|m| m.added_files_count.unwrap_or(0) > 0)
445            .expect("new snapshot should carry the appended data manifest");
446
447        // The manifest list entry must carry decodable key metadata.
448        let key_metadata_bytes = manifest_file
449            .key_metadata
450            .as_ref()
451            .expect("encrypted manifest must record key metadata");
452        let key_metadata = StandardKeyMetadata::decode(key_metadata_bytes)
453            .expect("recorded key metadata must decode as StandardKeyMetadata");
454        let manifest_size = table
455            .file_io()
456            .new_input(&manifest_file.manifest_path)
457            .unwrap()
458            .metadata()
459            .await
460            .unwrap()
461            .size;
462        assert_eq!(key_metadata.file_length(), Some(manifest_size));
463        assert_eq!(manifest_file.manifest_length, manifest_size as i64);
464
465        // The reader self-decrypts using the recorded key metadata and must
466        // recover the entry we appended. Because the read goes through the
467        // decryption path, this succeeding also proves the bytes
468        // on disk were genuinely encrypted (not silently written as plaintext).
469        let manifest = table.manifest_reader().read(manifest_file).await.unwrap();
470        assert_eq!(manifest.entries().len(), 1);
471        assert_eq!(
472            manifest.entries()[0].data_file().file_path(),
473            "memory:///table/data/00000.parquet"
474        );
475    }
476
477    #[tokio::test]
478    async fn test_fast_append_rejects_delete_files() {
479        let table = make_v2_minimal_table();
480        let delete_file = DataFileBuilder::default()
481            .content(DataContentType::EqualityDeletes)
482            .file_path("test/1-deletes.parquet".to_string())
483            .file_format(DataFileFormat::Parquet)
484            .file_size_in_bytes(100)
485            .record_count(1)
486            .partition_spec_id(table.metadata().default_partition_spec_id())
487            .partition(Struct::from_iter([Some(Literal::long(300))]))
488            .equality_ids(Some(vec![1]))
489            .build()
490            .unwrap();
491
492        let tx = Transaction::new(&table);
493        let action = tx.fast_append().add_data_files(vec![delete_file]);
494        let err = commit_with_fresh_state(action, &table)
495            .await
496            .err()
497            .expect("delete files must be rejected");
498        assert_eq!(err.kind(), ErrorKind::DataInvalid);
499    }
500
501    #[tokio::test]
502    async fn test_empty_data_append_action() {
503        let table = make_v2_minimal_table();
504        let tx = Transaction::new(&table);
505        let action = tx.fast_append().add_data_files(vec![]);
506        assert!(commit_with_fresh_state(action, &table).await.is_err());
507    }
508
509    /// A `fast_append` must write the manifest list and the manifest
510    /// files under the `write.metadata.path` prefix when configured,
511    /// rather than the default `<location>/metadata` directory.
512    #[tokio::test]
513    async fn test_fast_append_honors_write_metadata_path() {
514        let base = make_v2_minimal_table();
515        let metadata_root = format!("{}/custom-meta", base.metadata().location());
516        let metadata = base
517            .metadata()
518            .clone()
519            .into_builder(None)
520            .set_properties(HashMap::from([(
521                "write.metadata.path".to_string(),
522                metadata_root.clone(),
523            )]))
524            .unwrap()
525            .build()
526            .unwrap()
527            .metadata;
528        let table = base.with_metadata(Arc::new(metadata));
529
530        let data_file = DataFileBuilder::default()
531            .content(DataContentType::Data)
532            .file_path(format!("{}/data/1.parquet", table.metadata().location()))
533            .file_format(DataFileFormat::Parquet)
534            .file_size_in_bytes(100)
535            .record_count(1)
536            .partition_spec_id(table.metadata().default_partition_spec_id())
537            .partition(Struct::from_iter([Some(Literal::long(300))]))
538            .build()
539            .unwrap();
540
541        let tx = Transaction::new(&table);
542        let action = tx.fast_append().add_data_files(vec![data_file]);
543        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
544        let updates = action_commit.take_updates();
545
546        let new_snapshot: SnapshotRef = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
547            SnapshotRef::new(snapshot.clone())
548        } else {
549            unreachable!("first update of a fast append should be AddSnapshot")
550        };
551
552        let prefix = format!("{metadata_root}/");
553
554        // Manifest list
555        assert!(
556            new_snapshot.manifest_list().starts_with(prefix.as_str()),
557            "manifest list {} not under configured write.metadata.path {metadata_root}",
558            new_snapshot.manifest_list()
559        );
560
561        // Manifest files
562        let manifest_list = table
563            .manifest_list_reader(&new_snapshot)
564            .load()
565            .await
566            .unwrap();
567        assert!(
568            !manifest_list.entries().is_empty(),
569            "expected at least one manifest entry"
570        );
571        for entry in manifest_list.entries() {
572            assert!(
573                entry.manifest_path.starts_with(prefix.as_str()),
574                "manifest {} not under configured write.metadata.path {metadata_root}",
575                entry.manifest_path
576            );
577        }
578    }
579
580    #[tokio::test]
581    async fn test_set_snapshot_properties() {
582        let table = make_v2_minimal_table();
583        let tx = Transaction::new(&table);
584
585        let mut snapshot_properties = HashMap::new();
586        snapshot_properties.insert("key".to_string(), "val".to_string());
587
588        let data_file = DataFileBuilder::default()
589            .content(DataContentType::Data)
590            .file_path("test/1.parquet".to_string())
591            .file_format(DataFileFormat::Parquet)
592            .file_size_in_bytes(100)
593            .record_count(1)
594            .partition_spec_id(table.metadata().default_partition_spec_id())
595            .partition(Struct::from_iter([Some(Literal::long(300))]))
596            .build()
597            .unwrap();
598
599        let action = tx
600            .fast_append()
601            .set_snapshot_properties(snapshot_properties)
602            .add_data_files(vec![data_file]);
603        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
604        let updates = action_commit.take_updates();
605
606        // Check customized properties is contained in snapshot summary properties.
607        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
608            snapshot
609        } else {
610            unreachable!()
611        };
612        assert_eq!(
613            new_snapshot
614                .summary()
615                .additional_properties
616                .get("key")
617                .unwrap(),
618            "val"
619        );
620    }
621
622    /// See `testdata/manifests_lists/README.md`.
623    const FIXTURE_MASTER_KEY_ID: &str = "master-1";
624    const FIXTURE_MASTER_KEY_BYTES: [u8; 16] = [
625        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
626        0x0f,
627    ];
628
629    async fn make_v3_encrypted_table() -> Table {
630        let json = fs::read_to_string(format!(
631            "{}/testdata/table_metadata/TableMetadataV3ValidEncryption.json",
632            env!("CARGO_MANIFEST_DIR")
633        ))
634        .unwrap();
635        let metadata = serde_json::from_str::<TableMetadata>(&json).unwrap();
636
637        let kms = MemoryKeyManagementClient::new();
638        kms.add_master_key_bytes(
639            FIXTURE_MASTER_KEY_ID,
640            SensitiveBytes::new(FIXTURE_MASTER_KEY_BYTES),
641        )
642        .unwrap();
643
644        let file_io = FileIO::new_with_memory();
645
646        let manifest_list_bytes = fs::read(format!(
647            "{}/testdata/manifests_lists/manifest-list-v3-encrypted.avro",
648            env!("CARGO_MANIFEST_DIR")
649        ))
650        .unwrap();
651        let parent_manifest_list = metadata.current_snapshot().unwrap().manifest_list();
652        file_io
653            .new_output(parent_manifest_list)
654            .unwrap()
655            .write(manifest_list_bytes.into())
656            .await
657            .unwrap();
658
659        Table::builder()
660            .metadata(metadata)
661            .metadata_location("memory:///table/metadata/v1.json")
662            .identifier(TableIdent::from_strs(["ns1", "enc"]).unwrap())
663            .file_io(file_io)
664            .kms_client(Arc::new(kms))
665            .runtime(test_runtime())
666            .build()
667            .unwrap()
668    }
669
670    #[tokio::test]
671    async fn test_commit_with_encryption_adds_keys_and_records_snapshot_key_id() {
672        let table = make_v3_encrypted_table().await;
673
674        let data_file = DataFileBuilder::default()
675            .content(DataContentType::Data)
676            .file_path("test/1.parquet".to_string())
677            .file_format(DataFileFormat::Parquet)
678            .file_size_in_bytes(100)
679            .record_count(1)
680            .partition_spec_id(table.metadata().default_partition_spec_id())
681            .partition(Struct::empty())
682            .build()
683            .unwrap();
684
685        let tx = Transaction::new(&table);
686        let action = tx.fast_append().add_data_files(vec![data_file]);
687        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
688        let updates = action_commit.take_updates();
689
690        let added_key_ids: Vec<String> = updates
691            .iter()
692            .filter_map(|u| match u {
693                TableUpdate::AddEncryptionKey { encryption_key } => {
694                    Some(encryption_key.key_id().to_string())
695                }
696                _ => None,
697            })
698            .collect();
699        assert!(!added_key_ids.is_empty(), "got {updates:?}");
700
701        // Encryption keys are added before the snapshot, so it isn't updates[0] here.
702        let new_snapshot = updates
703            .iter()
704            .find_map(|u| match u {
705                TableUpdate::AddSnapshot { snapshot } => Some(snapshot),
706                _ => None,
707            })
708            .expect("commit should add a snapshot");
709
710        let snapshot_key_id = new_snapshot
711            .encryption_key_id()
712            .expect("encrypted snapshot should record its manifest-list key id");
713        assert!(
714            added_key_ids.iter().any(|id| id == snapshot_key_id),
715            "snapshot key id {snapshot_key_id} not in added keys {added_key_ids:?}"
716        );
717
718        let new_snapshot_ref: SnapshotRef = Arc::new(new_snapshot.clone());
719        let manifest_list = table
720            .manifest_list_reader(&new_snapshot_ref)
721            .load()
722            .await
723            .expect("newly written encrypted manifest list should decrypt and parse");
724        assert_eq!(
725            manifest_list.entries().len(),
726            1,
727            "append should record exactly the one new data manifest"
728        );
729    }
730
731    #[tokio::test]
732    async fn test_snapshot_properties_cannot_override_computed_metrics() {
733        // A user-supplied snapshot property must not shadow a computed metric key
734        // such as `added-data-files`. Matching iceberg-java, the computed value
735        // wins, so the summary reflects the real count and a bad value can neither
736        // corrupt the summary nor panic total computation (see #2184-adjacent fix).
737        let table = make_v2_minimal_table();
738        let tx = Transaction::new(&table);
739
740        let mut snapshot_properties = HashMap::new();
741        // Both a benign-but-wrong value and a non-integer value collide with
742        // computed metric keys; neither should reach the final summary.
743        snapshot_properties.insert("added-data-files".to_string(), "9999".to_string());
744        snapshot_properties.insert("added-records".to_string(), "not-a-number".to_string());
745
746        let data_file = DataFileBuilder::default()
747            .content(DataContentType::Data)
748            .file_path("test/1.parquet".to_string())
749            .file_format(DataFileFormat::Parquet)
750            .file_size_in_bytes(100)
751            .record_count(1)
752            .partition_spec_id(table.metadata().default_partition_spec_id())
753            .partition(Struct::from_iter([Some(Literal::long(300))]))
754            .build()
755            .unwrap();
756
757        let action = tx
758            .fast_append()
759            .set_snapshot_properties(snapshot_properties)
760            .add_data_files(vec![data_file]);
761        // Must not panic during total computation.
762        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
763        let updates = action_commit.take_updates();
764
765        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
766            snapshot
767        } else {
768            unreachable!()
769        };
770        let props = &new_snapshot.summary().additional_properties;
771
772        // Computed metric wins over the user's colliding values.
773        assert_eq!(
774            props.get("added-data-files").unwrap(),
775            "1",
776            "computed added-data-files must override the user-supplied value"
777        );
778        assert_eq!(
779            props.get("added-records").unwrap(),
780            "1",
781            "computed added-records must override the user-supplied non-integer value"
782        );
783    }
784
785    #[tokio::test]
786    async fn test_snapshot_summary_records_writer_identity() {
787        let table = make_v2_minimal_table();
788        let tx = Transaction::new(&table);
789
790        let snapshot_properties = HashMap::from([
791            ("key".to_string(), "val".to_string()),
792            ("iceberg-version".to_string(), "caller".to_string()),
793        ]);
794
795        let action = tx
796            .fast_append()
797            .set_snapshot_properties(snapshot_properties);
798        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
799        let updates = action_commit.take_updates();
800
801        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
802            snapshot
803        } else {
804            unreachable!()
805        };
806        let props = &new_snapshot.summary().additional_properties;
807        let version = env!("CARGO_PKG_VERSION");
808
809        assert_eq!(
810            props.get("iceberg-version").unwrap(),
811            &format!("Apache Iceberg Rust {version}")
812        );
813        assert_eq!(props.get("key").unwrap(), "val");
814    }
815
816    #[tokio::test]
817    async fn test_append_snapshot_properties() {
818        let table = make_v2_minimal_table();
819        let tx = Transaction::new(&table);
820
821        let mut snapshot_properties = HashMap::new();
822        snapshot_properties.insert("key".to_string(), "val".to_string());
823
824        let action = tx
825            .fast_append()
826            .set_snapshot_properties(snapshot_properties);
827        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
828        let updates = action_commit.take_updates();
829
830        // Check customized properties is contained in snapshot summary properties.
831        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
832            snapshot
833        } else {
834            unreachable!()
835        };
836        assert_eq!(
837            new_snapshot
838                .summary()
839                .additional_properties
840                .get("key")
841                .unwrap(),
842            "val"
843        );
844    }
845
846    #[tokio::test]
847    async fn test_fast_append_file_with_incompatible_partition_value() {
848        let table = make_v2_minimal_table();
849        let tx = Transaction::new(&table);
850        let action = tx.fast_append();
851
852        // check add data file with incompatible partition value
853        let data_file = DataFileBuilder::default()
854            .content(DataContentType::Data)
855            .file_path("test/3.parquet".to_string())
856            .file_format(DataFileFormat::Parquet)
857            .file_size_in_bytes(100)
858            .record_count(1)
859            .partition_spec_id(table.metadata().default_partition_spec_id())
860            .partition(Struct::from_iter([Some(Literal::string("test"))]))
861            .build()
862            .unwrap();
863
864        let action = action.add_data_files(vec![data_file.clone()]);
865
866        assert!(commit_with_fresh_state(action, &table).await.is_err());
867    }
868
869    #[tokio::test]
870    async fn test_fast_append_dedupes_intra_batch_duplicate_paths() {
871        let table = make_v2_minimal_table();
872        let tx = Transaction::new(&table);
873
874        let make_file = |size: u64, records: u64| {
875            DataFileBuilder::default()
876                .content(DataContentType::Data)
877                .file_path("test/dup.parquet".to_string())
878                .file_format(DataFileFormat::Parquet)
879                .file_size_in_bytes(size)
880                .record_count(records)
881                .partition_spec_id(table.metadata().default_partition_spec_id())
882                .partition(Struct::from_iter([Some(Literal::long(1))]))
883                .build()
884                .unwrap()
885        };
886
887        // Same path three times, across two `add_data_files` calls: the manifest
888        // keeps a single entry, the first one.
889        let action = tx
890            .fast_append()
891            .add_data_files(vec![make_file(100, 10), make_file(200, 20)])
892            .add_data_files(vec![make_file(300, 30)]);
893        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
894        let files = committed_data_files(&table, &action_commit.take_updates()).await;
895        assert_eq!(1, files.len());
896        assert_eq!(100, files[0].file_size_in_bytes());
897    }
898
899    #[tokio::test]
900    async fn test_fast_append_dedupes_regardless_of_check_duplicate_flag() {
901        let table = make_v2_minimal_table();
902        let tx = Transaction::new(&table);
903
904        let make_file = || {
905            DataFileBuilder::default()
906                .content(DataContentType::Data)
907                .file_path("test/dup.parquet".to_string())
908                .file_format(DataFileFormat::Parquet)
909                .file_size_in_bytes(100)
910                .record_count(10)
911                .partition_spec_id(table.metadata().default_partition_spec_id())
912                .partition(Struct::from_iter([Some(Literal::long(1))]))
913                .build()
914                .unwrap()
915        };
916
917        // `check_duplicate` only gates the cross-snapshot check; intra-batch dedupe runs regardless.
918        let action = tx
919            .fast_append()
920            .with_check_duplicate(false)
921            .add_data_files(vec![make_file(), make_file()]);
922        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
923        let files = committed_data_files(&table, &action_commit.take_updates()).await;
924        assert_eq!(1, files.len());
925    }
926
927    #[tokio::test]
928    async fn test_fast_append() {
929        let table = make_v2_minimal_table();
930        let tx = Transaction::new(&table);
931        let action = tx.fast_append();
932
933        let data_file = DataFileBuilder::default()
934            .content(DataContentType::Data)
935            .file_path("test/3.parquet".to_string())
936            .file_format(DataFileFormat::Parquet)
937            .file_size_in_bytes(100)
938            .record_count(1)
939            .partition_spec_id(table.metadata().default_partition_spec_id())
940            .partition(Struct::from_iter([Some(Literal::long(300))]))
941            .build()
942            .unwrap();
943
944        let action = action.add_data_files(vec![data_file.clone()]);
945        let mut action_commit = commit_with_fresh_state(action, &table).await.unwrap();
946        let updates = action_commit.take_updates();
947        let requirements = action_commit.take_requirements();
948
949        // check updates and requirements
950        assert!(
951            matches!((&updates[0],&updates[1]), (TableUpdate::AddSnapshot { snapshot },TableUpdate::SetSnapshotRef { reference,ref_name }) if snapshot.snapshot_id() == reference.snapshot_id && ref_name == MAIN_BRANCH)
952        );
953        assert_eq!(
954            vec![
955                TableRequirement::UuidMatch {
956                    uuid: table.metadata().uuid()
957                },
958                TableRequirement::RefSnapshotIdMatch {
959                    r#ref: MAIN_BRANCH.to_string(),
960                    snapshot_id: table.metadata().current_snapshot_id
961                }
962            ],
963            requirements
964        );
965
966        // check manifest list
967        let new_snapshot: SnapshotRef = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
968            SnapshotRef::new(snapshot.clone())
969        } else {
970            unreachable!()
971        };
972        let manifest_list = table
973            .manifest_list_reader(&new_snapshot)
974            .load()
975            .await
976            .unwrap();
977        assert_eq!(1, manifest_list.entries().len());
978        assert_eq!(
979            manifest_list.entries()[0].sequence_number,
980            new_snapshot.sequence_number()
981        );
982
983        // check manifest
984        let manifest = table
985            .manifest_reader()
986            .read(&manifest_list.entries()[0])
987            .await
988            .unwrap();
989        assert_eq!(1, manifest.entries().len());
990        assert_eq!(
991            new_snapshot.sequence_number(),
992            manifest.entries()[0]
993                .sequence_number()
994                .expect("Inherit sequence number by load manifest")
995        );
996
997        assert_eq!(
998            new_snapshot.snapshot_id(),
999            manifest.entries()[0].snapshot_id().unwrap()
1000        );
1001        assert_eq!(data_file, *manifest.entries()[0].data_file());
1002    }
1003
1004    /// Two writers append concurrently. Writer A starts from S0, but writer B
1005    /// commits S1 first, so A's first attempt fails the `RefSnapshotIdMatch`
1006    /// requirement and A rebases onto S1 to commit S2.
1007    ///
1008    /// The producer state survives the retry: A's manifest is written once and
1009    /// reused, while parent, sequence number, manifest list and summary are
1010    /// recomputed against S1 (see #3418).
1011    #[tokio::test]
1012    async fn test_fast_append_retry_rebases_onto_concurrent_commit() {
1013        let (s1, s2) = rebase_onto_concurrent_commit(make_v2_minimal_table()).await;
1014        assert_eq!(s1.first_row_id(), None);
1015        assert_eq!(s2.first_row_id(), None);
1016    }
1017
1018    /// On a v3 table the retry must also move the row range: S2's first row id
1019    /// is S1's `next_row_id`, not S0's.
1020    #[tokio::test]
1021    async fn test_fast_append_retry_rebases_row_range_onto_concurrent_commit() {
1022        let (s1, s2) = rebase_onto_concurrent_commit(make_v3_minimal_table()).await;
1023
1024        // B's single-row file takes row 0 in S1, so A's file starts at row 1.
1025        assert_eq!(s1.row_range(), Some((0, 1)));
1026        assert_eq!(s2.row_range(), Some((1, 1)));
1027    }
1028
1029    /// Runs the concurrent-append rebase scenario on `s0` and returns the
1030    /// snapshots committed by writer B (S1) and writer A (S2).
1031    async fn rebase_onto_concurrent_commit(s0: Table) -> (SnapshotRef, SnapshotRef) {
1032        // `TableCommit::apply` derives the next metadata location from the
1033        // current one, so it needs a well-formed name.
1034        let s0 = with_retry_properties(s0, "3").with_metadata_location(
1035            "memory:///table/metadata/00000-9c12d441-03fe-4693-9a96-a0705ddf69c1.metadata.json"
1036                .to_string(),
1037        );
1038        let data_file = |path: &str| {
1039            DataFileBuilder::default()
1040                .content(DataContentType::Data)
1041                .file_path(path.to_string())
1042                .file_format(DataFileFormat::Parquet)
1043                .file_size_in_bytes(100)
1044                .record_count(1)
1045                .partition_spec_id(s0.metadata().default_partition_spec_id())
1046                .partition(Struct::from_iter([Some(Literal::long(300))]))
1047                .build()
1048                .unwrap()
1049        };
1050
1051        // Writer B commits S1 on top of S0.
1052        let mut b_commit = commit_with_fresh_state(
1053            FastAppendAction::new().add_data_files(vec![data_file("test/b.parquet")]),
1054            &s0,
1055        )
1056        .await
1057        .unwrap();
1058        let s1 = TableCommit::builder()
1059            .ident(s0.identifier().clone())
1060            .updates(b_commit.take_updates())
1061            .requirements(b_commit.take_requirements())
1062            .build()
1063            .apply(s0.clone())
1064            .unwrap();
1065        let s1_snapshot = s1.metadata().current_snapshot().unwrap().clone();
1066
1067        // The catalog serves S0 to A's first attempt and S1 afterwards; every
1068        // update is validated against S1, so the first attempt's
1069        // `RefSnapshotIdMatch { snapshot_id: None }` fails with a real conflict.
1070        let mut mock_catalog = MockCatalog::new();
1071        let loads = AtomicU32::new(0);
1072        let (load_s0, load_s1) = (s0.clone(), s1.clone());
1073        mock_catalog
1074            .expect_load_table()
1075            .times(2)
1076            .returning_st(move |_| {
1077                let table = if loads.fetch_add(1, Ordering::SeqCst) == 0 {
1078                    load_s0.clone()
1079                } else {
1080                    load_s1.clone()
1081                };
1082                Box::pin(async move { Ok(table) })
1083            });
1084        let apply_s1 = s1.clone();
1085        mock_catalog
1086            .expect_update_table()
1087            .times(2)
1088            .returning_st(move |commit| {
1089                let s1 = apply_s1.clone();
1090                Box::pin(async move { commit.apply(s1) })
1091            });
1092
1093        // Writer A starts from S0.
1094        let a_uuid = Uuid::now_v7();
1095        let tx = Transaction::new(&s0);
1096        let tx = tx
1097            .fast_append()
1098            .set_commit_uuid(a_uuid)
1099            .add_data_files(vec![data_file("test/a.parquet")])
1100            .apply(tx)
1101            .unwrap();
1102        let s2 = tx.commit(&mock_catalog).await.unwrap();
1103        let s2_snapshot = s2.metadata().current_snapshot().unwrap().clone();
1104
1105        // S2 is rebased onto S1, not S0.
1106        assert_eq!(
1107            s2_snapshot.parent_snapshot_id(),
1108            Some(s1_snapshot.snapshot_id())
1109        );
1110        assert_eq!(
1111            s2_snapshot.sequence_number(),
1112            s1_snapshot.sequence_number() + 1
1113        );
1114        // The committed manifest list is A's second attempt.
1115        assert!(
1116            s2_snapshot
1117                .manifest_list()
1118                .ends_with(&format!("-1-{a_uuid}.avro")),
1119            "unexpected manifest list path {}",
1120            s2_snapshot.manifest_list()
1121        );
1122
1123        // S2 carries B's manifest forward and adds A's. A's manifest is the one
1124        // written during the first attempt (counter 0), reused after rebase.
1125        let manifest_list = s2.manifest_list_reader(&s2_snapshot).load().await.unwrap();
1126        assert_eq!(manifest_list.entries().len(), 2);
1127        let b_manifest = manifest_list
1128            .entries()
1129            .iter()
1130            .find(|m| m.added_snapshot_id == s1_snapshot.snapshot_id())
1131            .expect("S2 should carry B's manifest forward");
1132        let a_manifest = manifest_list
1133            .entries()
1134            .iter()
1135            .find(|m| m.added_snapshot_id == s2_snapshot.snapshot_id())
1136            .expect("S2 should add A's manifest");
1137        assert!(
1138            a_manifest
1139                .manifest_path
1140                .ends_with(&format!("{a_uuid}-m0.avro")),
1141            "unexpected manifest path {}",
1142            a_manifest.manifest_path
1143        );
1144        assert_eq!(a_manifest.sequence_number, s2_snapshot.sequence_number());
1145        assert_eq!(b_manifest.sequence_number, s1_snapshot.sequence_number());
1146
1147        // Summary totals are computed against S1, not S0.
1148        let summary = &s2_snapshot.summary().additional_properties;
1149        assert_eq!(summary.get("added-data-files").unwrap(), "1");
1150        assert_eq!(summary.get("total-data-files").unwrap(), "2");
1151        assert_eq!(summary.get("total-records").unwrap(), "2");
1152
1153        (s1_snapshot, s2_snapshot)
1154    }
1155}