Skip to main content

hermes_core/index/
primary_key.rs

1//! Primary key deduplication index.
2//!
3//! Uses a bloom filter + `FxHashSet` for uncommitted keys to reject duplicates
4//! at `add_document()` time. Committed keys are checked via fast-field
5//! `TextDictReader::ordinal()` (binary search, O(log n)).
6//!
7//! The bloom filter is persisted to `pk_bloom.bin` so that restarts don't need
8//! to re-iterate every committed key. On load, only keys from segments that
9//! appeared since the last persist are iterated.
10
11use std::collections::HashSet;
12
13use byteorder::{LittleEndian, WriteBytesExt};
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use crate::dsl::Field;
17use crate::error::{Error, Result};
18use crate::segment::SegmentSnapshot;
19use crate::structures::BloomFilter;
20
21/// Bloom filter sizing: 10 bits/key ≈ 1% false positive rate.
22const BLOOM_BITS_PER_KEY: usize = 10;
23
24/// Extra capacity added to bloom filter beyond known keys.
25const BLOOM_HEADROOM: usize = 100_000;
26
27/// File name for the persisted primary-key bloom filter.
28pub const PK_BLOOM_FILE: &str = "pk_bloom.bin";
29
30/// Magic bytes for the persisted bloom file.
31const PK_BLOOM_MAGIC: u32 = 0x504B424C; // "PKBL"
32
33/// Lightweight per-segment data for primary key lookups.
34///
35/// Only holds fast-field readers (text dictionaries), not full `SegmentReader`s.
36/// This avoids loading DimensionTables, SSTable FSTs, bloom filters, etc.
37pub struct PkSegmentData {
38    pub segment_id: String,
39    pub fast_fields: FxHashMap<u32, crate::structures::fast_field::FastFieldReader>,
40}
41
42/// Thread-safe primary key deduplication index.
43///
44/// Sync dedup in the hot path: `BloomFilter::may_contain()`,
45/// `FxHashSet::contains()`, and `TextDictReader::ordinal()` are all sync.
46///
47/// Interior mutability for the mutable state (bloom + uncommitted set) is
48/// behind `parking_lot::Mutex`. The committed data is only mutated via
49/// `&mut self` methods (commit/abort path), so no lock is needed for it.
50pub struct PrimaryKeyIndex {
51    field: Field,
52    state: parking_lot::Mutex<PrimaryKeyState>,
53    /// Lightweight per-segment fast-field data for checking committed keys.
54    /// Only mutated by `&mut self` methods (refresh/clear) — no lock needed.
55    committed_data: Vec<PkSegmentData>,
56    /// Holds ref counts so segments aren't deleted while we hold readers.
57    _snapshot: Option<SegmentSnapshot>,
58}
59
60struct PrimaryKeyState {
61    bloom: BloomFilter,
62    uncommitted: FxHashSet<Vec<u8>>,
63}
64
65impl PrimaryKeyIndex {
66    /// Create a new PrimaryKeyIndex by scanning committed segments.
67    ///
68    /// Iterates each segment's fast-field text dictionary to populate the bloom
69    /// filter with all existing primary key values. The snapshot keeps ref counts
70    /// alive so segments aren't deleted while we hold data.
71    ///
72    /// **CPU-intensive** — call from `spawn_blocking`, not the async runtime.
73    pub fn new(field: Field, pk_data: Vec<PkSegmentData>, snapshot: SegmentSnapshot) -> Self {
74        // Count total unique keys across all segments for bloom sizing.
75        let mut total_keys: usize = 0;
76        for data in &pk_data {
77            if let Some(ff) = data.fast_fields.get(&field.0)
78                && let Some(dict) = ff.text_dict()
79            {
80                total_keys += dict.len() as usize;
81            }
82        }
83
84        let mut bloom = BloomFilter::new(total_keys + BLOOM_HEADROOM, BLOOM_BITS_PER_KEY);
85
86        // Insert all committed keys into the bloom filter.
87        for data in &pk_data {
88            if let Some(ff) = data.fast_fields.get(&field.0)
89                && let Some(dict) = ff.text_dict()
90            {
91                for key in dict.iter() {
92                    bloom.insert(key.as_bytes());
93                }
94            }
95        }
96
97        let bloom_bytes = bloom.size_bytes();
98        log::info!(
99            "[primary_key] bloom filter: {} keys, {}",
100            total_keys,
101            crate::format_bytes(bloom_bytes as u64),
102        );
103
104        Self {
105            field,
106            state: parking_lot::Mutex::new(PrimaryKeyState {
107                bloom,
108                uncommitted: FxHashSet::default(),
109            }),
110            committed_data: pk_data,
111            _snapshot: Some(snapshot),
112        }
113    }
114
115    /// Create from a pre-loaded bloom filter (loaded from `pk_bloom.bin`).
116    ///
117    /// Skips dictionary iteration because the caller has already extended the
118    /// persisted bloom with any segments it did not cover. `pk_data` contains
119    /// data for all current segments.
120    pub fn from_persisted(
121        field: Field,
122        bloom: BloomFilter,
123        pk_data: Vec<PkSegmentData>,
124        snapshot: SegmentSnapshot,
125    ) -> Self {
126        log::info!(
127            "[primary_key] bloom filter loaded from cache: {}",
128            crate::format_bytes(bloom.size_bytes() as u64),
129        );
130
131        Self {
132            field,
133            state: parking_lot::Mutex::new(PrimaryKeyState {
134                bloom,
135                uncommitted: FxHashSet::default(),
136            }),
137            committed_data: pk_data,
138            _snapshot: Some(snapshot),
139        }
140    }
141
142    /// Stream the complete primary-key cache without a corpus-sized
143    /// intermediate allocation.
144    pub fn write_bloom_cache(
145        &self,
146        segment_ids: &[String],
147        writer: &mut (impl std::io::Write + ?Sized),
148    ) -> std::io::Result<()> {
149        let state = self.state.lock();
150        write_pk_bloom(writer, segment_ids, &state.bloom)
151    }
152
153    /// Memory used by the bloom filter and uncommitted set.
154    pub fn memory_bytes(&self) -> usize {
155        let state = self.state.lock();
156        state.bloom.size_bytes() + state.uncommitted.len() * 32 // estimate 32 bytes per key
157    }
158
159    /// Check whether a document's primary key is unique, and if so, register it.
160    ///
161    /// Returns `Ok(())` if the key is new (inserted into bloom + uncommitted set).
162    /// Returns `Err(DuplicatePrimaryKey)` if the key already exists.
163    /// Returns `Err(Document)` if the primary key field is missing or empty.
164    pub fn check_and_insert(&self, doc: &crate::dsl::Document) -> Result<()> {
165        let value = doc
166            .get_first(self.field)
167            .ok_or_else(|| Error::Document("Missing primary key field".into()))?;
168        let key = value
169            .as_text()
170            .ok_or_else(|| Error::Document("Primary key must be text".into()))?;
171        if key.is_empty() {
172            return Err(Error::Document("Primary key must not be empty".into()));
173        }
174
175        let key_bytes = key.as_bytes();
176
177        {
178            let mut state = self.state.lock();
179
180            // Fast path: bloom says definitely not present → new key.
181            if !state.bloom.may_contain(key_bytes) {
182                state.bloom.insert(key_bytes);
183                state.uncommitted.insert(key_bytes.to_vec());
184                return Ok(());
185            }
186
187            // Bloom positive → check uncommitted set first (fast, in-memory).
188            if state.uncommitted.contains(key_bytes) {
189                return Err(Error::DuplicatePrimaryKey(key.to_string()));
190            }
191        }
192        // Lock released — check committed segments without holding mutex.
193        // committed_data is immutable (only changed via &mut self methods).
194        for data in &self.committed_data {
195            if let Some(ff) = data.fast_fields.get(&self.field.0)
196                && let Some(dict) = ff.text_dict()
197                && dict.ordinal(key).is_some()
198            {
199                return Err(Error::DuplicatePrimaryKey(key.to_string()));
200            }
201        }
202
203        // Re-acquire lock to insert. Re-check uncommitted in case another
204        // thread inserted the same key while we were scanning committed segments.
205        let mut state = self.state.lock();
206        if state.uncommitted.contains(key_bytes) {
207            return Err(Error::DuplicatePrimaryKey(key.to_string()));
208        }
209
210        // Bloom false positive — key is genuinely new.
211        state.bloom.insert(key_bytes);
212        state.uncommitted.insert(key_bytes.to_vec());
213        Ok(())
214    }
215
216    /// Refresh after commit: merge new segment data, prune removed segments,
217    /// insert new keys into bloom, and clear uncommitted set.
218    ///
219    /// Only `new_data` (segments not already held) need to be loaded by the
220    /// caller. Existing data for segments still in `snapshot` is retained.
221    /// The snapshot keeps ref counts alive so segments aren't deleted.
222    pub fn refresh_incremental(&mut self, new_data: Vec<PkSegmentData>, snapshot: SegmentSnapshot) {
223        // Insert new segments' keys into bloom (these were uncommitted before).
224        // get_mut() bypasses the mutex — safe because we have &mut self.
225        let state = self.state.get_mut();
226        for data in &new_data {
227            if let Some(ff) = data.fast_fields.get(&self.field.0)
228                && let Some(dict) = ff.text_dict()
229            {
230                for key in dict.iter() {
231                    state.bloom.insert(key.as_bytes());
232                }
233            }
234        }
235        state.uncommitted.clear();
236        self.replace_committed_data(new_data, snapshot);
237    }
238
239    /// Refresh segment readers after a topology-only replacement.
240    ///
241    /// Merge and BP reorder outputs contain exactly the same primary keys as
242    /// their sources. Their keys are therefore already represented in the
243    /// monotonic bloom filter, and any live ingestion reservations must remain
244    /// registered while only the committed segment topology changes.
245    pub fn refresh_replacement(&mut self, new_data: Vec<PkSegmentData>, snapshot: SegmentSnapshot) {
246        self.replace_committed_data(new_data, snapshot);
247    }
248
249    fn replace_committed_data(&mut self, new_data: Vec<PkSegmentData>, snapshot: SegmentSnapshot) {
250        let new_seg_ids: HashSet<&str> =
251            snapshot.segment_ids().iter().map(|s| s.as_str()).collect();
252        let mut kept: Vec<PkSegmentData> = self
253            .committed_data
254            .drain(..)
255            .filter(|d| new_seg_ids.contains(d.segment_id.as_str()))
256            .collect();
257        kept.extend(new_data);
258        self.committed_data = kept;
259        self._snapshot = Some(snapshot);
260    }
261
262    /// Iterator over segment IDs already held in this PK index.
263    pub fn committed_segment_ids(&self) -> impl Iterator<Item = &str> {
264        self.committed_data.iter().map(|d| d.segment_id.as_str())
265    }
266
267    /// Roll back an uncommitted key registration (e.g. when channel send fails
268    /// after check_and_insert succeeded). Bloom may retain the key but that only
269    /// causes harmless false positives, never missed duplicates.
270    pub fn rollback_uncommitted_key(&self, doc: &crate::dsl::Document) {
271        if let Some(value) = doc.get_first(self.field)
272            && let Some(key) = value.as_text()
273        {
274            self.state.lock().uncommitted.remove(key.as_bytes());
275        }
276    }
277
278    /// Clear uncommitted keys (e.g. on abort). Bloom may retain stale entries
279    /// but that only causes harmless false positives (extra committed-segment
280    /// lookups), never missed duplicates.
281    pub fn clear_uncommitted(&mut self) {
282        self.state.get_mut().uncommitted.clear();
283    }
284}
285
286/// Write a bloom filter with the segment IDs it covers in `pk_bloom.bin` format.
287///
288/// Layout: `[magic:u32][num_segs:u32][seg_id_hex × 32 bytes each...][bloom_bytes...]`
289fn write_pk_bloom(
290    writer: &mut (impl std::io::Write + ?Sized),
291    segment_ids: &[String],
292    bloom: &BloomFilter,
293) -> std::io::Result<()> {
294    writer.write_u32::<LittleEndian>(PK_BLOOM_MAGIC)?;
295    writer.write_u32::<LittleEndian>(u32::try_from(segment_ids.len()).map_err(|_| {
296        std::io::Error::new(
297            std::io::ErrorKind::InvalidInput,
298            "primary-key bloom segment count exceeds u32::MAX",
299        )
300    })?)?;
301    for seg_id in segment_ids {
302        let bytes = seg_id.as_bytes();
303        if bytes.len() > 32 {
304            return Err(std::io::Error::new(
305                std::io::ErrorKind::InvalidInput,
306                "primary-key bloom segment ID exceeds 32 bytes",
307            ));
308        }
309        writer.write_all(bytes)?;
310        // Pad to 32 bytes (segment IDs are 32-char hex strings)
311        writer.write_all(&[0u8; 32][..32 - bytes.len()])?;
312    }
313    bloom.write_to(writer)
314}
315
316/// Deserialize `pk_bloom.bin`. Returns the set of covered segment IDs and the bloom filter,
317/// or `None` if the data is corrupt / wrong magic.
318pub fn deserialize_pk_bloom(data: &[u8]) -> Option<(HashSet<String>, BloomFilter)> {
319    if data.len() < 8 {
320        return None;
321    }
322    let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
323    if magic != PK_BLOOM_MAGIC {
324        return None;
325    }
326    let num_segments = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
327    let header_end = 8 + num_segments * 32;
328    if data.len() < header_end + BloomFilter::SERIALIZED_HEADER_SIZE {
329        return None;
330    }
331    let mut segment_ids = HashSet::with_capacity(num_segments);
332    for i in 0..num_segments {
333        let start = 8 + i * 32;
334        let raw = &data[start..start + 32];
335        let end = raw.iter().position(|&b| b == 0).unwrap_or(32);
336        let hex = std::str::from_utf8(&raw[..end]).ok()?;
337        segment_ids.insert(hex.to_string());
338    }
339    let bloom = BloomFilter::from_bytes_mutable(&data[header_end..]).ok()?;
340    Some((segment_ids, bloom))
341}
342
343#[cfg(test)]
344mod tests {
345    use std::sync::Arc;
346
347    use super::*;
348    use crate::dsl::{Document, Field};
349    use crate::segment::SegmentTracker;
350
351    fn make_doc(field: Field, key: &str) -> Document {
352        let mut doc = Document::new();
353        doc.add_text(field, key);
354        doc
355    }
356
357    fn empty_snapshot() -> SegmentSnapshot {
358        SegmentSnapshot::new(Arc::new(SegmentTracker::new()), vec![])
359    }
360
361    #[test]
362    fn test_new_empty_readers() {
363        let field = Field(0);
364        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
365        // Should construct without panicking
366        let doc = make_doc(field, "key1");
367        assert!(pk.check_and_insert(&doc).is_ok());
368    }
369
370    #[test]
371    fn test_unique_keys_accepted() {
372        let field = Field(0);
373        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
374
375        assert!(pk.check_and_insert(&make_doc(field, "a")).is_ok());
376        assert!(pk.check_and_insert(&make_doc(field, "b")).is_ok());
377        assert!(pk.check_and_insert(&make_doc(field, "c")).is_ok());
378    }
379
380    #[test]
381    fn test_duplicate_uncommitted_rejected() {
382        let field = Field(0);
383        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
384
385        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
386        let result = pk.check_and_insert(&make_doc(field, "key1"));
387        assert!(result.is_err());
388        match result.unwrap_err() {
389            Error::DuplicatePrimaryKey(k) => assert_eq!(k, "key1"),
390            other => panic!("Expected DuplicatePrimaryKey, got {:?}", other),
391        }
392    }
393
394    #[test]
395    fn test_missing_field_rejected() {
396        let field = Field(0);
397        let other_field = Field(1);
398        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
399
400        // Document has a different field, not the primary key field
401        let doc = make_doc(other_field, "value");
402        let result = pk.check_and_insert(&doc);
403        assert!(result.is_err());
404        match result.unwrap_err() {
405            Error::Document(msg) => assert!(msg.contains("Missing"), "{}", msg),
406            other => panic!("Expected Document error, got {:?}", other),
407        }
408    }
409
410    #[test]
411    fn test_empty_key_rejected() {
412        let field = Field(0);
413        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
414
415        let result = pk.check_and_insert(&make_doc(field, ""));
416        assert!(result.is_err());
417        match result.unwrap_err() {
418            Error::Document(msg) => assert!(msg.contains("empty"), "{}", msg),
419            other => panic!("Expected Document error, got {:?}", other),
420        }
421    }
422
423    #[test]
424    fn test_clear_uncommitted() {
425        let field = Field(0);
426        let mut pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
427
428        // Insert key1
429        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
430        // Duplicate should fail
431        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_err());
432
433        // Clear uncommitted
434        pk.clear_uncommitted();
435
436        // After clear, bloom still has key1 but uncommitted doesn't.
437        // With no committed readers, the key should be allowed again
438        // (bloom positive → check uncommitted (not found) → check committed (empty) → accept)
439        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
440    }
441
442    #[test]
443    fn test_many_unique_keys() {
444        let field = Field(0);
445        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
446
447        for i in 0..1000 {
448            let key = format!("key_{}", i);
449            assert!(pk.check_and_insert(&make_doc(field, &key)).is_ok());
450        }
451
452        // All should be duplicates now
453        for i in 0..1000 {
454            let key = format!("key_{}", i);
455            assert!(pk.check_and_insert(&make_doc(field, &key)).is_err());
456        }
457    }
458
459    #[test]
460    fn test_refresh_clears_uncommitted() {
461        let field = Field(0);
462        let mut pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
463
464        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
465        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_err());
466
467        // Refresh with empty data (simulates commit where segments
468        // don't have fast fields — edge case)
469        pk.refresh_incremental(vec![], empty_snapshot());
470
471        // After refresh, uncommitted is cleared and no committed data has
472        // the key, so it should be accepted again
473        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
474    }
475
476    #[test]
477    fn replacement_refresh_preserves_uncommitted_reservations() {
478        let field = Field(0);
479        let mut pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
480
481        assert!(pk.check_and_insert(&make_doc(field, "queued")).is_ok());
482        pk.refresh_replacement(vec![], empty_snapshot());
483
484        assert!(
485            pk.check_and_insert(&make_doc(field, "queued")).is_err(),
486            "topology-only BP refresh must not erase a queued key reservation"
487        );
488    }
489
490    #[test]
491    fn test_pk_bloom_serialize_roundtrip() {
492        let field = Field(0);
493        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
494        for i in 0..100 {
495            pk.check_and_insert(&make_doc(field, &format!("key_{}", i)))
496                .unwrap();
497        }
498
499        let seg_ids = vec![
500            "00000000000000000000000000000001".to_string(),
501            "00000000000000000000000000000002".to_string(),
502        ];
503        let mut data = Vec::new();
504        pk.write_bloom_cache(&seg_ids, &mut data).unwrap();
505        let (got_ids, got_bloom) = deserialize_pk_bloom(&data).expect("deserialize failed");
506
507        assert_eq!(got_ids.len(), 2);
508        assert!(got_ids.contains(&seg_ids[0]));
509        assert!(got_ids.contains(&seg_ids[1]));
510
511        // Verify the loaded bloom recognizes previously inserted keys.
512        for i in 0..100 {
513            let key = format!("key_{}", i);
514            assert!(
515                got_bloom.may_contain(key.as_bytes()),
516                "bloom miss for {}",
517                key
518            );
519        }
520    }
521
522    #[test]
523    fn test_pk_bloom_deserialize_bad_data() {
524        assert!(deserialize_pk_bloom(&[]).is_none());
525        assert!(deserialize_pk_bloom(&[0; 7]).is_none());
526        assert!(deserialize_pk_bloom(&[0; 8]).is_none()); // wrong magic
527    }
528
529    #[test]
530    fn test_concurrent_access() {
531        use std::sync::Arc;
532
533        let field = Field(0);
534        let pk = Arc::new(PrimaryKeyIndex::new(field, vec![], empty_snapshot()));
535
536        // Spawn multiple threads trying to insert the same key
537        let mut handles = vec![];
538        for _ in 0..10 {
539            let pk = Arc::clone(&pk);
540            handles.push(std::thread::spawn(move || {
541                pk.check_and_insert(&make_doc(field, "contested_key"))
542            }));
543        }
544
545        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
546        let successes = results.iter().filter(|r| r.is_ok()).count();
547        let failures = results.iter().filter(|r| r.is_err()).count();
548
549        // Exactly one thread should succeed, rest should get DuplicatePrimaryKey
550        assert_eq!(successes, 1, "Exactly one insert should succeed");
551        assert_eq!(failures, 9, "Rest should fail with duplicate");
552    }
553}