Skip to content

Commit 7ceb2f8

Browse files
authored
fix(blob): support unknown-length BlobDescriptor ranges (#521)
1 parent 088cc60 commit 7ceb2f8

5 files changed

Lines changed: 500 additions & 52 deletions

File tree

crates/integrations/datafusion/tests/blob_tests.rs

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
mod common;
2323

2424
use arrow_array::{Array, BinaryArray, Int32Array, RecordBatch, StringArray};
25-
use common::{create_sql_context, create_test_env, exec};
25+
use common::{assert_sql_error, create_sql_context, create_test_env, exec};
2626
use paimon::catalog::Identifier;
2727
use paimon::spec::{BlobDescriptor, BlobViewStruct};
2828
use paimon::table::BranchManager;
@@ -577,6 +577,66 @@ async fn test_blob_resolve_descriptor_with_offset() {
577577
assert_eq!(rows[0], (1, "Partial".into(), Some(b"PAYLOAD".to_vec())));
578578
}
579579

580+
#[tokio::test]
581+
async fn test_blob_resolve_unknown_length_descriptor() {
582+
let (tmp, sql_context) = setup(BLOB_TABLE_DDL).await;
583+
584+
let source_data = b"HEADER_PAYLOAD_TRAILER";
585+
let source_path = tmp.path().join("blob_unknown_length.bin");
586+
std::fs::write(&source_path, source_data).unwrap();
587+
588+
let uri = format!("file://{}", source_path.display());
589+
let full_hex = to_hex(&BlobDescriptor::new(uri.clone(), 0, -1).serialize());
590+
let suffix_hex = to_hex(&BlobDescriptor::new(uri.clone(), 7, -1).serialize());
591+
let eof_hex =
592+
to_hex(&BlobDescriptor::new(uri.clone(), source_data.len() as i64, -1).serialize());
593+
let past_eof_hex =
594+
to_hex(&BlobDescriptor::new(uri, source_data.len() as i64 + 5, -1).serialize());
595+
596+
let sql = format!(
597+
"INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
598+
(1, 'Full', X'{full_hex}'), \
599+
(2, 'Suffix', X'{suffix_hex}'), \
600+
(3, 'Eof', X'{eof_hex}'), \
601+
(4, 'PastEof', X'{past_eof_hex}'), \
602+
(5, 'Raw', X'524157'), \
603+
(6, 'Null', NULL)"
604+
);
605+
exec(&sql_context, &sql).await;
606+
607+
let rows = query_id_name_picture(
608+
&sql_context,
609+
"SELECT id, name, picture FROM paimon.test_db.t ORDER BY id",
610+
)
611+
.await;
612+
assert_eq!(
613+
rows,
614+
vec![
615+
(1, "Full".into(), Some(source_data.to_vec())),
616+
(2, "Suffix".into(), Some(b"PAYLOAD_TRAILER".to_vec())),
617+
(3, "Eof".into(), Some(Vec::new())),
618+
(4, "PastEof".into(), Some(Vec::new())),
619+
(5, "Raw".into(), Some(b"RAW".to_vec())),
620+
(6, "Null".into(), None),
621+
]
622+
);
623+
}
624+
625+
#[tokio::test]
626+
async fn test_blob_descriptor_short_read_returns_error() {
627+
let (tmp, sql_context) = setup(BLOB_TABLE_DDL).await;
628+
629+
let source_path = tmp.path().join("blob_short_read.bin");
630+
std::fs::write(&source_path, b"short").unwrap();
631+
let uri = format!("file://{}", source_path.display());
632+
let desc_hex = to_hex(&BlobDescriptor::new(uri, 0, 6).serialize());
633+
let sql = format!(
634+
"INSERT INTO paimon.test_db.t (id, name, picture) VALUES (1, 'Short', X'{desc_hex}')"
635+
);
636+
637+
assert_sql_error(&sql_context, &sql, "Failed to read BlobDescriptor").await;
638+
}
639+
580640
/// Blob files roll independently when `blob.target-file-size` is small.
581641
#[tokio::test]
582642
async fn test_blob_rolling() {
@@ -709,6 +769,123 @@ async fn test_blob_descriptor_field_resolve_descriptor_value() {
709769
);
710770
}
711771

772+
#[tokio::test]
773+
async fn test_blob_descriptor_field_resolve_unknown_length_descriptor() {
774+
let (tmp, sql_context) = setup(
775+
"CREATE TABLE paimon.test_db.t (\
776+
id INT, \
777+
name STRING, \
778+
picture BLOB \
779+
) WITH (\
780+
'data-evolution.enabled' = 'true', \
781+
'row-tracking.enabled' = 'true', \
782+
'blob-descriptor-field' = 'picture'\
783+
)",
784+
)
785+
.await;
786+
787+
let source_data = b"HEADER_PAYLOAD_TRAILER";
788+
let source_path = tmp.path().join("descriptor_unknown_length.bin");
789+
std::fs::write(&source_path, source_data).unwrap();
790+
791+
let uri = format!("file://{}", source_path.display());
792+
let bounded_hex = to_hex(&BlobDescriptor::new(uri.clone(), 0, 6).serialize());
793+
let suffix_hex = to_hex(&BlobDescriptor::new(uri.clone(), 7, -1).serialize());
794+
let eof_hex =
795+
to_hex(&BlobDescriptor::new(uri.clone(), source_data.len() as i64, -1).serialize());
796+
let past_eof_hex =
797+
to_hex(&BlobDescriptor::new(uri, source_data.len() as i64 + 5, -1).serialize());
798+
let sql = format!(
799+
"INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
800+
(1, 'Bounded', X'{bounded_hex}'), \
801+
(2, 'Suffix', X'{suffix_hex}'), \
802+
(3, 'Eof', X'{eof_hex}'), \
803+
(4, 'PastEof', X'{past_eof_hex}'), \
804+
(5, 'Raw', X'524157'), \
805+
(6, 'Null', NULL)"
806+
);
807+
exec(&sql_context, &sql).await;
808+
809+
let rows = query_id_name_picture(
810+
&sql_context,
811+
"SELECT id, name, picture FROM paimon.test_db.t ORDER BY id",
812+
)
813+
.await;
814+
assert_eq!(
815+
rows,
816+
vec![
817+
(1, "Bounded".into(), Some(b"HEADER".to_vec())),
818+
(2, "Suffix".into(), Some(b"PAYLOAD_TRAILER".to_vec())),
819+
(3, "Eof".into(), Some(Vec::new())),
820+
(4, "PastEof".into(), Some(Vec::new())),
821+
(5, "Raw".into(), Some(b"RAW".to_vec())),
822+
(6, "Null".into(), None),
823+
]
824+
);
825+
}
826+
827+
#[tokio::test]
828+
async fn test_blob_descriptor_field_rejects_invalid_length() {
829+
let (tmp, sql_context) = setup(
830+
"CREATE TABLE paimon.test_db.t (\
831+
id INT, \
832+
name STRING, \
833+
picture BLOB \
834+
) WITH (\
835+
'data-evolution.enabled' = 'true', \
836+
'row-tracking.enabled' = 'true', \
837+
'blob-descriptor-field' = 'picture'\
838+
)",
839+
)
840+
.await;
841+
842+
let uri = format!("file://{}", tmp.path().join("unused.bin").display());
843+
let desc_hex = to_hex(&BlobDescriptor::new(uri, 0, -2).serialize());
844+
let sql = format!(
845+
"INSERT INTO paimon.test_db.t (id, name, picture) VALUES (1, 'Invalid', X'{desc_hex}')"
846+
);
847+
exec(&sql_context, &sql).await;
848+
849+
assert_sql_error(
850+
&sql_context,
851+
"SELECT id, name, picture FROM paimon.test_db.t",
852+
"length must be -1 or non-negative",
853+
)
854+
.await;
855+
}
856+
857+
#[tokio::test]
858+
async fn test_blob_descriptor_field_short_read_returns_error() {
859+
let (tmp, sql_context) = setup(
860+
"CREATE TABLE paimon.test_db.t (\
861+
id INT, \
862+
name STRING, \
863+
picture BLOB \
864+
) WITH (\
865+
'data-evolution.enabled' = 'true', \
866+
'row-tracking.enabled' = 'true', \
867+
'blob-descriptor-field' = 'picture'\
868+
)",
869+
)
870+
.await;
871+
872+
let source_path = tmp.path().join("descriptor_short_read.bin");
873+
std::fs::write(&source_path, b"short").unwrap();
874+
let uri = format!("file://{}", source_path.display());
875+
let desc_hex = to_hex(&BlobDescriptor::new(uri, 0, 6).serialize());
876+
let sql = format!(
877+
"INSERT INTO paimon.test_db.t (id, name, picture) VALUES (1, 'Short', X'{desc_hex}')"
878+
);
879+
exec(&sql_context, &sql).await;
880+
881+
assert_sql_error(
882+
&sql_context,
883+
"SELECT id, name, picture FROM paimon.test_db.t",
884+
"Failed to read BlobDescriptor",
885+
)
886+
.await;
887+
}
888+
712889
#[tokio::test]
713890
async fn test_blob_descriptor_filter_before_resolve_skips_filtered_bad_descriptor() {
714891
let (tmp, sql_context) = setup(

crates/paimon/src/arrow/format/blob.rs

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,23 @@ impl BlobFormatWriter {
617617

618618
const BLOB_WRITE_BUFFER_SIZE: u64 = 8 * 1024 * 1024; // 8 MB
619619

620+
fn checked_blob_entry_length(payload_len: u64) -> crate::Result<i64> {
621+
let entry_length = payload_len
622+
.checked_add(BLOB_ENTRY_OVERHEAD)
623+
.ok_or_else(|| Error::DataInvalid {
624+
message: format!(
625+
"Blob entry length overflows u64: payload_length={payload_len}, overhead={BLOB_ENTRY_OVERHEAD}"
626+
),
627+
source: None,
628+
})?;
629+
i64::try_from(entry_length).map_err(|e| Error::DataInvalid {
630+
message: format!(
631+
"Blob entry length exceeds i64: payload_length={payload_len}, entry_length={entry_length}"
632+
),
633+
source: Some(Box::new(e)),
634+
})
635+
}
636+
620637
#[async_trait]
621638
impl FormatFileWriter for BlobFormatWriter {
622639
async fn write(&mut self, batch: &RecordBatch) -> crate::Result<()> {
@@ -643,9 +660,7 @@ impl FormatFileWriter for BlobFormatWriter {
643660

644661
if BlobDescriptor::is_blob_descriptor(value) {
645662
let desc = BlobDescriptor::deserialize(value)?;
646-
let payload_len = desc.length() as u64;
647-
let entry_length = (payload_len + BLOB_ENTRY_OVERHEAD) as i64;
648-
self.lengths.push(entry_length);
663+
let range = desc.range_spec()?;
649664

650665
let file_io = self.file_io.as_ref().ok_or_else(|| Error::DataInvalid {
651666
message:
@@ -654,7 +669,47 @@ impl FormatFileWriter for BlobFormatWriter {
654669
source: None,
655670
})?;
656671
let input = file_io.new_input(desc.uri())?;
657-
let reader = input.reader().await?;
672+
let offset = range.offset();
673+
let payload_len = match range.length() {
674+
Some(length) => length,
675+
None => input
676+
.metadata()
677+
.await
678+
.map_err(|e| Error::UnexpectedError {
679+
message: format!(
680+
"Failed to read metadata for BlobDescriptor '{}': {e}",
681+
desc.uri()
682+
),
683+
source: Some(Box::new(e)),
684+
})?
685+
.size
686+
.saturating_sub(offset),
687+
};
688+
let end = offset
689+
.checked_add(payload_len)
690+
.ok_or_else(|| Error::DataInvalid {
691+
message: format!(
692+
"BlobDescriptor range overflows u64: offset={offset}, length={payload_len}"
693+
),
694+
source: None,
695+
})?;
696+
let entry_length = checked_blob_entry_length(payload_len)?;
697+
let entry_length_u64 = entry_length as u64;
698+
let bytes_written = self
699+
.bytes_written
700+
.checked_add(entry_length_u64)
701+
.ok_or_else(|| Error::DataInvalid {
702+
message: format!(
703+
"Blob file size overflows u64: current_size={}, entry_length={entry_length_u64}",
704+
self.bytes_written
705+
),
706+
source: None,
707+
})?;
708+
let reader = if payload_len == 0 {
709+
None
710+
} else {
711+
Some(input.reader().await?)
712+
};
658713

659714
let mut hasher = crc32fast::Hasher::new();
660715

@@ -664,15 +719,34 @@ impl FormatFileWriter for BlobFormatWriter {
664719
.await?;
665720

666721
// Stream payload in chunks to avoid loading entire blob into memory
667-
let start = desc.offset() as u64;
668-
let end = start + payload_len;
669-
let mut pos = start;
670-
while pos < end {
671-
let chunk_end = (pos + BLOB_WRITE_BUFFER_SIZE).min(end);
672-
let chunk = reader.read(pos..chunk_end).await?;
673-
hasher.update(&chunk);
674-
self.writer.write(chunk).await?;
675-
pos = chunk_end;
722+
if let Some(reader) = reader.as_ref() {
723+
let mut pos = offset;
724+
while pos < end {
725+
let chunk_end = pos.saturating_add(BLOB_WRITE_BUFFER_SIZE).min(end);
726+
let chunk = reader.read(pos..chunk_end).await.map_err(|e| {
727+
Error::UnexpectedError {
728+
message: format!(
729+
"Failed to read BlobDescriptor '{}' range {pos}..{chunk_end}: {e}",
730+
desc.uri()
731+
),
732+
source: Some(Box::new(e)),
733+
}
734+
})?;
735+
let actual_len = chunk.len() as u64;
736+
let expected_len = chunk_end - pos;
737+
if actual_len != expected_len {
738+
return Err(Error::DataInvalid {
739+
message: format!(
740+
"Failed to read BlobDescriptor '{}': short read for range {pos}..{chunk_end}, expected={expected_len} bytes, actual={actual_len} bytes",
741+
desc.uri()
742+
),
743+
source: None,
744+
});
745+
}
746+
hasher.update(&chunk);
747+
self.writer.write(chunk).await?;
748+
pos = chunk_end;
749+
}
676750
}
677751

678752
let entry_length_bytes = entry_length.to_le_bytes();
@@ -685,7 +759,8 @@ impl FormatFileWriter for BlobFormatWriter {
685759
.write(Bytes::copy_from_slice(&hasher.finalize().to_le_bytes()))
686760
.await?;
687761

688-
self.bytes_written += entry_length as u64;
762+
self.lengths.push(entry_length);
763+
self.bytes_written = bytes_written;
689764
} else {
690765
let entry_length = (value.len() + BLOB_ENTRY_OVERHEAD as usize) as i64;
691766
self.lengths.push(entry_length);
@@ -1050,6 +1125,19 @@ mod tests {
10501125
assert_eq!(decoded, values);
10511126
}
10521127

1128+
#[test]
1129+
fn test_checked_blob_entry_length() {
1130+
assert_eq!(
1131+
checked_blob_entry_length(0).unwrap(),
1132+
BLOB_ENTRY_OVERHEAD as i64
1133+
);
1134+
1135+
let max_payload = i64::MAX as u64 - BLOB_ENTRY_OVERHEAD;
1136+
assert_eq!(checked_blob_entry_length(max_payload).unwrap(), i64::MAX);
1137+
assert!(checked_blob_entry_length(max_payload + 1).is_err());
1138+
assert!(checked_blob_entry_length(u64::MAX).is_err());
1139+
}
1140+
10531141
fn basic_blob_rows() -> [Option<&'static [u8]>; 4] {
10541142
[
10551143
Some(&b"hello"[..]),

0 commit comments

Comments
 (0)