1use 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#[derive(Clone)]
34pub struct FastAppendAction {
35 check_duplicate: bool,
36 commit_uuid: Option<Uuid>,
38 snapshot_properties: HashMap<String, String>,
39 added_data_files: Vec<DataFile>,
41 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 pub fn with_check_duplicate(mut self, v: bool) -> Self {
58 self.check_duplicate = v;
59 self
60 }
61
62 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 pub fn set_commit_uuid(mut self, commit_uuid: Uuid) -> Self {
78 self.commit_uuid = Some(commit_uuid);
79 self
80 }
81
82 pub fn set_snapshot_properties(mut self, snapshot_properties: HashMap<String, String>) -> Self {
84 self.snapshot_properties = snapshot_properties;
85 self
86 }
87
88 fn validate(&self) -> Result<()> {
90 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 }
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 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 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 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 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 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 #[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 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 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 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 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 #[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 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 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 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 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 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 let table = make_v2_minimal_table();
738 let tx = Transaction::new(&table);
739
740 let mut snapshot_properties = HashMap::new();
741 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 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 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 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 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 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 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 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 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 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 #[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 #[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 assert_eq!(s1.row_range(), Some((0, 1)));
1026 assert_eq!(s2.row_range(), Some((1, 1)));
1027 }
1028
1029 async fn rebase_onto_concurrent_commit(s0: Table) -> (SnapshotRef, SnapshotRef) {
1032 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 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 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 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 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 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 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 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}