Skip to content

Commit 8c62634

Browse files
committed
feat(table): add primary-key full-text read path
Add the read side of primary-key full-text search, mirroring Java PrimaryKeyFullTextScan / PrimaryKeyFullTextBucketSearch / PrimaryKeyFullTextRead: - PrimaryKeyFullTextScan + PrimaryKeyFullTextSearchSplit: pin a snapshot, scan the index manifest, group payloads by (partition, bucket) via the shared PkFullTextBucketState reconciliation, and emit splits (covered union uncovered equals the active data files; buckets below zero are skipped). - PrimaryKeyFullTextBucketSearch: lay each payload's source files out as a cumulative archive, build a live-row include allow-list (active ranges minus deletion-vector positions, or none when clean), search the archive through the paimon-ftindex-core reader, and map each returned archive row back to a physical (data file, row position). Archives are opened under the split's bucket directory. - PrimaryKeyFullTextCandidate plus score-descending top_k_by_score with a deterministic tie-break, and finite-score validation. - PrimaryKeyFullTextRead (FAST mode only): search every planned bucket, fuse by score into a global top-k, materialize the winning physical rows, reorder them best-score-first, and append the unified search-score column. - FullTextSearchBuilder: a primary-key branch whose execute_read materializes rows, while execute/execute_scored fail loud (the primary-key path yields physical positions, not global row ids). Dispatch selects the primary-key path only when data evolution is disabled and the queried column is a configured pk-full-text index column.
1 parent 9e7accf commit 8c62634

6 files changed

Lines changed: 2608 additions & 6 deletions

File tree

crates/paimon/src/table/full_text_search_builder.rs

Lines changed: 299 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,19 @@ use crate::spec::{
2525
CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta, IndexManifest,
2626
IndexManifestEntry, ROW_ID_FIELD_NAME,
2727
};
28+
use crate::table::data_file_reader::DataFileReader;
2829
use crate::table::full_text_index_adapter::{search_full_text_file, search_full_text_index};
2930
use crate::table::global_index_scanner::{
3031
deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
3132
unindexed_ranges_for_global_index_entries, RowRangeIndex,
3233
};
33-
use crate::table::{find_field_id_by_name, merge_row_ranges, RowRange, Table};
34+
use crate::table::pk_full_text_read::PrimaryKeyFullTextRead;
35+
use crate::table::pk_full_text_scan::PrimaryKeyFullTextScan;
36+
use crate::table::{
37+
find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream, RowRange, Table,
38+
};
3439
use arrow_array::{Array, Int64Array, LargeStringArray, RecordBatch, StringArray};
35-
use futures::{StreamExt, TryStreamExt};
40+
use futures::{stream, StreamExt, TryStreamExt};
3641
use paimon_ftindex_core::io::PosWriter;
3742
use paimon_ftindex_core::{FullTextIndexConfig, FullTextIndexWriter};
3843
use roaring::RoaringTreemap;
@@ -111,7 +116,8 @@ impl<'a> FullTextSearchBuilder<'a> {
111116

112117
pub async fn execute_scored(&self) -> crate::Result<SearchResult> {
113118
// Fail closed: returns data-derived row ranges outside `TableScan`/`TableRead`.
114-
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
119+
let core = CoreOptions::new(self.table.schema().options());
120+
core.ensure_read_authorized()?;
115121
let text_column =
116122
self.text_column
117123
.as_deref()
@@ -128,6 +134,21 @@ impl<'a> FullTextSearchBuilder<'a> {
128134
message: "Limit must be set via with_limit()".to_string(),
129135
})?;
130136

137+
// Primary-key full-text search does not produce global row-ids: it maps
138+
// hits to physical `(data file, row position)` pairs. A scored/row-range
139+
// search is therefore unsupported on the PK path — callers must use
140+
// `execute_read`. Fail loud rather than fall through to the append/DE
141+
// global-index path (which would search the wrong index and could return
142+
// an empty or wrong result). Mirrors the vector builder.
143+
if resolves_to_pk_full_text_path(&core, text_column) {
144+
return Err(crate::Error::DataInvalid {
145+
message: "primary-key full-text search does not produce global row ids; use the \
146+
materialized read (execute_read) instead"
147+
.to_string(),
148+
source: None,
149+
});
150+
}
151+
131152
let mut search = FullTextSearch::new(
132153
normalize_query_text(query_text, text_column)?,
133154
limit,
@@ -166,6 +187,109 @@ impl<'a> FullTextSearchBuilder<'a> {
166187
)
167188
.await
168189
}
190+
191+
/// Run the full-text search and materialize the matching rows as Arrow batches,
192+
/// ordered best-score-first with a `__paimon_search_score` column appended (the
193+
/// internal `_PKEY_VECTOR_POSITION` column is never exposed).
194+
///
195+
/// Only the primary-key full-text path can materialize rows: it produces
196+
/// physical `(data file, row position)` hits that a subsequent read turns into
197+
/// table rows. Dispatch mirrors Java `primaryKeyFullTextDefinition` — the PK
198+
/// path is taken only when data-evolution is DISABLED and the queried column is
199+
/// a configured `pk-full-text.index.columns` entry. The PK full-text read is
200+
/// FAST-mode only; `FULL`/`DETAIL` fail loud (no silent degrade). A query that
201+
/// does not resolve to the PK path also fails loud rather than silently
202+
/// returning nothing, since the append/data-evolution materialized read is not
203+
/// supported here.
204+
pub async fn execute_read(&self) -> crate::Result<ArrowRecordBatchStream> {
205+
// Fail closed: returns data outside `TableScan`/`TableRead`.
206+
let core = CoreOptions::new(self.table.schema().options());
207+
core.ensure_read_authorized()?;
208+
let text_column =
209+
self.text_column
210+
.as_deref()
211+
.ok_or_else(|| crate::Error::ConfigInvalid {
212+
message: "Text column must be set via with_text_column()".to_string(),
213+
})?;
214+
let query_text = self
215+
.query_text
216+
.as_deref()
217+
.ok_or_else(|| crate::Error::ConfigInvalid {
218+
message: "Query text must be set via with_query_text()".to_string(),
219+
})?;
220+
let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
221+
message: "Limit must be set via with_limit()".to_string(),
222+
})?;
223+
224+
if !resolves_to_pk_full_text_path(&core, text_column) {
225+
return Err(crate::Error::Unsupported {
226+
message: "materialized full-text read (execute_read) is only supported on the \
227+
primary-key full-text path (data-evolution disabled and the column \
228+
configured in pk-full-text.index.columns)"
229+
.to_string(),
230+
});
231+
}
232+
233+
// FAST-only: reject FULL/DETAIL loud rather than silently degrading.
234+
if core.global_index_search_mode()? != GlobalIndexSearchMode::Fast {
235+
return Err(crate::Error::DataInvalid {
236+
message: "primary-key full-text search supports only the FAST global-index search \
237+
mode"
238+
.to_string(),
239+
source: None,
240+
});
241+
}
242+
243+
// Reject a non-positive limit at construction, before the empty-plan fast
244+
// path — an empty table must not mask an invalid limit (mirrors Java
245+
// `PrimaryKeyFullTextRead`, and matches `search_bucket`'s own guard).
246+
if limit == 0 {
247+
return Err(crate::Error::ConfigInvalid {
248+
message: "Limit must be positive".to_string(),
249+
});
250+
}
251+
252+
// Resolve the queried column's schema field id for the scan/field-id guard.
253+
let field_id = find_field_id_by_name(self.table.schema().fields(), text_column)
254+
.ok_or_else(|| crate::Error::DataInvalid {
255+
message: format!("full-text search column '{text_column}' does not exist"),
256+
source: None,
257+
})?;
258+
259+
let plan = PrimaryKeyFullTextScan::new(self.table, field_id, None)
260+
.plan()
261+
.await?;
262+
if plan.splits.is_empty() {
263+
return Ok(Box::pin(stream::empty()));
264+
}
265+
266+
// A predicate-free materialization reader projecting the user table
267+
// columns (mirrors `table_read.rs::new_data_file_reader` with an empty
268+
// predicate list). The PK read appends the score column itself.
269+
let materialize_reader = DataFileReader::new(
270+
self.table.file_io().clone(),
271+
self.table.schema_manager().clone(),
272+
self.table.schema().id(),
273+
self.table.schema().fields().to_vec(),
274+
self.table.schema().fields().to_vec(),
275+
Vec::new(),
276+
);
277+
let read = PrimaryKeyFullTextRead::new(self.table.file_io().clone(), materialize_reader);
278+
read.read(&plan, query_text, limit).await
279+
}
280+
}
281+
282+
/// Whether a query on `text_column` resolves to the primary-key full-text read
283+
/// path. Mirrors Java `primaryKeyFullTextDefinition`: taken only when
284+
/// data-evolution is DISABLED and the column is a configured
285+
/// `pk-full-text.index.columns` entry (membership via the non-erroring accessor so
286+
/// a malformed config cannot abort an unrelated append/DE query).
287+
fn resolves_to_pk_full_text_path(core: &CoreOptions<'_>, text_column: &str) -> bool {
288+
!core.data_evolution_enabled()
289+
&& core
290+
.primary_key_full_text_index_columns()
291+
.iter()
292+
.any(|c| c == text_column)
169293
}
170294

171295
/// Evaluate a full-text search query against full-text indexes found in the index manifest.
@@ -1203,4 +1327,176 @@ mod tests {
12031327
"full-text search must fail closed for a query-auth table"
12041328
);
12051329
}
1330+
1331+
/// A primary-key full-text table: data-evolution off, `body` configured as a
1332+
/// `pk-full-text.index.columns` entry, so a `body` query resolves to the PK
1333+
/// full-text path. `extra` appends/overrides options (e.g. the search mode).
1334+
fn pk_full_text_table(name: &str, extra: &[(&str, &str)]) -> Table {
1335+
let mut builder = Schema::builder()
1336+
.column("id", DataType::Int(IntType::new()))
1337+
.column("body", DataType::VarChar(VarCharType::string_type()))
1338+
.primary_key(["id"])
1339+
.option("bucket", "1")
1340+
.option("pk-full-text.index.columns", "body");
1341+
for (k, v) in extra {
1342+
builder = builder.option(*k, *v);
1343+
}
1344+
let schema = builder.build().unwrap();
1345+
Table::new(
1346+
FileIOBuilder::new("memory").build().unwrap(),
1347+
Identifier::new("default", name),
1348+
format!("memory:/{name}"),
1349+
TableSchema::new(0, &schema),
1350+
None,
1351+
)
1352+
}
1353+
1354+
// (d) The PK full-text path produces physical positions, not global row-ids:
1355+
// `execute` / `execute_scored` must fail loud and point callers at execute_read.
1356+
#[tokio::test]
1357+
async fn pk_full_text_execute_fails_loud_use_execute_read() {
1358+
let table = pk_full_text_table("pk_ft_execute_loud", &[]);
1359+
let err = table
1360+
.new_full_text_search_builder()
1361+
.with_text_column("body")
1362+
.with_query_text(r#"{"match":{"query":"alpha"}}"#)
1363+
.with_limit(10)
1364+
.execute()
1365+
.await
1366+
.unwrap_err();
1367+
assert!(
1368+
matches!(&err, crate::Error::DataInvalid { message, .. } if message.contains("execute_read")),
1369+
"PK full-text execute must fail loud pointing at execute_read, got: {err}"
1370+
);
1371+
}
1372+
1373+
#[tokio::test]
1374+
async fn pk_full_text_execute_scored_fails_loud_use_execute_read() {
1375+
let table = pk_full_text_table("pk_ft_scored_loud", &[]);
1376+
let err = table
1377+
.new_full_text_search_builder()
1378+
.with_text_column("body")
1379+
.with_query_text(r#"{"match":{"query":"alpha"}}"#)
1380+
.with_limit(10)
1381+
.execute_scored()
1382+
.await
1383+
.unwrap_err();
1384+
assert!(
1385+
matches!(&err, crate::Error::DataInvalid { message, .. } if message.contains("global row ids")),
1386+
"PK full-text execute_scored must fail loud, got: {err}"
1387+
);
1388+
}
1389+
1390+
// (c) FAST-only: FULL / DETAIL global-index search modes must fail loud on the
1391+
// PK full-text read rather than silently degrade.
1392+
#[tokio::test]
1393+
async fn pk_full_text_execute_read_rejects_full_mode() {
1394+
let table = pk_full_text_table("pk_ft_full_mode", &[("global-index.search-mode", "full")]);
1395+
let err = match table
1396+
.new_full_text_search_builder()
1397+
.with_text_column("body")
1398+
.with_query_text(r#"{"match":{"query":"alpha"}}"#)
1399+
.with_limit(10)
1400+
.execute_read()
1401+
.await
1402+
{
1403+
Ok(_) => panic!("FULL mode PK full-text read must fail loud"),
1404+
Err(e) => e,
1405+
};
1406+
assert!(
1407+
matches!(&err, crate::Error::DataInvalid { message, .. } if message.contains("FAST")),
1408+
"FULL mode PK full-text read must fail loud, got: {err}"
1409+
);
1410+
}
1411+
1412+
#[tokio::test]
1413+
async fn pk_full_text_execute_read_rejects_detail_mode() {
1414+
let table = pk_full_text_table(
1415+
"pk_ft_detail_mode",
1416+
&[("global-index.search-mode", "detail")],
1417+
);
1418+
let err = match table
1419+
.new_full_text_search_builder()
1420+
.with_text_column("body")
1421+
.with_query_text(r#"{"match":{"query":"alpha"}}"#)
1422+
.with_limit(10)
1423+
.execute_read()
1424+
.await
1425+
{
1426+
Ok(_) => panic!("DETAIL mode PK full-text read must fail loud"),
1427+
Err(e) => e,
1428+
};
1429+
assert!(
1430+
matches!(&err, crate::Error::DataInvalid { message, .. } if message.contains("FAST")),
1431+
"DETAIL mode PK full-text read must fail loud, got: {err}"
1432+
);
1433+
}
1434+
1435+
// (e) A non-PK (append) table: execute_read is unsupported and must fail loud
1436+
// rather than silently return nothing; the append execute/execute_scored path
1437+
// stays unaffected (exercised by the raw-search tests above).
1438+
#[tokio::test]
1439+
async fn append_execute_read_fails_loud_unsupported() {
1440+
let file_io = FileIOBuilder::new("memory").build().unwrap();
1441+
let table = full_text_raw_table(&file_io, "memory:/append_ft_execute_read");
1442+
let err = match table
1443+
.new_full_text_search_builder()
1444+
.with_text_column("body")
1445+
.with_query_text(r#"{"match":{"query":"alpha"}}"#)
1446+
.with_limit(10)
1447+
.execute_read()
1448+
.await
1449+
{
1450+
Ok(_) => panic!("append full-text execute_read must fail loud"),
1451+
Err(e) => e,
1452+
};
1453+
assert!(
1454+
matches!(&err, crate::Error::Unsupported { message } if message.contains("primary-key full-text path")),
1455+
"append full-text execute_read must fail loud as unsupported, got: {err}"
1456+
);
1457+
}
1458+
1459+
// Dispatch: with data-evolution ENABLED, a configured pk-full-text column must
1460+
// NOT resolve to the PK path (mirrors Java `primaryKeyFullTextDefinition`), so
1461+
// execute_scored takes the append/DE path instead of the PK fail-loud guard.
1462+
#[tokio::test]
1463+
async fn data_evolution_enabled_does_not_take_pk_path() {
1464+
let de_on = HashMap::from([
1465+
("pk-full-text.index.columns".to_string(), "body".to_string()),
1466+
("data-evolution.enabled".to_string(), "true".to_string()),
1467+
]);
1468+
let core = CoreOptions::new(&de_on);
1469+
assert!(
1470+
!resolves_to_pk_full_text_path(&core, "body"),
1471+
"data-evolution on must not resolve to the PK full-text path"
1472+
);
1473+
// Off + configured column -> PK path; a non-configured column -> not PK.
1474+
let de_off =
1475+
HashMap::from([("pk-full-text.index.columns".to_string(), "body".to_string())]);
1476+
let core_off = CoreOptions::new(&de_off);
1477+
assert!(resolves_to_pk_full_text_path(&core_off, "body"));
1478+
assert!(!resolves_to_pk_full_text_path(&core_off, "other"));
1479+
}
1480+
1481+
// A non-positive limit must fail loud at construction, even on an empty table
1482+
// (before the empty-plan fast path).
1483+
#[tokio::test]
1484+
async fn pk_full_text_execute_read_rejects_zero_limit() {
1485+
let table = pk_full_text_table("pk_ft_zero_limit", &[]);
1486+
let err = match table
1487+
.new_full_text_search_builder()
1488+
.with_text_column("body")
1489+
.with_query_text(r#"{"match":{"query":"alpha"}}"#)
1490+
.with_limit(0)
1491+
.execute_read()
1492+
.await
1493+
{
1494+
Ok(_) => panic!("zero limit PK full-text read must fail loud"),
1495+
Err(e) => e,
1496+
};
1497+
assert!(
1498+
matches!(&err, crate::Error::ConfigInvalid { message } if message.contains("positive")),
1499+
"zero limit must fail loud, got: {err}"
1500+
);
1501+
}
12061502
}

crates/paimon/src/table/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ mod lumina_index_build_builder;
5858
pub(crate) mod merge_tree_split_generator;
5959
mod partition_filter;
6060
mod partition_stat;
61+
#[cfg(feature = "fulltext")]
62+
mod pk_full_text_bucket_search;
6163
mod pk_full_text_bucket_state;
64+
#[cfg(feature = "fulltext")]
65+
mod pk_full_text_read;
66+
#[cfg(feature = "fulltext")]
67+
mod pk_full_text_scan;
6268
mod pk_vector_data_file_reader;
6369
mod pk_vector_indexed_split_read;
6470
mod pk_vector_orchestrator;

0 commit comments

Comments
 (0)