Skip to content

Commit 70f0b03

Browse files
authored
Merge pull request #2978 from quickwit-oss/mallets/finalize-docidmapping
feat: add custom doc id mapping finalization
2 parents 31ca1a8 + 2554072 commit 70f0b03

10 files changed

Lines changed: 476 additions & 40 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ futures-util = { version = "0.3.28", optional = true }
7070
futures-channel = { version = "0.3.28", optional = true }
7171
fnv = "1.0.7"
7272
typetag = "0.2.21"
73+
unwrap-infallible = "1.0.0"
7374

7475
[target.'cfg(windows)'.dependencies]
7576
winapi = "0.3.9"

common/src/bitset.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,12 +281,16 @@ impl BitSet {
281281
}
282282

283283
/// Inserts an element in the `BitSet`
284+
///
285+
/// Returns true if the set changed.
284286
#[inline]
285-
pub fn insert(&mut self, el: u32) {
287+
pub fn insert(&mut self, el: u32) -> bool {
286288
// we do not check saturated els.
287289
let higher = el / 64u32;
288290
let lower = el % 64u32;
289-
self.len += u64::from(self.tinysets[higher as usize].insert_mut(lower));
291+
let changed = self.tinysets[higher as usize].insert_mut(lower);
292+
self.len += u64::from(changed);
293+
changed
290294
}
291295

292296
/// Inserts an element in the `BitSet`

src/aggregation/agg_data.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,9 @@ fn build_allowed_term_ids_for_str(
931931
// add matches
932932
allowed = Some(BitSet::with_max_value(allowed_capacity));
933933
let allowed = allowed.as_mut().unwrap();
934-
for_each_matching_term_ord(str_col, include, |ord| allowed.insert(ord))?;
934+
for_each_matching_term_ord(str_col, include, |ord| {
935+
let _ = allowed.insert(ord);
936+
})?;
935937
};
936938

937939
if let Some(exclude) = exclude {

src/index/index.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,14 @@ impl IndexBuilder {
233233

234234
fn validate(&self) -> crate::Result<()> {
235235
if let Some(schema) = self.schema.as_ref() {
236+
if self.index_settings.manual_doc_id_mapping
237+
&& self.index_settings.sort_by_field.is_some()
238+
{
239+
return Err(TantivyError::InvalidArgument(
240+
"IndexSettings::manual_doc_id_mapping cannot be combined with sort_by_field"
241+
.to_string(),
242+
));
243+
}
236244
if let Some(sort_by_field) = self.index_settings.sort_by_field.as_ref() {
237245
let schema_field = schema.get_field(&sort_by_field.field).map_err(|_| {
238246
TantivyError::InvalidArgument(format!(

src/index/index_meta.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,11 @@ pub struct IndexSettings {
250250
/// provided in `IndexSortByField`
251251
#[serde(skip_serializing_if = "Option::is_none")]
252252
pub sort_by_field: Option<IndexSortByField>,
253+
/// If true, enables caller-provided doc id mappings at segment finalization time.
254+
/// Always skip serializing this field since it's only used at segment finalization time.
255+
#[doc(hidden)]
256+
#[serde(skip)]
257+
pub manual_doc_id_mapping: bool,
253258
/// The `Compressor` used to compress the doc store.
254259
#[serde(default)]
255260
pub docstore_compression: Compressor,
@@ -273,6 +278,7 @@ impl Default for IndexSettings {
273278
fn default() -> Self {
274279
Self {
275280
sort_by_field: None,
281+
manual_doc_id_mapping: false,
276282
docstore_compression: Compressor::default(),
277283
docstore_blocksize: default_docstore_blocksize(),
278284
docstore_compress_dedicated_thread: true,
@@ -460,6 +466,7 @@ mod tests {
460466
field: "text".to_string(),
461467
order: Order::Asc,
462468
}),
469+
manual_doc_id_mapping: false,
463470
docstore_compression: crate::store::Compressor::Zstd(ZstdCompressor {
464471
compression_level: Some(4),
465472
}),
@@ -529,6 +536,7 @@ mod tests {
529536
index_settings,
530537
IndexSettings {
531538
sort_by_field: None,
539+
manual_doc_id_mapping: false,
532540
docstore_compression: Compressor::default(),
533541
docstore_compress_dedicated_thread: true,
534542
docstore_blocksize: 16_384
@@ -547,6 +555,19 @@ mod tests {
547555
serde_json::from_value(index_settings_json).unwrap();
548556
assert_eq!(index_settings_deser, index_settings);
549557
}
558+
{
559+
// manual_doc_id_mapping should not be persisted.
560+
index_settings.manual_doc_id_mapping = true;
561+
let index_settings_json = serde_json::to_value(&index_settings).unwrap();
562+
assert_eq!(
563+
index_settings_json,
564+
serde_json::json!({
565+
"docstore_compression": "lz4",
566+
"docstore_blocksize": 16384
567+
})
568+
);
569+
index_settings.manual_doc_id_mapping = false;
570+
}
550571
{
551572
index_settings.docstore_compress_dedicated_thread = false;
552573
let index_settings_json = serde_json::to_value(&index_settings).unwrap();

src/indexer/doc_id_mapping.rs

Lines changed: 205 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
//! This module is used when sorting the index by a property, e.g.
22
//! to get mappings from old doc_id to new doc_id and vice versa, after sorting
3+
use std::convert::Infallible;
4+
use std::error::Error;
35

4-
use common::ReadOnlyBitSet;
6+
use common::{BitSet, ReadOnlyBitSet};
7+
use unwrap_infallible::UnwrapInfallible;
58

69
use super::SegmentWriter;
710
use crate::schema::{Field, Schema};
@@ -71,7 +74,34 @@ pub struct DocIdMapping {
7174
}
7275

7376
impl DocIdMapping {
74-
pub fn from_new_id_to_old_id(new_doc_id_to_old: Vec<DocId>) -> Self {
77+
/// Creates a `DocIdMapping` from a mapping of new doc ids to old doc ids, with permutation
78+
/// validation. The mapping is validated by checking that every old doc id appears exactly
79+
/// once in the mapping. I.e., doc ids must be consecutive from `0` to
80+
/// `new_doc_id_to_old.len() - 1`, inclusive.
81+
pub fn new_permutation(new_doc_id_to_old: Vec<DocId>) -> crate::Result<Self> {
82+
let max_doc = new_doc_id_to_old.len() as DocId;
83+
let mut seen_doc_ids: BitSet = BitSet::with_max_value(max_doc);
84+
85+
Self::from_new_id_to_old_id_inner(new_doc_id_to_old, |old_doc_id| {
86+
if old_doc_id >= max_doc || !seen_doc_ids.insert(old_doc_id) {
87+
return Err(TantivyError::InvalidArgument(
88+
"Mapping must be a permutation of the segment doc ids".to_string(),
89+
));
90+
}
91+
Ok::<(), TantivyError>(())
92+
})
93+
}
94+
95+
/// Creates a `DocIdMapping` from a mapping of new doc ids to old doc ids.
96+
pub(crate) fn from_new_id_to_old_id(new_doc_id_to_old: Vec<DocId>) -> Self {
97+
Self::from_new_id_to_old_id_inner(new_doc_id_to_old, |_| Ok::<(), Infallible>(()))
98+
.unwrap_infallible()
99+
}
100+
101+
fn from_new_id_to_old_id_inner<E: Error>(
102+
new_doc_id_to_old: Vec<DocId>,
103+
mut validate: impl FnMut(DocId) -> Result<(), E>,
104+
) -> Result<Self, E> {
75105
let max_doc = new_doc_id_to_old.len();
76106
let old_max_doc = new_doc_id_to_old
77107
.iter()
@@ -81,43 +111,50 @@ impl DocIdMapping {
81111
.unwrap_or(0);
82112
let mut old_doc_id_to_new = vec![0; old_max_doc as usize];
83113
for i in 0..max_doc {
114+
(validate)(new_doc_id_to_old[i])?;
84115
old_doc_id_to_new[new_doc_id_to_old[i] as usize] = i as DocId;
85116
}
86-
DocIdMapping {
117+
Ok(DocIdMapping {
87118
new_doc_id_to_old,
88119
old_doc_id_to_new,
89-
}
120+
})
90121
}
91122

92-
/// returns the new doc_id for the old doc_id
93-
pub fn get_new_doc_id(&self, doc_id: DocId) -> DocId {
123+
/// Returns the new doc_id for the old doc_id
124+
pub(crate) fn get_new_doc_id(&self, doc_id: DocId) -> DocId {
94125
self.old_doc_id_to_new[doc_id as usize]
95126
}
96-
/// returns the old doc_id for the new doc_id
97-
pub fn get_old_doc_id(&self, doc_id: DocId) -> DocId {
98-
self.new_doc_id_to_old[doc_id as usize]
99-
}
100-
/// iterate over old doc_ids in order of the new doc_ids
101-
pub fn iter_old_doc_ids(&self) -> impl Iterator<Item = DocId> + Clone + '_ {
102-
self.new_doc_id_to_old.iter().cloned()
127+
128+
/// Iiterate over old doc_ids in order of the new doc_ids
129+
pub(crate) fn iter_old_doc_ids(&self) -> impl Iterator<Item = DocId> + Clone + '_ {
130+
self.new_doc_id_to_old.iter().copied()
103131
}
104132

105-
pub fn old_to_new_ids(&self) -> &[DocId] {
133+
/// Returns the new doc_ids in order of the old doc_ids
134+
pub(crate) fn old_to_new_ids(&self) -> &[DocId] {
106135
&self.old_doc_id_to_new[..]
107136
}
108137

109138
/// Remaps a given array to the new doc ids.
110-
pub fn remap<T: Copy>(&self, els: &[T]) -> Vec<T> {
139+
pub(crate) fn remap<T: Copy>(&self, els: &[T]) -> Vec<T> {
111140
self.new_doc_id_to_old
112141
.iter()
113142
.map(|old_doc| els[*old_doc as usize])
114143
.collect()
115144
}
116-
pub fn num_new_doc_ids(&self) -> usize {
145+
146+
/// Returns the number of documents in the mapping.
147+
pub(crate) fn len(&self) -> usize {
148+
// new_doc_id_to_old and old_doc_id_to_new have the same length by construction.
117149
self.new_doc_id_to_old.len()
118150
}
119-
pub fn num_old_doc_ids(&self) -> usize {
120-
self.old_doc_id_to_new.len()
151+
}
152+
153+
#[cfg(test)]
154+
impl DocIdMapping {
155+
/// returns the old doc_id for the new doc_id
156+
fn get_old_doc_id(&self, doc_id: DocId) -> DocId {
157+
self.new_doc_id_to_old[doc_id as usize]
121158
}
122159
}
123160

@@ -155,11 +192,15 @@ mod tests_indexsorting {
155192
use common::DateTime;
156193

157194
use crate::collector::TopDocs;
195+
use crate::directory::{Directory, RamDirectory};
196+
use crate::index::SegmentComponent;
158197
use crate::indexer::doc_id_mapping::DocIdMapping;
159198
use crate::indexer::NoMergePolicy;
160199
use crate::query::QueryParser;
161200
use crate::schema::*;
162-
use crate::{DocAddress, Index, IndexBuilder, IndexSettings, IndexSortByField, Order};
201+
use crate::{
202+
DocAddress, Index, IndexBuilder, IndexSettings, IndexSortByField, Order, TantivyError,
203+
};
163204

164205
fn create_test_index(
165206
index_settings: Option<IndexSettings>,
@@ -536,6 +577,139 @@ mod tests_indexsorting {
536577
Ok(())
537578
}
538579

580+
#[test]
581+
fn test_single_segment_index_writer_with_doc_id_mapping() -> crate::Result<()> {
582+
let mut schema_builder = Schema::builder();
583+
let text_field = schema_builder.add_text_field("text", TEXT | STORED);
584+
let schema = schema_builder.build();
585+
let settings = IndexSettings {
586+
manual_doc_id_mapping: true,
587+
..Default::default()
588+
};
589+
let mut single_segment_index_writer = Index::builder()
590+
.schema(schema)
591+
.settings(settings)
592+
.single_segment_index_writer(RamDirectory::default(), 15_000_000)?;
593+
594+
single_segment_index_writer.add_document(doc!(text_field=>"alpha beta"))?;
595+
single_segment_index_writer.add_document(doc!())?;
596+
single_segment_index_writer.add_document(doc!(text_field=>"gamma"))?;
597+
598+
let mapping = DocIdMapping::new_permutation(vec![2, 1, 0])?;
599+
let index = single_segment_index_writer.finalize_with_doc_id_mapping(&mapping)?;
600+
601+
let searcher = index.reader()?.searcher();
602+
let segment_reader = searcher.segment_reader(0);
603+
let fieldnorm_reader = segment_reader.get_fieldnorms_reader(text_field)?;
604+
605+
assert_eq!(fieldnorm_reader.fieldnorm(0), 1);
606+
assert_eq!(fieldnorm_reader.fieldnorm(1), 0);
607+
assert_eq!(fieldnorm_reader.fieldnorm(2), 2);
608+
609+
let doc_0 = searcher.doc::<TantivyDocument>(DocAddress::new(0, 0))?;
610+
assert_eq!(
611+
doc_0.get_first(text_field).and_then(|val| val.as_str()),
612+
Some("gamma")
613+
);
614+
let doc_1 = searcher.doc::<TantivyDocument>(DocAddress::new(0, 1))?;
615+
assert!(doc_1.get_first(text_field).is_none());
616+
let doc_2 = searcher.doc::<TantivyDocument>(DocAddress::new(0, 2))?;
617+
assert_eq!(
618+
doc_2.get_first(text_field).and_then(|val| val.as_str()),
619+
Some("alpha beta")
620+
);
621+
622+
assert!(!index.settings().manual_doc_id_mapping);
623+
let segment_metas = index.searchable_segment_metas()?;
624+
let segment_meta = &segment_metas[0];
625+
let temp_store_path = segment_meta.relative_path(SegmentComponent::TempStore);
626+
assert!(!segment_meta.list_files().contains(&temp_store_path));
627+
assert!(!index.directory().exists(&temp_store_path)?);
628+
629+
let mut index_writer = index.writer_for_tests()?;
630+
index_writer.add_document(doc!(text_field=>"delta"))?;
631+
index_writer.commit()?;
632+
633+
Ok(())
634+
}
635+
636+
#[test]
637+
fn test_single_segment_index_writer_with_sort_by_field_untracks_tempstore() -> crate::Result<()>
638+
{
639+
let mut schema_builder = Schema::builder();
640+
let sort_field = schema_builder.add_u64_field("sort", FAST | STORED);
641+
let schema = schema_builder.build();
642+
let sort_field_name = schema.get_field_entry(sort_field).name().to_string();
643+
let settings = IndexSettings {
644+
sort_by_field: Some(IndexSortByField {
645+
field: sort_field_name,
646+
order: Order::Asc,
647+
}),
648+
..Default::default()
649+
};
650+
let mut single_segment_index_writer = Index::builder()
651+
.schema(schema)
652+
.settings(settings)
653+
.single_segment_index_writer(RamDirectory::default(), 15_000_000)?;
654+
655+
single_segment_index_writer.add_document(doc!(sort_field=>2u64))?;
656+
single_segment_index_writer.add_document(doc!(sort_field=>1u64))?;
657+
let index = single_segment_index_writer.finalize()?;
658+
659+
let segment_metas = index.searchable_segment_metas()?;
660+
let segment_meta = &segment_metas[0];
661+
let temp_store_path = segment_meta.relative_path(SegmentComponent::TempStore);
662+
assert!(!segment_meta.list_files().contains(&temp_store_path));
663+
assert!(!index.directory().exists(&temp_store_path)?);
664+
Ok(())
665+
}
666+
667+
#[test]
668+
fn test_single_segment_index_writer_finalize_rejects_manual_doc_id_mapping() -> crate::Result<()>
669+
{
670+
let mut schema_builder = Schema::builder();
671+
let text_field = schema_builder.add_text_field("text", TEXT | STORED);
672+
let schema = schema_builder.build();
673+
let settings = IndexSettings {
674+
manual_doc_id_mapping: true,
675+
..Default::default()
676+
};
677+
let mut single_segment_index_writer = Index::builder()
678+
.schema(schema)
679+
.settings(settings)
680+
.single_segment_index_writer(RamDirectory::default(), 15_000_000)?;
681+
682+
single_segment_index_writer.add_document(doc!(text_field=>"alpha"))?;
683+
684+
let error = single_segment_index_writer.finalize().unwrap_err();
685+
assert!(matches!(error, TantivyError::InvalidArgument(_)));
686+
Ok(())
687+
}
688+
689+
#[test]
690+
fn test_index_builder_rejects_manual_doc_id_mapping_with_sort_by_field() {
691+
let mut schema_builder = Schema::builder();
692+
schema_builder.add_text_field("text", TEXT | STORED);
693+
let sort_field = schema_builder.add_u64_field("sort", STORED | FAST);
694+
let schema = schema_builder.build();
695+
let sort_field_name = schema.get_field_entry(sort_field).name().to_string();
696+
let settings = IndexSettings {
697+
manual_doc_id_mapping: true,
698+
sort_by_field: Some(IndexSortByField {
699+
field: sort_field_name,
700+
order: Order::Asc,
701+
}),
702+
..Default::default()
703+
};
704+
705+
let error = Index::builder()
706+
.schema(schema)
707+
.settings(settings)
708+
.create_in_ram()
709+
.unwrap_err();
710+
assert!(matches!(error, TantivyError::InvalidArgument(_)));
711+
}
712+
539713
#[test]
540714
fn test_doc_mapping() {
541715
let doc_mapping = DocIdMapping::from_new_id_to_old_id(vec![3, 2, 5]);
@@ -550,6 +724,18 @@ mod tests_indexsorting {
550724
assert_eq!(doc_mapping.get_new_doc_id(5), 2);
551725
}
552726

727+
#[test]
728+
fn test_doc_mapping_new_permutation_rejects_out_of_range() {
729+
let result = DocIdMapping::new_permutation(vec![5, 0]);
730+
assert!(matches!(result, Err(TantivyError::InvalidArgument(_)),));
731+
}
732+
733+
#[test]
734+
fn test_doc_mapping_new_permutation_rejects_duplicates() {
735+
let result = DocIdMapping::new_permutation(vec![0, 1, 0]);
736+
assert!(matches!(result, Err(TantivyError::InvalidArgument(_)),));
737+
}
738+
553739
#[test]
554740
fn test_doc_mapping_remap() {
555741
let doc_mapping = DocIdMapping::from_new_id_to_old_id(vec![2, 8, 3]);

0 commit comments

Comments
 (0)