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: Accounted<Vec<SegmentReader>>,
305    query_segment_count: usize,
306    cancelled: AtomicBool,
307    reader_signal: Arc<ReaderSignal>,
308    _mapping_reservation: Option<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: 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 = directory.join(MANIFEST_FILE);
359        match vfs.open(&manifest_path) {
360            Ok(_) => {}
361            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
362                return Ok(Self::empty_with_signal(
363                    0,
364                    ReaderSignal::accounted(accounting)?,
365                ));
366            }
367            Err(source) => {
368                return Err(StoreError::Io {
369                    path: manifest_path,
370                    source,
371                });
372            }
373        }
374        let wal = WalReader::open(vfs, &directory.join(STORE_WAL_FILE)).map_err(StoreError::Wal)?;
375        let durable_end = wal.durable_end();
376        if !matches!(
377            wal.terminator(),
378            None | Some(crate::wal::replay::ReplayTerminator::InvalidHeader(
379                crate::wal::header::WalHeaderError::Missing
380            ))
381        ) {
382            wal.into_clean().map_err(StoreError::WalRecovery)?;
383        }
384        let manifest =
385            load_manifest(vfs, &manifest_path, durable_end).map_err(StoreError::Manifest)?;
386        if probe_segment_headers {
387            for expected in &manifest.segments {
388                let path = directory.join(expected.id.file_name());
389                validate_header_with_vfs(vfs, &path, expected).map_err(StoreError::Segment)?;
390            }
391        }
392        Self::from_manifest(vfs, directory, &manifest, accounting)
393    }
394
395    pub(crate) fn from_manifest(
396        vfs: &dyn Vfs,
397        directory: &Path,
398        manifest: &Manifest,
399        accounting: &Arc<Accounting>,
400    ) -> Result<Self, StoreError> {
401        let mut segments = Accounted::try_with_capacity(
402            accounting,
403            manifest.segments.len(),
404            AllocationComponent::Snapshot,
405        )?;
406        let query_segment_count = manifest
407            .segments
408            .iter()
409            .filter(|segment| segment_is_published(manifest.epoch_alias, segment))
410            .count();
411        let ordered_segments = manifest
412            .segments
413            .iter()
414            .filter(|segment| segment_is_published(manifest.epoch_alias, segment))
415            .chain(
416                manifest
417                    .segments
418                    .iter()
419                    .filter(|segment| !segment_is_published(manifest.epoch_alias, segment)),
420            );
421        for expected in ordered_segments {
422            let path = directory.join(expected.id.file_name());
423            let reader = SegmentReader::open_accounted(
424                vfs,
425                &path,
426                expected,
427                accounting,
428                |allocation_bytes| {
429                    let bytes = u64::try_from(allocation_bytes).map_err(|_| {
430                        StoreError::BudgetExceeded {
431                            needed: u64::MAX,
432                            budget: u64::MAX,
433                            component: "snapshot",
434                        }
435                    })?;
436                    segments.reserve_additional_bytes(bytes)
437                },
438            )?;
439            // Stored text is an optional append-only region. Older segments
440            // omit it, while a present v1 region must be semantically valid
441            // before the snapshot becomes reachable by any query.
442            let _ = reader.stored_text().map_err(StoreError::Segment)?;
443            segments.push(reader)?;
444        }
445        let mapped_bytes = segments.iter().try_fold(0_u64, |total, segment| {
446            let bytes =
447                u64::try_from(segment.mapped_bytes()).map_err(|_| StoreError::Statistics {
448                    component: "mapped bytes",
449                    source: std::io::Error::other("mapped byte count exceeds u64"),
450                })?;
451            total
452                .checked_add(bytes)
453                .ok_or_else(|| StoreError::Statistics {
454                    component: "mapped bytes",
455                    source: std::io::Error::other("mapped byte count overflow"),
456                })
457        })?;
458        let mapping_reservation = accounting.track_mapping(mapped_bytes)?;
459        let graph_profile = manifest.epoch_alias.map_or(
460            Err(crate::graph::search::GraphProfileError::EpochUnstamped),
461            |alias| {
462                manifest
463                    .epochs
464                    .iter()
465                    .find(|epoch| epoch.id == alias.embedding && epoch.tokenizer == alias.tokenizer)
466                    .ok_or(
467                        crate::graph::search::GraphProfileError::EpochNotRegistered {
468                            epoch: alias,
469                        },
470                    )
471                    .and_then(|epoch| {
472                        crate::graph::search::select_epoch_graph_profile(
473                            epoch,
474                            crate::graph::search::GraphDistanceMetric::SquaredL2,
475                        )
476                    })
477            },
478        );
479        Ok(Self {
480            generation: manifest.generation,
481            absorbed_through: manifest.log_seq,
482            epoch_alias: manifest.epoch_alias,
483            graph_profile,
484            schema: manifest.schema.clone(),
485            segments,
486            query_segment_count,
487            cancelled: AtomicBool::new(false),
488            reader_signal: ReaderSignal::accounted(accounting)?,
489            _mapping_reservation: Some(mapping_reservation),
490            #[cfg(test)]
491            release_probe: SnapshotReleaseProbe(None),
492        })
493    }
494
495    #[cfg(test)]
496    pub(crate) fn set_teardown_probe(&mut self, probe: Arc<TeardownProbe>) {
497        self.release_probe.0 = Some(probe);
498    }
499
500    /// Returns the monotonic manifest generation published with these segments.
501    #[must_use]
502    pub const fn generation(&self) -> u64 {
503        self.generation
504    }
505
506    pub(crate) const fn absorbed_through(&self) -> u64 {
507        self.absorbed_through
508    }
509
510    /// Returns the manifest sequence for independent storage durability tests.
511    #[cfg(any(test, feature = "test-support"))]
512    #[doc(hidden)]
513    #[must_use]
514    pub const fn storage_absorbed_through(&self) -> u64 {
515        self.absorbed_through
516    }
517
518    pub(crate) const fn epoch_alias(&self) -> Option<crate::epoch::EpochIdentity> {
519        self.epoch_alias
520    }
521
522    pub(crate) const fn graph_profile(
523        &self,
524    ) -> Result<crate::graph::search::EpochGraphProfile, crate::graph::search::GraphProfileError>
525    {
526        self.graph_profile
527    }
528
529    pub(crate) const fn schema(&self) -> &crate::meta::Schema {
530        &self.schema
531    }
532
533    /// Returns immutable segments belonging to the published epoch alias.
534    #[must_use]
535    pub fn segments(&self) -> &[SegmentReader] {
536        match self.segments.get(..self.query_segment_count) {
537            Some(segments) => segments,
538            None => &[],
539        }
540    }
541
542    pub(crate) fn all_segments(&self) -> &[SegmentReader] {
543        &self.segments
544    }
545
546    pub(crate) fn drain_readers(self: &Arc<Self>, grace: Duration) -> Result<(), StoreError> {
547        let deadline = Instant::now().checked_add(grace);
548        let mut signal =
549            self.reader_signal
550                .mutex
551                .lock()
552                .map_err(|_| StoreError::Synchronization {
553                    component: "snapshot readers",
554                })?;
555        while Arc::strong_count(self) > 1 {
556            let Some(deadline) = deadline else {
557                break;
558            };
559            let now = Instant::now();
560            if now >= deadline {
561                break;
562            }
563            let remaining = deadline.saturating_duration_since(now);
564            let waited = self
565                .reader_signal
566                .changed
567                .wait_timeout(signal, remaining)
568                .map_err(|_| StoreError::Synchronization {
569                    component: "snapshot readers",
570                })?;
571            signal = waited.0;
572        }
573        if Arc::strong_count(self) > 1 {
574            self.cancelled.store(true, Ordering::Release);
575            self.reader_signal.changed.notify_all();
576        }
577        while Arc::strong_count(self) > 1 {
578            signal = self.reader_signal.changed.wait(signal).map_err(|_| {
579                StoreError::Synchronization {
580                    component: "snapshot readers",
581                }
582            })?;
583        }
584        drop(signal);
585        Ok(())
586    }
587
588    pub(crate) fn cancel_readers(&self) {
589        self.cancelled.store(true, Ordering::Release);
590        self.reader_signal.changed.notify_all();
591    }
592}
593
594fn segment_is_published(
595    alias: Option<crate::epoch::EpochIdentity>,
596    segment: &crate::segment::SegmentMeta,
597) -> bool {
598    alias.is_none_or(|identity| segment.epoch_id == Some(identity.embedding))
599}
600
601// The notification allocation outlives the snapshot reference being released.
602// Keeping it independent lets ordinary field-drop order decrement ownership
603// before the close waiter is signalled, without unsafe code or timed polling.
604struct ReaderSignal {
605    mutex: Mutex<()>,
606    changed: Condvar,
607    _charge: Option<super::stats::AccountedCounter>,
608    #[cfg(test)]
609    release_observed: std::sync::atomic::AtomicUsize,
610}
611
612impl ReaderSignal {
613    fn new(charge: Option<super::stats::AccountedCounter>) -> Self {
614        Self {
615            mutex: Mutex::new(()),
616            changed: Condvar::new(),
617            _charge: charge,
618            #[cfg(test)]
619            release_observed: std::sync::atomic::AtomicUsize::new(0),
620        }
621    }
622
623    fn accounted(accounting: &Arc<Accounting>) -> Result<Arc<Self>, StoreError> {
624        let mut charge =
625            super::stats::AccountedCounter::new(accounting, AllocationComponent::Snapshot)?;
626        charge.set(std::mem::size_of::<Self>() + 2 * std::mem::size_of::<usize>())?;
627        Ok(Arc::new(Self::new(Some(charge))))
628    }
629}
630
631struct ReaderRelease {
632    signal: Arc<ReaderSignal>,
633    #[cfg(test)]
634    snapshot: std::sync::Weak<PublishedSnapshot>,
635}
636
637impl Drop for ReaderRelease {
638    fn drop(&mut self) {
639        let _guard = self
640            .signal
641            .mutex
642            .lock()
643            .unwrap_or_else(std::sync::PoisonError::into_inner);
644        #[cfg(test)]
645        self.signal
646            .release_observed
647            .store(self.snapshot.strong_count(), Ordering::Relaxed);
648        self.signal.changed.notify_all();
649    }
650}
651
652/// Internal ownership shared by public leases and complete query admissions.
653/// Fields deliberately drop in order: snapshot reference, then notification.
654pub(super) struct ReadSnapshot {
655    snapshot: Arc<PublishedSnapshot>,
656    _release: ReaderRelease,
657}
658
659impl ReadSnapshot {
660    pub(super) fn new(snapshot: Arc<PublishedSnapshot>) -> Self {
661        let release = ReaderRelease {
662            signal: Arc::clone(&snapshot.reader_signal),
663            #[cfg(test)]
664            snapshot: Arc::downgrade(&snapshot),
665        };
666        Self {
667            snapshot,
668            _release: release,
669        }
670    }
671}
672
673impl Deref for ReadSnapshot {
674    type Target = Arc<PublishedSnapshot>;
675    fn deref(&self) -> &Self::Target {
676        &self.snapshot
677    }
678}
679
680/// One admitted read's strong ownership of its published snapshot.
681pub struct SnapshotLease {
682    snapshot: ReadSnapshot,
683    generation: u64,
684}
685
686impl SnapshotLease {
687    pub(crate) fn new(snapshot: Arc<PublishedSnapshot>) -> Self {
688        let generation = snapshot.generation();
689        Self {
690            snapshot: ReadSnapshot::new(snapshot),
691            generation,
692        }
693    }
694
695    pub(crate) fn new_at(snapshot: Arc<PublishedSnapshot>, generation: u64) -> Self {
696        Self {
697            snapshot: ReadSnapshot::new(snapshot),
698            generation,
699        }
700    }
701
702    /// Returns the active-state generation pinned when this lease was admitted.
703    #[must_use]
704    pub const fn generation(&self) -> u64 {
705        self.generation
706    }
707
708    /// Rejects work after close has cancelled this admitted read.
709    pub fn check_active(&self) -> Result<(), StoreError> {
710        if self.is_cancelled() {
711            Err(StoreError::ReadCancelled)
712        } else {
713            Ok(())
714        }
715    }
716
717    pub(crate) fn is_cancelled(&self) -> bool {
718        self.snapshot.cancelled.load(Ordering::Relaxed)
719    }
720
721    /// Blocks without polling until close cancels this admitted read.
722    pub fn wait_for_close_cancellation(&self) -> Result<(), StoreError> {
723        let mut signal =
724            self.snapshot
725                .reader_signal
726                .mutex
727                .lock()
728                .map_err(|_| StoreError::Synchronization {
729                    component: "snapshot readers",
730                })?;
731        while !self.snapshot.cancelled.load(Ordering::Acquire) {
732            signal = self
733                .snapshot
734                .reader_signal
735                .changed
736                .wait(signal)
737                .map_err(|_| StoreError::Synchronization {
738                    component: "snapshot readers",
739                })?;
740        }
741        drop(signal);
742        Ok(())
743    }
744}
745
746impl Deref for SnapshotLease {
747    type Target = PublishedSnapshot;
748
749    fn deref(&self) -> &Self::Target {
750        &self.snapshot
751    }
752}
753
754#[cfg(test)]
755#[allow(clippy::expect_used)]
756mod tests {
757    use std::sync::Arc;
758    use std::sync::atomic::Ordering;
759
760    use tempfile::tempdir;
761
762    use super::{InMemorySegment, InMemorySegmentFactors, PublishedSnapshot};
763    use crate::ingest::{DocId, DocumentVersion, IngestBatch, IngestDocument, Revision};
764    use crate::lifecycle::durability::{CommitTier, DurabilityMode, DurabilityPolicy};
765    use crate::lifecycle::{
766        ManualMonotonicClock, OpenOptions, Store, StoreError, StoreTestDependencies,
767    };
768    use crate::manifest::Manifest;
769    use crate::manifest::io::commit_manifest;
770    use crate::meta::{AliveSet, ColumnStoreBuilder, Schema};
771    use crate::quant::Bit4Factors;
772    use crate::segment::SegmentId;
773    use crate::segment::writer::{SegmentBuild, SegmentFactors, write_segment};
774    use crate::vfs::crash::{CrashOperation, RecordingVfs};
775    use crate::vfs::{CountingVfs, StdVfs, SyncKind};
776
777    #[test]
778    fn astra_05_snapshot_release_notifies_after_ownership_drops() {
779        let snapshot = Arc::new(PublishedSnapshot::empty(1));
780        let lease = super::SnapshotLease::new(snapshot.clone());
781        drop(lease);
782        assert_eq!(
783            snapshot
784                .reader_signal
785                .release_observed
786                .load(std::sync::atomic::Ordering::Relaxed),
787            1,
788            "close notification must observe only the draining owner"
789        );
790        // A complete admission can outlive its last partition lease. Its
791        // final raw snapshot reference must generate a second notification.
792        let admission = super::ReadSnapshot::new(snapshot.clone());
793        let partition = super::SnapshotLease::new(snapshot.clone());
794        drop(partition);
795        assert_eq!(
796            snapshot
797                .reader_signal
798                .release_observed
799                .load(Ordering::Relaxed),
800            2
801        );
802        drop(admission);
803        assert_eq!(
804            snapshot
805                .reader_signal
806                .release_observed
807                .load(Ordering::Relaxed),
808            1
809        );
810    }
811
812    #[test]
813    fn sealed_segment_open_is_routed_through_vfs_open_for_map() {
814        let directory = tempdir().expect("mapped segment directory");
815        let schema = Schema::new(Vec::new()).expect("timestamp-only schema");
816        let mut builder = ColumnStoreBuilder::new(schema.clone());
817        builder.push_row(7, &[]).expect("fixture row");
818        let columns = builder.finish().expect("fixture columns");
819        let alive = AliveSet::new(1);
820        let id = SegmentId::new(0x06, [0x06; 10]);
821        let policy = DurabilityPolicy::new(DurabilityMode::Derived, CommitTier::None)
822            .expect("derived policy");
823        let segment = write_segment(
824            &StdVfs,
825            directory.path(),
826            SegmentBuild {
827                id,
828                scheme: 4,
829                dims: 4,
830                codes: &[0_u8; 2],
831                factors: SegmentFactors::Bit4(&[Bit4Factors::from_persisted(1.0, 1.0, 1.0)]),
832                rescore: &[0.0_f32; 4],
833                columns: &columns,
834                alive: &alive,
835            },
836            policy,
837        )
838        .expect("sealed segment");
839        commit_manifest(
840            &StdVfs,
841            directory.path(),
842            &Manifest {
843                generation: 1,
844                log_seq: 0,
845                segments: vec![segment],
846                epochs: Vec::new(),
847                epoch_alias: None,
848                schema,
849            },
850            policy,
851        )
852        .expect("segment manifest");
853        let vfs = Arc::new(CountingVfs::new(StdVfs));
854
855        let store = Store::open_with_test_dependencies(
856            directory.path(),
857            OpenOptions::default(),
858            StoreTestDependencies::new(vfs.clone(), Arc::new(ManualMonotonicClock::new())),
859        )
860        .expect("store maps sealed segment");
861
862        assert_eq!(
863            vfs.open_for_map_calls(),
864            1,
865            "sealed segment mapping bypassed Vfs::open_for_map"
866        );
867        store.close().expect("close mapped store");
868    }
869
870    #[test]
871    fn publish_swaps_a_whole_snapshot_while_existing_readers_keep_their_generation() {
872        let directory = tempdir().expect("store directory");
873        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
874        let old_reader = store.snapshot().expect("old reader");
875
876        store
877            .publish_snapshot(PublishedSnapshot::empty(9))
878            .expect("publish generation");
879        let new_reader = store.snapshot().expect("new reader");
880
881        assert_eq!(old_reader.generation(), 0);
882        assert_eq!(new_reader.generation(), 9);
883        drop(old_reader);
884        drop(new_reader);
885        store.close().expect("close");
886    }
887
888    #[test]
889    fn publish_after_close_returns_the_typed_closed_error() {
890        let directory = tempdir().expect("store directory");
891        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
892        store.close().expect("close");
893
894        let result = store.publish_snapshot(PublishedSnapshot::empty(10));
895
896        assert!(matches!(result, Err(StoreError::Closed)));
897    }
898
899    #[test]
900    fn seal_uses_the_opened_durable_policy_for_segment_and_manifest() {
901        const DIMS: usize = 4;
902
903        let directory = tempdir().expect("store directory");
904        let schema = Schema::new(Vec::new()).expect("timestamp-only schema");
905        let mut builder = ColumnStoreBuilder::new(schema.clone());
906        builder.push_row(7, &[]).expect("fixture row");
907        let columns = builder.finish().expect("fixture columns");
908        let alive = AliveSet::new(1);
909        let initial_id = SegmentId::new(0x0102_0304_0506, [0x41; 10]);
910        let codes = [0x88_u8; DIMS.div_ceil(2)];
911        let factors = [Bit4Factors::from_persisted(1.0, 1.0, 1.0)];
912        let rescore = [0.0_f32; DIMS];
913        let derived = DurabilityPolicy::new(DurabilityMode::Derived, CommitTier::Ordered)
914            .expect("derived policy");
915        let initial = write_segment(
916            &StdVfs,
917            directory.path(),
918            SegmentBuild {
919                id: initial_id,
920                scheme: 4,
921                dims: DIMS as u32,
922                codes: &codes,
923                factors: SegmentFactors::Bit4(&factors),
924                rescore: &rescore,
925                columns: &columns,
926                alive: &alive,
927            },
928            derived,
929        )
930        .expect("initial segment");
931        commit_manifest(
932            &StdVfs,
933            directory.path(),
934            &Manifest {
935                generation: 1,
936                log_seq: 0,
937                segments: vec![initial],
938                epochs: Vec::new(),
939                epoch_alias: None,
940                schema: schema.clone(),
941            },
942            derived,
943        )
944        .expect("initial manifest");
945        let store = Store::open(
946            directory.path(),
947            OpenOptions::new().with_durability(DurabilityMode::Durable, CommitTier::Durable),
948        )
949        .expect("durable store open");
950        let replacement_id = SegmentId::new(0x0102_0304_0507, [0x42; 10]);
951        let prepared = store
952            .prepare_segment(InMemorySegment {
953                id: replacement_id,
954                scheme: 4,
955                dims: DIMS as u32,
956                codes: codes.to_vec(),
957                factors: InMemorySegmentFactors::Bit4(factors.to_vec()),
958                rescore: rescore.to_vec(),
959                columns: &columns,
960                alive: &alive,
961            })
962            .expect("prepare replacement");
963        let recorder = RecordingVfs::new(StdVfs);
964
965        store
966            .seal_snapshot_with_vfs(prepared, &recorder)
967            .expect("durable seal");
968
969        let sync_kinds = recorder
970            .operations()
971            .expect("recorded seal operations")
972            .into_iter()
973            .filter_map(|operation| match operation {
974                CrashOperation::Sync { kind, .. } => Some(kind),
975                _ => None,
976            })
977            .collect::<Vec<_>>();
978        assert_eq!(
979            sync_kinds,
980            vec![SyncKind::Full; 4],
981            "seal must preserve the opened policy's exact SyncKind sequence"
982        );
983        store.close().expect("close");
984    }
985
986    #[test]
987    fn first_durable_ingest_syncs_new_wal_directory_entry_before_ack() {
988        let directory = tempdir().expect("store directory");
989        let recorder = Arc::new(RecordingVfs::new(StdVfs));
990        let store = Store::open_with_test_dependencies(
991            directory.path(),
992            OpenOptions::new().with_durability(DurabilityMode::Durable, CommitTier::Durable),
993            StoreTestDependencies::new(recorder.clone(), Arc::new(ManualMonotonicClock::new())),
994        )
995        .expect("durable store open");
996        let operations_before_ingest = recorder.operations().expect("open operations").len();
997
998        store
999            .ingest(IngestBatch::new(vec![IngestDocument::new(
1000                DocumentVersion::new(DocId::new(1), Revision::new(1)),
1001                vec![1.0, 2.0, 3.0, 4.0],
1002            )]))
1003            .expect("durable ingest ack");
1004
1005        let syncs = recorder
1006            .operations()
1007            .expect("ingest operations")
1008            .into_iter()
1009            .skip(operations_before_ingest)
1010            .filter_map(|operation| match operation {
1011                CrashOperation::Sync { path, kind } => Some((path, kind)),
1012                _ => None,
1013            })
1014            .collect::<Vec<_>>();
1015        assert_eq!(
1016            syncs,
1017            vec![
1018                (directory.path().join("wal.ze"), SyncKind::Full),
1019                (directory.path().to_path_buf(), SyncKind::Full),
1020            ],
1021            "the first durable WAL group must sync its new directory entry before ack"
1022        );
1023        store.close().expect("close");
1024    }
1025}