1mod action;
54
55pub use action::*;
56mod append;
57mod expire_snapshots;
58mod snapshot;
59mod sort_order;
60mod update_location;
61mod update_properties;
62mod update_schema;
63mod update_statistics;
64mod upgrade_format_version;
65
66use std::sync::Arc;
67use std::time::Duration;
68
69use backon::{BackoffBuilder, ExponentialBackoff, ExponentialBuilder, RetryableWithContext};
70pub use update_schema::AddColumn;
71
72use crate::error::Result;
73use crate::spec::TableProperties;
74use crate::table::Table;
75use crate::transaction::action::TransactionActionEntry;
76pub use crate::transaction::append::FastAppendAction;
77pub use crate::transaction::expire_snapshots::ExpireSnapshotsAction;
78pub use crate::transaction::sort_order::ReplaceSortOrderAction;
79pub use crate::transaction::update_location::UpdateLocationAction;
80pub use crate::transaction::update_properties::UpdatePropertiesAction;
81pub use crate::transaction::update_schema::UpdateSchemaAction;
82pub use crate::transaction::update_statistics::UpdateStatisticsAction;
83pub use crate::transaction::upgrade_format_version::UpgradeFormatVersionAction;
84use crate::{Catalog, Error, ErrorKind, TableCommit, TableRequirement, TableUpdate};
85
86pub struct Transaction {
88 table: Table,
89 actions: Vec<TransactionActionEntry>,
90}
91
92impl Transaction {
93 pub fn new(table: &Table) -> Self {
95 Self {
96 table: table.clone(),
97 actions: vec![],
98 }
99 }
100
101 fn update_table_metadata(table: Table, updates: &[TableUpdate]) -> Result<Table> {
102 let mut metadata_builder = table.metadata().clone().into_builder(None);
103 for update in updates {
104 metadata_builder = update.clone().apply(metadata_builder)?;
105 }
106
107 Ok(table.with_metadata(Arc::new(metadata_builder.build()?.metadata)))
108 }
109
110 fn apply(
113 table: Table,
114 mut action_commit: ActionCommit,
115 existing_updates: &mut Vec<TableUpdate>,
116 existing_requirements: &mut Vec<TableRequirement>,
117 ) -> Result<Table> {
118 let updates = action_commit.take_updates();
119 let requirements = action_commit.take_requirements();
120
121 for requirement in &requirements {
122 requirement.check(Some(table.metadata()))?;
123 }
124
125 let updated_table = Self::update_table_metadata(table, &updates)?;
126
127 existing_updates.extend(updates);
128 existing_requirements.extend(requirements);
129
130 Ok(updated_table)
131 }
132
133 pub fn upgrade_table_version(&self) -> UpgradeFormatVersionAction {
135 UpgradeFormatVersionAction::new()
136 }
137
138 pub fn update_table_properties(&self) -> UpdatePropertiesAction {
140 UpdatePropertiesAction::new()
141 }
142
143 pub fn update_schema(&self) -> UpdateSchemaAction {
145 UpdateSchemaAction::new()
146 }
147
148 pub fn fast_append(&self) -> FastAppendAction {
150 FastAppendAction::new()
151 }
152
153 pub fn replace_sort_order(&self) -> ReplaceSortOrderAction {
155 ReplaceSortOrderAction::new()
156 }
157
158 pub fn update_location(&self) -> UpdateLocationAction {
160 UpdateLocationAction::new()
161 }
162
163 pub fn update_statistics(&self) -> UpdateStatisticsAction {
165 UpdateStatisticsAction::new()
166 }
167
168 pub fn expire_snapshots(&self) -> ExpireSnapshotsAction {
170 ExpireSnapshotsAction::new()
171 }
172
173 pub async fn commit(self, catalog: &dyn Catalog) -> Result<Table> {
178 if self.table.readonly() {
179 return Err(Error::new(
180 ErrorKind::PreconditionFailed,
181 "Cannot modify a read-only table",
182 )
183 .with_context("table", self.table.identifier().to_string()));
184 }
185
186 if self.actions.is_empty() {
187 return Ok(self.table);
189 }
190
191 let table_props = self.table.metadata().table_properties();
192
193 let backoff = Self::build_backoff(table_props)?;
194 let tx = self;
195
196 (|mut tx: Transaction| async {
197 let result = tx.do_commit(catalog).await;
198 (tx, result)
199 })
200 .retry(backoff)
201 .sleep(tokio::time::sleep)
202 .context(tx)
203 .when(|e| e.retryable())
204 .await
205 .1
206 }
207
208 fn build_backoff(props: TableProperties<'_>) -> Result<ExponentialBackoff> {
209 Ok(ExponentialBuilder::new()
210 .with_min_delay(Duration::from_millis(props.commit_min_retry_wait_ms()?))
211 .with_max_delay(Duration::from_millis(props.commit_max_retry_wait_ms()?))
212 .with_total_delay(Some(Duration::from_millis(
213 props.commit_total_retry_timeout_ms()?,
214 )))
215 .with_max_times(props.commit_num_retries()?)
216 .with_factor(2.0)
217 .build())
218 }
219
220 async fn do_commit(&mut self, catalog: &dyn Catalog) -> Result<Table> {
221 let refreshed = catalog.load_table(self.table.identifier()).await?;
222
223 if self.table.metadata() != refreshed.metadata()
224 || self.table.metadata_location() != refreshed.metadata_location()
225 {
226 self.table = refreshed.clone();
228 }
229
230 let mut current_table = self.table.clone();
231 let mut existing_updates: Vec<TableUpdate> = vec![];
232 let mut existing_requirements: Vec<TableRequirement> = vec![];
233
234 for entry in self.actions.iter_mut() {
235 let action_commit = entry.commit(¤t_table).await?;
236 current_table = Self::apply(
238 current_table,
239 action_commit,
240 &mut existing_updates,
241 &mut existing_requirements,
242 )?;
243 }
244
245 let table_commit = TableCommit::builder()
246 .ident(self.table.identifier().to_owned())
247 .updates(existing_updates)
248 .requirements(existing_requirements)
249 .build();
250
251 catalog.update_table(table_commit).await
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use std::collections::HashMap;
258 use std::fs::File;
259 use std::io::BufReader;
260 use std::sync::Arc;
261 use std::sync::atomic::{AtomicU32, Ordering};
262
263 use crate::catalog::MockCatalog;
264 use crate::io::FileIO;
265 use crate::memory::tests::new_memory_catalog;
266 use crate::spec::{
267 DataContentType, DataFileBuilder, DataFileFormat, Literal, Struct, TableMetadata,
268 TableProperties,
269 };
270 use crate::table::{StaticTable, Table};
271 use crate::test_utils::{make_encrypted_table, test_runtime};
272 use crate::transaction::{ApplyTransactionAction, Transaction};
273 use crate::{Catalog, Error, ErrorKind, TableCreation, TableIdent};
274
275 pub fn make_v1_table() -> Table {
276 let file = File::open(format!(
277 "{}/testdata/table_metadata/{}",
278 env!("CARGO_MANIFEST_DIR"),
279 "TableMetadataV1Valid.json"
280 ))
281 .unwrap();
282 let reader = BufReader::new(file);
283 let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
284
285 Table::builder()
286 .metadata(resp)
287 .metadata_location("s3://bucket/test/location/metadata/v1.json")
288 .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
289 .file_io(FileIO::new_with_memory())
290 .runtime(test_runtime())
291 .build()
292 .unwrap()
293 }
294
295 pub fn make_v2_table() -> Table {
296 let file = File::open(format!(
297 "{}/testdata/table_metadata/{}",
298 env!("CARGO_MANIFEST_DIR"),
299 "TableMetadataV2Valid.json"
300 ))
301 .unwrap();
302 let reader = BufReader::new(file);
303 let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
304
305 Table::builder()
306 .metadata(resp)
307 .metadata_location("s3://bucket/test/location/metadata/v1.json")
308 .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
309 .file_io(FileIO::new_with_memory())
310 .runtime(test_runtime())
311 .build()
312 .unwrap()
313 }
314
315 pub fn make_v2_minimal_table() -> Table {
316 let file = File::open(format!(
317 "{}/testdata/table_metadata/{}",
318 env!("CARGO_MANIFEST_DIR"),
319 "TableMetadataV2ValidMinimal.json"
320 ))
321 .unwrap();
322 let reader = BufReader::new(file);
323 let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
324
325 Table::builder()
326 .metadata(resp)
327 .metadata_location("s3://bucket/test/location/metadata/v1.json")
328 .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
329 .file_io(FileIO::new_with_memory())
330 .runtime(test_runtime())
331 .build()
332 .unwrap()
333 }
334
335 pub(crate) fn make_v3_minimal_table() -> Table {
336 let file = File::open(format!(
337 "{}/testdata/table_metadata/{}",
338 env!("CARGO_MANIFEST_DIR"),
339 "TableMetadataV3ValidMinimal.json"
340 ))
341 .unwrap();
342 let reader = BufReader::new(file);
343 let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
344
345 Table::builder()
346 .metadata(resp)
347 .metadata_location("s3://bucket/test/location/metadata/v1.json")
348 .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
349 .file_io(FileIO::new_with_memory())
350 .runtime(test_runtime())
351 .build()
352 .unwrap()
353 }
354
355 async fn make_v2_minimal_readonly_table() -> Table {
356 let table = make_v2_minimal_table();
357 StaticTable::from_metadata(
358 table.metadata().clone(),
359 table.identifier().clone(),
360 FileIO::new_with_memory(),
361 )
362 .await
363 .unwrap()
364 .into_table()
365 }
366
367 pub(crate) async fn make_v3_minimal_table_in_catalog(catalog: &impl Catalog) -> Table {
368 let table_ident =
369 TableIdent::from_strs([format!("ns1-{}", uuid::Uuid::new_v4()), "test1".to_string()])
370 .unwrap();
371
372 catalog
373 .create_namespace(table_ident.namespace(), HashMap::new())
374 .await
375 .unwrap();
376
377 let file = File::open(format!(
378 "{}/testdata/table_metadata/{}",
379 env!("CARGO_MANIFEST_DIR"),
380 "TableMetadataV3ValidMinimal.json"
381 ))
382 .unwrap();
383 let reader = BufReader::new(file);
384 let base_metadata = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
385
386 let table_creation = TableCreation::builder()
387 .schema((**base_metadata.current_schema()).clone())
388 .partition_spec((**base_metadata.default_partition_spec()).clone())
389 .sort_order((**base_metadata.default_sort_order()).clone())
390 .name(table_ident.name().to_string())
391 .format_version(crate::spec::FormatVersion::V3)
392 .build();
393
394 catalog
395 .create_table(table_ident.namespace(), table_creation)
396 .await
397 .unwrap()
398 }
399
400 pub(super) fn setup_test_table(num_retries: &str) -> Table {
402 with_retry_properties(make_v2_table(), num_retries)
403 }
404
405 pub(super) fn with_retry_properties(table: Table, num_retries: &str) -> Table {
407 let mut props = HashMap::new();
409 props.insert("commit.retry.min-wait-ms".to_string(), "10".to_string());
410 props.insert("commit.retry.max-wait-ms".to_string(), "100".to_string());
411 props.insert(
412 "commit.retry.total-timeout-ms".to_string(),
413 "1000".to_string(),
414 );
415 props.insert(
416 "commit.retry.num-retries".to_string(),
417 num_retries.to_string(),
418 );
419
420 let metadata = table
422 .metadata()
423 .clone()
424 .into_builder(None)
425 .set_properties(props)
426 .unwrap()
427 .build()
428 .unwrap()
429 .metadata;
430
431 table.with_metadata(Arc::new(metadata))
432 }
433
434 fn create_test_transaction(table: &Table) -> Transaction {
436 let tx = Transaction::new(table);
437 tx.update_table_properties()
438 .set("test.key".to_string(), "test.value".to_string())
439 .apply(tx)
440 .unwrap()
441 }
442
443 fn setup_mock_catalog_with_retryable_errors(
445 success_after_attempts: Option<u32>,
446 expected_calls: usize,
447 ) -> MockCatalog {
448 let mut mock_catalog = MockCatalog::new();
449
450 mock_catalog
451 .expect_load_table()
452 .returning_st(|_| Box::pin(async move { Ok(make_v2_table()) }));
453
454 let attempts = AtomicU32::new(0);
455 mock_catalog
456 .expect_update_table()
457 .times(expected_calls)
458 .returning_st(move |_| {
459 if let Some(success_after_attempts) = success_after_attempts {
460 attempts.fetch_add(1, Ordering::SeqCst);
461 if attempts.load(Ordering::SeqCst) <= success_after_attempts {
462 Box::pin(async move {
463 Err(
464 Error::new(ErrorKind::CatalogCommitConflicts, "Commit conflict")
465 .with_retryable(true),
466 )
467 })
468 } else {
469 Box::pin(async move { Ok(make_v2_table()) })
470 }
471 } else {
472 Box::pin(async move {
474 Err(
475 Error::new(ErrorKind::CatalogCommitConflicts, "Commit conflict")
476 .with_retryable(true),
477 )
478 })
479 }
480 });
481
482 mock_catalog
483 }
484
485 fn setup_mock_catalog_with_non_retryable_error() -> MockCatalog {
487 let mut mock_catalog = MockCatalog::new();
488
489 mock_catalog
490 .expect_load_table()
491 .returning_st(|_| Box::pin(async move { Ok(make_v2_table()) }));
492
493 mock_catalog
494 .expect_update_table()
495 .times(1) .returning_st(move |_| {
497 Box::pin(async move {
498 Err(Error::new(ErrorKind::Unexpected, "Non-retryable error")
499 .with_retryable(false))
500 })
501 });
502
503 mock_catalog
504 }
505
506 #[tokio::test]
507 async fn test_commit_retryable_error() {
508 let table = setup_test_table("3");
510
511 let tx = create_test_transaction(&table);
513
514 let mock_catalog = setup_mock_catalog_with_retryable_errors(Some(2), 3);
516
517 let result = tx.commit(&mock_catalog).await;
519
520 assert!(result.is_ok(), "Transaction should eventually succeed");
522 }
523
524 #[tokio::test]
525 async fn test_commit_non_retryable_error() {
526 let table = setup_test_table("3");
528
529 let tx = create_test_transaction(&table);
531
532 let mock_catalog = setup_mock_catalog_with_non_retryable_error();
534
535 let result = tx.commit(&mock_catalog).await;
537
538 assert!(result.is_err(), "Transaction should fail immediately");
540 if let Err(err) = result {
541 assert_eq!(err.kind(), ErrorKind::Unexpected);
542 assert_eq!(err.message(), "Non-retryable error");
543 assert!(!err.retryable(), "Error should not be retryable");
544 }
545 }
546
547 #[tokio::test]
548 async fn test_commit_max_retries_exceeded() {
549 let table = setup_test_table("2");
551
552 let tx = create_test_transaction(&table);
554
555 let mock_catalog = setup_mock_catalog_with_retryable_errors(None, 3); let result = tx.commit(&mock_catalog).await;
560
561 assert!(result.is_err(), "Transaction should fail after max retries");
563 if let Err(err) = result {
564 assert_eq!(err.kind(), ErrorKind::CatalogCommitConflicts);
565 assert_eq!(err.message(), "Commit conflict");
566 assert!(err.retryable(), "Error should be retryable");
567 }
568 }
569
570 #[tokio::test]
571 async fn test_transaction_snapshot_summary() {
572 let catalog = new_memory_catalog().await;
573 let table = make_v3_minimal_table_in_catalog(&catalog).await;
574
575 let mut file_seq = 0u32;
576 let mut append_file = |table: &Table, record_count: u64, file_size: u64| {
577 file_seq += 1;
578 let file = DataFileBuilder::default()
579 .content(DataContentType::Data)
580 .file_path(format!("test/{file_seq}.parquet"))
581 .file_format(DataFileFormat::Parquet)
582 .file_size_in_bytes(file_size)
583 .record_count(record_count)
584 .partition(Struct::from_iter([Some(Literal::long(1))]))
585 .partition_spec_id(0)
586 .build()
587 .unwrap();
588 let tx = Transaction::new(table);
589 tx.fast_append()
590 .add_data_files(vec![file])
591 .apply(tx)
592 .unwrap()
593 };
594
595 let table = append_file(&table, 10, 100)
596 .commit(&catalog)
597 .await
598 .unwrap();
599 let table = append_file(&table, 20, 200)
600 .commit(&catalog)
601 .await
602 .unwrap();
603
604 let summary = &table
605 .metadata()
606 .current_snapshot()
607 .unwrap()
608 .summary()
609 .additional_properties;
610
611 assert_eq!(summary.get("total-records").unwrap(), "30");
612 assert_eq!(summary.get("total-data-files").unwrap(), "2");
613 assert_eq!(summary.get("total-files-size").unwrap(), "300");
614 }
615
616 #[tokio::test]
617 async fn test_commit_to_encrypted_table() {
618 let table = make_encrypted_table().await.with_metadata_location(
619 "memory:///table/metadata/00000-9c12d441-03fe-4693-9a96-a0705ddf69c1.metadata.json"
620 .to_string(),
621 );
622 let refreshed_table = table.clone();
623 let update_table = table.clone();
624 let mut mock_catalog = MockCatalog::new();
625 mock_catalog
626 .expect_load_table()
627 .times(1)
628 .returning_st(move |_| {
629 let refreshed_table = refreshed_table.clone();
630 Box::pin(async move { Ok(refreshed_table) })
631 });
632 mock_catalog
633 .expect_update_table()
634 .times(1)
635 .returning_st(move |commit| {
636 let update_table = update_table.clone();
637 Box::pin(async move { commit.apply(update_table) })
638 });
639
640 let tx = Transaction::new(&table);
641 let tx = tx
642 .update_table_properties()
643 .set("test.key".to_string(), "test.value".to_string())
644 .apply(tx)
645 .unwrap();
646
647 let updated_table = tx.commit(&mock_catalog).await.unwrap();
648
649 assert_eq!(
650 updated_table
651 .metadata()
652 .properties()
653 .get(TableProperties::PROPERTY_ENCRYPTION_KEY_ID)
654 .map(String::as_str),
655 Some("master-1")
656 );
657 assert_eq!(
658 updated_table
659 .metadata()
660 .properties()
661 .get("test.key")
662 .map(String::as_str),
663 Some("test.value")
664 );
665 assert!(updated_table.encryption_manager().is_some());
666 }
667
668 #[tokio::test]
669 async fn test_commit_readonly_table_fails() {
670 let table = make_v2_minimal_readonly_table().await;
671 let catalog = MockCatalog::new();
672
673 let err = Transaction::new(&table).commit(&catalog).await.unwrap_err();
675
676 assert_eq!(err.kind(), ErrorKind::PreconditionFailed);
677 assert_eq!(err.message(), "Cannot modify a read-only table");
678 assert!(!err.retryable(), "Error should not be retryable");
679 }
680
681 #[tokio::test]
682 async fn test_commit_readonly_table_with_actions_fails() {
683 let table = make_v2_minimal_readonly_table().await;
684 let catalog = MockCatalog::new();
685
686 let err = create_test_transaction(&table)
687 .commit(&catalog)
688 .await
689 .unwrap_err();
690
691 assert_eq!(err.kind(), ErrorKind::PreconditionFailed);
692 }
693
694 #[tokio::test]
695 async fn test_commit_empty_transaction_is_noop() {
696 let table = make_v2_minimal_table();
697 let catalog = MockCatalog::new();
698
699 let result = Transaction::new(&table).commit(&catalog).await.unwrap();
700
701 assert_eq!(result.metadata(), table.metadata());
702 }
703}
704
705#[cfg(test)]
706mod test_row_lineage {
707 use crate::memory::tests::new_memory_catalog;
708 use crate::spec::{
709 DataContentType, DataFile, DataFileBuilder, DataFileFormat, Literal, Struct,
710 };
711 use crate::transaction::tests::make_v3_minimal_table_in_catalog;
712 use crate::transaction::{ApplyTransactionAction, Transaction};
713
714 #[tokio::test]
715 async fn test_fast_append_with_row_lineage() {
716 fn file_with_rows(record_count: u64) -> DataFile {
718 DataFileBuilder::default()
719 .content(DataContentType::Data)
720 .file_path(format!("test/{record_count}.parquet"))
721 .file_format(DataFileFormat::Parquet)
722 .file_size_in_bytes(100)
723 .record_count(record_count)
724 .partition(Struct::from_iter([Some(Literal::long(0))]))
725 .partition_spec_id(0)
726 .build()
727 .unwrap()
728 }
729 let catalog = new_memory_catalog().await;
730
731 let table = make_v3_minimal_table_in_catalog(&catalog).await;
732
733 assert_eq!(table.metadata().next_row_id(), 0);
735
736 let tx = Transaction::new(&table);
738 let data_file_30 = file_with_rows(30);
739 let action = tx.fast_append().add_data_files(vec![data_file_30]);
740 let tx = action.apply(tx).unwrap();
741 let table = tx.commit(&catalog).await.unwrap();
742
743 let snapshot = table.metadata().current_snapshot().unwrap();
745 assert_eq!(snapshot.first_row_id(), Some(0));
746 assert_eq!(table.metadata().next_row_id(), 30);
747
748 let snapshot = table.metadata().current_snapshot().unwrap();
750 let manifest_list = table.manifest_list_reader(snapshot).load().await.unwrap();
751
752 assert_eq!(manifest_list.entries().len(), 1);
753 let manifest_file = &manifest_list.entries()[0];
754 assert_eq!(manifest_file.first_row_id, Some(0));
755
756 let tx = Transaction::new(&table);
758 let data_file_17 = file_with_rows(17);
759 let data_file_11 = file_with_rows(11);
760 let action = tx
761 .fast_append()
762 .add_data_files(vec![data_file_17, data_file_11]);
763 let tx = action.apply(tx).unwrap();
764 let table = tx.commit(&catalog).await.unwrap();
765
766 let snapshot = table.metadata().current_snapshot().unwrap();
768 assert_eq!(snapshot.first_row_id(), Some(30));
769 assert_eq!(table.metadata().next_row_id(), 30 + 17 + 11);
770
771 let manifest_list = table.manifest_list_reader(snapshot).load().await.unwrap();
773 assert_eq!(manifest_list.entries().len(), 2);
774 let manifest_file = &manifest_list.entries()[1];
775 assert_eq!(manifest_file.first_row_id, Some(30));
776 }
777}