Skip to content

Commit 23f60a1

Browse files
authored
feat(table): add primary-key full-text read path (#599)
1 parent 17a993e commit 23f60a1

6 files changed

Lines changed: 2634 additions & 6 deletions

File tree

crates/paimon/src/table/full_text_search_builder.rs

Lines changed: 303 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,113 @@ 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(
278+
self.table.file_io().clone(),
279+
materialize_reader,
280+
self.table.location().trim_end_matches('/').to_string(),
281+
);
282+
read.read(&plan, query_text, limit).await
283+
}
284+
}
285+
286+
/// Whether a query on `text_column` resolves to the primary-key full-text read
287+
/// path. Mirrors Java `primaryKeyFullTextDefinition`: taken only when
288+
/// data-evolution is DISABLED and the column is a configured
289+
/// `pk-full-text.index.columns` entry (membership via the non-erroring accessor so
290+
/// a malformed config cannot abort an unrelated append/DE query).
291+
fn resolves_to_pk_full_text_path(core: &CoreOptions<'_>, text_column: &str) -> bool {
292+
!core.data_evolution_enabled()
293+
&& core
294+
.primary_key_full_text_index_columns()
295+
.iter()
296+
.any(|c| c == text_column)
169297
}
170298

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

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)