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::TransactionActionEntry;
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, Error, ErrorKind, TableCommit, TableRequirement, TableUpdate};
85
86/// Table transaction.
87pub struct Transaction {
88    table: Table,
89    actions: Vec<TransactionActionEntry>,
90}
91
92impl Transaction {
93    /// Creates a new transaction.
94    pub fn new(table: &Table) -> Self {
95        Self {
96            table: table.clone(),
97            actions: vec![],
98        }
99    }
100
101    fn update_table_metadata(table: Table, updates: &[TableUpdate]) -> Result<Table> {
102        let mut metadata_builder = table.metadata().clone().into_builder(None);
103        for update in updates {
104            metadata_builder = update.clone().apply(metadata_builder)?;
105        }
106
107        Ok(table.with_metadata(Arc::new(metadata_builder.build()?.metadata)))
108    }
109
110    /// Applies an [`ActionCommit`] to the given [`Table`], returning a new [`Table`] with updated metadata.
111    /// Also appends any derived [`TableUpdate`]s and [`TableRequirement`]s to the provided vectors.
112    fn apply(
113        table: Table,
114        mut action_commit: ActionCommit,
115        existing_updates: &mut Vec<TableUpdate>,
116        existing_requirements: &mut Vec<TableRequirement>,
117    ) -> Result<Table> {
118        let updates = action_commit.take_updates();
119        let requirements = action_commit.take_requirements();
120
121        for requirement in &requirements {
122            requirement.check(Some(table.metadata()))?;
123        }
124
125        let updated_table = Self::update_table_metadata(table, &updates)?;
126
127        existing_updates.extend(updates);
128        existing_requirements.extend(requirements);
129
130        Ok(updated_table)
131    }
132
133    /// Sets table to a new version.
134    pub fn upgrade_table_version(&self) -> UpgradeFormatVersionAction {
135        UpgradeFormatVersionAction::new()
136    }
137
138    /// Update table's property.
139    pub fn update_table_properties(&self) -> UpdatePropertiesAction {
140        UpdatePropertiesAction::new()
141    }
142
143    /// Creates an update schema action.
144    pub fn update_schema(&self) -> UpdateSchemaAction {
145        UpdateSchemaAction::new()
146    }
147
148    /// Creates a fast append action.
149    pub fn fast_append(&self) -> FastAppendAction {
150        FastAppendAction::new()
151    }
152
153    /// Creates replace sort order action.
154    pub fn replace_sort_order(&self) -> ReplaceSortOrderAction {
155        ReplaceSortOrderAction::new()
156    }
157
158    /// Set the location of table
159    pub fn update_location(&self) -> UpdateLocationAction {
160        UpdateLocationAction::new()
161    }
162
163    /// Update the statistics of table
164    pub fn update_statistics(&self) -> UpdateStatisticsAction {
165        UpdateStatisticsAction::new()
166    }
167
168    /// Expire snapshots from the table metadata.
169    pub fn expire_snapshots(&self) -> ExpireSnapshotsAction {
170        ExpireSnapshotsAction::new()
171    }
172
173    /// Commit transaction.
174    ///
175    /// Returns `ErrorKind::PreconditionFailed` if the table is read-only
176    /// (`Table::readonly()`), even when the transaction has no actions.
177    pub async fn commit(self, catalog: &dyn Catalog) -> Result<Table> {
178        if self.table.readonly() {
179            return Err(Error::new(
180                ErrorKind::PreconditionFailed,
181                "Cannot modify a read-only table",
182            )
183            .with_context("table", self.table.identifier().to_string()));
184        }
185
186        if self.actions.is_empty() {
187            // nothing to commit
188            return Ok(self.table);
189        }
190
191        let table_props = self.table.metadata().table_properties();
192
193        let backoff = Self::build_backoff(table_props)?;
194        let tx = self;
195
196        (|mut tx: Transaction| async {
197            let result = tx.do_commit(catalog).await;
198            (tx, result)
199        })
200        .retry(backoff)
201        .sleep(tokio::time::sleep)
202        .context(tx)
203        .when(|e| e.retryable())
204        .await
205        .1
206    }
207
208    fn build_backoff(props: TableProperties<'_>) -> Result<ExponentialBackoff> {
209        Ok(ExponentialBuilder::new()
210            .with_min_delay(Duration::from_millis(props.commit_min_retry_wait_ms()?))
211            .with_max_delay(Duration::from_millis(props.commit_max_retry_wait_ms()?))
212            .with_total_delay(Some(Duration::from_millis(
213                props.commit_total_retry_timeout_ms()?,
214            )))
215            .with_max_times(props.commit_num_retries()?)
216            .with_factor(2.0)
217            .build())
218    }
219
220    async fn do_commit(&mut self, catalog: &dyn Catalog) -> Result<Table> {
221        let refreshed = catalog.load_table(self.table.identifier()).await?;
222
223        if self.table.metadata() != refreshed.metadata()
224            || self.table.metadata_location() != refreshed.metadata_location()
225        {
226            // current base is stale, use refreshed as base and re-apply transaction actions
227            self.table = refreshed.clone();
228        }
229
230        let mut current_table = self.table.clone();
231        let mut existing_updates: Vec<TableUpdate> = vec![];
232        let mut existing_requirements: Vec<TableRequirement> = vec![];
233
234        for entry in self.actions.iter_mut() {
235            let action_commit = entry.commit(&current_table).await?;
236            // apply action commit to current_table
237            current_table = Self::apply(
238                current_table,
239                action_commit,
240                &mut existing_updates,
241                &mut existing_requirements,
242            )?;
243        }
244
245        let table_commit = TableCommit::builder()
246            .ident(self.table.identifier().to_owned())
247            .updates(existing_updates)
248            .requirements(existing_requirements)
249            .build();
250
251        catalog.update_table(table_commit).await
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use std::collections::HashMap;
258    use std::fs::File;
259    use std::io::BufReader;
260    use std::sync::Arc;
261    use std::sync::atomic::{AtomicU32, Ordering};
262
263    use crate::catalog::MockCatalog;
264    use crate::io::FileIO;
265    use crate::memory::tests::new_memory_catalog;
266    use crate::spec::{
267        DataContentType, DataFileBuilder, DataFileFormat, Literal, Struct, TableMetadata,
268        TableProperties,
269    };
270    use crate::table::{StaticTable, Table};
271    use crate::test_utils::{make_encrypted_table, test_runtime};
272    use crate::transaction::{ApplyTransactionAction, Transaction};
273    use crate::{Catalog, Error, ErrorKind, TableCreation, TableIdent};
274
275    pub fn make_v1_table() -> Table {
276        let file = File::open(format!(
277            "{}/testdata/table_metadata/{}",
278            env!("CARGO_MANIFEST_DIR"),
279            "TableMetadataV1Valid.json"
280        ))
281        .unwrap();
282        let reader = BufReader::new(file);
283        let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
284
285        Table::builder()
286            .metadata(resp)
287            .metadata_location("s3://bucket/test/location/metadata/v1.json")
288            .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
289            .file_io(FileIO::new_with_memory())
290            .runtime(test_runtime())
291            .build()
292            .unwrap()
293    }
294
295    pub fn make_v2_table() -> Table {
296        let file = File::open(format!(
297            "{}/testdata/table_metadata/{}",
298            env!("CARGO_MANIFEST_DIR"),
299            "TableMetadataV2Valid.json"
300        ))
301        .unwrap();
302        let reader = BufReader::new(file);
303        let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
304
305        Table::builder()
306            .metadata(resp)
307            .metadata_location("s3://bucket/test/location/metadata/v1.json")
308            .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
309            .file_io(FileIO::new_with_memory())
310            .runtime(test_runtime())
311            .build()
312            .unwrap()
313    }
314
315    pub fn make_v2_minimal_table() -> Table {
316        let file = File::open(format!(
317            "{}/testdata/table_metadata/{}",
318            env!("CARGO_MANIFEST_DIR"),
319            "TableMetadataV2ValidMinimal.json"
320        ))
321        .unwrap();
322        let reader = BufReader::new(file);
323        let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
324
325        Table::builder()
326            .metadata(resp)
327            .metadata_location("s3://bucket/test/location/metadata/v1.json")
328            .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
329            .file_io(FileIO::new_with_memory())
330            .runtime(test_runtime())
331            .build()
332            .unwrap()
333    }
334
335    pub(crate) fn make_v3_minimal_table() -> Table {
336        let file = File::open(format!(
337            "{}/testdata/table_metadata/{}",
338            env!("CARGO_MANIFEST_DIR"),
339            "TableMetadataV3ValidMinimal.json"
340        ))
341        .unwrap();
342        let reader = BufReader::new(file);
343        let resp = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
344
345        Table::builder()
346            .metadata(resp)
347            .metadata_location("s3://bucket/test/location/metadata/v1.json")
348            .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
349            .file_io(FileIO::new_with_memory())
350            .runtime(test_runtime())
351            .build()
352            .unwrap()
353    }
354
355    async fn make_v2_minimal_readonly_table() -> Table {
356        let table = make_v2_minimal_table();
357        StaticTable::from_metadata(
358            table.metadata().clone(),
359            table.identifier().clone(),
360            FileIO::new_with_memory(),
361        )
362        .await
363        .unwrap()
364        .into_table()
365    }
366
367    pub(crate) async fn make_v3_minimal_table_in_catalog(catalog: &impl Catalog) -> Table {
368        let table_ident =
369            TableIdent::from_strs([format!("ns1-{}", uuid::Uuid::new_v4()), "test1".to_string()])
370                .unwrap();
371
372        catalog
373            .create_namespace(table_ident.namespace(), HashMap::new())
374            .await
375            .unwrap();
376
377        let file = File::open(format!(
378            "{}/testdata/table_metadata/{}",
379            env!("CARGO_MANIFEST_DIR"),
380            "TableMetadataV3ValidMinimal.json"
381        ))
382        .unwrap();
383        let reader = BufReader::new(file);
384        let base_metadata = serde_json::from_reader::<_, TableMetadata>(reader).unwrap();
385
386        let table_creation = TableCreation::builder()
387            .schema((**base_metadata.current_schema()).clone())
388            .partition_spec((**base_metadata.default_partition_spec()).clone())
389            .sort_order((**base_metadata.default_sort_order()).clone())
390            .name(table_ident.name().to_string())
391            .format_version(crate::spec::FormatVersion::V3)
392            .build();
393
394        catalog
395            .create_table(table_ident.namespace(), table_creation)
396            .await
397            .unwrap()
398    }
399
400    /// Helper function to create a test table with retry properties
401    pub(super) fn setup_test_table(num_retries: &str) -> Table {
402        with_retry_properties(make_v2_table(), num_retries)
403    }
404
405    /// Sets fast commit retry properties on `table`.
406    pub(super) fn with_retry_properties(table: Table, num_retries: &str) -> Table {
407        // Set retry properties
408        let mut props = HashMap::new();
409        props.insert("commit.retry.min-wait-ms".to_string(), "10".to_string());
410        props.insert("commit.retry.max-wait-ms".to_string(), "100".to_string());
411        props.insert(
412            "commit.retry.total-timeout-ms".to_string(),
413            "1000".to_string(),
414        );
415        props.insert(
416            "commit.retry.num-retries".to_string(),
417            num_retries.to_string(),
418        );
419
420        // Update table properties
421        let metadata = table
422            .metadata()
423            .clone()
424            .into_builder(None)
425            .set_properties(props)
426            .unwrap()
427            .build()
428            .unwrap()
429            .metadata;
430
431        table.with_metadata(Arc::new(metadata))
432    }
433
434    /// Helper function to create a transaction with a simple update action
435    fn create_test_transaction(table: &Table) -> Transaction {
436        let tx = Transaction::new(table);
437        tx.update_table_properties()
438            .set("test.key".to_string(), "test.value".to_string())
439            .apply(tx)
440            .unwrap()
441    }
442
443    /// Helper function to set up a mock catalog with retryable errors
444    fn setup_mock_catalog_with_retryable_errors(
445        success_after_attempts: Option<u32>,
446        expected_calls: usize,
447    ) -> MockCatalog {
448        let mut mock_catalog = MockCatalog::new();
449
450        mock_catalog
451            .expect_load_table()
452            .returning_st(|_| Box::pin(async move { Ok(make_v2_table()) }));
453
454        let attempts = AtomicU32::new(0);
455        mock_catalog
456            .expect_update_table()
457            .times(expected_calls)
458            .returning_st(move |_| {
459                if let Some(success_after_attempts) = success_after_attempts {
460                    attempts.fetch_add(1, Ordering::SeqCst);
461                    if attempts.load(Ordering::SeqCst) <= success_after_attempts {
462                        Box::pin(async move {
463                            Err(
464                                Error::new(ErrorKind::CatalogCommitConflicts, "Commit conflict")
465                                    .with_retryable(true),
466                            )
467                        })
468                    } else {
469                        Box::pin(async move { Ok(make_v2_table()) })
470                    }
471                } else {
472                    // Always fail with retryable error
473                    Box::pin(async move {
474                        Err(
475                            Error::new(ErrorKind::CatalogCommitConflicts, "Commit conflict")
476                                .with_retryable(true),
477                        )
478                    })
479                }
480            });
481
482        mock_catalog
483    }
484
485    /// Helper function to set up a mock catalog with non-retryable error
486    fn setup_mock_catalog_with_non_retryable_error() -> MockCatalog {
487        let mut mock_catalog = MockCatalog::new();
488
489        mock_catalog
490            .expect_load_table()
491            .returning_st(|_| Box::pin(async move { Ok(make_v2_table()) }));
492
493        mock_catalog
494            .expect_update_table()
495            .times(1) // Should only be called once since error is not retryable
496            .returning_st(move |_| {
497                Box::pin(async move {
498                    Err(Error::new(ErrorKind::Unexpected, "Non-retryable error")
499                        .with_retryable(false))
500                })
501            });
502
503        mock_catalog
504    }
505
506    #[tokio::test]
507    async fn test_commit_retryable_error() {
508        // Create a test table with retry properties
509        let table = setup_test_table("3");
510
511        // Create a transaction with a simple update action
512        let tx = create_test_transaction(&table);
513
514        // Create a mock catalog that fails twice then succeeds
515        let mock_catalog = setup_mock_catalog_with_retryable_errors(Some(2), 3);
516
517        // Commit the transaction
518        let result = tx.commit(&mock_catalog).await;
519
520        // Verify the result
521        assert!(result.is_ok(), "Transaction should eventually succeed");
522    }
523
524    #[tokio::test]
525    async fn test_commit_non_retryable_error() {
526        // Create a test table with retry properties
527        let table = setup_test_table("3");
528
529        // Create a transaction with a simple update action
530        let tx = create_test_transaction(&table);
531
532        // Create a mock catalog that fails with non-retryable error
533        let mock_catalog = setup_mock_catalog_with_non_retryable_error();
534
535        // Commit the transaction
536        let result = tx.commit(&mock_catalog).await;
537
538        // Verify the result
539        assert!(result.is_err(), "Transaction should fail immediately");
540        if let Err(err) = result {
541            assert_eq!(err.kind(), ErrorKind::Unexpected);
542            assert_eq!(err.message(), "Non-retryable error");
543            assert!(!err.retryable(), "Error should not be retryable");
544        }
545    }
546
547    #[tokio::test]
548    async fn test_commit_max_retries_exceeded() {
549        // Create a test table with retry properties (only allow 2 retries)
550        let table = setup_test_table("2");
551
552        // Create a transaction with a simple update action
553        let tx = create_test_transaction(&table);
554
555        // Create a mock catalog that always fails with retryable error
556        let mock_catalog = setup_mock_catalog_with_retryable_errors(None, 3); // Initial attempt + 2 retries = 3 total attempts
557
558        // Commit the transaction
559        let result = tx.commit(&mock_catalog).await;
560
561        // Verify the result
562        assert!(result.is_err(), "Transaction should fail after max retries");
563        if let Err(err) = result {
564            assert_eq!(err.kind(), ErrorKind::CatalogCommitConflicts);
565            assert_eq!(err.message(), "Commit conflict");
566            assert!(err.retryable(), "Error should be retryable");
567        }
568    }
569
570    #[tokio::test]
571    async fn test_transaction_snapshot_summary() {
572        let catalog = new_memory_catalog().await;
573        let table = make_v3_minimal_table_in_catalog(&catalog).await;
574
575        let mut file_seq = 0u32;
576        let mut append_file = |table: &Table, record_count: u64, file_size: u64| {
577            file_seq += 1;
578            let file = DataFileBuilder::default()
579                .content(DataContentType::Data)
580                .file_path(format!("test/{file_seq}.parquet"))
581                .file_format(DataFileFormat::Parquet)
582                .file_size_in_bytes(file_size)
583                .record_count(record_count)
584                .partition(Struct::from_iter([Some(Literal::long(1))]))
585                .partition_spec_id(0)
586                .build()
587                .unwrap();
588            let tx = Transaction::new(table);
589            tx.fast_append()
590                .add_data_files(vec![file])
591                .apply(tx)
592                .unwrap()
593        };
594
595        let table = append_file(&table, /*record_count=*/ 10, /*file_size=*/ 100)
596            .commit(&catalog)
597            .await
598            .unwrap();
599        let table = append_file(&table, /*record_count=*/ 20, /*file_size=*/ 200)
600            .commit(&catalog)
601            .await
602            .unwrap();
603
604        let summary = &table
605            .metadata()
606            .current_snapshot()
607            .unwrap()
608            .summary()
609            .additional_properties;
610
611        assert_eq!(summary.get("total-records").unwrap(), "30");
612        assert_eq!(summary.get("total-data-files").unwrap(), "2");
613        assert_eq!(summary.get("total-files-size").unwrap(), "300");
614    }
615
616    #[tokio::test]
617    async fn test_commit_to_encrypted_table() {
618        let table = make_encrypted_table().await.with_metadata_location(
619            "memory:///table/metadata/00000-9c12d441-03fe-4693-9a96-a0705ddf69c1.metadata.json"
620                .to_string(),
621        );
622        let refreshed_table = table.clone();
623        let update_table = table.clone();
624        let mut mock_catalog = MockCatalog::new();
625        mock_catalog
626            .expect_load_table()
627            .times(1)
628            .returning_st(move |_| {
629                let refreshed_table = refreshed_table.clone();
630                Box::pin(async move { Ok(refreshed_table) })
631            });
632        mock_catalog
633            .expect_update_table()
634            .times(1)
635            .returning_st(move |commit| {
636                let update_table = update_table.clone();
637                Box::pin(async move { commit.apply(update_table) })
638            });
639
640        let tx = Transaction::new(&table);
641        let tx = tx
642            .update_table_properties()
643            .set("test.key".to_string(), "test.value".to_string())
644            .apply(tx)
645            .unwrap();
646
647        let updated_table = tx.commit(&mock_catalog).await.unwrap();
648
649        assert_eq!(
650            updated_table
651                .metadata()
652                .properties()
653                .get(TableProperties::PROPERTY_ENCRYPTION_KEY_ID)
654                .map(String::as_str),
655            Some("master-1")
656        );
657        assert_eq!(
658            updated_table
659                .metadata()
660                .properties()
661                .get("test.key")
662                .map(String::as_str),
663            Some("test.value")
664        );
665        assert!(updated_table.encryption_manager().is_some());
666    }
667
668    #[tokio::test]
669    async fn test_commit_readonly_table_fails() {
670        let table = make_v2_minimal_readonly_table().await;
671        let catalog = MockCatalog::new();
672
673        // make sure even empty transactions fail
674        let err = Transaction::new(&table).commit(&catalog).await.unwrap_err();
675
676        assert_eq!(err.kind(), ErrorKind::PreconditionFailed);
677        assert_eq!(err.message(), "Cannot modify a read-only table");
678        assert!(!err.retryable(), "Error should not be retryable");
679    }
680
681    #[tokio::test]
682    async fn test_commit_readonly_table_with_actions_fails() {
683        let table = make_v2_minimal_readonly_table().await;
684        let catalog = MockCatalog::new();
685
686        let err = create_test_transaction(&table)
687            .commit(&catalog)
688            .await
689            .unwrap_err();
690
691        assert_eq!(err.kind(), ErrorKind::PreconditionFailed);
692    }
693
694    #[tokio::test]
695    async fn test_commit_empty_transaction_is_noop() {
696        let table = make_v2_minimal_table();
697        let catalog = MockCatalog::new();
698
699        let result = Transaction::new(&table).commit(&catalog).await.unwrap();
700
701        assert_eq!(result.metadata(), table.metadata());
702    }
703}
704
705#[cfg(test)]
706mod test_row_lineage {
707    use crate::memory::tests::new_memory_catalog;
708    use crate::spec::{
709        DataContentType, DataFile, DataFileBuilder, DataFileFormat, Literal, Struct,
710    };
711    use crate::transaction::tests::make_v3_minimal_table_in_catalog;
712    use crate::transaction::{ApplyTransactionAction, Transaction};
713
714    #[tokio::test]
715    async fn test_fast_append_with_row_lineage() {
716        // Helper function to create a data file with specified number of rows
717        fn file_with_rows(record_count: u64) -> DataFile {
718            DataFileBuilder::default()
719                .content(DataContentType::Data)
720                .file_path(format!("test/{record_count}.parquet"))
721                .file_format(DataFileFormat::Parquet)
722                .file_size_in_bytes(100)
723                .record_count(record_count)
724                .partition(Struct::from_iter([Some(Literal::long(0))]))
725                .partition_spec_id(0)
726                .build()
727                .unwrap()
728        }
729        let catalog = new_memory_catalog().await;
730
731        let table = make_v3_minimal_table_in_catalog(&catalog).await;
732
733        // Check initial state - next_row_id should be 0
734        assert_eq!(table.metadata().next_row_id(), 0);
735
736        // First fast append with 30 rows
737        let tx = Transaction::new(&table);
738        let data_file_30 = file_with_rows(30);
739        let action = tx.fast_append().add_data_files(vec![data_file_30]);
740        let tx = action.apply(tx).unwrap();
741        let table = tx.commit(&catalog).await.unwrap();
742
743        // Check snapshot and table state after first append
744        let snapshot = table.metadata().current_snapshot().unwrap();
745        assert_eq!(snapshot.first_row_id(), Some(0));
746        assert_eq!(table.metadata().next_row_id(), 30);
747
748        // Check written manifest for first_row_id
749        let snapshot = table.metadata().current_snapshot().unwrap();
750        let manifest_list = table.manifest_list_reader(snapshot).load().await.unwrap();
751
752        assert_eq!(manifest_list.entries().len(), 1);
753        let manifest_file = &manifest_list.entries()[0];
754        assert_eq!(manifest_file.first_row_id, Some(0));
755
756        // Second fast append with 17 and 11 rows
757        let tx = Transaction::new(&table);
758        let data_file_17 = file_with_rows(17);
759        let data_file_11 = file_with_rows(11);
760        let action = tx
761            .fast_append()
762            .add_data_files(vec![data_file_17, data_file_11]);
763        let tx = action.apply(tx).unwrap();
764        let table = tx.commit(&catalog).await.unwrap();
765
766        // Check snapshot and table state after second append
767        let snapshot = table.metadata().current_snapshot().unwrap();
768        assert_eq!(snapshot.first_row_id(), Some(30));
769        assert_eq!(table.metadata().next_row_id(), 30 + 17 + 11);
770
771        // Check written manifest for first_row_id
772        let manifest_list = table.manifest_list_reader(snapshot).load().await.unwrap();
773        assert_eq!(manifest_list.entries().len(), 2);
774        let manifest_file = &manifest_list.entries()[1];
775        assert_eq!(manifest_file.first_row_id, Some(30));
776    }
777}