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;
76use crate::transaction::append::FastAppendAction;
77use crate::transaction::expire_snapshots::ExpireSnapshotsAction;
78use crate::transaction::sort_order::ReplaceSortOrderAction;
79use crate::transaction::update_location::UpdateLocationAction;
80use crate::transaction::update_properties::UpdatePropertiesAction;
81use crate::transaction::update_schema::UpdateSchemaAction;
82use crate::transaction::update_statistics::UpdateStatisticsAction;
83use crate::transaction::upgrade_format_version::UpgradeFormatVersionAction;
84use crate::{Catalog, Error, ErrorKind, 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 if table_props.encryption_key_id.is_some() {
185 return Err(Error::new(
186 ErrorKind::FeatureUnsupported,
187 "Cannot commit to an encrypted table: encrypted writes are not yet supported",
188 ));
189 }
190
191 let backoff = Self::build_backoff(table_props)?;
192 let tx = self;
193
194 (|mut tx: Transaction| async {
195 let result = tx.do_commit(catalog).await;
196 (tx, result)
197 })
198 .retry(backoff)
199 .sleep(tokio::time::sleep)
200 .context(tx)
201 .when(|e| e.retryable())
202 .await
203 .1
204 }
205
206 fn build_backoff(props: TableProperties) -> Result<ExponentialBackoff> {
207 Ok(ExponentialBuilder::new()
208 .with_min_delay(Duration::from_millis(props.commit_min_retry_wait_ms))
209 .with_max_delay(Duration::from_millis(props.commit_max_retry_wait_ms))
210 .with_total_delay(Some(Duration::from_millis(
211 props.commit_total_retry_timeout_ms,
212 )))
213 .with_max_times(props.commit_num_retries)
214 .with_factor(2.0)
215 .build())
216 }
217
218 async fn do_commit(&mut self, catalog: &dyn Catalog) -> Result<Table> {
219 let refreshed = catalog.load_table(self.table.identifier()).await?;
220
221 if self.table.metadata() != refreshed.metadata()
222 || self.table.metadata_location() != refreshed.metadata_location()
223 {
224 self.table = refreshed.clone();
226 }
227
228 let mut current_table = self.table.clone();
229 let mut existing_updates: Vec<TableUpdate> = vec![];
230 let mut existing_requirements: Vec<TableRequirement> = vec![];
231
232 for action in &self.actions {
233 let action_commit = Arc::clone(action).commit(¤t_table).await?;
234 current_table = Self::apply(
236 current_table,
237 action_commit,
238 &mut existing_updates,
239 &mut existing_requirements,
240 )?;
241 }
242
243 let table_commit = TableCommit::builder()
244 .ident(self.table.identifier().to_owned())
245 .updates(existing_updates)
246 .requirements(existing_requirements)
247 .build();
248
249 catalog.update_table(table_commit).await
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use std::collections::HashMap;
256 use std::fs::File;
257 use std::io::BufReader;
258 use std::sync::Arc;
259 use std::sync::atomic::{AtomicU32, Ordering};
260
261 use crate::catalog::MockCatalog;
262 use crate::io::FileIO;
263 use crate::memory::tests::new_memory_catalog;
264 use crate::spec::{
265 DataContentType, DataFileBuilder, DataFileFormat, Literal, Struct, TableMetadata,
266 };
267 use crate::table::Table;
268 use crate::test_utils::{make_encrypted_table, test_runtime};
269 use crate::transaction::{ApplyTransactionAction, Transaction};
270 use crate::{Catalog, Error, ErrorKind, TableCreation, TableIdent};
271
272 pub fn make_v1_table() -> Table {
273 let file = File::open(format!(
274 "{}/testdata/table_metadata/{}",
275 env!("CARGO_MANIFEST_DIR"),
276 "TableMetadataV1Valid.json"
277 ))
278 .unwrap();
279 let reader = BufReader::new(file);
280 let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
281
282 Table::builder()
283 .metadata(resp)
284 .metadata_location("s3://bucket/test/location/metadata/v1.json")
285 .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
286 .file_io(FileIO::new_with_memory())
287 .runtime(test_runtime())
288 .build()
289 .unwrap()
290 }
291
292 pub fn make_v2_table() -> Table {
293 let file = File::open(format!(
294 "{}/testdata/table_metadata/{}",
295 env!("CARGO_MANIFEST_DIR"),
296 "TableMetadataV2Valid.json"
297 ))
298 .unwrap();
299 let reader = BufReader::new(file);
300 let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
301
302 Table::builder()
303 .metadata(resp)
304 .metadata_location("s3://bucket/test/location/metadata/v1.json")
305 .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
306 .file_io(FileIO::new_with_memory())
307 .runtime(test_runtime())
308 .build()
309 .unwrap()
310 }
311
312 pub fn make_v2_minimal_table() -> Table {
313 let file = File::open(format!(
314 "{}/testdata/table_metadata/{}",
315 env!("CARGO_MANIFEST_DIR"),
316 "TableMetadataV2ValidMinimal.json"
317 ))
318 .unwrap();
319 let reader = BufReader::new(file);
320 let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
321
322 Table::builder()
323 .metadata(resp)
324 .metadata_location("s3://bucket/test/location/metadata/v1.json")
325 .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
326 .file_io(FileIO::new_with_memory())
327 .runtime(test_runtime())
328 .build()
329 .unwrap()
330 }
331
332 pub(crate) async fn make_v3_minimal_table_in_catalog(catalog: &impl Catalog) -> Table {
333 let table_ident =
334 TableIdent::from_strs([format!("ns1-{}", uuid::Uuid::new_v4()), "test1".to_string()])
335 .unwrap();
336
337 catalog
338 .create_namespace(table_ident.namespace(), HashMap::new())
339 .await
340 .unwrap();
341
342 let file = File::open(format!(
343 "{}/testdata/table_metadata/{}",
344 env!("CARGO_MANIFEST_DIR"),
345 "TableMetadataV3ValidMinimal.json"
346 ))
347 .unwrap();
348 let reader = BufReader::new(file);
349 let base_metadata = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
350
351 let table_creation = TableCreation::builder()
352 .schema((**base_metadata.current_schema()).clone())
353 .partition_spec((**base_metadata.default_partition_spec()).clone())
354 .sort_order((**base_metadata.default_sort_order()).clone())
355 .name(table_ident.name().to_string())
356 .format_version(crate::spec::FormatVersion::V3)
357 .build();
358
359 catalog
360 .create_table(table_ident.namespace(), table_creation)
361 .await
362 .unwrap()
363 }
364
365 pub(super) fn setup_test_table(num_retries: &str) -> Table {
367 let table = make_v2_table();
368
369 let mut props = HashMap::new();
371 props.insert("commit.retry.min-wait-ms".to_string(), "10".to_string());
372 props.insert("commit.retry.max-wait-ms".to_string(), "100".to_string());
373 props.insert(
374 "commit.retry.total-timeout-ms".to_string(),
375 "1000".to_string(),
376 );
377 props.insert(
378 "commit.retry.num-retries".to_string(),
379 num_retries.to_string(),
380 );
381
382 let metadata = table
384 .metadata()
385 .clone()
386 .into_builder(None)
387 .set_properties(props)
388 .unwrap()
389 .build()
390 .unwrap()
391 .metadata;
392
393 table.with_metadata(Arc::new(metadata))
394 }
395
396 fn create_test_transaction(table: &Table) -> Transaction {
398 let tx = Transaction::new(table);
399 tx.update_table_properties()
400 .set("test.key".to_string(), "test.value".to_string())
401 .apply(tx)
402 .unwrap()
403 }
404
405 fn setup_mock_catalog_with_retryable_errors(
407 success_after_attempts: Option<u32>,
408 expected_calls: usize,
409 ) -> MockCatalog {
410 let mut mock_catalog = MockCatalog::new();
411
412 mock_catalog
413 .expect_load_table()
414 .returning_st(|_| Box::pin(async move { Ok(make_v2_table()) }));
415
416 let attempts = AtomicU32::new(0);
417 mock_catalog
418 .expect_update_table()
419 .times(expected_calls)
420 .returning_st(move |_| {
421 if let Some(success_after_attempts) = success_after_attempts {
422 attempts.fetch_add(1, Ordering::SeqCst);
423 if attempts.load(Ordering::SeqCst) <= success_after_attempts {
424 Box::pin(async move {
425 Err(
426 Error::new(ErrorKind::CatalogCommitConflicts, "Commit conflict")
427 .with_retryable(true),
428 )
429 })
430 } else {
431 Box::pin(async move { Ok(make_v2_table()) })
432 }
433 } else {
434 Box::pin(async move {
436 Err(
437 Error::new(ErrorKind::CatalogCommitConflicts, "Commit conflict")
438 .with_retryable(true),
439 )
440 })
441 }
442 });
443
444 mock_catalog
445 }
446
447 fn setup_mock_catalog_with_non_retryable_error() -> MockCatalog {
449 let mut mock_catalog = MockCatalog::new();
450
451 mock_catalog
452 .expect_load_table()
453 .returning_st(|_| Box::pin(async move { Ok(make_v2_table()) }));
454
455 mock_catalog
456 .expect_update_table()
457 .times(1) .returning_st(move |_| {
459 Box::pin(async move {
460 Err(Error::new(ErrorKind::Unexpected, "Non-retryable error")
461 .with_retryable(false))
462 })
463 });
464
465 mock_catalog
466 }
467
468 #[tokio::test]
469 async fn test_commit_retryable_error() {
470 let table = setup_test_table("3");
472
473 let tx = create_test_transaction(&table);
475
476 let mock_catalog = setup_mock_catalog_with_retryable_errors(Some(2), 3);
478
479 let result = tx.commit(&mock_catalog).await;
481
482 assert!(result.is_ok(), "Transaction should eventually succeed");
484 }
485
486 #[tokio::test]
487 async fn test_commit_non_retryable_error() {
488 let table = setup_test_table("3");
490
491 let tx = create_test_transaction(&table);
493
494 let mock_catalog = setup_mock_catalog_with_non_retryable_error();
496
497 let result = tx.commit(&mock_catalog).await;
499
500 assert!(result.is_err(), "Transaction should fail immediately");
502 if let Err(err) = result {
503 assert_eq!(err.kind(), ErrorKind::Unexpected);
504 assert_eq!(err.message(), "Non-retryable error");
505 assert!(!err.retryable(), "Error should not be retryable");
506 }
507 }
508
509 #[tokio::test]
510 async fn test_commit_max_retries_exceeded() {
511 let table = setup_test_table("2");
513
514 let tx = create_test_transaction(&table);
516
517 let mock_catalog = setup_mock_catalog_with_retryable_errors(None, 3); let result = tx.commit(&mock_catalog).await;
522
523 assert!(result.is_err(), "Transaction should fail after max retries");
525 if let Err(err) = result {
526 assert_eq!(err.kind(), ErrorKind::CatalogCommitConflicts);
527 assert_eq!(err.message(), "Commit conflict");
528 assert!(err.retryable(), "Error should be retryable");
529 }
530 }
531
532 #[tokio::test]
533 async fn test_transaction_snapshot_summary() {
534 let catalog = new_memory_catalog().await;
535 let table = make_v3_minimal_table_in_catalog(&catalog).await;
536
537 let mut file_seq = 0u32;
538 let mut append_file = |table: &Table, record_count: u64, file_size: u64| {
539 file_seq += 1;
540 let file = DataFileBuilder::default()
541 .content(DataContentType::Data)
542 .file_path(format!("test/{file_seq}.parquet"))
543 .file_format(DataFileFormat::Parquet)
544 .file_size_in_bytes(file_size)
545 .record_count(record_count)
546 .partition(Struct::from_iter([Some(Literal::long(1))]))
547 .partition_spec_id(0)
548 .build()
549 .unwrap();
550 let tx = Transaction::new(table);
551 tx.fast_append()
552 .add_data_files(vec![file])
553 .apply(tx)
554 .unwrap()
555 };
556
557 let table = append_file(&table, 10, 100)
558 .commit(&catalog)
559 .await
560 .unwrap();
561 let table = append_file(&table, 20, 200)
562 .commit(&catalog)
563 .await
564 .unwrap();
565
566 let summary = &table
567 .metadata()
568 .current_snapshot()
569 .unwrap()
570 .summary()
571 .additional_properties;
572
573 assert_eq!(summary.get("total-records").unwrap(), "30");
574 assert_eq!(summary.get("total-data-files").unwrap(), "2");
575 assert_eq!(summary.get("total-files-size").unwrap(), "300");
576 }
577
578 #[tokio::test]
579 async fn test_commit_rejects_encrypted_table() {
580 let table = make_encrypted_table().await;
581
582 let tx = Transaction::new(&table);
583 let tx = tx
584 .update_table_properties()
585 .set("test.key".to_string(), "test.value".to_string())
586 .apply(tx)
587 .unwrap();
588
589 let mock_catalog = MockCatalog::new();
590 let result = tx.commit(&mock_catalog).await;
591
592 assert!(result.is_err());
593 let err = result.unwrap_err();
594 assert_eq!(err.kind(), ErrorKind::FeatureUnsupported);
595 assert!(
596 err.message()
597 .contains("encrypted writes are not yet supported"),
598 "unexpected error message: {}",
599 err.message()
600 );
601 }
602}
603
604#[cfg(test)]
605mod test_row_lineage {
606 use crate::memory::tests::new_memory_catalog;
607 use crate::spec::{
608 DataContentType, DataFile, DataFileBuilder, DataFileFormat, Literal, Struct,
609 };
610 use crate::transaction::tests::make_v3_minimal_table_in_catalog;
611 use crate::transaction::{ApplyTransactionAction, Transaction};
612
613 #[tokio::test]
614 async fn test_fast_append_with_row_lineage() {
615 fn file_with_rows(record_count: u64) -> DataFile {
617 DataFileBuilder::default()
618 .content(DataContentType::Data)
619 .file_path(format!("test/{record_count}.parquet"))
620 .file_format(DataFileFormat::Parquet)
621 .file_size_in_bytes(100)
622 .record_count(record_count)
623 .partition(Struct::from_iter([Some(Literal::long(0))]))
624 .partition_spec_id(0)
625 .build()
626 .unwrap()
627 }
628 let catalog = new_memory_catalog().await;
629
630 let table = make_v3_minimal_table_in_catalog(&catalog).await;
631
632 assert_eq!(table.metadata().next_row_id(), 0);
634
635 let tx = Transaction::new(&table);
637 let data_file_30 = file_with_rows(30);
638 let action = tx.fast_append().add_data_files(vec![data_file_30]);
639 let tx = action.apply(tx).unwrap();
640 let table = tx.commit(&catalog).await.unwrap();
641
642 let snapshot = table.metadata().current_snapshot().unwrap();
644 assert_eq!(snapshot.first_row_id(), Some(0));
645 assert_eq!(table.metadata().next_row_id(), 30);
646
647 let snapshot = table.metadata().current_snapshot().unwrap();
649 let manifest_list = table.manifest_list_reader(snapshot).load().await.unwrap();
650
651 assert_eq!(manifest_list.entries().len(), 1);
652 let manifest_file = &manifest_list.entries()[0];
653 assert_eq!(manifest_file.first_row_id, Some(0));
654
655 let tx = Transaction::new(&table);
657 let data_file_17 = file_with_rows(17);
658 let data_file_11 = file_with_rows(11);
659 let action = tx
660 .fast_append()
661 .add_data_files(vec![data_file_17, data_file_11]);
662 let tx = action.apply(tx).unwrap();
663 let table = tx.commit(&catalog).await.unwrap();
664
665 let snapshot = table.metadata().current_snapshot().unwrap();
667 assert_eq!(snapshot.first_row_id(), Some(30));
668 assert_eq!(table.metadata().next_row_id(), 30 + 17 + 11);
669
670 let manifest_list = table.manifest_list_reader(snapshot).load().await.unwrap();
672 assert_eq!(manifest_list.entries().len(), 2);
673 let manifest_file = &manifest_list.entries()[1];
674 assert_eq!(manifest_file.first_row_id, Some(30));
675 }
676}