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