Skip to main content

summa_core/index/
primary_key.rs

1//! Primary key deduplication index.
2//!
3//! Uses a bloom filter + a map of latest staged rows to reject duplicate adds
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
11#[cfg(feature = "native")]
12use std::collections::HashSet;
13
14use super::staged_row::StagedRow;
15use crate::dsl::{Document, FieldValue, Schema};
16use byteorder::{LittleEndian, WriteBytesExt};
17use rustc_hash::{FxHashMap, FxHashSet};
18use std::sync::Arc;
19
20use crate::dsl::Field;
21use crate::error::{Error, Result};
22#[cfg(feature = "native")]
23use crate::segment::SegmentSnapshot;
24use crate::structures::BloomFilter;
25
26/// Bloom filter sizing: 10 bits/key ≈ 1% false positive rate.
27const BLOOM_BITS_PER_KEY: usize = 10;
28
29/// Extra capacity added to bloom filter beyond known keys.
30const BLOOM_HEADROOM: usize = 100_000;
31
32/// File name for the persisted primary-key bloom filter.
33#[cfg(feature = "native")]
34pub const PK_BLOOM_FILE: &str = "pk_bloom.bin";
35
36/// Magic bytes for the persisted bloom file.
37const PK_BLOOM_MAGIC: u32 = 0x504B424C; // "PKBL"
38
39/// The reservation, deletion target and persisted single-value column must
40/// identify the same key. Validate before mutating any writer state.
41pub(super) fn document_key(doc: &crate::dsl::Document, field: crate::Field) -> Result<&str> {
42    let mut values = doc.get_all(field);
43    let key = values
44        .next()
45        .ok_or_else(|| Error::Document("Missing primary key field".into()))?
46        .as_text()
47        .ok_or_else(|| Error::Document("Primary key must be text".into()))?;
48    if values.next().is_some() {
49        return Err(Error::Document(
50            "primary key requires exactly one text value".into(),
51        ));
52    }
53    if key.is_empty() {
54        return Err(Error::Document("Primary key must not be empty".into()));
55    }
56    if key.len() > 64 * 1024 {
57        return Err(Error::Document(
58            "primary key must contain 1..=65536 bytes".into(),
59        ));
60    }
61    Ok(key)
62}
63
64/// Lightweight per-segment data for primary key lookups.
65///
66/// Only holds fast-field readers (text dictionaries), not full `SegmentReader`s.
67/// This avoids loading DimensionTables, SSTable FSTs, bloom filters, etc.
68pub struct PkSegmentData {
69    pub segment_id: String,
70    pub(super) content_hash: Option<super::content_hash::ContentHashLookup>,
71    pub deletion_meta: Option<crate::segment::DeletionMeta>,
72    pub alive_docs: Option<std::sync::Arc<crate::query::DocBitset>>,
73    pub live_key_ordinals: Option<crate::query::DocBitset>,
74    pub fast_fields: FxHashMap<u32, crate::structures::fast_field::FastFieldReader>,
75}
76
77impl PkSegmentData {
78    // One scan at visibility refresh; duplicate checks stay dictionary lookup
79    // plus O(1) membership, even for a heavily deleted segment.
80    pub(crate) fn prepare_live_keys(&mut self, field: Field) {
81        if self.live_key_ordinals.is_some() {
82            return;
83        }
84        let Some(alive) = &self.alive_docs else {
85            return;
86        };
87        let Some(ff) = self.fast_fields.get(&field.0) else {
88            return;
89        };
90        let Some(dict) = ff.text_dict() else {
91            return;
92        };
93        let mut keys = crate::query::DocBitset::new(dict.len());
94        ff.scan_single_values(|doc, ordinal| {
95            if alive.contains(doc) && ordinal < u64::from(dict.len()) {
96                keys.set(ordinal as u32);
97            }
98        });
99        self.live_key_ordinals = Some(keys);
100    }
101}
102
103/// Thread-safe primary key deduplication index.
104///
105/// Sync dedup in the hot path: `BloomFilter::may_contain()`,
106/// `FxHashMap::contains_key()`, and `TextDictReader::ordinal()` are all sync.
107///
108/// Interior mutability for the mutable state (bloom + latest staged map) is
109/// behind `parking_lot::Mutex`. The committed data is only mutated via
110/// `&mut self` methods (commit/abort path), so no lock is needed for it.
111pub struct PrimaryKeyIndex {
112    field: Field,
113    state: parking_lot::Mutex<PrimaryKeyState>,
114    /// Lightweight per-segment fast-field data for checking committed keys.
115    /// Only mutated by `&mut self` methods (refresh/clear) — no lock needed.
116    committed_data: Vec<PkSegmentData>,
117    /// Holds ref counts so segments aren't deleted while we hold readers.
118    #[cfg(feature = "native")]
119    _snapshot: Option<std::sync::Arc<SegmentSnapshot>>,
120}
121
122struct PrimaryKeyState {
123    bloom: BloomFilter,
124    uncommitted: FxHashMap<Vec<u8>, PendingKey>,
125    pending_bytes: usize,
126    cancelled_bytes: usize,
127    deletes: FxHashSet<String>,
128    delete_bytes: usize,
129}
130
131const MAX_PENDING_KEY_BYTES: usize = 64 * 1024 * 1024;
132// Vec<u32> initially allocates four slots; later growth uses at most two per row.
133const CANCELLED_ROW_BYTES: usize = 4 * std::mem::size_of::<u32>();
134
135struct PendingKey {
136    row: Arc<StagedRow>,
137    hash: Option<FieldValue>,
138    bytes: usize,
139}
140
141// Twice entry size covers table occupancy, control bytes, and allocation overhead.
142const KEY_SLOT_BYTES: usize =
143    2 * (std::mem::size_of::<PendingKey>() + std::mem::size_of::<Vec<u8>>());
144
145fn pending_bytes(key: &str, hash: Option<&FieldValue>) -> usize {
146    KEY_SLOT_BYTES
147        + std::mem::size_of::<StagedRow>()
148        + 2 * std::mem::size_of::<usize>()
149        + key.len()
150        + match hash {
151            Some(FieldValue::Text(value)) => value.len(),
152            Some(FieldValue::Bytes(value)) => value.len(),
153            _ => 0,
154        }
155}
156
157fn check_pending_budget(
158    state: &PrimaryKeyState,
159    old: usize,
160    new: usize,
161    cancelled: usize,
162) -> Result<()> {
163    let next_len = state.uncommitted.len() + usize::from(new > 0) - usize::from(old > 0);
164    // HashMap may grow on the accepted insertion. Reserve a conservative next
165    // capacity before queue admission; retained capacity still counts after clear.
166    let capacity = if next_len > state.uncommitted.capacity() {
167        (next_len * 2).max(3)
168    } else {
169        state.uncommitted.capacity()
170    };
171    let payload = state.pending_bytes - old + new - next_len * KEY_SLOT_BYTES;
172    let used = payload + capacity * KEY_SLOT_BYTES + state.cancelled_bytes + cancelled;
173    if used > MAX_PENDING_KEY_BYTES {
174        return Err(Error::Document(
175            "pending primary-key metadata exceeds 64 MiB; commit before continuing".into(),
176        ));
177    }
178    Ok(())
179}
180
181fn validate_mutation_key(key: &str) -> Result<()> {
182    if key.is_empty() || key.len() > 64 * 1024 {
183        return Err(Error::Document(
184            "deletion key must contain 1..=65536 bytes".into(),
185        ));
186    }
187    Ok(())
188}
189
190fn check_delete_budget(state: &PrimaryKeyState, key: &str) -> Result<()> {
191    if !state.deletes.contains(key)
192        && (state.deletes.len() >= 100_000 || state.delete_bytes + key.len() > 8 * 1024 * 1024)
193    {
194        return Err(Error::Document(
195            "pending deletions exceed 100000 keys or 8 MiB; commit before continuing".into(),
196        ));
197    }
198    Ok(())
199}
200
201fn stage_delete(state: &mut PrimaryKeyState, key: &str) -> bool {
202    if state.deletes.contains(key) {
203        return false;
204    }
205    state.delete_bytes += key.len();
206    state.deletes.insert(key.to_owned());
207    true
208}
209
210impl PrimaryKeyIndex {
211    /// Create a new PrimaryKeyIndex by scanning committed segments.
212    ///
213    /// Iterates each segment's fast-field text dictionary to populate the bloom
214    /// filter with all existing primary key values. The snapshot keeps ref counts
215    /// alive so segments aren't deleted while we hold data.
216    ///
217    /// **CPU-intensive** — call from `spawn_blocking`, not the async runtime.
218    #[cfg(feature = "native")]
219    pub fn new(field: Field, pk_data: Vec<PkSegmentData>, snapshot: SegmentSnapshot) -> Self {
220        let mut index = Self::build(field, pk_data);
221        index._snapshot = Some(std::sync::Arc::new(snapshot));
222        index
223    }
224
225    pub(crate) fn build(field: Field, mut pk_data: Vec<PkSegmentData>) -> Self {
226        for data in &mut pk_data {
227            data.prepare_live_keys(field);
228        }
229        // Count total unique keys across all segments for bloom sizing.
230        let mut total_keys: usize = 0;
231        for data in &pk_data {
232            if let Some(ff) = data.fast_fields.get(&field.0)
233                && let Some(dict) = ff.text_dict()
234            {
235                total_keys += dict.len() as usize;
236            }
237        }
238
239        let mut bloom = BloomFilter::new(total_keys + BLOOM_HEADROOM, BLOOM_BITS_PER_KEY);
240
241        // Insert all committed keys into the bloom filter.
242        for data in &pk_data {
243            if let Some(ff) = data.fast_fields.get(&field.0)
244                && let Some(dict) = ff.text_dict()
245            {
246                for key in dict.iter() {
247                    bloom.insert(key.as_bytes());
248                }
249            }
250        }
251
252        let bloom_bytes = bloom.size_bytes();
253        log::info!(
254            "[primary_key] bloom filter: {} keys, {}",
255            total_keys,
256            crate::format_bytes(bloom_bytes as u64),
257        );
258
259        Self {
260            field,
261            state: parking_lot::Mutex::new(PrimaryKeyState {
262                bloom,
263                uncommitted: FxHashMap::default(),
264                pending_bytes: 0,
265                cancelled_bytes: 0,
266                deletes: FxHashSet::default(),
267                delete_bytes: 0,
268            }),
269            committed_data: pk_data,
270            #[cfg(feature = "native")]
271            _snapshot: None,
272        }
273    }
274
275    /// Create from a pre-loaded bloom filter (loaded from `pk_bloom.bin`).
276    ///
277    /// Skips dictionary iteration because the caller has already extended the
278    /// persisted bloom with any segments it did not cover. `pk_data` contains
279    /// data for all current segments.
280    #[cfg(feature = "native")]
281    pub fn from_persisted(
282        field: Field,
283        bloom: BloomFilter,
284        mut pk_data: Vec<PkSegmentData>,
285        snapshot: SegmentSnapshot,
286    ) -> Self {
287        for data in &mut pk_data {
288            data.prepare_live_keys(field);
289        }
290        log::info!(
291            "[primary_key] bloom filter loaded from cache: {}",
292            crate::format_bytes(bloom.size_bytes() as u64),
293        );
294
295        Self {
296            field,
297            state: parking_lot::Mutex::new(PrimaryKeyState {
298                bloom,
299                uncommitted: FxHashMap::default(),
300                pending_bytes: 0,
301                cancelled_bytes: 0,
302                deletes: FxHashSet::default(),
303                delete_bytes: 0,
304            }),
305            committed_data: pk_data,
306            _snapshot: Some(std::sync::Arc::new(snapshot)),
307        }
308    }
309
310    /// Stream the complete primary-key cache without a corpus-sized
311    /// intermediate allocation.
312    pub fn write_bloom_cache(
313        &self,
314        segment_ids: &[String],
315        writer: &mut (impl std::io::Write + ?Sized),
316    ) -> std::io::Result<()> {
317        let state = self.state.lock();
318        write_pk_bloom(writer, segment_ids, &state.bloom)
319    }
320
321    /// Memory used by the bloom filter and latest staged map.
322    pub fn memory_bytes(&self) -> usize {
323        let state = self.state.lock();
324        state.bloom.size_bytes()
325            + state.pending_bytes
326            + (state.uncommitted.capacity() - state.uncommitted.len()) * KEY_SLOT_BYTES
327            + state.cancelled_bytes
328            + state.delete_bytes
329            + state.deletes.capacity() * std::mem::size_of::<String>()
330            + self
331                .committed_data
332                .iter()
333                .map(|data| {
334                    data.content_hash
335                        .as_ref()
336                        .map_or(0, |lookup| lookup.memory_bytes())
337                        + data
338                            .alive_docs
339                            .as_ref()
340                            .map_or(0, |bits| bits.bits.len() * 8)
341                        + data
342                            .live_key_ordinals
343                            .as_ref()
344                            .map_or(0, |bits| bits.bits.len() * 8)
345                })
346                .sum::<usize>()
347    }
348
349    /// Resolve committed content only when there is no latest staged row.
350    pub(super) fn content_hash_target(
351        &self,
352        key: &str,
353    ) -> Result<Option<super::content_hash::ContentHashTarget>> {
354        let state = self.state.lock();
355        if !state.bloom.may_contain(key.as_bytes()) {
356            return Ok(None);
357        }
358        if state.uncommitted.contains_key(key.as_bytes()) {
359            return Ok(None);
360        }
361        if state.deletes.contains(key) {
362            return Ok(None);
363        }
364        drop(state);
365        let mut target = None;
366        for data in &self.committed_data {
367            let column = data
368                .fast_fields
369                .get(&self.field.0)
370                .ok_or_else(|| Error::Corruption("primary-key column is missing".into()))?;
371            if let Some(ordinal) = column.text_ordinal(key)
372                && data
373                    .live_key_ordinals
374                    .as_ref()
375                    .is_none_or(|keys| keys.contains(ordinal as u32))
376            {
377                if target.is_some() {
378                    return Err(Error::Corruption(
379                        "multiple live segments share a primary key".into(),
380                    ));
381                }
382                let lookup = data.content_hash.as_ref().ok_or_else(|| {
383                    Error::Internal("content hash lookup was not initialized".into())
384                })?;
385                target = Some(super::content_hash::ContentHashTarget {
386                    store: std::sync::Arc::clone(&lookup.store),
387                    row: lookup.row(ordinal, column, data)?,
388                    #[cfg(feature = "native")]
389                    snapshot: self._snapshot.clone(),
390                });
391            }
392        }
393        Ok(target)
394    }
395
396    /// Stage deletion of the latest row, including an unpublished insertion.
397    pub(crate) fn delete(&self, key: &str) -> Result<bool> {
398        validate_mutation_key(key)?;
399        let mut state = self.state.lock();
400        check_delete_budget(&state, key)?;
401        if let Some(old) = state.uncommitted.get(key.as_bytes()) {
402            check_pending_budget(&state, old.bytes, 0, CANCELLED_ROW_BYTES)?;
403        }
404        let staged = stage_delete(&mut state, key);
405        if let Some(old) = state.uncommitted.remove(key.as_bytes()) {
406            state.pending_bytes -= old.bytes;
407            state.cancelled_bytes += CANCELLED_ROW_BYTES;
408            old.row.cancel();
409        }
410        Ok(staged)
411    }
412
413    /// Some(false) means a staged version exists and committed content must
414    /// not be consulted. Missing hashes are never equality assertions.
415    pub(super) fn staged_hash_matches(&self, key: &str, hash: &FieldValue) -> Option<bool> {
416        self.state
417            .lock()
418            .uncommitted
419            .get(key.as_bytes())
420            .map(|pending| pending.hash.as_ref() == Some(hash))
421    }
422
423    pub(super) fn admit_document(
424        &self,
425        doc: Document,
426        schema: &Schema,
427        replace: bool,
428        accept: impl FnOnce(Document, Arc<StagedRow>) -> Result<()>,
429    ) -> Result<()> {
430        let key = document_key(&doc, self.field)?.to_owned();
431        let hash = super::content_hash::document_hash(&doc, schema)?;
432        // Check the budget before cloning a caller-controlled hash.
433        let bytes = pending_bytes(&key, hash);
434        let mut state = self.state.lock();
435        self.check_admission(&state, &key, replace, bytes)?;
436        let hash = hash.cloned();
437        let row = Arc::new(StagedRow::default());
438        accept(doc, Arc::clone(&row))?;
439        self.finish_admission(&mut state, key, PendingKey { row, hash, bytes }, replace);
440        Ok(())
441    }
442
443    fn check_admission(
444        &self,
445        state: &PrimaryKeyState,
446        key: &str,
447        replace: bool,
448        bytes: usize,
449    ) -> Result<()> {
450        if replace {
451            check_delete_budget(state, key)?;
452        } else if state.uncommitted.contains_key(key.as_bytes()) {
453            return Err(Error::DuplicatePrimaryKey(key.to_owned()));
454        } else if !state.deletes.contains(key) && state.bloom.may_contain(key.as_bytes()) {
455            for data in &self.committed_data {
456                if let Some(ff) = data.fast_fields.get(&self.field.0)
457                    && let Some(ordinal) = ff.text_ordinal(key)
458                    && data
459                        .live_key_ordinals
460                        .as_ref()
461                        .is_none_or(|keys| keys.contains(ordinal as u32))
462                {
463                    return Err(Error::DuplicatePrimaryKey(key.to_owned()));
464                }
465            }
466        }
467        let old_bytes = state
468            .uncommitted
469            .get(key.as_bytes())
470            .map_or(0, |old| old.bytes);
471        check_pending_budget(
472            state,
473            old_bytes,
474            bytes,
475            if old_bytes > 0 {
476                CANCELLED_ROW_BYTES
477            } else {
478                0
479            },
480        )?;
481        Ok(())
482    }
483
484    fn finish_admission(
485        &self,
486        state: &mut PrimaryKeyState,
487        key: String,
488        pending: PendingKey,
489        replace: bool,
490    ) {
491        if replace {
492            stage_delete(state, &key);
493        }
494        state.bloom.insert(key.as_bytes());
495        state.pending_bytes += pending.bytes;
496        if let Some(old) = state.uncommitted.insert(key.into_bytes(), pending) {
497            state.pending_bytes -= old.bytes;
498            state.cancelled_bytes += CANCELLED_ROW_BYTES;
499            old.row.cancel();
500        }
501    }
502
503    // Clear at the publication boundary, independently of the fallible PK
504    // cache refresh. Retrying a refresh must never delete the replacement rows.
505    pub(crate) fn mark_deletes_published(&self) {
506        let mut state = self.state.lock();
507        state.deletes.clear();
508        state.delete_bytes = 0;
509    }
510
511    pub(crate) fn pending_deletes(&self) -> Vec<String> {
512        self.state.lock().deletes.iter().cloned().collect()
513    }
514
515    /// Check whether a document's primary key is unique, and if so, register it.
516    ///
517    /// Returns `Ok(())` if the key is new (inserted into bloom + latest staged map).
518    /// Returns `Err(DuplicatePrimaryKey)` if the key already exists.
519    /// Returns `Err(Document)` if the primary key field is missing or empty.
520    pub fn check_and_insert(&self, doc: &Document) -> Result<()> {
521        let key = document_key(doc, self.field)?;
522        let bytes = pending_bytes(key, None);
523        let mut state = self.state.lock();
524        self.check_admission(&state, key, false, bytes)?;
525        self.finish_admission(
526            &mut state,
527            key.to_owned(),
528            PendingKey {
529                row: Arc::new(StagedRow::default()),
530                hash: None,
531                bytes,
532            },
533            false,
534        );
535        Ok(())
536    }
537
538    /// Refresh after commit: merge new segment data, prune removed segments,
539    /// insert new keys into bloom, and clear latest staged map.
540    ///
541    /// Only `new_data` (segments not already held) need to be loaded by the
542    /// caller. Existing data for segments still in `snapshot` is retained.
543    /// The snapshot keeps ref counts alive so segments aren't deleted.
544    #[cfg(feature = "native")]
545    pub fn refresh_incremental(&mut self, new_data: Vec<PkSegmentData>, snapshot: SegmentSnapshot) {
546        self.refresh_data(new_data, snapshot.segment_ids());
547        self._snapshot = Some(std::sync::Arc::new(snapshot));
548    }
549
550    pub(crate) fn refresh_data(&mut self, new_data: Vec<PkSegmentData>, segment_ids: &[String]) {
551        // Insert new segments' keys into bloom (these were uncommitted before).
552        // get_mut() bypasses the mutex — safe because we have &mut self.
553        let state = self.state.get_mut();
554        for data in &new_data {
555            if let Some(ff) = data.fast_fields.get(&self.field.0)
556                && let Some(dict) = ff.text_dict()
557            {
558                for key in dict.iter() {
559                    state.bloom.insert(key.as_bytes());
560                }
561            }
562        }
563        state.uncommitted.clear();
564        state.pending_bytes = 0;
565        state.cancelled_bytes = 0;
566        state.deletes.clear();
567        state.delete_bytes = 0;
568        self.replace_committed_data(new_data, segment_ids);
569    }
570
571    /// Refresh segment readers after a topology-only replacement.
572    ///
573    /// Merge/reorder outputs contain only keys from their sources (compaction
574    /// can remove deleted keys). Their keys are already represented in the
575    /// monotonic bloom filter, and any live ingestion reservations must remain
576    /// registered while only the committed segment topology changes.
577    #[cfg(feature = "native")]
578    pub fn refresh_replacement(&mut self, new_data: Vec<PkSegmentData>, snapshot: SegmentSnapshot) {
579        self.replace_committed_data(new_data, snapshot.segment_ids());
580        self._snapshot = Some(std::sync::Arc::new(snapshot));
581    }
582
583    fn replace_committed_data(&mut self, new_data: Vec<PkSegmentData>, segment_ids: &[String]) {
584        let new_seg_ids: FxHashSet<&str> = segment_ids.iter().map(|s| s.as_str()).collect();
585        let replaced: FxHashSet<&str> = new_data
586            .iter()
587            .map(|data| data.segment_id.as_str())
588            .collect();
589        let mut kept: Vec<PkSegmentData> = self
590            .committed_data
591            .drain(..)
592            .filter(|d| {
593                new_seg_ids.contains(d.segment_id.as_str())
594                    && !replaced.contains(d.segment_id.as_str())
595            })
596            .collect();
597        kept.extend(new_data);
598        self.committed_data = kept;
599    }
600
601    #[cfg(all(feature = "wasm", not(feature = "native")))]
602    pub(crate) fn segment_data(&self) -> &[PkSegmentData] {
603        &self.committed_data
604    }
605
606    /// Iterator over segment IDs already held in this PK index.
607    pub fn committed_segment_ids(&self) -> impl Iterator<Item = &str> {
608        self.committed_data.iter().map(|d| d.segment_id.as_str())
609    }
610
611    pub(crate) fn committed_visibility(
612        &self,
613    ) -> impl Iterator<Item = (&str, Option<&crate::segment::DeletionMeta>)> {
614        self.committed_data
615            .iter()
616            .map(|data| (data.segment_id.as_str(), data.deletion_meta.as_ref()))
617    }
618
619    /// Roll back an uncommitted key registration (e.g. when channel send fails
620    /// after check_and_insert succeeded). Bloom may retain the key but that only
621    /// causes harmless false positives, never missed duplicates.
622    pub fn rollback_uncommitted_key(&self, doc: &crate::dsl::Document) {
623        if let Some(value) = doc.get_first(self.field)
624            && let Some(key) = value.as_text()
625        {
626            let mut state = self.state.lock();
627            if let Some(old) = state.uncommitted.remove(key.as_bytes()) {
628                state.pending_bytes -= old.bytes;
629                state.cancelled_bytes += CANCELLED_ROW_BYTES;
630                old.row.cancel();
631            }
632        }
633    }
634
635    /// Clear uncommitted keys (e.g. on abort). Bloom may retain stale entries
636    /// but that only causes harmless false positives (extra committed-segment
637    /// lookups), never missed duplicates.
638    pub fn clear_uncommitted(&mut self) {
639        let state = self.state.get_mut();
640        state.uncommitted.clear();
641        state.pending_bytes = 0;
642        state.cancelled_bytes = 0;
643        state.deletes.clear();
644        state.delete_bytes = 0;
645    }
646}
647
648/// Write a bloom filter with the segment IDs it covers in `pk_bloom.bin` format.
649///
650/// Layout: `[magic:u32][num_segs:u32][seg_id_hex × 32 bytes each...][bloom_bytes...]`
651fn write_pk_bloom(
652    writer: &mut (impl std::io::Write + ?Sized),
653    segment_ids: &[String],
654    bloom: &BloomFilter,
655) -> std::io::Result<()> {
656    writer.write_u32::<LittleEndian>(PK_BLOOM_MAGIC)?;
657    writer.write_u32::<LittleEndian>(u32::try_from(segment_ids.len()).map_err(|_| {
658        std::io::Error::new(
659            std::io::ErrorKind::InvalidInput,
660            "primary-key bloom segment count exceeds u32::MAX",
661        )
662    })?)?;
663    for seg_id in segment_ids {
664        let bytes = seg_id.as_bytes();
665        if bytes.len() > 32 {
666            return Err(std::io::Error::new(
667                std::io::ErrorKind::InvalidInput,
668                "primary-key bloom segment ID exceeds 32 bytes",
669            ));
670        }
671        writer.write_all(bytes)?;
672        // Pad to 32 bytes (segment IDs are 32-char hex strings)
673        writer.write_all(&[0u8; 32][..32 - bytes.len()])?;
674    }
675    bloom.write_to(writer)
676}
677
678/// Deserialize `pk_bloom.bin`. Returns the set of covered segment IDs and the bloom filter,
679/// or `None` if the data is corrupt / wrong magic.
680#[cfg(feature = "native")]
681pub fn deserialize_pk_bloom(data: &[u8]) -> Option<(HashSet<String>, BloomFilter)> {
682    if data.len() < 8 {
683        return None;
684    }
685    let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
686    if magic != PK_BLOOM_MAGIC {
687        return None;
688    }
689    let num_segments = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
690    let header_end = 8 + num_segments * 32;
691    if data.len() < header_end + BloomFilter::SERIALIZED_HEADER_SIZE {
692        return None;
693    }
694    let mut segment_ids = HashSet::with_capacity(num_segments);
695    for i in 0..num_segments {
696        let start = 8 + i * 32;
697        let raw = &data[start..start + 32];
698        let end = raw.iter().position(|&b| b == 0).unwrap_or(32);
699        let hex = std::str::from_utf8(&raw[..end]).ok()?;
700        segment_ids.insert(hex.to_string());
701    }
702    let bloom = BloomFilter::from_bytes_mutable(&data[header_end..]).ok()?;
703    Some((segment_ids, bloom))
704}
705
706#[cfg(all(test, feature = "native"))]
707mod tests {
708    use std::sync::Arc;
709
710    use super::*;
711    use crate::dsl::{Document, Field};
712    use crate::segment::SegmentTracker;
713
714    fn make_doc(field: Field, key: &str) -> Document {
715        let mut doc = Document::new();
716        doc.add_text(field, key);
717        doc
718    }
719
720    fn empty_snapshot() -> SegmentSnapshot {
721        SegmentSnapshot::new(Arc::new(SegmentTracker::new()), vec![])
722    }
723
724    #[test]
725    fn test_new_empty_readers() {
726        let field = Field(0);
727        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
728        // Should construct without panicking
729        let doc = make_doc(field, "key1");
730        assert!(pk.check_and_insert(&doc).is_ok());
731    }
732
733    #[test]
734    fn test_unique_keys_accepted() {
735        let field = Field(0);
736        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
737
738        assert!(pk.check_and_insert(&make_doc(field, "a")).is_ok());
739        assert!(pk.check_and_insert(&make_doc(field, "b")).is_ok());
740        assert!(pk.check_and_insert(&make_doc(field, "c")).is_ok());
741    }
742
743    #[test]
744    fn test_duplicate_uncommitted_rejected() {
745        let field = Field(0);
746        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
747
748        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
749        let result = pk.check_and_insert(&make_doc(field, "key1"));
750        assert!(result.is_err());
751        match result.unwrap_err() {
752            Error::DuplicatePrimaryKey(k) => assert_eq!(k, "key1"),
753            other => panic!("Expected DuplicatePrimaryKey, got {:?}", other),
754        }
755    }
756
757    #[test]
758    fn test_missing_field_rejected() {
759        let field = Field(0);
760        let other_field = Field(1);
761        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
762
763        // Document has a different field, not the primary key field
764        let doc = make_doc(other_field, "value");
765        let result = pk.check_and_insert(&doc);
766        assert!(result.is_err());
767        match result.unwrap_err() {
768            Error::Document(msg) => assert!(msg.contains("Missing"), "{}", msg),
769            other => panic!("Expected Document error, got {:?}", other),
770        }
771    }
772
773    #[test]
774    fn test_empty_key_rejected() {
775        let field = Field(0);
776        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
777
778        let result = pk.check_and_insert(&make_doc(field, ""));
779        assert!(result.is_err());
780        match result.unwrap_err() {
781            Error::Document(msg) => assert!(msg.contains("empty"), "{}", msg),
782            other => panic!("Expected Document error, got {:?}", other),
783        }
784    }
785
786    #[test]
787    fn test_clear_uncommitted() {
788        let field = Field(0);
789        let mut pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
790
791        // Insert key1
792        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
793        // Duplicate should fail
794        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_err());
795
796        // Clear uncommitted
797        pk.clear_uncommitted();
798
799        // After clear, bloom still has key1 but uncommitted doesn't.
800        // With no committed readers, the key should be allowed again
801        // (bloom positive → check uncommitted (not found) → check committed (empty) → accept)
802        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
803    }
804
805    #[test]
806    fn test_many_unique_keys() {
807        let field = Field(0);
808        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
809
810        for i in 0..1000 {
811            let key = format!("key_{}", i);
812            assert!(pk.check_and_insert(&make_doc(field, &key)).is_ok());
813        }
814
815        // All should be duplicates now
816        for i in 0..1000 {
817            let key = format!("key_{}", i);
818            assert!(pk.check_and_insert(&make_doc(field, &key)).is_err());
819        }
820    }
821
822    #[test]
823    fn test_refresh_clears_uncommitted() {
824        let field = Field(0);
825        let mut pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
826
827        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
828        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_err());
829
830        // Refresh with empty data (simulates commit where segments
831        // don't have fast fields — edge case)
832        pk.refresh_incremental(vec![], empty_snapshot());
833
834        // After refresh, uncommitted is cleared and no committed data has
835        // the key, so it should be accepted again
836        assert!(pk.check_and_insert(&make_doc(field, "key1")).is_ok());
837    }
838
839    #[test]
840    fn replacement_refresh_preserves_uncommitted_reservations() {
841        let field = Field(0);
842        let mut pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
843
844        assert!(pk.check_and_insert(&make_doc(field, "queued")).is_ok());
845        pk.refresh_replacement(vec![], empty_snapshot());
846
847        assert!(
848            pk.check_and_insert(&make_doc(field, "queued")).is_err(),
849            "topology-only BP refresh must not erase a queued key reservation"
850        );
851    }
852
853    #[test]
854    fn test_pk_bloom_serialize_roundtrip() {
855        let field = Field(0);
856        let pk = PrimaryKeyIndex::new(field, vec![], empty_snapshot());
857        for i in 0..100 {
858            pk.check_and_insert(&make_doc(field, &format!("key_{}", i)))
859                .unwrap();
860        }
861
862        let seg_ids = vec![
863            "00000000000000000000000000000001".to_string(),
864            "00000000000000000000000000000002".to_string(),
865        ];
866        let mut data = Vec::new();
867        pk.write_bloom_cache(&seg_ids, &mut data).unwrap();
868        let (got_ids, got_bloom) = deserialize_pk_bloom(&data).expect("deserialize failed");
869
870        assert_eq!(got_ids.len(), 2);
871        assert!(got_ids.contains(&seg_ids[0]));
872        assert!(got_ids.contains(&seg_ids[1]));
873
874        // Verify the loaded bloom recognizes previously inserted keys.
875        for i in 0..100 {
876            let key = format!("key_{}", i);
877            assert!(
878                got_bloom.may_contain(key.as_bytes()),
879                "bloom miss for {}",
880                key
881            );
882        }
883    }
884
885    #[test]
886    fn test_pk_bloom_deserialize_bad_data() {
887        assert!(deserialize_pk_bloom(&[]).is_none());
888        assert!(deserialize_pk_bloom(&[0; 7]).is_none());
889        assert!(deserialize_pk_bloom(&[0; 8]).is_none()); // wrong magic
890    }
891
892    #[test]
893    fn test_concurrent_access() {
894        use std::sync::Arc;
895
896        let field = Field(0);
897        let pk = Arc::new(PrimaryKeyIndex::new(field, vec![], empty_snapshot()));
898
899        // Spawn multiple threads trying to insert the same key
900        let mut handles = vec![];
901        for _ in 0..10 {
902            let pk = Arc::clone(&pk);
903            handles.push(std::thread::spawn(move || {
904                pk.check_and_insert(&make_doc(field, "contested_key"))
905            }));
906        }
907
908        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
909        let successes = results.iter().filter(|r| r.is_ok()).count();
910        let failures = results.iter().filter(|r| r.is_err()).count();
911
912        // Exactly one thread should succeed, rest should get DuplicatePrimaryKey
913        assert_eq!(successes, 1, "Exactly one insert should succeed");
914        assert_eq!(failures, 9, "Rest should fail with duplicate");
915    }
916}
917
918/// Load only fast-field data for a segment (lightweight alternative to full SegmentReader).
919pub(crate) async fn load_pk_segment_data<D: crate::directories::Directory>(
920    dir: &D,
921    seg_id_str: &str,
922    schema: &crate::dsl::Schema,
923    deletion: Option<(u32, crate::segment::DeletionMeta)>,
924) -> Result<PkSegmentData> {
925    let seg_id = crate::segment::SegmentId::from_hex(seg_id_str)
926        .ok_or_else(|| Error::Internal(format!("Invalid segment id: {}", seg_id_str)))?;
927    let files = crate::segment::SegmentFiles::new(seg_id.0);
928    let fast_fields =
929        crate::segment::reader::loader::load_fast_fields_file(dir, &files, schema).await?;
930    let (deletion_meta, alive_docs) = match deletion {
931        Some((num_docs, meta)) => {
932            let alive = meta.load(dir, num_docs).await?;
933            (Some(meta), Some(alive))
934        }
935        None => (None, None),
936    };
937    let data = PkSegmentData {
938        deletion_meta,
939        alive_docs,
940        live_key_ordinals: None,
941        segment_id: seg_id_str.to_string(),
942        content_hash: None,
943        fast_fields,
944    };
945    if schema.content_hash_field().is_none() {
946        return Ok(data);
947    }
948    #[cfg(feature = "native")]
949    let cache = super::shared_store_cache(32 * 1024 * 1024);
950    #[cfg(not(feature = "native"))]
951    let cache = std::sync::Arc::new(crate::segment::SharedStoreCache::new(0));
952    let store = std::sync::Arc::new(
953        crate::segment::AsyncStoreReader::open(
954            dir.open_lazy(&files.store).await?,
955            dir as *const D as usize,
956            seg_id.0,
957            cache,
958        )
959        .await?,
960    );
961    let field = schema
962        .primary_field()
963        .ok_or_else(|| Error::Schema("content_hash requires a primary key".into()))?;
964    let prepare = move || {
965        let mut data = data;
966        let column = data
967            .fast_fields
968            .get(&field.0)
969            .ok_or_else(|| Error::Corruption("primary-key column is missing".into()))?;
970        let lookup = super::content_hash::ContentHashLookup::new(store, column, &data)?;
971        data.content_hash = Some(lookup);
972        Ok(data)
973    };
974    #[cfg(feature = "native")]
975    {
976        tokio::task::spawn_blocking(prepare)
977            .await
978            .map_err(|error| {
979                Error::Internal(format!("content hash lookup preparation failed: {error}"))
980            })?
981    }
982    #[cfg(not(feature = "native"))]
983    {
984        prepare()
985    }
986}