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 eed779e [core] Parse primary-key vector index source metadata
(_SOURCE_META) (#515)
eed779e is described below
commit eed779e16cd0bf12f81f92b9a115d6c52b86e9a1
Author: Junrui Lee <[email protected]>
AuthorDate: Mon Jul 13 13:04:34 2026 +0800
[core] Parse primary-key vector index source metadata (_SOURCE_META) (#515)
---
.../src/spec/avro/index_manifest_entry_decode.rs | 25 +-
crates/paimon/src/spec/index_file_meta.rs | 3 +
crates/paimon/src/spec/index_manifest.rs | 145 ++++++-
crates/paimon/src/spec/mod.rs | 3 +
crates/paimon/src/spec/pk_vector_source.rs | 452 +++++++++++++++++++++
.../src/table/btree_global_index_build_builder.rs | 2 +
.../paimon/src/table/global_index_drop_builder.rs | 1 +
crates/paimon/src/table/global_index_scanner.rs | 1 +
.../paimon/src/table/lumina_index_build_builder.rs | 2 +
crates/paimon/src/table/table_commit.rs | 1 +
crates/paimon/src/table/vector_search_builder.rs | 1 +
.../paimon/src/table/vindex_index_build_builder.rs | 3 +
12 files changed, 635 insertions(+), 4 deletions(-)
diff --git a/crates/paimon/src/spec/avro/index_manifest_entry_decode.rs
b/crates/paimon/src/spec/avro/index_manifest_entry_decode.rs
index b35453f..986b27b 100644
--- a/crates/paimon/src/spec/avro/index_manifest_entry_decode.rs
+++ b/crates/paimon/src/spec/avro/index_manifest_entry_decode.rs
@@ -18,7 +18,8 @@
use super::cursor::AvroCursor;
use super::decode::{neg_count_to_usize, AvroRecordDecode};
use super::decode_helpers::{
- normalize_partition, read_bytes_field, read_int_field, read_long_field,
read_string_field,
+ extract_record_schema, normalize_partition, read_bytes_field,
read_int_field, read_long_field,
+ read_string_field,
};
use super::schema::{skip_nullable_field, WriterSchema};
use crate::spec::index_manifest::IndexManifestEntry;
@@ -65,7 +66,8 @@ impl AvroRecordDecode for IndexManifestEntry {
deletion_vectors_ranges =
decode_nullable_dv_ranges(cursor, field.nullable)?;
}
"_GLOBAL_INDEX" => {
- global_index_meta = decode_nullable_global_index(cursor,
field.nullable)?;
+ global_index_meta =
+ decode_nullable_global_index(cursor, field.nullable,
&field.schema)?;
}
_ => skip_nullable_field(cursor, &field.schema,
field.nullable)?,
}
@@ -145,6 +147,7 @@ fn decode_nullable_dv_ranges(
fn decode_nullable_global_index(
cursor: &mut AvroCursor,
nullable: bool,
+ schema: &super::schema::FieldSchema,
) -> crate::Result<Option<GlobalIndexMeta>> {
if nullable {
let idx = cursor.read_union_index()?;
@@ -192,11 +195,29 @@ fn decode_nullable_global_index(
}
};
+ // _SOURCE_META: nullable bytes — only present in >= #8549 writer schemas.
+ // Guard on the writer's nested field list so a legacy 5-field
_GLOBAL_INDEX
+ // record does not misalign the cursor into the next record.
+ let has_source_meta = extract_record_schema(schema)
+ .map(|s| s.fields.iter().any(|f| f.name == "_SOURCE_META"))
+ .unwrap_or(false);
+ let source_meta = if has_source_meta {
+ let u_idx = cursor.read_union_index()?;
+ if u_idx == 0 {
+ None
+ } else {
+ Some(cursor.read_bytes()?.to_vec())
+ }
+ } else {
+ None
+ };
+
Ok(Some(GlobalIndexMeta {
row_range_start,
row_range_end,
index_field_id,
extra_field_ids,
index_meta,
+ source_meta,
}))
}
diff --git a/crates/paimon/src/spec/index_file_meta.rs
b/crates/paimon/src/spec/index_file_meta.rs
index e985bb0..ff1e63c 100644
--- a/crates/paimon/src/spec/index_file_meta.rs
+++ b/crates/paimon/src/spec/index_file_meta.rs
@@ -46,6 +46,9 @@ pub struct GlobalIndexMeta {
#[serde(default, rename = "_INDEX_META", with = "serde_bytes")]
pub index_meta: Option<Vec<u8>>,
+
+ #[serde(default, rename = "_SOURCE_META", with = "serde_bytes")]
+ pub source_meta: Option<Vec<u8>>,
}
/// Metadata of index file.
diff --git a/crates/paimon/src/spec/index_manifest.rs
b/crates/paimon/src/spec/index_manifest.rs
index b8ee2b5..a0430b2 100644
--- a/crates/paimon/src/spec/index_manifest.rs
+++ b/crates/paimon/src/spec/index_manifest.rs
@@ -71,7 +71,8 @@ pub const INDEX_MANIFEST_ENTRY_SCHEMA: &str = r#"{
{"name": "_ROW_RANGE_END", "type": "long"},
{"name": "_INDEX_FIELD_ID", "type": "int"},
{"name": "_EXTRA_FIELD_IDS", "type": ["null", {"type":
"array", "items": "int"}], "default": null},
- {"name": "_INDEX_META", "type": ["null", "bytes"],
"default": null}
+ {"name": "_INDEX_META", "type": ["null", "bytes"],
"default": null},
+ {"name": "_SOURCE_META", "type": ["null", "bytes"],
"default": null}
]
}]
}
@@ -173,7 +174,7 @@ mod tests {
use indexmap::IndexMap;
use super::*;
- use crate::spec::DeletionVectorMeta;
+ use crate::spec::{DeletionVectorMeta, GlobalIndexMeta};
#[test]
fn test_read_index_manifest_file() {
@@ -276,4 +277,144 @@ mod tests {
};
assert_eq!(sample, decoded);
}
+
+ fn global_index_entry(source_meta: Option<Vec<u8>>) -> IndexManifestEntry {
+ IndexManifestEntry {
+ version: 1,
+ kind: FileKind::Add,
+ partition: vec![0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6],
+ bucket: 0,
+ index_file: IndexFileMeta {
+ index_type: "GLOBAL_INDEX".into(),
+ file_name: "gi-1".into(),
+ file_size: 42,
+ row_count: 7,
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: 10,
+ row_range_end: 20,
+ index_field_id: 3,
+ extra_field_ids: Some(vec![4, 5]),
+ index_meta: Some(vec![9, 8, 7]),
+ source_meta,
+ }),
+ },
+ }
+ }
+
+ #[test]
+ fn source_meta_round_trips_through_index_manifest() {
+ // New-format writer schema carries _SOURCE_META; a Some(..) value
must round-trip.
+ let entry = global_index_entry(Some(vec![1, 2, 3]));
+ let bytes = crate::spec::to_avro_bytes_with_compression(
+ INDEX_MANIFEST_ENTRY_SCHEMA,
+ std::slice::from_ref(&entry),
+ crate::spec::DEFAULT_AVRO_COMPRESSION,
+ )
+ .unwrap();
+ let decoded = IndexManifest::read_from_bytes(&bytes).unwrap();
+ assert_eq!(decoded[0], entry);
+ assert_eq!(
+ decoded[0]
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .unwrap()
+ .source_meta,
+ Some(vec![1, 2, 3])
+ );
+
+ // A None source_meta must also round-trip as None.
+ let entry_none = global_index_entry(None);
+ let bytes_none = crate::spec::to_avro_bytes_with_compression(
+ INDEX_MANIFEST_ENTRY_SCHEMA,
+ std::slice::from_ref(&entry_none),
+ crate::spec::DEFAULT_AVRO_COMPRESSION,
+ )
+ .unwrap();
+ let decoded_none =
IndexManifest::read_from_bytes(&bytes_none).unwrap();
+ assert_eq!(decoded_none[0], entry_none);
+ assert_eq!(
+ decoded_none[0]
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .unwrap()
+ .source_meta,
+ None
+ );
+ }
+
+ #[test]
+ fn legacy_five_field_global_index_decodes_without_source_meta() {
+ // 5-field _GLOBAL_INDEX schema (pre-#8549): no _SOURCE_META.
Identical to
+ // INDEX_MANIFEST_ENTRY_SCHEMA with the trailing _SOURCE_META line
removed.
+ const LEGACY_SCHEMA: &str = r#"{
+ "type": "record",
+ "name": "org.apache.paimon.avro.generated.record",
+ "fields": [
+ {"name": "_VERSION", "type": "int"},
+ {"name": "_KIND", "type": "int"},
+ {"name": "_PARTITION", "type": "bytes"},
+ {"name": "_BUCKET", "type": "int"},
+ {"name": "_INDEX_TYPE", "type": "string"},
+ {"name": "_FILE_NAME", "type": "string"},
+ {"name": "_FILE_SIZE", "type": "long"},
+ {"name": "_ROW_COUNT", "type": "long"},
+ {
+ "default": null,
+ "name": "_DELETIONS_VECTORS_RANGES",
+ "type": ["null", {
+ "type": "array",
+ "items": ["null", {
+ "type": "record",
+ "name":
"org.apache.paimon.avro.generated.record__DELETIONS_VECTORS_RANGES",
+ "fields": [
+ {"name": "f0", "type": "string"},
+ {"name": "f1", "type": "int"},
+ {"name": "f2", "type": "int"},
+ {"name": "_CARDINALITY", "type": ["null", "long"],
"default": null}
+ ]
+ }]
+ }]
+ },
+ {
+ "default": null,
+ "name": "_GLOBAL_INDEX",
+ "type": ["null", {
+ "type": "record",
+ "name":
"org.apache.paimon.avro.generated.record__GLOBAL_INDEX",
+ "fields": [
+ {"name": "_ROW_RANGE_START", "type": "long"},
+ {"name": "_ROW_RANGE_END", "type": "long"},
+ {"name": "_INDEX_FIELD_ID", "type": "int"},
+ {"name": "_EXTRA_FIELD_IDS", "type": ["null", {"type":
"array", "items": "int"}], "default": null},
+ {"name": "_INDEX_META", "type": ["null", "bytes"],
"default": null}
+ ]
+ }]
+ }
+ ]
+}"#;
+
+ // Written by a pre-#8549 writer (5-field record, source_meta absent).
+ let entry = global_index_entry(None);
+ let bytes = crate::spec::to_avro_bytes_with_compression(
+ LEGACY_SCHEMA,
+ std::slice::from_ref(&entry),
+ crate::spec::DEFAULT_AVRO_COMPRESSION,
+ )
+ .unwrap();
+ // Decoding with the current 6-field reader must not misalign the
stream.
+ let decoded = IndexManifest::read_from_bytes(&bytes).unwrap();
+ assert_eq!(decoded[0], entry);
+ assert_eq!(
+ decoded[0]
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .unwrap()
+ .source_meta,
+ None
+ );
+ }
}
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 9e8b07b..4614e89 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -64,6 +64,9 @@ pub use manifest_file_meta::*;
mod index_file_meta;
pub use index_file_meta::*;
+mod pk_vector_source;
+pub use pk_vector_source::*;
+
mod index_manifest;
pub use index_manifest::{IndexManifest, IndexManifestEntry};
mod manifest;
diff --git a/crates/paimon/src/spec/pk_vector_source.rs
b/crates/paimon/src/spec/pk_vector_source.rs
new file mode 100644
index 0000000..2685f45
--- /dev/null
+++ b/crates/paimon/src/spec/pk_vector_source.rs
@@ -0,0 +1,452 @@
+// 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.
+
+//! Primary-key vector (bucket-local ANN) source metadata.
+//!
+//! Parses the `_SOURCE_META` blob embedded in
[`crate::spec::GlobalIndexMeta`],
+//! written by Java Paimon's `PkVectorSourceMeta` (apache/paimon#8549). The
blob
+//! lists the ordered source data files backing an ANN segment; a segment's
+//! ordinals concatenate those files in order, so an ordinal maps to a
+//! `(data file, physical row position)` pair.
+//!
+//! Two distinct encodings are involved and must not be conflated: Avro
extracts
+//! the opaque `_SOURCE_META` bytes (handled in the Avro decoder), and the
bytes
+//! *inside* use Java `DataOutput` (big-endian ints/longs, modified-UTF-8 via
+//! `writeUTF`) — parsed here, independent of Avro.
+
+use crate::spec::GlobalIndexMeta;
+
+fn data_invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+/// The `_SOURCE_META` frame version written by Java `PkVectorSourceMeta`.
+const SOURCE_META_VERSION: i32 = 1;
+
+/// One source data file captured when a PK-vector ANN segment was built.
+///
+/// Mirrors Java `org.apache.paimon.index.pkvector.PkVectorSourceFile`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PkVectorSourceFile {
+ file_name: String,
+ row_count: i64,
+}
+
+impl PkVectorSourceFile {
+ pub fn new(file_name: String, row_count: i64) -> crate::Result<Self> {
+ if row_count < 0 {
+ return Err(data_invalid(format!(
+ "source file row count must not be negative: {row_count}"
+ )));
+ }
+ Ok(Self {
+ file_name,
+ row_count,
+ })
+ }
+
+ pub fn file_name(&self) -> &str {
+ &self.file_name
+ }
+
+ pub fn row_count(&self) -> i64 {
+ self.row_count
+ }
+}
+
+/// Ordered source data files for a primary-key vector index payload.
+///
+/// Mirrors Java `org.apache.paimon.index.pkvector.PkVectorSourceMeta`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PkVectorSourceMeta {
+ source_files: Vec<PkVectorSourceFile>,
+}
+
+impl PkVectorSourceMeta {
+ pub fn new(source_files: Vec<PkVectorSourceFile>) -> crate::Result<Self> {
+ if source_files.is_empty() {
+ return Err(data_invalid("a vector index must reference source
files"));
+ }
+ Ok(Self { source_files })
+ }
+
+ pub fn source_files(&self) -> &[PkVectorSourceFile] {
+ &self.source_files
+ }
+
+ /// Build from the `_SOURCE_META` bytes carried on a [`GlobalIndexMeta`].
+ /// Errors if the metadata carries no source blob.
+ pub fn from_global_index_meta(meta: &GlobalIndexMeta) ->
crate::Result<Self> {
+ let bytes = meta
+ .source_meta
+ .as_deref()
+ .ok_or_else(|| data_invalid("global index meta has no vector
source metadata"))?;
+ Self::deserialize(bytes)
+ }
+
+ /// Map an ANN segment ordinal to `(data file name, physical row
position)`.
+ ///
+ /// Ordinals concatenate source files in stored order: file `i` owns
+ /// `[sum(rows[..i]), sum(rows[..=i]))`. The returned position is local to
+ /// its file.
+ pub fn resolve(&self, ordinal: i64) -> crate::Result<(String, i64)> {
+ if ordinal < 0 {
+ return Err(data_invalid(format!(
+ "vector ordinal must not be negative: {ordinal}"
+ )));
+ }
+ let mut cumulative: i64 = 0;
+ for file in &self.source_files {
+ let next = cumulative
+ .checked_add(file.row_count)
+ .ok_or_else(|| data_invalid("vector source row counts overflow
i64"))?;
+ if ordinal < next {
+ return Ok((file.file_name.clone(), ordinal - cumulative));
+ }
+ cumulative = next;
+ }
+ Err(data_invalid(format!(
+ "vector ordinal {ordinal} is out of range (total rows
{cumulative})"
+ )))
+ }
+
+ /// Parse a Java `PkVectorSourceMeta`-serialized `_SOURCE_META` blob.
+ pub fn deserialize(bytes: &[u8]) -> crate::Result<Self> {
+ let mut cursor = DataInputCursor::new(bytes);
+ let version = cursor.read_i32_be()?;
+ if version != SOURCE_META_VERSION {
+ return Err(data_invalid(format!(
+ "unsupported vector source version: {version}"
+ )));
+ }
+ let count = cursor.read_i32_be()?;
+ if count <= 0 {
+ return Err(data_invalid("a vector index must reference source
files"));
+ }
+ // NOT Vec::with_capacity(count): count is untrusted and may be huge.
+ let mut source_files = Vec::new();
+ for _ in 0..count {
+ let file_name = read_java_utf(&mut cursor)?;
+ let row_count = cursor.read_i64_be()?;
+ source_files.push(PkVectorSourceFile::new(file_name, row_count)?);
+ }
+ if cursor.remaining() != 0 {
+ return Err(data_invalid(
+ "unexpected trailing bytes in vector source metadata",
+ ));
+ }
+ Self::new(source_files)
+ }
+}
+
+/// Minimal big-endian reader mirroring the Java `DataInput` primitives the
+/// `_SOURCE_META` frame uses. Module-private by design (see the PR1 spec: no
+/// shared `common/` abstraction until a second consumer exists).
+struct DataInputCursor<'a> {
+ bytes: &'a [u8],
+ position: usize,
+}
+
+impl<'a> DataInputCursor<'a> {
+ fn new(bytes: &'a [u8]) -> Self {
+ Self { bytes, position: 0 }
+ }
+
+ fn remaining(&self) -> usize {
+ self.bytes.len() - self.position
+ }
+
+ fn read_exact(&mut self, len: usize) -> crate::Result<&'a [u8]> {
+ if self.remaining() < len {
+ return Err(data_invalid(format!(
+ "unexpected end of vector source metadata: need {len} bytes,
{} remain",
+ self.remaining()
+ )));
+ }
+ let slice = &self.bytes[self.position..self.position + len];
+ self.position += len;
+ Ok(slice)
+ }
+
+ fn read_u16_be(&mut self) -> crate::Result<u16> {
+ let b = self.read_exact(2)?;
+ Ok(u16::from_be_bytes([b[0], b[1]]))
+ }
+
+ fn read_i32_be(&mut self) -> crate::Result<i32> {
+ let b = self.read_exact(4)?;
+ Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]]))
+ }
+
+ fn read_i64_be(&mut self) -> crate::Result<i64> {
+ let b = self.read_exact(8)?;
+ Ok(i64::from_be_bytes([
+ b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
+ ]))
+ }
+}
+
+/// Decode one Java `DataOutput#writeUTF` value: a u16-BE byte length followed
by
+/// modified UTF-8. Reassembles UTF-16 code units, then `String::from_utf16`.
+///
+/// A Java-written NUL is the two-byte sequence `C0 80`; a raw `0x00` is
accepted
+/// (Java `DataInputStream#readUTF` treats `0x00..=0x7F` as single-byte chars).
+fn read_java_utf(cursor: &mut DataInputCursor<'_>) -> crate::Result<String> {
+ let len = cursor.read_u16_be()? as usize;
+ let bytes = cursor.read_exact(len)?;
+ let mut units: Vec<u16> = Vec::new();
+ let mut i = 0;
+ while i < len {
+ let b0 = bytes[i];
+ if b0 & 0x80 == 0 {
+ // 1-byte: 0xxxxxxx (includes raw 0x00)
+ units.push(b0 as u16);
+ i += 1;
+ } else if b0 & 0xE0 == 0xC0 {
+ // 2-byte: 110xxxxx 10xxxxxx
+ if i + 1 >= len || bytes[i + 1] & 0xC0 != 0x80 {
+ return Err(data_invalid("malformed modified UTF-8 (2-byte)"));
+ }
+ let u = (((b0 & 0x1F) as u16) << 6) | ((bytes[i + 1] & 0x3F) as
u16);
+ units.push(u);
+ i += 2;
+ } else if b0 & 0xF0 == 0xE0 {
+ // 3-byte: 1110xxxx 10xxxxxx 10xxxxxx
+ if i + 2 >= len || bytes[i + 1] & 0xC0 != 0x80 || bytes[i + 2] &
0xC0 != 0x80 {
+ return Err(data_invalid("malformed modified UTF-8 (3-byte)"));
+ }
+ let u = (((b0 & 0x0F) as u16) << 12)
+ | (((bytes[i + 1] & 0x3F) as u16) << 6)
+ | ((bytes[i + 2] & 0x3F) as u16);
+ units.push(u);
+ i += 3;
+ } else {
+ return Err(data_invalid("malformed modified UTF-8 (lead byte)"));
+ }
+ }
+ String::from_utf16(&units)
+ .map_err(|_| data_invalid("modified UTF-8 did not decode to valid
UTF-16"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Build a Java `writeUTF` byte sequence (u16-BE length + modified UTF-8)
+ /// for ASCII/BMP test strings. Encodes via UTF-16 code units to match
Java.
+ fn java_write_utf(s: &str) -> Vec<u8> {
+ let mut body = Vec::new();
+ for c in s.encode_utf16() {
+ if (0x0001..=0x007F).contains(&c) {
+ body.push(c as u8);
+ } else if c > 0x07FF {
+ body.push(0xE0 | (c >> 12) as u8);
+ body.push(0x80 | ((c >> 6) & 0x3F) as u8);
+ body.push(0x80 | (c & 0x3F) as u8);
+ } else {
+ body.push(0xC0 | (c >> 6) as u8);
+ body.push(0x80 | (c & 0x3F) as u8);
+ }
+ }
+ let mut out = (body.len() as u16).to_be_bytes().to_vec();
+ out.extend_from_slice(&body);
+ out
+ }
+
+ fn frame(files: &[(&str, i64)]) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&1i32.to_be_bytes()); // version
+ out.extend_from_slice(&(files.len() as i32).to_be_bytes());
+ for (name, rows) in files {
+ out.extend_from_slice(&java_write_utf(name));
+ out.extend_from_slice(&rows.to_be_bytes());
+ }
+ out
+ }
+
+ #[test]
+ fn read_java_utf_round_trips_ascii_and_multibyte() {
+ for s in ["data-abc.parquet", "café", "ü", "日本語", "🚀"] {
+ let bytes = java_write_utf(s);
+ let mut cursor = DataInputCursor::new(&bytes);
+ assert_eq!(read_java_utf(&mut cursor).unwrap(), s);
+ assert_eq!(cursor.remaining(), 0);
+ }
+ }
+
+ #[test]
+ fn read_java_utf_decodes_c0_80_as_nul() {
+ // Java writeUTF encodes U+0000 as 0xC0 0x80.
+ let bytes = [0x00, 0x02, 0xC0, 0x80];
+ let mut cursor = DataInputCursor::new(&bytes);
+ assert_eq!(read_java_utf(&mut cursor).unwrap(), "\u{0000}");
+ }
+
+ #[test]
+ fn read_java_utf_accepts_raw_nul() {
+ // Raw 0x00 is a valid single-byte char for Java readUTF; must not
error.
+ let bytes = [0x00, 0x01, 0x00];
+ let mut cursor = DataInputCursor::new(&bytes);
+ assert_eq!(read_java_utf(&mut cursor).unwrap(), "\u{0000}");
+ }
+
+ #[test]
+ fn read_java_utf_rejects_truncated_and_bad_continuation() {
+ // 2-byte lead with no continuation.
+ let mut c1 = DataInputCursor::new(&[0x00, 0x01, 0xC0]);
+ assert!(read_java_utf(&mut c1).is_err());
+ // 3-byte lead with bad 2nd byte.
+ let mut c2 = DataInputCursor::new(&[0x00, 0x03, 0xE0, 0x00, 0x80]);
+ assert!(read_java_utf(&mut c2).is_err());
+ }
+
+ #[test]
+ fn deserialize_single_source_file() {
+ let bytes = frame(&[("data-abc.parquet", 100)]);
+ let meta = PkVectorSourceMeta::deserialize(&bytes).unwrap();
+ assert_eq!(meta.source_files().len(), 1);
+ assert_eq!(meta.source_files()[0].file_name(), "data-abc.parquet");
+ assert_eq!(meta.source_files()[0].row_count(), 100);
+ }
+
+ #[test]
+ fn deserialize_multi_source_files() {
+ let bytes = frame(&[("f0", 3), ("f1", 5)]);
+ let meta = PkVectorSourceMeta::deserialize(&bytes).unwrap();
+ assert_eq!(meta.source_files().len(), 2);
+ assert_eq!(meta.source_files()[1].row_count(), 5);
+ }
+
+ #[test]
+ fn deserialize_rejects_bad_version() {
+ let mut bytes = frame(&[("f0", 1)]);
+ bytes[0..4].copy_from_slice(&2i32.to_be_bytes());
+ assert!(PkVectorSourceMeta::deserialize(&bytes).is_err());
+ }
+
+ #[test]
+ fn deserialize_rejects_zero_count() {
+ let mut out = Vec::new();
+ out.extend_from_slice(&1i32.to_be_bytes());
+ out.extend_from_slice(&0i32.to_be_bytes());
+ assert!(PkVectorSourceMeta::deserialize(&out).is_err());
+ }
+
+ #[test]
+ fn deserialize_rejects_trailing_bytes() {
+ let mut bytes = frame(&[("f0", 1)]);
+ bytes.push(0xFF);
+ assert!(PkVectorSourceMeta::deserialize(&bytes).is_err());
+ }
+
+ #[test]
+ fn deserialize_rejects_truncated_input() {
+ let bytes = frame(&[("f0", 1)]);
+ assert!(PkVectorSourceMeta::deserialize(&bytes[..bytes.len() -
2]).is_err());
+ }
+
+ #[test]
+ fn deserialize_rejects_negative_row_count() {
+ let bytes = frame(&[("f0", -1)]);
+ assert!(PkVectorSourceMeta::deserialize(&bytes).is_err());
+ }
+
+ #[test]
+ fn new_rejects_empty() {
+ assert!(PkVectorSourceMeta::new(Vec::new()).is_err());
+ }
+
+ #[test]
+ fn resolve_single_file() {
+ let meta = PkVectorSourceMeta::deserialize(&frame(&[("f0",
3)])).unwrap();
+ assert_eq!(meta.resolve(0).unwrap(), ("f0".to_string(), 0));
+ assert_eq!(meta.resolve(2).unwrap(), ("f0".to_string(), 2));
+ }
+
+ #[test]
+ fn resolve_multi_file_prefix_sum_boundaries() {
+ // f0 owns ordinals 0..=2, f1 owns 3..=7.
+ let meta = PkVectorSourceMeta::deserialize(&frame(&[("f0", 3), ("f1",
5)])).unwrap();
+ assert_eq!(meta.resolve(2).unwrap(), ("f0".to_string(), 2)); // last
of f0
+ assert_eq!(meta.resolve(3).unwrap(), ("f1".to_string(), 0)); // first
of f1
+ assert_eq!(meta.resolve(7).unwrap(), ("f1".to_string(), 4)); // last
of f1
+ }
+
+ #[test]
+ fn resolve_rejects_negative_ordinal() {
+ let meta = PkVectorSourceMeta::deserialize(&frame(&[("f0",
3)])).unwrap();
+ assert!(meta.resolve(-1).is_err());
+ }
+
+ #[test]
+ fn resolve_rejects_ordinal_at_or_past_total() {
+ let meta = PkVectorSourceMeta::deserialize(&frame(&[("f0",
3)])).unwrap();
+ assert!(meta.resolve(3).is_err()); // total == 3, valid range 0..=2
+ }
+
+ #[test]
+ fn from_global_index_meta_errors_without_source_meta() {
+ let meta = GlobalIndexMeta {
+ row_range_start: 0,
+ row_range_end: 0,
+ index_field_id: 0,
+ extra_field_ids: None,
+ index_meta: None,
+ source_meta: None,
+ };
+ assert!(PkVectorSourceMeta::from_global_index_meta(&meta).is_err());
+ }
+
+ #[test]
+ fn from_global_index_meta_parses_source_meta() {
+ let meta = GlobalIndexMeta {
+ row_range_start: 0,
+ row_range_end: 0,
+ index_field_id: 0,
+ extra_field_ids: None,
+ index_meta: None,
+ source_meta: Some(frame(&[("f0", 3)])),
+ };
+ let parsed =
PkVectorSourceMeta::from_global_index_meta(&meta).unwrap();
+ assert_eq!(parsed.source_files()[0].file_name(), "f0");
+ }
+
+ #[test]
+ fn resolve_rejects_row_count_overflow() {
+ // Two individually-valid row counts whose prefix sum overflows i64.
+ // Resolving past the first file forces the checked_add on the second.
+ let meta = PkVectorSourceMeta::new(vec![
+ PkVectorSourceFile::new("f0".to_string(), i64::MAX).unwrap(),
+ PkVectorSourceFile::new("f1".to_string(), 1).unwrap(),
+ ])
+ .unwrap();
+ assert!(meta.resolve(i64::MAX).is_err());
+ }
+
+ #[test]
+ fn read_java_utf_rejects_lone_high_surrogate() {
+ // U+D800 (a lone high surrogate) as 3-byte modified UTF-8: ED A0 80.
+ // It decodes to a single UTF-16 code unit that is not valid on its
own,
+ // so String::from_utf16 must reject it (not panic).
+ let bytes = [0x00, 0x03, 0xED, 0xA0, 0x80];
+ let mut cursor = DataInputCursor::new(&bytes);
+ assert!(read_java_utf(&mut cursor).is_err());
+ }
+}
diff --git a/crates/paimon/src/table/btree_global_index_build_builder.rs
b/crates/paimon/src/table/btree_global_index_build_builder.rs
index cc87c70..0b759b1 100644
--- a/crates/paimon/src/table/btree_global_index_build_builder.rs
+++ b/crates/paimon/src/table/btree_global_index_build_builder.rs
@@ -286,6 +286,7 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
row_range_end: shard.row_range_end,
index_field_id: index_field.id(),
extra_field_ids: None,
+ source_meta: None,
index_meta: Some(index_meta.serialize()),
}),
})
@@ -1989,6 +1990,7 @@ mod tests {
row_range_end: hole_end,
index_field_id: name_field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: None,
}),
};
diff --git a/crates/paimon/src/table/global_index_drop_builder.rs
b/crates/paimon/src/table/global_index_drop_builder.rs
index e9ab86c..cc86ceb 100644
--- a/crates/paimon/src/table/global_index_drop_builder.rs
+++ b/crates/paimon/src/table/global_index_drop_builder.rs
@@ -216,6 +216,7 @@ mod tests {
row_range_end,
index_field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: None,
}),
}
diff --git a/crates/paimon/src/table/global_index_scanner.rs
b/crates/paimon/src/table/global_index_scanner.rs
index 57cce1c..adfc628 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -1439,6 +1439,7 @@ mod tests {
row_range_end,
index_field_id: field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: Some(meta.serialize()),
}),
},
diff --git a/crates/paimon/src/table/lumina_index_build_builder.rs
b/crates/paimon/src/table/lumina_index_build_builder.rs
index 4a2980a..cbff6e7 100644
--- a/crates/paimon/src/table/lumina_index_build_builder.rs
+++ b/crates/paimon/src/table/lumina_index_build_builder.rs
@@ -241,6 +241,7 @@ impl<'a> LuminaIndexBuildBuilder<'a> {
row_range_end: shard.row_range_end,
index_field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: Some(index_meta),
}),
})
@@ -1697,6 +1698,7 @@ mod tests {
row_range_end: end,
index_field_id: field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: None,
}),
};
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index dad551f..2a81047 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -2884,6 +2884,7 @@ mod tests {
row_range_end,
index_field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: None,
}),
}
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index c439bc6..c5bdc96 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -1897,6 +1897,7 @@ mod tests {
row_range_end: 9,
index_field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: None,
}),
},
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
index 5803af8..0f0d8c2 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -266,6 +266,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
row_range_end: shard.row_range_end,
index_field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: Some(index_meta),
}),
})
@@ -1091,6 +1092,7 @@ mod tests {
row_range_end: end,
index_field_id: field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: None,
}),
};
@@ -1333,6 +1335,7 @@ mod tests {
row_range_end: coverage[0].to(),
index_field_id: field_id,
extra_field_ids: None,
+ source_meta: None,
index_meta: None,
}),
};