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