Skip to main content

iceberg/transaction/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! This module contains transaction api.
19//!
20//! The transaction API enables changes to be made to an existing table.
21//!
22//! Note that this may also have side effects, such as producing new manifest
23//! files.
24//!
25//! Below is a basic example using the "fast-append" action:
26//!
27//! ```ignore
28//! use iceberg::transaction::{ApplyTransactionAction, Transaction};
29//! use iceberg::Catalog;
30//!
31//! // Create a transaction.
32//! let tx = Transaction::new(my_table);
33//!
34//! // Create a `FastAppendAction` which will not rewrite or append
35//! // to existing metadata. This will create a new manifest.
36//! let action = tx.fast_append().add_data_files(my_data_files);
37//!
38//! // Apply the fast-append action to the given transaction, returning
39//! // the newly updated `Transaction`.
40//! let tx = action.apply(tx).unwrap();
41//!
42//!
43//! // End the transaction by committing to an `iceberg::Catalog`
44//! // implementation. This will cause a table update to occur.
45//! let table = tx
46//!     .commit(&some_catalog_impl)
47//!     .await
48//!     .unwrap();
49//! ```
50
51/// The `ApplyTransactionAction` trait provides an `apply` method
52/// that allows users to apply a transaction action to a `Transaction`.
53mod 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/// Table transaction.
87#[derive(Clone)]
88pub struct Transaction {
89    table: Table,
90    actions: Vec<BoxedTransactionAction>,
91}
92
93impl Transaction {
94    /// Creates a new transaction.
95    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    /// Applies an [`ActionCommit`] to the given [`Table`], returning a new [`Table`] with updated metadata.
112    /// Also appends any derived [`TableUpdate`]s and [`TableRequirement`]s to the provided vectors.
113    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    /// Sets table to a new version.
135    pub fn upgrade_table_version(&self) -> UpgradeFormatVersionAction {
136        UpgradeFormatVersionAction::new()
137    }
138
139    /// Update table's property.
140    pub fn update_table_properties(&self) -> UpdatePropertiesAction {
141        UpdatePropertiesAction::new()
142    }
143
144    /// Creates an update schema action.
145    pub fn update_schema(&self) -> UpdateSchemaAction {
146        UpdateSchemaAction::new()
147    }
148
149    /// Creates a fast append action.
150    pub fn fast_append(&self) -> FastAppendAction {
151        FastAppendAction::new()
152    }
153
154    /// Creates replace sort order action.
155    pub fn replace_sort_order(&self) -> ReplaceSortOrderAction {
156        ReplaceSortOrderAction::new()
157    }
158
159    /// Set the location of table
160    pub fn update_location(&self) -> UpdateLocationAction {
161        UpdateLocationAction::new()
162    }
163
164    /// Update the statistics of table
165    pub fn update_statistics(&self) -> UpdateStatisticsAction {
166        UpdateStatisticsAction::new()
167    }
168
169    /// Expire snapshots from the table metadata.
170    pub fn expire_snapshots(&self) -> ExpireSnapshotsAction {
171        ExpireSnapshotsAction::new()
172    }
173
174    /// Commit transaction.
175    pub async fn commit(self, catalog: &dyn Catalog) -> Result<Table> {
176        if self.actions.is_empty() {
177            // nothing to commit
178            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            // current base is stale, use refreshed as base and re-apply transaction actions
217            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(&current_table).await?;
226            // apply action commit to current_table
227            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    /// Helper function to create a test table with retry properties
359    pub(super) fn setup_test_table(num_retries: &str) -> Table {
360        let table = make_v2_table();
361
362        // Set retry properties
363        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        // Update table properties
376        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    /// Helper function to create a transaction with a simple update action
390    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    /// Helper function to set up a mock catalog with retryable errors
399    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                    // Always fail with retryable error
428                    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    /// Helper function to set up a mock catalog with non-retryable error
441    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) // Should only be called once since error is not retryable
451            .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        // Create a test table with retry properties
464        let table = setup_test_table("3");
465
466        // Create a transaction with a simple update action
467        let tx = create_test_transaction(&table);
468
469        // Create a mock catalog that fails twice then succeeds
470        let mock_catalog = setup_mock_catalog_with_retryable_errors(Some(2), 3);
471
472        // Commit the transaction
473        let result = tx.commit(&mock_catalog).await;
474
475        // Verify the result
476        assert!(result.is_ok(), "Transaction should eventually succeed");
477    }
478
479    #[tokio::test]
480    async fn test_commit_non_retryable_error() {
481        // Create a test table with retry properties
482        let table = setup_test_table("3");
483
484        // Create a transaction with a simple update action
485        let tx = create_test_transaction(&table);
486
487        // Create a mock catalog that fails with non-retryable error
488        let mock_catalog = setup_mock_catalog_with_non_retryable_error();
489
490        // Commit the transaction
491        let result = tx.commit(&mock_catalog).await;
492
493        // Verify the result
494        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        // Create a test table with retry properties (only allow 2 retries)
505        let table = setup_test_table("2");
506
507        // Create a transaction with a simple update action
508        let tx = create_test_transaction(&table);
509
510        // Create a mock catalog that always fails with retryable error
511        let mock_catalog = setup_mock_catalog_with_retryable_errors(None, 3); // Initial attempt + 2 retries = 3 total attempts
512
513        // Commit the transaction
514        let result = tx.commit(&mock_catalog).await;
515
516        // Verify the result
517        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, /*record_count=*/ 10, /*file_size=*/ 100)
551            .commit(&catalog)
552            .await
553            .unwrap();
554        let table = append_file(&table, /*record_count=*/ 20, /*file_size=*/ 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        // Helper function to create a data file with specified number of rows
636        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        // Check initial state - next_row_id should be 0
653        assert_eq!(table.metadata().next_row_id(), 0);
654
655        // First fast append with 30 rows
656        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        // Check snapshot and table state after first append
663        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        // Check written manifest for first_row_id
668        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        // Second fast append with 17 and 11 rows
676        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        // Check snapshot and table state after second append
686        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        // Check written manifest for first_row_id
691        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}