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