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 d0e2efb0 feat(table): add primary-key full-text read data layer (#594)
d0e2efb0 is described below
commit d0e2efb098ea62f653788d1f85bd1588dbcbf7a9
Author: Junrui Lee <[email protected]>
AuthorDate: Thu Jul 23 14:06:53 2026 +0800
feat(table): add primary-key full-text read data layer (#594)
---
crates/paimon/src/spec/core_options.rs | 70 +++
crates/paimon/src/spec/mod.rs | 4 +-
.../{pk_vector_source.rs => pk_index_source.rs} | 114 ++--
crates/paimon/src/table/mod.rs | 1 +
.../paimon/src/table/pk_full_text_bucket_state.rs | 614 +++++++++++++++++++++
crates/paimon/src/table/pk_vector_orchestrator.rs | 7 +-
crates/paimon/src/table/pk_vector_scan.rs | 27 +-
crates/paimon/src/table/vector_search_builder.rs | 8 +-
crates/paimon/src/vindex/pkvector/ann.rs | 58 +-
crates/paimon/src/vindex/pkvector/bucket.rs | 14 +-
crates/paimon/tests/pk_vector_baseline_test.rs | 12 +-
crates/paimon/tests/pk_vector_java_fixture_test.rs | 2 +-
12 files changed, 815 insertions(+), 116 deletions(-)
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index eec1ca57..fce04169 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -123,6 +123,7 @@ pub(crate) const BLOB_DESCRIPTOR_FIELD_OPTION: &str =
"blob-descriptor-field";
pub(crate) const BLOB_VIEW_FIELD_OPTION: &str = "blob-view-field";
pub const BLOB_VIEW_RESOLVE_ENABLED_OPTION: &str = "blob-view.resolve.enabled";
const PK_VECTOR_INDEX_COLUMNS_OPTION: &str = "pk-vector.index.columns";
+const PK_FULL_TEXT_INDEX_COLUMNS_OPTION: &str = "pk-full-text.index.columns";
/// Merge engine for primary-key tables.
///
@@ -1198,6 +1199,23 @@ impl<'a> CoreOptions<'a> {
crate::vindex::pkvector::metric::VectorSearchMetric::parse(&raw)?;
Ok(raw)
}
+
+ /// True when the PK full-text index column option key is present (any
value).
+ pub fn primary_key_full_text_index_enabled(&self) -> bool {
+ self.options.contains_key(PK_FULL_TEXT_INDEX_COLUMNS_OPTION)
+ }
+
+ /// Configured PK full-text index columns: split on ',' and trim each
token,
+ /// mirroring Java `split(",",-1).map(trim)`. Blank tokens are PRESERVED
(do NOT
+ /// filter them) so parsing matches Java exactly; `[]` only when the key is
+ /// absent. Never returns a `Result`, never errors (do not copy the
fail-loud
+ /// shape of `primary_key_vector_index_columns`).
+ pub fn primary_key_full_text_index_columns(&self) -> Vec<String> {
+ match self.options.get(PK_FULL_TEXT_INDEX_COLUMNS_OPTION) {
+ None => Vec::new(),
+ Some(raw) => raw.split(',').map(|c|
c.trim().to_string()).collect(),
+ }
+ }
}
/// Parse a memory size string to bytes using binary (1024-based) semantics.
@@ -2147,4 +2165,56 @@ mod tests {
.primary_key_vector_index_type("e")
.is_err());
}
+
+ #[test]
+ fn test_pk_full_text_index_absent_is_disabled_and_empty() {
+ let opts = HashMap::new();
+ let co = CoreOptions::new(&opts);
+ assert!(!co.primary_key_full_text_index_enabled());
+ assert_eq!(
+ co.primary_key_full_text_index_columns(),
+ Vec::<String>::new()
+ );
+ }
+
+ #[test]
+ fn test_pk_full_text_index_columns_split_and_trim() {
+ let opts = HashMap::from([(
+ "pk-full-text.index.columns".to_string(),
+ "a, b ,c".to_string(),
+ )]);
+ let co = CoreOptions::new(&opts);
+ assert!(co.primary_key_full_text_index_enabled());
+ assert_eq!(
+ co.primary_key_full_text_index_columns(),
+ vec!["a".to_string(), "b".to_string(), "c".to_string()]
+ );
+ }
+
+ #[test]
+ fn test_pk_full_text_index_columns_preserve_blank_tokens() {
+ // Java `split(",",-1).map(trim)` keeps empty tokens, so "a,,b" yields
+ // three columns with a blank in the middle.
+ let opts = HashMap::from([("pk-full-text.index.columns".to_string(),
"a,,b".to_string())]);
+ let co = CoreOptions::new(&opts);
+ assert!(co.primary_key_full_text_index_enabled());
+ assert_eq!(
+ co.primary_key_full_text_index_columns(),
+ vec!["a".to_string(), "".to_string(), "b".to_string()]
+ );
+ }
+
+ #[test]
+ fn test_pk_full_text_index_blank_value_is_enabled_but_empty_no_error() {
+ // key present but only blanks: enabled (key exists), NO error
(lenient,
+ // unlike vector). Blank tokens are PRESERVED to match Java's
+ // `split(",",-1).map(trim)`, so " , " yields two empty tokens (NOT
[]).
+ let opts = HashMap::from([("pk-full-text.index.columns".to_string(), "
, ".to_string())]);
+ let co = CoreOptions::new(&opts);
+ assert!(co.primary_key_full_text_index_enabled());
+ assert_eq!(
+ co.primary_key_full_text_index_columns(),
+ vec!["".to_string(), "".to_string()]
+ );
+ }
}
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 13f9c3d6..2902ee72 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -64,8 +64,8 @@ pub use manifest_file_meta::*;
mod index_file_meta;
pub use index_file_meta::*;
-mod pk_vector_source;
-pub use pk_vector_source::*;
+mod pk_index_source;
+pub use pk_index_source::*;
mod index_manifest;
pub use index_manifest::{IndexManifest, IndexManifestEntry};
diff --git a/crates/paimon/src/spec/pk_vector_source.rs
b/crates/paimon/src/spec/pk_index_source.rs
similarity index 77%
rename from crates/paimon/src/spec/pk_vector_source.rs
rename to crates/paimon/src/spec/pk_index_source.rs
index 802cbf7c..a821bc6d 100644
--- a/crates/paimon/src/spec/pk_vector_source.rs
+++ b/crates/paimon/src/spec/pk_index_source.rs
@@ -15,20 +15,30 @@
// specific language governing permissions and limitations
// under the License.
-//! Primary-key vector (bucket-local ANN) source metadata.
+//! Shared primary-key index 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.
+//! written by Java Paimon's `PrimaryKeyIndexSourceMeta` (apache/paimon#8549).
The
+//! blob lists the ordered source data files backing an index payload; the
+//! payload's ordinals concatenate those files in order, so an ordinal maps to
a
+//! `(data file, physical row position)` pair. Shared by the primary-key vector
+//! and full-text read paths.
//!
//! 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;
+use crate::spec::{DataFileMeta, GlobalIndexMeta};
+
+/// Compacted file source discriminant, matching Java `FileSource.COMPACT`.
+const FILE_SOURCE_COMPACT: i32 = 1;
+
+/// Mirror of `PrimaryKeyIndexSourcePolicy.shouldRead`: only compacted,
non-level-0
+/// files back a primary-key index; an absent file source reads as false.
+pub fn should_read_pk_index_source(file: &DataFileMeta) -> bool {
+ matches!(file.file_source, Some(src) if src == FILE_SOURCE_COMPACT) &&
file.level > 0
+}
fn data_invalid(message: impl Into<String>) -> crate::Error {
crate::Error::DataInvalid {
@@ -37,19 +47,19 @@ fn data_invalid(message: impl Into<String>) -> crate::Error
{
}
}
-/// The `_SOURCE_META` frame version written by Java `PkVectorSourceMeta`.
+/// The `_SOURCE_META` frame version written by Java
`PrimaryKeyIndexSourceMeta`.
const SOURCE_META_VERSION: i32 = 1;
-/// One source data file captured when a PK-vector ANN segment was built.
+/// One source data file captured when a primary-key index payload was built.
///
-/// Mirrors Java `org.apache.paimon.index.pkvector.PkVectorSourceFile`.
+/// Mirrors Java `org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile`.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PkVectorSourceFile {
+pub struct PrimaryKeyIndexSourceFile {
file_name: String,
row_count: i64,
}
-impl PkVectorSourceFile {
+impl PrimaryKeyIndexSourceFile {
pub fn new(file_name: String, row_count: i64) -> crate::Result<Self> {
if row_count < 0 {
return Err(data_invalid(format!(
@@ -71,24 +81,27 @@ impl PkVectorSourceFile {
}
}
-/// Ordered source data files for a primary-key vector index payload.
+/// Ordered source data files for a primary-key index payload.
///
-/// Mirrors Java `org.apache.paimon.index.pkvector.PkVectorSourceMeta`.
+/// Mirrors Java `org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta`.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PkVectorSourceMeta {
+pub struct PrimaryKeyIndexSourceMeta {
data_level: i32,
- source_files: Vec<PkVectorSourceFile>,
+ source_files: Vec<PrimaryKeyIndexSourceFile>,
}
-impl PkVectorSourceMeta {
- pub fn new(data_level: i32, source_files: Vec<PkVectorSourceFile>) ->
crate::Result<Self> {
+impl PrimaryKeyIndexSourceMeta {
+ pub fn new(
+ data_level: i32,
+ source_files: Vec<PrimaryKeyIndexSourceFile>,
+ ) -> crate::Result<Self> {
if data_level <= 0 {
return Err(data_invalid(format!(
"source meta data level must be positive: {data_level}"
)));
}
if source_files.is_empty() {
- return Err(data_invalid("a vector index must reference source
files"));
+ return Err(data_invalid("an index must reference source files"));
}
Ok(Self {
data_level,
@@ -100,7 +113,7 @@ impl PkVectorSourceMeta {
self.data_level
}
- pub fn source_files(&self) -> &[PkVectorSourceFile] {
+ pub fn source_files(&self) -> &[PrimaryKeyIndexSourceFile] {
&self.source_files
}
@@ -110,7 +123,7 @@ impl PkVectorSourceMeta {
let bytes = meta
.source_meta
.as_deref()
- .ok_or_else(|| data_invalid("global index meta has no vector
source metadata"))?;
+ .ok_or_else(|| data_invalid("global index meta has no source
metadata"))?;
Self::deserialize(bytes)
}
@@ -122,31 +135,31 @@ impl PkVectorSourceMeta {
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}"
+ "source 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"))?;
+ .ok_or_else(|| data_invalid("index 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})"
+ "source ordinal {ordinal} is out of range (total rows
{cumulative})"
)))
}
- /// Parse a Java `PkVectorSourceMeta`-serialized `_SOURCE_META` blob.
+ /// Parse a Java `PrimaryKeyIndexSourceMeta`-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}"
+ "unsupported index source version: {version}"
)));
}
let data_level = cursor.read_i32_be()?;
@@ -157,18 +170,18 @@ impl PkVectorSourceMeta {
}
let count = cursor.read_i32_be()?;
if count <= 0 {
- return Err(data_invalid("a vector index must reference source
files"));
+ return Err(data_invalid("an 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)?);
+ source_files.push(PrimaryKeyIndexSourceFile::new(file_name,
row_count)?);
}
if cursor.remaining() != 0 {
return Err(data_invalid(
- "unexpected trailing bytes in vector source metadata",
+ "unexpected trailing bytes in index source metadata",
));
}
Self::new(data_level, source_files)
@@ -195,7 +208,7 @@ impl<'a> DataInputCursor<'a> {
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",
+ "unexpected end of index source metadata: need {len} bytes, {}
remain",
self.remaining()
)));
}
@@ -340,7 +353,7 @@ mod tests {
#[test]
fn deserialize_single_source_file() {
let bytes = frame(1, &[("data-abc.parquet", 100)]);
- let meta = PkVectorSourceMeta::deserialize(&bytes).unwrap();
+ let meta = PrimaryKeyIndexSourceMeta::deserialize(&bytes).unwrap();
assert_eq!(meta.data_level(), 1);
assert_eq!(meta.source_files().len(), 1);
assert_eq!(meta.source_files()[0].file_name(), "data-abc.parquet");
@@ -350,7 +363,7 @@ mod tests {
#[test]
fn deserialize_multi_source_files() {
let bytes = frame(2, &[("f0", 3), ("f1", 5)]);
- let meta = PkVectorSourceMeta::deserialize(&bytes).unwrap();
+ let meta = PrimaryKeyIndexSourceMeta::deserialize(&bytes).unwrap();
assert_eq!(meta.data_level(), 2);
assert_eq!(meta.source_files().len(), 2);
assert_eq!(meta.source_files()[1].row_count(), 5);
@@ -360,13 +373,13 @@ mod tests {
fn deserialize_rejects_bad_version() {
let mut bytes = frame(1, &[("f0", 1)]);
bytes[0..4].copy_from_slice(&2i32.to_be_bytes());
- assert!(PkVectorSourceMeta::deserialize(&bytes).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::deserialize(&bytes).is_err());
}
#[test]
fn deserialize_rejects_zero_or_negative_data_level() {
- assert!(PkVectorSourceMeta::deserialize(&frame(0, &[("f0",
1)])).is_err());
- assert!(PkVectorSourceMeta::deserialize(&frame(-1, &[("f0",
1)])).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::deserialize(&frame(0, &[("f0",
1)])).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::deserialize(&frame(-1, &[("f0",
1)])).is_err());
}
#[test]
@@ -375,41 +388,41 @@ mod tests {
out.extend_from_slice(&1i32.to_be_bytes());
out.extend_from_slice(&1i32.to_be_bytes());
out.extend_from_slice(&0i32.to_be_bytes());
- assert!(PkVectorSourceMeta::deserialize(&out).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::deserialize(&out).is_err());
}
#[test]
fn deserialize_rejects_trailing_bytes() {
let mut bytes = frame(1, &[("f0", 1)]);
bytes.push(0xFF);
- assert!(PkVectorSourceMeta::deserialize(&bytes).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::deserialize(&bytes).is_err());
}
#[test]
fn deserialize_rejects_truncated_input() {
let bytes = frame(1, &[("f0", 1)]);
- assert!(PkVectorSourceMeta::deserialize(&bytes[..bytes.len() -
2]).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::deserialize(&bytes[..bytes.len() -
2]).is_err());
}
#[test]
fn deserialize_rejects_negative_row_count() {
let bytes = frame(1, &[("f0", -1)]);
- assert!(PkVectorSourceMeta::deserialize(&bytes).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::deserialize(&bytes).is_err());
}
#[test]
fn new_rejects_empty() {
- assert!(PkVectorSourceMeta::new(1, Vec::new()).is_err());
- assert!(PkVectorSourceMeta::new(
+ assert!(PrimaryKeyIndexSourceMeta::new(1, Vec::new()).is_err());
+ assert!(PrimaryKeyIndexSourceMeta::new(
0,
- vec![PkVectorSourceFile::new("f0".to_string(), 1).unwrap()]
+ vec![PrimaryKeyIndexSourceFile::new("f0".to_string(), 1).unwrap()]
)
.is_err());
}
#[test]
fn resolve_single_file() {
- let meta = PkVectorSourceMeta::deserialize(&frame(1, &[("f0",
3)])).unwrap();
+ let meta = PrimaryKeyIndexSourceMeta::deserialize(&frame(1, &[("f0",
3)])).unwrap();
assert_eq!(meta.resolve(0).unwrap(), ("f0".to_string(), 0));
assert_eq!(meta.resolve(2).unwrap(), ("f0".to_string(), 2));
}
@@ -417,7 +430,8 @@ mod tests {
#[test]
fn resolve_multi_file_prefix_sum_boundaries() {
// f0 owns ordinals 0..=2, f1 owns 3..=7.
- let meta = PkVectorSourceMeta::deserialize(&frame(1, &[("f0", 3),
("f1", 5)])).unwrap();
+ let meta =
+ PrimaryKeyIndexSourceMeta::deserialize(&frame(1, &[("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
@@ -425,13 +439,13 @@ mod tests {
#[test]
fn resolve_rejects_negative_ordinal() {
- let meta = PkVectorSourceMeta::deserialize(&frame(1, &[("f0",
3)])).unwrap();
+ let meta = PrimaryKeyIndexSourceMeta::deserialize(&frame(1, &[("f0",
3)])).unwrap();
assert!(meta.resolve(-1).is_err());
}
#[test]
fn resolve_rejects_ordinal_at_or_past_total() {
- let meta = PkVectorSourceMeta::deserialize(&frame(1, &[("f0",
3)])).unwrap();
+ let meta = PrimaryKeyIndexSourceMeta::deserialize(&frame(1, &[("f0",
3)])).unwrap();
assert!(meta.resolve(3).is_err()); // total == 3, valid range 0..=2
}
@@ -445,7 +459,7 @@ mod tests {
index_meta: None,
source_meta: None,
};
- assert!(PkVectorSourceMeta::from_global_index_meta(&meta).is_err());
+
assert!(PrimaryKeyIndexSourceMeta::from_global_index_meta(&meta).is_err());
}
#[test]
@@ -458,7 +472,7 @@ mod tests {
index_meta: None,
source_meta: Some(frame(1, &[("f0", 3)])),
};
- let parsed =
PkVectorSourceMeta::from_global_index_meta(&meta).unwrap();
+ let parsed =
PrimaryKeyIndexSourceMeta::from_global_index_meta(&meta).unwrap();
assert_eq!(parsed.data_level(), 1);
assert_eq!(parsed.source_files()[0].file_name(), "f0");
}
@@ -467,11 +481,11 @@ mod tests {
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(
+ let meta = PrimaryKeyIndexSourceMeta::new(
1,
vec![
- PkVectorSourceFile::new("f0".to_string(), i64::MAX).unwrap(),
- PkVectorSourceFile::new("f1".to_string(), 1).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f0".to_string(),
i64::MAX).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f1".to_string(), 1).unwrap(),
],
)
.unwrap();
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index b7d48a63..0b76d283 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -58,6 +58,7 @@ mod lumina_index_build_builder;
pub(crate) mod merge_tree_split_generator;
mod partition_filter;
mod partition_stat;
+mod pk_full_text_bucket_state;
mod pk_vector_data_file_reader;
mod pk_vector_indexed_split_read;
mod pk_vector_orchestrator;
diff --git a/crates/paimon/src/table/pk_full_text_bucket_state.rs
b/crates/paimon/src/table/pk_full_text_bucket_state.rs
new file mode 100644
index 00000000..f9122ea1
--- /dev/null
+++ b/crates/paimon/src/table/pk_full_text_bucket_state.rs
@@ -0,0 +1,614 @@
+// 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.
+
+//! Per-bucket current/stale reconciliation for primary-key full-text index
+//! payloads, aligned to active data files by level.
+//!
+//! Mirrors Java `PkFullTextBucketIndexState.fromActiveDataFiles`: a payload is
+//! *current* only when it exactly covers the live source files at its level
and
+//! passes its own per-payload validation. Payloads that no longer match the
live
+//! data files, fail to parse, or fail their singleton validation (bad row
count,
+//! bad row range, or an overflowing source row total) are collected as *stale*
+//! (not an error); payloads that are not full-text or carry no global index
meta
+//! are ignored. Only cross-payload conflicts across the surviving current set
+//! (a duplicate payload file name, or a source data file covered by two
current
+//! payloads) fail loud.
+
+use std::collections::{BTreeMap, HashSet};
+
+use indexmap::IndexMap;
+
+use crate::spec::{
+ should_read_pk_index_source, DataFileMeta, GlobalIndexMeta, IndexFileMeta,
+ PrimaryKeyIndexSourceFile, PrimaryKeyIndexSourceMeta,
+};
+
+pub(crate) const PK_FULL_TEXT_INDEX_TYPE: &str = "full-text";
+
+fn data_invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+/// Per-payload singleton validation, mirroring Java's public
+/// `PkFullTextBucketIndexState` payload constructor guarded by a `try/catch`
+/// that routes any `RuntimeException` to stale: the payload's declared row
count
+/// must equal the sum of its source rows, and its row range must span exactly
+/// `[0, total - 1]`. The sum is computed with `checked_add` so an overflowing
+/// total fails the check (Java `Math.addExact` throws → caught → stale)
instead
+/// of panicking. Returns `false` on any defect so the caller routes the
payload
+/// to stale.
+fn payload_matches_source(
+ payload: &IndexFileMeta,
+ global_meta: &GlobalIndexMeta,
+ source_meta: &PrimaryKeyIndexSourceMeta,
+) -> bool {
+ let mut total: i64 = 0;
+ for source in source_meta.source_files() {
+ match total.checked_add(source.row_count()) {
+ Some(next) => total = next,
+ None => return false, // overflow → stale
+ }
+ }
+ i64::from(payload.row_count) == total
+ && global_meta.row_range_start == 0
+ && global_meta.row_range_end == total - 1
+}
+
+/// Reconciled full-text payloads for a single bucket.
+// The read path consumes this once it lands; until then the state has no
+// production caller.
+#[allow(dead_code)]
+pub(crate) struct PkFullTextBucketState {
+ // Consumed by the read path once it lands, kept to identify the indexed
field.
+ text_field_id: i32,
+ current_payloads: Vec<IndexFileMeta>,
+ stale_payloads: Vec<IndexFileMeta>,
+ // Maps each covered source data file to its current payload. Insertion
order
+ // follows the current-payload order and, within a payload, its source-file
+ // order, so the read path can build deterministically ordered splits from
it.
+ payload_by_source_file: IndexMap<String, IndexFileMeta>,
+}
+
+#[allow(dead_code)]
+impl PkFullTextBucketState {
+ pub(crate) fn from_active_data_files(
+ text_field_id: i32,
+ active_data_files: &[DataFileMeta],
+ active_payloads: Vec<IndexFileMeta>,
+ ) -> crate::Result<Self> {
+ // Desired live source set per level (sorted by file name).
+ let mut sources_by_level: BTreeMap<i32,
Vec<PrimaryKeyIndexSourceFile>> = BTreeMap::new();
+ for file in active_data_files {
+ if should_read_pk_index_source(file) {
+ sources_by_level.entry(file.level).or_default().push(
+ PrimaryKeyIndexSourceFile::new(file.file_name.clone(),
file.row_count)?,
+ );
+ }
+ }
+ for sources in sources_by_level.values_mut() {
+ sources.sort_by(|a, b| a.file_name().cmp(b.file_name()));
+ }
+
+ // Classify payloads. A payload lands in `by_level` only when it
parses,
+ // matches the desired live source set exactly, and passes its own
+ // per-payload validation; every other single-payload defect routes it
to
+ // stale (mirroring Java's `try/catch (RuntimeException → stale)`).
+ let mut by_level: BTreeMap<i32, Vec<(IndexFileMeta,
PrimaryKeyIndexSourceMeta)>> =
+ BTreeMap::new();
+ let mut stale: Vec<IndexFileMeta> = Vec::new();
+ for payload in active_payloads {
+ if payload.index_type != PK_FULL_TEXT_INDEX_TYPE {
+ continue; // IGNORED
+ }
+ let Some(global_meta) = payload.global_index_meta.as_ref() else {
+ continue; // IGNORED
+ };
+ // Field id is checked before the source-meta is even parsed: a
payload
+ // that indexes a different text field is stale only when it
carries
+ // source-meta, and is otherwise skipped without ever decoding it.
+ if global_meta.index_field_id != text_field_id {
+ if global_meta.source_meta.is_some() {
+ stale.push(payload); // STALE (wrong field, has
source-meta)
+ }
+ continue; // wrong field, no source-meta → IGNORED
+ }
+ // Matching field: parse source-meta. A parse failure or absent
+ // source-meta makes the payload stale (mirrors Java decoding it
inside
+ // the per-candidate try/catch).
+ let source_meta = match global_meta
+ .source_meta
+ .as_ref()
+ .map(|b| PrimaryKeyIndexSourceMeta::deserialize(b))
+ {
+ Some(Ok(m)) => m,
+ // No source-meta or a parse failure: cannot be a current
payload.
+ Some(Err(_)) | None => {
+ stale.push(payload);
+ continue;
+ }
+ };
+ // Desired-set match (exact, order-sensitive).
+ let matches_desired = sources_by_level
+ .get(&source_meta.data_level())
+ .is_some_and(|desired| desired.as_slice() ==
source_meta.source_files());
+ if !matches_desired {
+ stale.push(payload); // STALE (missing level or set mismatch)
+ continue;
+ }
+ // Per-payload singleton validation (row total via checked_add, row
+ // count, row range). Java runs this inside try/catch, so any
failure
+ // — including an overflowing row total — routes the payload to
stale,
+ // never an error.
+ if !payload_matches_source(&payload, global_meta, &source_meta) {
+ stale.push(payload); // STALE (per-payload defect)
+ continue;
+ }
+ by_level
+ .entry(source_meta.data_level())
+ .or_default()
+ .push((payload, source_meta));
+ }
+
+ // Per level: exactly one survivor is current; >1 → all stale.
+ let mut current: Vec<(IndexFileMeta, PrimaryKeyIndexSourceMeta)> =
Vec::new();
+ for (_level, mut payloads) in by_level {
+ if payloads.len() == 1 {
+ current.push(payloads.remove(0));
+ } else {
+ stale.extend(payloads.into_iter().map(|(payload, _)| payload));
+ }
+ }
+
+ // Cross-payload conflicts across the current set are the only
fail-loud
+ // cases (the surviving payloads already passed per-payload
validation):
+ // a duplicate payload file name, or a source data file covered twice.
+ let mut payload_by_source_file: IndexMap<String, IndexFileMeta> =
IndexMap::new();
+ let mut seen_payloads: HashSet<String> = HashSet::new();
+ let mut current_payloads: Vec<IndexFileMeta> =
Vec::with_capacity(current.len());
+ for (payload, source_meta) in current {
+ if !seen_payloads.insert(payload.file_name.clone()) {
+ return Err(data_invalid(format!(
+ "full-text payload {} appears more than once",
+ payload.file_name
+ )));
+ }
+ for source in source_meta.source_files() {
+ if payload_by_source_file
+ .insert(source.file_name().to_string(), payload.clone())
+ .is_some()
+ {
+ return Err(data_invalid(format!(
+ "source data file {} is covered by more than one
full-text payload",
+ source.file_name()
+ )));
+ }
+ }
+ current_payloads.push(payload);
+ }
+
+ Ok(Self {
+ text_field_id,
+ current_payloads,
+ stale_payloads: stale,
+ payload_by_source_file,
+ })
+ }
+
+ pub(crate) fn current_payloads(&self) -> &[IndexFileMeta] {
+ &self.current_payloads
+ }
+ pub(crate) fn stale_payloads(&self) -> &[IndexFileMeta] {
+ &self.stale_payloads
+ }
+ pub(crate) fn payload_by_source_file(&self) -> &IndexMap<String,
IndexFileMeta> {
+ &self.payload_by_source_file
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::GlobalIndexMeta;
+
+ /// COMPACT file source discriminant (matches Java `FileSource.COMPACT`).
+ const FILE_SOURCE_COMPACT: i32 = 1;
+
+ /// A COMPACT data file at `level` (>0), so `should_read_pk_index_source`
+ /// accepts it as a live source.
+ fn dfm(name: &str, rows: i64, level: i32) -> DataFileMeta {
+ DataFileMeta {
+ file_name: name.into(),
+ file_size: 1,
+ row_count: rows,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 1,
+ level,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: Some(FILE_SOURCE_COMPACT),
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: Some(0),
+ write_cols: None,
+ }
+ }
+
+ /// One Java `DataOutput#writeUTF` value (u16-BE length + modified UTF-8)
for
+ /// the ASCII file names used in these tests.
+ 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
+ }
+
+ /// A valid `_SOURCE_META` frame (version 1) for the given level and files.
+ fn frame(data_level: i32, files: &[(&str, i64)]) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&1i32.to_be_bytes()); // version
+ out.extend_from_slice(&data_level.to_be_bytes());
+ 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
+ }
+
+ /// Build a full-text `IndexFileMeta` payload with a `GlobalIndexMeta`.
+ fn payload(
+ file_name: &str,
+ index_type: &str,
+ row_count: i32,
+ global_index_meta: Option<GlobalIndexMeta>,
+ ) -> IndexFileMeta {
+ IndexFileMeta {
+ index_type: index_type.into(),
+ file_name: file_name.into(),
+ file_size: 1,
+ row_count,
+ deletion_vectors_ranges: None,
+ global_index_meta,
+ }
+ }
+
+ /// A `GlobalIndexMeta` with a source-meta blob and matching row range.
+ fn gim(field_id: i32, start: i64, end: i64, source_meta: Option<Vec<u8>>)
-> GlobalIndexMeta {
+ GlobalIndexMeta {
+ row_range_start: start,
+ row_range_end: end,
+ index_field_id: field_id,
+ extra_field_ids: None,
+ index_meta: None,
+ source_meta,
+ }
+ }
+
+ // (a) exact match → current.
+ #[test]
+ fn exact_match_is_current() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 99, Some(frame(1, &[("d0.parquet", 100)])))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert_eq!(state.current_payloads().len(), 1);
+ assert_eq!(state.current_payloads()[0].file_name, "ft-0");
+ assert!(state.stale_payloads().is_empty());
+ assert_eq!(state.payload_by_source_file().len(), 1);
+ assert!(state.payload_by_source_file().contains_key("d0.parquet"));
+ }
+
+ // (b) source drift (source set no longer matches live files) → stale.
+ #[test]
+ fn source_drift_is_stale() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ // Payload references a file that is no longer live at level 1.
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 50,
+ Some(gim(7, 0, 49, Some(frame(1, &[("stale.parquet", 50)])))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+
+ // (b') reorder-only drift → stale. The desired set is sorted by file
name, so
+ // a payload whose source-meta lists the same files in a different order
fails
+ // the order-sensitive equality gate (pins that the match is
order-sensitive).
+ #[test]
+ fn reordered_source_files_are_stale() {
+ let files = [dfm("a.parquet", 100, 1), dfm("b.parquet", 200, 1)];
+ // Same files and row counts, but listed b-before-a instead of the
sorted
+ // a-before-b, so the exact List.equals gate rejects it.
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 300,
+ Some(gim(
+ 7,
+ 0,
+ 299,
+ Some(frame(1, &[("b.parquet", 200), ("a.parquet", 100)])),
+ )),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+ #[test]
+ fn two_payloads_same_level_are_both_stale() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ let source = frame(1, &[("d0.parquet", 100)]);
+ let payloads = vec![
+ payload(
+ "ft-a",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 99, Some(source.clone()))),
+ ),
+ payload(
+ "ft-b",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 99, Some(source))),
+ ),
+ ];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ let stale: Vec<&str> = state
+ .stale_payloads()
+ .iter()
+ .map(|p| p.file_name.as_str())
+ .collect();
+ assert_eq!(stale.len(), 2);
+ assert!(stale.contains(&"ft-a") && stale.contains(&"ft-b"));
+ }
+
+ // (d) wrong index_type → ignored (in neither list).
+ #[test]
+ fn wrong_index_type_is_ignored() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ let payloads = vec![payload(
+ "gi-0",
+ "GLOBAL_INDEX",
+ 100,
+ Some(gim(7, 0, 99, Some(frame(1, &[("d0.parquet", 100)])))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert!(state.stale_payloads().is_empty());
+ }
+
+ // (e) no global_index_meta → ignored (in neither list).
+ #[test]
+ fn no_global_index_meta_is_ignored() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ let payloads = vec![payload("ft-0", PK_FULL_TEXT_INDEX_TYPE, 100,
None)];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert!(state.stale_payloads().is_empty());
+ }
+
+ // (f) field-id mismatch (with source-meta present) → stale.
+ #[test]
+ fn field_id_mismatch_is_stale() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ // index_field_id 9 != text_field_id 7.
+ Some(gim(9, 0, 99, Some(frame(1, &[("d0.parquet", 100)])))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+
+ // (f') field-id mismatch with NO source-meta → ignored (skipped, not
stale):
+ // a payload indexing another field that carries no source metadata is
another
+ // field's concern, so it is dropped without ever being decoded.
+ #[test]
+ fn field_id_mismatch_without_source_meta_is_ignored() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ let payloads = vec![payload(
+ "ft-other",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ // index_field_id 9 != text_field_id 7, and no source-meta blob.
+ Some(gim(9, 0, 99, None)),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert!(state.stale_payloads().is_empty());
+ assert!(state.payload_by_source_file().is_empty());
+ }
+
+ // (g) corrupt source-meta bytes → stale.
+ #[test]
+ fn corrupt_source_meta_is_stale() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 99, Some(vec![0xFF, 0x00, 0x01]))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+
+ // (h) desired level missing from live files → stale.
+ #[test]
+ fn missing_desired_level_is_stale() {
+ // Live files are all at level 1; payload's source-meta claims level 2.
+ let files = [dfm("d0.parquet", 100, 1)];
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 99, Some(frame(2, &[("d0.parquet", 100)])))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+
+ // (l) duplicate payload file_name across levels in the current set → Err.
+ #[test]
+ fn duplicate_payload_file_name_is_error() {
+ // Same file name lives at two levels, each with a matching current
payload
+ // that happens to share the same index file name.
+ let files = [dfm("d0.parquet", 100, 1), dfm("d1.parquet", 200, 2)];
+ let payloads = vec![
+ payload(
+ "ft-dup",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 99, Some(frame(1, &[("d0.parquet", 100)])))),
+ ),
+ payload(
+ "ft-dup",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 200,
+ Some(gim(7, 0, 199, Some(frame(2, &[("d1.parquet", 200)])))),
+ ),
+ ];
+ assert!(PkFullTextBucketState::from_active_data_files(7, &files,
payloads).is_err());
+ }
+
+ // (i) row_count mismatch (per-payload defect) → stale, NOT error.
+ #[test]
+ fn row_count_mismatch_is_stale() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ // Source rows total 100 but payload declares 99.
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 99,
+ Some(gim(7, 0, 98, Some(frame(1, &[("d0.parquet", 100)])))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+
+ // (j) row_range mismatch alone (row_count correct, per-payload defect) →
+ // stale, NOT error.
+ #[test]
+ fn row_range_mismatch_is_stale() {
+ let files = [dfm("d0.parquet", 100, 1)];
+ // row_count matches total (100) but row range end is wrong (should be
99).
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 50, Some(frame(1, &[("d0.parquet", 100)])))),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+
+ // (k) source row counts overflow i64 (per-payload defect) → stale, NOT a
+ // panic and NOT error.
+ #[test]
+ fn source_row_count_overflow_is_stale() {
+ // Two live level-1 files whose row counts sum past i64::MAX; the
payload
+ // matches the desired set exactly, so only the overflowing row total
can
+ // reject it — and it must do so without panicking.
+ let files = [dfm("a.parquet", i64::MAX, 1), dfm("b.parquet", i64::MAX,
1)];
+ let payloads = vec![payload(
+ "ft-0",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 0,
+ Some(gim(
+ 7,
+ 0,
+ 0,
+ Some(frame(
+ 1,
+ &[("a.parquet", i64::MAX), ("b.parquet", i64::MAX)],
+ )),
+ )),
+ )];
+ let state = PkFullTextBucketState::from_active_data_files(7, &files,
payloads).unwrap();
+ assert!(state.current_payloads().is_empty());
+ assert_eq!(state.stale_payloads().len(), 1);
+ assert_eq!(state.stale_payloads()[0].file_name, "ft-0");
+ }
+
+ // (m) a source data file covered by two current payloads → Err.
+ #[test]
+ fn source_double_covered_is_error() {
+ // The same source file name is live at two levels; two current
payloads
+ // (one per level) each cover it, colliding in payload_by_source_file.
+ let files = [dfm("shared.parquet", 100, 1), dfm("shared.parquet", 200,
2)];
+ let payloads = vec![
+ payload(
+ "ft-a",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 100,
+ Some(gim(7, 0, 99, Some(frame(1, &[("shared.parquet",
100)])))),
+ ),
+ payload(
+ "ft-b",
+ PK_FULL_TEXT_INDEX_TYPE,
+ 200,
+ Some(gim(7, 0, 199, Some(frame(2, &[("shared.parquet",
200)])))),
+ ),
+ ];
+ assert!(PkFullTextBucketState::from_active_data_files(7, &files,
payloads).is_err());
+ }
+}
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs
b/crates/paimon/src/table/pk_vector_orchestrator.rs
index 99ab5108..2278b0d2 100644
--- a/crates/paimon/src/table/pk_vector_orchestrator.rs
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -914,7 +914,8 @@ mod e2e_tests {
use crate::io::{FileIO, FileIOBuilder};
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
- DataField, DataFileMeta, DataType, IntType, PkVectorSourceFile,
PkVectorSourceMeta,
+ DataField, DataFileMeta, DataType, IntType, PrimaryKeyIndexSourceFile,
+ PrimaryKeyIndexSourceMeta,
};
use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplitRead;
use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN,
SEARCH_SCORE_COLUMN};
@@ -1070,11 +1071,11 @@ mod e2e_tests {
fn ann_segment(sources: &[(&str, i64)]) -> BucketAnnSegment {
BucketAnnSegment::for_test(
- PkVectorSourceMeta::new(
+ PrimaryKeyIndexSourceMeta::new(
1,
sources
.iter()
- .map(|(n, r)| PkVectorSourceFile::new((*n).to_string(),
*r).unwrap())
+ .map(|(n, r)|
PrimaryKeyIndexSourceFile::new((*n).to_string(), *r).unwrap())
.collect(),
)
.unwrap(),
diff --git a/crates/paimon/src/table/pk_vector_scan.rs
b/crates/paimon/src/table/pk_vector_scan.rs
index c4947240..e6ba0c55 100644
--- a/crates/paimon/src/table/pk_vector_scan.rs
+++ b/crates/paimon/src/table/pk_vector_scan.rs
@@ -23,8 +23,8 @@
use std::collections::{BTreeMap, HashSet};
use crate::spec::{
- BinaryRow, DataFileMeta, FileKind, GlobalIndexMeta, IndexManifest,
PkVectorSourceFile,
- PkVectorSourceMeta, Predicate,
+ should_read_pk_index_source, BinaryRow, DataFileMeta, FileKind,
GlobalIndexMeta, IndexManifest,
+ Predicate, PrimaryKeyIndexSourceFile, PrimaryKeyIndexSourceMeta,
};
use crate::table::pk_vector_orchestrator::PkVectorSearchSplit;
use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile};
@@ -32,7 +32,6 @@ use crate::table::Table;
use crate::vindex::pkvector::bucket::{BucketActiveFile, BucketAnnSegment};
const INDEX_DIR: &str = "index";
-const FILE_SOURCE_COMPACT: i32 = 1;
fn data_invalid(message: impl Into<String>) -> crate::Error {
crate::Error::DataInvalid {
@@ -41,13 +40,7 @@ fn data_invalid(message: impl Into<String>) -> crate::Error {
}
}
-/// Mirror of `PrimaryKeyIndexSourcePolicy.shouldRead`: only compacted,
non-level-0
-/// files back the PK-vector index; an absent file source reads as false.
-fn should_read_pk_index_source(file: &DataFileMeta) -> bool {
- matches!(file.file_source, Some(src) if src == FILE_SOURCE_COMPACT) &&
file.level > 0
-}
-
-fn source_files_unique(files: &[PkVectorSourceFile]) -> bool {
+fn source_files_unique(files: &[PrimaryKeyIndexSourceFile]) -> bool {
let mut seen = HashSet::new();
files.iter().all(|file| seen.insert(file.file_name()))
}
@@ -56,13 +49,13 @@ fn current_ann_segments(
active_data_files: &[DataFileMeta],
ann_segments: Vec<BucketAnnSegment>,
) -> crate::Result<Vec<BucketAnnSegment>> {
- let mut sources_by_level: BTreeMap<i32, Vec<PkVectorSourceFile>> =
BTreeMap::new();
+ let mut sources_by_level: BTreeMap<i32, Vec<PrimaryKeyIndexSourceFile>> =
BTreeMap::new();
for file in active_data_files {
if should_read_pk_index_source(file) {
sources_by_level
.entry(file.level)
.or_default()
- .push(PkVectorSourceFile::new(
+ .push(PrimaryKeyIndexSourceFile::new(
file.file_name.clone(),
file.row_count,
)?);
@@ -320,7 +313,7 @@ fn plan_from_inputs(
// Phase A: group ANN payloads by (partition, bucket).
let mut segments_by_bucket: BTreeMap<Key, Vec<BucketAnnSegment>> =
BTreeMap::new();
for (partition, bucket, gim, path, file_size, file_name) in index_entries {
- let source_meta = PkVectorSourceMeta::from_global_index_meta(&gim)
+ let source_meta =
PrimaryKeyIndexSourceMeta::from_global_index_meta(&gim)
.map_err(|_| data_invalid(format!("index file {file_name} is not
active")))?;
let key = (partition.to_serialized_bytes(), bucket);
segments_by_bucket
@@ -435,9 +428,9 @@ mod tests {
out
}
- /// Build a `_SOURCE_META` blob the way `PkVectorSourceMeta::deserialize`
+ /// Build a `_SOURCE_META` blob the way
`PrimaryKeyIndexSourceMeta::deserialize`
/// expects it. There is no public serializer, so we mirror the frame used
by
- /// `pk_vector_source.rs`'s own round-trip tests.
+ /// `pk_index_source.rs`'s own round-trip tests.
fn source_meta_bytes(data_level: i32, files: &[(&str, i64)]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&1i32.to_be_bytes()); // version
@@ -463,12 +456,12 @@ mod tests {
fn ann_segment(data_level: i32, path: &str, source_files: &[(&str, i64)])
-> BucketAnnSegment {
BucketAnnSegment {
- source_meta: PkVectorSourceMeta::new(
+ source_meta: PrimaryKeyIndexSourceMeta::new(
data_level,
source_files
.iter()
.map(|(name, rows)| {
- PkVectorSourceFile::new((*name).to_string(),
*rows).unwrap()
+ PrimaryKeyIndexSourceFile::new((*name).to_string(),
*rows).unwrap()
})
.collect(),
)
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 9f88558e..273dced9 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -4161,9 +4161,9 @@ mod tests {
/// A `PkVectorSearchSplit` carrying a single ANN segment addressed by
`path`.
fn pk_split_with_segment(path: &str) -> PkVectorSearchSplit {
let mut split = pk_search_split(0, vec![pk_data_file("file-a", 3,
Some(0))]);
- let source_meta = crate::spec::PkVectorSourceMeta::new(
+ let source_meta = crate::spec::PrimaryKeyIndexSourceMeta::new(
1,
- vec![crate::spec::PkVectorSourceFile::new("file-a".to_string(),
3).unwrap()],
+
vec![crate::spec::PrimaryKeyIndexSourceFile::new("file-a".to_string(),
3).unwrap()],
)
.unwrap();
let mut segment = BucketAnnSegment::for_test(source_meta);
@@ -4174,9 +4174,9 @@ mod tests {
fn pk_split_with_lumina_segment(path: &str, metric: &str) ->
PkVectorSearchSplit {
let mut split = pk_search_split(0, vec![pk_data_file("file-a", 3,
Some(0))]);
- let source_meta = crate::spec::PkVectorSourceMeta::new(
+ let source_meta = crate::spec::PrimaryKeyIndexSourceMeta::new(
1,
- vec![crate::spec::PkVectorSourceFile::new("file-a".to_string(),
3).unwrap()],
+
vec![crate::spec::PrimaryKeyIndexSourceFile::new("file-a".to_string(),
3).unwrap()],
)
.unwrap();
let mut segment = BucketAnnSegment::for_test(source_meta);
diff --git a/crates/paimon/src/vindex/pkvector/ann.rs
b/crates/paimon/src/vindex/pkvector/ann.rs
index bbd7088d..ab50196a 100644
--- a/crates/paimon/src/vindex/pkvector/ann.rs
+++ b/crates/paimon/src/vindex/pkvector/ann.rs
@@ -23,7 +23,7 @@ use super::data_invalid;
use super::metric::{java_float_compare, VectorSearchMetric};
use super::result::PkVectorSearchResult;
use crate::deletion_vector::DeletionVector;
-use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
+use crate::spec::{PrimaryKeyIndexSourceFile, PrimaryKeyIndexSourceMeta};
use crate::vector_search::VectorSearch;
/// Build the live-row-id mask for the ANN reader's `include_row_ids` filter,
in
@@ -46,7 +46,7 @@ use crate::vector_search::VectorSearch;
/// no deletion vector is relevant — nothing to mask. Otherwise returns the
masked
/// live ids.
pub(crate) fn build_live_row_ids(
- source_files: &[PkVectorSourceFile],
+ source_files: &[PrimaryKeyIndexSourceFile],
active_source_files: &HashSet<String>,
deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
@@ -122,7 +122,7 @@ pub(crate) fn build_live_row_ids(
/// sorted BEST_FIRST.
pub(crate) fn map_ann_results(
scored: &[(u64, f32)],
- source_meta: &PkVectorSourceMeta,
+ source_meta: &PrimaryKeyIndexSourceMeta,
active_source_files: &HashSet<String>,
deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
@@ -327,12 +327,12 @@ mod tests {
use super::*;
use roaring::RoaringBitmap;
- fn source_meta(files: &[(&str, i64)]) -> PkVectorSourceMeta {
+ fn source_meta(files: &[(&str, i64)]) -> PrimaryKeyIndexSourceMeta {
let files = files
.iter()
- .map(|(name, rows)| PkVectorSourceFile::new((*name).to_string(),
*rows).unwrap())
+ .map(|(name, rows)|
PrimaryKeyIndexSourceFile::new((*name).to_string(), *rows).unwrap())
.collect();
- PkVectorSourceMeta::new(1, files).unwrap()
+ PrimaryKeyIndexSourceMeta::new(1, files).unwrap()
}
fn dv(deleted: &[u32]) -> Arc<DeletionVector> {
@@ -349,7 +349,7 @@ mod tests {
#[test]
fn test_build_live_row_ids_none_when_all_active_and_no_relevant_dv() {
- let files = [PkVectorSourceFile::new("f0".into(), 3).unwrap()];
+ let files = [PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap()];
let active = active_set(&["f0"]);
// All active + empty map -> None.
assert!(build_live_row_ids(&files, &active, &HashMap::new(), None)
@@ -368,8 +368,8 @@ mod tests {
// f0 rows 0..3 (global 0,1,2), f1 rows 0..2 (global 3,4). f1 is
inactive,
// so its whole ordinal range is masked out; f0 stays fully live. No
DV.
let files = vec![
- PkVectorSourceFile::new("f0".into(), 3).unwrap(),
- PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
];
let live = build_live_row_ids(&files, &active_set(&["f0"]),
&HashMap::new(), None)
.unwrap()
@@ -381,8 +381,8 @@ mod tests {
fn test_build_live_row_ids_masks_deleted_positions_with_file_offsets() {
// f0 rows 0..3 (global 0,1,2), f1 rows 0..2 (global 3,4).
let files = vec![
- PkVectorSourceFile::new("f0".into(), 3).unwrap(),
- PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
];
let mut dvs = HashMap::new();
dvs.insert("f0".to_string(), dv(&[1])); // deletes global 1
@@ -498,12 +498,12 @@ mod tests {
),
);
let segment = BucketAnnSegment::for_test({
- use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
- PkVectorSourceMeta::new(
+ use crate::spec::{PrimaryKeyIndexSourceFile,
PrimaryKeyIndexSourceMeta};
+ PrimaryKeyIndexSourceMeta::new(
1,
vec![
- PkVectorSourceFile::new("f0".into(), 3).unwrap(),
- PkVectorSourceFile::new("f1".into(), 5).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f1".into(), 5).unwrap(),
],
)
.unwrap()
@@ -541,9 +541,12 @@ mod tests {
}),
);
let segment = BucketAnnSegment::for_test({
- use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
- PkVectorSourceMeta::new(1,
vec![PkVectorSourceFile::new("f0".into(), 1).unwrap()])
- .unwrap()
+ use crate::spec::{PrimaryKeyIndexSourceFile,
PrimaryKeyIndexSourceMeta};
+ PrimaryKeyIndexSourceMeta::new(
+ 1,
+ vec![PrimaryKeyIndexSourceFile::new("f0".into(), 1).unwrap()],
+ )
+ .unwrap()
});
let err = searcher
.search(
@@ -569,9 +572,12 @@ mod tests {
}),
);
let segment = BucketAnnSegment::for_test({
- use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
- PkVectorSourceMeta::new(1,
vec![PkVectorSourceFile::new("f0".into(), 1).unwrap()])
- .unwrap()
+ use crate::spec::{PrimaryKeyIndexSourceFile,
PrimaryKeyIndexSourceMeta};
+ PrimaryKeyIndexSourceMeta::new(
+ 1,
+ vec![PrimaryKeyIndexSourceFile::new("f0".into(), 1).unwrap()],
+ )
+ .unwrap()
});
let results = searcher
.search(
@@ -603,8 +609,8 @@ mod tests {
// entry (empty allow). Result: f0 keeps {0} (1 is residual-allowed but
// deleted, 2 not residual-allowed); f1 contributes nothing.
let files = vec![
- PkVectorSourceFile::new("f0".into(), 3).unwrap(),
- PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
];
let mut dvs = HashMap::new();
dvs.insert("f0".to_string(), dv(&[1]));
@@ -621,8 +627,8 @@ mod tests {
// f0 rows global 0,1,2; f1 rows global 3,4. residual allows f0={2},
f1={1}.
// f1 physical pos 1 -> global 3 + 1 = 4. Result {2, 4}. No DV.
let files = vec![
- PkVectorSourceFile::new("f0".into(), 3).unwrap(),
- PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
+ PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
];
let mut residual = HashMap::new();
residual.insert("f0".to_string(), treemap(&[2]));
@@ -642,7 +648,7 @@ mod tests {
fn
test_build_live_row_ids_residual_some_returns_mask_even_when_all_active_no_dv()
{
// All active, no DV: without residual this returns None. With a
residual
// present, a mask is always required.
- let files = [PkVectorSourceFile::new("f0".into(), 3).unwrap()];
+ let files = [PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap()];
let mut residual = HashMap::new();
residual.insert("f0".to_string(), treemap(&[0, 2]));
let live = build_live_row_ids(
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs
b/crates/paimon/src/vindex/pkvector/bucket.rs
index b946918f..4666d403 100644
--- a/crates/paimon/src/vindex/pkvector/bucket.rs
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -28,7 +28,7 @@ use super::data_invalid;
use super::metric::{java_float_compare, VectorSearchMetric};
use super::result::PkVectorSearchResult;
use crate::deletion_vector::DeletionVector;
-use crate::spec::PkVectorSourceMeta;
+use crate::spec::PrimaryKeyIndexSourceMeta;
/// Search one uncovered data file for its per-query exact Top-K. Returns one
/// bounded, BEST_FIRST list per query (outer index aligns to the `queries`
slice
@@ -44,7 +44,7 @@ pub(crate) type ExactFileSearchFuture<'a> =
/// masking; the remaining fields address the segment's index file for the ANN
/// scorer that reads it.
pub(crate) struct BucketAnnSegment {
- pub source_meta: PkVectorSourceMeta,
+ pub source_meta: PrimaryKeyIndexSourceMeta,
/// Resolved index-file path (globally unique; the scorer's preload key).
pub path: String,
pub file_size: u64,
@@ -55,7 +55,7 @@ pub(crate) struct BucketAnnSegment {
impl BucketAnnSegment {
/// Build a segment with dummy index-file fields for tests that exercise
only
/// `source_meta`-driven logic.
- pub(crate) fn for_test(source_meta: PkVectorSourceMeta) -> Self {
+ pub(crate) fn for_test(source_meta: PrimaryKeyIndexSourceMeta) -> Self {
Self {
source_meta,
path: "seg".to_string(),
@@ -714,18 +714,18 @@ pub(crate) async fn bucket_search_batch(
#[cfg(test)]
mod tests {
use super::*;
- use crate::spec::PkVectorSourceFile;
+ use crate::spec::PrimaryKeyIndexSourceFile;
use crate::vindex::pkvector::ann::PkVectorAnnSearcher;
use crate::vindex::pkvector::exact::exact_search;
use crate::vindex::pkvector::reader::test_support::ArrayReader;
use roaring::RoaringBitmap;
- fn meta(files: &[(&str, i64)]) -> PkVectorSourceMeta {
- PkVectorSourceMeta::new(
+ fn meta(files: &[(&str, i64)]) -> PrimaryKeyIndexSourceMeta {
+ PrimaryKeyIndexSourceMeta::new(
1,
files
.iter()
- .map(|(n, r)| PkVectorSourceFile::new((*n).into(),
*r).unwrap())
+ .map(|(n, r)| PrimaryKeyIndexSourceFile::new((*n).into(),
*r).unwrap())
.collect(),
)
.unwrap()
diff --git a/crates/paimon/tests/pk_vector_baseline_test.rs
b/crates/paimon/tests/pk_vector_baseline_test.rs
index 1328cddd..058e499f 100644
--- a/crates/paimon/tests/pk_vector_baseline_test.rs
+++ b/crates/paimon/tests/pk_vector_baseline_test.rs
@@ -32,11 +32,11 @@
//! binaries and nothing skipped.
//!
//! Two constraints the primary-key read path enforces are satisfied by hand
-//! (mirroring Java `PrimaryKeyIndexSourcePolicy` and `PkVectorSourceMeta`):
+//! (mirroring Java `PrimaryKeyIndexSourcePolicy` and
`PrimaryKeyIndexSourceMeta`):
//! 1. Only a compacted (`file_source == COMPACT`), non-level-0 data file
backs
//! the index, so the written file's meta is cloned with `level = 1` and
//! `file_source = Some(1)`.
-//! 2. `GlobalIndexMeta.source_meta` must be the Java `PkVectorSourceMeta`
frame
+//! 2. `GlobalIndexMeta.source_meta` must be the Java
`PrimaryKeyIndexSourceMeta` frame
//! (big-endian ints/longs, `writeUTF` file names), assembled below.
//!
//! Determinism: every fixture uses `nlist = 1`, so the single IVF inverted
list
@@ -188,7 +188,7 @@ fn data_batch(vectors: &[[f32; DIM]]) -> RecordBatch {
/// Encode one Java `DataOutput#writeUTF` value (u16-BE byte length + modified
/// UTF-8). ASCII file names are the common case; multibyte handling mirrors
the
-/// round-trip helper in `PkVectorSourceMeta`'s own tests.
+/// round-trip helper in `PrimaryKeyIndexSourceMeta`'s own tests.
fn java_write_utf(s: &str) -> Vec<u8> {
let mut body = Vec::new();
for c in s.encode_utf16() {
@@ -208,8 +208,8 @@ fn java_write_utf(s: &str) -> Vec<u8> {
out
}
-/// Assemble the `_SOURCE_META` frame the way Java `PkVectorSourceMeta` writes
it
-/// and `PkVectorSourceMeta::deserialize` expects: `i32-BE version=1`, `i32-BE
+/// Assemble the `_SOURCE_META` frame the way Java `PrimaryKeyIndexSourceMeta`
writes it
+/// and `PrimaryKeyIndexSourceMeta::deserialize` expects: `i32-BE version=1`,
`i32-BE
/// data_level`, `i32-BE count`, then per source file a `writeUTF` name and an
/// `i64-BE` row count. No trailing bytes. Source files are listed in global
/// ordinal order.
@@ -411,7 +411,7 @@ async fn build_table_with_first_row_id(
assert_segment_reads_back(&bytes, query, &analytic_topk(query,
vectors, k));
}
- // Constraint 2: GlobalIndexMeta.source_meta must be the Java
PkVectorSourceMeta
+ // Constraint 2: GlobalIndexMeta.source_meta must be the Java
PrimaryKeyIndexSourceMeta
// frame naming the backing data file(s) in ordinal order. Here one source
file
// owns all rows, so ordinal == physical position.
let vector_field_id = schema
diff --git a/crates/paimon/tests/pk_vector_java_fixture_test.rs
b/crates/paimon/tests/pk_vector_java_fixture_test.rs
index 579a58eb..f64584bb 100644
--- a/crates/paimon/tests/pk_vector_java_fixture_test.rs
+++ b/crates/paimon/tests/pk_vector_java_fixture_test.rs
@@ -22,7 +22,7 @@
//! Java writer with the production `ivf-flat` primary-key vector indexer, and
//! asserts Rust reads + searches it correctly. It validates the cross-language
//! contract at the table-metadata layer (snapshot / manifest / data-file
-//! `level`+`file_source` / `GlobalIndexMeta` / the `PkVectorSourceMeta`
frame);
+//! `level`+`file_source` / `GlobalIndexMeta` / the
`PrimaryKeyIndexSourceMeta` frame);
//! the ANN segment interior is the same native core on both sides
//! (`paimon-vindex-core` <-> its JNI wrapper), so a divergence here would be a
//! real metadata-parse bug, not a fixture artifact.