This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 0452c6ee [core] Support precomputed bucket writes and snapshot
restoration (#1056)
0452c6ee is described below
commit 0452c6eef7caeaf5fc0f7b28ae0d4f098b3acc54
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 16:38:41 2026 +0800
[core] Support precomputed bucket writes and snapshot restoration (#1056)
---
.../python/python/pypaimon_rust/datafusion.pyi | 6 +-
bindings/python/src/write.rs | 86 ++-
bindings/python/tests/test_write.py | 33 +
crates/paimon/src/table/bucket_assigner_dynamic.rs | 158 ++++-
crates/paimon/src/table/table_write.rs | 75 ++-
crates/paimon/src/table/table_write_precomputed.rs | 102 +++
crates/paimon/src/table/write_builder.rs | 47 +-
.../paimon/tests/precomputed_bucket_write_test.rs | 731 +++++++++++++++++++++
8 files changed, 1190 insertions(+), 48 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 517222e3..efac2bc3 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -237,7 +237,7 @@ class CommitMessage:
class BatchTableWrite:
def with_write_type(self, columns: List[str]) -> "BatchTableWrite": ...
def close(self) -> None: ...
- def write_arrow(self, batch: pyarrow.RecordBatch) -> None: ...
+ def write_arrow(self, batch: pyarrow.RecordBatch, bucket: Optional[int] =
None) -> None: ...
def prepare_commit(self) -> List[CommitMessage]:
"""Prepare once per instance, including empty or failed attempts."""
...
@@ -290,7 +290,7 @@ class StreamTableUpdate:
class StreamTableWrite:
def with_write_type(self, columns: List[str]) -> "StreamTableWrite": ...
def close(self) -> None: ...
- def write_arrow(self, batch: pyarrow.RecordBatch) -> None: ...
+ def write_arrow(self, batch: pyarrow.RecordBatch, bucket: Optional[int] =
None) -> None: ...
def prepare_commit(self, wait_compaction: bool, commit_identifier: int) ->
List[CommitMessage]:
"""Flush a checkpoint; submit its messages with the same identifier.
@@ -341,6 +341,7 @@ class BatchWriteBuilder:
use the schema's Python types; None or the default partition name is
null.
"""
...
+ def with_restore_snapshot(self, snapshot_id: int) -> "BatchWriteBuilder":
...
def new_write(self) -> BatchTableWrite: ...
def new_update(self) -> BatchTableUpdate: ...
def new_commit(self) -> BatchTableCommit: ...
@@ -368,6 +369,7 @@ class PostponeFixedBucketWriteBuilder:
class StreamWriteBuilder:
def commit_user(self) -> str: ...
def with_commit_user(self, commit_user: str) -> "StreamWriteBuilder": ...
+ def with_restore_snapshot(self, snapshot_id: int) -> "StreamWriteBuilder":
...
def new_write(self) -> StreamTableWrite: ...
def new_update(self) -> StreamTableUpdate: ...
def new_commit(self) -> StreamTableCommit: ...
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 3057a440..eea7870a 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -41,12 +41,26 @@ type PythonPartitionSpec = HashMap<String, Py<PyAny>>;
struct WriteContext {
table: Arc<Table>,
commit_user: String,
+ restore_snapshot_id: Option<i64>,
}
impl WriteContext {
fn new(table: Arc<Table>) -> Self {
let commit_user = table.new_write_builder().commit_user().to_string();
- Self { table, commit_user }
+ Self {
+ table,
+ commit_user,
+ restore_snapshot_id: None,
+ }
+ }
+
+ fn with_restore_snapshot(&mut self, snapshot_id: i64) -> PyResult<()> {
+ self.table
+ .new_write_builder()
+ .with_restore_snapshot(snapshot_id)
+ .map_err(to_py_err)?;
+ self.restore_snapshot_id = Some(snapshot_id);
+ Ok(())
}
fn new_write(&self, overwrite: bool) -> PyResult<WriteState> {
@@ -60,6 +74,10 @@ impl WriteContext {
} else {
builder
};
+ let builder = match self.restore_snapshot_id {
+ Some(id) => builder.with_restore_snapshot(id).map_err(to_py_err)?,
+ None => builder,
+ };
Ok(WriteState {
inner: Some(WriteTarget::Table(Box::new(
builder.new_write().map_err(to_py_err)?,
@@ -184,6 +202,14 @@ impl PyBatchWriteBuilder {
Ok(slf)
}
+ fn with_restore_snapshot(
+ mut slf: PyRefMut<'_, Self>,
+ snapshot_id: i64,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ slf.context.with_restore_snapshot(snapshot_id)?;
+ Ok(slf)
+ }
+
fn new_write(&self) -> PyResult<PyBatchTableWrite> {
Ok(PyBatchTableWrite {
state: self.context.new_write(self.static_partition.is_some())?,
@@ -253,6 +279,14 @@ impl PyStreamWriteBuilder {
Ok(slf)
}
+ fn with_restore_snapshot(
+ mut slf: PyRefMut<'_, Self>,
+ snapshot_id: i64,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ slf.context.with_restore_snapshot(snapshot_id)?;
+ Ok(slf)
+ }
+
fn new_write(&self) -> PyResult<PyStreamTableWrite> {
Ok(PyStreamTableWrite {
state: self.context.new_write(false)?,
@@ -292,10 +326,23 @@ impl WriteTarget {
}
}
- async fn write_arrow_batch(&mut self, batch: &RecordBatch) ->
paimon::Result<()> {
- match self {
- Self::Table(writer) => writer.write_arrow_batch(batch).await,
- Self::PostponeFixed(writer) =>
writer.write_arrow_batch(batch).await,
+ async fn write_arrow(
+ &mut self,
+ batch: &RecordBatch,
+ bucket: Option<i32>,
+ ) -> paimon::Result<()> {
+ match (self, bucket) {
+ (Self::Table(writer), bucket) => {
+ writer
+ .write_arrow(std::slice::from_ref(batch), bucket)
+ .await
+ }
+ (Self::PostponeFixed(writer), None) =>
writer.write_arrow_batch(batch).await,
+ (Self::PostponeFixed(_), Some(_)) =>
Err(paimon::Error::DataInvalid {
+ message: "Precomputed bucket writes require HASH_FIXED or
HASH_DYNAMIC tables"
+ .into(),
+ source: None,
+ }),
}
}
@@ -610,13 +657,18 @@ impl WriteState {
.map_err(to_py_err)
}
- fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
+ fn write_arrow(
+ &mut self,
+ py: Python<'_>,
+ batch: &Bound<'_, PyAny>,
+ bucket: Option<i32>,
+ ) -> PyResult<()> {
let batch = RecordBatch::from_pyarrow_bound(batch)?;
let inner = self
.inner
.as_mut()
.ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?;
- py.detach(|| runtime().block_on(inner.write_arrow_batch(&batch)))
+ py.detach(|| runtime().block_on(inner.write_arrow(&batch, bucket)))
.map_err(to_py_err)
}
@@ -848,8 +900,14 @@ impl PyBatchTableWrite {
}
}
- fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
- self.state.write_arrow(py, batch)
+ #[pyo3(signature = (batch, bucket=None))]
+ fn write_arrow(
+ &mut self,
+ py: Python<'_>,
+ batch: &Bound<'_, PyAny>,
+ bucket: Option<i32>,
+ ) -> PyResult<()> {
+ self.state.write_arrow(py, batch, bucket)
}
fn prepare_commit(&mut self, py: Python<'_>) ->
PyResult<Vec<PyCommitMessage>> {
@@ -888,8 +946,14 @@ impl PyStreamTableWrite {
}
}
- fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
- self.state.write_arrow(py, batch)
+ #[pyo3(signature = (batch, bucket=None))]
+ fn write_arrow(
+ &mut self,
+ py: Python<'_>,
+ batch: &Bound<'_, PyAny>,
+ bucket: Option<i32>,
+ ) -> PyResult<()> {
+ self.state.write_arrow(py, batch, bucket)
}
/// Rust currently flushes synchronously and has no background compaction.
diff --git a/bindings/python/tests/test_write.py
b/bindings/python/tests/test_write.py
index 900a1774..a915f314 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -89,6 +89,39 @@ def
test_partial_write_type_roundtrip_and_lifecycle(tmp_path, streaming):
"id": [1, 2], "name": [None, None]}
[email protected]("streaming", [False, True])
[email protected]("dynamic", [False, True])
+def test_precomputed_bucket_api_roundtrip_and_closed_writer(tmp_path,
streaming, dynamic):
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": str(tmp_path)})
+ ctx.sql("CREATE SCHEMA paimon.wdb")
+ bucket = -1 if dynamic else 4
+ ctx.sql("CREATE TABLE paimon.wdb.t (id INT, name STRING, PRIMARY KEY (id))
"
+ "WITH ('bucket' = '{}')".format(bucket))
+ table = _get_table(str(tmp_path))
+ builder = table.new_stream_write_builder() if streaming else
table.new_batch_write_builder()
+ if dynamic:
+ assert builder.with_restore_snapshot(0) is builder
+ else:
+ with pytest.raises(ValueError, match="HASH_DYNAMIC"):
+ builder.with_restore_snapshot(0)
+ writer = builder.new_write()
+ try:
+ writer.write_arrow(_batch([1, 1, 2], ["old", "new", "other"]),
bucket=3)
+ with pytest.raises(ValueError, match="Bucket id"):
+ writer.write_arrow(_batch([3], ["wrong"]), bucket=-1)
+ messages = writer.prepare_commit(True, 1) if streaming else
writer.prepare_commit()
+ commit = builder.new_commit()
+ commit.commit(1, messages) if streaming else commit.commit(messages)
+ assert len(messages) == 1
+ finally:
+ writer.close()
+ with pytest.raises(RuntimeError, match="closed"):
+ writer.write_arrow(_batch([4], ["closed"]), bucket=3)
+ assert pa.Table.from_batches(ctx.sql("SELECT id, name FROM paimon.wdb.t
ORDER BY id")).to_pydict() == {
+ "id": [1, 2], "name": ["new", "other"]}
+
+
@pytest.mark.parametrize("primary_key", [False, True])
def test_custom_data_file_prefix_matches_table_option(tmp_path, primary_key):
diff --git a/crates/paimon/src/table/bucket_assigner_dynamic.rs
b/crates/paimon/src/table/bucket_assigner_dynamic.rs
index ee30e5ef..d6422b60 100644
--- a/crates/paimon/src/table/bucket_assigner_dynamic.rs
+++ b/crates/paimon/src/table/bucket_assigner_dynamic.rs
@@ -276,6 +276,67 @@ impl PartitionIndex {
}
}
+ /// Merge buckets restored or modified by direct writers. The base snapshot
+ /// is shared, so an already-notified maintainer retains its newer state.
+ fn merge(&mut self, other: Self) -> Result<()> {
+ for (hash, bucket) in other.hash_to_bucket {
+ self.record_mapping(hash, bucket)?;
+ }
+ self.bucket_maintainers.extend(other.bucket_maintainers);
+ Ok(())
+ }
+
+ fn record_mapping(&mut self, hash: i32, bucket: i32) -> Result<()> {
+ if let Some(&previous) = self.hash_to_bucket.get(&hash) {
+ if previous != bucket {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Precomputed hash {hash} belongs to bucket {previous},
not {bucket}"
+ ),
+ source: None,
+ });
+ }
+ return Ok(());
+ }
+ self.hash_to_bucket.insert(hash, bucket);
+ if self.all_buckets.insert(bucket) {
+ self.bucket_ids.push(bucket);
+ self.non_full_buckets.insert(bucket, 0);
+ }
+ if let Some(count) = self.non_full_buckets.get_mut(&bucket) {
+ *count += 1;
+ if *count >= self.target_bucket_row_number {
+ self.non_full_buckets.remove(&bucket);
+ }
+ }
+ Ok(())
+ }
+
+ fn notify_precomputed(&mut self, bucket: i32, hashes: &[i32]) ->
Result<()> {
+ let notified: HashSet<i32> = hashes.iter().copied().collect();
+ // Validate the entire group before changing the assignment or
maintainer.
+ for &hash in hashes {
+ if let Some(&previous) = self.hash_to_bucket.get(&hash) {
+ if previous != bucket {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Precomputed hash {hash} belongs to bucket
{previous}, not {bucket}"
+ ),
+ source: None,
+ });
+ }
+ }
+ }
+ for hash in notified {
+ self.record_mapping(hash, bucket)?;
+ self.bucket_maintainers
+ .entry(bucket)
+ .or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
+ .notify_new_record(hash);
+ }
+ Ok(())
+ }
+
/// Load partition index from existing hash index files.
///
/// Reads all HASH-type index entries for this partition and reconstructs
@@ -480,10 +541,14 @@ pub(crate) struct DynamicBucketAssigner {
/// Schema fields for BinaryRow extraction.
fields: Vec<DataField>,
partition_indexes: HashMap<Vec<u8>, PartitionIndex>,
+ /// Direct writers restore only the supplied buckets. Assignment upgrades
+ /// these partial indexes to a full partition index on first use.
+ precomputed_indexes: HashMap<Vec<u8>, PartitionIndex>,
target_bucket_row_number: i64,
table: Table,
max_buckets: i32,
snapshot: Option<Snapshot>,
+ restore_snapshot_id: Option<i64>,
/// Cached index manifest entries from the latest snapshot (loaded once).
cached_index_entries: Option<Vec<IndexManifestEntry>>,
/// Overwrite mode: skip loading existing index entries.
@@ -515,9 +580,11 @@ impl DynamicBucketAssigner {
primary_key_indices,
fields: table.schema().fields().to_vec(),
partition_indexes: HashMap::new(),
+ precomputed_indexes: HashMap::new(),
target_bucket_row_number: options.dynamic_bucket_target_row_num(),
max_buckets: options.dynamic_bucket_max_buckets()?,
snapshot: None,
+ restore_snapshot_id: None,
cached_index_entries: None,
is_overwrite,
partition_computer,
@@ -530,6 +597,59 @@ impl DynamicBucketAssigner {
self.is_overwrite = is_overwrite;
}
+ /// Set by the builder before any index state is loaded.
+ pub fn set_restore_snapshot(&mut self, snapshot_id: i64) {
+ self.restore_snapshot_id = Some(snapshot_id);
+ }
+
+ /// Java's DynamicBucketIndexMaintainer sees keys after bucket assignment.
+ /// Keep that same full-file replacement lifecycle for precomputed groups.
+ pub async fn notify_precomputed_batch(
+ &mut self,
+ batch: &RecordBatch,
+ partition: &[u8],
+ bucket: i32,
+ ) -> Result<()> {
+ let hashes = batch_hash_codes(batch, &self.primary_key_indices,
&self.fields)?;
+ if let Some(index) = self.partition_indexes.get_mut(partition) {
+ return index.notify_precomputed(bucket, &hashes);
+ }
+ let loaded = self
+ .precomputed_indexes
+ .get(partition)
+ .is_some_and(|index|
index.bucket_maintainers.contains_key(&bucket));
+ if !loaded {
+ self.ensure_index_entries_loaded().await?;
+ let entries: Vec<_> = self
+ .cached_index_entries
+ .as_deref()
+ .unwrap_or(&[])
+ .iter()
+ .filter(|entry| {
+ entry.partition == partition
+ && entry.bucket == bucket
+ && entry.index_file.index_type == HASH_INDEX
+ })
+ .cloned()
+ .collect();
+ let mut restored = self.load_hash_indexes(partition,
&entries).await?;
+ // Remember an empty bucket too, so subsequent batches do not
restore
+ // it repeatedly. It remains unmodified until a key is notified.
+ restored
+ .bucket_maintainers
+ .entry(bucket)
+ .or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]));
+ self.precomputed_indexes
+ .entry(partition.to_vec())
+ .or_insert_with(||
PartitionIndex::empty(self.target_bucket_row_number))
+ .merge(restored)?;
+ }
+ self.precomputed_indexes
+ .get_mut(partition)
+ .unwrap()
+ .notify_precomputed(bucket, &hashes)
+ }
+
/// Load all index manifest entries from the latest snapshot (cached).
/// Overwrite mode skips loading — old index is irrelevant.
async fn ensure_index_entries_loaded(&mut self) -> Result<()> {
@@ -541,7 +661,11 @@ impl DynamicBucketAssigner {
return Ok(());
}
let snapshot_manager = self.table.snapshot_manager();
- let latest_snapshot = snapshot_manager.get_latest_snapshot().await?;
+ let latest_snapshot = match self.restore_snapshot_id {
+ Some(0) => None,
+ Some(id) => Some(snapshot_manager.get_snapshot(id).await?),
+ None => snapshot_manager.get_latest_snapshot().await?,
+ };
let entries = if let Some(snapshot) = &latest_snapshot {
if let Some(index_manifest_name) = snapshot.index_manifest() {
@@ -578,6 +702,15 @@ impl DynamicBucketAssigner {
self.validate_data_buckets(partition_bytes, &partition_entries)
.await?;
+ self.load_hash_indexes(partition_bytes, &partition_entries)
+ .await
+ }
+
+ async fn load_hash_indexes(
+ &self,
+ partition_bytes: &[u8],
+ partition_entries: &[IndexManifestEntry],
+ ) -> Result<PartitionIndex> {
if !partition_entries.is_empty() {
let partition_path = self.partition_path(partition_bytes)?;
let options = CoreOptions::new(self.table.schema().options());
@@ -590,7 +723,7 @@ impl DynamicBucketAssigner {
return PartitionIndex::load(
self.table.file_io(),
&layout,
- &partition_entries,
+ partition_entries,
self.target_bucket_row_number,
)
.await;
@@ -606,6 +739,9 @@ impl DynamicBucketAssigner {
partition: &[u8],
indexes: &[IndexManifestEntry],
) -> Result<()> {
+ if self.is_overwrite {
+ return Ok(());
+ }
let Some(snapshot) = &self.snapshot else {
return Ok(());
};
@@ -658,7 +794,10 @@ impl BucketAssigner for DynamicBucketAssigner {
self.ensure_index_entries_loaded().await?;
}
for partition_bytes in unseen {
- let index = self.load_partition_index(&partition_bytes).await?;
+ let mut index = self.load_partition_index(&partition_bytes).await?;
+ if let Some(notified) =
self.precomputed_indexes.remove(&partition_bytes) {
+ index.merge(notified)?;
+ }
self.partition_indexes.insert(partition_bytes, index);
}
@@ -687,7 +826,12 @@ impl BucketAssigner for DynamicBucketAssigner {
let mut result = HashMap::new();
let table_path =
self.table.location().trim_end_matches('/').to_string();
let index_file_in_data_file_dir = self.index_file_in_data_file_dir;
- let partition_keys: Vec<Vec<u8>> =
self.partition_indexes.keys().cloned().collect();
+ let partition_keys: Vec<Vec<u8>> = self
+ .partition_indexes
+ .keys()
+ .chain(self.precomputed_indexes.keys())
+ .cloned()
+ .collect();
let mut partition_paths = Vec::with_capacity(partition_keys.len());
for partition_bytes in &partition_keys {
partition_paths.push(self.partition_path(partition_bytes)?);
@@ -701,7 +845,11 @@ impl BucketAssigner for DynamicBucketAssigner {
partition_path: &partition_path,
index_file_in_data_file_dir,
};
- if let Some(partition_index) =
self.partition_indexes.get_mut(&partition_bytes) {
+ if let Some(partition_index) = self
+ .partition_indexes
+ .get_mut(&partition_bytes)
+ .or_else(||
self.precomputed_indexes.get_mut(&partition_bytes))
+ {
let bucket_files = partition_index
.prepare_commit(
file_io,
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index d5d1a7e2..f36323d1 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -52,6 +52,9 @@ use arrow_array::{ArrayRef, RecordBatch};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
+#[path = "table_write_precomputed.rs"]
+mod precomputed;
+
/// Enum to hold either an append-only writer, a key-value writer, or a
postpone writer.
enum FileWriter {
Append(DataFileWriter),
@@ -101,9 +104,11 @@ impl FileWriter {
if result.is_err() {
self.abort().await;
}
- // A pending-bucket writer keeps its first-retract validation and
- // arrival sequence across stream checkpoints, as Java does.
- let retain = result.is_ok() && matches!(&self,
FileWriter::Postpone(_));
+ // Java MergeTreeWriter keeps its sequence counter across checkpoints,
+ // including prepares whose messages have not been committed yet.
+ // Pending-bucket writers also retain first-retract validation.
+ let retain =
+ result.is_ok() && matches!(&self, FileWriter::KeyValue(_) |
FileWriter::Postpone(_));
(result, retain.then_some(self))
}
@@ -158,6 +163,7 @@ pub struct TableWrite {
changelog_file_compression: String,
partition_seq_cache: HashMap<Vec<u8>, HashMap<i32, i64>>,
sequence_snapshot: Option<Option<Snapshot>>,
+ restore_snapshot_id: Option<i64>,
commit_user: String,
/// Shared by this writer's postpone files, as in Java's per-writer
writeId.
postpone_write_id: i32,
@@ -230,6 +236,7 @@ impl TableWrite {
changelog_file_compression: String::new(),
partition_seq_cache: HashMap::new(),
sequence_snapshot: None,
+ restore_snapshot_id: None,
commit_user,
postpone_write_id: 0,
bucket_assigner:
BucketAssignerEnum::Constant(ConstantBucketAssigner::new(
@@ -511,6 +518,7 @@ impl TableWrite {
changelog_file_compression,
partition_seq_cache: HashMap::new(),
sequence_snapshot: None,
+ restore_snapshot_id: None,
commit_user,
postpone_write_id: (uuid::Uuid::new_v4().as_u128() % i32::MAX as
u128) as i32,
bucket_assigner,
@@ -728,7 +736,12 @@ impl TableWrite {
let batch = self.validate_write_batch_schema(batch)?;
// Java filters row kinds before extracting Blob values. Ignored rows
// need neither valid descriptor bytes nor physical files.
- let batch = self.enrich_rowkind_batch(&batch)?;
+ let batch = self.generate_rowkind_batch(&batch)?;
+ let selection = self.rowkind_selection(&batch)?;
+ let batch = match &selection {
+ Some(rows) => take_rows(&batch, rows)?,
+ None => batch,
+ };
if batch.num_rows() == 0 {
return Ok(None);
}
@@ -1001,9 +1014,9 @@ impl TableWrite {
})
}
- fn enrich_rowkind_batch(&self, batch: &RecordBatch) -> Result<RecordBatch>
{
+ fn generate_rowkind_batch(&self, batch: &RecordBatch) ->
Result<RecordBatch> {
let Some(generator) = &self.row_kind_generator else {
- return self.filter_rowkind_batch(batch);
+ return Ok(batch.clone());
};
if batch
.schema()
@@ -1020,18 +1033,17 @@ impl TableWrite {
let kinds = (0..batch.num_rows())
.map(|row| generator.generate(batch, row).map(|kind|
kind.to_value()))
.collect::<Result<Vec<_>>>()?;
- let batch = Self::add_per_row_value_kind_column(batch, kinds)?;
- self.filter_rowkind_batch(&batch)
+ Self::add_per_row_value_kind_column(batch, kinds)
}
/// Both generated row kinds and explicit caller-provided kinds use the
/// same Java RowKindFilter before bucket assignment and changelog writing.
- fn filter_rowkind_batch(&self, batch: &RecordBatch) -> Result<RecordBatch>
{
+ fn rowkind_selection(&self, batch: &RecordBatch) ->
Result<Option<Vec<usize>>> {
let Some(filter) = &self.row_kind_filter else {
- return Ok(batch.clone());
+ return Ok(None);
};
let Some(column) = batch.column_by_name(VALUE_KIND_FIELD_NAME) else {
- return Ok(batch.clone());
+ return Ok(None);
};
let kinds = column
.as_any()
@@ -1047,7 +1059,7 @@ impl TableWrite {
keep_rows.push(row);
}
}
- take_rows(batch, &keep_rows)
+ Ok((keep_rows.len() != batch.num_rows()).then_some(keep_rows))
}
fn add_per_row_value_kind_column(
@@ -1159,10 +1171,19 @@ impl TableWrite {
Ok(())
}
- /// Write multiple Arrow RecordBatches.
- pub async fn write_arrow(&mut self, batches: &[RecordBatch]) -> Result<()>
{
+ /// Write Arrow batches, optionally using a bucket assigned upstream, as
+ /// in Java TableWrite.write(row, bucket). Dynamic HASH indexes observe
every
+ /// surviving key after RowKind filtering.
+ pub async fn write_arrow(
+ &mut self,
+ batches: &[RecordBatch],
+ bucket: Option<i32>,
+ ) -> Result<()> {
for batch in batches {
- self.write_arrow_batch(batch).await?;
+ match bucket {
+ Some(bucket) => self.write_precomputed_bucket(batch,
bucket).await?,
+ None => self.write_arrow_batch(batch).await?,
+ }
}
Ok(())
}
@@ -1179,17 +1200,12 @@ impl TableWrite {
}
}
- /// Close all writers and collect CommitMessages for use with TableCommit.
- /// Writers are cleared after this call, allowing the TableWrite to be
reused.
+ /// Flush all writers and collect CommitMessages for use with TableCommit.
+ /// Append writers are closed; primary-key writers retain their sequence
+ /// counters after handing off the prepared files, as Java does.
///
- /// The per-bucket primary-key sequence cache is invalidated here too: it
- /// memoizes `max_sequence_number + 1` scanned from the latest snapshot,
which
- /// this commit advances. Keeping it would make the next reuse cycle
re-seed
- /// from the pre-commit value and assign sequence numbers that overlap the
- /// just-written files, so the highest-sequence-wins merge would silently
drop
- /// the newer rows -- Java's `MergeTreeWriter` advances its counter across
- /// commits. (`sequence_snapshot` is pinned only by the postpone path,
which
- /// forbids reuse, so it is left untouched.)
+ /// Invalidate the snapshot sequence cache used to seed previously unseen
+ /// buckets. Already-open key-value writers keep their own advancing
counter.
pub async fn prepare_commit(&mut self) -> Result<Vec<CommitMessage>> {
if let Some(writer) = self.format_writer.as_mut() {
return writer.prepare_commit().await;
@@ -1412,6 +1428,15 @@ impl TableWrite {
bucket: i32,
partition_bytes: &[u8],
) -> Result<FileWriter> {
+ if !self.is_overwrite && self.sequence_snapshot.is_none() {
+ if let Some(id) = self.restore_snapshot_id {
+ self.sequence_snapshot = Some(if id == 0 {
+ None
+ } else {
+ Some(self.table.snapshot_manager().get_snapshot(id).await?)
+ });
+ }
+ }
// Lazily scan partition sequence numbers on first writer creation per
partition.
// Overwrite mode skips this — old data will be replaced, so seq
starts at 0.
if !self.is_overwrite &&
!self.partition_seq_cache.contains_key(partition_bytes) {
diff --git a/crates/paimon/src/table/table_write_precomputed.rs
b/crates/paimon/src/table/table_write_precomputed.rs
new file mode 100644
index 00000000..ca627c3e
--- /dev/null
+++ b/crates/paimon/src/table/table_write_precomputed.rs
@@ -0,0 +1,102 @@
+// 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.
+
+//! Java TableWrite.write(row, bucket) for groups assigned upstream.
+
+use super::{BucketAssignerEnum, CoreOptions, RecordBatch, Result, TableWrite};
+use crate::spec::{batch_to_serialized_bytes, EMPTY_SERIALIZED_ROW,
MAX_DYNAMIC_BUCKETS};
+
+fn invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+impl TableWrite {
+ pub(in crate::table) fn set_restore_snapshot(&mut self, snapshot_id: i64) {
+ self.restore_snapshot_id = Some(snapshot_id);
+ if let BucketAssignerEnum::Dynamic(assigner) = &mut
self.bucket_assigner {
+ assigner.set_restore_snapshot(snapshot_id);
+ }
+ // Java WriteRestore restores data files and indexes from the same
snapshot.
+ }
+
+ pub(super) async fn write_precomputed_bucket(
+ &mut self,
+ batch: &RecordBatch,
+ bucket: i32,
+ ) -> Result<()> {
+ self.ensure_active()?;
+ let dynamic = matches!(self.bucket_assigner,
BucketAssignerEnum::Dynamic(_));
+ let total_buckets =
CoreOptions::new(self.table.schema().options()).bucket();
+ if self.format_writer.is_some()
+ || matches!(self.bucket_assigner,
BucketAssignerEnum::CrossPartition(_))
+ || (!dynamic && total_buckets <= 0)
+ {
+ return Err(invalid(
+ "Precomputed bucket writes are only valid for HASH_FIXED or
HASH_DYNAMIC tables",
+ ));
+ }
+ let upper_bound = if dynamic {
+ MAX_DYNAMIC_BUCKETS
+ } else {
+ total_buckets
+ };
+ if !(0..upper_bound).contains(&bucket) {
+ return Err(invalid(format!(
+ "Bucket id must be between 0 and {}, but was {bucket}",
+ upper_bound - 1,
+ )));
+ }
+ let Some(batch) = self.normalize_write_batch(batch)? else {
+ return Ok(());
+ };
+ let partition_indices = self
+ .partition_keys
+ .iter()
+ .map(|name| {
+ self.write_fields
+ .iter()
+ .position(|field| field.name() == name)
+ .unwrap()
+ })
+ .collect::<Vec<_>>();
+ let partitions = if partition_indices.is_empty() {
+ vec![EMPTY_SERIALIZED_ROW.clone(); batch.num_rows()]
+ } else {
+ batch_to_serialized_bytes(&batch, &partition_indices,
&self.write_fields)?
+ };
+ let partition = &partitions[0];
+ if partitions.iter().any(|actual| actual != partition) {
+ return Err(invalid(
+ "A precomputed bucket group contained multiple partitions",
+ ));
+ }
+ self.written = true;
+ if let BucketAssignerEnum::Dynamic(assigner) = &mut
self.bucket_assigner {
+ if let Err(error) = assigner
+ .notify_precomputed_batch(&batch, partition, bucket)
+ .await
+ {
+ self.fail_write().await;
+ return Err(error);
+ }
+ }
+ self.write_bucket(partition.clone(), bucket, batch).await
+ }
+}
diff --git a/crates/paimon/src/table/write_builder.rs
b/crates/paimon/src/table/write_builder.rs
index 41712038..bbde338f 100644
--- a/crates/paimon/src/table/write_builder.rs
+++ b/crates/paimon/src/table/write_builder.rs
@@ -78,6 +78,39 @@ impl<'a> WriteBuilder<'a> {
}
}
+ /// Restore dynamic-bucket write state from the coordinator's snapshot.
+ /// Zero denotes an empty base; without this option the writer restores
+ /// latest. Data-file sequence numbers and HASH indexes share this base,
+ /// as in Java FileSystemWriteRestore. Overwrite writers start empty.
+ pub fn with_restore_snapshot(self, snapshot_id: i64) ->
crate::Result<Self> {
+ if snapshot_id < 0 {
+ return Err(crate::Error::DataInvalid {
+ message: "Restore snapshot id must not be negative".into(),
+ source: None,
+ });
+ }
+ match self.0 {
+ WriteBuilderKind::Paimon(mut builder)
+ if
crate::spec::CoreOptions::new(builder.table.schema().options()).bucket()
+ == -1
+ && !builder.table.schema().primary_keys().is_empty()
+ && builder
+ .table
+ .schema()
+ .partition_keys()
+ .iter()
+ .all(|key|
builder.table.schema().primary_keys().contains(key)) =>
+ {
+ builder.restore_snapshot_id = Some(snapshot_id);
+ Ok(Self(WriteBuilderKind::Paimon(builder)))
+ }
+ _ => Err(crate::Error::DataInvalid {
+ message: "Restore snapshots are only valid for HASH_DYNAMIC
tables".into(),
+ source: None,
+ }),
+ }
+ }
+
/// Share a memory budget across table writers created by `new_write`.
pub fn with_resources(self, resources: ResourceContext) -> Self {
match self.0 {
@@ -147,6 +180,7 @@ struct PaimonWriteBuilder<'a> {
commit_user: String,
overwrite: bool,
resources: Option<ResourceContext>,
+ restore_snapshot_id: Option<i64>,
}
impl<'a> PaimonWriteBuilder<'a> {
@@ -156,6 +190,7 @@ impl<'a> PaimonWriteBuilder<'a> {
commit_user: Uuid::new_v4().to_string(),
overwrite: false,
resources: None,
+ restore_snapshot_id: None,
}
}
@@ -216,11 +251,13 @@ impl<'a> PaimonWriteBuilder<'a> {
if let Some(resources) = &self.resources {
write = write.with_resources(resources.clone());
}
- Ok(if self.overwrite {
- write.with_overwrite()
- } else {
- write
- })
+ if self.overwrite {
+ write = write.with_overwrite();
+ }
+ if let Some(snapshot_id) = self.restore_snapshot_id {
+ write.set_restore_snapshot(snapshot_id);
+ }
+ Ok(write)
}
/// Create a configured table update object.
diff --git a/crates/paimon/tests/precomputed_bucket_write_test.rs
b/crates/paimon/tests/precomputed_bucket_write_test.rs
new file mode 100644
index 00000000..5a2f58fe
--- /dev/null
+++ b/crates/paimon/tests/precomputed_bucket_write_test.rs
@@ -0,0 +1,731 @@
+// 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.
+
+mod common;
+
+use std::sync::Arc;
+
+use arrow_array::{ArrayRef, Int32Array, RecordBatch, StringArray};
+use common::incremental_helpers::{
+ make_batch, make_batch_with_kinds, memory_table, persist_table_schema,
pk_schema, setup_dirs,
+};
+use futures::TryStreamExt;
+use paimon::spec::{
+ batch_hash_codes, DataType, IndexManifest, IntType, Schema, TableSchema,
VarCharType,
+};
+use paimon::table::{CommitMessage, Table};
+
+async fn table(options: &[(&str, &str)]) -> Table {
+ let (io, table) = memory_table("memory:/precomputed", pk_schema(options));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ table
+}
+
+fn hashes(table: &Table, batch: &RecordBatch) -> Vec<i32> {
+ batch_hash_codes(batch, &[0], table.schema().fields()).unwrap()
+}
+
+async fn rows(table: &Table) -> Vec<(i32, i32)> {
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ let ids = batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batch
+ .column_by_name("value")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ rows.extend((0..batch.num_rows()).map(|row| (ids.value(row),
values.value(row))));
+ }
+ rows.sort();
+ rows
+}
+
+async fn assert_index_hashes(table: &Table, bucket: i32, expected: &[i32]) {
+ let manager = table.snapshot_manager();
+ let snapshot = manager.get_latest_snapshot().await.unwrap().unwrap();
+ let path = format!(
+ "{}/{}",
+ manager.manifest_dir(),
+ snapshot.index_manifest().unwrap()
+ );
+ let entries = IndexManifest::read(table.file_io(), &path).await.unwrap();
+ let entries: Vec<_> = entries
+ .iter()
+ .filter(|e| e.index_file.index_type == "HASH" && e.bucket == bucket)
+ .collect();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].index_file.row_count, expected.len() as i64);
+ let path = entries[0]
+ .index_file
+ .external_path
+ .clone()
+ .unwrap_or_else(|| {
+ format!(
+ "{}/index/{}",
+ table.location(),
+ entries[0].index_file.file_name
+ )
+ });
+ let bytes = table
+ .file_io()
+ .new_input(&path)
+ .unwrap()
+ .read()
+ .await
+ .unwrap();
+ let mut actual = bytes
+ .as_chunks::<4>()
+ .0
+ .iter()
+ .map(|bytes| i32::from_be_bytes(*bytes))
+ .collect::<Vec<_>>();
+ let mut expected = expected.to_vec();
+ actual.sort();
+ expected.sort();
+ assert_eq!(actual, expected);
+}
+
+async fn commit(table: &Table, messages: Vec<CommitMessage>) {
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn fixed_bucket_write_uses_upstream_bucket_and_shared_sequence() {
+ let table = table(&[("bucket", "4"), ("changelog-producer",
"input")]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![10])),
+ Some(3),
+ )
+ .await
+ .unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1, 2], vec![20, 30])),
+ Some(3),
+ )
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].bucket, 3);
+ // Deduplication discards id=1's first version, while its changelog
remains.
+ assert_eq!(messages[0].new_files[0].min_sequence_number, 1);
+ assert_eq!(messages[0].new_files[0].max_sequence_number, 2);
+ assert!(!messages[0].new_changelog_files.is_empty());
+ assert!(messages[0].new_index_files.is_empty());
+ commit(&table, messages).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![40])),
+ Some(3),
+ )
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_files[0].min_sequence_number, 3);
+ commit(&table, messages).await;
+ assert_eq!(rows(&table).await, vec![(1, 40), (2, 30)]);
+}
+
+#[tokio::test]
+async fn dynamic_upstream_bucket_survives_mixed_writes_and_checkpoints() {
+ let table = table(&[("bucket", "-1"), ("dynamic-bucket.target-row-num",
"1")]).await;
+ let mut writer = table
+ .new_write_builder()
+ .with_restore_snapshot(0)
+ .unwrap()
+ .new_write()
+ .unwrap();
+ let first = make_batch(vec![1], vec![10]);
+ let first_hashes = hashes(&table, &first);
+ writer
+ .write_arrow(std::slice::from_ref(&first), Some(17))
+ .await
+ .unwrap();
+ // Ordinary assignment must recognize a mapping notified by an upstream
group.
+ writer
+ .write_arrow_batch(&make_batch(vec![1], vec![20]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].bucket, 17);
+ commit(&table, messages).await;
+ assert_index_hashes(&table, 17, &first_hashes).await;
+ let second = make_batch(vec![1, 2], vec![30, 40]);
+ let second_hashes = hashes(&table, &second);
+ writer
+ .write_arrow(std::slice::from_ref(&second), Some(17))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_index_files[0].row_count, 2);
+ commit(&table, messages).await;
+ assert_index_hashes(&table, 17, &second_hashes).await;
+ assert_eq!(rows(&table).await, vec![(1, 30), (2, 40)]);
+ let mut restarted = table.new_write_builder().new_write().unwrap();
+ restarted
+ .write_arrow_batch(&make_batch(vec![1, 2], vec![50, 60]))
+ .await
+ .unwrap();
+ let messages = restarted.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].bucket, 17);
+ assert!(messages[0].new_index_files.is_empty());
+ commit(&table, messages).await;
+ assert_eq!(rows(&table).await, vec![(1, 50), (2, 60)]);
+}
+
+#[tokio::test]
+async fn repeated_hashes_do_not_rewrite_unchanged_index() {
+ let table = table(&[("bucket", "-1")]).await;
+ let batch = make_batch(vec![1], vec![10]);
+ let key_hashes = hashes(&table, &batch);
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow(std::slice::from_ref(&batch), Some(7))
+ .await
+ .unwrap();
+ commit(&table, writer.prepare_commit().await.unwrap()).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow(std::slice::from_ref(&batch), Some(7))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(messages[0].new_index_files.is_empty());
+ commit(&table, messages).await;
+ assert_index_hashes(&table, 7, &key_hashes).await;
+}
+
+#[tokio::test]
+async fn row_kind_filter_precedes_index_hash_computation() {
+ let table = table(&[
+ ("bucket", "-1"),
+ ("ignore-delete", "true"),
+ ("changelog-producer", "input"),
+ ])
+ .await;
+ // Ignored deletes must not suppress index notification for surviving
inserts.
+ let batch = make_batch_with_kinds(vec![1, 1, 2, 2], vec![0, 10, 0, 20],
vec![3, 0, 3, 0]);
+ let key_hashes = hashes(&table, &batch);
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow(std::slice::from_ref(&batch), Some(9))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_index_files[0].row_count, 2);
+ commit(&table, messages).await;
+ assert_index_hashes(&table, 9, &[key_hashes[1], key_hashes[3]]).await;
+ assert_eq!(rows(&table).await, vec![(1, 10), (2, 20)]);
+}
+
+#[tokio::test]
+async fn
partition_group_and_projected_write_type_are_validated_before_staging() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .column("pt", DataType::VarChar(VarCharType::string_type()))
+ .partition_keys(["pt"])
+ .option("bucket", "4")
+ .option("bucket-key", "id")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table("memory:/precomputed_partial",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .with_write_type(vec!["pt".into(), "id".into()])
+ .unwrap();
+ let batch = RecordBatch::try_from_iter([
+ (
+ "pt",
+ Arc::new(StringArray::from(vec!["a", "b"])) as ArrayRef,
+ ),
+ ("id", Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef),
+ ])
+ .unwrap();
+ let error = writer
+ .write_arrow(std::slice::from_ref(&batch), Some(2))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("multiple partitions"));
+ // Rejected input did not freeze the write type or stage half a group.
+ writer
+ .with_write_type(vec!["pt".into(), "id".into()])
+ .unwrap();
+ writer
+ .write_arrow(std::slice::from_ref(&batch.slice(0, 1)), Some(2))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].bucket, 2);
+ assert_eq!(messages[0].new_files[0].row_count, 1);
+ assert_eq!(
+ messages[0].new_files[0].write_cols,
+ Some(vec!["pt".into(), "id".into()])
+ );
+}
+
+#[tokio::test]
+async fn configuration_and_bucket_errors_do_not_stage_data() {
+ for bucket in ["1", "4", "-1"] {
+ let table = table(&[("bucket", bucket)]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let batch = make_batch(vec![1, 2], vec![10, 20]);
+ for invalid in [
+ -1,
+ if bucket == "-1" {
+ 32768
+ } else {
+ bucket.parse().unwrap()
+ },
+ ] {
+ assert!(writer
+ .write_arrow(std::slice::from_ref(&batch), Some(invalid))
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("Bucket id"));
+ }
+ if bucket != "-1" {
+ assert!(table
+ .new_write_builder()
+ .with_restore_snapshot(0)
+ .err()
+ .unwrap()
+ .to_string()
+ .contains("HASH_DYNAMIC"));
+ } else {
+
assert!(table.new_write_builder().with_restore_snapshot(-1).is_err());
+ let mut missing = table
+ .new_write_builder()
+ .with_restore_snapshot(99)
+ .unwrap()
+ .new_write()
+ .unwrap();
+ assert!(missing
+ .write_arrow(std::slice::from_ref(&batch), Some(0))
+ .await
+ .is_err());
+ missing.close().await;
+ }
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ }
+}
+
+#[tokio::test]
+async fn builder_configuration_is_copied_to_created_writers() {
+ let table = table(&[("bucket", "-1")]).await;
+ let builder = table.new_write_builder().with_restore_snapshot(0).unwrap();
+ let mut writer = builder.new_write().unwrap();
+ let builder = builder.with_restore_snapshot(99).unwrap();
+ writer
+ .write_arrow(std::slice::from_ref(&make_batch(vec![], vec![])),
Some(0))
+ .await
+ .unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![10])),
+ Some(0),
+ )
+ .await
+ .unwrap();
+ assert_eq!(writer.prepare_commit().await.unwrap().len(), 1);
+ let mut missing = builder.new_write().unwrap();
+ assert!(missing
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![2], vec![20])),
+ Some(0)
+ )
+ .await
+ .is_err());
+}
+
+#[tokio::test]
+async fn pinned_base_does_not_silently_follow_later_snapshots() {
+ let table = table(&[("bucket", "-1")]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![10])),
+ Some(0),
+ )
+ .await
+ .unwrap();
+ commit(&table, writer.prepare_commit().await.unwrap()).await;
+ let mut pinned = table
+ .new_write_builder()
+ .with_restore_snapshot(1)
+ .unwrap()
+ .new_write()
+ .unwrap();
+ let second = make_batch(vec![2], vec![20]);
+ writer
+ .write_arrow(std::slice::from_ref(&second), Some(0))
+ .await
+ .unwrap();
+ commit(&table, writer.prepare_commit().await.unwrap()).await;
+ pinned
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![3], vec![30])),
+ Some(0),
+ )
+ .await
+ .unwrap();
+ let messages = pinned.prepare_commit().await.unwrap();
+ // Java restores both data-file sequences and HASH indexes from snapshot 1.
+ assert_eq!(messages[0].new_index_files[0].row_count, 2);
+ assert_eq!(messages[0].new_files[0].min_sequence_number, 1);
+ table
+ .new_write_builder()
+ .new_commit()
+ .abort(&messages)
+ .await
+ .unwrap();
+ assert_eq!(rows(&table).await, vec![(1, 10), (2, 20)]);
+}
+
+#[tokio::test]
+async fn
restore_snapshot_pins_data_file_sequences_and_default_restores_latest() {
+ let table = table(&[("bucket", "-1")]).await;
+ let mut first = table.new_write_builder().new_write().unwrap();
+ first
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![10])),
+ Some(7),
+ )
+ .await
+ .unwrap();
+ commit(&table, first.prepare_commit().await.unwrap()).await;
+ let mut pinned = table
+ .new_write_builder()
+ .with_restore_snapshot(1)
+ .unwrap()
+ .new_write()
+ .unwrap();
+ first
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1, 1, 1], vec![20, 30, 40])),
+ Some(7),
+ )
+ .await
+ .unwrap();
+ commit(&table, first.prepare_commit().await.unwrap()).await;
+ pinned
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![50])),
+ Some(7),
+ )
+ .await
+ .unwrap();
+ let messages = pinned.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_files[0].min_sequence_number, 1);
+ // The supplied historical base is a coordinated restore, not permission to
+ // overwrite another owner's newer writes. Inspect and abort these
messages.
+ table
+ .new_write_builder()
+ .new_commit()
+ .abort(&messages)
+ .await
+ .unwrap();
+ pinned.close().await;
+ assert_eq!(rows(&table).await, vec![(1, 40)]);
+ let mut current = table.new_write_builder().new_write().unwrap();
+ current
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![50])),
+ Some(7),
+ )
+ .await
+ .unwrap();
+ let messages = current.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_files[0].min_sequence_number, 4);
+ commit(&table, messages).await;
+ assert_eq!(rows(&table).await, vec![(1, 50)]);
+}
+
+#[tokio::test]
+async fn conflicting_upstream_mapping_never_publishes_a_second_bucket() {
+ let table = table(&[("bucket", "-1")]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let batch = make_batch(vec![1], vec![10]);
+ writer
+ .write_arrow(std::slice::from_ref(&batch), Some(7))
+ .await
+ .unwrap();
+ let error = writer
+ .write_arrow(std::slice::from_ref(&batch), Some(8))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("belongs to bucket 7"));
+ assert!(writer.prepare_commit().await.is_err());
+ assert!(table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .is_none());
+}
+
+#[tokio::test]
+async fn overwrite_ignores_old_hashes_and_sequence_numbers() {
+ let table = table(&[("bucket", "-1")]).await;
+ let mut first = table.new_write_builder().new_write().unwrap();
+ first
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1, 2], vec![10, 20])),
+ Some(5),
+ )
+ .await
+ .unwrap();
+ commit(&table, first.prepare_commit().await.unwrap()).await;
+ let builder = table
+ .new_write_builder()
+ .with_overwrite()
+ .with_restore_snapshot(1)
+ .unwrap();
+ let mut writer = builder.new_write().unwrap();
+ let batch = make_batch(vec![3], vec![30]);
+ let key_hashes = hashes(&table, &batch);
+ writer
+ .write_arrow(std::slice::from_ref(&batch), Some(5))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_files[0].min_sequence_number, 0);
+ assert_eq!(messages[0].new_index_files[0].row_count, 1);
+ builder
+ .new_commit()
+ .overwrite(messages, None)
+ .await
+ .unwrap();
+ assert_index_hashes(&table, 5, &key_hashes).await;
+ assert_eq!(rows(&table).await, vec![(3, 30)]);
+}
+
+#[tokio::test]
+async fn prepared_checkpoints_keep_sequence_progress_before_publication() {
+ let table = table(&[("bucket", "-1")]).await;
+ let builder = table.new_write_builder().with_restore_snapshot(0).unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![2, 1], vec![20, 10])),
+ Some(7),
+ )
+ .await
+ .unwrap();
+ let first = writer.prepare_commit().await.unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![1], vec![30])),
+ Some(7),
+ )
+ .await
+ .unwrap();
+ let second = writer.prepare_commit().await.unwrap();
+ assert_eq!(first[0].new_files[0].max_sequence_number, 1);
+ assert_eq!(second[0].new_files[0].min_sequence_number, 2);
+ builder
+ .new_commit()
+ .commit_with_identifier(first, 1)
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .commit_with_identifier(second, 2)
+ .await
+ .unwrap();
+ assert_eq!(rows(&table).await, vec![(1, 30), (2, 20)]);
+}
+
+#[tokio::test]
+async fn configured_overwrite_can_use_ordinary_and_direct_assignment() {
+ let table = table(&[("bucket", "-1")]).await;
+ let mut first = table.new_write_builder().new_write().unwrap();
+ first
+ .write_arrow_batch(&make_batch(vec![1, 2], vec![10, 20]))
+ .await
+ .unwrap();
+ commit(&table, first.prepare_commit().await.unwrap()).await;
+ let builder = table
+ .new_write_builder()
+ .with_overwrite()
+ .with_restore_snapshot(1)
+ .unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![3], vec![30]))
+ .await
+ .unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![3, 4], vec![40, 50])),
+ Some(0),
+ )
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .overwrite(writer.prepare_commit().await.unwrap(), None)
+ .await
+ .unwrap();
+ assert_eq!(rows(&table).await, vec![(3, 40), (4, 50)]);
+}
+
+#[tokio::test]
+async fn precomputed_writer_restores_only_its_bucket() {
+ let table = table(&[("bucket", "-1")]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ for (id, bucket) in [(1, 7), (2, 8)] {
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(vec![id], vec![id * 10])),
+ Some(bucket),
+ )
+ .await
+ .unwrap();
+ }
+ let messages = writer.prepare_commit().await.unwrap();
+ let other_index = messages
+ .iter()
+ .find(|message| message.bucket == 8)
+ .unwrap()
+ .new_index_files[0]
+ .file_name
+ .clone();
+ commit(&table, messages).await;
+ // An unrelated bucket's payload must never be opened by a direct writer.
+ // Ordinary assignment still needs all hashes and detects the corruption.
+ table
+ .file_io()
+ .new_output(&format!("{}/index/{other_index}", table.location()))
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"bad"))
+ .await
+ .unwrap();
+ let mut direct = table.new_write_builder().new_write().unwrap();
+ let batch = make_batch(vec![1, 3], vec![100, 30]);
+ direct
+ .write_arrow(std::slice::from_ref(&batch), Some(7))
+ .await
+ .unwrap();
+ let messages = direct.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_index_files[0].row_count, 2);
+ let mut assign = table.new_write_builder().new_write().unwrap();
+ assert!(assign
+ .write_arrow_batch(&batch)
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("Corrupt HASH index"));
+ table
+ .new_write_builder()
+ .new_commit()
+ .abort(&messages)
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn bucket_local_index_paths_survive_precomputed_writer_restart() {
+ let table = table(&[
+ ("bucket", "-1"),
+ ("index-file-in-data-file-dir", "true"),
+ ("data-file.path-directory", "data/nested"),
+ ])
+ .await;
+ for ids in [vec![1, 2], vec![2, 3]] {
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow(
+ std::slice::from_ref(&make_batch(ids.clone(), ids)),
+ Some(19),
+ )
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let meta = &messages[0].new_index_files[0];
+ let path = format!(
+ "{}/data/nested/bucket-19/{}",
+ table.location(),
+ meta.file_name
+ );
+ assert!(table.file_io().exists(&path).await.unwrap());
+ commit(&table, messages).await;
+ }
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1, 2, 3], vec![10, 20, 30]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].bucket, 19);
+ assert!(messages[0].new_index_files.is_empty());
+ commit(&table, messages).await;
+ assert_eq!(rows(&table).await, vec![(1, 10), (2, 20), (3, 30)]);
+}
+
+#[tokio::test]
+async fn unified_arrow_writes_multiple_batches_with_or_without_bucket() {
+ for bucket in [None, Some(0)] {
+ let table = table(&[("bucket", "4")]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let batches = vec![
+ make_batch(vec![1, 2], vec![10, 20]),
+ make_batch(vec![1], vec![30]),
+ ];
+ writer.write_arrow(&batches, bucket).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ if let Some(bucket) = bucket {
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].bucket, bucket);
+ }
+ commit(&table, messages).await;
+ assert_eq!(rows(&table).await, vec![(1, 30), (2, 20)]);
+ }
+}