1use 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#[derive(Debug)]
26pub enum EpochTransitionError {
27 Store(StoreError),
29 EpochUnavailable {
31 target: EpochIdentity,
33 },
34 IncompleteEpoch {
36 target: EpochIdentity,
38 missing_documents: usize,
40 unexpected_documents: usize,
42 },
43 MissingDocumentIdentity {
45 epoch: EpochId,
47 segment_id: SegmentId,
49 row: u32,
51 },
52 EmbeddingEpochUnavailable {
54 target: EpochId,
56 },
57 PublishedEpoch {
59 target: EpochId,
61 },
62 UnsealedWrites {
64 active_rows: usize,
66 absorbed_through: u64,
68 durable_end: u64,
70 },
71 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#[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 #[must_use]
161 pub const fn generation(self) -> u64 {
162 self.generation
163 }
164
165 #[must_use]
167 pub const fn previous(self) -> EpochIdentity {
168 self.previous
169 }
170
171 #[must_use]
173 pub const fn published(self) -> EpochIdentity {
174 self.published
175 }
176
177 #[must_use]
179 pub const fn manifest_committed(self) -> bool {
180 self.manifest_committed
181 }
182}
183
184#[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 #[must_use]
195 pub const fn generation(&self) -> u64 {
196 self.generation
197 }
198
199 #[must_use]
201 pub fn segments_dropped(&self) -> &[SegmentId] {
202 &self.segments_dropped
203 }
204
205 #[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 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 pub fn drop_epoch(&self, target: EpochId) -> Result<DropEpochReport, EpochTransitionError> {
367 self.drop_epoch_on_vfs(target, self.vfs.as_ref())
368 }
369
370 #[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}