Skip to main content

zeppelin_embed/lifecycle/
snapshot.rs

1//! Generation-stamped immutable segment snapshots.
2
3use std::ops::Deref;
4use std::path::Path;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Condvar, Mutex};
7use std::time::{Duration, Instant};
8
9use crate::manifest::Manifest;
10use crate::manifest::io::{DurableLog, MANIFEST_FILE, load_manifest};
11use crate::meta::{AliveSet, ColumnStore};
12use crate::quant::Bit4Factors;
13use crate::segment::SegmentId;
14use crate::segment::layout::Int8Factors;
15use crate::segment::reader::{SegmentReader, validate_header_with_vfs};
16use crate::segment::writer::{SegmentBuild, SegmentFactors, write_segment};
17use crate::vfs::Vfs;
18use crate::wal::WalReader;
19
20#[cfg(test)]
21use super::close::TeardownProbe;
22use super::stats::{Accounted, Accounting, AllocationComponent, MappingReservation};
23use super::{Store, StoreError, StoreState};
24
25#[cfg(test)]
26struct SnapshotReleaseProbe(Option<Arc<TeardownProbe>>);
27
28#[cfg(test)]
29impl Drop for SnapshotReleaseProbe {
30    fn drop(&mut self) {
31        if let Some(probe) = &self.0 {
32            probe.record_snapshot_released();
33        }
34    }
35}
36
37const STORE_WAL_FILE: &str = "wal.ze";
38
39/// Owned vector-factor buffers for one already-built in-memory segment.
40///
41/// This is an ownership-transfer seam for sealing, not an ingest API. Task 10
42/// supplies the mutation path that eventually builds these buffers.
43pub enum InMemorySegmentFactors {
44    /// Permanent 12-byte Bit4 factor records.
45    Bit4(Vec<Bit4Factors>),
46    /// Permanent 8-byte affine Int8 factor records.
47    Int8(Vec<Int8Factors>),
48}
49
50/// One complete already-built segment whose vector buffers can be adopted by a store.
51///
52/// Metadata and alive state remain caller-borrowed during the synchronous seal.
53/// The store takes ownership only of the three vector buffers whose exact
54/// capacities become part of `resident_owned_bytes`.
55pub struct InMemorySegment<'a> {
56    /// Sortable immutable identity.
57    pub id: SegmentId,
58    /// Permanent per-segment quantization scheme id.
59    pub scheme: u16,
60    /// Logical vector dimension.
61    pub dims: u32,
62    /// Contiguous row-major packed codes with no row padding.
63    pub codes: Vec<u8>,
64    /// Contiguous factor records matching `scheme`.
65    pub factors: InMemorySegmentFactors,
66    /// Contiguous row-major f32 exact-rescore source.
67    pub rescore: Vec<f32>,
68    /// Typed metadata arrays aligned to row ids.
69    pub columns: &'a ColumnStore,
70    /// Alive/tombstone state aligned to row ids.
71    pub alive: &'a AliveSet,
72}
73
74enum PreparedFactors {
75    Bit4(Accounted<Vec<Bit4Factors>>),
76    Int8(Accounted<Vec<Int8Factors>>),
77}
78
79/// Store-owned, exactly accounted anonymous buffers waiting to be sealed.
80pub struct PreparedSegment<'a> {
81    accounting: Arc<Accounting>,
82    id: SegmentId,
83    scheme: u16,
84    dims: u32,
85    codes: Accounted<Vec<u8>>,
86    factors: PreparedFactors,
87    rescore: Accounted<Vec<f32>>,
88    columns: &'a ColumnStore,
89    alive: &'a AliveSet,
90    resident_bytes: u64,
91}
92
93impl PreparedSegment<'_> {
94    /// Returns the exact anonymous allocation capacity transferred to the store.
95    #[must_use]
96    pub const fn resident_bytes(&self) -> u64 {
97        self.resident_bytes
98    }
99
100    fn as_build(&self) -> SegmentBuild<'_> {
101        let factors = match &self.factors {
102            PreparedFactors::Bit4(values) => SegmentFactors::Bit4(values.as_slice()),
103            PreparedFactors::Int8(values) => SegmentFactors::Int8(values.as_slice()),
104        };
105        SegmentBuild {
106            id: self.id,
107            scheme: self.scheme,
108            dims: self.dims,
109            codes: self.codes.as_slice(),
110            factors,
111            rescore: self.rescore.as_slice(),
112            columns: self.columns,
113            alive: self.alive,
114        }
115    }
116}
117
118impl Store {
119    /// Adopts already-built vector buffers and charges their exact capacities.
120    ///
121    /// No ingest, graph construction, scheduling, or background work occurs at
122    /// this seam. The returned value must be consumed by [`Self::seal_snapshot`].
123    pub fn prepare_segment<'a>(
124        &self,
125        segment: InMemorySegment<'a>,
126    ) -> Result<PreparedSegment<'a>, StoreError> {
127        let state = self
128            .state
129            .lock()
130            .map_err(|_| StoreError::Synchronization { component: "state" })?;
131        match *state {
132            StoreState::Open => {}
133            StoreState::Closing => return Err(StoreError::Closing),
134            StoreState::Closed => return Err(StoreError::Closed),
135        }
136        let writer_lock = self
137            .writer_lock
138            .lock()
139            .map_err(|_| StoreError::Synchronization {
140                component: "writer lock",
141            })?;
142        if writer_lock.is_none() {
143            return Err(StoreError::ReadOnly);
144        }
145        let InMemorySegment {
146            id,
147            scheme,
148            dims,
149            codes,
150            factors,
151            rescore,
152            columns,
153            alive,
154        } = segment;
155        let codes =
156            Accounted::try_from_vec(&self.accounting, codes, AllocationComponent::Snapshot)?;
157        let factors = match factors {
158            InMemorySegmentFactors::Bit4(values) => PreparedFactors::Bit4(Accounted::try_from_vec(
159                &self.accounting,
160                values,
161                AllocationComponent::Snapshot,
162            )?),
163            InMemorySegmentFactors::Int8(values) => PreparedFactors::Int8(Accounted::try_from_vec(
164                &self.accounting,
165                values,
166                AllocationComponent::Snapshot,
167            )?),
168        };
169        let rescore =
170            Accounted::try_from_vec(&self.accounting, rescore, AllocationComponent::Snapshot)?;
171        let factor_bytes = match &factors {
172            PreparedFactors::Bit4(values) => values.resident_bytes(),
173            PreparedFactors::Int8(values) => values.resident_bytes(),
174        };
175        let resident_bytes = codes
176            .resident_bytes()
177            .checked_add(factor_bytes)
178            .and_then(|bytes| bytes.checked_add(rescore.resident_bytes()))
179            .ok_or(StoreError::BudgetExceeded {
180                needed: u64::MAX,
181                budget: u64::MAX,
182                component: "snapshot",
183            })?;
184        drop(writer_lock);
185        drop(state);
186        Ok(PreparedSegment {
187            accounting: Arc::clone(&self.accounting),
188            id,
189            scheme,
190            dims,
191            codes,
192            factors,
193            rescore,
194            columns,
195            alive,
196            resident_bytes,
197        })
198    }
199
200    /// Writes, commits, read-only remaps, and publishes one complete snapshot.
201    ///
202    /// The prepared anonymous vector buffers are consumed only after the new
203    /// [`SegmentReader`] snapshot is live. The committed manifest contains the
204    /// one complete segment supplied here; this is snapshot replacement, not
205    /// task 10's future append/ingest behavior.
206    pub fn seal_snapshot(&self, segment: PreparedSegment<'_>) -> Result<u64, StoreError> {
207        self.seal_snapshot_with_vfs(segment, self.vfs.as_ref())
208    }
209
210    fn seal_snapshot_with_vfs(
211        &self,
212        segment: PreparedSegment<'_>,
213        vfs: &dyn Vfs,
214    ) -> Result<u64, StoreError> {
215        let state = self
216            .state
217            .lock()
218            .map_err(|_| StoreError::Synchronization { component: "state" })?;
219        match *state {
220            StoreState::Open => {}
221            StoreState::Closing => return Err(StoreError::Closing),
222            StoreState::Closed => return Err(StoreError::Closed),
223        }
224        let writer_lock = self
225            .writer_lock
226            .lock()
227            .map_err(|_| StoreError::Synchronization {
228                component: "writer lock",
229            })?;
230        if writer_lock.is_none() {
231            return Err(StoreError::ReadOnly);
232        }
233        if !Arc::ptr_eq(&segment.accounting, &self.accounting) {
234            return Err(StoreError::ForeignPreparedSegment);
235        }
236        let wal = self
237            .wal_writer
238            .lock()
239            .map_err(|_| StoreError::Synchronization {
240                component: "WAL writer",
241            })?;
242        let writer = wal.as_ref().ok_or(StoreError::ReadOnly)?;
243        let generation = self
244            .active
245            .lock()
246            .map_err(|_| StoreError::Synchronization {
247                component: "active segment",
248            })?
249            .as_ref()
250            .ok_or(StoreError::Closed)?
251            .generation
252            .checked_add(1)
253            .ok_or(StoreError::GenerationOverflow)?;
254        #[cfg(feature = "graph-cypher")]
255        let mut committed =
256            crate::ingest::load_current_manifest(vfs, &self.directory, u64::MAX, 0, &self.schema)?;
257        #[cfg(feature = "graph-cypher")]
258        let graph_log_seq = if committed.graph.is_some() {
259            committed.log_seq
260        } else {
261            0
262        };
263        #[cfg(feature = "graph-cypher")]
264        if committed.graph.is_some() {
265            let boundary = writer.durable_end();
266            committed
267                .record_generation_bump(boundary)
268                .map_err(StoreError::Manifest)?;
269        }
270        #[cfg(feature = "graph-cypher")]
271        let graph = committed.graph;
272        let mut meta = write_segment(
273            vfs,
274            &self.directory,
275            segment.as_build(),
276            self.durability_policy,
277        )
278        .map_err(StoreError::Segment)?;
279        let epoch_alias = self.epoch_identity();
280        meta.epoch_id = epoch_alias.map(|identity| identity.embedding);
281        let mut publication = writer.manifest_publication()?;
282        publication
283            .commit_manifest(
284                vfs,
285                &self.directory,
286                &Manifest {
287                    #[cfg(feature = "graph-cypher")]
288                    graph,
289                    generation,
290                    #[cfg(feature = "graph-cypher")]
291                    log_seq: graph_log_seq,
292                    #[cfg(not(feature = "graph-cypher"))]
293                    log_seq: 0,
294                    segments: vec![meta],
295                    epochs: self.epoch_registry(&[]),
296                    epoch_alias,
297                    schema: segment.columns.schema().clone(),
298                },
299                self.durability_policy,
300            )
301            .map_err(StoreError::Manifest)?;
302        let remapped = PublishedSnapshot::load_on_vfs(&self.directory, &self.accounting, vfs)?;
303        let mut published = self
304            .snapshot
305            .write()
306            .map_err(|_| StoreError::Synchronization {
307                component: "published snapshot",
308            })?;
309        let previous = published.replace(Arc::new(remapped));
310        drop(published);
311        drop(previous);
312        drop(segment);
313        let mut active_guard = self
314            .active
315            .lock()
316            .map_err(|_| StoreError::Synchronization {
317                component: "active segment",
318            })?;
319        active_guard.as_mut().ok_or(StoreError::Closed)?.generation = generation;
320        publication.complete();
321        drop(wal);
322        drop(active_guard);
323        drop(writer_lock);
324        drop(state);
325        Ok(generation)
326    }
327}
328
329#[cfg(feature = "graph-cypher")]
330pub(crate) struct SnapshotGraphManifest {
331    graph: crate::manifest::GraphManifest,
332    _charge: super::stats::AccountedCounter,
333}
334
335#[cfg(feature = "graph-cypher")]
336impl Deref for SnapshotGraphManifest {
337    type Target = crate::manifest::GraphManifest;
338    fn deref(&self) -> &Self::Target {
339        &self.graph
340    }
341}
342
343#[cfg(feature = "graph-cypher")]
344impl SnapshotGraphManifest {
345    fn copy(
346        graph: &crate::manifest::GraphManifest,
347        accounting: &Arc<Accounting>,
348    ) -> Result<Arc<Self>, StoreError> {
349        let bytes = |graph: &crate::manifest::GraphManifest| {
350            graph
351                .backing_bytes()
352                .and_then(|bytes| {
353                    bytes
354                        .checked_add(std::mem::size_of::<Self>() + 2 * std::mem::size_of::<usize>())
355                })
356                .ok_or(StoreError::AllocationFailed {
357                    needed: u64::MAX,
358                    component: "snapshot graph manifest",
359                })
360        };
361        let mut charge =
362            super::stats::AccountedCounter::new(accounting, AllocationComponent::Snapshot)?;
363        charge.set(bytes(graph)?)?;
364        #[cfg(feature = "allocation-audit")]
365        let graph = crate::allocation_audit::attributed(|| graph.clone());
366        #[cfg(not(feature = "allocation-audit"))]
367        let graph = graph.clone();
368        charge.set(bytes(&graph)?)?;
369        let owner = Self {
370            graph,
371            _charge: charge,
372        };
373        #[cfg(feature = "allocation-audit")]
374        let owner = crate::allocation_audit::attributed(|| Arc::new(owner));
375        #[cfg(not(feature = "allocation-audit"))]
376        let owner = Arc::new(owner);
377        Ok(owner)
378    }
379}
380
381/// One atomically published generation and its immutable segment readers.
382pub struct PublishedSnapshot {
383    generation: u64,
384    absorbed_through: u64,
385    pub(crate) graph_absorbed_through: u64,
386    #[cfg(feature = "graph-cypher")]
387    pub(crate) graph_enabled: bool,
388    #[cfg(feature = "graph-cypher")]
389    pub(crate) graph_manifest: Option<Arc<SnapshotGraphManifest>>,
390    epoch_alias: Option<crate::epoch::EpochIdentity>,
391    graph_profile:
392        Result<crate::graph::search::EpochGraphProfile, crate::graph::search::GraphProfileError>,
393    schema: crate::meta::Schema,
394    segments: Arc<Accounted<Vec<SegmentReader>>>,
395    query_segment_count: usize,
396    cancelled: AtomicBool,
397    reader_signal: Arc<ReaderSignal>,
398    _mapping_reservation: Option<Arc<MappingReservation>>,
399    #[cfg(test)]
400    release_probe: SnapshotReleaseProbe,
401}
402
403impl PublishedSnapshot {
404    #[cfg(test)]
405    pub(crate) fn empty(generation: u64) -> Self {
406        Self::empty_with_signal(generation, Arc::new(ReaderSignal::new(None)))
407    }
408
409    fn empty_with_signal(generation: u64, reader_signal: Arc<ReaderSignal>) -> Self {
410        Self {
411            generation,
412            absorbed_through: 0,
413            graph_absorbed_through: 0,
414            #[cfg(feature = "graph-cypher")]
415            graph_enabled: false,
416            #[cfg(feature = "graph-cypher")]
417            graph_manifest: None,
418            epoch_alias: None,
419            graph_profile: Err(crate::graph::search::GraphProfileError::EpochUnstamped),
420            schema: crate::meta::Schema::timestamp_only(),
421            segments: Arc::new(Accounted::unaccounted_empty()),
422            query_segment_count: 0,
423            cancelled: AtomicBool::new(false),
424            reader_signal,
425            _mapping_reservation: None,
426            #[cfg(test)]
427            release_probe: SnapshotReleaseProbe(None),
428        }
429    }
430
431    pub(crate) fn load_on_vfs(
432        directory: &Path,
433        accounting: &Arc<Accounting>,
434        vfs: &dyn Vfs,
435    ) -> Result<Self, StoreError> {
436        Self::load_impl(directory, accounting, vfs, false, None)
437    }
438
439    pub(crate) fn load_for_open_on_vfs(
440        directory: &Path,
441        accounting: &Arc<Accounting>,
442        vfs: &dyn Vfs,
443        preparation: Option<&super::namespace_batch::PrivatePreparation>,
444    ) -> Result<Self, StoreError> {
445        Self::load_impl(directory, accounting, vfs, true, preparation)
446    }
447
448    fn load_impl(
449        directory: &Path,
450        accounting: &Arc<Accounting>,
451        vfs: &dyn Vfs,
452        probe_segment_headers: bool,
453        preparation: Option<&super::namespace_batch::PrivatePreparation>,
454    ) -> Result<Self, StoreError> {
455        let manifest_path = if probe_segment_headers {
456            super::namespace_batch::manifest_for_open(vfs, directory, preparation)?
457        } else {
458            directory.join(MANIFEST_FILE)
459        };
460        match vfs.open(&manifest_path) {
461            Ok(_) => {}
462            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
463                return Ok(Self::empty_with_signal(
464                    0,
465                    ReaderSignal::accounted(accounting)?,
466                ));
467            }
468            Err(source) => {
469                return Err(StoreError::Io {
470                    path: manifest_path,
471                    source,
472                });
473            }
474        }
475        let wal = WalReader::open(vfs, &directory.join(STORE_WAL_FILE)).map_err(StoreError::Wal)?;
476        // A malformed header cannot provide a manifest boundary. Preserve the
477        // header refusal before comparing the manifest to that boundary.
478        if let Some(crate::wal::replay::ReplayTerminator::InvalidHeader(reason)) = wal.terminator()
479            && reason != crate::wal::header::WalHeaderError::Missing
480        {
481            let error = crate::wal::WalRecoveryError::InvalidHeader(reason);
482            #[cfg(any(test, feature = "test-seams"))]
483            super::record_storage_wal_recovery_fault(&error);
484            return Err(StoreError::WalRecovery(error));
485        }
486        let durable_end = wal.durable_end();
487        let manifest =
488            load_manifest(vfs, &manifest_path, durable_end).map_err(StoreError::Manifest)?;
489        if !matches!(
490            wal.terminator(),
491            None | Some(crate::wal::replay::ReplayTerminator::InvalidHeader(
492                crate::wal::header::WalHeaderError::Missing
493            ))
494        ) {
495            #[cfg(feature = "graph-cypher")]
496            if manifest.graph.is_some() {
497                wal.into_graph_prefix()
498            } else {
499                wal.into_clean()
500            }
501            .map_err(StoreError::WalRecovery)?;
502            #[cfg(not(feature = "graph-cypher"))]
503            wal.into_clean().map_err(StoreError::WalRecovery)?;
504        }
505        if probe_segment_headers {
506            for expected in &manifest.segments {
507                let path = directory.join(expected.id.file_name());
508                validate_header_with_vfs(vfs, &path, expected).map_err(StoreError::Segment)?;
509            }
510        }
511        Self::from_manifest(vfs, directory, &manifest, accounting)
512    }
513
514    pub(crate) fn from_manifest(
515        vfs: &dyn Vfs,
516        directory: &Path,
517        manifest: &Manifest,
518        accounting: &Arc<Accounting>,
519    ) -> Result<Self, StoreError> {
520        #[cfg(feature = "graph-cypher")]
521        if let Some(graph) = &manifest.graph {
522            super::enable_graph::validate_objects(vfs, directory, graph)?;
523        }
524        let mut segments = Accounted::try_with_capacity(
525            accounting,
526            manifest.segments.len(),
527            AllocationComponent::Snapshot,
528        )?;
529        let query_segment_count = manifest
530            .segments
531            .iter()
532            .filter(|segment| segment_is_published(manifest.epoch_alias, segment))
533            .count();
534        let ordered_segments = manifest
535            .segments
536            .iter()
537            .filter(|segment| segment_is_published(manifest.epoch_alias, segment))
538            .chain(
539                manifest
540                    .segments
541                    .iter()
542                    .filter(|segment| !segment_is_published(manifest.epoch_alias, segment)),
543            );
544        let collection_schema = Arc::new(manifest.schema.clone());
545        for expected in ordered_segments {
546            let path = directory.join(expected.id.file_name());
547            let reader = SegmentReader::open_accounted(
548                vfs,
549                &path,
550                expected,
551                accounting,
552                &collection_schema,
553                |allocation_bytes| {
554                    let bytes = u64::try_from(allocation_bytes).map_err(|_| {
555                        StoreError::BudgetExceeded {
556                            needed: u64::MAX,
557                            budget: u64::MAX,
558                            component: "snapshot",
559                        }
560                    })?;
561                    segments.reserve_additional_bytes(bytes)
562                },
563            )?;
564            // Stored text is an optional append-only region. Older segments
565            // omit it, while a present v1 region must be semantically valid
566            // before the snapshot becomes reachable by any query.
567            let _ = reader.stored_text().map_err(StoreError::Segment)?;
568            segments.push(reader)?;
569        }
570        let mapped_bytes = segments.iter().try_fold(0_u64, |total, segment| {
571            let bytes =
572                u64::try_from(segment.mapped_bytes()).map_err(|_| StoreError::Statistics {
573                    component: "mapped bytes",
574                    source: std::io::Error::other("mapped byte count exceeds u64"),
575                })?;
576            total
577                .checked_add(bytes)
578                .ok_or_else(|| StoreError::Statistics {
579                    component: "mapped bytes",
580                    source: std::io::Error::other("mapped byte count overflow"),
581                })
582        })?;
583        let mapping_reservation = accounting.track_mapping(mapped_bytes)?;
584        let graph_profile = manifest.epoch_alias.map_or(
585            Err(crate::graph::search::GraphProfileError::EpochUnstamped),
586            |alias| {
587                manifest
588                    .epochs
589                    .iter()
590                    .find(|epoch| epoch.id == alias.embedding && epoch.tokenizer == alias.tokenizer)
591                    .ok_or(
592                        crate::graph::search::GraphProfileError::EpochNotRegistered {
593                            epoch: alias,
594                        },
595                    )
596                    .and_then(|epoch| {
597                        crate::graph::search::select_epoch_graph_profile(
598                            epoch,
599                            crate::graph::search::GraphDistanceMetric::SquaredL2,
600                        )
601                    })
602            },
603        );
604        Ok(Self {
605            generation: manifest.generation,
606            absorbed_through: manifest.log_seq,
607            #[cfg(feature = "graph-cypher")]
608            graph_enabled: manifest.graph.is_some(),
609            #[cfg(feature = "graph-cypher")]
610            graph_manifest: manifest
611                .graph
612                .as_ref()
613                .map(|graph| SnapshotGraphManifest::copy(graph, accounting))
614                .transpose()?,
615            graph_absorbed_through: {
616                #[cfg(feature = "graph-cypher")]
617                {
618                    manifest
619                        .graph
620                        .as_ref()
621                        // With no graph, replay and transaction decisions start
622                        // at log_seq exactly as in a v2-only reader.
623                        .map_or(manifest.log_seq, |graph| graph.graph_absorbed_through)
624                }
625                #[cfg(not(feature = "graph-cypher"))]
626                {
627                    manifest.log_seq
628                }
629            },
630            epoch_alias: manifest.epoch_alias,
631            graph_profile,
632            schema: manifest.schema.clone(),
633            segments: Arc::new(segments),
634            query_segment_count,
635            cancelled: AtomicBool::new(false),
636            reader_signal: ReaderSignal::accounted(accounting)?,
637            _mapping_reservation: Some(Arc::new(mapping_reservation)),
638            #[cfg(test)]
639            release_probe: SnapshotReleaseProbe(None),
640        })
641    }
642
643    /// Shares immutable storage, with independent reader cancellation and drain.
644    pub(super) fn fork_read_view(&self, accounting: &Arc<Accounting>) -> Result<Self, StoreError> {
645        Ok(Self {
646            generation: self.generation,
647            absorbed_through: self.absorbed_through,
648            graph_absorbed_through: self.graph_absorbed_through,
649            #[cfg(feature = "graph-cypher")]
650            graph_enabled: self.graph_enabled,
651            #[cfg(feature = "graph-cypher")]
652            graph_manifest: self.graph_manifest.clone(),
653            epoch_alias: self.epoch_alias,
654            graph_profile: self.graph_profile,
655            schema: self.schema.clone(),
656            segments: Arc::clone(&self.segments),
657            query_segment_count: self.query_segment_count,
658            cancelled: AtomicBool::new(false),
659            reader_signal: ReaderSignal::accounted(accounting)?,
660            _mapping_reservation: self._mapping_reservation.clone(),
661            #[cfg(test)]
662            release_probe: SnapshotReleaseProbe(None),
663        })
664    }
665
666    #[cfg(test)]
667    pub(crate) fn set_teardown_probe(&mut self, probe: Arc<TeardownProbe>) {
668        self.release_probe.0 = Some(probe);
669    }
670
671    /// Returns the monotonic manifest generation published with these segments.
672    #[must_use]
673    pub const fn generation(&self) -> u64 {
674        self.generation
675    }
676
677    pub(crate) const fn absorbed_through(&self) -> u64 {
678        self.absorbed_through
679    }
680
681    /// Returns the manifest sequence for independent storage durability tests.
682    #[cfg(any(test, feature = "test-seams"))]
683    #[doc(hidden)]
684    #[must_use]
685    pub const fn storage_absorbed_through(&self) -> u64 {
686        self.absorbed_through
687    }
688
689    pub(crate) const fn epoch_alias(&self) -> Option<crate::epoch::EpochIdentity> {
690        self.epoch_alias
691    }
692
693    pub(crate) const fn graph_profile(
694        &self,
695    ) -> Result<crate::graph::search::EpochGraphProfile, crate::graph::search::GraphProfileError>
696    {
697        self.graph_profile
698    }
699
700    pub(crate) const fn schema(&self) -> &crate::meta::Schema {
701        &self.schema
702    }
703
704    /// Returns immutable segments belonging to the published epoch alias.
705    #[must_use]
706    pub fn segments(&self) -> &[SegmentReader] {
707        match self.segments.get(..self.query_segment_count) {
708            Some(segments) => segments,
709            None => &[],
710        }
711    }
712
713    pub(crate) fn all_segments(&self) -> &[SegmentReader] {
714        &self.segments
715    }
716
717    pub(crate) fn drain_readers(self: &Arc<Self>, grace: Duration) -> Result<(), StoreError> {
718        let deadline = Instant::now().checked_add(grace);
719        let mut signal =
720            self.reader_signal
721                .mutex
722                .lock()
723                .map_err(|_| StoreError::Synchronization {
724                    component: "snapshot readers",
725                })?;
726        while Arc::strong_count(self) > 1 {
727            let Some(deadline) = deadline else {
728                break;
729            };
730            let now = Instant::now();
731            if now >= deadline {
732                break;
733            }
734            let remaining = deadline.saturating_duration_since(now);
735            let waited = self
736                .reader_signal
737                .changed
738                .wait_timeout(signal, remaining)
739                .map_err(|_| StoreError::Synchronization {
740                    component: "snapshot readers",
741                })?;
742            signal = waited.0;
743        }
744        if Arc::strong_count(self) > 1 {
745            self.cancelled.store(true, Ordering::Release);
746            self.reader_signal.changed.notify_all();
747        }
748        while Arc::strong_count(self) > 1 {
749            signal = self.reader_signal.changed.wait(signal).map_err(|_| {
750                StoreError::Synchronization {
751                    component: "snapshot readers",
752                }
753            })?;
754        }
755        drop(signal);
756        Ok(())
757    }
758
759    pub(crate) fn cancel_readers(&self) {
760        self.cancelled.store(true, Ordering::Release);
761        self.reader_signal.changed.notify_all();
762    }
763}
764
765fn segment_is_published(
766    alias: Option<crate::epoch::EpochIdentity>,
767    segment: &crate::segment::SegmentMeta,
768) -> bool {
769    alias.is_none_or(|identity| segment.epoch_id == Some(identity.embedding))
770}
771
772// The notification allocation outlives the snapshot reference being released.
773// Keeping it independent lets ordinary field-drop order decrement ownership
774// before the close waiter is signalled, without unsafe code or timed polling.
775struct ReaderSignal {
776    mutex: Mutex<()>,
777    changed: Condvar,
778    _charge: Option<super::stats::AccountedCounter>,
779    #[cfg(test)]
780    release_observed: std::sync::atomic::AtomicUsize,
781}
782
783impl ReaderSignal {
784    fn new(charge: Option<super::stats::AccountedCounter>) -> Self {
785        Self {
786            mutex: Mutex::new(()),
787            changed: Condvar::new(),
788            _charge: charge,
789            #[cfg(test)]
790            release_observed: std::sync::atomic::AtomicUsize::new(0),
791        }
792    }
793
794    fn accounted(accounting: &Arc<Accounting>) -> Result<Arc<Self>, StoreError> {
795        let mut charge =
796            super::stats::AccountedCounter::new(accounting, AllocationComponent::Snapshot)?;
797        charge.set(std::mem::size_of::<Self>() + 2 * std::mem::size_of::<usize>())?;
798        Ok(Arc::new(Self::new(Some(charge))))
799    }
800}
801
802struct ReaderRelease {
803    signal: Arc<ReaderSignal>,
804    #[cfg(test)]
805    snapshot: std::sync::Weak<PublishedSnapshot>,
806}
807
808impl Drop for ReaderRelease {
809    fn drop(&mut self) {
810        let _guard = self
811            .signal
812            .mutex
813            .lock()
814            .unwrap_or_else(std::sync::PoisonError::into_inner);
815        #[cfg(test)]
816        self.signal
817            .release_observed
818            .store(self.snapshot.strong_count(), Ordering::Relaxed);
819        self.signal.changed.notify_all();
820    }
821}
822
823/// Internal ownership shared by public leases and complete query admissions.
824/// Fields deliberately drop in order: snapshot reference, then notification.
825pub(super) struct ReadSnapshot {
826    snapshot: Arc<PublishedSnapshot>,
827    _release: ReaderRelease,
828}
829
830impl ReadSnapshot {
831    pub(super) fn new(snapshot: Arc<PublishedSnapshot>) -> Self {
832        let release = ReaderRelease {
833            signal: Arc::clone(&snapshot.reader_signal),
834            #[cfg(test)]
835            snapshot: Arc::downgrade(&snapshot),
836        };
837        Self {
838            snapshot,
839            _release: release,
840        }
841    }
842}
843
844impl Deref for ReadSnapshot {
845    type Target = Arc<PublishedSnapshot>;
846    fn deref(&self) -> &Self::Target {
847        &self.snapshot
848    }
849}
850
851/// One admitted read's strong ownership of its published snapshot.
852pub struct SnapshotLease {
853    snapshot: ReadSnapshot,
854    generation: u64,
855}
856
857impl SnapshotLease {
858    pub(crate) fn new(snapshot: Arc<PublishedSnapshot>) -> Self {
859        let generation = snapshot.generation();
860        Self {
861            snapshot: ReadSnapshot::new(snapshot),
862            generation,
863        }
864    }
865
866    pub(crate) fn new_at(snapshot: Arc<PublishedSnapshot>, generation: u64) -> Self {
867        Self {
868            snapshot: ReadSnapshot::new(snapshot),
869            generation,
870        }
871    }
872
873    /// Returns the active-state generation pinned when this lease was admitted.
874    #[must_use]
875    pub const fn generation(&self) -> u64 {
876        self.generation
877    }
878
879    /// Rejects work after close has cancelled this admitted read.
880    pub fn check_active(&self) -> Result<(), StoreError> {
881        if self.is_cancelled() {
882            Err(StoreError::ReadCancelled)
883        } else {
884            Ok(())
885        }
886    }
887
888    pub(crate) fn is_cancelled(&self) -> bool {
889        self.snapshot.cancelled.load(Ordering::Relaxed)
890    }
891
892    /// Blocks without polling until close cancels this admitted read.
893    pub fn wait_for_close_cancellation(&self) -> Result<(), StoreError> {
894        let mut signal =
895            self.snapshot
896                .reader_signal
897                .mutex
898                .lock()
899                .map_err(|_| StoreError::Synchronization {
900                    component: "snapshot readers",
901                })?;
902        while !self.snapshot.cancelled.load(Ordering::Acquire) {
903            signal = self
904                .snapshot
905                .reader_signal
906                .changed
907                .wait(signal)
908                .map_err(|_| StoreError::Synchronization {
909                    component: "snapshot readers",
910                })?;
911        }
912        drop(signal);
913        Ok(())
914    }
915}
916
917impl Deref for SnapshotLease {
918    type Target = PublishedSnapshot;
919
920    fn deref(&self) -> &Self::Target {
921        &self.snapshot
922    }
923}
924
925#[cfg(test)]
926#[allow(clippy::expect_used)]
927mod tests {
928    use std::sync::Arc;
929    use std::sync::atomic::Ordering;
930
931    use tempfile::tempdir;
932
933    use super::{InMemorySegment, InMemorySegmentFactors, PublishedSnapshot};
934    use crate::ingest::{DocId, DocumentVersion, IngestBatch, IngestDocument, Revision};
935    use crate::lifecycle::durability::{CommitTier, DurabilityMode, DurabilityPolicy};
936    use crate::lifecycle::{
937        ManualMonotonicClock, OpenOptions, Store, StoreError, StoreTestDependencies,
938    };
939    use crate::manifest::Manifest;
940    use crate::manifest::io::commit_manifest;
941    use crate::meta::{AliveSet, ColumnStoreBuilder, Schema};
942    use crate::quant::Bit4Factors;
943    use crate::segment::SegmentId;
944    use crate::segment::writer::{SegmentBuild, SegmentFactors, write_segment};
945    use crate::vfs::crash::{CrashOperation, RecordingVfs};
946    use crate::vfs::{CountingVfs, StdVfs, SyncKind};
947
948    #[test]
949    fn astra_05_snapshot_release_notifies_after_ownership_drops() {
950        let snapshot = Arc::new(PublishedSnapshot::empty(1));
951        let lease = super::SnapshotLease::new(snapshot.clone());
952        drop(lease);
953        assert_eq!(
954            snapshot
955                .reader_signal
956                .release_observed
957                .load(std::sync::atomic::Ordering::Relaxed),
958            1,
959            "close notification must observe only the draining owner"
960        );
961        // A complete admission can outlive its last partition lease. Its
962        // final raw snapshot reference must generate a second notification.
963        let admission = super::ReadSnapshot::new(snapshot.clone());
964        let partition = super::SnapshotLease::new(snapshot.clone());
965        drop(partition);
966        assert_eq!(
967            snapshot
968                .reader_signal
969                .release_observed
970                .load(Ordering::Relaxed),
971            2
972        );
973        drop(admission);
974        assert_eq!(
975            snapshot
976                .reader_signal
977                .release_observed
978                .load(Ordering::Relaxed),
979            1
980        );
981    }
982
983    #[test]
984    fn sealed_segment_open_is_routed_through_vfs_open_for_map() {
985        let directory = tempdir().expect("mapped segment directory");
986        let schema = Schema::new(Vec::new()).expect("timestamp-only schema");
987        let mut builder = ColumnStoreBuilder::new(schema.clone());
988        builder.push_row(7, &[]).expect("fixture row");
989        let columns = builder.finish().expect("fixture columns");
990        let alive = AliveSet::new(1);
991        let id = SegmentId::new(0x06, [0x06; 10]);
992        let policy = DurabilityPolicy::new(DurabilityMode::Derived, CommitTier::None)
993            .expect("derived policy");
994        let segment = write_segment(
995            &StdVfs,
996            directory.path(),
997            SegmentBuild {
998                id,
999                scheme: 4,
1000                dims: 4,
1001                codes: &[0_u8; 2],
1002                factors: SegmentFactors::Bit4(&[Bit4Factors::from_persisted(1.0, 1.0, 1.0)]),
1003                rescore: &[0.0_f32; 4],
1004                columns: &columns,
1005                alive: &alive,
1006            },
1007            policy,
1008        )
1009        .expect("sealed segment");
1010        commit_manifest(
1011            &StdVfs,
1012            directory.path(),
1013            &Manifest {
1014                #[cfg(feature = "graph-cypher")]
1015                graph: None,
1016                generation: 1,
1017                log_seq: 0,
1018                segments: vec![segment],
1019                epochs: Vec::new(),
1020                epoch_alias: None,
1021                schema,
1022            },
1023            policy,
1024        )
1025        .expect("segment manifest");
1026        let vfs = Arc::new(CountingVfs::new(StdVfs));
1027
1028        let store = Store::open_with_test_dependencies(
1029            directory.path(),
1030            OpenOptions::default(),
1031            StoreTestDependencies::new(vfs.clone(), Arc::new(ManualMonotonicClock::new())),
1032        )
1033        .expect("store maps sealed segment");
1034
1035        assert_eq!(
1036            vfs.open_for_map_calls(),
1037            1,
1038            "sealed segment mapping bypassed Vfs::open_for_map"
1039        );
1040        store.close().expect("close mapped store");
1041    }
1042
1043    #[test]
1044    fn publish_swaps_a_whole_snapshot_while_existing_readers_keep_their_generation() {
1045        let directory = tempdir().expect("store directory");
1046        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
1047        let old_reader = store.snapshot().expect("old reader");
1048
1049        store
1050            .publish_snapshot(PublishedSnapshot::empty(9))
1051            .expect("publish generation");
1052        let new_reader = store.snapshot().expect("new reader");
1053
1054        assert_eq!(old_reader.generation(), 0);
1055        assert_eq!(new_reader.generation(), 9);
1056        drop(old_reader);
1057        drop(new_reader);
1058        store.close().expect("close");
1059    }
1060
1061    #[test]
1062    fn publish_after_close_returns_the_typed_closed_error() {
1063        let directory = tempdir().expect("store directory");
1064        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
1065        store.close().expect("close");
1066
1067        let result = store.publish_snapshot(PublishedSnapshot::empty(10));
1068
1069        assert!(matches!(result, Err(StoreError::Closed)));
1070    }
1071
1072    #[test]
1073    fn seal_uses_the_opened_durable_policy_for_segment_and_manifest() {
1074        const DIMS: usize = 4;
1075
1076        let directory = tempdir().expect("store directory");
1077        let schema = Schema::new(Vec::new()).expect("timestamp-only schema");
1078        let mut builder = ColumnStoreBuilder::new(schema.clone());
1079        builder.push_row(7, &[]).expect("fixture row");
1080        let columns = builder.finish().expect("fixture columns");
1081        let alive = AliveSet::new(1);
1082        let initial_id = SegmentId::new(0x0102_0304_0506, [0x41; 10]);
1083        let codes = [0x88_u8; DIMS.div_ceil(2)];
1084        let factors = [Bit4Factors::from_persisted(1.0, 1.0, 1.0)];
1085        let rescore = [0.0_f32; DIMS];
1086        let derived = DurabilityPolicy::new(DurabilityMode::Derived, CommitTier::Ordered)
1087            .expect("derived policy");
1088        let initial = write_segment(
1089            &StdVfs,
1090            directory.path(),
1091            SegmentBuild {
1092                id: initial_id,
1093                scheme: 4,
1094                dims: DIMS as u32,
1095                codes: &codes,
1096                factors: SegmentFactors::Bit4(&factors),
1097                rescore: &rescore,
1098                columns: &columns,
1099                alive: &alive,
1100            },
1101            derived,
1102        )
1103        .expect("initial segment");
1104        commit_manifest(
1105            &StdVfs,
1106            directory.path(),
1107            &Manifest {
1108                #[cfg(feature = "graph-cypher")]
1109                graph: None,
1110                generation: 1,
1111                log_seq: 0,
1112                segments: vec![initial],
1113                epochs: Vec::new(),
1114                epoch_alias: None,
1115                schema: schema.clone(),
1116            },
1117            derived,
1118        )
1119        .expect("initial manifest");
1120        let store = Store::open(
1121            directory.path(),
1122            OpenOptions::new().with_durability(DurabilityMode::Durable, CommitTier::Durable),
1123        )
1124        .expect("durable store open");
1125        let replacement_id = SegmentId::new(0x0102_0304_0507, [0x42; 10]);
1126        let prepared = store
1127            .prepare_segment(InMemorySegment {
1128                id: replacement_id,
1129                scheme: 4,
1130                dims: DIMS as u32,
1131                codes: codes.to_vec(),
1132                factors: InMemorySegmentFactors::Bit4(factors.to_vec()),
1133                rescore: rescore.to_vec(),
1134                columns: &columns,
1135                alive: &alive,
1136            })
1137            .expect("prepare replacement");
1138        let recorder = RecordingVfs::new(StdVfs);
1139
1140        store
1141            .seal_snapshot_with_vfs(prepared, &recorder)
1142            .expect("durable seal");
1143
1144        let sync_kinds = recorder
1145            .operations()
1146            .expect("recorded seal operations")
1147            .into_iter()
1148            .filter_map(|operation| match operation {
1149                CrashOperation::Sync { kind, .. } => Some(kind),
1150                _ => None,
1151            })
1152            .collect::<Vec<_>>();
1153        assert_eq!(
1154            sync_kinds,
1155            vec![SyncKind::Full; 4],
1156            "seal must preserve the opened policy's exact SyncKind sequence"
1157        );
1158        store.close().expect("close");
1159    }
1160
1161    #[test]
1162    fn first_durable_ingest_syncs_new_wal_directory_entry_before_ack() {
1163        let directory = tempdir().expect("store directory");
1164        let recorder = Arc::new(RecordingVfs::new(StdVfs));
1165        let store = Store::open_with_test_dependencies(
1166            directory.path(),
1167            OpenOptions::new().with_durability(DurabilityMode::Durable, CommitTier::Durable),
1168            StoreTestDependencies::new(recorder.clone(), Arc::new(ManualMonotonicClock::new())),
1169        )
1170        .expect("durable store open");
1171        let operations_before_ingest = recorder.operations().expect("open operations").len();
1172
1173        store
1174            .ingest(IngestBatch::new(vec![IngestDocument::new(
1175                DocumentVersion::new(DocId::new(1), Revision::new(1)),
1176                vec![1.0, 2.0, 3.0, 4.0],
1177            )]))
1178            .expect("durable ingest ack");
1179
1180        let syncs = recorder
1181            .operations()
1182            .expect("ingest operations")
1183            .into_iter()
1184            .skip(operations_before_ingest)
1185            .filter_map(|operation| match operation {
1186                CrashOperation::Sync { path, kind } => Some((path, kind)),
1187                _ => None,
1188            })
1189            .collect::<Vec<_>>();
1190        assert_eq!(
1191            syncs,
1192            vec![
1193                (directory.path().join("wal.ze"), SyncKind::Full),
1194                (directory.path().to_path_buf(), SyncKind::Full),
1195            ],
1196            "the first durable WAL group must sync its new directory entry before ack"
1197        );
1198        store.close().expect("close");
1199    }
1200}
1201
1202#[cfg(all(test, feature = "graph-cypher"))]
1203#[allow(clippy::unwrap_used)]
1204mod unified_capacity {
1205    use super::*;
1206
1207    #[test]
1208    fn retained_readers_share_the_manifest_graph_capacity() {
1209        let directory = tempfile::tempdir().unwrap();
1210        let store = Store::open(directory.path(), super::super::OpenOptions::new()).unwrap();
1211        store.enable_graph().unwrap();
1212        let original = store.snapshot.read().unwrap().as_ref().unwrap().clone();
1213        let fork = original.fork_read_view(&store.accounting).unwrap();
1214        assert_eq!(
1215            original.graph_manifest.as_ref().unwrap().objects.as_ptr(),
1216            fork.graph_manifest.as_ref().unwrap().objects.as_ptr(),
1217            "immutable manifest inventory has one capacity owner"
1218        );
1219        drop(fork);
1220        drop(original);
1221        store.close().unwrap();
1222    }
1223}