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, 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#[derive(Debug)]
27pub enum EpochTransitionError {
28 Store(StoreError),
30 EpochUnavailable {
32 target: EpochIdentity,
34 },
35 IncompleteEpoch {
37 target: EpochIdentity,
39 missing_documents: usize,
41 unexpected_documents: usize,
43 },
44 MissingDocumentIdentity {
46 epoch: EpochId,
48 segment_id: SegmentId,
50 row: u32,
52 },
53 EmbeddingEpochUnavailable {
55 target: EpochId,
57 },
58 PublishedEpoch {
60 target: EpochId,
62 },
63 UnsealedWrites {
65 active_rows: usize,
67 absorbed_through: u64,
69 durable_end: u64,
71 },
72 GraphEpochTransition,
74 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#[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 #[must_use]
166 pub const fn generation(self) -> u64 {
167 self.generation
168 }
169
170 #[must_use]
172 pub const fn previous(self) -> EpochIdentity {
173 self.previous
174 }
175
176 #[must_use]
178 pub const fn published(self) -> EpochIdentity {
179 self.published
180 }
181
182 #[must_use]
184 pub const fn manifest_committed(self) -> bool {
185 self.manifest_committed
186 }
187}
188
189#[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 #[must_use]
200 pub const fn generation(&self) -> u64 {
201 self.generation
202 }
203
204 #[must_use]
206 pub fn segments_dropped(&self) -> &[SegmentId] {
207 &self.segments_dropped
208 }
209
210 #[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 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 pub fn drop_epoch(&self, target: EpochId) -> Result<DropEpochReport, EpochTransitionError> {
390 self.drop_epoch_on_vfs(target, self.vfs.as_ref())
391 }
392
393 #[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}