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 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/// Table transaction.
89#[derive(Clone)]
90pub struct Transaction {
91    table: Table,
92    actions: Vec<BoxedTransactionAction>,
93}
94
95impl Transaction {
96    /// Creates a new transaction.
97    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    /// Applies an [`ActionCommit`] to the given [`Table`], returning a new [`Table`] with updated metadata.
114    /// Also appends any derived [`TableUpdate`]s and [`TableRequirement`]s to the provided vectors.
115    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    /// Sets table to a new version.
137    pub fn upgrade_table_version(&self) -> UpgradeFormatVersionAction {
138        UpgradeFormatVersionAction::new()
139    }
140
141    /// Update table's property.
142    pub fn update_table_properties(&self) -> UpdatePropertiesAction {
143        UpdatePropertiesAction::new()
144    }
145
146    /// Creates an update schema action.
147    pub fn update_schema(&self) -> UpdateSchemaAction {
148        UpdateSchemaAction::new()
149    }
150
151    /// Creates a fast append action.
152    pub fn fast_append(&self) -> FastAppendAction {
153        FastAppendAction::new()
154    }
155
156    /// Creates a row delta action for row-level changes.
157    ///
158    /// Use this action for:
159    /// - CDC (Change Data Capture) ingestion
160    /// - Upsert operations
161    /// - Adding delete files (position or equality deletes)
162    pub fn row_delta(&self) -> RowDeltaAction {
163        RowDeltaAction::new()
164    }
165
166    /// Creates replace sort order action.
167    pub fn replace_sort_order(&self) -> ReplaceSortOrderAction {
168        ReplaceSortOrderAction::new()
169    }
170
171    /// Set the location of table
172    pub fn update_location(&self) -> UpdateLocationAction {
173        UpdateLocationAction::new()
174    }
175
176    /// Update the statistics of table
177    pub fn update_statistics(&self) -> UpdateStatisticsAction {
178        UpdateStatisticsAction::new()
179    }
180
181    /// Expire snapshots from the table metadata.
182    pub fn expire_snapshots(&self) -> ExpireSnapshotsAction {
183        ExpireSnapshotsAction::new()
184    }
185
186    /// Commit transaction.
187    pub async fn commit(self, catalog: &dyn Catalog) -> Result<Table> {
188        if self.actions.is_empty() {
189            // nothing to commit
190            return Ok(self.table);
191        }
192
193        let table_props = self.table.metadata().table_properties()?;
194
195        // TODO(https://github.com/apache/iceberg-rust/issues/2034): remove once encrypted writes are supported
196        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            // current base is stale, use refreshed as base and re-apply transaction actions
237            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(&current_table).await?;
246            // apply action commit to current_table
247            current_table = Self::apply(
248                current_table,
249                action_commit,
250                &mut existing_updates,
251                &mut existing_requirements,
252            )?;
253        }
254
255        // A location change moves metadata/data to a new prefix that the refresh
256        // load's vended credentials do not cover, so it needs a post-commit reload.
257        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            // The new location has its own vended credentials; the reused FileIO is
270            // scoped to the old prefix, so reload the table to pick them up.
271            catalog.load_table(committed.identifier()).await
272        } else {
273            // The commit response carries no credentials. Reuse the FileIO from the
274            // refresh load above (not `self.table`, which is left untouched when the
275            // metadata is unchanged) so freshly vended credentials are not dropped.
276            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    /// Helper function to create a test table with retry properties
396    pub(super) fn setup_test_table(num_retries: &str) -> Table {
397        let table = make_v2_table();
398
399        // Set retry properties
400        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        // Update table properties
413        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    /// Helper function to create a transaction with a simple update action
427    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    /// Helper function to set up a mock catalog with retryable errors
436    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                    // Always fail with retryable error
465                    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    /// Helper function to set up a mock catalog with non-retryable error
478    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) // Should only be called once since error is not retryable
488            .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        // Create a test table with retry properties
501        let table = setup_test_table("3");
502
503        // Create a transaction with a simple update action
504        let tx = create_test_transaction(&table);
505
506        // Create a mock catalog that fails twice then succeeds
507        let mock_catalog = setup_mock_catalog_with_retryable_errors(Some(2), 3);
508
509        // Commit the transaction
510        let result = tx.commit(&mock_catalog).await;
511
512        // Verify the result
513        assert!(result.is_ok(), "Transaction should eventually succeed");
514    }
515
516    #[tokio::test]
517    async fn test_commit_non_retryable_error() {
518        // Create a test table with retry properties
519        let table = setup_test_table("3");
520
521        // Create a transaction with a simple update action
522        let tx = create_test_transaction(&table);
523
524        // Create a mock catalog that fails with non-retryable error
525        let mock_catalog = setup_mock_catalog_with_non_retryable_error();
526
527        // Commit the transaction
528        let result = tx.commit(&mock_catalog).await;
529
530        // Verify the result
531        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        // Create a test table with retry properties (only allow 2 retries)
542        let table = setup_test_table("2");
543
544        // Create a transaction with a simple update action
545        let tx = create_test_transaction(&table);
546
547        // Create a mock catalog that always fails with retryable error
548        let mock_catalog = setup_mock_catalog_with_retryable_errors(None, 3); // Initial attempt + 2 retries = 3 total attempts
549
550        // Commit the transaction
551        let result = tx.commit(&mock_catalog).await;
552
553        // Verify the result
554        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, /*record_count=*/ 10, /*file_size=*/ 100)
588            .commit(&catalog)
589            .await
590            .unwrap();
591        let table = append_file(&table, /*record_count=*/ 20, /*file_size=*/ 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        // Helper function to create a data file with specified number of rows
676        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        // Check initial state - next_row_id should be 0
693        assert_eq!(table.metadata().next_row_id(), 0);
694
695        // First fast append with 30 rows
696        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        // Check snapshot and table state after first append
703        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        // Check written manifest for first_row_id
708        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        // Second fast append with 17 and 11 rows
716        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        // Check snapshot and table state after second append
726        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        // Check written manifest for first_row_id
731        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}