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