This is an automated email from the ASF dual-hosted git repository.

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new fc1e09f0 feat(core): add append_only writes for append-only tables 
(#588)
fc1e09f0 is described below

commit fc1e09f0b6e02390af01becce78b1f8c03a1285b
Author: Sourav <[email protected]>
AuthorDate: Wed Oct 28 07:57:31 2026 +0530

    feat(core): add append_only writes for append-only tables (#588)
    
    Adds append_only support for append-only tables in hudi-core. So the writer 
creates a Parquet base file, writes the matching commit metadata, and rejects 
partitioned tables for now. The result now exposes the actual commit-relative 
path, so callers can find the commit file in both timeline layouts.
---
 crates/core/src/error.rs                     |   3 +
 crates/core/src/table/mod.rs                 |  71 +++++
 crates/core/src/table/writer.rs              | 330 +++++++++++++++++++++++
 crates/core/tests/table_bulk_insert_tests.rs | 386 +++++++++++++++++++++++++++
 4 files changed, 790 insertions(+)

diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs
index 610deb5e..c49e7f3e 100644
--- a/crates/core/src/error.rs
+++ b/crates/core/src/error.rs
@@ -94,6 +94,9 @@ pub enum CoreError {
     #[error("{0}")]
     Unsupported(String),
 
+    #[error("Write error: {0}")]
+    Write(String),
+
     #[error(transparent)]
     Utf8Error(#[from] std::str::Utf8Error),
 }
diff --git a/crates/core/src/table/mod.rs b/crates/core/src/table/mod.rs
index 76637d61..359f29b5 100644
--- a/crates/core/src/table/mod.rs
+++ b/crates/core/src/table/mod.rs
@@ -94,8 +94,10 @@ pub(crate) mod fs_view;
 mod listing;
 pub mod partition;
 mod validation;
+mod writer;
 
 pub use crate::config::read_options::{QueryType, ReadOptions};
+pub use writer::AppendResult;
 
 use crate::Result;
 use crate::config::HudiConfigs;
@@ -120,6 +122,7 @@ use crate::table::builder::TableBuilder;
 use crate::table::file_pruner::FilePruner;
 use crate::table::fs_view::FileSystemView;
 use crate::table::partition::{PartitionPruner, project_partition_schema};
+use crate::table::writer::AppendOnlyWriter;
 use crate::timeline::util::format_timestamp;
 use crate::timeline::{EARLIEST_START_TIMESTAMP, Timeline};
 use arrow::record_batch::RecordBatch;
@@ -1234,6 +1237,74 @@ impl Table {
         Ok(Box::pin(combined_stream))
     }
 
+    /// Append record batches to an append-only table as one new base file.
+    ///
+    /// Intentionally narrow: it only supports unpartitioned append-only 
tables,
+    /// and all batches must share one schema matching the table's.
+    pub async fn append_only(
+        &mut self,
+        batches: impl IntoIterator<Item = RecordBatch>,
+    ) -> Result<AppendResult> {
+        let batches: Vec<RecordBatch> = batches.into_iter().collect();
+        let Some(first) = batches.first() else {
+            return Err(CoreError::Write(
+                "append requires at least one RecordBatch".to_string(),
+            ));
+        };
+        let schema = first.schema();
+        if batches.iter().any(|batch| batch.schema() != schema) {
+            return Err(CoreError::Write(
+                "All RecordBatches in append must share the same 
schema".to_string(),
+            ));
+        }
+        self.ensure_append_schema_matches_table(schema.as_ref())
+            .await?;
+        let batch = arrow::compute::concat_batches(&schema, &batches)?;
+        if batch.num_rows() == 0 {
+            return Err(CoreError::Write(
+                "append requires at least one row".to_string(),
+            ));
+        }
+        let result = AppendOnlyWriter::new(self).write(batch).await?;
+        // The commit is durable; a failed refresh must not turn it into an 
error.
+        if let Ok(timeline) =
+            Timeline::new_from_storage(self.hudi_configs.clone(), 
self.storage_options.clone())
+                .await
+        {
+            self.timeline = timeline;
+        }
+        if let Ok(file_system_view) =
+            FileSystemView::new(self.hudi_configs.clone(), 
self.storage_options.clone()).await
+        {
+            self.file_system_view = file_system_view;
+        }
+        self.cached_metadata_table = Arc::new(OnceCell::new());
+        self.cached_estimator = Arc::new(OnceCell::new());
+        Ok(result)
+    }
+
+    async fn ensure_append_schema_matches_table(&self, batch_schema: &Schema) 
-> Result<()> {
+        if self.timeline.completed_commits.is_empty() {
+            return Ok(());
+        }
+        let table_schema = self.get_schema().await?;
+        let fields = |schema: &Schema| {
+            schema
+                .fields()
+                .iter()
+                .map(|f| (f.name().clone(), f.data_type().clone()))
+                .collect::<Vec<_>>()
+        };
+        if fields(&table_schema) == fields(batch_schema) {
+            return Ok(());
+        }
+        Err(CoreError::Write(format!(
+            "append batch schema does not match table schema (table 
fields={:?}, batch fields={:?})",
+            fields(&table_schema),
+            fields(batch_schema),
+        )))
+    }
+
     /// Compute estimated table-level statistics for scan planning.
     ///
     /// Returns `(estimated_num_rows, estimated_total_byte_size)` derived from
diff --git a/crates/core/src/table/writer.rs b/crates/core/src/table/writer.rs
new file mode 100644
index 00000000..c206d113
--- /dev/null
+++ b/crates/core/src/table/writer.rs
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::collections::HashMap;
+use std::io::Cursor;
+use std::str::FromStr;
+use std::sync::atomic::{AtomicI64, Ordering};
+
+use apache_avro::Writer as AvroWriter;
+use apache_avro::schema::AvroSchema;
+use arrow::record_batch::RecordBatch;
+use bytes::Bytes;
+use chrono::Utc;
+use object_store::path::Path;
+use object_store::{ObjectStoreExt, PutPayload};
+use parquet::arrow::ArrowWriter;
+
+use crate::Result;
+use crate::config::table::HudiTableConfig::{
+    MetadataTablePartitions, PopulatesMetaFields, RecordMergeStrategy, 
TableVersion,
+    TimelineLayoutVersion, TimelinePath,
+};
+use crate::error::CoreError;
+use crate::merge::RecordMergeStrategyValue;
+use crate::metadata::HUDI_METADATA_DIR;
+use crate::metadata::commit::{HoodieCommitMetadata, HoodieWriteStat};
+use crate::storage::error::StorageError;
+use crate::storage::util::join_url_segments;
+use crate::table::Table;
+use crate::timeline::instant::Instant;
+
+/// Result of an append write.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct AppendResult {
+    pub instant: String,
+    pub commit_relative_path: String,
+    pub base_file_path: String,
+    pub num_rows: usize,
+}
+
+/// Minimal writer for append-only tables.
+#[derive(Clone, Debug)]
+pub(crate) struct AppendOnlyWriter {
+    table: Table,
+}
+
+static LAST_EPOCH_MILLIS: AtomicI64 = AtomicI64::new(0);
+
+impl AppendOnlyWriter {
+    pub(crate) fn new(table: &Table) -> Self {
+        Self {
+            table: table.clone(),
+        }
+    }
+
+    pub(crate) async fn write(&self, batch: RecordBatch) -> 
Result<AppendResult> {
+        self.ensure_append_only()?;
+        self.ensure_unpartitioned()?;
+        self.ensure_no_meta_fields()?;
+        self.ensure_no_metadata_table().await?;
+
+        let request_instant = Self::current_timestamp();
+        let completion_instant = request_instant.clone();
+        let layout_two = self.is_layout_two();
+        let instant_name = if layout_two {
+            format!("{request_instant}_{completion_instant}.commit")
+        } else {
+            format!("{request_instant}.commit")
+        };
+        let instant = Instant::from_str(&instant_name)?;
+        let timeline_dir = self.timeline_dir();
+
+        let file_id = format!("bulk-insert-{request_instant}");
+        let write_token = "0-0-0";
+        let base_file_name = 
format!("{file_id}_{write_token}_{request_instant}.parquet");
+        let base_file_path = base_file_name.clone();
+
+        let file_bytes = self.write_parquet_bytes(&batch)?;
+        let file_size = file_bytes.len() as i64;
+
+        self.put_file(&base_file_path, file_bytes).await?;
+
+        let commit_metadata = self.build_commit_metadata(
+            &file_id,
+            &base_file_path,
+            batch.num_rows() as i64,
+            file_size,
+        );
+        let commit_relative_path = 
instant.relative_path_with_base(&timeline_dir)?;
+        if let Err(error) = self
+            .write_commit_file(&commit_relative_path, &commit_metadata)
+            .await
+        {
+            let _ = self.delete_file(&base_file_path).await;
+            return Err(error);
+        }
+
+        Ok(AppendResult {
+            instant: request_instant,
+            commit_relative_path,
+            base_file_path,
+            num_rows: batch.num_rows(),
+        })
+    }
+
+    fn ensure_append_only(&self) -> Result<()> {
+        let strategy: String = self
+            .table
+            .hudi_configs
+            .get_or_default(RecordMergeStrategy)
+            .into();
+
+        if RecordMergeStrategyValue::from_str(&strategy)? != 
RecordMergeStrategyValue::AppendOnly {
+            return Err(CoreError::Unsupported(
+                "append_only requires 
hoodie.record.merge.mode=append_only".to_string(),
+            ));
+        }
+
+        Ok(())
+    }
+
+    fn ensure_unpartitioned(&self) -> Result<()> {
+        let partition_fields: Vec<String> = self
+            .table
+            .hudi_configs
+            
.get_or_default(crate::config::table::HudiTableConfig::PartitionFields)
+            .into();
+
+        if !partition_fields.is_empty() {
+            return Err(CoreError::Unsupported(
+                "append_only currently supports only unpartitioned append-only 
tables".to_string(),
+            ));
+        }
+
+        Ok(())
+    }
+
+    /// The written base file carries only the batch's columns, so a table that
+    /// populates the `_hoodie_*` meta fields would get a file without them.
+    fn ensure_no_meta_fields(&self) -> Result<()> {
+        let populates_meta_fields: bool = self
+            .table
+            .hudi_configs
+            .get_or_default(PopulatesMetaFields)
+            .into();
+        if populates_meta_fields {
+            return Err(CoreError::Unsupported(
+                "append_only currently supports only tables with 
hoodie.populate.meta.fields=false"
+                    .to_string(),
+            ));
+        }
+        Ok(())
+    }
+
+    /// The metadata table is not updated, so its file listings would miss the
+    /// new base file.
+    async fn ensure_no_metadata_table(&self) -> Result<()> {
+        let partitions: Vec<String> = self
+            .table
+            .hudi_configs
+            .get_or_default(MetadataTablePartitions)
+            .into();
+        let has_metadata_dir = self
+            .table
+            .file_system_view
+            .storage
+            .list_dirs(Some(HUDI_METADATA_DIR))
+            .await?
+            .iter()
+            .any(|dir| dir == "metadata");
+        if partitions.iter().any(|p| !p.is_empty()) || has_metadata_dir {
+            return Err(CoreError::Unsupported(
+                "append_only currently supports only tables without a metadata 
table".to_string(),
+            ));
+        }
+        Ok(())
+    }
+
+    fn is_layout_two(&self) -> bool {
+        let table_version: isize = 
self.table.hudi_configs.get_or_default(TableVersion).into();
+        let layout_version: isize = self
+            .table
+            .hudi_configs
+            .get_or_default(TimelineLayoutVersion)
+            .into();
+        table_version >= 8 && layout_version == 2
+    }
+
+    fn timeline_dir(&self) -> String {
+        if self.is_layout_two() {
+            let timeline_path: String = 
self.table.hudi_configs.get_or_default(TimelinePath).into();
+            format!("{HUDI_METADATA_DIR}/{timeline_path}")
+        } else {
+            HUDI_METADATA_DIR.to_string()
+        }
+    }
+
+    fn current_timestamp() -> String {
+        let now = Utc::now().timestamp_millis();
+
+        loop {
+            let last = LAST_EPOCH_MILLIS.load(Ordering::Relaxed);
+            let next = if now <= last { last + 1 } else { now };
+
+            if LAST_EPOCH_MILLIS
+                .compare_exchange(last, next, Ordering::SeqCst, 
Ordering::Relaxed)
+                .is_ok()
+            {
+                return format!("{next:017}");
+            }
+        }
+    }
+
+    fn write_parquet_bytes(&self, batch: &RecordBatch) -> Result<Vec<u8>> {
+        let cursor = Cursor::new(Vec::new());
+        let mut writer = ArrowWriter::try_new(cursor, batch.schema(), None)?;
+        writer.write(batch)?;
+        let cursor = writer.into_inner()?;
+        Ok(cursor.into_inner())
+    }
+
+    async fn put_file(&self, relative_path: &str, bytes: Vec<u8>) -> 
Result<()> {
+        let storage = self.table.file_system_view.storage.clone();
+        let object_url = join_url_segments(&storage.base_url, 
&[relative_path])?;
+        let object_path = 
Path::from_url_path(object_url.path()).map_err(StorageError::from)?;
+        storage
+            .object_store
+            .put(&object_path, PutPayload::from(Bytes::from(bytes)))
+            .await
+            .map_err(StorageError::from)?;
+        Ok(())
+    }
+
+    async fn delete_file(&self, relative_path: &str) -> Result<()> {
+        let storage = self.table.file_system_view.storage.clone();
+        let object_url = join_url_segments(&storage.base_url, 
&[relative_path])?;
+        let object_path = 
Path::from_url_path(object_url.path()).map_err(StorageError::from)?;
+        storage
+            .object_store
+            .delete(&object_path)
+            .await
+            .map_err(StorageError::from)?;
+        Ok(())
+    }
+
+    fn build_commit_metadata(
+        &self,
+        file_id: &str,
+        base_file_path: &str,
+        num_rows: i64,
+        file_size: i64,
+    ) -> HoodieCommitMetadata {
+        let write_stat = HoodieWriteStat {
+            file_id: Some(file_id.to_string()),
+            path: Some(base_file_path.to_string()),
+            base_file: None,
+            log_files: None,
+            prev_commit: None,
+            num_writes: Some(num_rows),
+            num_deletes: Some(0),
+            num_update_writes: Some(0),
+            num_inserts: Some(num_rows),
+            total_write_bytes: Some(file_size),
+            total_write_errors: Some(0),
+            partition_path: Some(String::new()),
+            total_log_records: None,
+            total_log_files: None,
+            total_updated_records_compacted: None,
+            total_log_blocks: None,
+            total_corrupt_log_block: None,
+            total_rollback_blocks: None,
+            file_size_in_bytes: Some(file_size),
+            log_version: None,
+            log_offset: None,
+            prev_base_file: None,
+            min_event_time: None,
+            max_event_time: None,
+            total_log_files_compacted: None,
+            total_log_read_time_ms: None,
+            total_log_size_compacted: None,
+            temp_path: None,
+            num_updates: Some(0),
+        };
+
+        HoodieCommitMetadata {
+            version: Some(1),
+            operation_type: Some("BULK_INSERT".to_string()),
+            partition_to_write_stats: Some(HashMap::from([(String::new(), 
vec![write_stat])])),
+            partition_to_replace_file_ids: None,
+            compacted: Some(false),
+            extra_metadata: None,
+        }
+    }
+
+    async fn write_commit_file(
+        &self,
+        commit_relative_path: &str,
+        metadata: &HoodieCommitMetadata,
+    ) -> Result<()> {
+        let bytes = if self.is_layout_two() {
+            let schema = HoodieCommitMetadata::get_schema();
+            let mut writer = AvroWriter::new(&schema, Vec::new());
+            writer.append_ser(metadata)?;
+            writer.flush()?;
+            writer.into_inner()?
+        } else {
+            serde_json::to_vec(metadata).map_err(|e| {
+                CoreError::CommitMetadata(format!("Failed to serialize commit 
metadata: {e}"))
+            })?
+        };
+
+        self.put_file(commit_relative_path, bytes).await
+    }
+}
diff --git a/crates/core/tests/table_bulk_insert_tests.rs 
b/crates/core/tests/table_bulk_insert_tests.rs
new file mode 100644
index 00000000..8908ba2b
--- /dev/null
+++ b/crates/core/tests/table_bulk_insert_tests.rs
@@ -0,0 +1,386 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::fs;
+use std::sync::Arc;
+
+use arrow::array::{Int32Array, StringArray};
+use arrow::compute::concat_batches;
+use arrow::record_batch::RecordBatch;
+use arrow_schema::{DataType, Field, Schema};
+use hudi_core::error::CoreError;
+use hudi_core::error::Result;
+use hudi_core::table::{ReadOptions, Table};
+use tempfile::tempdir;
+
+fn write_table_properties(base_dir: &std::path::Path) -> Result<()> {
+    let hoodie_dir = base_dir.join(".hoodie");
+    fs::create_dir_all(&hoodie_dir)?;
+
+    let properties = [
+        "hoodie.table.name=test_table",
+        "hoodie.table.type=COPY_ON_WRITE",
+        "hoodie.table.version=6",
+        "hoodie.timeline.layout.version=1",
+        "hoodie.populate.meta.fields=false",
+        "hoodie.base.file.format=parquet",
+    ]
+    .join("\n");
+
+    fs::write(hoodie_dir.join("hoodie.properties"), properties)?;
+    Ok(())
+}
+
+fn write_partitioned_table_properties(base_dir: &std::path::Path) -> 
Result<()> {
+    let hoodie_dir = base_dir.join(".hoodie");
+    fs::create_dir_all(&hoodie_dir)?;
+
+    let properties = [
+        "hoodie.table.name=test_table",
+        "hoodie.table.type=COPY_ON_WRITE",
+        "hoodie.table.version=6",
+        "hoodie.timeline.layout.version=1",
+        "hoodie.populate.meta.fields=false",
+        "hoodie.base.file.format=parquet",
+        "hoodie.table.partition.fields=region",
+    ]
+    .join("\n");
+
+    fs::write(hoodie_dir.join("hoodie.properties"), properties)?;
+    Ok(())
+}
+
+fn write_layout_v2_table_properties(base_dir: &std::path::Path) -> Result<()> {
+    let hoodie_dir = base_dir.join(".hoodie");
+    fs::create_dir_all(&hoodie_dir)?;
+
+    let properties = [
+        "hoodie.table.name=test_table",
+        "hoodie.table.type=COPY_ON_WRITE",
+        "hoodie.table.version=8",
+        "hoodie.timeline.layout.version=2",
+        "hoodie.populate.meta.fields=false",
+        "hoodie.base.file.format=parquet",
+    ]
+    .join("\n");
+
+    fs::write(hoodie_dir.join("hoodie.properties"), properties)?;
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_writes_records_and_commit() -> Result<()> {
+    let temp_dir = tempdir()?;
+    write_table_properties(temp_dir.path())?;
+
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    let schema = Schema::new(vec![
+        Field::new("id", DataType::Int32, false),
+        Field::new("name", DataType::Utf8, false),
+    ]);
+
+    let batch = RecordBatch::try_new(
+        schema.into(),
+        vec![
+            Arc::new(Int32Array::from(vec![1, 2, 3])),
+            Arc::new(StringArray::from(vec!["alice", "bob", "carol"])),
+        ],
+    )?;
+
+    let result = table.append_only([batch]).await?;
+    assert_eq!(result.num_rows, 3);
+    assert_eq!(
+        result.commit_relative_path,
+        format!(".hoodie/{}.commit", result.instant)
+    );
+
+    let reopened = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+    let records = reopened.read(&ReadOptions::new()).await?;
+    assert_eq!(records.len(), 1);
+
+    let read_batch = concat_batches(&records[0].schema(), &records)?;
+    assert_eq!(read_batch.num_rows(), 3);
+
+    let ids = read_batch
+        .column_by_name("id")
+        .and_then(|array| array.as_any().downcast_ref::<Int32Array>())
+        .expect("expected id column");
+    let names = read_batch
+        .column_by_name("name")
+        .and_then(|array| array.as_any().downcast_ref::<StringArray>())
+        .expect("expected name column");
+
+    assert_eq!(ids.values(), &[1, 2, 3]);
+    assert_eq!(names.value(0), "alice");
+    assert_eq!(names.value(1), "bob");
+    assert_eq!(names.value(2), "carol");
+
+    let commit_file = temp_dir.path().join(&result.commit_relative_path);
+    assert!(commit_file.exists(), "commit file should exist");
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_rejects_partitioned_tables() -> Result<()> {
+    let temp_dir = tempdir()?;
+    write_partitioned_table_properties(temp_dir.path())?;
+
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
+    let batch = RecordBatch::try_new(schema.into(), 
vec![Arc::new(Int32Array::from(vec![1]))])?;
+
+    let error = table.append_only([batch]).await.unwrap_err();
+    assert!(
+        matches!(error, CoreError::Unsupported(message) if 
message.contains("unpartitioned append-only tables"))
+    );
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_writes_layout_v2_commit() -> Result<()> {
+    let temp_dir = tempdir()?;
+    write_layout_v2_table_properties(temp_dir.path())?;
+
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    let schema = Schema::new(vec![
+        Field::new("id", DataType::Int32, false),
+        Field::new("name", DataType::Utf8, false),
+    ]);
+
+    let batch = RecordBatch::try_new(
+        schema.into(),
+        vec![
+            Arc::new(Int32Array::from(vec![7])),
+            Arc::new(StringArray::from(vec!["delta"])),
+        ],
+    )?;
+
+    let result = table.append_only([batch]).await?;
+    assert!(result.commit_relative_path.starts_with(".hoodie/timeline/"));
+    assert!(result.commit_relative_path.ends_with(".commit"));
+
+    let commit_file = temp_dir.path().join(&result.commit_relative_path);
+    assert!(commit_file.exists(), "layout v2 commit file should exist");
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_writes_multiple_batches_as_one_file() -> Result<()> {
+    let temp_dir = tempdir()?;
+    write_table_properties(temp_dir.path())?;
+
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Int32, false),
+        Field::new("name", DataType::Utf8, false),
+    ]));
+    let first = RecordBatch::try_new(
+        schema.clone(),
+        vec![
+            Arc::new(Int32Array::from(vec![1, 2])),
+            Arc::new(StringArray::from(vec!["alice", "bob"])),
+        ],
+    )?;
+    let second = RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(Int32Array::from(vec![3])),
+            Arc::new(StringArray::from(vec!["carol"])),
+        ],
+    )?;
+
+    let result = table.append_only([first, second]).await?;
+    assert_eq!(result.num_rows, 3);
+
+    let reopened = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+    let records = reopened.read(&ReadOptions::new()).await?;
+    let read_batch = concat_batches(&records[0].schema(), &records)?;
+    let ids = read_batch
+        .column_by_name("id")
+        .and_then(|array| array.as_any().downcast_ref::<Int32Array>())
+        .expect("expected id column");
+    assert_eq!(ids.values(), &[1, 2, 3]);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_rejects_empty_and_mismatched_batches() -> Result<()> {
+    let temp_dir = tempdir()?;
+    write_table_properties(temp_dir.path())?;
+
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    let error = table
+        .append_only(Vec::<RecordBatch>::new())
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(error, CoreError::Write(message) if message.contains("at 
least one RecordBatch"))
+    );
+
+    let ints = RecordBatch::try_new(
+        Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])),
+        vec![Arc::new(Int32Array::from(vec![1]))],
+    )?;
+    let strings = RecordBatch::try_new(
+        Arc::new(Schema::new(vec![Field::new("id", DataType::Utf8, false)])),
+        vec![Arc::new(StringArray::from(vec!["1"]))],
+    )?;
+    let error = table.append_only([ints, strings]).await.unwrap_err();
+    assert!(
+        matches!(error, CoreError::Write(message) if message.contains("share 
the same schema"))
+    );
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_rejects_tables_with_meta_fields_or_metadata_table() -> 
Result<()> {
+    let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, 
false)]));
+    let batch = RecordBatch::try_new(schema, 
vec![Arc::new(Int32Array::from(vec![1]))])?;
+
+    let meta_fields_dir = tempdir()?;
+    write_table_properties(meta_fields_dir.path())?;
+    let props = meta_fields_dir.path().join(".hoodie/hoodie.properties");
+    let rewritten = fs::read_to_string(&props)?.replace(
+        "hoodie.populate.meta.fields=false",
+        "hoodie.populate.meta.fields=true",
+    );
+    fs::write(&props, rewritten)?;
+    let mut table = Table::new(meta_fields_dir.path().to_str().expect("valid 
temp path")).await?;
+    let error = table.append_only([batch.clone()]).await.unwrap_err();
+    assert!(
+        matches!(error, CoreError::Unsupported(message) if 
message.contains("hoodie.populate.meta.fields=false"))
+    );
+
+    let mdt_dir = tempdir()?;
+    write_table_properties(mdt_dir.path())?;
+    fs::create_dir_all(mdt_dir.path().join(".hoodie/metadata/.hoodie"))?;
+    fs::write(
+        mdt_dir
+            .path()
+            .join(".hoodie/metadata/.hoodie/hoodie.properties"),
+        "hoodie.table.name=test_table_metadata\n",
+    )?;
+    let mut table = Table::new(mdt_dir.path().to_str().expect("valid temp 
path")).await?;
+    let error = table.append_only([batch]).await.unwrap_err();
+    assert!(
+        matches!(error, CoreError::Unsupported(message) if 
message.contains("without a metadata table"))
+    );
+    assert!(
+        fs::read_dir(mdt_dir.path().join(".hoodie"))?
+            .filter_map(|entry| entry.ok())
+            .all(|entry| 
!entry.file_name().to_string_lossy().ends_with(".commit")),
+        "a rejected append must not write a commit"
+    );
+
+    Ok(())
+}
+
+fn id_name_batch(ids: Vec<i32>, names: Vec<&str>) -> RecordBatch {
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Int32, false),
+        Field::new("name", DataType::Utf8, false),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(Int32Array::from(ids)),
+            Arc::new(StringArray::from(names)),
+        ],
+    )
+    .expect("valid batch")
+}
+
+#[tokio::test]
+async fn append_only_rejects_zero_rows_and_table_schema_mismatch() -> 
Result<()> {
+    let temp_dir = tempdir()?;
+    write_table_properties(temp_dir.path())?;
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    let error = table
+        .append_only([id_name_batch(vec![], vec![])])
+        .await
+        .unwrap_err();
+    assert!(matches!(error, CoreError::Write(message) if message.contains("at 
least one row")));
+
+    table
+        .append_only([id_name_batch(vec![1], vec!["alice"])])
+        .await?;
+    let ids_only = RecordBatch::try_new(
+        Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])),
+        vec![Arc::new(Int32Array::from(vec![2]))],
+    )?;
+    let error = table.append_only([ids_only]).await.unwrap_err();
+    assert!(
+        matches!(error, CoreError::Write(message) if message.contains("does 
not match table schema"))
+    );
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_handle_reads_its_own_writes() -> Result<()> {
+    let temp_dir = tempdir()?;
+    write_table_properties(temp_dir.path())?;
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    table
+        .append_only([id_name_batch(vec![1, 2], vec!["alice", "bob"])])
+        .await?;
+    table
+        .append_only([id_name_batch(vec![3], vec!["carol"])])
+        .await?;
+
+    let rows: usize = table
+        .read(&ReadOptions::new())
+        .await?
+        .iter()
+        .map(RecordBatch::num_rows)
+        .sum();
+    assert_eq!(rows, 3);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn append_only_accepts_cleared_metadata_partitions_property() -> 
Result<()> {
+    let temp_dir = tempdir()?;
+    write_table_properties(temp_dir.path())?;
+    let props = temp_dir.path().join(".hoodie/hoodie.properties");
+    let mut content = fs::read_to_string(&props)?;
+    content.push_str("\nhoodie.table.metadata.partitions=\n");
+    fs::write(&props, content)?;
+    let mut table = Table::new(temp_dir.path().to_str().expect("valid temp 
path")).await?;
+
+    let result = table
+        .append_only([id_name_batch(vec![1], vec!["alice"])])
+        .await?;
+    assert_eq!(result.num_rows, 1);
+
+    Ok(())
+}

Reply via email to