Skip to main content

zeppelin_embed/epoch/
transition.rs

1//! Atomic published-alias transitions and explicit epoch reclamation.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use crate::lifecycle::durability::SyncRequirement;
7use crate::lifecycle::{PublishedSnapshot, Store, StoreError, StoreState};
8use crate::manifest::io::{MANIFEST_FILE, load_manifest};
9use crate::segment::SegmentId;
10use crate::vfs::Vfs;
11
12use super::{EpochId, EpochIdentity};
13
14struct EpochTransitionAdmission<'a> {
15    active: std::sync::MutexGuard<'a, Option<crate::ingest::ActiveState>>,
16    wal: std::sync::MutexGuard<'a, Option<crate::ingest::StoreWal>>,
17    writer_lock:
18        std::sync::MutexGuard<'a, Option<std::sync::Arc<crate::lifecycle::lock::StoreLock>>>,
19    state: std::sync::MutexGuard<'a, StoreState>,
20    manifest: crate::manifest::Manifest,
21    _maintenance: std::sync::MutexGuard<'a, ()>,
22    _durable_end: u64,
23}
24
25/// Typed rejection from alias switching or explicit epoch reclamation.
26#[derive(Debug)]
27pub enum EpochTransitionError {
28    /// Store lifecycle, persistence, or filesystem work failed.
29    Store(StoreError),
30    /// The requested alias is not a registered epoch with retained segments.
31    EpochUnavailable {
32        /// Alias that cannot be published.
33        target: EpochIdentity,
34    },
35    /// The target epoch does not contain exactly the published live revisions.
36    IncompleteEpoch {
37        /// Alias that was refused before the manifest commit point.
38        target: EpochIdentity,
39        /// Live source revisions absent from the target epoch.
40        missing_documents: usize,
41        /// Live target revisions absent from the source, including duplicates.
42        unexpected_documents: usize,
43    },
44    /// A live epoch row lacks the stable document identity needed for completeness checks.
45    MissingDocumentIdentity {
46        /// Embedding epoch whose row could not be identified.
47        epoch: EpochId,
48        /// Segment containing the unidentified live row.
49        segment_id: SegmentId,
50        /// Local live row with no document/revision value.
51        row: u32,
52    },
53    /// No retained segment belongs to the requested embedding epoch.
54    EmbeddingEpochUnavailable {
55        /// Embedding epoch that cannot be dropped.
56        target: EpochId,
57    },
58    /// The published epoch cannot be dropped while queries name it.
59    PublishedEpoch {
60        /// Embedding epoch that remains published.
61        target: EpochId,
62    },
63    /// A transition was attempted before the active WAL state was sealed.
64    UnsealedWrites {
65        /// Rows still held by the active segment.
66        active_rows: usize,
67        /// Largest sequence already absorbed by the manifest.
68        absorbed_through: u64,
69        /// Largest sequence durable in the WAL.
70        durable_end: u64,
71    },
72    /// Graph catalogs cannot yet carry an epoch alias switch or epoch drop.
73    GraphEpochTransition,
74    /// Summing the reclaimed segment bytes exceeded `u64`.
75    ReclaimedBytesOverflow,
76}
77
78impl std::fmt::Display for EpochTransitionError {
79    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::Store(error) => error.fmt(formatter),
82            Self::EpochUnavailable { target } => write!(
83                formatter,
84                "epoch alias ({}, {}) has no retained segment set",
85                target.embedding, target.tokenizer
86            ),
87            Self::IncompleteEpoch {
88                target,
89                missing_documents,
90                unexpected_documents,
91            } => write!(
92                formatter,
93                "epoch alias ({}, {}) is incomplete: {missing_documents} published revisions missing and {unexpected_documents} unexpected target revisions",
94                target.embedding, target.tokenizer
95            ),
96            Self::MissingDocumentIdentity {
97                epoch,
98                segment_id,
99                row,
100            } => write!(
101                formatter,
102                "embedding epoch {epoch} segment {segment_id} live row {row} has no document identity"
103            ),
104            Self::EmbeddingEpochUnavailable { target } => {
105                write!(
106                    formatter,
107                    "embedding epoch {target} has no retained segments"
108                )
109            }
110            Self::PublishedEpoch { target } => {
111                write!(
112                    formatter,
113                    "published embedding epoch {target} cannot be dropped"
114                )
115            }
116            Self::UnsealedWrites {
117                active_rows,
118                absorbed_through,
119                durable_end,
120            } => write!(
121                formatter,
122                "epoch transition requires a sealed WAL: active rows {active_rows}, manifest absorbed through {absorbed_through}, durable WAL end {durable_end}"
123            ),
124            Self::GraphEpochTransition => formatter.write_str("graph stores cannot switch or drop epochs until graph catalogs can carry the transition"),
125            Self::ReclaimedBytesOverflow => {
126                formatter.write_str("dropped epoch segment bytes exceed u64")
127            }
128        }
129    }
130}
131
132impl std::error::Error for EpochTransitionError {
133    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
134        match self {
135            Self::Store(error) => Some(error),
136            Self::EpochUnavailable { .. }
137            | Self::IncompleteEpoch { .. }
138            | Self::MissingDocumentIdentity { .. }
139            | Self::EmbeddingEpochUnavailable { .. }
140            | Self::PublishedEpoch { .. }
141            | Self::UnsealedWrites { .. }
142            | Self::GraphEpochTransition
143            | Self::ReclaimedBytesOverflow => None,
144        }
145    }
146}
147
148impl From<StoreError> for EpochTransitionError {
149    fn from(error: StoreError) -> Self {
150        Self::Store(error)
151    }
152}
153
154/// Result of one atomic published-alias transition.
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub struct EpochAliasReport {
157    generation: u64,
158    previous: EpochIdentity,
159    published: EpochIdentity,
160    manifest_committed: bool,
161}
162
163impl EpochAliasReport {
164    /// Returns the committed or unchanged generation.
165    #[must_use]
166    pub const fn generation(self) -> u64 {
167        self.generation
168    }
169
170    /// Returns the alias visible before this call.
171    #[must_use]
172    pub const fn previous(self) -> EpochIdentity {
173        self.previous
174    }
175
176    /// Returns the alias visible after this call.
177    #[must_use]
178    pub const fn published(self) -> EpochIdentity {
179        self.published
180    }
181
182    /// Returns whether this call crossed the manifest commit point.
183    #[must_use]
184    pub const fn manifest_committed(self) -> bool {
185        self.manifest_committed
186    }
187}
188
189/// Result of one explicit embedding-epoch drop.
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct DropEpochReport {
192    generation: u64,
193    segments_dropped: Vec<SegmentId>,
194    bytes_reclaimed: u64,
195}
196
197impl DropEpochReport {
198    /// Returns the generation committed without the dropped segments.
199    #[must_use]
200    pub const fn generation(&self) -> u64 {
201        self.generation
202    }
203
204    /// Returns the segment ids omitted by the committed manifest.
205    #[must_use]
206    pub fn segments_dropped(&self) -> &[SegmentId] {
207        &self.segments_dropped
208    }
209
210    /// Returns the exact segment-file bytes unlinked after commit.
211    #[must_use]
212    pub const fn bytes_reclaimed(&self) -> u64 {
213        self.bytes_reclaimed
214    }
215}
216
217impl Store {
218    fn admit_epoch_transition<'a>(
219        &'a self,
220        vfs: &dyn Vfs,
221        component: &'static str,
222    ) -> Result<EpochTransitionAdmission<'a>, EpochTransitionError> {
223        let maintenance = self
224            .maintenance
225            .lock()
226            .map_err(|_| StoreError::Synchronization { component })?;
227        let state = self
228            .state
229            .lock()
230            .map_err(|_| StoreError::Synchronization { component: "state" })?;
231        match *state {
232            StoreState::Open => {}
233            StoreState::Closing => return Err(StoreError::Closing.into()),
234            StoreState::Closed => return Err(StoreError::Closed.into()),
235        }
236        let writer_lock = self
237            .writer_lock
238            .lock()
239            .map_err(|_| StoreError::Synchronization {
240                component: "writer lock",
241            })?;
242        if writer_lock.is_none() {
243            return Err(StoreError::ReadOnly.into());
244        }
245        let wal = self
246            .wal_writer
247            .lock()
248            .map_err(|_| StoreError::Synchronization {
249                component: "WAL writer",
250            })?;
251        wal.as_ref()
252            .ok_or(StoreError::ReadOnly)?
253            .manifest_publication()?
254            .complete();
255        let durable_end = wal.as_ref().ok_or(StoreError::ReadOnly)?.durable_end();
256        let mut active = self
257            .active
258            .lock()
259            .map_err(|_| StoreError::Synchronization {
260                component: "active segment",
261            })?;
262        let active_state = active.as_mut().ok_or(StoreError::Closed)?;
263        let manifest_path = self.directory.join(MANIFEST_FILE);
264        let manifest =
265            load_manifest(vfs, &manifest_path, durable_end).map_err(StoreError::Manifest)?;
266        #[cfg(feature = "graph-cypher")]
267        if manifest.graph.is_some() {
268            return Err(EpochTransitionError::GraphEpochTransition);
269        }
270        require_absorbed(active_state.segment.row_count(), &manifest, durable_end)?;
271        Ok(EpochTransitionAdmission {
272            active,
273            wal,
274            writer_lock,
275            state,
276            manifest,
277            _maintenance: maintenance,
278            _durable_end: durable_end,
279        })
280    }
281
282    /// Atomically publishes a registered epoch whose segments are retained.
283    pub fn switch_epoch_alias(
284        &self,
285        target: EpochIdentity,
286    ) -> Result<EpochAliasReport, EpochTransitionError> {
287        self.switch_epoch_alias_on_vfs(target, self.vfs.as_ref())
288    }
289
290    fn switch_epoch_alias_on_vfs(
291        &self,
292        target: EpochIdentity,
293        vfs: &dyn Vfs,
294    ) -> Result<EpochAliasReport, EpochTransitionError> {
295        let EpochTransitionAdmission {
296            _maintenance,
297            state,
298            writer_lock,
299            wal,
300            mut active,
301            _durable_end: _,
302            mut manifest,
303        } = self.admit_epoch_transition(vfs, "epoch transition")?;
304        let active_state = active.as_mut().ok_or(StoreError::Closed)?;
305        let previous = manifest
306            .epoch_alias
307            .ok_or(EpochTransitionError::EpochUnavailable { target })?;
308        if previous == target {
309            return Ok(EpochAliasReport {
310                generation: active_state.generation.max(manifest.generation),
311                previous,
312                published: target,
313                manifest_committed: false,
314            });
315        }
316        let registered = manifest
317            .epochs
318            .iter()
319            .any(|epoch| epoch.id == target.embedding && epoch.tokenizer == target.tokenizer);
320        let retained = manifest
321            .segments
322            .iter()
323            .any(|segment| segment.epoch_id == Some(target.embedding));
324        if !registered || !retained {
325            return Err(EpochTransitionError::EpochUnavailable { target });
326        }
327        let generation = active_state
328            .generation
329            .max(manifest.generation)
330            .checked_add(1)
331            .ok_or(StoreError::GenerationOverflow)?;
332        manifest.generation = generation;
333        #[cfg(feature = "graph-cypher")]
334        manifest
335            .record_generation_bump(manifest.log_seq)
336            .map_err(StoreError::Manifest)?;
337        manifest.epoch_alias = Some(target);
338        let remapped =
339            PublishedSnapshot::from_manifest(vfs, &self.directory, &manifest, &self.accounting)?;
340        let source_documents = live_document_versions(&remapped, previous.embedding)?;
341        let target_documents = live_document_versions(&remapped, target.embedding)?;
342        let missing_documents = multiset_difference(&source_documents, &target_documents);
343        let unexpected_documents = multiset_difference(&target_documents, &source_documents);
344        if missing_documents != 0 || unexpected_documents != 0 {
345            return Err(EpochTransitionError::IncompleteEpoch {
346                target,
347                missing_documents,
348                unexpected_documents,
349            });
350        }
351        let mut publication = wal
352            .as_ref()
353            .ok_or(StoreError::ReadOnly)?
354            .manifest_publication()?;
355        publication
356            .commit_manifest(vfs, &self.directory, &manifest, self.durability_policy)
357            .map_err(StoreError::Manifest)?;
358        let mut published = self
359            .snapshot
360            .write()
361            .map_err(|_| StoreError::Synchronization {
362                component: "published snapshot",
363            })?;
364        let prior_snapshot = published.replace(Arc::new(remapped));
365        active_state.generation = generation;
366        drop(published);
367        if let Some(snapshot) = prior_snapshot.as_ref() {
368            let drain_result = snapshot.drain_readers(self.reader_drain_timeout);
369            self.epoch_alias.store(Some(target));
370            drain_result?;
371        } else {
372            self.epoch_alias.store(Some(target));
373        }
374        publication.complete();
375        drop(prior_snapshot);
376        drop(active);
377        drop(wal);
378        drop(writer_lock);
379        drop(state);
380        Ok(EpochAliasReport {
381            generation,
382            previous,
383            published: target,
384            manifest_committed: true,
385        })
386    }
387
388    /// Explicitly drops every immutable segment belonging to an old epoch.
389    pub fn drop_epoch(&self, target: EpochId) -> Result<DropEpochReport, EpochTransitionError> {
390        self.drop_epoch_on_vfs(target, self.vfs.as_ref())
391    }
392
393    /// Test-support seam for observing the commit-then-unlink protocol.
394    #[doc(hidden)]
395    pub fn drop_epoch_on_vfs(
396        &self,
397        target: EpochId,
398        vfs: &dyn Vfs,
399    ) -> Result<DropEpochReport, EpochTransitionError> {
400        let EpochTransitionAdmission {
401            _maintenance,
402            state,
403            writer_lock,
404            wal,
405            mut active,
406            _durable_end: _,
407            mut manifest,
408        } = self.admit_epoch_transition(vfs, "drop epoch")?;
409        let active_state = active.as_mut().ok_or(StoreError::Closed)?;
410        self.require_no_snapshot_views()?;
411        if manifest
412            .epoch_alias
413            .is_some_and(|identity| identity.embedding == target)
414        {
415            return Err(EpochTransitionError::PublishedEpoch { target });
416        }
417        let mut retained = Vec::with_capacity(manifest.segments.len());
418        let mut dropped = Vec::new();
419        let mut bytes_reclaimed = 0_u64;
420        for segment in manifest.segments {
421            if segment.epoch_id == Some(target) {
422                bytes_reclaimed = bytes_reclaimed
423                    .checked_add(segment.file_size)
424                    .ok_or(EpochTransitionError::ReclaimedBytesOverflow)?;
425                dropped.push(segment);
426            } else {
427                retained.push(segment);
428            }
429        }
430        if dropped.is_empty() {
431            return Err(EpochTransitionError::EmbeddingEpochUnavailable { target });
432        }
433        let generation = active_state
434            .generation
435            .max(manifest.generation)
436            .checked_add(1)
437            .ok_or(StoreError::GenerationOverflow)?;
438        manifest.generation = generation;
439        manifest.segments = retained;
440        #[cfg(feature = "graph-cypher")]
441        manifest
442            .record_generation_bump(manifest.log_seq)
443            .map_err(StoreError::Manifest)?;
444        let remapped =
445            PublishedSnapshot::from_manifest(vfs, &self.directory, &manifest, &self.accounting)?;
446        let mut publication = wal
447            .as_ref()
448            .ok_or(StoreError::ReadOnly)?
449            .manifest_publication()?;
450        publication
451            .commit_manifest(vfs, &self.directory, &manifest, self.durability_policy)
452            .map_err(StoreError::Manifest)?;
453        let mut published = self
454            .snapshot
455            .write()
456            .map_err(|_| StoreError::Synchronization {
457                component: "published snapshot",
458            })?;
459        let prior_snapshot = published.replace(Arc::new(remapped));
460        active_state.generation = generation;
461        publication.complete();
462        drop(published);
463        drop(prior_snapshot);
464
465        for segment in &dropped {
466            let path = self.directory.join(segment.id.file_name());
467            vfs.delete(&path)
468                .map_err(|source| StoreError::Io { path, source })?;
469        }
470        if let SyncRequirement::Sync(kind) = self.durability_policy.directory_sync() {
471            vfs.sync(&self.directory, kind)
472                .map_err(|source| StoreError::Io {
473                    path: self.directory.clone(),
474                    source,
475                })?;
476        }
477        let segments_dropped = dropped.iter().map(|segment| segment.id).collect();
478        drop(active);
479        drop(wal);
480        drop(writer_lock);
481        drop(state);
482        Ok(DropEpochReport {
483            generation,
484            segments_dropped,
485            bytes_reclaimed,
486        })
487    }
488}
489
490fn require_absorbed(
491    active_rows: usize,
492    manifest: &crate::manifest::Manifest,
493    durable_end: u64,
494) -> Result<(), EpochTransitionError> {
495    if active_rows != 0 || manifest.log_seq != durable_end {
496        return Err(EpochTransitionError::UnsealedWrites {
497            active_rows,
498            absorbed_through: manifest.log_seq,
499            durable_end,
500        });
501    }
502    Ok(())
503}
504
505fn live_document_versions(
506    snapshot: &PublishedSnapshot,
507    epoch: EpochId,
508) -> Result<BTreeMap<(u128, u64), usize>, EpochTransitionError> {
509    let mut documents = BTreeMap::new();
510    for segment in snapshot
511        .all_segments()
512        .iter()
513        .filter(|segment| segment.meta().epoch_id == Some(epoch))
514    {
515        let alive = segment.alive().map_err(StoreError::Segment)?;
516        for row in alive.iter_alive() {
517            let version = segment
518                .document_version(row as usize)
519                .map_err(StoreError::Segment)?
520                .ok_or(EpochTransitionError::MissingDocumentIdentity {
521                    epoch,
522                    segment_id: segment.meta().id,
523                    row,
524                })?;
525            let key = (version.doc_id().get(), version.revision().get());
526            let count = documents.entry(key).or_insert(0_usize);
527            *count = count.saturating_add(1);
528        }
529    }
530    Ok(documents)
531}
532
533fn multiset_difference(
534    left: &BTreeMap<(u128, u64), usize>,
535    right: &BTreeMap<(u128, u64), usize>,
536) -> usize {
537    left.iter().fold(0_usize, |difference, (document, count)| {
538        difference.saturating_add(count.saturating_sub(*right.get(document).unwrap_or(&0)))
539    })
540}