This is an automated email from the ASF dual-hosted git repository.
XiaoHongbo-Hope pushed a commit to branch release-0.1.0
in repository https://gitbox.apache.org/repos/asf/paimon-full-text.git
The following commit(s) were added to refs/heads/release-0.1.0 by this push:
new ba268f6 fix(core): stream full-text index writes #13
ba268f6 is described below
commit ba268f6287fd179651ebd2f1c689454defb3218b
Author: jerry <[email protected]>
AuthorDate: Wed Jul 15 17:40:18 2026 +0800
fix(core): stream full-text index writes #13
(cherry picked from commit 87cb32dd8f4991f3b3943c864b57aac99c06f4f0)
---
core/src/index.rs | 164 ++++++++++++++-------
core/src/storage.rs | 46 +++++-
core/tests/core_roundtrip.rs | 85 ++++++++++-
ffi/src/lib.rs | 4 +
.../paimon/index/fulltext/FullTextIndexWriter.java | 6 +
python/paimon_ftindex/writer.py | 5 +
6 files changed, 255 insertions(+), 55 deletions(-)
diff --git a/core/src/index.rs b/core/src/index.rs
index 0738c57..007812b 100644
--- a/core/src/index.rs
+++ b/core/src/index.rs
@@ -20,14 +20,14 @@ use crate::config::{FullTextIndexConfig,
FullTextIndexMetadata};
use crate::error::{FtIndexError, Result};
use crate::io::{FullTextReadMetrics, ReadMetrics, ReadRequest, SeekRead,
SeekWrite};
use crate::query::{BooleanOccur, MatchOperator, QuerySpec};
-use crate::storage::{read_header, write_envelope, ArchiveFileEntry,
IndexHeader};
+use crate::storage::{read_header, write_envelope_from_paths, ArchiveFileEntry,
IndexHeader};
use crate::tokenizer::{TokenizerConfig, TokenizerKind};
use levenshtein_automata::{Distance, LevenshteinAutomatonBuilder, DFA,
SINK_STATE};
use roaring::RoaringTreemap;
use std::collections::HashMap;
use std::fmt;
use std::fs;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tantivy::collector::{FilterCollector, TopDocs};
use tantivy::query::{
@@ -40,13 +40,14 @@ use tantivy::tokenizer::{
SimpleTokenizer, Stemmer, StopWordFilter, TextAnalyzer, TokenStream,
WhitespaceTokenizer,
};
use tantivy::{
- DocId, DocSet, Index, Score, SegmentReader, SingleSegmentIndexWriter,
TantivyDocument, Term,
- TERMINATED,
+ DocId, DocSet, Index, IndexWriter, Score, SegmentReader, TantivyDocument,
Term, TERMINATED,
};
use tantivy_fst::Automaton;
use tantivy_jieba::JiebaTokenizer;
use tempfile::TempDir;
+const INDEX_WRITER_MEMORY_BUDGET_BYTES: usize = 50_000_000;
+
#[derive(Clone, Debug, PartialEq)]
pub struct FullTextSearchResult {
pub row_ids: Vec<i64>,
@@ -55,7 +56,15 @@ pub struct FullTextSearchResult {
pub struct FullTextIndexWriter {
config: FullTextIndexConfig,
- documents: Vec<FullTextDocument>,
+ state: Option<FullTextIndexWriterState>,
+ row_id_field: tantivy::schema::Field,
+ text_fields: HashMap<String, tantivy::schema::Field>,
+ document_count: u64,
+}
+
+struct FullTextIndexWriterState {
+ index_writer: IndexWriter<TantivyDocument>,
+ temp_dir: TempDir,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -67,9 +76,24 @@ pub struct FullTextDocument {
impl FullTextIndexWriter {
pub fn new(config: FullTextIndexConfig) -> Result<Self> {
config.validate()?;
+ let temp_dir = TempDir::new()?;
+ let schema = build_schema(&config);
+ let mut index = Index::create_in_dir(temp_dir.path(), schema.clone())?;
+ register_tokenizer(&mut index, &config.tokenizer)?;
+ let row_id_field = schema
+ .get_field(&config.row_id_field)
+ .map_err(|_| FtIndexError::InvalidStorage("missing row_id
field".to_string()))?;
+ let text_fields = text_field_map(&schema, &config)?;
+ let index_writer = index.writer_with_num_threads(1,
INDEX_WRITER_MEMORY_BUDGET_BYTES)?;
Ok(Self {
config,
- documents: Vec::new(),
+ state: Some(FullTextIndexWriterState {
+ index_writer,
+ temp_dir,
+ }),
+ row_id_field,
+ text_fields,
+ document_count: 0,
})
}
@@ -89,72 +113,79 @@ impl FullTextIndexWriter {
"row id must be non-negative, got {row_id}"
)));
}
- let fields = fields
- .into_iter()
- .map(|(name, text)| (name.into(), text.into()))
- .collect::<Vec<_>>();
- if fields.is_empty() {
+ let state = self.state.as_ref().ok_or_else(|| {
+ FtIndexError::InvalidStorage("full-text index writer is already
finalized".to_string())
+ })?;
+ let next_document_count =
self.document_count.checked_add(1).ok_or_else(|| {
+ FtIndexError::InvalidStorage("full-text document count
overflow".to_string())
+ })?;
+ let mut doc = TantivyDocument::new();
+ doc.add_u64(self.row_id_field, row_id as u64);
+ let mut has_text_field = false;
+ for (name, text) in fields {
+ let name = name.into();
+ let text = text.into();
+ validate_indexed_field(&self.config, &name)?;
+ let text_field = self.text_fields.get(&name).ok_or_else(|| {
+ FtIndexError::InvalidStorage(format!(
+ "document field '{name}' is not configured for this index"
+ ))
+ })?;
+ doc.add_text(*text_field, &text);
+ has_text_field = true;
+ }
+ if !has_text_field {
return Err(FtIndexError::InvalidStorage(
"document must contain at least one text field".to_string(),
));
}
- for (name, _) in &fields {
- validate_indexed_field(&self.config, name)?;
- }
- self.documents.push(FullTextDocument { row_id, fields });
+ state.index_writer.add_document(doc)?;
+ self.document_count = next_document_count;
Ok(())
}
+ /// Finalizes this writer and streams the completed index archive to
`output`.
+ ///
+ /// A write attempt is single-use regardless of whether it succeeds: the
active Tantivy writer
+ /// is consumed before commit and serialization begin. After this method
is called, subsequent
+ /// calls to `write`, `add_document`, or `add_document_fields` return an
already-finalized
+ /// error. If `output` returns an error, it may contain a partial archive
and must be discarded;
+ /// retrying requires a new writer and re-adding the documents.
pub fn write<W: SeekWrite>(&mut self, output: &mut W) -> Result<()> {
- let temp_dir = TempDir::new()?;
- let schema = build_schema(&self.config);
- let mut index = Index::create_in_dir(temp_dir.path(), schema.clone())?;
- register_tokenizer(&mut index, &self.config.tokenizer)?;
- let row_id_field = schema
- .get_field(&self.config.row_id_field)
- .map_err(|_| FtIndexError::InvalidStorage("missing row_id
field".to_string()))?;
- let text_fields = text_field_map(&schema, &self.config)?;
-
- {
- let mut index_writer =
- SingleSegmentIndexWriter::<TantivyDocument>::new(index,
50_000_000)?;
- for document in &self.documents {
- let mut doc = TantivyDocument::new();
- doc.add_u64(row_id_field, document.row_id as u64);
- for (name, text) in &document.fields {
- let text_field = text_fields.get(name).ok_or_else(|| {
- FtIndexError::InvalidStorage(format!(
- "document field '{name}' is not configured for
this index"
- ))
- })?;
- doc.add_text(*text_field, text);
- }
- index_writer.add_document(doc)?;
- }
- index_writer.finalize()?;
- }
+ let state = self.state.take().ok_or_else(|| {
+ FtIndexError::InvalidStorage("full-text index writer is already
finalized".to_string())
+ })?;
+ let FullTextIndexWriterState {
+ mut index_writer,
+ temp_dir,
+ } = state;
+ index_writer.commit()?;
+ index_writer.wait_merging_threads()?;
let files = collect_index_files(temp_dir.path())?;
let mut offset = 0u64;
let mut entries = Vec::with_capacity(files.len());
- for (name, data) in &files {
+ for file in &files {
entries.push(ArchiveFileEntry {
- name: name.clone(),
+ name: file.name.clone(),
offset,
- length: data.len() as u64,
+ length: file.length,
});
- offset += data.len() as u64;
+ offset = offset.checked_add(file.length).ok_or_else(|| {
+ FtIndexError::InvalidStorage("full-text archive size
overflow".to_string())
+ })?;
}
let header = IndexHeader {
metadata: FullTextIndexMetadata {
config: self.config.clone(),
- document_count: self.documents.len() as u64,
+ document_count: self.document_count,
tantivy_version: tantivy::version().to_string(),
},
files: entries,
};
- write_envelope(output, &header, &files)
+ let paths = files.iter().map(|file| &file.path).collect::<Vec<_>>();
+ write_envelope_from_paths(output, &header, &paths)
}
}
@@ -456,7 +487,13 @@ fn validate_indexed_field(config: &FullTextIndexConfig,
field: &str) -> Result<(
}
}
-fn collect_index_files(path: &Path) -> Result<Vec<(String, Vec<u8>)>> {
+struct IndexFile {
+ name: String,
+ path: PathBuf,
+ length: u64,
+}
+
+fn collect_index_files(path: &Path) -> Result<Vec<IndexFile>> {
let mut paths = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
@@ -475,7 +512,8 @@ fn collect_index_files(path: &Path) -> Result<Vec<(String,
Vec<u8>)>> {
if name.ends_with(".lock") {
continue;
}
- files.push((name, fs::read(path)?));
+ let length = fs::metadata(&path)?.len();
+ files.push(IndexFile { name, path, length });
}
Ok(files)
}
@@ -1178,3 +1216,29 @@ impl Automaton for PrefixedDfaAutomaton {
(dfa_state, prefix_state)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn added_documents_are_not_retained_as_source_strings() -> Result<()> {
+ let mut writer = FullTextIndexWriter::new(FullTextIndexConfig::new())?;
+
+ for row_id in 0..10_000 {
+ writer.add_document(row_id, format!("document {row_id} with unique
source text"))?;
+ }
+
+ // Keep this exhaustive: the writer state must not grow a
source-document collection.
+ let FullTextIndexWriter {
+ config: _,
+ state,
+ row_id_field: _,
+ text_fields: _,
+ document_count,
+ } = writer;
+ assert!(state.is_some());
+ assert_eq!(document_count, 10_000);
+ Ok(())
+ }
+}
diff --git a/core/src/storage.rs b/core/src/storage.rs
index e8322a0..eca06d4 100644
--- a/core/src/storage.rs
+++ b/core/src/storage.rs
@@ -20,6 +20,8 @@ use crate::error::{FtIndexError, Result};
use crate::io::{ReadRequest, SeekRead, SeekWrite};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
+use std::fs::File;
+use std::io::Read;
use std::path::{Component, Path};
pub const FORMAT_MAGIC: &[u8; 8] = b"PFTIDX01";
@@ -27,6 +29,7 @@ pub const FORMAT_VERSION: u32 = 1;
const MAX_HEADER_BYTES: usize = 16 * 1024 * 1024;
const MAX_ARCHIVE_READ_BATCH_BYTES: usize = 64 * 1024 * 1024;
const MAX_ARCHIVE_READ_BATCH_RANGES: usize = 64;
+const ARCHIVE_WRITE_BUFFER_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArchiveFileEntry {
@@ -46,6 +49,45 @@ pub fn write_envelope<W: SeekWrite>(
header: &IndexHeader,
files: &[(String, Vec<u8>)],
) -> Result<()> {
+ write_envelope_header(output, header)?;
+ for (_, data) in files {
+ output.write_all(data)?;
+ }
+ output.flush()?;
+ Ok(())
+}
+
+pub(crate) fn write_envelope_from_paths<W: SeekWrite>(
+ output: &mut W,
+ header: &IndexHeader,
+ paths: &[impl AsRef<Path>],
+) -> Result<()> {
+ if header.files.len() != paths.len() {
+ return Err(FtIndexError::InvalidStorage(format!(
+ "archive header contains {} files but {} paths were provided",
+ header.files.len(),
+ paths.len()
+ )));
+ }
+
+ write_envelope_header(output, header)?;
+ let mut buffer = vec![0u8; ARCHIVE_WRITE_BUFFER_BYTES];
+ for (entry, path) in header.files.iter().zip(paths) {
+ let mut file = File::open(path)?;
+ let mut remaining = entry.length;
+ while remaining > 0 {
+ let chunk_len = usize::try_from(remaining.min(buffer.len() as u64))
+ .expect("chunk length is bounded by the archive write buffer");
+ file.read_exact(&mut buffer[..chunk_len])?;
+ output.write_all(&buffer[..chunk_len])?;
+ remaining -= chunk_len as u64;
+ }
+ }
+ output.flush()?;
+ Ok(())
+}
+
+fn write_envelope_header<W: SeekWrite>(output: &mut W, header: &IndexHeader)
-> Result<()> {
let header_json = serde_json::to_vec(header)?;
if header_json.len() > MAX_HEADER_BYTES {
return Err(FtIndexError::InvalidStorage(format!(
@@ -62,10 +104,6 @@ pub fn write_envelope<W: SeekWrite>(
.to_be_bytes(),
)?;
output.write_all(&header_json)?;
- for (_, data) in files {
- output.write_all(data)?;
- }
- output.flush()?;
Ok(())
}
diff --git a/core/tests/core_roundtrip.rs b/core/tests/core_roundtrip.rs
index 2695191..1b71da9 100644
--- a/core/tests/core_roundtrip.rs
+++ b/core/tests/core_roundtrip.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use paimon_ftindex_core::io::{PosWriter, ReadRequest, SeekRead, SliceReader};
+use paimon_ftindex_core::io::{PosWriter, ReadRequest, SeekRead, SeekWrite,
SliceReader};
use paimon_ftindex_core::storage::{read_header, write_envelope,
ArchiveFileEntry, IndexHeader};
use paimon_ftindex_core::{
FullTextIndexConfig, FullTextIndexMetadata, FullTextIndexReader,
FullTextIndexWriter,
@@ -147,6 +147,89 @@ impl SeekRead for CountingSliceReader {
}
}
+struct ChunkLimitedWriter {
+ data: Vec<u8>,
+ max_chunk_len: usize,
+}
+
+impl ChunkLimitedWriter {
+ fn new() -> Self {
+ Self {
+ data: Vec::new(),
+ max_chunk_len: 0,
+ }
+ }
+}
+
+impl SeekWrite for ChunkLimitedWriter {
+ fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
+ const MAX_CHUNK_LEN: usize = 64 * 1024;
+ if buf.len() > MAX_CHUNK_LEN {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("archive write chunk exceeds {MAX_CHUNK_LEN} bytes"),
+ ));
+ }
+ self.max_chunk_len = self.max_chunk_len.max(buf.len());
+ self.data.extend_from_slice(buf);
+ Ok(())
+ }
+}
+
+struct FailingWriter;
+
+impl SeekWrite for FailingWriter {
+ fn write_all(&mut self, _buf: &[u8]) -> io::Result<()> {
+ Err(io::Error::other("intentional output failure"))
+ }
+}
+
+#[test]
+fn failed_write_attempt_finalizes_writer() -> anyhow::Result<()> {
+ let mut writer = FullTextIndexWriter::new(FullTextIndexConfig::new())?;
+ writer.add_document(1, "Apache Paimon full text")?;
+
+ let write_error = writer
+ .write(&mut FailingWriter)
+ .expect_err("the failing output should reject the first write
attempt");
+ assert!(write_error
+ .to_string()
+ .contains("intentional output failure"));
+
+ let mut retry_output = ChunkLimitedWriter::new();
+ let retry_error = writer
+ .write(&mut retry_output)
+ .expect_err("a failed write attempt should still finalize the writer");
+ assert!(retry_error.to_string().contains("already finalized"));
+
+ let add_error = writer
+ .add_document(2, "retry document")
+ .expect_err("a finalized writer should reject additional documents");
+ assert!(add_error.to_string().contains("already finalized"));
+ Ok(())
+}
+
+#[test]
+fn large_incremental_archive_is_streamed_and_searchable() ->
anyhow::Result<()> {
+ let mut writer = FullTextIndexWriter::new(FullTextIndexConfig::new())?;
+ for row_id in 0..10_000 {
+ writer.add_document(
+ row_id,
+ format!("common archive text with unique marker{row_id}"),
+ )?;
+ }
+
+ let mut output = ChunkLimitedWriter::new();
+ writer.write(&mut output)?;
+
+ assert!(output.max_chunk_len <= 64 * 1024);
+ let reader = FullTextIndexReader::open(SliceReader::new(output.data))?;
+ assert_eq!(reader.metadata().document_count, 10_000);
+ let result = reader.search(match_query("marker8191", "text"), 10)?;
+ assert_eq!(result.row_ids, vec![8191]);
+ Ok(())
+}
+
#[test]
fn reader_open_does_not_load_all_archive_files() -> anyhow::Result<()> {
let bytes = build_index()?;
diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs
index 60b7921..8908f82 100644
--- a/ffi/src/lib.rs
+++ b/ffi/src/lib.rs
@@ -265,6 +265,10 @@ pub unsafe extern "C" fn
paimon_ftindex_writer_add_document_fields(
}
#[no_mangle]
+/// Finalizes the writer and writes its archive to `output`.
+///
+/// The writer is finalized after any call, including calls that return a
non-zero status. Callers
+/// must discard a potentially partial output and create a new writer to retry.
pub unsafe extern "C" fn paimon_ftindex_writer_write_index(
writer: *mut PaimonFtindexWriterHandle,
output: PaimonFtindexOutputFile,
diff --git
a/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexWriter.java
b/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexWriter.java
index 776c851..337b996 100644
---
a/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexWriter.java
+++
b/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexWriter.java
@@ -62,6 +62,12 @@ public final class FullTextIndexWriter implements
AutoCloseable {
FullTextNative.addDocumentFields(requireOpen(), rowId, fieldNames,
texts);
}
+ /**
+ * Finalizes this writer and streams the index archive to the output.
+ *
+ * <p>Every write attempt finalizes the native writer, even when writing
or flushing fails.
+ * Discard a potentially partial output and create a new writer to retry.
+ */
public void writeIndex(FullTextIndexOutput output) {
if (output == null) {
throw new NullPointerException("output");
diff --git a/python/paimon_ftindex/writer.py b/python/paimon_ftindex/writer.py
index 6f609a9..ff4c6e1 100644
--- a/python/paimon_ftindex/writer.py
+++ b/python/paimon_ftindex/writer.py
@@ -75,6 +75,11 @@ class FullTextIndexWriter:
)
def write(self, output):
+ """Finalize this writer and stream the index archive to ``output``.
+
+ Every write attempt finalizes the native writer, even when writing or
flushing fails.
+ Discard a potentially partial output and create a new writer to retry.
+ """
if self._closed:
raise RuntimeError("FullTextIndexWriter is closed")