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