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 d4050850 [Feature] Add manifest sidecar support (#861)
d4050850 is described below
commit d4050850d7ee1136fcf880fadce2be6433dde85b
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Sep 17 22:41:06 2026 +0800
[Feature] Add manifest sidecar support (#861)
---
crates/paimon/src/spec/avro/ocf.rs | 15 +-
crates/paimon/src/spec/core_options.rs | 41 +
.../src/spec/goldens/manifest_sidecar_v1.bin | Bin 0 -> 255 bytes
crates/paimon/src/spec/manifest_sidecar.rs | 1429 ++++++++++++++++++++
crates/paimon/src/spec/mod.rs | 5 +
crates/paimon/src/table/table_commit.rs | 83 +-
crates/paimon/src/table/table_scan.rs | 186 ++-
docs/src/sql.md | 7 +
8 files changed, 1756 insertions(+), 10 deletions(-)
diff --git a/crates/paimon/src/spec/avro/ocf.rs
b/crates/paimon/src/spec/avro/ocf.rs
index aed7ee25..58b89ca7 100644
--- a/crates/paimon/src/spec/avro/ocf.rs
+++ b/crates/paimon/src/spec/avro/ocf.rs
@@ -29,6 +29,8 @@ pub struct OcfHeader {
pub schema_json: String,
pub codec: OcfCodec,
pub sync_marker: [u8; SYNC_MARKER_LEN],
+ /// Encoded header length, including the sync marker.
+ pub encoded_len: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -42,6 +44,10 @@ pub enum OcfCodec {
pub struct OcfBlock<'a> {
pub object_count: usize,
pub data: Cow<'a, [u8]>,
+ /// Offset of the encoded block in the original OCF container.
+ pub encoded_offset: usize,
+ /// Length of the complete encoded block, including its sync marker.
+ pub encoded_len: usize,
}
/// Streaming iterator over OCF blocks with lazy decompression and reusable
decoder state.
@@ -67,6 +73,7 @@ impl<'a> OcfBlockIter<'a> {
return Ok(None);
}
+ let encoded_offset = self.cursor.position();
let raw_object_count = self.cursor.read_long()?;
if raw_object_count < 0 {
return Err(Error::UnexpectedError {
@@ -95,7 +102,12 @@ impl<'a> OcfBlockIter<'a> {
});
}
- Ok(Some(OcfBlock { object_count, data }))
+ Ok(Some(OcfBlock {
+ object_count,
+ data,
+ encoded_offset,
+ encoded_len: self.cursor.position() - encoded_offset,
+ }))
}
fn decompress(&mut self, data: &'a [u8]) -> crate::Result<Cow<'a, [u8]>> {
@@ -181,6 +193,7 @@ pub fn parse_ocf_streaming(bytes: &[u8]) ->
crate::Result<(OcfHeader, OcfBlockIt
schema_json,
codec,
sync_marker,
+ encoded_len: cursor.position(),
};
let iter = OcfBlockIter::new(cursor, header.codec, header.sync_marker);
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index d3114fd4..b10258a4 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -88,6 +88,8 @@ const MANIFEST_COMPRESSION_OPTION: &str =
"manifest.compression";
const MANIFEST_TARGET_FILE_SIZE_OPTION: &str = "manifest.target-file-size";
const MANIFEST_TARGET_SIZE_OPTION: &str = "manifest.target-size";
const MANIFEST_MERGE_MIN_COUNT_OPTION: &str = "manifest.merge-min-count";
+const MANIFEST_SIDECAR_ENABLED_OPTION: &str = "manifest.sidecar.enabled";
+const MANIFEST_SORT_ENABLED_OPTION: &str = "manifest-sort.enabled";
const WRITE_PARQUET_BUFFER_SIZE_OPTION: &str = "write.parquet-buffer-size";
const READ_BATCH_SIZE_OPTION: &str = "read.batch-size";
const PARQUET_ROW_GROUP_PARALLELISM_OPTION: &str =
"read.parquet.row-group.parallelism";
@@ -1165,6 +1167,18 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(DEFAULT_MANIFEST_MERGE_MIN_COUNT)
}
+ /// Whether manifest block sidecars are read and written.
+ ///
+ /// An explicit `manifest.sidecar.enabled` value wins. When it is absent,
+ /// this follows Java Paimon's `manifest-sort.enabled` compatibility rule.
+ pub fn manifest_sidecar_enabled(&self) -> bool {
+ self.options
+ .get(MANIFEST_SIDECAR_ENABLED_OPTION)
+ .or_else(|| self.options.get(MANIFEST_SORT_ENABLED_OPTION))
+ .map(|value| value.eq_ignore_ascii_case("true"))
+ .unwrap_or(false)
+ }
+
/// Number of buckets for the table. Default is -1 (dynamic bucket).
pub fn bucket(&self) -> i32 {
self.options
@@ -2562,6 +2576,7 @@ mod tests {
assert_eq!(core.manifest_compression(), "zstd");
assert_eq!(core.manifest_target_size(), 8 * 1024 * 1024);
assert_eq!(core.manifest_merge_min_count(), 30);
+ assert!(!core.manifest_sidecar_enabled());
}
#[test]
@@ -2600,6 +2615,32 @@ mod tests {
assert_eq!(core.manifest_target_size(), 2 * 1024);
}
+ #[test]
+ fn test_manifest_sidecar_defaults_to_sort_with_explicit_override() {
+ for (sort, sidecar, expected) in [
+ (None, None, false),
+ (Some("true"), None, true),
+ (Some("false"), None, false),
+ (Some("true"), Some("false"), false),
+ (Some("false"), Some("true"), true),
+ ] {
+ let mut options = HashMap::new();
+ if let Some(sort) = sort {
+ options.insert(MANIFEST_SORT_ENABLED_OPTION.to_string(),
sort.to_string());
+ }
+ if let Some(sidecar) = sidecar {
+ options.insert(
+ MANIFEST_SIDECAR_ENABLED_OPTION.to_string(),
+ sidecar.to_string(),
+ );
+ }
+ assert_eq!(
+ CoreOptions::new(&options).manifest_sidecar_enabled(),
+ expected
+ );
+ }
+ }
+
#[test]
fn test_try_time_travel_selector_normalizes_valid_selector() {
let timestamp_options =
diff --git a/crates/paimon/src/spec/goldens/manifest_sidecar_v1.bin
b/crates/paimon/src/spec/goldens/manifest_sidecar_v1.bin
new file mode 100644
index 00000000..671025d2
Binary files /dev/null and
b/crates/paimon/src/spec/goldens/manifest_sidecar_v1.bin differ
diff --git a/crates/paimon/src/spec/manifest_sidecar.rs
b/crates/paimon/src/spec/manifest_sidecar.rs
new file mode 100644
index 00000000..c11a4a41
--- /dev/null
+++ b/crates/paimon/src/spec/manifest_sidecar.rs
@@ -0,0 +1,1429 @@
+// 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.
+
+//! Binary sidecar indexes for pruning complete Avro manifest blocks.
+//!
+//! The format is compatible with Java Paimon's `ManifestSidecar` version 1.
+//! Coverage for partitions, row IDs, and buckets is independent: unavailable
+//! coverage in one dimension never disables pruning by the other dimensions.
+
+use std::collections::{BTreeMap, BTreeSet, HashSet};
+use std::ops::Range;
+
+use bytes::Bytes;
+use indexmap::IndexMap;
+
+use crate::io::{FileIO, FileRead};
+use crate::spec::avro::ocf::parse_ocf_streaming;
+use crate::spec::{ManifestEntry, ManifestFileMeta};
+use crate::table::RowRange;
+use crate::{Error, Result};
+
+/// Suffix used by manifest sidecar files.
+pub const MANIFEST_SIDECAR_SUFFIX: &str = ".avro.sidecar";
+
+const MAGIC: &[u8; 4] = b"PMSC";
+const FORMAT_VERSION: u64 = 1;
+const MIN_HEADER_BYTES: usize = 29;
+const CHECKSUM_BYTES: usize = 4;
+const MIN_BLOCK_BYTES: usize = 6;
+const MAX_INT: u64 = i32::MAX as u64;
+const MAX_LONG: u64 = i64::MAX as u64;
+const BLOCK_READ_BUFFER_BYTES: u64 = 4 * 1024 * 1024;
+
+type RowBlockFilter<'a> = dyn Fn(i64, i64) -> bool + Sync + 'a;
+type PartitionBlockFilter<'a> = dyn FnMut(&[u8]) -> bool + Send + 'a;
+type BucketBlockFilter<'a> = dyn FnMut(i32, i32) -> bool + Send + 'a;
+
+/// Original file offset/length and zero-based manifest entry ordinal.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ManifestSidecarBlock {
+ offset: u64,
+ length: u64,
+ first_record: u64,
+ record_count: u64,
+}
+
+impl ManifestSidecarBlock {
+ pub fn offset(&self) -> u64 {
+ self.offset
+ }
+
+ pub fn length(&self) -> u64 {
+ self.length
+ }
+
+ pub fn first_record(&self) -> u64 {
+ self.first_record
+ }
+
+ pub fn record_count(&self) -> u64 {
+ self.record_count
+ }
+}
+
+/// Selected blocks in original file order. An empty block list excludes the
manifest.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ManifestSidecarSelection {
+ header: Bytes,
+ blocks: Vec<ManifestSidecarBlock>,
+}
+
+impl ManifestSidecarSelection {
+ pub fn header(&self) -> &[u8] {
+ &self.header
+ }
+
+ pub fn blocks(&self) -> &[ManifestSidecarBlock] {
+ &self.blocks
+ }
+}
+
+#[derive(Debug)]
+struct IndexedBlock {
+ block: ManifestSidecarBlock,
+ partitions: Vec<u8>,
+ row_ids: Vec<u8>,
+ buckets: Vec<u8>,
+}
+
+/// Incremental builder for a complete manifest block directory.
+#[derive(Debug)]
+pub struct ManifestSidecarBuilder {
+ row_id_enabled: bool,
+ bucket_enabled: bool,
+ header: Vec<u8>,
+ ranges: BTreeMap<u64, u64>,
+ dictionary: IndexMap<Vec<u8>, usize>,
+ partition_ids: BTreeSet<usize>,
+ bucket_pairs: BTreeSet<(i32, i32)>,
+ blocks: Vec<IndexedBlock>,
+ next_offset: u64,
+ next_record: u64,
+ current: Option<ManifestSidecarBlock>,
+ entries_in_block: u64,
+ row_available: bool,
+ partition_available: bool,
+ bucket_available: bool,
+}
+
+impl ManifestSidecarBuilder {
+ pub fn new(header: Vec<u8>, row_id_enabled: bool, bucket_enabled: bool) ->
Self {
+ let next_offset = header.len() as u64;
+ Self {
+ row_id_enabled,
+ bucket_enabled,
+ header,
+ ranges: BTreeMap::new(),
+ dictionary: IndexMap::new(),
+ partition_ids: BTreeSet::new(),
+ bucket_pairs: BTreeSet::new(),
+ blocks: Vec::new(),
+ next_offset,
+ next_record: 0,
+ current: None,
+ entries_in_block: 0,
+ row_available: false,
+ partition_available: false,
+ bucket_available: false,
+ }
+ }
+
+ pub fn begin_block(&mut self, offset: u64, length: u64, records: u64) ->
Result<()> {
+ require(self.current.is_none() && offset == self.next_offset && length
> 0 && records > 0)?;
+ self.current = Some(ManifestSidecarBlock {
+ offset,
+ length,
+ first_record: self.next_record,
+ record_count: records,
+ });
+ self.entries_in_block = 0;
+ self.row_available = self.row_id_enabled;
+ self.partition_available = true;
+ self.bucket_available = self.bucket_enabled;
+ self.ranges.clear();
+ self.partition_ids.clear();
+ self.bucket_pairs.clear();
+ Ok(())
+ }
+
+ pub fn add(
+ &mut self,
+ first_row_id: Option<i64>,
+ row_count: i64,
+ partition: Option<&[u8]>,
+ bucket: Option<i32>,
+ total_buckets: Option<i32>,
+ ) -> Result<()> {
+ require(self.current.is_some())?;
+ self.entries_in_block = self
+ .entries_in_block
+ .checked_add(1)
+ .ok_or_else(invalid_sidecar)?;
+ self.add_partition(partition);
+ self.add_bucket(bucket, total_buckets);
+ self.add_row_range(first_row_id, row_count);
+ Ok(())
+ }
+
+ fn add_partition(&mut self, partition: Option<&[u8]>) {
+ if !self.partition_available {
+ return;
+ }
+ let Some(partition) = partition else {
+ self.partition_available = false;
+ self.partition_ids.clear();
+ return;
+ };
+ let id = if let Some(id) = self.dictionary.get_index_of(partition) {
+ id
+ } else {
+ let id = self.dictionary.len();
+ self.dictionary.insert(partition.to_vec(), id);
+ id
+ };
+ self.partition_ids.insert(id);
+ }
+
+ fn add_bucket(&mut self, bucket: Option<i32>, total_buckets: Option<i32>) {
+ if !self.bucket_available {
+ return;
+ }
+ let (Some(bucket), Some(total_buckets)) = (bucket, total_buckets) else
{
+ self.bucket_available = false;
+ self.bucket_pairs.clear();
+ return;
+ };
+ if bucket < 0 || total_buckets <= bucket {
+ self.bucket_available = false;
+ self.bucket_pairs.clear();
+ return;
+ }
+ self.bucket_pairs.insert((bucket, total_buckets));
+ }
+
+ fn add_row_range(&mut self, first_row_id: Option<i64>, row_count: i64) {
+ if !self.row_available {
+ return;
+ }
+ let Some(first_row_id) = first_row_id.filter(|first| *first >= 0) else
{
+ self.row_available = false;
+ self.ranges.clear();
+ return;
+ };
+ let Some(end) = row_count
+ .checked_sub(1)
+ .filter(|_| row_count > 0)
+ .and_then(|delta| first_row_id.checked_add(delta))
+ else {
+ self.row_available = false;
+ self.ranges.clear();
+ return;
+ };
+
+ let mut start = first_row_id as u64;
+ let mut end = end as u64;
+ if let Some((&before_start, &before_end)) =
self.ranges.range(..=start).next_back() {
+ if before_end.saturating_add(1) >= start {
+ start = before_start;
+ end = end.max(before_end);
+ self.ranges.remove(&before_start);
+ }
+ }
+ loop {
+ let next = self
+ .ranges
+ .range(start..)
+ .next()
+ .map(|(&next_start, &next_end)| (next_start, next_end));
+ let Some((next_start, next_end)) = next else {
+ break;
+ };
+ if next_start > end.saturating_add(1) {
+ break;
+ }
+ end = end.max(next_end);
+ self.ranges.remove(&next_start);
+ }
+ self.ranges.insert(start, end);
+ }
+
+ pub fn end_block(&mut self) -> Result<()> {
+ let block = self.current.ok_or_else(invalid_sidecar)?;
+ require(self.entries_in_block == block.record_count)?;
+ let partitions = if self.partition_available {
+ encode_deltas(
+ self.partition_ids.iter().map(|id| *id as u64),
+ self.partition_ids.len(),
+ 0,
+ false,
+ )?
+ } else {
+ Vec::new()
+ };
+ let row_ids = if self.row_available {
+ self.encode_row_ranges()?
+ } else {
+ Vec::new()
+ };
+ let buckets = if self.bucket_available {
+ self.encode_buckets()?
+ } else {
+ Vec::new()
+ };
+ self.blocks.push(IndexedBlock {
+ block,
+ partitions,
+ row_ids,
+ buckets,
+ });
+ self.next_offset = block
+ .offset
+ .checked_add(block.length)
+ .ok_or_else(invalid_sidecar)?;
+ self.next_record = block
+ .first_record
+ .checked_add(block.record_count)
+ .ok_or_else(invalid_sidecar)?;
+ self.ranges.clear();
+ self.partition_ids.clear();
+ self.bucket_pairs.clear();
+ self.current = None;
+ Ok(())
+ }
+
+ fn encode_row_ranges(&self) -> Result<Vec<u8>> {
+ let (&minimum, _) =
self.ranges.first_key_value().ok_or_else(invalid_sidecar)?;
+ let maximum = *self
+ .ranges
+ .last_key_value()
+ .map(|(_, end)| end)
+ .ok_or_else(invalid_sidecar)?;
+ let mut out = Vec::new();
+ out.extend_from_slice(&(minimum as i64).to_be_bytes());
+ out.extend_from_slice(&(maximum as i64).to_be_bytes());
+
+ let range_count = self.ranges.len();
+ let mut endpoints = Vec::with_capacity(range_count.saturating_sub(1) *
2);
+ for (index, (&start, &end)) in self.ranges.iter().enumerate() {
+ if index > 0 {
+ endpoints.push(start);
+ }
+ if index + 1 < range_count {
+ endpoints.push(end);
+ }
+ }
+ out.extend(encode_deltas(
+ endpoints,
+ range_count.saturating_sub(1) * 2,
+ minimum,
+ false,
+ )?);
+ Ok(out)
+ }
+
+ fn encode_buckets(&self) -> Result<Vec<u8>> {
+ let mut out = encode_deltas(
+ self.bucket_pairs.iter().map(|(bucket, _)| *bucket as u64),
+ self.bucket_pairs.len(),
+ 0,
+ false,
+ )?;
+ out.extend(encode_deltas(
+ self.bucket_pairs.iter().map(|(_, total)| *total as u64),
+ self.bucket_pairs.len(),
+ 0,
+ true,
+ )?);
+ Ok(out)
+ }
+
+ pub fn serialize(&self, file_size: u64, entry_count: u64) ->
Result<Vec<u8>> {
+ require(
+ self.current.is_none()
+ && self.next_offset == file_size
+ && self.next_record == entry_count,
+ )?;
+ let mut out = Vec::new();
+ out.extend_from_slice(MAGIC);
+ encode_varint(FORMAT_VERSION, &mut out)?;
+ encode_varint(self.header.len() as u64, &mut out)?;
+ out.extend_from_slice(&self.header);
+ encode_varint(self.dictionary.len() as u64, &mut out)?;
+ for partition in self.dictionary.keys() {
+ encode_varint(partition.len() as u64, &mut out)?;
+ out.extend_from_slice(partition);
+ }
+ encode_varint(self.blocks.len() as u64, &mut out)?;
+ for indexed in &self.blocks {
+ encode_varint(indexed.block.offset, &mut out)?;
+ encode_varint(indexed.block.length, &mut out)?;
+ encode_varint(indexed.block.record_count, &mut out)?;
+ write_payload(&mut out, &indexed.partitions)?;
+ write_payload(&mut out, &indexed.row_ids)?;
+ write_payload(&mut out, &indexed.buckets)?;
+ }
+ out.extend_from_slice(&crc32fast::hash(&out).to_be_bytes());
+ Ok(out)
+ }
+}
+
+/// Codec and I/O helpers for version-1 manifest sidecars.
+pub struct ManifestSidecar;
+
+impl ManifestSidecar {
+ pub fn path(manifest_path: &str) -> String {
+ format!("{manifest_path}{MANIFEST_SIDECAR_SUFFIX}")
+ }
+
+ /// Return the explicitly published sidecar name. Readers never probe a
derived path.
+ pub fn file_name(manifest: &ManifestFileMeta) -> Option<&str> {
+ manifest
+ .extra_files()?
+ .iter()
+ .find(|name| name.ends_with(MANIFEST_SIDECAR_SUFFIX))
+ .map(String::as_str)
+ }
+
+ /// Build a sidecar from final OCF bytes and their corresponding manifest
entries.
+ pub fn build(
+ avro_bytes: &[u8],
+ entries: &[ManifestEntry],
+ row_id_enabled: bool,
+ bucket_enabled: bool,
+ ) -> Result<Vec<u8>> {
+ let (header, mut blocks) = parse_ocf_streaming(avro_bytes)?;
+ let mut builder = ManifestSidecarBuilder::new(
+ avro_bytes[..header.encoded_len].to_vec(),
+ row_id_enabled,
+ bucket_enabled,
+ );
+ let mut position = 0usize;
+ while let Some(block) = blocks.next_block()? {
+ let end = position
+ .checked_add(block.object_count)
+ .ok_or_else(invalid_sidecar)?;
+ require(end <= entries.len())?;
+ builder.begin_block(
+ block.encoded_offset as u64,
+ block.encoded_len as u64,
+ block.object_count as u64,
+ )?;
+ for entry in &entries[position..end] {
+ let partition = if entry.partition().is_empty() ||
entry.partition() == [0, 0, 0, 0]
+ {
+ crate::spec::EMPTY_SERIALIZED_ROW.as_slice()
+ } else {
+ entry.partition()
+ };
+ builder.add(
+ if row_id_enabled {
+ entry.file().first_row_id
+ } else {
+ None
+ },
+ if row_id_enabled {
+ entry.file().row_count
+ } else {
+ 0
+ },
+ Some(partition),
+ bucket_enabled.then(|| entry.bucket()),
+ bucket_enabled.then(|| entry.total_buckets()),
+ )?;
+ }
+ builder.end_block()?;
+ position = end;
+ }
+ require(position == entries.len())?;
+ builder.serialize(avro_bytes.len() as u64, entries.len() as u64)
+ }
+
+ /// Validate a sidecar and select blocks using row-ID ranges.
+ pub fn select(
+ data: &[u8],
+ manifest: &ManifestFileMeta,
+ row_ranges: Option<&[RowRange]>,
+ ) -> Result<ManifestSidecarSelection> {
+ let query = row_ranges.map(RowRangeQuery::new);
+ let row_filter = |start, end| {
+ query
+ .as_ref()
+ .is_some_and(|query| query.intersects(start, end))
+ };
+ select_with_filters(
+ data,
+ manifest,
+ query
+ .as_ref()
+ .map(|_| &row_filter as &(dyn Fn(i64, i64) -> bool + Sync)),
+ None,
+ None,
+ None,
+ )
+ }
+
+ /// Read and validate the explicitly referenced sidecar. Any I/O or format
error
+ /// returns `None`, allowing the caller to fall back to a full manifest
read.
+ pub async fn read(
+ file_io: &FileIO,
+ manifest_path: &str,
+ manifest: &ManifestFileMeta,
+ row_ranges: Option<&[RowRange]>,
+ ) -> Option<ManifestSidecarSelection> {
+ let query = row_ranges.map(RowRangeQuery::new);
+ let row_filter = |start, end| {
+ query
+ .as_ref()
+ .is_some_and(|query| query.intersects(start, end))
+ };
+ Self::read_with_filters(
+ file_io,
+ manifest_path,
+ manifest,
+ query
+ .as_ref()
+ .map(|_| &row_filter as &(dyn Fn(i64, i64) -> bool + Sync)),
+ None,
+ None,
+ None,
+ )
+ .await
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub(crate) async fn read_with_filters(
+ file_io: &FileIO,
+ manifest_path: &str,
+ manifest: &ManifestFileMeta,
+ row_filter: Option<&RowBlockFilter<'_>>,
+ partition_filter: Option<&mut PartitionBlockFilter<'_>>,
+ partition_arity: Option<usize>,
+ bucket_filter: Option<&mut BucketBlockFilter<'_>>,
+ ) -> Option<ManifestSidecarSelection> {
+ let name = Self::file_name(manifest)?;
+ let sidecar_path = sibling_path(manifest_path, name);
+ let result = async {
+ let input = file_io.new_input(&sidecar_path)?;
+ let data = input.read().await?;
+ select_with_filters(
+ &data,
+ manifest,
+ row_filter,
+ partition_filter,
+ partition_arity,
+ bucket_filter,
+ )
+ }
+ .await;
+ match result {
+ Ok(selection) => Some(selection),
+ Err(error) => {
+ log::debug!(
+ "Cannot use manifest sidecar for {}; reading full
manifest: {}",
+ manifest_path,
+ error
+ );
+ None
+ }
+ }
+ }
+
+ /// Read an OCF stream composed of the original header and selected
complete blocks.
+ pub async fn read_selected_bytes(
+ file_io: &FileIO,
+ manifest_path: &str,
+ selection: &ManifestSidecarSelection,
+ ) -> Result<Bytes> {
+ if selection.blocks.is_empty() {
+ return Ok(selection.header.clone());
+ }
+ let reader = file_io.new_input(manifest_path)?.reader().await?;
+ let total_block_bytes = selection.blocks.iter().try_fold(0usize,
|total, block| {
+ let length = usize::try_from(block.length).map_err(|_|
invalid_sidecar())?;
+ total.checked_add(length).ok_or_else(invalid_sidecar)
+ })?;
+ let mut out = Vec::with_capacity(
+ selection
+ .header
+ .len()
+ .checked_add(total_block_bytes)
+ .ok_or_else(invalid_sidecar)?,
+ );
+ out.extend_from_slice(&selection.header);
+
+ let mut block_index = 0usize;
+ while block_index < selection.blocks.len() {
+ let first = selection.blocks[block_index];
+ let mut span_end = first
+ .offset
+ .checked_add(first.length)
+ .ok_or_else(invalid_sidecar)?;
+ block_index += 1;
+ while block_index < selection.blocks.len() {
+ let next = selection.blocks[block_index];
+ let Some(next_end) = next.offset.checked_add(next.length) else
{
+ return Err(invalid_sidecar());
+ };
+ if next.offset != span_end
+ || next_end.saturating_sub(first.offset) >
BLOCK_READ_BUFFER_BYTES
+ {
+ break;
+ }
+ span_end = next_end;
+ block_index += 1;
+ }
+
+ let mut position = first.offset;
+ while position < span_end {
+ let end = span_end.min(position + BLOCK_READ_BUFFER_BYTES);
+ let bytes = reader
+ .read(Range {
+ start: position,
+ end,
+ })
+ .await?;
+ require(bytes.len() as u64 == end - position)?;
+ out.extend_from_slice(&bytes);
+ position = end;
+ }
+ }
+ Ok(Bytes::from(out))
+ }
+}
+
+struct RowRangeQuery {
+ starts: Vec<i64>,
+ ends: Vec<i64>,
+}
+
+impl RowRangeQuery {
+ fn new(ranges: &[RowRange]) -> Self {
+ let ranges = crate::table::merge_row_ranges(ranges.to_vec());
+ Self {
+ starts: ranges.iter().map(RowRange::from).collect(),
+ ends: ranges.iter().map(RowRange::to).collect(),
+ }
+ }
+}
+
+impl RowRangeQuery {
+ fn intersects(&self, start: i64, end: i64) -> bool {
+ let candidate = self.ends.partition_point(|candidate| *candidate <
start);
+ candidate < self.starts.len() && self.starts[candidate] <= end
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn select_with_filters(
+ data: &[u8],
+ manifest: &ManifestFileMeta,
+ row_filter: Option<&RowBlockFilter<'_>>,
+ mut partition_filter: Option<&mut PartitionBlockFilter<'_>>,
+ partition_arity: Option<usize>,
+ mut bucket_filter: Option<&mut BucketBlockFilter<'_>>,
+) -> Result<ManifestSidecarSelection> {
+ require(data.len() >= MIN_HEADER_BYTES + CHECKSUM_BYTES)?;
+ let payload_end = data.len() - CHECKSUM_BYTES;
+ let expected_checksum =
u32::from_be_bytes(data[payload_end..].try_into().unwrap());
+ require(crc32fast::hash(&data[..payload_end]) == expected_checksum)?;
+
+ let mut input = Cursor::new(&data[..payload_end]);
+ require(input.take(4)? == MAGIC)?;
+ require(input.varint(MAX_INT)? == FORMAT_VERSION)?;
+ let entries = manifest
+ .num_added_files()
+ .checked_add(manifest.num_deleted_files())
+ .filter(|entries| *entries >= 0)
+ .ok_or_else(invalid_sidecar)? as u64;
+ let manifest_size = u64::try_from(manifest.file_size()).map_err(|_|
invalid_sidecar())?;
+
+ let header_length = usize::try_from(input.varint(MAX_INT)?).map_err(|_|
invalid_sidecar())?;
+ require(
+ header_length >= 21
+ && header_length <= input.remaining().saturating_sub(2)
+ && header_length as u64 <= manifest_size,
+ )?;
+ let header = input.take(header_length)?;
+ require(header.starts_with(b"Obj\x01"))?;
+
+ let partition_count = usize::try_from(input.varint(MAX_INT)?).map_err(|_|
invalid_sidecar())?;
+ require(partition_count <= input.remaining() / 13)?;
+ let mut partition_matches = partition_filter
+ .as_ref()
+ .map(|_| Vec::with_capacity(partition_count));
+ let mut unique = HashSet::with_capacity(partition_count);
+ for _ in 0..partition_count {
+ let length = usize::try_from(input.varint(MAX_INT)?).map_err(|_|
invalid_sidecar())?;
+ require(length >= 12 && length <= input.remaining())?;
+ let partition = input.take(length)?;
+ let arity = i32::from_be_bytes(partition[..4].try_into().unwrap());
+ require(arity >= 0)?;
+ let minimum_length = 4u64
+ .checked_add(((arity as u64 + 71) / 64) * 8)
+ .and_then(|size| size.checked_add(arity as u64 * 8))
+ .ok_or_else(invalid_sidecar)?;
+ require(minimum_length <= length as u64)?;
+ require(partition_arity.is_none_or(|expected| expected == arity as
usize))?;
+ require(unique.insert(partition))?;
+ if let (Some(matches), Some(filter)) =
+ (partition_matches.as_mut(), partition_filter.as_mut())
+ {
+ matches.push((*filter)(partition));
+ }
+ }
+
+ let block_count = usize::try_from(input.varint(MAX_INT)?).map_err(|_|
invalid_sidecar())?;
+ require(block_count <= input.remaining() / MIN_BLOCK_BYTES)?;
+ let mut next_offset = header_length as u64;
+ let mut first_record = 0u64;
+ let mut selected = Vec::new();
+ for _ in 0..block_count {
+ require(input.remaining() >= MIN_BLOCK_BYTES)?;
+ let offset = input.varint(MAX_LONG)?;
+ let length = input.varint(MAX_LONG)?;
+ let records = input.varint(MAX_LONG)?;
+ require(
+ offset == next_offset
+ && length > 0
+ && offset
+ .checked_add(length)
+ .is_some_and(|end| end <= manifest_size)
+ && records > 0
+ && first_record
+ .checked_add(records)
+ .is_some_and(|end| end <= entries),
+ )?;
+ let partition_payload = payload(&mut input)?;
+ let row_payload = payload(&mut input)?;
+ let bucket_payload = payload(&mut input)?;
+
+ let partition_values = if let Some(payload) = partition_payload {
+ let values =
+ DeltaReader::new(payload, 0, partition_count.saturating_sub(1)
as u64, false)?;
+ require(
+ values.count > 0
+ && values.count as u64 <= records
+ && values.count <= partition_count,
+ )?;
+ Some(values)
+ } else {
+ None
+ };
+
+ let row_values = if let Some(payload) = row_payload {
+ let mut cursor = Cursor::new(payload);
+ require(cursor.remaining() >= 17)?;
+ let minimum = cursor.long()?;
+ let maximum = cursor.long()?;
+ require(minimum >= 0 && minimum <= maximum)?;
+ let values = DeltaReader::from_cursor(cursor, minimum as u64,
maximum as u64, false)?;
+ require(values.count % 2 == 0 && values.count as u64 / 2 <
records)?;
+ require(values.count != 0 || values.cursor.remaining() == 0)?;
+ Some((minimum, maximum, values))
+ } else {
+ None
+ };
+
+ if let Some(payload) = bucket_payload {
+ let mut prefix = Cursor::new(payload);
+ let pairs = prefix.varint(MAX_INT)?;
+ require(
+ pairs > 0
+ && pairs <= records
+ && pairs
+ .checked_mul(2)
+ .and_then(|value| value.checked_add(1))
+ .is_some_and(|minimum| minimum <= prefix.remaining()
as u64),
+ )?;
+ }
+
+ let block = ManifestSidecarBlock {
+ offset,
+ length,
+ first_record,
+ record_count: records,
+ };
+ next_offset = offset.checked_add(length).ok_or_else(invalid_sidecar)?;
+ first_record = first_record
+ .checked_add(records)
+ .ok_or_else(invalid_sidecar)?;
+
+ if let (Some(filter), Some((minimum, maximum, mut endpoints))) =
(row_filter, row_values) {
+ if !filter(minimum, maximum) {
+ continue;
+ }
+ let range_count = endpoints.count / 2 + 1;
+ let mut hit = range_count == 1;
+ let mut start = minimum;
+ for range in 0..range_count {
+ if hit {
+ break;
+ }
+ let end = if range + 1 == range_count {
+ maximum
+ } else {
+ endpoints.next()? as i64
+ };
+ require(end >= start)?;
+ hit = filter(start, end);
+ if !hit && range + 1 < range_count {
+ start = endpoints.next()? as i64;
+ require(start > end)?;
+ }
+ require(endpoints.remaining > 0 ||
endpoints.cursor.remaining() == 0)?;
+ }
+ if !hit {
+ continue;
+ }
+ }
+
+ if partition_filter.is_some() {
+ if let Some(mut ids) = partition_values {
+ let mut hit = false;
+ let mut previous = None;
+ while !hit && ids.remaining > 0 {
+ let id = ids.next()?;
+ require(previous.is_none_or(|previous| id > previous))?;
+ require(ids.remaining > 0 || ids.cursor.remaining() == 0)?;
+ previous = Some(id);
+ hit = partition_matches
+ .as_ref()
+ .and_then(|matches| matches.get(id as usize))
+ .copied()
+ .ok_or_else(invalid_sidecar)?;
+ }
+ if !hit {
+ continue;
+ }
+ }
+ }
+
+ if let (Some(filter), Some(payload)) = (bucket_filter.as_mut(),
bucket_payload) {
+ let mut directory = DeltaReader::new(payload, 0, MAX_INT, false)?;
+ while directory.remaining > 0 {
+ directory.next()?;
+ }
+ let totals_offset = directory.cursor.position();
+ let mut buckets = DeltaReader::new(&payload[..totals_offset], 0,
MAX_INT, false)?;
+ let mut totals = DeltaReader::new(&payload[totals_offset..], 0,
MAX_INT, true)?;
+ require(totals.count == buckets.count)?;
+ let mut hit = false;
+ let mut previous: Option<(i32, i32)> = None;
+ while !hit && buckets.remaining > 0 {
+ let bucket = i32::try_from(buckets.next()?).map_err(|_|
invalid_sidecar())?;
+ let total = i32::try_from(totals.next()?).map_err(|_|
invalid_sidecar())?;
+ require(total > bucket)?;
+ require(previous.is_none_or(|previous| (bucket, total) >
previous))?;
+ require(
+ buckets.remaining > 0
+ || (buckets.cursor.remaining() == 0 &&
totals.cursor.remaining() == 0),
+ )?;
+ previous = Some((bucket, total));
+ hit = (*filter)(bucket, total);
+ }
+ if !hit {
+ continue;
+ }
+ }
+ selected.push(block);
+ }
+ require(input.remaining() == 0 && next_offset == manifest_size &&
first_record == entries)?;
+ Ok(ManifestSidecarSelection {
+ header: Bytes::copy_from_slice(header),
+ blocks: selected,
+ })
+}
+
+fn sibling_path(path: &str, name: &str) -> String {
+ path.rsplit_once('/')
+ .map(|(parent, _)| format!("{parent}/{name}"))
+ .unwrap_or_else(|| name.to_string())
+}
+
+fn write_payload(out: &mut Vec<u8>, payload: &[u8]) -> Result<()> {
+ if payload.is_empty() {
+ out.push(0);
+ } else {
+ out.push(1);
+ encode_varint(payload.len() as u64, out)?;
+ out.extend_from_slice(payload);
+ }
+ Ok(())
+}
+
+fn payload<'a>(input: &mut Cursor<'a>) -> Result<Option<&'a [u8]>> {
+ let encoding = input.byte()?;
+ if encoding == 0 {
+ return Ok(None);
+ }
+ let length = usize::try_from(input.varint(MAX_INT)?).map_err(|_|
invalid_sidecar())?;
+ let payload = input.take(length)?;
+ Ok((encoding == 1).then_some(payload))
+}
+
+fn encode_deltas<I>(values: I, count: usize, mut base: u64, signed: bool) ->
Result<Vec<u8>>
+where
+ I: IntoIterator<Item = u64>,
+{
+ require(count <= i32::MAX as usize)?;
+ let mut out = Vec::new();
+ encode_varint(count as u64, &mut out)?;
+ let mut actual_count = 0usize;
+ for value in values {
+ require(value <= if signed { MAX_INT } else { MAX_LONG })?;
+ let encoded = if signed {
+ let delta = value as i64 - base as i64;
+ ((delta << 1) ^ (delta >> 63)) as u64
+ } else {
+ value.checked_sub(base).ok_or_else(invalid_sidecar)?
+ };
+ encode_varint(encoded, &mut out)?;
+ base = value;
+ actual_count += 1;
+ }
+ require(actual_count == count)?;
+ Ok(out)
+}
+
+fn encode_varint(mut value: u64, out: &mut Vec<u8>) -> Result<()> {
+ require(value <= MAX_LONG)?;
+ while value >= 0x80 {
+ out.push((value as u8 & 0x7f) | 0x80);
+ value >>= 7;
+ }
+ out.push(value as u8);
+ Ok(())
+}
+
+#[derive(Clone, Copy)]
+struct Cursor<'a> {
+ data: &'a [u8],
+ position: usize,
+}
+
+impl<'a> Cursor<'a> {
+ fn new(data: &'a [u8]) -> Self {
+ Self { data, position: 0 }
+ }
+
+ fn position(&self) -> usize {
+ self.position
+ }
+
+ fn remaining(&self) -> usize {
+ self.data.len() - self.position
+ }
+
+ fn take(&mut self, count: usize) -> Result<&'a [u8]> {
+ let end = self
+ .position
+ .checked_add(count)
+ .filter(|end| *end <= self.data.len())
+ .ok_or_else(invalid_sidecar)?;
+ let result = &self.data[self.position..end];
+ self.position = end;
+ Ok(result)
+ }
+
+ fn byte(&mut self) -> Result<u8> {
+ Ok(self.take(1)?[0])
+ }
+
+ fn long(&mut self) -> Result<i64> {
+ Ok(i64::from_be_bytes(self.take(8)?.try_into().unwrap()))
+ }
+
+ fn varint(&mut self, maximum: u64) -> Result<u64> {
+ let mut value = 0u64;
+ for index in 0..9 {
+ let byte = self.byte()?;
+ value |= u64::from(byte & 0x7f) << (index * 7);
+ if byte & 0x80 == 0 {
+ require((index == 0 || byte != 0) && value <= maximum)?;
+ return Ok(value);
+ }
+ }
+ Err(invalid_sidecar())
+ }
+}
+
+struct DeltaReader<'a> {
+ cursor: Cursor<'a>,
+ value: u64,
+ maximum: u64,
+ signed: bool,
+ count: usize,
+ remaining: usize,
+}
+
+impl<'a> DeltaReader<'a> {
+ fn new(data: &'a [u8], base: u64, maximum: u64, signed: bool) ->
Result<Self> {
+ Self::from_cursor(Cursor::new(data), base, maximum, signed)
+ }
+
+ fn from_cursor(mut cursor: Cursor<'a>, base: u64, maximum: u64, signed:
bool) -> Result<Self> {
+ require(base <= maximum && (!signed || maximum <= MAX_INT))?;
+ let count = usize::try_from(cursor.varint(MAX_INT)?).map_err(|_|
invalid_sidecar())?;
+ require(count <= cursor.remaining())?;
+ Ok(Self {
+ cursor,
+ value: base,
+ maximum,
+ signed,
+ count,
+ remaining: count,
+ })
+ }
+
+ fn next(&mut self) -> Result<u64> {
+ require(self.remaining > 0)?;
+ let encoded = self
+ .cursor
+ .varint(if self.signed { MAX_INT * 2 } else { MAX_LONG })?;
+ let next = if self.signed {
+ let delta = ((encoded >> 1) as i64) ^ -((encoded & 1) as i64);
+ let value = i64::try_from(self.value).map_err(|_|
invalid_sidecar())?;
+ value
+ .checked_add(delta)
+ .filter(|value| *value >= 0 && *value as u64 <= self.maximum)
+ .ok_or_else(invalid_sidecar)? as u64
+ } else {
+ self.value
+ .checked_add(encoded)
+ .filter(|value| *value <= self.maximum)
+ .ok_or_else(invalid_sidecar)?
+ };
+ self.value = next;
+ self.remaining -= 1;
+ Ok(next)
+ }
+}
+
+fn require(valid: bool) -> Result<()> {
+ if valid {
+ Ok(())
+ } else {
+ Err(invalid_sidecar())
+ }
+}
+
+fn invalid_sidecar() -> Error {
+ Error::DataInvalid {
+ message: "Invalid, unsupported or mismatched manifest
sidecar".to_string(),
+ source: None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::manifest_common::FileKind;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{BinaryRowBuilder, DataFileMeta, MANIFEST_ENTRY_SCHEMA};
+ use apache_avro::{to_value, Schema};
+
+ fn header() -> Vec<u8> {
+ let mut header =
+
b"Obj\x01\x04\x14avro.codec\x08null\x16avro.schema\x0c\"long\"\x00".to_vec();
+ header.extend_from_slice(&[0; 16]);
+ header
+ }
+
+ fn meta(size: u64, entries: u64) -> ManifestFileMeta {
+ ManifestFileMeta::new(
+ "manifest-golden".to_string(),
+ size as i64,
+ entries as i64,
+ 0,
+ BinaryTableStats::empty(),
+ 0,
+ )
+ }
+
+ fn test_sidecar() -> (Vec<u8>, ManifestFileMeta) {
+ let header = header();
+ let mut builder = ManifestSidecarBuilder::new(header.clone(), true,
false);
+ for (offset, length, ranges) in [
+ (0, 100, vec![(0, 10), (5, 5), (20, 5)]),
+ (
+ 100,
+ 200,
+ vec![((1_i64 << 32) - 2, 5), (8_254_058_425_445, 1)],
+ ),
+ (300, 100, vec![(20, 5), (i64::MAX, 1)]),
+ ] {
+ builder
+ .begin_block(header.len() as u64 + offset, length,
ranges.len() as u64)
+ .unwrap();
+ for (first, count) in ranges {
+ builder.add(Some(first), count, None, None, None).unwrap();
+ }
+ builder.end_block().unwrap();
+ }
+ let size = header.len() as u64 + 400;
+ (builder.serialize(size, 7).unwrap(), meta(size, 7))
+ }
+
+ fn partition(p: i32, q: Option<&str>) -> Vec<u8> {
+ let mut builder = BinaryRowBuilder::new(2);
+ builder.write_int(0, p);
+ if let Some(q) = q {
+ builder.write_string_inline(1, q);
+ } else {
+ builder.set_null_at(1);
+ }
+ builder.build_serialized()
+ }
+
+ fn manifest_entry(first_row_id: i64, bucket: i32, total_buckets: i32) ->
ManifestEntry {
+ ManifestEntry::new(
+ FileKind::Add,
+ crate::spec::EMPTY_SERIALIZED_ROW.clone(),
+ bucket,
+ total_buckets,
+ DataFileMeta {
+ file_name: format!("data-{first_row_id}.parquet"),
+ file_size: 100,
+ row_count: 10,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 0,
+ level: 0,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: Some(first_row_id),
+ write_cols: None,
+ column_max_sequence_numbers: None,
+ },
+ 2,
+ )
+ }
+
+ #[test]
+ fn java_generated_v1_golden_is_bidirectionally_compatible() {
+ // Generated by ManifestSidecarTest.testSidecar() in Apache Paimon Java
+ // commit d3e178e29726b5f6ef1ad6d989dc7de684a09374. Keep this as an
opaque
+ // Java-writer artifact; regenerate it instead of editing it by hand.
+ let golden = include_bytes!("goldens/manifest_sidecar_v1.bin");
+ let header = header();
+ let a = partition(7, Some("left"));
+ let b = partition(9, None);
+ let mut builder = ManifestSidecarBuilder::new(header.clone(), true,
true);
+ for (offset, length, entries) in [
+ (
+ 0,
+ 100,
+ vec![(0, 10, &a, 1, 4), (5, 5, &a, 1, 4), (20, 5, &b, 1, 8)],
+ ),
+ (
+ 100,
+ 200,
+ vec![
+ ((1_i64 << 32) - 2, 5, &b, 2, 4),
+ (8_254_058_425_445, 1, &a, 2, 8),
+ ],
+ ),
+ (300, 100, vec![(20, 5, &a, 0, 1), (i64::MAX, 1, &b, 3, 4)]),
+ ] {
+ builder
+ .begin_block(header.len() as u64 + offset, length,
entries.len() as u64)
+ .unwrap();
+ for (first, count, partition, bucket, total_buckets) in entries {
+ builder
+ .add(
+ Some(first),
+ count,
+ Some(partition),
+ Some(bucket),
+ Some(total_buckets),
+ )
+ .unwrap();
+ }
+ builder.end_block().unwrap();
+ }
+ let sidecar = builder.serialize(header.len() as u64 + 400, 7).unwrap();
+ assert_eq!(
+ sidecar.as_slice(),
+ golden,
+ "Rust V1 output differs from the Java-generated golden fixture"
+ );
+ assert_eq!(
+ u32::from_be_bytes(golden[golden.len() - 4..].try_into().unwrap()),
+ 0xdf82_cd30
+ );
+ let manifest = meta(header.len() as u64 + 400, 7);
+ assert_eq!(
+ ManifestSidecar::select(golden, &manifest,
Some(&[RowRange::new(20, 20)]),)
+ .unwrap()
+ .blocks()
+ .iter()
+ .map(ManifestSidecarBlock::first_record)
+ .collect::<Vec<_>>(),
+ vec![0, 5]
+ );
+
+ let mut partition_miss = |bytes: &[u8]| bytes == b"not-a-partition";
+ assert!(select_with_filters(
+ golden,
+ &manifest,
+ None,
+ Some(&mut partition_miss),
+ Some(2),
+ None,
+ )
+ .unwrap()
+ .blocks()
+ .is_empty());
+
+ let mut bucket = |bucket: i32, total: i32| bucket == 2 && total == 8;
+ assert_eq!(
+ select_with_filters(golden, &manifest, None, None, None, Some(&mut
bucket))
+ .unwrap()
+ .blocks()
+ .iter()
+ .map(ManifestSidecarBlock::first_record)
+ .collect::<Vec<_>>(),
+ vec![3]
+ );
+ }
+
+ #[test]
+ fn build_select_and_decode_real_avro_blocks() {
+ let entries = vec![
+ manifest_entry(0, 0, 4),
+ manifest_entry(100, 1, 4),
+ manifest_entry(200, 2, 4),
+ ];
+ let schema = Schema::parse_str(MANIFEST_ENTRY_SCHEMA).unwrap();
+ let mut writer = crate::spec::new_avro_writer(&schema, "zstd",
1).unwrap();
+ for entry in &entries {
+ let value = to_value(entry).unwrap().resolve(&schema).unwrap();
+ writer.append(value).unwrap();
+ }
+ let avro = writer.into_inner().unwrap();
+ let sidecar = ManifestSidecar::build(&avro, &entries, true,
true).unwrap();
+ let manifest = meta(avro.len() as u64, entries.len() as u64);
+ let selection =
+ ManifestSidecar::select(&sidecar, &manifest,
Some(&[RowRange::new(105, 105)])).unwrap();
+ assert_eq!(selection.blocks().len(), 1);
+
+ let mut selected_avro = selection.header().to_vec();
+ for block in selection.blocks() {
+ selected_avro.extend_from_slice(
+ &avro[block.offset() as usize..(block.offset() +
block.length()) as usize],
+ );
+ }
+ let decoded =
+
crate::spec::avro::from_avro_bytes_fast::<ManifestEntry>(&selected_avro).unwrap();
+ assert_eq!(decoded, vec![entries[1].clone()]);
+ }
+
+ #[test]
+ fn row_id_selection_preserves_exact_gaps_and_ordinals() {
+ let (sidecar, meta) = test_sidecar();
+ for point in [
+ 0,
+ 9,
+ 20,
+ 24,
+ (1_i64 << 32) - 2,
+ 1_i64 << 32,
+ (1_i64 << 32) + 2,
+ 8_254_058_425_445,
+ i64::MAX,
+ ] {
+ let ranges = [RowRange::new(point, point)];
+ assert!(!ManifestSidecar::select(&sidecar, &meta, Some(&ranges))
+ .unwrap()
+ .blocks()
+ .is_empty());
+ }
+ for point in [
+ 10,
+ 19,
+ 25,
+ (1_i64 << 32) - 3,
+ (1_i64 << 32) + 3,
+ 8_254_058_425_444,
+ i64::MAX - 1,
+ ] {
+ let ranges = [RowRange::new(point, point)];
+ assert!(ManifestSidecar::select(&sidecar, &meta, Some(&ranges))
+ .unwrap()
+ .blocks()
+ .is_empty());
+ }
+
+ let selected =
+ ManifestSidecar::select(&sidecar, &meta, Some(&[RowRange::new(20,
20)])).unwrap();
+ assert_eq!(
+ selected
+ .blocks()
+ .iter()
+ .map(ManifestSidecarBlock::first_record)
+ .collect::<Vec<_>>(),
+ vec![0, 5]
+ );
+ }
+
+ #[test]
+ fn corrupt_and_mismatched_sidecars_are_rejected() {
+ let (sidecar, manifest) = test_sidecar();
+ for length in 0..sidecar.len() {
+ assert!(ManifestSidecar::select(&sidecar[..length], &manifest,
None).is_err());
+ }
+ let mut corrupt = sidecar.clone();
+ corrupt[4] = 2;
+ let end = corrupt.len() - 4;
+ let crc = crc32fast::hash(&corrupt[..end]);
+ corrupt[end..].copy_from_slice(&crc.to_be_bytes());
+ assert!(ManifestSidecar::select(&corrupt, &manifest, None).is_err());
+
+ let wrong_size = meta(manifest.file_size() as u64 + 1, 7);
+ assert!(ManifestSidecar::select(&sidecar, &wrong_size, None).is_err());
+ }
+
+ #[test]
+ fn unavailable_row_coverage_fails_open() {
+ let header = header();
+ for (first, count) in [(None, 1), (Some(-1), 1), (Some(10), 0)] {
+ let mut builder = ManifestSidecarBuilder::new(header.clone(),
true, false);
+ builder.begin_block(header.len() as u64, 100, 1).unwrap();
+ builder.add(first, count, None, None, None).unwrap();
+ builder.end_block().unwrap();
+ let bytes = builder.serialize(header.len() as u64 + 100,
1).unwrap();
+ assert_eq!(
+ ManifestSidecar::select(
+ &bytes,
+ &meta(header.len() as u64 + 100, 1),
+ Some(&[RowRange::new(1_000, 1_000)]),
+ )
+ .unwrap()
+ .blocks()
+ .len(),
+ 1
+ );
+ }
+ }
+
+ #[test]
+ fn partition_and_bucket_dimensions_are_independent() {
+ let header = header();
+ let p0 = crate::spec::EMPTY_SERIALIZED_ROW.clone();
+ let p1 = {
+ let mut bytes = p0.clone();
+ bytes.extend_from_slice(b"distinct");
+ bytes
+ };
+ let mut builder = ManifestSidecarBuilder::new(header.clone(), false,
true);
+ builder.begin_block(header.len() as u64, 100, 2).unwrap();
+ builder.add(None, 0, Some(&p0), Some(1), Some(4)).unwrap();
+ builder.add(None, 0, Some(&p1), Some(2), Some(8)).unwrap();
+ builder.end_block().unwrap();
+ let bytes = builder.serialize(header.len() as u64 + 100, 2).unwrap();
+ let mut partition = |bytes: &[u8]| bytes == p0;
+ let mut bucket = |bucket: i32, total: i32| bucket == 1 && total == 4;
+ let selected = select_with_filters(
+ &bytes,
+ &meta(header.len() as u64 + 100, 2),
+ None,
+ Some(&mut partition),
+ None,
+ Some(&mut bucket),
+ )
+ .unwrap();
+ assert_eq!(selected.blocks().len(), 1);
+
+ let mut bucket_miss = |bucket: i32, total: i32| bucket == 7 && total
== 8;
+ assert!(select_with_filters(
+ &bytes,
+ &meta(header.len() as u64 + 100, 2),
+ None,
+ None,
+ None,
+ Some(&mut bucket_miss),
+ )
+ .unwrap()
+ .blocks()
+ .is_empty());
+ }
+
+ #[tokio::test]
+ async fn read_falls_back_on_corruption_and_selected_reads_skip_gaps() {
+ let (sidecar, manifest) = test_sidecar();
+ let manifest = manifest.with_extra_files(Some(vec![format!(
+ "manifest-golden{MANIFEST_SIDECAR_SUFFIX}"
+ )]));
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let manifest_path = "memory:/manifest/manifest-golden";
+ let sidecar_path = ManifestSidecar::path(manifest_path);
+ file_io
+ .new_output(&sidecar_path)
+ .unwrap()
+ .write(Bytes::from(sidecar.clone()))
+ .await
+ .unwrap();
+
+ let selection = ManifestSidecar::read(
+ &file_io,
+ manifest_path,
+ &manifest,
+ Some(&[RowRange::new(20, 20)]),
+ )
+ .await
+ .unwrap();
+ let mut avro = header();
+ avro.extend((0..400).map(|value| value as u8));
+ file_io
+ .new_output(manifest_path)
+ .unwrap()
+ .write(Bytes::from(avro.clone()))
+ .await
+ .unwrap();
+ let selected = ManifestSidecar::read_selected_bytes(&file_io,
manifest_path, &selection)
+ .await
+ .unwrap();
+ let mut expected = header();
+ expected.extend_from_slice(&avro[header().len()..header().len() +
100]);
+ expected.extend_from_slice(&avro[header().len() + 300..header().len()
+ 400]);
+ assert_eq!(selected.as_ref(), expected);
+
+ let mut corrupt = sidecar;
+ corrupt[0] ^= 1;
+ file_io
+ .new_output(&sidecar_path)
+ .unwrap()
+ .write(Bytes::from(corrupt))
+ .await
+ .unwrap();
+ assert!(
+ ManifestSidecar::read(&file_io, manifest_path, &manifest, None)
+ .await
+ .is_none()
+ );
+ }
+}
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 3a35fd20..f1da81a4 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -76,6 +76,11 @@ pub use index_manifest::{IndexManifest, IndexManifestEntry};
mod manifest;
pub(crate) use manifest::merge_active_entries;
pub use manifest::Manifest;
+mod manifest_sidecar;
+pub use manifest_sidecar::{
+ ManifestSidecar, ManifestSidecarBlock, ManifestSidecarBuilder,
ManifestSidecarSelection,
+ MANIFEST_SIDECAR_SUFFIX,
+};
mod manifest_common;
pub use manifest_common::FileKind;
mod manifest_entry;
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 676d5358..a77267a7 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -116,6 +116,7 @@ pub struct TableCommit {
manifest_compression: String,
manifest_target_size: i64,
manifest_merge_min_count: usize,
+ manifest_sidecar_enabled: bool,
row_tracking_enabled: bool,
data_evolution_enabled: bool,
partition_default_name: String,
@@ -140,6 +141,7 @@ impl TableCommit {
let manifest_compression =
core_options.manifest_compression().to_string();
let manifest_target_size = core_options.manifest_target_size();
let manifest_merge_min_count = core_options.manifest_merge_min_count();
+ let manifest_sidecar_enabled = core_options.manifest_sidecar_enabled();
let row_tracking_enabled = core_options.row_tracking_enabled();
let data_evolution_enabled = core_options.data_evolution_enabled();
let partition_default_name =
core_options.partition_default_name().to_string();
@@ -156,6 +158,7 @@ impl TableCommit {
manifest_compression,
manifest_target_size,
manifest_merge_min_count,
+ manifest_sidecar_enabled,
row_tracking_enabled,
data_evolution_enabled,
partition_default_name,
@@ -1214,8 +1217,34 @@ impl TableCommit {
bytes: Vec<u8>,
) -> Result<ManifestFileMeta> {
let file_size = bytes.len() as i64;
+ let sidecar = if self.manifest_sidecar_enabled {
+ Some(crate::spec::ManifestSidecar::build(
+ &bytes,
+ entries,
+ self.data_evolution_enabled,
+ self.total_buckets != -1,
+ )?)
+ } else {
+ None
+ };
let output = file_io.new_output(path)?;
output.write(bytes::Bytes::from(bytes)).await?;
+ let sidecar_name = sidecar
+ .as_ref()
+ .map(|_| format!("{}{}", file_name,
crate::spec::MANIFEST_SIDECAR_SUFFIX));
+ if let (Some(sidecar), Some(_)) = (sidecar, sidecar_name.as_ref()) {
+ let sidecar_path = crate::spec::ManifestSidecar::path(path);
+ let write_result = async {
+ let output = file_io.new_output(&sidecar_path)?;
+ output.write(bytes::Bytes::from(sidecar)).await
+ }
+ .await;
+ if let Err(error) = write_result {
+ let _ = file_io.delete_file(path).await;
+ let _ = file_io.delete_file(&sidecar_path).await;
+ return Err(error);
+ }
+ }
let mut added_file_count: i64 = 0;
let mut deleted_file_count: i64 = 0;
@@ -1265,7 +1294,8 @@ impl TableCommit {
schema_id,
)
.with_bucket_level_stats(min_bucket, max_bucket, min_level, max_level)
- .with_row_id_stats(min_row_id, max_row_id))
+ .with_row_id_stats(min_row_id, max_row_id)
+ .with_extra_files(sidecar_name.map(|name| vec![name])))
}
/// Check if this commit was already completed (idempotency).
@@ -3504,6 +3534,57 @@ mod tests {
assert_eq!(entries[0].file().file_name, "data-0.parquet");
}
+ #[tokio::test]
+ async fn test_commit_writes_and_publishes_manifest_sidecar() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_manifest_sidecar_commit";
+ setup_dirs(&file_io, table_path).await;
+ let table = test_table_with_options(
+ &file_io,
+ table_path,
+ HashMap::from([("manifest.sidecar.enabled".to_string(),
"true".to_string())]),
+ );
+ let commit = TableCommit::new(table, "test-user".to_string());
+ commit
+ .commit(vec![CommitMessage::new(
+ vec![],
+ 0,
+ vec![test_data_file("data-0.parquet", 100)],
+ )])
+ .await
+ .unwrap();
+
+ let snapshot = latest_snapshot(&file_io, table_path).await.unwrap();
+ let manifest_dir = format!("{table_path}/manifest");
+ let metas = ManifestList::read(
+ &file_io,
+ &format!("{manifest_dir}/{}", snapshot.delta_manifest_list()),
+ )
+ .await
+ .unwrap();
+ let manifest = &metas[0];
+ let sidecar_name = format!(
+ "{}{}",
+ manifest.file_name(),
+ crate::spec::MANIFEST_SIDECAR_SUFFIX
+ );
+ assert_eq!(
+ manifest.extra_files(),
+ Some([sidecar_name.clone()].as_slice())
+ );
+
+ let manifest_path = format!("{manifest_dir}/{}", manifest.file_name());
+ let selection =
+ crate::spec::ManifestSidecar::read(&file_io, &manifest_path,
manifest, None)
+ .await
+ .expect("published sidecar should be readable");
+ assert_eq!(selection.blocks().len(), 1);
+ assert!(file_io
+ .exists(&format!("{manifest_dir}/{sidecar_name}"))
+ .await
+ .unwrap());
+ }
+
#[tokio::test]
async fn test_commit_rejects_writer_mode_mismatch() {
let file_io = test_file_io();
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 49b999ce..bfc9f2b1 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -37,9 +37,9 @@ use crate::io::FileIO;
use crate::spec::{
avro::SharedSchemaCache, bucket_path, BinaryRow, BucketFunctionType,
CoreOptions, DataField,
DataFileMeta, FileKind, GlobalIndexSearchMode, IndexManifest,
IndexManifestEntry,
- ManifestEntry, PartitionComputer, Predicate, Snapshot, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
- SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_ID,
- VALUE_KIND_FIELD_NAME,
+ ManifestEntry, ManifestFileMeta, ManifestSidecar, PartitionComputer,
Predicate, Snapshot,
+ ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME, SEQUENCE_NUMBER_FIELD_ID,
SEQUENCE_NUMBER_FIELD_NAME,
+ VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
};
use crate::table::bin_pack::{pack_for_ordered, split_for_batch};
use crate::table::index_file_path::IndexFileLocation;
@@ -111,6 +111,89 @@ async fn read_manifest_list(
crate::spec::avro::from_avro_bytes_fast::<crate::spec::ManifestFileMeta>(&bytes)
}
+struct ManifestSidecarPruning<'a> {
+ row_range_index: Option<&'a RowRangeIndex>,
+ partition_filter: Option<&'a PartitionFilter>,
+ partition_arity: usize,
+ bucket_predicate: Option<&'a Predicate>,
+ bucket_key_fields: &'a [DataField],
+ bucket_function_type: BucketFunctionType,
+}
+
+impl ManifestSidecarPruning<'_> {
+ fn has_filter(&self) -> bool {
+ self.row_range_index.is_some()
+ || self.partition_filter.is_some()
+ || self.bucket_predicate.is_some()
+ }
+}
+
+/// Read a complete manifest or a sidecar-selected OCF stream.
+///
+/// Callers decide whether row-range block pruning is safe by supplying or
omitting
+/// `row_range_index`; incremental paths must validate DELETE entries before
pruning them.
+async fn read_manifest_bytes_with_sidecar(
+ file_io: &FileIO,
+ path: &str,
+ manifest: &ManifestFileMeta,
+ enabled: bool,
+ pruning: ManifestSidecarPruning<'_>,
+) -> crate::Result<bytes::Bytes> {
+ if !enabled || !pruning.has_filter() {
+ return file_io.new_input(path)?.read().await;
+ }
+
+ let ManifestSidecarPruning {
+ row_range_index,
+ partition_filter,
+ partition_arity,
+ bucket_predicate,
+ bucket_key_fields,
+ bucket_function_type,
+ } = pruning;
+ let row_filter = |start, end| row_range_index.is_some_and(|index|
index.intersects(start, end));
+ let mut partition_block_filter = |partition: &[u8]| {
+ partition_filter
+ .and_then(|filter| filter.matches_entry(partition).ok())
+ .unwrap_or(true)
+ };
+ let mut bucket_cache: HashMap<i32, Option<HashSet<i32>>> = HashMap::new();
+ let mut bucket_block_filter = |bucket: i32, total_buckets: i32| {
+ let Some(predicate) = bucket_predicate else {
+ return true;
+ };
+ bucket_cache
+ .entry(total_buckets)
+ .or_insert_with(|| {
+ compute_target_buckets(
+ predicate,
+ bucket_key_fields,
+ bucket_function_type,
+ total_buckets,
+ )
+ })
+ .as_ref()
+ .is_none_or(|targets| targets.contains(&bucket))
+ };
+ let selection = ManifestSidecar::read_with_filters(
+ file_io,
+ path,
+ manifest,
+ row_range_index.map(|_| &row_filter as &(dyn Fn(i64, i64) -> bool +
Sync)),
+ partition_filter
+ .map(|_| &mut partition_block_filter as &mut (dyn FnMut(&[u8]) ->
bool + Send)),
+ Some(partition_arity),
+ bucket_predicate
+ .map(|_| &mut bucket_block_filter as &mut (dyn FnMut(i32, i32) ->
bool + Send)),
+ )
+ .await;
+
+ match selection.as_ref() {
+ Some(selection) => ManifestSidecar::read_selected_bytes(file_io, path,
selection).await,
+ None => file_io.new_input(path)?.read().await,
+ }
+}
+
fn validate_incremental_entries(entries: &[ManifestEntry]) ->
crate::Result<()> {
if entries.iter().any(|entry| *entry.kind() != FileKind::Add) {
return Err(crate::Error::DataInvalid {
@@ -157,6 +240,7 @@ async fn read_all_manifest_entries(
bucket_key_fields: &[DataField],
bucket_function_type: BucketFunctionType,
row_range_index: Option<&RowRangeIndex>,
+ manifest_sidecar_enabled: bool,
trace: Option<&mut ScanTrace>,
) -> crate::Result<Vec<ManifestEntry>> {
let incremental = matches!(&source, ManifestListSource::AppendDeltas(_));
@@ -235,8 +319,24 @@ async fn read_all_manifest_entries(
let path = format!("{}/{}", manifest_path_prefix,
meta.file_name());
let cache = shared_cache.clone();
async move {
- let input_file = file_io.new_input(&path)?;
- let content = input_file.read().await?;
+ // Incremental delta validation must observe every ADD/DELETE
before
+ // row-range pruning; keep its existing post-validation row
filter.
+ let sidecar_row_index =
(!incremental).then_some(row_range_index).flatten();
+ let content = read_manifest_bytes_with_sidecar(
+ file_io,
+ &path,
+ &meta,
+ manifest_sidecar_enabled,
+ ManifestSidecarPruning {
+ row_range_index: sidecar_row_index,
+ partition_filter,
+ partition_arity: partition_fields.len(),
+ bucket_predicate,
+ bucket_key_fields,
+ bucket_function_type,
+ },
+ )
+ .await?;
// Per-task bucket cache (few distinct total_buckets values
per manifest).
let mut bucket_cache: HashMap<i32, Option<HashSet<i32>>> =
HashMap::new();
@@ -1427,6 +1527,7 @@ impl<'a> PaimonTableScan<'a> {
&bucket_key_fields,
bucket_function_type,
row_range_index,
+ core_options.manifest_sidecar_enabled(),
trace,
)
.await?;
@@ -1907,8 +2008,24 @@ impl<'a> PaimonTableScan<'a> {
let mut entries = Vec::new();
for meta in manifest_metas {
let path = format!("{base_path}/{}", meta.file_name());
- let input = file_io.new_input(&path)?;
- let bytes = input.read().await?;
+ // This path validates incremental/changelog manifests before its
+ // existing post-read row-range pruning, so do not hide DELETEs at
+ // the block layer.
+ let bytes = read_manifest_bytes_with_sidecar(
+ file_io,
+ &path,
+ &meta,
+ core_options.manifest_sidecar_enabled(),
+ ManifestSidecarPruning {
+ row_range_index: None,
+ partition_filter,
+ partition_arity: partition_fields.len(),
+ bucket_predicate,
+ bucket_key_fields: &bucket_key_fields,
+ bucket_function_type,
+ },
+ )
+ .await?;
let mut bucket_cache: HashMap<i32, Option<HashSet<i32>>> =
HashMap::new();
let manifest_entries =
crate::spec::avro::from_manifest_bytes_filtered_shared(
&bytes,
@@ -3623,6 +3740,55 @@ mod tests {
assert_eq!(delta_files, vec!["a-new", "a-old"]);
}
+ #[tokio::test]
+ async fn test_manifest_sidecar_prunes_avro_blocks_before_entry_decode() {
+ const FILE_COUNT: usize = 1_000;
+ let table_path = "memory:/manifest_sidecar_block_pruning";
+ let schema = two_column_schema(0, "id",
"name").copy_with_options(HashMap::from([(
+ "manifest.sidecar.enabled".to_string(),
+ "true".to_string(),
+ )]));
+ let table = data_evolution_test_table(table_path, schema);
+ setup_scan_trace_dirs(&table).await;
+
+ let files = (0..FILE_COUNT)
+ .map(|row_id| {
+ make_evo_file(
+ &format!("data-{row_id:04}.parquet"),
+ 10,
+ 1,
+ row_id as i64,
+ Some(row_id as i64),
+ )
+ })
+ .collect();
+ TableCommit::new(table.clone(), "manifest-sidecar-test".to_string())
+ .commit(vec![CommitMessage::new(
+ BinaryRowBuilder::new(0).build_serialized(),
+ 0,
+ files,
+ )])
+ .await
+ .unwrap();
+
+ let mut reader = table.new_read_builder();
+ reader.with_row_ranges(vec![RowRange::new(0, 0)]);
+ let (plan, trace) = reader.new_scan().plan_with_trace().await.unwrap();
+ let planned = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .map(|file| file.file_name.as_str())
+ .collect::<Vec<_>>();
+
+ assert_eq!(planned, vec!["data-0000.parquet"]);
+ assert!(trace.manifest_entries_read > 0);
+ assert!(
+ trace.manifest_entries_read < FILE_COUNT,
+ "the sidecar should avoid decoding non-overlapping Avro blocks"
+ );
+ }
+
#[tokio::test]
async fn test_row_range_trace_excludes_manifest_netting() {
let table_path = "memory:/de_row_range_trace_netting";
@@ -3666,7 +3832,11 @@ mod tests {
#[tokio::test]
async fn
test_snapshot_delta_rejects_manifest_deletes_before_row_range_pruning() {
let table_path = "memory:/de_delta_row_range_netting";
- let table = data_evolution_test_table(table_path, two_column_schema(0,
"id", "payload"));
+ let schema = two_column_schema(0, "id",
"payload").copy_with_options(HashMap::from([(
+ "manifest.sidecar.enabled".to_string(),
+ "true".to_string(),
+ )]));
+ let table = data_evolution_test_table(table_path, schema);
setup_scan_trace_dirs(&table).await;
let deleted = make_evo_file_with_cols("deleted.blob", 100, 1, 0,
&["payload"]);
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 0aa71fa5..85ff5b3c 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -2460,11 +2460,18 @@ the normal physical format without wrapping the writer.
| `'sequence.field' = 'col'` | Sequence field used to determine which record
wins during deduplication |
| `'row-tracking.enabled' = 'true'` | Enable stable row ids |
| `'data-evolution.enabled' = 'true'` | Enable data evolution (partial-column
writes, row-level UPDATE/MERGE/DELETE) |
+| `'manifest.sidecar.enabled' = 'true'` | Write and read manifest block
sidecars for partition, row-id, and bucket pruning; when unset, inherits
`manifest-sort.enabled` |
| `'global-index.enabled' = 'true'` | Enable global index metadata and reads |
| `'deletion-vectors.enabled' = 'true'` | Enable deletion vectors |
| `'deletion-vectors.merge-on-read' = 'true'` | Include and key-merge
uncompacted level-0 files in DV-enabled deduplicate batch reads |
| `'changelog-producer' = 'input'` | Changelog producer; primary-key tables
support reads and writes in this mode |
+Manifest sidecars use the Java-compatible `<manifest-name>.avro.sidecar`
+format and are published through the manifest metadata's `_EXTRA_FILES` list.
+Missing, corrupt, unsupported, or mismatched sidecars fall back to reading the
+complete manifest. Entry filters and ADD/DELETE reconciliation still run after
+block selection.
+
Cross-partition updates are not configured by an option: a primary-key table is
in cross-partition update mode when `'bucket' = '-1'` and the primary key does
not contain every partition field.