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 a811c2f fix(blob): support placeholder fallback reads (#530)
a811c2f is described below
commit a811c2ff41ca0b7dba5505b2fa843ee88ff4f74d
Author: QuakeWang <[email protected]>
AuthorDate: Sat Jul 18 10:21:36 2026 +0800
fix(blob): support placeholder fallback reads (#530)
---
crates/blob_test_utils.rs | 29 +-
crates/paimon/src/arrow/format/blob.rs | 304 ++++---
crates/paimon/src/table/data_evolution_reader.rs | 897 ++++++++++++++++++---
.../table/data_evolution_reader/blob_fallback.rs | 432 ++++++++++
crates/paimon/testdata/blob/blob-placeholder.blob | Bin 0 -> 33 bytes
5 files changed, 1420 insertions(+), 242 deletions(-)
diff --git a/crates/blob_test_utils.rs b/crates/blob_test_utils.rs
index 24fcae0..e878783 100644
--- a/crates/blob_test_utils.rs
+++ b/crates/blob_test_utils.rs
@@ -22,13 +22,31 @@ const BLOB_MAGIC_NUMBER_BYTES: [u8; 4] =
1481511375_i32.to_le_bytes();
const BLOB_ENTRY_OVERHEAD: usize = 16;
const BLOB_FORMAT_VERSION: u8 = 1;
+#[derive(Clone, Copy)]
+pub(crate) enum BlobFixtureValue<'a> {
+ Value(&'a [u8]),
+ Null,
+ Placeholder,
+}
+
pub(crate) fn build_blob_file_bytes(rows: &[Option<&[u8]>]) -> Vec<u8> {
+ let values = rows
+ .iter()
+ .map(|row| match row {
+ Some(payload) => BlobFixtureValue::Value(payload),
+ None => BlobFixtureValue::Null,
+ })
+ .collect::<Vec<_>>();
+ build_blob_file_bytes_with_values(&values)
+}
+
+pub(crate) fn build_blob_file_bytes_with_values(rows: &[BlobFixtureValue<'_>])
-> Vec<u8> {
let mut file_bytes = Vec::new();
let mut lengths = Vec::with_capacity(rows.len());
for row in rows {
match row {
- Some(payload) => {
+ BlobFixtureValue::Value(payload) => {
let entry_length = payload
.len()
.checked_add(BLOB_ENTRY_OVERHEAD)
@@ -49,7 +67,8 @@ pub(crate) fn build_blob_file_bytes(rows: &[Option<&[u8]>])
-> Vec<u8> {
hasher.update(&entry_length_bytes);
file_bytes.extend_from_slice(&hasher.finalize().to_le_bytes());
}
- None => lengths.push(-1),
+ BlobFixtureValue::Null => lengths.push(-1),
+ BlobFixtureValue::Placeholder => lengths.push(-2),
}
}
@@ -72,6 +91,12 @@ pub(crate) fn write_blob_file(path: &Path, rows:
&[Option<&[u8]>]) {
.unwrap_or_else(|e| panic!("Failed to write blob test file {path:?}:
{e}"));
}
+pub(crate) fn write_blob_file_with_values(path: &Path, rows:
&[BlobFixtureValue<'_>]) {
+ let file_bytes = build_blob_file_bytes_with_values(rows);
+ fs::write(path, file_bytes)
+ .unwrap_or_else(|e| panic!("Failed to write blob test file {path:?}:
{e}"));
+}
+
pub(crate) fn encode_delta_varints(values: &[i64]) -> Vec<u8> {
if values.is_empty() {
return Vec::new();
diff --git a/crates/paimon/src/arrow/format/blob.rs
b/crates/paimon/src/arrow/format/blob.rs
index c401a06..f66108c 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -44,6 +44,53 @@ impl BlobFormatReader {
}
}
+pub(crate) struct IndexedBlobReader {
+ reader: Box<dyn FileRead>,
+ index: BlobFileIndex,
+ descriptor_mode: bool,
+ file_path: String,
+}
+
+impl IndexedBlobReader {
+ pub(crate) async fn open(
+ reader: Box<dyn FileRead>,
+ file_size: u64,
+ file_path: String,
+ descriptor_mode: bool,
+ ) -> crate::Result<Self> {
+ let index = BlobFileIndex::load(reader.as_ref(), file_size).await?;
+ Ok(Self {
+ reader,
+ index,
+ descriptor_mode,
+ file_path,
+ })
+ }
+
+ pub(crate) fn num_rows(&self) -> usize {
+ self.index.num_rows()
+ }
+
+ pub(crate) async fn read_positions(
+ &self,
+ positions: &[usize],
+ ) -> crate::Result<Vec<BlobReadValue>> {
+ if self.descriptor_mode {
+ build_descriptor_values(&self.index, positions, &self.file_path)
+ } else {
+ let planned_reads = plan_blob_reads(&self.index, positions)?;
+ fetch_blob_values(self.reader.as_ref(), planned_reads).await
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) enum BlobReadValue {
+ Value(Bytes),
+ Null,
+ Placeholder,
+}
+
const BLOB_FOOTER_SIZE: u64 = 5;
const BLOB_FORMAT_VERSION: u8 = 1;
const BLOB_INLINE_HEADER_SIZE: u64 = 4;
@@ -67,46 +114,36 @@ impl FormatFileReader for BlobFormatReader {
let target_schema = build_target_arrow_schema(read_fields)?;
let batch_size = batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
- let blob_index = BlobFileIndex::load(reader.as_ref(),
file_size).await?;
- let mut selection = RowSelectionCursor::new(blob_index.num_rows(),
row_selection)?;
+ let blob_reader = IndexedBlobReader::open(
+ reader,
+ file_size,
+ self.file_path.clone(),
+ self.descriptor_mode,
+ )
+ .await?;
+ let mut selection = RowSelectionCursor::new(blob_reader.num_rows(),
row_selection)?;
let project_values = !read_fields.is_empty();
- if self.descriptor_mode {
- let file_path = self.file_path.clone();
- Ok(try_stream! {
- while let Some(positions) = selection.next_batch(batch_size) {
- let batch = if project_values {
- build_descriptor_batch(&blob_index, &target_schema,
&positions, &file_path)?
- } else {
- RecordBatch::try_new_with_options(
- target_schema.clone(),
- Vec::new(),
-
&RecordBatchOptions::new().with_row_count(Some(positions.len())),
- )
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to build empty blob
RecordBatch: {e}"),
- source: Some(Box::new(e)),
- })?
- };
- yield batch;
- }
- }
- .boxed())
- } else {
- Ok(try_stream! {
- while let Some(positions) = selection.next_batch(batch_size) {
- let batch = read_blob_batch(
- reader.as_ref(),
- &blob_index,
- &target_schema,
- &positions,
- project_values,
- ).await?;
- yield batch;
- }
+ Ok(try_stream! {
+ while let Some(positions) = selection.next_batch(batch_size) {
+ let batch = if project_values {
+ let values = blob_reader.read_positions(&positions).await?;
+ build_blob_batch(&target_schema, values)?
+ } else {
+ RecordBatch::try_new_with_options(
+ target_schema.clone(),
+ Vec::new(),
+
&RecordBatchOptions::new().with_row_count(Some(positions.len())),
+ )
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to build empty blob
RecordBatch: {e}"),
+ source: Some(Box::new(e)),
+ })?
+ };
+ yield batch;
}
- .boxed())
}
+ .boxed())
}
}
@@ -138,70 +175,49 @@ fn validate_read_fields(read_fields: &[DataField]) ->
crate::Result<()> {
Ok(())
}
-fn build_descriptor_batch(
+fn build_descriptor_values(
blob_index: &BlobFileIndex,
- target_schema: &Arc<arrow_schema::Schema>,
positions: &[usize],
file_path: &str,
-) -> crate::Result<RecordBatch> {
- let mut builder = BinaryBuilder::new();
- for &position in positions {
- let entry = blob_index
- .entry(position)
- .ok_or_else(|| Error::DataInvalid {
- message: format!(
- "Blob row selection referenced out-of-range position
{position} for {} rows",
- blob_index.num_rows()
- ),
- source: None,
- })?;
-
- match entry.inline_data_range() {
- None => builder.append_null(),
- Some(range) => {
- let descriptor = BlobDescriptor::new(
- file_path.to_string(),
- range.start as i64,
- (range.end - range.start) as i64,
- );
- builder.append_value(descriptor.serialize());
- }
- }
- }
+) -> crate::Result<Vec<BlobReadValue>> {
+ positions
+ .iter()
+ .map(|&position| {
+ let entry = blob_index
+ .entry(position)
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Blob row selection referenced out-of-range position
{position} for {} rows",
+ blob_index.num_rows()
+ ),
+ source: None,
+ })?;
- let columns: Vec<ArrayRef> = vec![Arc::new(builder.finish())];
- RecordBatch::try_new(target_schema.clone(), columns).map_err(|e|
Error::UnexpectedError {
- message: format!("Failed to build descriptor blob RecordBatch: {e}"),
- source: Some(Box::new(e)),
- })
+ Ok(match entry {
+ BlobEntry::Value(range) => {
+ let descriptor = BlobDescriptor::new(
+ file_path.to_string(),
+ range.start as i64,
+ (range.end - range.start) as i64,
+ );
+ BlobReadValue::Value(Bytes::from(descriptor.serialize()))
+ }
+ BlobEntry::Null => BlobReadValue::Null,
+ BlobEntry::Placeholder => BlobReadValue::Placeholder,
+ })
+ })
+ .collect()
}
-async fn read_blob_batch(
- reader: &dyn FileRead,
- blob_index: &BlobFileIndex,
+fn build_blob_batch(
target_schema: &Arc<arrow_schema::Schema>,
- positions: &[usize],
- project_values: bool,
+ values: Vec<BlobReadValue>,
) -> crate::Result<RecordBatch> {
- if !project_values {
- return RecordBatch::try_new_with_options(
- target_schema.clone(),
- Vec::new(),
- &RecordBatchOptions::new().with_row_count(Some(positions.len())),
- )
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to build empty blob RecordBatch: {e}"),
- source: Some(Box::new(e)),
- });
- }
-
- let planned_reads = plan_blob_reads(blob_index, positions)?;
- let values = fetch_blob_values(reader, planned_reads).await?;
let mut builder = BinaryBuilder::new();
for value in values {
match value {
- BlobValue::Null => builder.append_null(),
- BlobValue::Inline(bytes) => builder.append_value(bytes.as_ref()),
+ BlobReadValue::Value(bytes) =>
builder.append_value(bytes.as_ref()),
+ BlobReadValue::Null | BlobReadValue::Placeholder =>
builder.append_null(),
}
}
@@ -229,10 +245,11 @@ fn plan_blob_reads(
source: None,
})?;
- Ok(match entry.inline_data_range() {
- Some(range) if range.start == range.end =>
PlannedBlobRead::Empty,
- Some(range) => PlannedBlobRead::Read(range),
- None => PlannedBlobRead::Null,
+ Ok(match entry {
+ BlobEntry::Value(range) if range.start == range.end =>
PlannedBlobRead::Empty,
+ BlobEntry::Value(range) =>
PlannedBlobRead::Read(range.clone()),
+ BlobEntry::Null => PlannedBlobRead::Null,
+ BlobEntry::Placeholder => PlannedBlobRead::Placeholder,
})
})
.collect()
@@ -241,12 +258,13 @@ fn plan_blob_reads(
async fn fetch_blob_values(
reader: &dyn FileRead,
planned_reads: Vec<PlannedBlobRead>,
-) -> crate::Result<Vec<BlobValue>> {
+) -> crate::Result<Vec<BlobReadValue>> {
futures::stream::iter(planned_reads.into_iter().map(|planned_read| async
move {
match planned_read {
- PlannedBlobRead::Null => Ok(BlobValue::Null),
- PlannedBlobRead::Empty => Ok(BlobValue::Inline(Bytes::new())),
- PlannedBlobRead::Read(range) =>
reader.read(range).await.map(BlobValue::Inline),
+ PlannedBlobRead::Null => Ok(BlobReadValue::Null),
+ PlannedBlobRead::Placeholder => Ok(BlobReadValue::Placeholder),
+ PlannedBlobRead::Empty => Ok(BlobReadValue::Value(Bytes::new())),
+ PlannedBlobRead::Read(range) =>
reader.read(range).await.map(BlobReadValue::Value),
}
}))
.buffered(BLOB_READ_CONCURRENCY)
@@ -257,16 +275,11 @@ async fn fetch_blob_values(
#[derive(Debug, Clone)]
enum PlannedBlobRead {
Null,
+ Placeholder,
Empty,
Read(Range<u64>),
}
-#[derive(Debug, Clone)]
-enum BlobValue {
- Null,
- Inline(Bytes),
-}
-
#[derive(Debug, Clone)]
struct BlobFileIndex {
entries: Vec<BlobEntry>,
@@ -348,9 +361,10 @@ impl BlobFileIndex {
}
#[derive(Debug, Clone)]
-struct BlobEntry {
- data_offset: Option<u64>,
- data_length: u64,
+enum BlobEntry {
+ Value(Range<u64>),
+ Null,
+ Placeholder,
}
impl BlobEntry {
@@ -359,16 +373,22 @@ impl BlobEntry {
let mut next_offset = 0_u64;
for &entry_length in lengths {
- if entry_length == -1 {
- entries.push(Self {
- data_offset: None,
- data_length: 0,
- });
- continue;
+ match entry_length {
+ -1 => {
+ entries.push(Self::Null);
+ continue;
+ }
+ -2 => {
+ entries.push(Self::Placeholder);
+ continue;
+ }
+ _ => {}
}
let entry_length = u64::try_from(entry_length).map_err(|e|
Error::DataInvalid {
- message: format!("Blob entry length must be positive or -1,
got {entry_length}"),
+ message: format!(
+ "Blob entry length must be positive, -1, or -2, got
{entry_length}"
+ ),
source: Some(Box::new(e)),
})?;
@@ -397,20 +417,14 @@ impl BlobEntry {
});
}
- entries.push(Self {
- data_offset: Some(next_offset + BLOB_INLINE_HEADER_SIZE),
- data_length: entry_length - BLOB_ENTRY_OVERHEAD,
- });
+ let data_offset = next_offset + BLOB_INLINE_HEADER_SIZE;
+ let data_length = entry_length - BLOB_ENTRY_OVERHEAD;
+ entries.push(Self::Value(data_offset..data_offset + data_length));
next_offset = entry_end;
}
Ok(entries)
}
-
- fn inline_data_range(&self) -> Option<Range<u64>> {
- self.data_offset
- .map(|offset| offset..offset + self.data_length)
- }
}
#[derive(Debug, Clone)]
@@ -915,6 +929,38 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_blob_reader_treats_java_placeholders_as_null() {
+ let read_fields = vec![DataField::new(
+ 0,
+ "payload".to_string(),
+ DataType::Blob(BlobType::new()),
+ )];
+ let file_bytes = load_blob_fixture("blob-placeholder.blob");
+
+ let batches = BlobFormatReader::new(String::new(), false)
+ .read_batch_stream(
+ Box::new(BytesFileRead(Bytes::from(file_bytes.clone()))),
+ file_bytes.len() as u64,
+ &read_fields,
+ None,
+ Some(2),
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(batches.len(), 2);
+ assert_eq!(collect_binary_values(&batches[0]), vec![None, None]);
+ assert_eq!(
+ collect_binary_values(&batches[1]),
+ vec![Some(b"latest-3".to_vec()), None]
+ );
+ }
+
#[tokio::test]
async fn test_blob_reader_reads_payloads_with_bounded_parallelism() {
let read_fields = vec![DataField::new(
@@ -960,6 +1006,20 @@ mod tests {
assert_eq!(generated, load_blob_fixture("blob-basic.blob"));
}
+ #[test]
+ fn test_blob_reader_test_helper_matches_java_placeholder_fixture() {
+ use blob_test_utils::BlobFixtureValue::{Null, Placeholder, Value};
+
+ let generated = blob_test_utils::build_blob_file_bytes_with_values(&[
+ Placeholder,
+ Null,
+ Value(b"latest-3"),
+ Placeholder,
+ ]);
+
+ assert_eq!(generated, load_blob_fixture("blob-placeholder.blob"));
+ }
+
#[tokio::test]
async fn test_blob_reader_supports_empty_projection() {
let reader = BlobFormatReader::new(String::new(), false);
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index 1f0e9af..1d5a130 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -15,6 +15,8 @@
// specific language governing permissions and limitations
// under the License.
+mod blob_fallback;
+
use super::blob_resolver::{BlobReadLimiter, BLOB_DESCRIPTOR_READ_CONCURRENCY};
use super::data_file_reader::{
append_null_row_id_column, attach_row_id, expand_selected_row_ids,
insert_column_at,
@@ -36,7 +38,8 @@ use arrow_array::{Array, BinaryArray, Int64Array,
RecordBatch};
use async_stream::try_stream;
use futures::{StreamExt, TryStreamExt};
use roaring::RoaringBitmap;
-use std::collections::{HashMap, HashSet};
+use std::cmp::Reverse;
+use std::collections::{BTreeMap, HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
@@ -1109,6 +1112,21 @@ fn open_source_stream(
blob_as_descriptor: bool,
anchor_deletion_vector: Option<&DeletionVectorContext>,
) -> crate::Result<ArrowRecordBatchStream> {
+ if let FieldSource::BlobBunch { bunch, read_fields } = source {
+ // A single sequence group has no fallback work, so keep per-file lazy
streaming.
+ if !bunch.can_read_sequentially() {
+ return blob_fallback::read(
+ split,
+ bunch.clone(),
+ read_fields.clone(),
+ row_ranges,
+ file_io,
+ blob_as_descriptor,
+ anchor_deletion_vector.cloned(),
+ );
+ }
+ }
+
let file_reader = DataFileReader::new(
file_io,
schema_manager,
@@ -1132,16 +1150,26 @@ fn open_source_stream(
row_ranges,
)
}
- FieldSource::BlobBunch {
- bunch, data_fields, ..
- } => read_bunch_files_stream(
- file_reader,
- split,
- bunch.files.clone(),
- data_fields.clone(),
- row_ranges,
- anchor_deletion_vector.cloned(),
- ),
+ FieldSource::BlobBunch { bunch, .. } => {
+ let selected_ranges = selected_absolute_row_ranges_for_file(
+ bunch.expected_first_row_id,
+ bunch.expected_row_count,
+ row_ranges.as_deref(),
+ anchor_deletion_vector.map(|context|
context.deletion_vector.as_ref()),
+ )?;
+ let files = match selected_ranges.as_deref() {
+ Some(ranges) => bunch.files_overlapping(ranges)?,
+ None => bunch.files.clone(),
+ };
+ read_bunch_files_stream(
+ file_reader,
+ split,
+ files,
+ None,
+ row_ranges,
+ anchor_deletion_vector.cloned(),
+ )
+ }
FieldSource::VectorBunch {
bunch, data_fields, ..
} => read_bunch_files_stream(
@@ -1550,8 +1578,7 @@ fn build_source_plan(
} else {
let source_idx = sources.len();
sources.push(FieldSource::BlobBunch {
- bunch: BlobBunch::new(expected_row_count),
- data_fields: info.data_fields.clone(),
+ bunch: BlobBunch::new(prepared_group.first_row_id,
expected_row_count),
read_fields: Vec::new(),
});
blob_source_indices.insert(field_id, source_idx);
@@ -1667,15 +1694,8 @@ fn build_source_plan(
bunch, read_fields, ..
} = source
{
- if !read_fields.is_empty() && bunch.row_count() !=
prepared_group.logical_row_count {
- return Err(Error::DataInvalid {
- message: format!(
- "Blob bunch row count {} does not match logical row
count {}",
- bunch.row_count(),
- prepared_group.logical_row_count
- ),
- source: None,
- });
+ if !read_fields.is_empty() {
+ bunch.validate_logical_range()?;
}
}
}
@@ -1733,7 +1753,6 @@ enum FieldSource {
},
BlobBunch {
bunch: BlobBunch,
- data_fields: Option<Vec<DataField>>,
read_fields: Vec<DataField>,
},
}
@@ -1779,25 +1798,20 @@ impl FieldSource {
}
}
+/// All physical BLOB files for one field, including overlapping older
sequence groups.
#[derive(Debug, Clone)]
struct BlobBunch {
files: Vec<DataFileMeta>,
+ expected_first_row_id: i64,
expected_row_count: i64,
- latest_first_row_id: i64,
- expected_next_first_row_id: i64,
- latest_max_sequence_number: i64,
- row_count: i64,
}
impl BlobBunch {
- fn new(expected_row_count: i64) -> Self {
+ fn new(expected_first_row_id: i64, expected_row_count: i64) -> Self {
Self {
files: Vec::new(),
+ expected_first_row_id,
expected_row_count,
- latest_first_row_id: -1,
- expected_next_first_row_id: -1,
- latest_max_sequence_number: -1,
- row_count: 0,
}
}
@@ -1809,85 +1823,166 @@ impl BlobBunch {
});
}
- let first_row_id = file.first_row_id.ok_or_else(|| Error::DataInvalid {
- message: format!("Blob file '{}' is missing first_row_id",
file.file_name),
- source: None,
- })?;
-
- if first_row_id == self.latest_first_row_id {
- if file.max_sequence_number >= self.latest_max_sequence_number {
+ let range = blob_file_row_range(&file)?;
+ if let Some(first_file) = self.files.first() {
+ if file.write_cols != first_file.write_cols {
return Err(Error::DataInvalid {
- message:
- "Blob file with same first row id should have
decreasing sequence number."
- .to_string(),
+ message: "All files in a blob bunch should have the same
write columns."
+ .to_string(),
source: None,
});
}
- return Ok(());
}
- if !self.files.is_empty() {
- if first_row_id < self.expected_next_first_row_id {
- if file.max_sequence_number >= self.latest_max_sequence_number
{
- return Err(Error::DataInvalid {
- message:
- "Blob file with overlapping row id should have
decreasing sequence number."
- .to_string(),
- source: None,
- });
- }
- return Ok(());
- } else if first_row_id > self.expected_next_first_row_id {
+ for existing in self
+ .files
+ .iter()
+ .filter(|existing| existing.max_sequence_number ==
file.max_sequence_number)
+ {
+ let existing_range = blob_file_row_range(existing)?;
+ if range.overlaps_inclusive(existing_range.from(),
existing_range.to()) {
return Err(Error::DataInvalid {
message: format!(
- "Blob file first row id should be continuous, expect
{} but got {}",
- self.expected_next_first_row_id, first_row_id
+ "Blob files '{}' and '{}' in the same max sequence
group overlap",
+ existing.file_name, file.file_name
),
source: None,
});
}
-
- if !self.files.is_empty() {
- let first_file = &self.files[0];
- if file.schema_id != first_file.schema_id {
- return Err(Error::DataInvalid {
- message: "All files in a blob bunch should have the
same schema id."
- .to_string(),
- source: None,
- });
- }
- if file.write_cols != first_file.write_cols {
- return Err(Error::DataInvalid {
- message: "All files in a blob bunch should have the
same write columns."
- .to_string(),
- source: None,
- });
- }
- }
}
- self.row_count += file.row_count;
- if self.row_count > self.expected_row_count {
+ let mut ranges = self.logical_ranges();
+ ranges.push(range);
+ let row_count = crate::table::merge_row_ranges(ranges)
+ .iter()
+ .map(RowRange::count)
+ .sum::<i64>();
+ if row_count > self.expected_row_count {
return Err(Error::DataInvalid {
message: format!(
- "Blob files row count {} exceed the expected {}",
- self.row_count, self.expected_row_count
+ "Blob files logical row count {row_count} exceeds the
expected {}",
+ self.expected_row_count
),
source: None,
});
}
- self.latest_max_sequence_number = file.max_sequence_number;
- self.latest_first_row_id = first_row_id;
- self.expected_next_first_row_id = first_row_id + file.row_count;
+
self.files.push(file);
Ok(())
}
fn row_count(&self) -> i64 {
- self.row_count
+ self.logical_ranges().iter().map(RowRange::count).sum()
+ }
+
+ fn logical_ranges(&self) -> Vec<RowRange> {
+ crate::table::merge_row_ranges(
+ self.files
+ .iter()
+ .map(|file| blob_file_row_range(file).expect("validated blob
file range"))
+ .collect(),
+ )
+ }
+
+ fn expected_range(&self) -> crate::Result<RowRange> {
+ if self.expected_row_count <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Blob bunch expected row count must be positive, got {}",
+ self.expected_row_count
+ ),
+ source: None,
+ });
+ }
+ let to = self
+ .expected_first_row_id
+ .checked_add(self.expected_row_count - 1)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Blob bunch expected row range overflows
i64".to_string(),
+ source: None,
+ })?;
+ Ok(RowRange::new(self.expected_first_row_id, to))
+ }
+
+ fn validate_logical_range(&self) -> crate::Result<()> {
+ let ranges = self.logical_ranges();
+ let expected = self.expected_range()?;
+ if ranges.as_slice() != [expected.clone()] {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Blob bunch logical row ranges {ranges:?} ({} rows) do not
match expected range {expected:?}",
+ self.row_count()
+ ),
+ source: None,
+ });
+ }
+ Ok(())
+ }
+
+ fn sequence_groups(&self) -> Vec<Vec<DataFileMeta>> {
+ let mut groups: BTreeMap<Reverse<i64>, Vec<DataFileMeta>> =
BTreeMap::new();
+ for file in &self.files {
+ groups
+ .entry(Reverse(file.max_sequence_number))
+ .or_default()
+ .push(file.clone());
+ }
+ for files in groups.values_mut() {
+ files.sort_by_key(|file| file.first_row_id.expect("validated blob
first_row_id"));
+ }
+ groups.into_values().collect()
+ }
+
+ fn can_read_sequentially(&self) -> bool {
+ let Some(first_file) = self.files.first() else {
+ return false;
+ };
+ self.files
+ .iter()
+ .all(|file| file.max_sequence_number ==
first_file.max_sequence_number)
+ }
+
+ fn files_overlapping(&self, ranges: &[RowRange]) ->
crate::Result<Vec<DataFileMeta>> {
+ let mut files = Vec::new();
+ for file in &self.files {
+ let file_range = blob_file_row_range(file)?;
+ if row_range_overlaps_any(&file_range, ranges) {
+ files.push(file.clone());
+ }
+ }
+ Ok(files)
}
}
+fn row_range_overlaps_any(range: &RowRange, ranges: &[RowRange]) -> bool {
+ ranges
+ .iter()
+ .any(|selected| range.overlaps_inclusive(selected.from(),
selected.to()))
+}
+
+fn blob_file_row_range(file: &DataFileMeta) -> crate::Result<RowRange> {
+ let first_row_id = file.first_row_id.ok_or_else(|| Error::DataInvalid {
+ message: format!("Blob file '{}' is missing first_row_id",
file.file_name),
+ source: None,
+ })?;
+ if file.row_count <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Blob file '{}' row count must be positive, got {}",
+ file.file_name, file.row_count
+ ),
+ source: None,
+ });
+ }
+ let last_row_id = first_row_id
+ .checked_add(file.row_count - 1)
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!("Blob file '{}' row range overflows i64",
file.file_name),
+ source: None,
+ })?;
+ Ok(RowRange::new(first_row_id, last_row_id))
+}
+
/// Aggregates rolled `.vector.<format>` segments belonging to one logical
vector
/// source, mirroring upstream `VectorFileBunch` non-pushdown semantics. Unlike
/// `BlobBunch`, the expected row count is taken directly from the prepared
group's
@@ -2101,10 +2196,11 @@ mod tests {
BinaryRow, BlobType, Datum, FloatType, IntType, PredicateBuilder,
Schema, TableSchema,
VectorType,
};
- use crate::table::{DataSplitBuilder, Table, TableRead};
+ use crate::table::{DataSplitBuilder, DeletionFile, Table, TableRead};
use arrow_array::{
Array, BinaryArray, FixedSizeListArray, Float32Array, Int32Array,
Int64Array, RecordBatch,
};
+ use bytes::Bytes;
use futures::TryStreamExt;
use std::fs;
use std::path::{Path, PathBuf};
@@ -2122,7 +2218,7 @@ mod tests {
include!(concat!(env!("CARGO_MANIFEST_DIR"), "/../test_utils.rs"));
}
- use blob_test_utils::write_blob_file;
+ use blob_test_utils::{write_blob_file, write_blob_file_with_values,
BlobFixtureValue};
use test_utils::{local_file_path, write_int_parquet_file};
#[tokio::test]
@@ -2421,8 +2517,8 @@ mod tests {
}
#[test]
- fn test_blob_bunch_ignores_same_first_row_id_with_lower_sequence() {
- let mut bunch = BlobBunch::new(1000);
+ fn test_blob_bunch_retains_same_range_from_older_sequence() {
+ let mut bunch = BlobBunch::new(0, 1000);
bunch
.add(data_file(
"blob-high.blob",
@@ -2437,8 +2533,9 @@ mod tests {
.unwrap();
assert_eq!(bunch.row_count(), 100);
- assert_eq!(bunch.files.len(), 1);
+ assert_eq!(bunch.files.len(), 2);
assert_eq!(bunch.files[0].file_name, "blob-high.blob");
+ assert_eq!(bunch.files[1].file_name, "blob-low.blob");
}
#[test]
@@ -2467,13 +2564,12 @@ mod tests {
}
#[test]
- fn test_blob_bunch_rejects_same_first_row_id_with_higher_sequence() {
- let mut bunch = BlobBunch::new(1000);
+ fn test_blob_bunch_groups_sequences_in_descending_order() {
+ let mut bunch = BlobBunch::new(0, 1000);
bunch
.add(data_file("blob-low.blob", 0, 100, 2, Some(vec!["payload"])))
.unwrap();
-
- let err = bunch
+ bunch
.add(data_file(
"blob-high.blob",
0,
@@ -2481,48 +2577,63 @@ mod tests {
3,
Some(vec!["payload"]),
))
- .unwrap_err();
+ .unwrap();
- assert!(
- matches!(err, Error::DataInvalid { message, .. } if
message.contains("same first row id"))
- );
+ let groups = bunch.sequence_groups();
+ assert_eq!(groups.len(), 2);
+ assert_eq!(groups[0][0].file_name, "blob-high.blob");
+ assert_eq!(groups[1][0].file_name, "blob-low.blob");
}
#[test]
- fn test_blob_bunch_rejects_overlapping_higher_sequence_file() {
- let mut bunch = BlobBunch::new(1000);
+ fn test_blob_bunch_retains_overlapping_ranges_across_sequences() {
+ let mut bunch = BlobBunch::new(0, 1000);
bunch
.add(data_file("blob1.blob", 0, 100, 1, Some(vec!["payload"])))
.unwrap();
+ bunch
+ .add(data_file("blob2.blob", 50, 150, 2, Some(vec!["payload"])))
+ .unwrap();
+
+ assert_eq!(bunch.files.len(), 2);
+ assert_eq!(bunch.row_count(), 200);
+ assert_eq!(bunch.logical_ranges(), vec![RowRange::new(0, 199)]);
+ }
+ #[test]
+ fn test_blob_bunch_rejects_overlapping_ranges_within_sequence() {
+ let mut bunch = BlobBunch::new(0, 1000);
+ bunch
+ .add(data_file("blob1.blob", 0, 100, 2, Some(vec!["payload"])))
+ .unwrap();
let err = bunch
.add(data_file("blob2.blob", 50, 150, 2, Some(vec!["payload"])))
.unwrap_err();
assert!(
- matches!(err, Error::DataInvalid { message, .. } if
message.contains("overlapping row id"))
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("same max sequence group"))
);
}
#[test]
- fn test_blob_bunch_rejects_non_continuous_first_row_id() {
- let mut bunch = BlobBunch::new(1000);
+ fn test_blob_bunch_rejects_non_contiguous_logical_range() {
+ let mut bunch = BlobBunch::new(0, 250);
bunch
.add(data_file("blob1.blob", 0, 100, 3, Some(vec!["payload"])))
.unwrap();
-
- let err = bunch
+ bunch
.add(data_file("blob2.blob", 150, 100, 2, Some(vec!["payload"])))
- .unwrap_err();
+ .unwrap();
+ let err = bunch.validate_logical_range().unwrap_err();
assert!(
- matches!(err, Error::DataInvalid { message, .. } if
message.contains("continuous"))
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("logical row ranges"))
);
}
#[test]
fn test_blob_bunch_rejects_mixed_write_columns() {
- let mut bunch = BlobBunch::new(200);
+ let mut bunch = BlobBunch::new(0, 200);
bunch
.add(data_file("blob1.blob", 0, 100, 3, Some(vec!["payload"])))
.unwrap();
@@ -2537,24 +2648,27 @@ mod tests {
}
#[test]
- fn test_blob_bunch_rejects_mixed_schema_ids() {
- let mut bunch = BlobBunch::new(200);
+ fn test_blob_bunch_accepts_mixed_schema_ids() {
+ let mut bunch = BlobBunch::new(0, 200);
bunch
.add(data_file("blob1.blob", 0, 100, 3, Some(vec!["payload"])))
.unwrap();
- let mut mixed_schema = data_file("blob2.blob", 100, 100, 2,
Some(vec!["payload"]));
+ let mut mixed_schema = data_file("blob2.blob", 100, 100, 3,
Some(vec!["payload"]));
mixed_schema.schema_id = 1;
- let err = bunch.add(mixed_schema).unwrap_err();
-
- assert!(
- matches!(err, Error::DataInvalid { message, .. } if
message.contains("same schema id"))
- );
+ bunch.add(mixed_schema).unwrap();
+
+ assert_eq!(bunch.files.len(), 2);
+ assert_eq!(bunch.files[0].schema_id, 0);
+ assert_eq!(bunch.files[1].schema_id, 1);
+ assert_eq!(bunch.row_count(), 200);
+ assert!(bunch.can_read_sequentially());
+ bunch.validate_logical_range().unwrap();
}
#[test]
fn test_blob_bunch_rejects_row_count_exceeding_expected() {
- let mut bunch = BlobBunch::new(100);
+ let mut bunch = BlobBunch::new(0, 100);
bunch
.add(data_file("blob1.blob", 0, 60, 3, Some(vec!["payload"])))
.unwrap();
@@ -2564,7 +2678,7 @@ mod tests {
.unwrap_err();
assert!(
- matches!(err, Error::DataInvalid { message, .. } if
message.contains("exceed the expected"))
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("exceeds the expected"))
);
}
@@ -2767,7 +2881,7 @@ mod tests {
}
#[test]
- fn test_build_source_plan_picks_latest_blob_segments() {
+ fn test_build_source_plan_retains_all_blob_sequence_groups() {
let files = vec![
data_file("others.parquet", 0, 1000, 1, None),
data_file("blob1.blob", 0, 1000, 1, Some(vec!["payload"])),
@@ -2812,7 +2926,17 @@ mod tests {
.collect();
assert_eq!(
file_names,
- vec!["blob5.blob", "blob9.blob", "blob7.blob",
"blob8.blob"]
+ vec![
+ "blob5.blob",
+ "blob2.blob",
+ "blob1.blob",
+ "blob9.blob",
+ "blob6.blob",
+ "blob3.blob",
+ "blob7.blob",
+ "blob4.blob",
+ "blob8.blob",
+ ]
);
}
FieldSource::DataFile { .. } | FieldSource::VectorBunch { .. } => {
@@ -3008,6 +3132,501 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_table_read_falls_back_across_blob_sequence_groups() {
+ use BlobFixtureValue::{Null, Placeholder, Value};
+
+ let tempdir = tempdir().unwrap();
+ let table_path = local_file_path(tempdir.path());
+ let bucket_dir = tempdir.path().join("bucket-0");
+ fs::create_dir_all(&bucket_dir).unwrap();
+
+ let parquet_path = bucket_dir.join("data.parquet");
+ write_int_parquet_file(&parquet_path, vec![("id", vec![1, 2, 3, 4, 5,
6])], None);
+
+ let base_path = bucket_dir.join("blob-base.blob");
+ let middle_path = bucket_dir.join("blob-middle.blob");
+ let latest_path = bucket_dir.join("blob-latest.blob");
+ write_blob_file_with_values(
+ &base_path,
+ &[
+ Value(b"old-0"),
+ Value(b"old-1"),
+ Value(b"old-2"),
+ Value(b"old-3"),
+ Null,
+ Placeholder,
+ ],
+ );
+ write_blob_file_with_values(
+ &middle_path,
+ &[Placeholder, Value(b"middle-1"), Value(b"middle-2")],
+ );
+ copy_blob_fixture("blob-placeholder.blob", &latest_path);
+
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "blob_fallback_t"),
+ table_path,
+ table_schema,
+ None,
+ );
+
+ let files = vec![
+ data_file_meta_with_path(
+ "data.parquet",
+ 0,
+ 6,
+ 1,
+ parquet_path.metadata().unwrap().len() as i64,
+ Some(vec!["id"]),
+ ),
+ data_file_meta_with_path(
+ "blob-base.blob",
+ 0,
+ 6,
+ 1,
+ base_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ ),
+ data_file_meta_with_path(
+ "blob-middle.blob",
+ 0,
+ 3,
+ 2,
+ middle_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ ),
+ data_file_meta_with_path(
+ "blob-latest.blob",
+ 1,
+ 4,
+ 3,
+ latest_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ ),
+ ];
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(local_file_path(&bucket_dir))
+ .with_total_buckets(1)
+ .with_data_files(files.clone())
+ .build()
+ .unwrap();
+
+ let read = TableRead::new(&table, table.schema().fields().to_vec(),
Vec::new());
+ let batches = read
+ .to_arrow(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(collect_int_values(&batches, "id"), vec![1, 2, 3, 4, 5, 6]);
+ assert_eq!(
+ collect_binary_values(&batches, "payload"),
+ vec![
+ Some(b"old-0".to_vec()),
+ Some(b"middle-1".to_vec()),
+ None,
+ Some(b"latest-3".to_vec()),
+ None,
+ None,
+ ]
+ );
+
+ let descriptor_table = table.copy_with_options(HashMap::from([(
+ "blob-as-descriptor".to_string(),
+ "true".to_string(),
+ )]));
+ let descriptor_read = TableRead::new(
+ &descriptor_table,
+ descriptor_table.schema().fields().to_vec(),
+ Vec::new(),
+ );
+ let descriptor_batches = descriptor_read
+ .to_arrow(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let descriptors = collect_binary_values(&descriptor_batches,
"payload");
+ assert!(
+ BlobDescriptor::deserialize(descriptors[0].as_deref().unwrap())
+ .unwrap()
+ .uri()
+ .ends_with("blob-base.blob")
+ );
+ assert!(
+ BlobDescriptor::deserialize(descriptors[1].as_deref().unwrap())
+ .unwrap()
+ .uri()
+ .ends_with("blob-middle.blob")
+ );
+ assert!(descriptors[2].is_none());
+ assert!(
+ BlobDescriptor::deserialize(descriptors[3].as_deref().unwrap())
+ .unwrap()
+ .uri()
+ .ends_with("blob-latest.blob")
+ );
+ assert!(descriptors[4].is_none());
+ assert!(descriptors[5].is_none());
+
+ let deletion_path = format!("{}/index/dv-0",
local_file_path(tempdir.path()));
+ let deletion_file = write_test_deletion_file(&file_io, &deletion_path,
&[2]).await;
+ let selected_split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(local_file_path(&bucket_dir))
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .with_data_deletion_files(vec![Some(deletion_file), None, None,
None])
+ .with_row_ranges(vec![RowRange::new(1, 5)])
+ .build()
+ .unwrap();
+ let selected = read
+ .to_arrow(&[selected_split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(collect_int_values(&selected, "id"), vec![2, 4, 5, 6]);
+ assert_eq!(
+ collect_binary_values(&selected, "payload"),
+ vec![
+ Some(b"middle-1".to_vec()),
+ Some(b"latest-3".to_vec()),
+ None,
+ None
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_blob_fallback_across_schema_ids_skips_unselected_files() {
+ use BlobFixtureValue::{Placeholder, Value};
+
+ let tempdir = tempdir().unwrap();
+ let table_path = local_file_path(tempdir.path());
+ let bucket_dir = tempdir.path().join("bucket-0");
+ fs::create_dir_all(&bucket_dir).unwrap();
+
+ let parquet_path = bucket_dir.join("data.parquet");
+ write_int_parquet_file(&parquet_path, vec![("id", vec![1, 2, 3, 4])],
None);
+
+ let old_path = bucket_dir.join("blob-old-selected.blob");
+ let latest_path = bucket_dir.join("blob-latest-selected.blob");
+ write_blob_file_with_values(&old_path, &[Value(b"old-2"),
Value(b"old-3")]);
+ write_blob_file_with_values(&latest_path, &[Placeholder, Placeholder]);
+
+ let schema_v0 = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+ let schema_v1 = TableSchema::new(
+ 1,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .column("added", DataType::Int(IntType::new()))
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+ assert_eq!(schema_v0.fields()[1].id(), schema_v1.fields()[1].id());
+
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "blob_schema_fallback_t"),
+ table_path,
+ schema_v1.clone(),
+ None,
+ );
+ write_schema_file(&table, &schema_v0).await;
+ write_schema_file(&table, &schema_v1).await;
+
+ let mut anchor = data_file_meta_with_path(
+ "data.parquet",
+ 0,
+ 4,
+ 1,
+ parquet_path.metadata().unwrap().len() as i64,
+ Some(vec!["id"]),
+ );
+ anchor.schema_id = 1;
+
+ // These two files make each sequence group cover rows 0..=3, but are
+ // deliberately absent. A read restricted to rows 2..=3 must not open
them.
+ let mut latest_unselected = data_file_meta_with_path(
+ "blob-latest-unselected.blob",
+ 0,
+ 2,
+ 2,
+ 5,
+ Some(vec!["payload"]),
+ );
+ latest_unselected.schema_id = 1;
+ let old_unselected = data_file_meta_with_path(
+ "blob-old-unselected.blob",
+ 0,
+ 2,
+ 1,
+ 5,
+ Some(vec!["payload"]),
+ );
+ let mut latest_selected = data_file_meta_with_path(
+ "blob-latest-selected.blob",
+ 2,
+ 2,
+ 2,
+ latest_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ );
+ latest_selected.schema_id = 1;
+ let old_selected = data_file_meta_with_path(
+ "blob-old-selected.blob",
+ 2,
+ 2,
+ 1,
+ old_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ );
+
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(local_file_path(&bucket_dir))
+ .with_total_buckets(1)
+ .with_data_files(vec![
+ anchor,
+ latest_unselected,
+ old_unselected,
+ latest_selected,
+ old_selected,
+ ])
+ .with_row_ranges(vec![RowRange::new(2, 3)])
+ .build()
+ .unwrap();
+
+ let read = TableRead::new(&table, table.schema().fields().to_vec(),
Vec::new());
+ let batches = read
+ .to_arrow(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(collect_int_values(&batches, "id"), vec![3, 4]);
+ assert_eq!(
+ collect_binary_values(&batches, "payload"),
+ vec![Some(b"old-2".to_vec()), Some(b"old-3".to_vec())]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_single_blob_sequence_group_yields_before_opening_next_file()
{
+ let tempdir = tempdir().unwrap();
+ let table_path = local_file_path(tempdir.path());
+ let bucket_dir = tempdir.path().join("bucket-0");
+ fs::create_dir_all(&bucket_dir).unwrap();
+
+ let parquet_path = bucket_dir.join("data.parquet");
+ write_int_parquet_file(&parquet_path, vec![("id", vec![1, 2, 3, 4])],
None);
+
+ let first_blob_path = bucket_dir.join("blob-first.blob");
+ write_blob_file(
+ &first_blob_path,
+ &[Some(&b"first-0"[..]), Some(&b"first-1"[..])],
+ );
+
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "blob_lazy_t"),
+ table_path,
+ table_schema,
+ None,
+ );
+
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(local_file_path(&bucket_dir))
+ .with_total_buckets(1)
+ .with_data_files(vec![
+ data_file_meta_with_path(
+ "data.parquet",
+ 0,
+ 4,
+ 1,
+ parquet_path.metadata().unwrap().len() as i64,
+ Some(vec!["id"]),
+ ),
+ data_file_meta_with_path(
+ "blob-first.blob",
+ 0,
+ 2,
+ 1,
+ first_blob_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ ),
+ data_file_meta_with_path(
+ "blob-missing-next.blob",
+ 2,
+ 2,
+ 1,
+ 5,
+ Some(vec!["payload"]),
+ ),
+ ])
+ .build()
+ .unwrap();
+
+ let read = TableRead::new(&table, table.schema().fields().to_vec(),
Vec::new());
+ let mut stream = read.to_arrow(&[split]).unwrap();
+ let first_batch = stream.try_next().await.unwrap().unwrap();
+
+ assert_eq!(
+ collect_int_values(std::slice::from_ref(&first_batch), "id"),
+ vec![1, 2]
+ );
+ assert_eq!(
+ collect_binary_values(std::slice::from_ref(&first_batch),
"payload"),
+ vec![Some(b"first-0".to_vec()), Some(b"first-1".to_vec())]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_blob_fallback_defers_later_files_until_their_batch() {
+ use BlobFixtureValue::{Placeholder, Value};
+
+ let tempdir = tempdir().unwrap();
+ let table_path = local_file_path(tempdir.path());
+ let bucket_dir = tempdir.path().join("bucket-0");
+ fs::create_dir_all(&bucket_dir).unwrap();
+
+ let parquet_path = bucket_dir.join("data.parquet");
+ write_int_parquet_file(
+ &parquet_path,
+ vec![("id", (0..2048).collect::<Vec<_>>())],
+ None,
+ );
+
+ let latest_path = bucket_dir.join("blob-latest-first.blob");
+ let older_path = bucket_dir.join("blob-older-first.blob");
+ let latest_values = vec![Placeholder; 1024];
+ let older_values = vec![Value(b"old"); 1024];
+ write_blob_file_with_values(&latest_path, &latest_values);
+ write_blob_file_with_values(&older_path, &older_values);
+
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "blob_lazy_fallback_t"),
+ table_path,
+ table_schema,
+ None,
+ );
+
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(local_file_path(&bucket_dir))
+ .with_total_buckets(1)
+ .with_data_files(vec![
+ data_file_meta_with_path(
+ "data.parquet",
+ 0,
+ 2048,
+ 1,
+ parquet_path.metadata().unwrap().len() as i64,
+ Some(vec!["id"]),
+ ),
+ data_file_meta_with_path(
+ "blob-latest-first.blob",
+ 0,
+ 1024,
+ 2,
+ latest_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ ),
+ data_file_meta_with_path(
+ "blob-older-first.blob",
+ 0,
+ 1024,
+ 1,
+ older_path.metadata().unwrap().len() as i64,
+ Some(vec!["payload"]),
+ ),
+ data_file_meta_with_path(
+ "blob-older-missing.blob",
+ 1024,
+ 1024,
+ 1,
+ 5,
+ Some(vec!["payload"]),
+ ),
+ ])
+ .build()
+ .unwrap();
+
+ let read = TableRead::new(&table, table.schema().fields().to_vec(),
Vec::new());
+ let mut stream = read.to_arrow(&[split]).unwrap();
+ let first_batch = stream.try_next().await.unwrap().unwrap();
+
+ assert_eq!(first_batch.num_rows(), 1024);
+ let ids = collect_int_values(std::slice::from_ref(&first_batch), "id");
+ assert_eq!(ids.first(), Some(&0));
+ assert_eq!(ids.last(), Some(&1023));
+ assert!(
+ collect_binary_values(std::slice::from_ref(&first_batch),
"payload")
+ .into_iter()
+ .all(|value| value.as_deref() == Some(&b"old"[..]))
+ );
+
+ assert!(stream.try_next().await.is_err());
+ }
+
#[tokio::test]
async fn test_table_read_merges_multiple_blob_columns_with_row_ranges() {
let tempdir = tempdir().unwrap();
@@ -4150,6 +4769,48 @@ mod tests {
file
}
+ async fn write_schema_file(table: &Table, schema: &TableSchema) {
+ let path = table.schema_manager().schema_path(schema.id());
+ let dir = path.rsplit_once('/').map(|(dir, _)| dir).unwrap();
+ table.file_io().mkdirs(dir).await.unwrap();
+ let json = serde_json::to_vec(schema).unwrap();
+ table
+ .file_io()
+ .new_output(&path)
+ .unwrap()
+ .write(Bytes::from(json))
+ .await
+ .unwrap();
+ }
+
+ async fn write_test_deletion_file(
+ file_io: &crate::io::FileIO,
+ path: &str,
+ deleted_rows: &[u32],
+ ) -> DeletionFile {
+ let mut bitmap = RoaringBitmap::new();
+ for row in deleted_rows {
+ bitmap.insert(*row);
+ }
+ let bytes = DeletionVector::from_bitmap(bitmap)
+ .serialize_to_bytes()
+ .unwrap();
+ let bitmap_length =
i32::from_be_bytes(bytes[0..4].try_into().unwrap());
+ file_io
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from(bytes))
+ .await
+ .unwrap();
+
+ DeletionFile::new(
+ path.to_string(),
+ 0,
+ bitmap_length as i64,
+ Some(deleted_rows.len() as i64),
+ )
+ }
+
fn copy_blob_fixture(name: &str, destination: &Path) {
let source = blob_fixture_path(name);
fs::copy(&source, destination).unwrap_or_else(|e| {
diff --git a/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
new file mode 100644
index 0000000..e34dbca
--- /dev/null
+++ b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
@@ -0,0 +1,432 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::{
+ blob_file_row_range, row_range_overlaps_any,
selected_absolute_row_ranges_for_file, BlobBunch,
+ DeletionVectorContext,
+};
+use crate::arrow::build_target_arrow_schema;
+use crate::arrow::format::blob::{BlobReadValue, IndexedBlobReader};
+use crate::io::FileIO;
+use crate::spec::DataField;
+use crate::table::{ArrowRecordBatchStream, RowRange};
+use crate::{DataSplit, Error};
+use arrow_array::builder::BinaryBuilder;
+use arrow_array::RecordBatch;
+use async_stream::try_stream;
+use futures::StreamExt;
+use std::collections::VecDeque;
+use std::sync::Arc;
+
+const BATCH_SIZE: usize = 1024;
+
+struct LazyBlobFile {
+ range: RowRange,
+ file_name: String,
+ path: String,
+ file_size: i64,
+ row_count: i64,
+ reader: Option<IndexedBlobReader>,
+}
+
+impl LazyBlobFile {
+ async fn read_positions(
+ &mut self,
+ positions: &[usize],
+ file_io: &FileIO,
+ blob_as_descriptor: bool,
+ ) -> crate::Result<Vec<BlobReadValue>> {
+ if self.reader.is_none() {
+ let file_size = u64::try_from(self.file_size).map_err(|e|
Error::DataInvalid {
+ message: format!(
+ "Blob file '{}' has negative file size {}",
+ self.file_name, self.file_size
+ ),
+ source: Some(Box::new(e)),
+ })?;
+ let input = file_io.new_input(&self.path)?;
+ let reader = input.reader().await?;
+ let reader = IndexedBlobReader::open(
+ Box::new(reader),
+ file_size,
+ self.path.clone(),
+ blob_as_descriptor,
+ )
+ .await?;
+ let indexed_rows =
+ i64::try_from(reader.num_rows()).map_err(|e|
Error::DataInvalid {
+ message: format!(
+ "Blob file '{}' index row count {} exceeds i64",
+ self.file_name,
+ reader.num_rows()
+ ),
+ source: Some(Box::new(e)),
+ })?;
+ if indexed_rows != self.row_count {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Blob file '{}' index contains {indexed_rows} rows but
metadata declares {}",
+ self.file_name, self.row_count
+ ),
+ source: None,
+ });
+ }
+ self.reader = Some(reader);
+ }
+
+ self.reader
+ .as_ref()
+ .expect("blob reader is initialized above")
+ .read_positions(positions)
+ .await
+ }
+
+ fn release_reader(&mut self) {
+ self.reader = None;
+ }
+}
+
+pub(super) fn read(
+ split: &DataSplit,
+ bunch: BlobBunch,
+ read_fields: Vec<DataField>,
+ row_ranges: Option<Vec<RowRange>>,
+ file_io: FileIO,
+ blob_as_descriptor: bool,
+ anchor_deletion_vector: Option<DeletionVectorContext>,
+) -> crate::Result<ArrowRecordBatchStream> {
+ if read_fields.len() != 1 || !read_fields[0].data_type().is_blob_type() {
+ return Err(Error::DataInvalid {
+ message: "Blob bunch should provide exactly one BLOB
field".to_string(),
+ source: None,
+ });
+ }
+
+ let target_schema = build_target_arrow_schema(&read_fields)?;
+ let split = split.clone();
+
+ Ok(try_stream! {
+ bunch.validate_logical_range()?;
+ let expected_range = bunch.expected_range()?;
+ let selected_ranges = selected_absolute_row_ranges_for_file(
+ bunch.expected_first_row_id,
+ bunch.expected_row_count,
+ row_ranges.as_deref(),
+ anchor_deletion_vector
+ .as_ref()
+ .map(|context| context.deletion_vector.as_ref()),
+ )?
+ .unwrap_or_else(|| vec![expected_range]);
+
+ let mut sequence_groups = Vec::new();
+ for files in bunch.sequence_groups() {
+ let mut group = VecDeque::with_capacity(files.len());
+ for file in files {
+ let range = blob_file_row_range(&file)?;
+ if !row_range_overlaps_any(&range, &selected_ranges) {
+ continue;
+ }
+ let path = split.data_file_path(&file);
+ group.push_back(LazyBlobFile {
+ range,
+ file_name: file.file_name,
+ path,
+ file_size: file.file_size,
+ row_count: file.row_count,
+ reader: None,
+ });
+ }
+ if !group.is_empty() {
+ sequence_groups.push(group);
+ }
+ }
+
+ let mut row_cursor = RowIdBatchCursor::new(selected_ranges);
+ while let Some(row_ids) = row_cursor.next_batch(BATCH_SIZE) {
+ yield resolve_batch(
+ &mut sequence_groups,
+ &row_ids,
+ target_schema.clone(),
+ &file_io,
+ blob_as_descriptor,
+ ).await?;
+ }
+ }
+ .boxed())
+}
+
+async fn resolve_batch(
+ sequence_groups: &mut [VecDeque<LazyBlobFile>],
+ row_ids: &[i64],
+ target_schema: Arc<arrow_schema::Schema>,
+ file_io: &FileIO,
+ blob_as_descriptor: bool,
+) -> crate::Result<RecordBatch> {
+ let mut resolved = (0..row_ids.len())
+ .map(|_| BlobReadValue::Placeholder)
+ .collect::<Vec<_>>();
+ let mut unresolved_count = resolved.len();
+ let batch_from = row_ids[0];
+ let batch_to = *row_ids.last().expect("row id batch is non-empty");
+
+ for group in sequence_groups.iter_mut() {
+ while group
+ .front()
+ .is_some_and(|file| file.range.to() < batch_from)
+ {
+ group.pop_front();
+ }
+ }
+
+ // Groups are newest first. A missing row or placeholder leaves the row
unresolved;
+ // an explicit NULL or value stops fallback.
+ for group in sequence_groups.iter_mut() {
+ for file in group.iter_mut() {
+ if unresolved_count == 0 || file.range.from() > batch_to {
+ break;
+ }
+
+ let mut output_positions = Vec::new();
+ let mut file_positions = Vec::new();
+ for (output_position, row_id) in
row_ids.iter().copied().enumerate() {
+ if !matches!(&resolved[output_position],
BlobReadValue::Placeholder) {
+ continue;
+ }
+ if row_id < file.range.from() || row_id > file.range.to() {
+ continue;
+ }
+ output_positions.push(output_position);
+ file_positions.push(usize::try_from(row_id -
file.range.from()).map_err(|e| {
+ Error::DataInvalid {
+ message: format!(
+ "Blob row id {row_id} cannot be represented as a
file position"
+ ),
+ source: Some(Box::new(e)),
+ }
+ })?);
+ }
+
+ if !file_positions.is_empty() {
+ let values = file
+ .read_positions(&file_positions, file_io,
blob_as_descriptor)
+ .await?;
+ for (output_position, value) in
output_positions.into_iter().zip(values) {
+ if !matches!(&value, BlobReadValue::Placeholder) {
+ resolved[output_position] = value;
+ unresolved_count -= 1;
+ }
+ }
+ }
+
+ if file.range.to() <= batch_to {
+ file.release_reader();
+ }
+ }
+
+ if unresolved_count == 0 {
+ break;
+ }
+ }
+
+ for group in sequence_groups.iter_mut() {
+ while group
+ .front()
+ .is_some_and(|file| file.range.to() <= batch_to)
+ {
+ group.pop_front();
+ }
+ }
+
+ let mut builder = BinaryBuilder::new();
+ for value in resolved {
+ match value {
+ BlobReadValue::Value(bytes) => builder.append_value(bytes),
+ BlobReadValue::Null | BlobReadValue::Placeholder =>
builder.append_null(),
+ }
+ }
+ RecordBatch::try_new(target_schema,
vec![Arc::new(builder.finish())]).map_err(|e| {
+ Error::UnexpectedError {
+ message: format!("Failed to build blob fallback RecordBatch: {e}"),
+ source: Some(Box::new(e)),
+ }
+ })
+}
+
+struct RowIdBatchCursor {
+ ranges: Vec<RowRange>,
+ range_index: usize,
+ next_row_id: Option<i64>,
+}
+
+impl RowIdBatchCursor {
+ fn new(ranges: Vec<RowRange>) -> Self {
+ let next_row_id = ranges.first().map(RowRange::from);
+ Self {
+ ranges,
+ range_index: 0,
+ next_row_id,
+ }
+ }
+
+ fn next_batch(&mut self, batch_size: usize) -> Option<Vec<i64>> {
+ let mut row_ids = Vec::with_capacity(batch_size);
+ while row_ids.len() < batch_size {
+ let Some(row_id) = self.next_row_id else {
+ break;
+ };
+ row_ids.push(row_id);
+
+ let range = &self.ranges[self.range_index];
+ if row_id == range.to() {
+ self.range_index += 1;
+ self.next_row_id =
self.ranges.get(self.range_index).map(RowRange::from);
+ } else {
+ self.next_row_id = Some(row_id + 1);
+ }
+ }
+ (!row_ids.is_empty()).then_some(row_ids)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::io::FileRead;
+ use crate::spec::{BlobType, DataType};
+ use arrow_array::{Array, BinaryArray};
+ use bytes::Bytes;
+ use std::ops::Range;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ #[allow(dead_code)]
+ mod blob_test_utils {
+ include!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../blob_test_utils.rs"
+ ));
+ }
+
+ use blob_test_utils::{build_blob_file_bytes_with_values, BlobFixtureValue};
+
+ #[derive(Clone)]
+ struct TrackingFileRead {
+ bytes: Bytes,
+ reads: Arc<AtomicUsize>,
+ }
+
+ #[async_trait::async_trait]
+ impl FileRead for TrackingFileRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ self.reads.fetch_add(1, Ordering::SeqCst);
+ Ok(self.bytes.slice(range.start as usize..range.end as usize))
+ }
+ }
+
+ async fn tracking_blob_file(
+ file_name: &str,
+ first_row_id: i64,
+ values: &[BlobFixtureValue<'_>],
+ ) -> (LazyBlobFile, Arc<AtomicUsize>) {
+ let bytes = Bytes::from(build_blob_file_bytes_with_values(values));
+ let file_size = bytes.len() as u64;
+ let reads = Arc::new(AtomicUsize::new(0));
+ let reader = IndexedBlobReader::open(
+ Box::new(TrackingFileRead {
+ bytes,
+ reads: reads.clone(),
+ }),
+ file_size,
+ file_name.to_string(),
+ false,
+ )
+ .await
+ .unwrap();
+ reads.store(0, Ordering::SeqCst);
+
+ let last_row_id = first_row_id + i64::try_from(values.len()).unwrap()
- 1;
+ (
+ LazyBlobFile {
+ range: RowRange::new(first_row_id, last_row_id),
+ file_name: file_name.to_string(),
+ path: file_name.to_string(),
+ file_size: i64::try_from(file_size).unwrap(),
+ row_count: i64::try_from(values.len()).unwrap(),
+ reader: Some(reader),
+ },
+ reads,
+ )
+ }
+
+ #[tokio::test]
+ async fn test_resolve_batch_skips_payloads_for_resolved_rows() {
+ use BlobFixtureValue::{Null, Placeholder, Value};
+
+ let (latest, latest_reads) =
+ tracking_blob_file("latest.blob", 0, &[Value(b"new-0"), Null,
Placeholder]).await;
+ let (older, older_reads) = tracking_blob_file(
+ "older.blob",
+ 0,
+ &[
+ Value(b"old-0"),
+ Value(b"old-1"),
+ Value(b"old-2"),
+ Value(b"old-3"),
+ ],
+ )
+ .await;
+ let (oldest, oldest_reads) = tracking_blob_file(
+ "oldest.blob",
+ 0,
+ &[
+ Value(b"ancient-0"),
+ Value(b"ancient-1"),
+ Value(b"ancient-2"),
+ Value(b"ancient-3"),
+ ],
+ )
+ .await;
+ let schema = build_target_arrow_schema(&[DataField::new(
+ 0,
+ "payload".to_string(),
+ DataType::Blob(BlobType::new()),
+ )])
+ .unwrap();
+
+ let mut groups = vec![
+ VecDeque::from([latest]),
+ VecDeque::from([older]),
+ VecDeque::from([oldest]),
+ ];
+ let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+ let batch = resolve_batch(&mut groups, &[0, 1, 2, 3], schema,
&file_io, false)
+ .await
+ .unwrap();
+ let values = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<BinaryArray>()
+ .unwrap();
+
+ assert_eq!(values.value(0), b"new-0");
+ assert!(values.is_null(1));
+ assert_eq!(values.value(2), b"old-2");
+ assert_eq!(values.value(3), b"old-3");
+ assert_eq!(latest_reads.load(Ordering::SeqCst), 1);
+ assert_eq!(older_reads.load(Ordering::SeqCst), 2);
+ assert_eq!(oldest_reads.load(Ordering::SeqCst), 0);
+ }
+}
diff --git a/crates/paimon/testdata/blob/blob-placeholder.blob
b/crates/paimon/testdata/blob/blob-placeholder.blob
new file mode 100644
index 0000000..49d169b
Binary files /dev/null and b/crates/paimon/testdata/blob/blob-placeholder.blob
differ