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