Skip to main content

hermes_core/segment/
store.rs

1//! Document store with Zstd compression and lazy loading
2//!
3//! Optimized for static indexes:
4//! - Maximum compression level (22) for best compression ratio
5//! - Larger block sizes (256KB) for better compression efficiency
6//! - Optional trained dictionary support for even better compression
7//! - Parallel compression support for faster indexing
8//!
9//! Writer stores documents in compressed blocks.
10//! Reader only loads index into memory, blocks are loaded on-demand.
11
12use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
13use lru::LruCache;
14use parking_lot::RwLock;
15use rustc_hash::FxHashMap;
16#[cfg(feature = "native")]
17use std::collections::BTreeMap;
18use std::io::{self, Write};
19use std::sync::Arc;
20#[cfg(feature = "native")]
21use std::sync::mpsc::{Receiver, SyncSender};
22#[cfg(feature = "native")]
23use std::thread::JoinHandle;
24
25use crate::DocId;
26use crate::compression::CompressionDict;
27#[cfg(feature = "native")]
28use crate::compression::CompressionLevel;
29use crate::directories::FileHandle;
30use crate::dsl::{Document, Schema};
31
32const STORE_MAGIC: u32 = 0x53544F52; // "STOR"
33const STORE_VERSION: u32 = 2; // Version 2 supports dictionaries
34
35/// Block size for document store (16KB).
36/// Smaller blocks reduce read amplification for single-doc fetches at the
37/// cost of slightly worse compression ratio. Zstd dictionary training
38/// recovers most of the compression loss.
39pub const STORE_BLOCK_SIZE: usize = 16 * 1024;
40
41/// Default dictionary size (4KB is a good balance)
42pub const DEFAULT_DICT_SIZE: usize = 4 * 1024;
43
44/// Hard safety bounds for individual on-disk store objects. Writers normally
45/// emit ~16 KiB blocks and 4 KiB dictionaries; these generous limits preserve
46/// unusually large stored documents while bounding corrupt compressed frames.
47const MAX_STORE_BLOCK_BYTES: usize = 64 * 1024 * 1024;
48const MAX_STORE_DICTIONARY_BYTES: u64 = 16 * 1024 * 1024;
49
50/// Default compression level for document store
51#[cfg(feature = "native")]
52const DEFAULT_COMPRESSION_LEVEL: CompressionLevel = CompressionLevel(3);
53
54/// Write block index + footer to a store file.
55///
56/// Shared by `EagerParallelStoreWriter::finish` and `StoreMerger::finish`.
57fn write_store_index_and_footer(
58    writer: &mut (impl Write + ?Sized),
59    index: &[StoreBlockIndex],
60    data_end_offset: u64,
61    dict_offset: u64,
62    num_docs: u32,
63    has_dict: bool,
64) -> io::Result<()> {
65    writer.write_u32::<LittleEndian>(u32::try_from(index.len()).map_err(|_| {
66        io::Error::new(
67            io::ErrorKind::InvalidInput,
68            "too many document store blocks",
69        )
70    })?)?;
71    for entry in index {
72        writer.write_u32::<LittleEndian>(entry.first_doc_id)?;
73        writer.write_u64::<LittleEndian>(entry.offset)?;
74        writer.write_u32::<LittleEndian>(entry.length)?;
75        writer.write_u32::<LittleEndian>(entry.num_docs)?;
76    }
77    writer.write_u64::<LittleEndian>(data_end_offset)?;
78    writer.write_u64::<LittleEndian>(dict_offset)?;
79    writer.write_u32::<LittleEndian>(num_docs)?;
80    writer.write_u32::<LittleEndian>(if has_dict { 1 } else { 0 })?;
81    writer.write_u32::<LittleEndian>(STORE_VERSION)?;
82    writer.write_u32::<LittleEndian>(STORE_MAGIC)?;
83    Ok(())
84}
85
86/// Binary document format:
87///   num_fields: u16
88///   per field: field_id: u16, type_tag: u8, value data
89///     0=Text:         len:u32 + utf8
90///     1=U64:          u64 LE
91///     2=I64:          i64 LE
92///     3=F64:          f64 LE
93///     4=Bytes:        len:u32 + raw
94///     5=SparseVector: count:u32 + count*(u32+f32)
95///     6=DenseVector:  count:u32 + count*f32
96///     7=Json:         len:u32 + json utf8
97pub fn serialize_document(doc: &Document, schema: &Schema) -> io::Result<Vec<u8>> {
98    let mut buf = Vec::with_capacity(256);
99    serialize_document_into(doc, schema, &mut buf)?;
100    Ok(buf)
101}
102
103/// Serialize a document into a reusable buffer (clears it first).
104/// Avoids per-document allocation when called in a loop.
105pub fn serialize_document_into(
106    doc: &Document,
107    schema: &Schema,
108    buf: &mut Vec<u8>,
109) -> io::Result<()> {
110    use crate::dsl::FieldValue;
111
112    buf.clear();
113
114    // Two-pass approach avoids allocating a Vec just to count + iterate stored fields.
115    let is_stored = |field: &crate::dsl::Field, value: &FieldValue| -> bool {
116        // Dense/binary vectors live in .vectors (LazyFlatVectorData), not in .store
117        if matches!(
118            value,
119            FieldValue::DenseVector(_) | FieldValue::BinaryDenseVector(_)
120        ) {
121            return false;
122        }
123        schema.get_field_entry(*field).is_some_and(|e| e.stored)
124    };
125
126    let stored_count = doc
127        .field_values()
128        .iter()
129        .filter(|(field, value)| is_stored(field, value))
130        .count();
131
132    buf.write_u16::<LittleEndian>(
133        u16::try_from(stored_count)
134            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "too many stored fields"))?,
135    )?;
136
137    for (field, value) in doc.field_values().iter().filter(|(f, v)| is_stored(f, v)) {
138        buf.write_u16::<LittleEndian>(u16::try_from(field.0).map_err(|_| {
139            io::Error::new(io::ErrorKind::InvalidInput, "stored field id exceeds u16")
140        })?)?;
141        match value {
142            FieldValue::Text(s) => {
143                buf.push(0);
144                let bytes = s.as_bytes();
145                buf.write_u32::<LittleEndian>(u32::try_from(bytes.len()).map_err(|_| {
146                    io::Error::new(io::ErrorKind::InvalidInput, "stored text is too large")
147                })?)?;
148                buf.extend_from_slice(bytes);
149            }
150            FieldValue::U64(v) => {
151                buf.push(1);
152                buf.write_u64::<LittleEndian>(*v)?;
153            }
154            FieldValue::I64(v) => {
155                buf.push(2);
156                buf.write_i64::<LittleEndian>(*v)?;
157            }
158            FieldValue::F64(v) => {
159                buf.push(3);
160                buf.write_f64::<LittleEndian>(*v)?;
161            }
162            FieldValue::Bytes(b) => {
163                buf.push(4);
164                buf.write_u32::<LittleEndian>(u32::try_from(b.len()).map_err(|_| {
165                    io::Error::new(
166                        io::ErrorKind::InvalidInput,
167                        "stored byte field is too large",
168                    )
169                })?)?;
170                buf.extend_from_slice(b);
171            }
172            FieldValue::SparseVector(entries) => {
173                buf.push(5);
174                buf.write_u32::<LittleEndian>(u32::try_from(entries.len()).map_err(|_| {
175                    io::Error::new(
176                        io::ErrorKind::InvalidInput,
177                        "stored sparse vector is too large",
178                    )
179                })?)?;
180                for (idx, val) in entries {
181                    buf.write_u32::<LittleEndian>(*idx)?;
182                    buf.write_f32::<LittleEndian>(*val)?;
183                }
184            }
185            FieldValue::DenseVector(values) => {
186                buf.push(6);
187                buf.write_u32::<LittleEndian>(u32::try_from(values.len()).map_err(|_| {
188                    io::Error::new(
189                        io::ErrorKind::InvalidInput,
190                        "stored dense vector is too large",
191                    )
192                })?)?;
193                // Write raw f32 bytes directly
194                let byte_slice = unsafe {
195                    std::slice::from_raw_parts(values.as_ptr() as *const u8, values.len() * 4)
196                };
197                buf.extend_from_slice(byte_slice);
198            }
199            FieldValue::Json(v) => {
200                buf.push(7);
201                let json_bytes = serde_json::to_vec(v)
202                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
203                buf.write_u32::<LittleEndian>(u32::try_from(json_bytes.len()).map_err(|_| {
204                    io::Error::new(io::ErrorKind::InvalidInput, "stored JSON is too large")
205                })?)?;
206                buf.extend_from_slice(&json_bytes);
207            }
208            FieldValue::BinaryDenseVector(b) => {
209                buf.push(8);
210                buf.write_u32::<LittleEndian>(u32::try_from(b.len()).map_err(|_| {
211                    io::Error::new(
212                        io::ErrorKind::InvalidInput,
213                        "stored binary dense vector is too large",
214                    )
215                })?)?;
216                buf.extend_from_slice(b);
217            }
218        }
219    }
220
221    Ok(())
222}
223
224/// Compressed block result
225#[cfg(feature = "native")]
226struct CompressedBlock {
227    seq: usize,
228    first_doc_id: DocId,
229    num_docs: u32,
230    compressed: Vec<u8>,
231}
232
233#[cfg(feature = "native")]
234struct CompressionJob {
235    seq: usize,
236    first_doc_id: DocId,
237    num_docs: u32,
238    data: Vec<u8>,
239}
240
241/// Fixed-width compression pool used by [`EagerParallelStoreWriter`].
242///
243/// The producer separately limits the number of submitted-but-not-written
244/// blocks. The bounded job channel provides backpressure as a second line of
245/// defence and prevents a fast store-file scan from creating an unbounded task
246/// queue.
247#[cfg(feature = "native")]
248struct StoreCompressionPool {
249    jobs: Option<SyncSender<CompressionJob>>,
250    results: Receiver<io::Result<CompressedBlock>>,
251    workers: Vec<JoinHandle<()>>,
252    num_threads: usize,
253}
254
255#[cfg(feature = "native")]
256impl StoreCompressionPool {
257    fn new(
258        num_threads: usize,
259        dict: Option<Arc<CompressionDict>>,
260        compression_level: CompressionLevel,
261    ) -> Self {
262        // A zero-width pool can never make progress. Treat zero as the
263        // conservative single-worker setting; the public configuration did
264        // not historically reject zero.
265        let num_threads = num_threads.max(1);
266        let (job_sender, job_receiver) =
267            std::sync::mpsc::sync_channel::<CompressionJob>(num_threads);
268        let job_receiver = Arc::new(std::sync::Mutex::new(job_receiver));
269        let (result_sender, result_receiver) = std::sync::mpsc::channel();
270        let mut workers = Vec::with_capacity(num_threads);
271
272        for worker_id in 0..num_threads {
273            let jobs = Arc::clone(&job_receiver);
274            let results = result_sender.clone();
275            let dict = dict.clone();
276            let worker = std::thread::Builder::new()
277                .name(format!("hermes-store-compress-{worker_id}"))
278                .spawn(move || {
279                    loop {
280                        // std::sync::mpsc::Receiver is single-consumer, so the
281                        // short receive operation is mutex-protected. The lock
282                        // is released before compression and all workers can
283                        // then run concurrently.
284                        let job = {
285                            let receiver = jobs
286                                .lock()
287                                .unwrap_or_else(std::sync::PoisonError::into_inner);
288                            receiver.recv()
289                        };
290                        let Ok(job) = job else {
291                            break;
292                        };
293
294                        // Always produce exactly one result for every accepted
295                        // job. Without catch_unwind, one unexpected codec panic
296                        // would leave the ordered producer waiting forever for
297                        // the missing sequence number.
298                        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
299                            let compressed = if let Some(ref dict) = dict {
300                                crate::compression::compress_with_dict(
301                                    &job.data,
302                                    compression_level,
303                                    dict,
304                                )
305                            } else {
306                                crate::compression::compress(&job.data, compression_level)
307                            }?;
308                            Ok(CompressedBlock {
309                                seq: job.seq,
310                                first_doc_id: job.first_doc_id,
311                                num_docs: job.num_docs,
312                                compressed,
313                            })
314                        }))
315                        .unwrap_or_else(|_| {
316                            Err(io::Error::other(
317                                "document-store compression worker panicked",
318                            ))
319                        });
320
321                        if results.send(result).is_err() {
322                            break;
323                        }
324                    }
325                })
326                .expect("failed to spawn document-store compression worker");
327            workers.push(worker);
328        }
329        drop(result_sender);
330
331        Self {
332            jobs: Some(job_sender),
333            results: result_receiver,
334            workers,
335            num_threads,
336        }
337    }
338
339    #[inline]
340    fn num_threads(&self) -> usize {
341        self.num_threads
342    }
343
344    fn submit(&self, job: CompressionJob) -> io::Result<()> {
345        self.jobs
346            .as_ref()
347            .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "compression pool is closed"))?
348            .send(job)
349            .map_err(|_| {
350                io::Error::new(
351                    io::ErrorKind::BrokenPipe,
352                    "document-store compression workers stopped",
353                )
354            })
355    }
356
357    fn receive(&self) -> io::Result<CompressedBlock> {
358        self.results.recv().map_err(|_| {
359            io::Error::new(
360                io::ErrorKind::BrokenPipe,
361                "document-store compression result channel closed",
362            )
363        })?
364    }
365
366    fn shutdown(&mut self) -> io::Result<()> {
367        // Closing the only producer wakes idle workers. All submitted jobs have
368        // already yielded results before the normal finish path reaches here.
369        self.jobs.take();
370        let mut panicked = false;
371        for worker in self.workers.drain(..) {
372            panicked |= worker.join().is_err();
373        }
374        if panicked {
375            Err(io::Error::other(
376                "document-store compression worker panicked",
377            ))
378        } else {
379            Ok(())
380        }
381    }
382}
383
384#[cfg(feature = "native")]
385impl Drop for StoreCompressionPool {
386    fn drop(&mut self) {
387        // Also clean up on an early write/compression error. The result channel
388        // is unbounded, so workers can finish the small bounded tail without
389        // deadlocking while this thread joins them.
390        self.jobs.take();
391        for worker in self.workers.drain(..) {
392            let _ = worker.join();
393        }
394    }
395}
396
397/// Parallel document store writer - compresses blocks immediately when queued
398///
399/// A fixed number of workers compress blocks while the caller continues
400/// accepting documents. Submitted blocks and out-of-order results are bounded,
401/// and completed blocks are written continuously in sequence order.
402#[cfg(feature = "native")]
403pub struct EagerParallelStoreWriter<'a> {
404    writer: &'a mut dyn Write,
405    block_buffer: Vec<u8>,
406    /// Reusable buffer for document serialization (avoids per-doc allocation)
407    serialize_buf: Vec<u8>,
408    workers: StoreCompressionPool,
409    /// Completed blocks waiting for an earlier sequence number.
410    ready_blocks: BTreeMap<usize, CompressedBlock>,
411    /// Number of submitted jobs whose result has not yet been received.
412    pending_results: usize,
413    /// Maximum submitted-but-not-written blocks.
414    max_in_flight: usize,
415    next_seq: usize,
416    next_write_seq: usize,
417    next_doc_id: DocId,
418    block_first_doc: DocId,
419    index: Vec<StoreBlockIndex>,
420    current_offset: u64,
421    dict: Option<Arc<CompressionDict>>,
422}
423
424#[cfg(feature = "native")]
425impl<'a> EagerParallelStoreWriter<'a> {
426    /// Create a new eager parallel store writer
427    pub fn new(writer: &'a mut dyn Write, num_threads: usize) -> Self {
428        Self::with_compression_level(writer, num_threads, DEFAULT_COMPRESSION_LEVEL)
429    }
430
431    /// Create with specific compression level
432    pub fn with_compression_level(
433        writer: &'a mut dyn Write,
434        num_threads: usize,
435        compression_level: CompressionLevel,
436    ) -> Self {
437        Self::with_optional_dict(writer, None, num_threads, compression_level)
438    }
439
440    /// Create with dictionary
441    pub fn with_dict(writer: &'a mut dyn Write, dict: CompressionDict, num_threads: usize) -> Self {
442        Self::with_dict_and_level(writer, dict, num_threads, DEFAULT_COMPRESSION_LEVEL)
443    }
444
445    /// Create with dictionary and specific compression level
446    pub fn with_dict_and_level(
447        writer: &'a mut dyn Write,
448        dict: CompressionDict,
449        num_threads: usize,
450        compression_level: CompressionLevel,
451    ) -> Self {
452        Self::with_optional_dict(writer, Some(Arc::new(dict)), num_threads, compression_level)
453    }
454
455    fn with_optional_dict(
456        writer: &'a mut dyn Write,
457        dict: Option<Arc<CompressionDict>>,
458        num_threads: usize,
459        compression_level: CompressionLevel,
460    ) -> Self {
461        let workers = StoreCompressionPool::new(num_threads, dict.clone(), compression_level);
462        // One active block plus one queued block per worker keeps the pool
463        // saturated without retaining the complete compressed store.
464        let max_in_flight = workers.num_threads().saturating_mul(2).max(1);
465        Self {
466            writer,
467            block_buffer: Vec::with_capacity(STORE_BLOCK_SIZE),
468            serialize_buf: Vec::with_capacity(512),
469            workers,
470            ready_blocks: BTreeMap::new(),
471            pending_results: 0,
472            max_in_flight,
473            next_seq: 0,
474            next_write_seq: 0,
475            next_doc_id: 0,
476            block_first_doc: 0,
477            index: Vec::new(),
478            current_offset: 0,
479            dict,
480        }
481    }
482
483    pub fn store(&mut self, doc: &Document, schema: &Schema) -> io::Result<DocId> {
484        serialize_document_into(doc, schema, &mut self.serialize_buf)?;
485        if self.serialize_buf.len() > MAX_STORE_BLOCK_BYTES.saturating_sub(4) {
486            return Err(io::Error::new(
487                io::ErrorKind::InvalidInput,
488                "serialized document exceeds store block limit",
489            ));
490        }
491        let doc_id = self.next_doc_id;
492        self.next_doc_id = self
493            .next_doc_id
494            .checked_add(1)
495            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "document id overflow"))?;
496        self.block_buffer
497            .write_u32::<LittleEndian>(self.serialize_buf.len() as u32)?;
498        self.block_buffer.extend_from_slice(&self.serialize_buf);
499        if self.block_buffer.len() >= STORE_BLOCK_SIZE {
500            self.queue_compression()?;
501        }
502        Ok(doc_id)
503    }
504
505    /// Store pre-serialized document bytes directly (avoids deserialize+reserialize roundtrip).
506    pub fn store_raw(&mut self, doc_bytes: &[u8]) -> io::Result<DocId> {
507        if doc_bytes.len() > MAX_STORE_BLOCK_BYTES.saturating_sub(4) {
508            return Err(io::Error::new(
509                io::ErrorKind::InvalidInput,
510                "serialized document exceeds store block limit",
511            ));
512        }
513        let doc_id = self.next_doc_id;
514        self.next_doc_id = self
515            .next_doc_id
516            .checked_add(1)
517            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "document id overflow"))?;
518
519        self.block_buffer
520            .write_u32::<LittleEndian>(doc_bytes.len() as u32)?;
521        self.block_buffer.extend_from_slice(doc_bytes);
522
523        if self.block_buffer.len() >= STORE_BLOCK_SIZE {
524            self.queue_compression()?;
525        }
526
527        Ok(doc_id)
528    }
529
530    /// Queue the current block and apply ordered backpressure when the bounded
531    /// in-flight window is full.
532    fn queue_compression(&mut self) -> io::Result<()> {
533        if self.block_buffer.is_empty() {
534            return Ok(());
535        }
536
537        let num_docs = self.next_doc_id - self.block_first_doc;
538        let data = std::mem::replace(&mut self.block_buffer, Vec::with_capacity(STORE_BLOCK_SIZE));
539        let seq = self.next_seq;
540        let first_doc_id = self.block_first_doc;
541
542        self.workers.submit(CompressionJob {
543            seq,
544            first_doc_id,
545            num_docs,
546            data,
547        })?;
548        self.next_seq = self
549            .next_seq
550            .checked_add(1)
551            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "store block overflow"))?;
552        self.pending_results += 1;
553        self.block_first_doc = self.next_doc_id;
554
555        while self.outstanding_blocks() >= self.max_in_flight {
556            self.receive_and_write_ready()?;
557        }
558
559        Ok(())
560    }
561
562    #[inline]
563    fn outstanding_blocks(&self) -> usize {
564        self.next_seq - self.next_write_seq
565    }
566
567    fn receive_and_write_ready(&mut self) -> io::Result<()> {
568        if self.pending_results == 0 {
569            return Err(io::Error::new(
570                io::ErrorKind::BrokenPipe,
571                "missing document-store compression result",
572            ));
573        }
574        let block = self.workers.receive()?;
575        self.pending_results -= 1;
576        if block.seq < self.next_write_seq || self.ready_blocks.insert(block.seq, block).is_some() {
577            return Err(io::Error::new(
578                io::ErrorKind::InvalidData,
579                "duplicate document-store compression result",
580            ));
581        }
582        self.write_ready_blocks()
583    }
584
585    fn write_ready_blocks(&mut self) -> io::Result<()> {
586        while let Some(block) = self.ready_blocks.remove(&self.next_write_seq) {
587            let length = u32::try_from(block.compressed.len()).map_err(|_| {
588                io::Error::new(
589                    io::ErrorKind::InvalidData,
590                    "compressed store block too large",
591                )
592            })?;
593            self.writer.write_all(&block.compressed)?;
594            self.index.push(StoreBlockIndex {
595                first_doc_id: block.first_doc_id,
596                offset: self.current_offset,
597                length,
598                num_docs: block.num_docs,
599            });
600            self.current_offset = self
601                .current_offset
602                .checked_add(u64::from(length))
603                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "store size overflow"))?;
604            self.next_write_seq += 1;
605        }
606        Ok(())
607    }
608
609    pub fn finish(mut self) -> io::Result<u32> {
610        // Queue any remaining data, then receive and stream every bounded
611        // out-of-order result before writing the footer.
612        self.queue_compression()?;
613        while self.next_write_seq < self.next_seq {
614            self.receive_and_write_ready()?;
615        }
616        debug_assert_eq!(self.pending_results, 0);
617        debug_assert!(self.ready_blocks.is_empty());
618        self.workers.shutdown()?;
619
620        if self.index.is_empty() {
621            write_store_index_and_footer(&mut self.writer, &[], 0, 0, 0, false)?;
622            return Ok(0);
623        }
624
625        // Write dictionary if present
626        let dict_offset = if let Some(ref dict) = self.dict {
627            let offset = self.current_offset;
628            let dict_bytes = dict.as_bytes();
629            self.writer
630                .write_u32::<LittleEndian>(dict_bytes.len() as u32)?;
631            self.writer.write_all(dict_bytes)?;
632            Some(offset)
633        } else {
634            None
635        };
636
637        // Write index + footer
638        write_store_index_and_footer(
639            &mut self.writer,
640            &self.index,
641            self.current_offset,
642            dict_offset.unwrap_or(0),
643            self.next_doc_id,
644            self.dict.is_some(),
645        )?;
646
647        Ok(self.next_doc_id)
648    }
649}
650
651/// Block index entry for document store
652#[derive(Debug, Clone)]
653pub(crate) struct StoreBlockIndex {
654    pub(crate) first_doc_id: DocId,
655    pub(crate) offset: u64,
656    pub(crate) length: u32,
657    pub(crate) num_docs: u32,
658}
659
660/// Async document store reader - loads blocks on demand
661pub struct AsyncStoreReader {
662    /// FileHandle for the data portion - fetches ranges on demand
663    data_slice: FileHandle,
664    /// Block index
665    index: Vec<StoreBlockIndex>,
666    num_docs: u32,
667    /// Optional compression dictionary
668    dict: Option<CompressionDict>,
669    /// Process-wide byte-bounded block cache.
670    cache: Arc<SharedStoreCache>,
671    /// Stable directory + segment namespace for shared-cache keys.
672    cache_namespace: StoreCacheNamespace,
673}
674
675/// Decompressed block with pre-built doc offset table.
676///
677/// The offset table is built once on decompression: `offsets[i]` is the byte
678/// position in `data` where doc `i`'s length prefix starts. This turns the
679/// O(n) linear scan per `get()` into O(1) direct indexing.
680struct CachedBlock {
681    data: Vec<u8>,
682    /// Byte offset of each doc's length prefix within `data`.
683    /// `offsets.len()` == number of docs in the block.
684    offsets: Vec<u32>,
685}
686
687impl CachedBlock {
688    fn build(data: Vec<u8>, num_docs: u32) -> io::Result<Self> {
689        if num_docs as usize > data.len() / 4 {
690            return Err(io::Error::new(
691                io::ErrorKind::InvalidData,
692                "store block document count exceeds block length",
693            ));
694        }
695        let mut offsets = Vec::new();
696        offsets.try_reserve_exact(num_docs as usize).map_err(|_| {
697            io::Error::new(
698                io::ErrorKind::InvalidData,
699                "store block has too many documents",
700            )
701        })?;
702        let mut pos = 0usize;
703        for _ in 0..num_docs {
704            let length_end = pos.checked_add(4).ok_or_else(|| {
705                io::Error::new(io::ErrorKind::InvalidData, "store block offset overflow")
706            })?;
707            if length_end > data.len() {
708                return Err(io::Error::new(
709                    io::ErrorKind::InvalidData,
710                    "truncated block while building offset table",
711                ));
712            }
713            offsets.push(u32::try_from(pos).map_err(|_| {
714                io::Error::new(io::ErrorKind::InvalidData, "store block offset exceeds u32")
715            })?);
716            let doc_len =
717                u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
718                    as usize;
719            pos = length_end.checked_add(doc_len).ok_or_else(|| {
720                io::Error::new(io::ErrorKind::InvalidData, "store document length overflow")
721            })?;
722            if pos > data.len() {
723                return Err(io::Error::new(
724                    io::ErrorKind::UnexpectedEof,
725                    "store document is truncated",
726                ));
727            }
728        }
729        if pos != data.len() {
730            return Err(io::Error::new(
731                io::ErrorKind::InvalidData,
732                "store block contains trailing data",
733            ));
734        }
735        Ok(Self { data, offsets })
736    }
737
738    /// Get doc bytes by index within the block (O(1))
739    fn doc_bytes(&self, doc_offset_in_block: u32) -> io::Result<&[u8]> {
740        let idx = doc_offset_in_block as usize;
741        if idx >= self.offsets.len() {
742            return Err(io::Error::new(
743                io::ErrorKind::InvalidData,
744                "doc offset out of range",
745            ));
746        }
747        let start = self.offsets[idx] as usize;
748        let data_start = start.checked_add(4).ok_or_else(|| {
749            io::Error::new(io::ErrorKind::InvalidData, "store document offset overflow")
750        })?;
751        if data_start > self.data.len() {
752            return Err(io::Error::new(
753                io::ErrorKind::InvalidData,
754                "truncated doc length",
755            ));
756        }
757        let doc_len = u32::from_le_bytes([
758            self.data[start],
759            self.data[start + 1],
760            self.data[start + 2],
761            self.data[start + 3],
762        ]) as usize;
763        let data_end = data_start.checked_add(doc_len).ok_or_else(|| {
764            io::Error::new(io::ErrorKind::InvalidData, "store document length overflow")
765        })?;
766        if data_end > self.data.len() {
767            return Err(io::Error::new(
768                io::ErrorKind::InvalidData,
769                "doc data overflow",
770            ));
771        }
772        Ok(&self.data[data_start..data_end])
773    }
774
775    #[inline]
776    fn retained_bytes(&self) -> usize {
777        self.data.capacity() + self.offsets.capacity() * std::mem::size_of::<u32>()
778    }
779}
780
781#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
782struct StoreCacheNamespace {
783    directory: usize,
784    segment: u128,
785}
786
787#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
788struct StoreCacheKey {
789    namespace: StoreCacheNamespace,
790    first_doc_id: DocId,
791}
792
793struct SharedStoreCacheState {
794    blocks: LruCache<StoreCacheKey, Arc<CachedBlock>>,
795    retained_bytes: usize,
796    namespace_bytes: FxHashMap<StoreCacheNamespace, usize>,
797    namespace_readers: FxHashMap<StoreCacheNamespace, usize>,
798}
799
800/// Process-wide byte-bounded cache for decompressed document-store blocks.
801///
802/// The old cache bounded each segment by an entry count. One large stored
803/// body can make a block close to `MAX_STORE_BLOCK_BYTES`, so 32 entries per
804/// segment retained up to 2 GiB and multiplied that by segment fan-out. This
805/// cache has one hard byte ceiling across all indexes using the same policy.
806/// Hits take a shared read lock; eviction is insertion-ordered rather than
807/// serializing every hit solely for exact LRU promotion.
808pub(crate) struct SharedStoreCache {
809    state: RwLock<SharedStoreCacheState>,
810    max_bytes: usize,
811    /// Very large one-document blocks have almost no spatial reuse and can
812    /// evict thousands of ordinary result blocks. The OS compressed-file page
813    /// cache remains available when decompressed admission is bypassed.
814    max_entry_bytes: usize,
815}
816
817impl std::fmt::Debug for SharedStoreCache {
818    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
819        formatter
820            .debug_struct("SharedStoreCache")
821            .field("max_bytes", &self.max_bytes)
822            .field("max_entry_bytes", &self.max_entry_bytes)
823            .field("retained_bytes", &self.total_bytes())
824            .finish()
825    }
826}
827
828impl SharedStoreCache {
829    const MAX_ADMITTED_ENTRY_BYTES: usize = 8 * 1024 * 1024;
830
831    pub(crate) fn new(max_bytes: usize) -> Self {
832        Self::with_limits(max_bytes, max_bytes.min(Self::MAX_ADMITTED_ENTRY_BYTES))
833    }
834
835    fn with_limits(max_bytes: usize, max_entry_bytes: usize) -> Self {
836        Self {
837            state: RwLock::new(SharedStoreCacheState {
838                blocks: LruCache::unbounded(),
839                retained_bytes: 0,
840                namespace_bytes: FxHashMap::default(),
841                namespace_readers: FxHashMap::default(),
842            }),
843            max_bytes,
844            max_entry_bytes: max_entry_bytes.min(max_bytes),
845        }
846    }
847
848    fn register(&self, namespace: StoreCacheNamespace) {
849        if self.max_bytes == 0 {
850            return;
851        }
852        let mut state = self.state.write();
853        *state.namespace_readers.entry(namespace).or_default() += 1;
854    }
855
856    fn unregister(&self, namespace: StoreCacheNamespace) {
857        if self.max_bytes == 0 {
858            return;
859        }
860        let mut state = self.state.write();
861        let Some(readers) = state.namespace_readers.get_mut(&namespace) else {
862            return;
863        };
864        *readers -= 1;
865        if *readers > 0 {
866            return;
867        }
868        state.namespace_readers.remove(&namespace);
869
870        // A merged-away segment will never hit these entries again. Remove
871        // them immediately instead of waiting for unrelated searches to
872        // create enough pressure for ordinary LRU eviction.
873        let keys: Vec<_> = state
874            .blocks
875            .iter()
876            .filter_map(|(key, _)| (key.namespace == namespace).then_some(*key))
877            .collect();
878        for key in keys {
879            if let Some(block) = state.blocks.pop(&key) {
880                state.retained_bytes = state.retained_bytes.saturating_sub(block.retained_bytes());
881            }
882        }
883        state.namespace_bytes.remove(&namespace);
884    }
885
886    fn get(&self, key: StoreCacheKey) -> Option<Arc<CachedBlock>> {
887        // Do not serialize all process-wide hits merely to update exact LRU
888        // order. Concurrent readers use a shared lock; insertion and
889        // decompression-race resolution still promote entries.
890        self.state.read().blocks.peek(&key).map(Arc::clone)
891    }
892
893    /// Admit one block and return the canonical cached allocation if another
894    /// request won the decompression race.
895    fn insert(&self, key: StoreCacheKey, block: Arc<CachedBlock>) -> Arc<CachedBlock> {
896        let bytes = block.retained_bytes();
897        if self.max_bytes == 0 || bytes == 0 || bytes > self.max_entry_bytes {
898            return block;
899        }
900
901        let mut state = self.state.write();
902        if let Some(existing) = state.blocks.get(&key) {
903            return Arc::clone(existing);
904        }
905
906        state.retained_bytes = state.retained_bytes.saturating_add(bytes);
907        *state.namespace_bytes.entry(key.namespace).or_default() = state
908            .namespace_bytes
909            .get(&key.namespace)
910            .copied()
911            .unwrap_or(0)
912            .saturating_add(bytes);
913        state.blocks.put(key, Arc::clone(&block));
914
915        while state.retained_bytes > self.max_bytes {
916            let Some((evicted_key, evicted)) = state.blocks.pop_lru() else {
917                state.retained_bytes = 0;
918                state.namespace_bytes.clear();
919                break;
920            };
921            let evicted_bytes = evicted.retained_bytes();
922            state.retained_bytes = state.retained_bytes.saturating_sub(evicted_bytes);
923            if let Some(namespace_bytes) = state.namespace_bytes.get_mut(&evicted_key.namespace) {
924                *namespace_bytes = namespace_bytes.saturating_sub(evicted_bytes);
925                if *namespace_bytes == 0 {
926                    state.namespace_bytes.remove(&evicted_key.namespace);
927                }
928            }
929        }
930        block
931    }
932
933    pub(crate) fn total_bytes(&self) -> usize {
934        self.state.read().retained_bytes
935    }
936
937    pub(crate) fn total_blocks(&self) -> usize {
938        self.state.read().blocks.len()
939    }
940
941    fn namespace_bytes(&self, namespace: StoreCacheNamespace) -> usize {
942        self.state
943            .read()
944            .namespace_bytes
945            .get(&namespace)
946            .copied()
947            .unwrap_or(0)
948    }
949
950    fn namespace_blocks(&self, namespace: StoreCacheNamespace) -> usize {
951        self.state
952            .read()
953            .blocks
954            .iter()
955            .filter(|(key, _)| key.namespace == namespace)
956            .count()
957    }
958}
959
960impl Drop for AsyncStoreReader {
961    fn drop(&mut self) {
962        self.cache.unregister(self.cache_namespace);
963    }
964}
965
966impl AsyncStoreReader {
967    /// Open a document store from FileHandle
968    /// Only loads footer and index into memory, data blocks are fetched on-demand
969    pub(crate) async fn open(
970        file_handle: FileHandle,
971        directory_namespace: usize,
972        segment_namespace: u128,
973        cache: Arc<SharedStoreCache>,
974    ) -> io::Result<Self> {
975        let file_len = file_handle.len();
976        // Footer: data_end(8) + dict_offset(8) + num_docs(4) + has_dict(4) + version(4) + magic(4) = 32 bytes
977        if file_len < 32 {
978            return Err(io::Error::new(
979                io::ErrorKind::InvalidData,
980                "Store too small",
981            ));
982        }
983
984        // Read footer (32 bytes)
985        let footer = file_handle
986            .read_bytes_range(file_len - 32..file_len)
987            .await?;
988        let mut reader = footer.as_slice();
989        let data_end_offset = reader.read_u64::<LittleEndian>()?;
990        let dict_offset = reader.read_u64::<LittleEndian>()?;
991        let num_docs = reader.read_u32::<LittleEndian>()?;
992        let has_dict = reader.read_u32::<LittleEndian>()? != 0;
993        let version = reader.read_u32::<LittleEndian>()?;
994        let magic = reader.read_u32::<LittleEndian>()?;
995
996        if magic != STORE_MAGIC {
997            return Err(io::Error::new(
998                io::ErrorKind::InvalidData,
999                "Invalid store magic",
1000            ));
1001        }
1002        if version != STORE_VERSION {
1003            return Err(io::Error::new(
1004                io::ErrorKind::InvalidData,
1005                format!("Unsupported store version: {}", version),
1006            ));
1007        }
1008
1009        let index_end = file_len - 32;
1010        if data_end_offset > index_end {
1011            return Err(io::Error::new(
1012                io::ErrorKind::InvalidData,
1013                "store data section extends past its footer",
1014            ));
1015        }
1016
1017        // Load dictionary if present, and compute index_start in one pass
1018        let (dict, index_start) = if has_dict {
1019            if dict_offset < data_end_offset || dict_offset >= index_end {
1020                return Err(io::Error::new(
1021                    io::ErrorKind::InvalidData,
1022                    "store dictionary offset is out of bounds",
1023                ));
1024            }
1025            let dict_start = dict_offset;
1026            let dict_header_end = dict_start.checked_add(4).ok_or_else(|| {
1027                io::Error::new(
1028                    io::ErrorKind::InvalidData,
1029                    "store dictionary range overflow",
1030                )
1031            })?;
1032            if dict_header_end > index_end {
1033                return Err(io::Error::new(
1034                    io::ErrorKind::UnexpectedEof,
1035                    "store dictionary length is truncated",
1036                ));
1037            }
1038            let dict_len_bytes = file_handle
1039                .read_bytes_range(dict_start..dict_header_end)
1040                .await?;
1041            let dict_len = (&dict_len_bytes[..]).read_u32::<LittleEndian>()? as u64;
1042            if dict_len > MAX_STORE_DICTIONARY_BYTES {
1043                return Err(io::Error::new(
1044                    io::ErrorKind::InvalidData,
1045                    "store dictionary exceeds safety limit",
1046                ));
1047            }
1048            let dict_end = dict_header_end.checked_add(dict_len).ok_or_else(|| {
1049                io::Error::new(
1050                    io::ErrorKind::InvalidData,
1051                    "store dictionary range overflow",
1052                )
1053            })?;
1054            if dict_end > index_end {
1055                return Err(io::Error::new(
1056                    io::ErrorKind::UnexpectedEof,
1057                    "store dictionary is truncated",
1058                ));
1059            }
1060            let dict_bytes = file_handle
1061                .read_bytes_range(dict_header_end..dict_end)
1062                .await?;
1063            (
1064                Some(CompressionDict::from_owned_bytes(dict_bytes)),
1065                dict_end,
1066            )
1067        } else {
1068            if dict_offset != 0 {
1069                return Err(io::Error::new(
1070                    io::ErrorKind::InvalidData,
1071                    "store without a dictionary has a dictionary offset",
1072                ));
1073            }
1074            (None, data_end_offset)
1075        };
1076
1077        if index_start > index_end {
1078            return Err(io::Error::new(
1079                io::ErrorKind::InvalidData,
1080                "store index offset is out of bounds",
1081            ));
1082        }
1083
1084        let index_bytes = file_handle.read_bytes_range(index_start..index_end).await?;
1085        let mut reader = index_bytes.as_slice();
1086
1087        let num_blocks = reader.read_u32::<LittleEndian>()? as usize;
1088        let required_index_bytes = num_blocks.checked_mul(20).ok_or_else(|| {
1089            io::Error::new(io::ErrorKind::InvalidData, "store index size overflow")
1090        })?;
1091        if reader.len() != required_index_bytes {
1092            return Err(io::Error::new(
1093                io::ErrorKind::InvalidData,
1094                "store index length is inconsistent",
1095            ));
1096        }
1097        let mut index = Vec::new();
1098        index
1099            .try_reserve_exact(num_blocks)
1100            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "too many store blocks"))?;
1101
1102        let mut expected_doc = 0u32;
1103        let mut expected_offset = 0u64;
1104
1105        for _ in 0..num_blocks {
1106            let first_doc_id = reader.read_u32::<LittleEndian>()?;
1107            let offset = reader.read_u64::<LittleEndian>()?;
1108            let length = reader.read_u32::<LittleEndian>()?;
1109            let num_docs_in_block = reader.read_u32::<LittleEndian>()?;
1110
1111            let end = offset.checked_add(length as u64).ok_or_else(|| {
1112                io::Error::new(io::ErrorKind::InvalidData, "store block range overflow")
1113            })?;
1114            if first_doc_id != expected_doc
1115                || num_docs_in_block == 0
1116                || offset != expected_offset
1117                || end > data_end_offset
1118            {
1119                return Err(io::Error::new(
1120                    io::ErrorKind::InvalidData,
1121                    "store block index is inconsistent",
1122                ));
1123            }
1124            expected_doc = expected_doc.checked_add(num_docs_in_block).ok_or_else(|| {
1125                io::Error::new(io::ErrorKind::InvalidData, "store document count overflow")
1126            })?;
1127            expected_offset = end;
1128
1129            index.push(StoreBlockIndex {
1130                first_doc_id,
1131                offset,
1132                length,
1133                num_docs: num_docs_in_block,
1134            });
1135        }
1136
1137        if expected_doc != num_docs || expected_offset != data_end_offset {
1138            return Err(io::Error::new(
1139                io::ErrorKind::InvalidData,
1140                "store footer totals do not match its block index",
1141            ));
1142        }
1143
1144        // Create lazy slice for data portion only
1145        let data_slice = file_handle.slice(0..data_end_offset);
1146
1147        let cache_namespace = StoreCacheNamespace {
1148            directory: directory_namespace,
1149            segment: segment_namespace,
1150        };
1151        cache.register(cache_namespace);
1152        Ok(Self {
1153            data_slice,
1154            index,
1155            num_docs,
1156            dict,
1157            cache,
1158            cache_namespace,
1159        })
1160    }
1161
1162    /// Number of documents
1163    pub fn num_docs(&self) -> u32 {
1164        self.num_docs
1165    }
1166
1167    /// Number of blocks currently in the cache
1168    pub fn cached_blocks(&self) -> usize {
1169        self.cache.namespace_blocks(self.cache_namespace)
1170    }
1171
1172    /// Heap bytes retained by decompressed blocks and their document offsets.
1173    pub fn cached_bytes(&self) -> usize {
1174        self.cache.namespace_bytes(self.cache_namespace)
1175    }
1176
1177    /// Get a document by doc_id (async - may load block)
1178    pub async fn get(&self, doc_id: DocId, schema: &Schema) -> io::Result<Option<Document>> {
1179        if doc_id >= self.num_docs {
1180            return Ok(None);
1181        }
1182
1183        let t = crate::observe::Timer::start();
1184        let (entry, block) = self.find_and_load_block(doc_id).await?;
1185        let doc_bytes = block.doc_bytes(doc_id - entry.first_doc_id)?;
1186        let result = deserialize_document(doc_bytes, schema).map(Some);
1187        crate::observe::store_get(schema.index_label(), t.secs());
1188        result
1189    }
1190
1191    /// Get specific fields of a document by doc_id (async - may load block)
1192    ///
1193    /// Only deserializes the requested fields, skipping over unwanted data.
1194    /// Much faster than `get()` when documents have large fields (text bodies,
1195    /// vectors) that aren't needed for the response.
1196    pub async fn get_fields(
1197        &self,
1198        doc_id: DocId,
1199        schema: &Schema,
1200        field_ids: &[u32],
1201    ) -> io::Result<Option<Document>> {
1202        if doc_id >= self.num_docs {
1203            return Ok(None);
1204        }
1205
1206        let t = crate::observe::Timer::start();
1207        let (entry, block) = self.find_and_load_block(doc_id).await?;
1208        let doc_bytes = block.doc_bytes(doc_id - entry.first_doc_id)?;
1209        let result = deserialize_document_fields(doc_bytes, schema, field_ids).map(Some);
1210        crate::observe::store_get(schema.index_label(), t.secs());
1211        result
1212    }
1213
1214    /// Find the block index entry and load/cache the block for a given doc_id
1215    async fn find_and_load_block(
1216        &self,
1217        doc_id: DocId,
1218    ) -> io::Result<(&StoreBlockIndex, Arc<CachedBlock>)> {
1219        let block_idx = self
1220            .index
1221            .binary_search_by(|entry| {
1222                if doc_id < entry.first_doc_id {
1223                    std::cmp::Ordering::Greater
1224                } else if doc_id >= entry.first_doc_id + entry.num_docs {
1225                    std::cmp::Ordering::Less
1226                } else {
1227                    std::cmp::Ordering::Equal
1228                }
1229            })
1230            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Doc not found in index"))?;
1231
1232        let entry = &self.index[block_idx];
1233        let block = self.load_block(entry).await?;
1234        Ok((entry, block))
1235    }
1236
1237    async fn load_block(&self, entry: &StoreBlockIndex) -> io::Result<Arc<CachedBlock>> {
1238        let key = StoreCacheKey {
1239            namespace: self.cache_namespace,
1240            first_doc_id: entry.first_doc_id,
1241        };
1242        if let Some(block) = self.cache.get(key) {
1243            return Ok(block);
1244        }
1245
1246        // Load from FileSlice
1247        let start = entry.offset;
1248        let end = start.checked_add(entry.length as u64).ok_or_else(|| {
1249            io::Error::new(io::ErrorKind::InvalidData, "store block range overflow")
1250        })?;
1251        let compressed = self.data_slice.read_bytes_range(start..end).await?;
1252
1253        // Use dictionary decompression if available
1254        let decompressed = if let Some(ref dict) = self.dict {
1255            crate::compression::decompress_with_dict_limited(
1256                compressed.as_slice(),
1257                dict,
1258                MAX_STORE_BLOCK_BYTES,
1259            )?
1260        } else {
1261            crate::compression::decompress_limited(compressed.as_slice(), MAX_STORE_BLOCK_BYTES)?
1262        };
1263
1264        // Build offset table for O(1) doc lookup within the block
1265        let cached = CachedBlock::build(decompressed, entry.num_docs)?;
1266        Ok(self.cache.insert(key, Arc::new(cached)))
1267    }
1268}
1269
1270/// Deserialize only specific fields from document bytes.
1271///
1272/// Skips over unwanted fields without allocating their values — just advances
1273/// the reader past their length-prefixed data. For large documents with many
1274/// fields (e.g., full text body), this avoids allocating/copying data that
1275/// the caller doesn't need.
1276pub fn deserialize_document_fields(
1277    data: &[u8],
1278    schema: &Schema,
1279    field_ids: &[u32],
1280) -> io::Result<Document> {
1281    deserialize_document_inner(data, schema, Some(field_ids))
1282}
1283
1284/// Deserialize all fields from document bytes.
1285///
1286/// Delegates to the shared field-parsing core with no field filter.
1287pub fn deserialize_document(data: &[u8], schema: &Schema) -> io::Result<Document> {
1288    deserialize_document_inner(data, schema, None)
1289}
1290
1291/// Shared deserialization core. `field_filter = None` means all fields wanted.
1292fn deserialize_document_inner(
1293    data: &[u8],
1294    _schema: &Schema,
1295    field_filter: Option<&[u32]>,
1296) -> io::Result<Document> {
1297    use crate::dsl::Field;
1298
1299    let mut reader = data;
1300    let num_fields = reader.read_u16::<LittleEndian>()? as usize;
1301    let mut doc = Document::new();
1302
1303    for _ in 0..num_fields {
1304        let field_id = reader.read_u16::<LittleEndian>()?;
1305        let type_tag = reader.read_u8()?;
1306
1307        let wanted = field_filter.is_none_or(|ids| ids.contains(&(field_id as u32)));
1308
1309        match type_tag {
1310            0 => {
1311                // Text
1312                let len = reader.read_u32::<LittleEndian>()? as usize;
1313                let bytes = take_document_bytes(&mut reader, len, "text field")?;
1314                if wanted {
1315                    let s = std::str::from_utf8(bytes)
1316                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1317                    doc.add_text(Field(field_id as u32), s);
1318                }
1319            }
1320            1 => {
1321                // U64
1322                let v = reader.read_u64::<LittleEndian>()?;
1323                if wanted {
1324                    doc.add_u64(Field(field_id as u32), v);
1325                }
1326            }
1327            2 => {
1328                // I64
1329                let v = reader.read_i64::<LittleEndian>()?;
1330                if wanted {
1331                    doc.add_i64(Field(field_id as u32), v);
1332                }
1333            }
1334            3 => {
1335                // F64
1336                let v = reader.read_f64::<LittleEndian>()?;
1337                if wanted {
1338                    doc.add_f64(Field(field_id as u32), v);
1339                }
1340            }
1341            4 => {
1342                // Bytes
1343                let len = reader.read_u32::<LittleEndian>()? as usize;
1344                let bytes = take_document_bytes(&mut reader, len, "byte field")?;
1345                if wanted {
1346                    doc.add_bytes(Field(field_id as u32), bytes.to_vec());
1347                }
1348            }
1349            5 => {
1350                // SparseVector
1351                let count = reader.read_u32::<LittleEndian>()? as usize;
1352                let byte_len = count.checked_mul(8).ok_or_else(|| {
1353                    io::Error::new(io::ErrorKind::InvalidData, "sparse vector size overflow")
1354                })?;
1355                let bytes = take_document_bytes(&mut reader, byte_len, "sparse vector")?;
1356                if wanted {
1357                    let mut entries = Vec::new();
1358                    entries.try_reserve_exact(count).map_err(|_| {
1359                        io::Error::new(io::ErrorKind::InvalidData, "sparse vector is too large")
1360                    })?;
1361                    let mut vector_reader = bytes;
1362                    for _ in 0..count {
1363                        let idx = vector_reader.read_u32::<LittleEndian>()?;
1364                        let val = vector_reader.read_f32::<LittleEndian>()?;
1365                        entries.push((idx, val));
1366                    }
1367                    doc.add_sparse_vector(Field(field_id as u32), entries);
1368                }
1369            }
1370            6 => {
1371                // DenseVector
1372                let count = reader.read_u32::<LittleEndian>()? as usize;
1373                let byte_len = count.checked_mul(4).ok_or_else(|| {
1374                    io::Error::new(io::ErrorKind::InvalidData, "dense vector size overflow")
1375                })?;
1376                let bytes = take_document_bytes(&mut reader, byte_len, "dense vector")?;
1377                if wanted {
1378                    let mut values = vec![0.0f32; count];
1379                    unsafe {
1380                        std::ptr::copy_nonoverlapping(
1381                            bytes.as_ptr(),
1382                            values.as_mut_ptr() as *mut u8,
1383                            byte_len,
1384                        );
1385                    }
1386                    doc.add_dense_vector(Field(field_id as u32), values);
1387                }
1388            }
1389            7 => {
1390                // Json
1391                let len = reader.read_u32::<LittleEndian>()? as usize;
1392                let bytes = take_document_bytes(&mut reader, len, "JSON field")?;
1393                if wanted {
1394                    let v: serde_json::Value = serde_json::from_slice(bytes)
1395                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1396                    doc.add_json(Field(field_id as u32), v);
1397                }
1398            }
1399            8 => {
1400                // BinaryDenseVector
1401                let len = reader.read_u32::<LittleEndian>()? as usize;
1402                let bytes = take_document_bytes(&mut reader, len, "binary dense vector")?;
1403                if wanted {
1404                    doc.add_binary_dense_vector(Field(field_id as u32), bytes.to_vec());
1405                }
1406            }
1407            _ => {
1408                return Err(io::Error::new(
1409                    io::ErrorKind::InvalidData,
1410                    format!("Unknown field type tag: {}", type_tag),
1411                ));
1412            }
1413        }
1414    }
1415
1416    Ok(doc)
1417}
1418
1419fn take_document_bytes<'a>(reader: &mut &'a [u8], len: usize, field: &str) -> io::Result<&'a [u8]> {
1420    if len > reader.len() {
1421        return Err(io::Error::new(
1422            io::ErrorKind::UnexpectedEof,
1423            format!("{field} is truncated"),
1424        ));
1425    }
1426    let (value, remaining) = reader.split_at(len);
1427    *reader = remaining;
1428    Ok(value)
1429}
1430
1431/// Raw block info for store merging (without decompression)
1432#[derive(Debug, Clone)]
1433pub struct RawStoreBlock {
1434    pub first_doc_id: DocId,
1435    pub num_docs: u32,
1436    pub offset: u64,
1437    pub length: u32,
1438}
1439
1440/// Store merger - concatenates compressed blocks from multiple stores without recompression
1441///
1442/// This is much faster than rebuilding stores since it avoids:
1443/// - Decompressing blocks from source stores
1444/// - Re-serializing documents
1445/// - Re-compressing blocks at level 22
1446///
1447/// Limitations:
1448/// - All source stores must NOT use dictionaries (or use the same dictionary)
1449/// - Doc IDs are remapped sequentially
1450pub struct StoreMerger<'a, W: Write> {
1451    writer: &'a mut W,
1452    index: Vec<StoreBlockIndex>,
1453    current_offset: u64,
1454    next_doc_id: DocId,
1455}
1456
1457impl<'a, W: Write> StoreMerger<'a, W> {
1458    pub fn new(writer: &'a mut W) -> Self {
1459        Self {
1460            writer,
1461            index: Vec::new(),
1462            current_offset: 0,
1463            next_doc_id: 0,
1464        }
1465    }
1466
1467    /// Append raw compressed blocks from a store file
1468    ///
1469    /// `data_slice` should be the data portion of the store (before index/footer)
1470    /// `blocks` contains the block metadata from the source store
1471    pub async fn append_store(
1472        &mut self,
1473        data_slice: &FileHandle,
1474        blocks: &[RawStoreBlock],
1475        cancellation: Option<&std::sync::atomic::AtomicBool>,
1476    ) -> io::Result<()> {
1477        for block in blocks {
1478            if cancellation
1479                .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Relaxed))
1480            {
1481                return Err(io::Error::new(
1482                    io::ErrorKind::Interrupted,
1483                    "store merge cancelled",
1484                ));
1485            }
1486            // Read raw compressed block data
1487            let start = block.offset;
1488            let end = start.checked_add(block.length as u64).ok_or_else(|| {
1489                io::Error::new(io::ErrorKind::InvalidData, "store block range overflow")
1490            })?;
1491            if end > data_slice.len() {
1492                return Err(io::Error::new(
1493                    io::ErrorKind::UnexpectedEof,
1494                    "store block range is out of bounds",
1495                ));
1496            }
1497            let compressed_data = data_slice.read_bytes_range(start..end).await?;
1498
1499            // Write to output
1500            self.writer.write_all(compressed_data.as_slice())?;
1501
1502            // Add to index with remapped doc IDs
1503            self.index.push(StoreBlockIndex {
1504                first_doc_id: self.next_doc_id,
1505                offset: self.current_offset,
1506                length: block.length,
1507                num_docs: block.num_docs,
1508            });
1509
1510            self.current_offset = self
1511                .current_offset
1512                .checked_add(block.length as u64)
1513                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "store size overflow"))?;
1514            self.next_doc_id = self
1515                .next_doc_id
1516                .checked_add(block.num_docs)
1517                .ok_or_else(|| {
1518                    io::Error::new(io::ErrorKind::InvalidData, "store document count overflow")
1519                })?;
1520        }
1521
1522        Ok(())
1523    }
1524
1525    /// Append blocks from a dict-compressed store by decompressing and recompressing.
1526    ///
1527    /// For stores that use dictionary compression, raw blocks can't be stacked
1528    /// directly because the decompressor needs the original dictionary.
1529    /// This method decompresses each block with the source dict, then
1530    /// recompresses without a dictionary so the merged output is self-contained.
1531    pub async fn append_store_recompressing(
1532        &mut self,
1533        store: &AsyncStoreReader,
1534        cancellation: Option<&std::sync::atomic::AtomicBool>,
1535    ) -> io::Result<()> {
1536        let dict = store.dict();
1537        let data_slice = store.data_slice();
1538        let blocks = store.block_index();
1539
1540        for block in blocks {
1541            if cancellation
1542                .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Relaxed))
1543            {
1544                return Err(io::Error::new(
1545                    io::ErrorKind::Interrupted,
1546                    "store merge cancelled",
1547                ));
1548            }
1549            let start = block.offset;
1550            let end = start.checked_add(block.length as u64).ok_or_else(|| {
1551                io::Error::new(io::ErrorKind::InvalidData, "store block range overflow")
1552            })?;
1553            if end > data_slice.len() {
1554                return Err(io::Error::new(
1555                    io::ErrorKind::UnexpectedEof,
1556                    "store block range is out of bounds",
1557                ));
1558            }
1559            let compressed = data_slice.read_bytes_range(start..end).await?;
1560
1561            // Decompress with source dict (or without if no dict)
1562            let decompressed = if let Some(d) = dict {
1563                crate::compression::decompress_with_dict_limited(
1564                    compressed.as_slice(),
1565                    d,
1566                    MAX_STORE_BLOCK_BYTES,
1567                )?
1568            } else {
1569                crate::compression::decompress_limited(
1570                    compressed.as_slice(),
1571                    MAX_STORE_BLOCK_BYTES,
1572                )?
1573            };
1574
1575            // Recompress without dictionary
1576            let recompressed = crate::compression::compress(
1577                &decompressed,
1578                crate::compression::CompressionLevel::default(),
1579            )?;
1580
1581            self.writer.write_all(&recompressed)?;
1582
1583            self.index.push(StoreBlockIndex {
1584                first_doc_id: self.next_doc_id,
1585                offset: self.current_offset,
1586                length: u32::try_from(recompressed.len()).map_err(|_| {
1587                    io::Error::new(
1588                        io::ErrorKind::InvalidData,
1589                        "compressed store block too large",
1590                    )
1591                })?,
1592                num_docs: block.num_docs,
1593            });
1594
1595            self.current_offset = self
1596                .current_offset
1597                .checked_add(recompressed.len() as u64)
1598                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "store size overflow"))?;
1599            self.next_doc_id = self
1600                .next_doc_id
1601                .checked_add(block.num_docs)
1602                .ok_or_else(|| {
1603                    io::Error::new(io::ErrorKind::InvalidData, "store document count overflow")
1604                })?;
1605        }
1606
1607        Ok(())
1608    }
1609
1610    /// Finish writing the merged store
1611    pub fn finish(self) -> io::Result<u32> {
1612        let data_end_offset = self.current_offset;
1613
1614        // No dictionary support for merged stores (would need same dict across all sources)
1615        let dict_offset = 0u64;
1616
1617        // Write index + footer
1618        write_store_index_and_footer(
1619            self.writer,
1620            &self.index,
1621            data_end_offset,
1622            dict_offset,
1623            self.next_doc_id,
1624            false,
1625        )?;
1626
1627        Ok(self.next_doc_id)
1628    }
1629}
1630
1631impl AsyncStoreReader {
1632    /// Get raw block metadata for merging (without loading block data)
1633    pub fn raw_blocks(&self) -> Vec<RawStoreBlock> {
1634        self.index
1635            .iter()
1636            .map(|entry| RawStoreBlock {
1637                first_doc_id: entry.first_doc_id,
1638                num_docs: entry.num_docs,
1639                offset: entry.offset,
1640                length: entry.length,
1641            })
1642            .collect()
1643    }
1644
1645    /// Get the data slice for raw block access
1646    pub fn data_slice(&self) -> &FileHandle {
1647        &self.data_slice
1648    }
1649
1650    /// Check if this store uses a dictionary (incompatible with raw merging)
1651    pub fn has_dict(&self) -> bool {
1652        self.dict.is_some()
1653    }
1654
1655    /// Get the decompression dictionary (if any)
1656    pub fn dict(&self) -> Option<&CompressionDict> {
1657        self.dict.as_ref()
1658    }
1659
1660    /// Get block index for iteration
1661    pub(crate) fn block_index(&self) -> &[StoreBlockIndex] {
1662        &self.index
1663    }
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668    use super::*;
1669
1670    #[cfg(feature = "native")]
1671    fn raw_store_bytes(num_threads: usize, documents: &[Vec<u8>]) -> Vec<u8> {
1672        let mut output = Vec::new();
1673        let mut store = EagerParallelStoreWriter::with_compression_level(
1674            &mut output,
1675            num_threads,
1676            CompressionLevel::FAST,
1677        );
1678        for document in documents {
1679            store.store_raw(document).unwrap();
1680        }
1681        assert_eq!(store.finish().unwrap() as usize, documents.len());
1682        output
1683    }
1684
1685    fn cached_test_block(byte: u8) -> Arc<CachedBlock> {
1686        Arc::new(CachedBlock::build(vec![4, 0, 0, 0, byte, byte, byte, byte], 1).unwrap())
1687    }
1688
1689    #[cfg(feature = "native")]
1690    #[test]
1691    fn parallel_store_is_byte_identical_across_worker_counts() {
1692        // Variable-sized, non-uniform blocks make completion order differ
1693        // readily without changing block boundaries or encoded bytes.
1694        let documents: Vec<Vec<u8>> = (0..64usize)
1695            .map(|doc| {
1696                let len = 1_000 + (doc * 7_919 % 40_000);
1697                (0..len)
1698                    .map(|offset| ((doc * 17 + offset * 31 + offset / 7) % 251) as u8)
1699                    .collect()
1700            })
1701            .collect();
1702
1703        let single_worker = raw_store_bytes(1, &documents);
1704        let four_workers = raw_store_bytes(4, &documents);
1705        assert_eq!(four_workers, single_worker);
1706    }
1707
1708    #[cfg(feature = "native")]
1709    #[test]
1710    fn parallel_store_bounds_in_flight_blocks_and_streams_before_finish() {
1711        use std::sync::atomic::{AtomicUsize, Ordering};
1712
1713        struct CountingWriter(Arc<AtomicUsize>);
1714        impl Write for CountingWriter {
1715            fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1716                self.0.fetch_add(bytes.len(), Ordering::Relaxed);
1717                Ok(bytes.len())
1718            }
1719
1720            fn flush(&mut self) -> io::Result<()> {
1721                Ok(())
1722            }
1723        }
1724
1725        let bytes_written = Arc::new(AtomicUsize::new(0));
1726        let mut output = CountingWriter(Arc::clone(&bytes_written));
1727        let mut store = EagerParallelStoreWriter::new(&mut output, 3);
1728        assert_eq!(store.workers.num_threads(), 3);
1729        assert_eq!(store.max_in_flight, 6);
1730
1731        let document = vec![7u8; STORE_BLOCK_SIZE];
1732        for _ in 0..24 {
1733            store.store_raw(&document).unwrap();
1734            assert!(store.outstanding_blocks() < store.max_in_flight);
1735            assert_eq!(
1736                store.outstanding_blocks(),
1737                store.pending_results + store.ready_blocks.len()
1738            );
1739        }
1740
1741        // Hitting the in-flight bound drains sequence-ready results directly
1742        // to the destination instead of retaining the complete store.
1743        assert!(bytes_written.load(Ordering::Relaxed) > 0);
1744        assert_eq!(store.finish().unwrap(), 24);
1745    }
1746
1747    #[cfg(feature = "native")]
1748    #[test]
1749    fn parallel_store_zero_threads_falls_back_to_one_worker() {
1750        let mut output = Vec::new();
1751        let store = EagerParallelStoreWriter::new(&mut output, 0);
1752        assert_eq!(store.workers.num_threads(), 1);
1753        assert_eq!(store.max_in_flight, 2);
1754        assert_eq!(store.finish().unwrap(), 0);
1755        // Empty block index (u32 count) plus the 32-byte footer.
1756        assert_eq!(output.len(), 36);
1757    }
1758
1759    #[test]
1760    fn cached_block_rejects_truncated_and_trailing_documents() {
1761        assert!(CachedBlock::build(vec![8, 0, 0, 0, 1], 1).is_err());
1762        assert!(CachedBlock::build(vec![0, 0, 0, 0, 1], 1).is_err());
1763    }
1764
1765    #[test]
1766    fn document_deserializer_rejects_length_prefixed_slice_overrun() {
1767        let schema = Schema::builder().build();
1768        let truncated_text = [1, 0, 0, 0, 0, 5, 0, 0, 0, b'x'];
1769        assert!(deserialize_document(&truncated_text, &schema).is_err());
1770
1771        let truncated_sparse = [1, 0, 0, 0, 5, 2, 0, 0, 0, 1, 0, 0, 0];
1772        assert!(deserialize_document(&truncated_sparse, &schema).is_err());
1773    }
1774
1775    #[test]
1776    fn shared_store_cache_is_byte_bounded_and_read_concurrent() {
1777        let block_bytes = cached_test_block(1).retained_bytes();
1778        let cache = SharedStoreCache::with_limits(block_bytes * 2, block_bytes);
1779        let key = |first_doc_id| StoreCacheKey {
1780            namespace: StoreCacheNamespace {
1781                directory: 1,
1782                segment: 7,
1783            },
1784            first_doc_id,
1785        };
1786
1787        cache.insert(key(1), cached_test_block(1));
1788        cache.insert(key(2), cached_test_block(2));
1789        assert!(cache.get(key(1)).is_some());
1790        cache.insert(key(3), cached_test_block(3));
1791
1792        assert!(cache.get(key(1)).is_none());
1793        assert!(cache.get(key(2)).is_some());
1794        assert!(cache.get(key(3)).is_some());
1795        assert!(cache.total_bytes() <= block_bytes * 2);
1796    }
1797
1798    #[test]
1799    fn shared_store_cache_bypasses_oversized_entries() {
1800        let block = cached_test_block(1);
1801        let cache = SharedStoreCache::with_limits(1024, block.retained_bytes() - 1);
1802        let key = StoreCacheKey {
1803            namespace: StoreCacheNamespace {
1804                directory: 1,
1805                segment: 9,
1806            },
1807            first_doc_id: 0,
1808        };
1809        cache.insert(key, block);
1810        assert_eq!(cache.total_bytes(), 0);
1811        assert!(cache.get(key).is_none());
1812    }
1813
1814    #[test]
1815    fn shared_store_cache_purges_closed_segment_namespace() {
1816        let block = cached_test_block(1);
1817        let cache = SharedStoreCache::with_limits(1024, 1024);
1818        let key = StoreCacheKey {
1819            namespace: StoreCacheNamespace {
1820                directory: 1,
1821                segment: 11,
1822            },
1823            first_doc_id: 0,
1824        };
1825        cache.register(key.namespace);
1826        cache.insert(key, block);
1827        assert!(cache.total_bytes() > 0);
1828        cache.unregister(key.namespace);
1829        assert_eq!(cache.total_bytes(), 0);
1830        assert!(cache.get(key).is_none());
1831    }
1832
1833    #[test]
1834    fn shared_store_cache_isolates_equal_segment_ids_across_directories() {
1835        let cache = SharedStoreCache::with_limits(1024, 1024);
1836        let key = |directory| StoreCacheKey {
1837            namespace: StoreCacheNamespace {
1838                directory,
1839                segment: 42,
1840            },
1841            first_doc_id: 0,
1842        };
1843        let left = cache.insert(key(1), cached_test_block(1));
1844        let right = cache.insert(key(2), cached_test_block(2));
1845
1846        assert!(!Arc::ptr_eq(&left, &right));
1847        assert_eq!(cache.total_blocks(), 2);
1848    }
1849}