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:
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 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#[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 #[must_use]
162 pub const fn generation(self) -> u64 {
163 self.generation
164 }
165
166 #[must_use]
168 pub const fn previous(self) -> EpochIdentity {
169 self.previous
170 }
171
172 #[must_use]
174 pub const fn published(self) -> EpochIdentity {
175 self.published
176 }
177
178 #[must_use]
180 pub const fn manifest_committed(self) -> bool {
181 self.manifest_committed
182 }
183}
184
185#[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 #[must_use]
196 pub const fn generation(&self) -> u64 {
197 self.generation
198 }
199
200 #[must_use]
202 pub fn segments_dropped(&self) -> &[SegmentId] {
203 &self.segments_dropped
204 }
205
206 #[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 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 pub fn drop_epoch(&self, target: EpochId) -> Result<DropEpochReport, EpochTransitionError> {
368 self.drop_epoch_on_vfs(target, self.vfs.as_ref())
369 }
370
371 #[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}