1use std::path::Path;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::graph::GraphParamsError;
8use crate::graph::build::{
9 CheckpointedGraphBuild, GraphBuildError, GraphBuildPasses, GraphRewriteRegion,
10 build_graph_checkpointed, graph_rewrite_source_region,
11};
12use crate::graph::consolidate::{
13 ConsolidateError, ConsolidationCheckpoint, consolidation_merge_id, consolidation_output_id,
14 consolidation_supports_region, merge_segments, merged_clustering,
15 read_consolidation_checkpoint, remove_consolidation_checkpoint, write_consolidation_checkpoint,
16};
17use crate::graph::refine::{
18 CheckpointedRefinement, RefinementError, RefinementPass, refine_graph_checkpointed,
19};
20use crate::lifecycle::{
21 Deadline, DeadlineError, PublishedSnapshot, QueryCancellation, QueryControl, SnapshotLease,
22 Store, StoreError,
23};
24use crate::manifest::io::{MANIFEST_FILE, commit_manifest, load_manifest};
25use crate::segment::layout::RegionKind;
26use crate::segment::reader::SegmentReader;
27use crate::segment::{ClusteringKeyRange, SegmentError, SegmentId};
28use crate::vfs::Vfs;
29
30use super::SegmentTier;
31use super::TierThresholds;
32use super::policy::{
33 RefinementPlan, SegmentStats, StorePlan, StoreStats, TierPlan, decide, decide_refinement,
34 decide_store, decide_with_thresholds,
35};
36
37const MAINTENANCE_SEED: u64 = 0x20_00c0_ffee;
38const MAINTENANCE_CHECKPOINT_ROWS: u64 = 64;
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct MaintenanceBudget {
43 pub wall_time: Duration,
45 pub bytes: u64,
47}
48
49#[derive(Debug)]
51pub enum MaintenanceStatus {
52 Complete,
54 BudgetExhausted,
56 Failed(MaintenanceError),
58}
59
60#[derive(Debug, Eq, PartialEq)]
62pub struct MaintenanceDeferral {
63 pub segment_id: SegmentId,
65 pub uncarried_regions: Vec<UncarriedRegionKind>,
67}
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub enum UncarriedRegionKind {
72 Known(RegionKind),
74 Unknown(u16),
76}
77
78#[derive(Debug)]
80pub enum MaintenanceError {
81 Store(StoreError),
83 Parameters(GraphParamsError),
85 Graph(GraphBuildError),
87 Consolidate(ConsolidateError),
89 Deadline(DeadlineError),
91 ArithmeticOverflow,
93}
94
95impl std::fmt::Display for MaintenanceError {
96 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 Self::Store(error) => write!(formatter, "tier maintenance store: {error}"),
99 Self::Parameters(error) => write!(formatter, "tier maintenance parameters: {error}"),
100 Self::Graph(error) => write!(formatter, "tier maintenance graph: {error}"),
101 Self::Consolidate(error) => {
102 write!(formatter, "tier maintenance consolidation: {error}")
103 }
104 Self::Deadline(error) => write!(formatter, "tier maintenance deadline: {error}"),
105 Self::ArithmeticOverflow => {
106 formatter.write_str("tier maintenance byte arithmetic overflow")
107 }
108 }
109 }
110}
111
112impl std::error::Error for MaintenanceError {
113 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
114 match self {
115 Self::Store(error) => Some(error),
116 Self::Parameters(error) => Some(error),
117 Self::Graph(error) => Some(error),
118 Self::Consolidate(error) => Some(error),
119 Self::Deadline(error) => Some(error),
120 Self::ArithmeticOverflow => None,
121 }
122 }
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub struct GraphBuildProfileReport {
128 pub segment_id: SegmentId,
130 pub epoch: crate::epoch::EpochIdentity,
132 pub profile: crate::graph::search::EpochGraphProfile,
134}
135
136#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
138pub struct RefinementPassCounters {
139 pub renumber: u64,
141 pub alpha_reprune: u64,
143 pub seed_refit: u64,
145 pub neighbor_reorder: u64,
147 pub connectivity_repair: u64,
149}
150
151impl RefinementPassCounters {
152 fn increment(&mut self, pass: RefinementPass) -> Result<(), MaintenanceError> {
153 let counter = match pass {
154 RefinementPass::Renumber => &mut self.renumber,
155 RefinementPass::AlphaReprune => &mut self.alpha_reprune,
156 RefinementPass::SeedRefit => &mut self.seed_refit,
157 RefinementPass::NeighborReorder => &mut self.neighbor_reorder,
158 RefinementPass::ConnectivityRepair => &mut self.connectivity_repair,
159 };
160 *counter = counter
161 .checked_add(1)
162 .ok_or(MaintenanceError::ArithmeticOverflow)?;
163 Ok(())
164 }
165}
166
167#[derive(Debug)]
169pub struct MaintenanceReport {
170 pub graphs_built: u64,
172 pub bytes_consumed: u64,
174 pub checkpoints_resumed: u64,
176 pub promotion_deferrals: Vec<MaintenanceDeferral>,
178 pub graph_profiles: Vec<GraphBuildProfileReport>,
180 pub consolidations: u64,
182 pub consolidation_generation: Option<u64>,
184 pub consolidation_deferrals: Vec<MaintenanceDeferral>,
186 pub passes_applied: u64,
188 pub pass_counters: RefinementPassCounters,
190 pub refinement_generation: Option<u64>,
192 pub status: MaintenanceStatus,
194}
195
196impl Store {
197 #[must_use]
199 pub fn maintain(&self, budget: MaintenanceBudget) -> MaintenanceReport {
200 self.maintain_with_optional_thresholds(budget, None)
201 }
202
203 #[doc(hidden)]
205 #[must_use]
206 pub fn maintain_with_test_thresholds(
207 &self,
208 budget: MaintenanceBudget,
209 thresholds: TierThresholds,
210 ) -> MaintenanceReport {
211 self.maintain_with_optional_thresholds(budget, Some(thresholds))
212 }
213
214 fn maintain_with_optional_thresholds(
215 &self,
216 budget: MaintenanceBudget,
217 thresholds: Option<TierThresholds>,
218 ) -> MaintenanceReport {
219 let report = match maintain_one(self, budget, thresholds) {
220 Ok(report) => report,
221 Err(error) => empty_report(MaintenanceStatus::Failed(error)),
222 };
223 if let Err(error) = self.record_maintenance(&report) {
224 return MaintenanceReport {
225 status: MaintenanceStatus::Failed(MaintenanceError::Store(error)),
226 ..report
227 };
228 }
229 report
230 }
231}
232
233fn empty_report(status: MaintenanceStatus) -> MaintenanceReport {
234 MaintenanceReport {
235 graphs_built: 0,
236 bytes_consumed: 0,
237 checkpoints_resumed: 0,
238 promotion_deferrals: Vec::new(),
239 graph_profiles: Vec::new(),
240 consolidations: 0,
241 consolidation_generation: None,
242 consolidation_deferrals: Vec::new(),
243 passes_applied: 0,
244 pass_counters: RefinementPassCounters::default(),
245 refinement_generation: None,
246 status,
247 }
248}
249
250fn admit_maintenance_rows(
251 segment: &SegmentReader,
252 params: crate::graph::GraphParams,
253 remaining_bytes: u64,
254) -> Result<(u64, u64), MaintenanceError> {
255 let stride = graph_work_stride(segment, params)?;
256 let maximum_rows = remaining_bytes / stride;
257 let admitted_rows = if maximum_rows >= u64::from(segment.meta().row_count) {
258 u64::from(segment.meta().row_count)
259 } else {
260 maximum_rows / MAINTENANCE_CHECKPOINT_ROWS * MAINTENANCE_CHECKPOINT_ROWS
261 };
262 Ok((stride, admitted_rows))
263}
264
265fn probe_checkpoint_resume(
266 vfs: &dyn Vfs,
267 path: std::path::PathBuf,
268) -> Result<(std::path::PathBuf, bool), MaintenanceError> {
269 match vfs.open(&path) {
270 Ok(_) => Ok((path, true)),
271 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok((path, false)),
272 Err(source) => Err(MaintenanceError::Graph(GraphBuildError::CheckpointIo {
273 path,
274 source,
275 })),
276 }
277}
278
279fn maintain_one(
280 store: &Store,
281 budget: MaintenanceBudget,
282 thresholds: Option<TierThresholds>,
283) -> Result<MaintenanceReport, MaintenanceError> {
284 if budget.wall_time.is_zero() || budget.bytes == 0 {
285 return Ok(empty_report(MaintenanceStatus::BudgetExhausted));
286 }
287 let _maintenance = store.maintenance.lock().map_err(|_| {
288 MaintenanceError::Store(StoreError::Synchronization {
289 component: "tier maintenance",
290 })
291 })?;
292 let lease = store.snapshot().map_err(MaintenanceError::Store)?;
293 {
294 let writer = store.writer_lock.lock().map_err(|_| {
295 MaintenanceError::Store(StoreError::Synchronization {
296 component: "writer lock",
297 })
298 })?;
299 if writer.is_none() {
300 return Err(MaintenanceError::Store(StoreError::ReadOnly));
301 }
302 }
303 let build_time = budget.wall_time.mul_f64(0.9);
304 if build_time.is_zero() {
305 return Ok(empty_report(MaintenanceStatus::BudgetExhausted));
306 }
307 let deadline = Deadline::after_with_clock(build_time, Arc::clone(&store.clock))
310 .map_err(MaintenanceError::Deadline)?;
311 let control = QueryControl::Deadline(deadline);
312 let mut report = empty_report(MaintenanceStatus::Complete);
313 let mut selected_profile = None;
314 for segment in lease
315 .segments()
316 .iter()
317 .filter(|segment| transition_due(segment, thresholds))
318 {
319 if let Some(deferral) = promotion_deferral(segment) {
320 report.promotion_deferrals.push(deferral);
321 continue;
322 }
323 let (epoch, profile) = match selected_profile {
324 Some(selected) => selected,
325 None => {
326 let epoch = lease.epoch_alias().ok_or(MaintenanceError::Graph(
327 GraphBuildError::Profile(
328 crate::graph::search::GraphProfileError::EpochUnstamped,
329 ),
330 ))?;
331 let profile = lease
332 .graph_profile()
333 .map_err(GraphBuildError::Profile)
334 .map_err(MaintenanceError::Graph)?;
335 selected_profile = Some((epoch, profile));
336 (epoch, profile)
337 }
338 };
339 let params = profile
340 .build_params()
341 .with_checkpoint_batch_rows(MAINTENANCE_CHECKPOINT_ROWS as u32)
342 .map_err(MaintenanceError::Parameters)?;
343 let remaining_bytes = budget.bytes.saturating_sub(report.bytes_consumed);
344 let (stride, admitted_rows) = admit_maintenance_rows(segment, params, remaining_bytes)?;
345 if admitted_rows == 0 {
346 report.status = MaintenanceStatus::BudgetExhausted;
347 return Ok(report);
348 }
349 let (checkpoint, checkpoint_resumed) = probe_checkpoint_resume(
350 store.vfs.as_ref(),
351 checkpoint_path(&store.directory, segment.meta().id),
352 )?;
353 report.checkpoints_resumed = report
354 .checkpoints_resumed
355 .checked_add(u64::from(checkpoint_resumed))
356 .ok_or(MaintenanceError::ArithmeticOverflow)?;
357 let artifact = match build_graph_checkpointed(
358 store,
359 segment,
360 CheckpointedGraphBuild::new(
361 params,
362 MAINTENANCE_SEED,
363 GraphBuildPasses::One,
364 &checkpoint,
365 &control,
366 )
367 .with_max_work_rows(admitted_rows),
368 &lease,
369 ) {
370 Ok(artifact) => artifact,
371 Err(GraphBuildError::BudgetExhausted { rows_completed }) => {
372 let consumed = rows_completed
373 .checked_mul(stride)
374 .ok_or(MaintenanceError::ArithmeticOverflow)?;
375 report.bytes_consumed = report
376 .bytes_consumed
377 .checked_add(consumed)
378 .ok_or(MaintenanceError::ArithmeticOverflow)?;
379 report.status = MaintenanceStatus::BudgetExhausted;
380 return Ok(report);
381 }
382 Err(GraphBuildError::Timeout { .. }) => {
383 report.status = MaintenanceStatus::BudgetExhausted;
384 return Ok(report);
385 }
386 Err(error) => return Err(MaintenanceError::Graph(error)),
387 };
388 let consumed = artifact
389 .work_rows_completed()
390 .checked_mul(stride)
391 .and_then(|bytes| bytes.checked_add(crate::graph::block::NODE_BLOCK_TRAILER_LEN as u64))
392 .ok_or(MaintenanceError::ArithmeticOverflow)?;
393 report.bytes_consumed = report
394 .bytes_consumed
395 .checked_add(consumed)
396 .ok_or(MaintenanceError::ArithmeticOverflow)?;
397 let output_id = graph_segment_id(segment.meta().id, lease.generation());
398 let meta = artifact
399 .write_segment_with_graph(
400 store.vfs.as_ref(),
401 &store.directory,
402 segment,
403 output_id,
404 store.durability_policy,
405 )
406 .map_err(MaintenanceError::Graph)?;
407 if publish_transition(store, segment.meta().id, meta)? {
408 report.graphs_built = report
409 .graphs_built
410 .checked_add(1)
411 .ok_or(MaintenanceError::ArithmeticOverflow)?;
412 report.graph_profiles.push(GraphBuildProfileReport {
413 segment_id: output_id,
414 epoch,
415 profile,
416 });
417 }
418 }
419 drop(lease);
420 maintain_consolidation(store, budget, thresholds, &control, &mut report)?;
421 if matches!(report.status, MaintenanceStatus::Complete) {
422 maintain_refinements(store, budget, &control, &mut report)?;
423 }
424 Ok(report)
425}
426
427fn maintain_refinements(
428 store: &Store,
429 budget: MaintenanceBudget,
430 control: &QueryControl,
431 report: &mut MaintenanceReport,
432) -> Result<(), MaintenanceError> {
433 loop {
434 let lease = store.snapshot().map_err(MaintenanceError::Store)?;
435 let selected_profile = lease.graph_profile();
436 let mut due = None;
437 for segment in lease.segments().iter().filter(|segment| has_graph(segment)) {
438 let graph = segment
439 .graph_node_blocks()
440 .map_err(StoreError::Segment)
441 .map_err(MaintenanceError::Store)?;
442 let pass = match decide_refinement(graph.refinement_passes()) {
443 RefinementPlan::Apply(pass) => Some(pass),
444 RefinementPlan::Stay
445 if selected_profile
446 == Ok(crate::graph::search::EpochGraphProfile::AngularClass)
447 && !graph
448 .refinement_passes()
449 .contains(RefinementPass::ConnectivityRepair) =>
450 {
451 Some(RefinementPass::ConnectivityRepair)
452 }
453 RefinementPlan::Stay => None,
454 };
455 if let Some(pass) = pass {
456 due = Some((segment, pass));
457 break;
458 }
459 }
460 let Some((segment, pass)) = due else {
461 return Ok(());
462 };
463 let profile = selected_profile
464 .map_err(GraphBuildError::Profile)
465 .map_err(MaintenanceError::Graph)?;
466 let params = profile
467 .build_params()
468 .with_checkpoint_batch_rows(MAINTENANCE_CHECKPOINT_ROWS as u32)
469 .map_err(MaintenanceError::Parameters)?;
470 let remaining = budget.bytes.saturating_sub(report.bytes_consumed);
471 let (stride, total_work_rows) = refinement_work(segment, pass)?;
472 let maximum_rows = remaining / stride;
473 let admitted_rows = if maximum_rows >= total_work_rows {
474 total_work_rows
475 } else {
476 maximum_rows / MAINTENANCE_CHECKPOINT_ROWS * MAINTENANCE_CHECKPOINT_ROWS
477 };
478 if admitted_rows == 0 {
479 report.status = MaintenanceStatus::BudgetExhausted;
480 return Ok(());
481 }
482 let checkpoint = refinement_checkpoint_path(&store.directory, segment.meta().id, pass);
483 let (_, resumed) = probe_checkpoint_resume(store.vfs.as_ref(), checkpoint.clone())?;
484 report.checkpoints_resumed = report
485 .checkpoints_resumed
486 .checked_add(u64::from(resumed))
487 .ok_or(MaintenanceError::ArithmeticOverflow)?;
488 let output_id = refinement_segment_id(segment.meta().id, lease.generation(), pass);
489 let artifact = match refine_graph_checkpointed(
490 store,
491 segment,
492 CheckpointedRefinement::new(
493 pass,
494 params,
495 MAINTENANCE_SEED,
496 lease.generation(),
497 &checkpoint,
498 control,
499 )
500 .with_max_work_rows(admitted_rows),
501 &lease,
502 ) {
503 Ok(artifact) => artifact,
504 Err(RefinementError::Graph(GraphBuildError::BudgetExhausted { rows_completed })) => {
505 report.bytes_consumed = report
506 .bytes_consumed
507 .checked_add(
508 rows_completed
509 .checked_mul(stride)
510 .ok_or(MaintenanceError::ArithmeticOverflow)?,
511 )
512 .ok_or(MaintenanceError::ArithmeticOverflow)?;
513 report.status = MaintenanceStatus::BudgetExhausted;
514 return Ok(());
515 }
516 Err(RefinementError::Graph(GraphBuildError::Timeout { .. })) => {
517 report.status = MaintenanceStatus::BudgetExhausted;
518 return Ok(());
519 }
520 Err(error) => return Err(refinement_maintenance_error(error)),
521 };
522 let meta = artifact
523 .write_segment(
524 store.vfs.as_ref(),
525 &store.directory,
526 segment,
527 output_id,
528 &store.tokenizer,
529 store.durability_policy,
530 )
531 .map_err(refinement_maintenance_error)?;
532 let published = publish_transition(store, segment.meta().id, meta)?;
533 drop(lease);
534 if !published {
535 let path = store.directory.join(output_id.file_name());
536 match store.vfs.delete(&path) {
537 Ok(()) => {}
538 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
539 Err(source) => {
540 return Err(MaintenanceError::Store(StoreError::Io { path, source }));
541 }
542 }
543 return Ok(());
544 }
545 let consumed = artifact
546 .work_rows_completed()
547 .checked_mul(stride)
548 .ok_or(MaintenanceError::ArithmeticOverflow)?;
549 report.bytes_consumed = report
550 .bytes_consumed
551 .checked_add(consumed)
552 .ok_or(MaintenanceError::ArithmeticOverflow)?;
553 report.passes_applied = report
554 .passes_applied
555 .checked_add(1)
556 .ok_or(MaintenanceError::ArithmeticOverflow)?;
557 report.pass_counters.increment(pass)?;
558 let generation = current_generation(store).map_err(MaintenanceError::Store)?;
559 report.refinement_generation = Some(generation);
560 if report.consolidation_generation.is_some() {
561 report.consolidation_generation = Some(generation);
562 }
563 }
564}
565
566fn refinement_maintenance_error(error: RefinementError) -> MaintenanceError {
567 match error {
568 RefinementError::Graph(error) => MaintenanceError::Graph(error),
569 RefinementError::NodeBlock(error) => {
570 MaintenanceError::Graph(GraphBuildError::NodeBlock(error))
571 }
572 RefinementError::Segment(error) => MaintenanceError::Store(StoreError::Segment(error)),
573 RefinementError::Store(error) => MaintenanceError::Store(error),
574 error @ (RefinementError::AlreadyApplied(_) | RefinementError::Geometry(_)) => {
575 MaintenanceError::Graph(GraphBuildError::Geometry(error.to_string()))
576 }
577 }
578}
579
580fn refinement_work(
581 segment: &SegmentReader,
582 pass: RefinementPass,
583) -> Result<(u64, u64), MaintenanceError> {
584 let node_count = u64::from(segment.meta().row_count);
585 match pass {
586 RefinementPass::Renumber => Ok((
587 segment
588 .meta()
589 .file_size
590 .checked_add(node_count.saturating_sub(1))
591 .ok_or(MaintenanceError::ArithmeticOverflow)?
592 / node_count.max(1),
593 node_count,
594 )),
595 RefinementPass::SeedRefit => {
596 let row_bytes = u64::from(segment.meta().dims)
597 .checked_mul(std::mem::size_of::<f32>() as u64)
598 .ok_or(MaintenanceError::ArithmeticOverflow)?;
599 Ok((
600 row_bytes
601 .checked_mul(node_count)
602 .ok_or(MaintenanceError::ArithmeticOverflow)?,
603 node_count
604 .checked_mul(node_count.min(4))
605 .ok_or(MaintenanceError::ArithmeticOverflow)?,
606 ))
607 }
608 RefinementPass::AlphaReprune | RefinementPass::NeighborReorder => {
609 let graph = segment
610 .graph_node_blocks()
611 .map_err(StoreError::Segment)
612 .map_err(MaintenanceError::Store)?;
613 Ok((u64::from(graph.layout().stride()), node_count))
614 }
615 RefinementPass::ConnectivityRepair => {
616 let graph = segment
617 .graph_node_blocks()
618 .map_err(StoreError::Segment)
619 .map_err(MaintenanceError::Store)?;
620 Ok((
621 u64::from(graph.layout().stride()),
622 node_count
623 .checked_mul(2)
624 .ok_or(MaintenanceError::ArithmeticOverflow)?,
625 ))
626 }
627 }
628}
629
630fn refinement_checkpoint_path(
631 directory: &Path,
632 segment_id: SegmentId,
633 pass: RefinementPass,
634) -> std::path::PathBuf {
635 directory.join(format!(
636 ".tier-{segment_id}.{}.refine.checkpoint",
637 pass.label()
638 ))
639}
640
641fn refinement_segment_id(source: SegmentId, generation: u64, pass: RefinementPass) -> SegmentId {
642 let mut bytes = [0_u8; 16];
643 if let Some(prefix) = bytes.get_mut(..8) {
644 prefix.copy_from_slice(&generation.saturating_add(1).to_be_bytes());
645 }
646 let hash = xxhash_rust::xxh3::xxh3_64_with_seed(source.as_bytes(), pass as u64).to_be_bytes();
647 if let Some(suffix) = bytes.get_mut(8..) {
648 suffix.copy_from_slice(&hash);
649 }
650 SegmentId::from_bytes(bytes)
651}
652
653fn current_generation(store: &Store) -> Result<u64, StoreError> {
654 store.snapshot().map(|snapshot| snapshot.generation())
655}
656
657fn maintain_consolidation(
666 store: &Store,
667 budget: MaintenanceBudget,
668 thresholds: Option<TierThresholds>,
669 control: &QueryControl,
670 report: &mut MaintenanceReport,
671) -> Result<(), MaintenanceError> {
672 let lease = store.snapshot().map_err(MaintenanceError::Store)?;
673 let plan = decide_store(consolidation_store_stats(lease.segments(), thresholds)?);
674 let mut inputs = match plan {
675 StorePlan::Consolidate => lease
676 .segments()
677 .iter()
678 .filter(|segment| has_graph(segment))
679 .collect::<Vec<_>>(),
680 StorePlan::ConsolidateScan => lease
681 .segments()
682 .iter()
683 .filter(|segment| !has_graph(segment))
684 .collect::<Vec<_>>(),
685 StorePlan::Stay => {
686 clear_stale_consolidation(store)?;
687 return Ok(());
688 }
689 };
690 inputs.sort_by(|left, right| left.meta().id.as_bytes().cmp(right.meta().id.as_bytes()));
691 let deferrals = consolidation_deferrals(&inputs);
692 if !deferrals.is_empty() {
693 report.consolidation_deferrals.extend(deferrals);
694 return Ok(());
695 }
696 let input_ids = inputs
697 .iter()
698 .map(|segment| segment.meta().id)
699 .collect::<Vec<_>>();
700 let epoch_id = consolidation_epoch(&inputs)?;
701 let merge_id = consolidation_merge_id(&input_ids);
702 let output_id = consolidation_output_id(&input_ids);
703 let resumed_merge = probe_consolidation_resume(store, &input_ids, merge_id, output_id)?;
704 if resumed_merge {
705 report.checkpoints_resumed = report
706 .checkpoints_resumed
707 .checked_add(1)
708 .ok_or(MaintenanceError::ArithmeticOverflow)?;
709 } else {
710 let merge_charge = inputs
711 .iter()
712 .try_fold(0_u64, |total, segment| {
713 total.checked_add(segment.meta().file_size)
714 })
715 .ok_or(MaintenanceError::ArithmeticOverflow)?;
716 let remaining = budget.bytes.saturating_sub(report.bytes_consumed);
717 if remaining < merge_charge {
718 report.status = MaintenanceStatus::BudgetExhausted;
719 return Ok(());
720 }
721 if !consolidation_may_continue(control, &lease)? {
722 report.status = MaintenanceStatus::BudgetExhausted;
723 return Ok(());
724 }
725 let Some(merged) = merge_segments(
726 store.vfs.as_ref(),
727 &store.directory,
728 &inputs,
729 merge_id,
730 &store.tokenizer,
731 store.durability_policy,
732 )
733 .map_err(MaintenanceError::Consolidate)?
734 else {
735 return Ok(());
738 };
739 write_consolidation_checkpoint(
740 store.vfs.as_ref(),
741 &store.directory,
742 &ConsolidationCheckpoint {
743 inputs: input_ids.clone(),
744 merge_id,
745 output_id,
746 rows_emitted: merged.live_rows,
747 output_hash: merged.file_hash,
748 },
749 store.durability_policy,
750 )
751 .map_err(MaintenanceError::Consolidate)?;
752 report.bytes_consumed = report
753 .bytes_consumed
754 .checked_add(merge_charge)
755 .ok_or(MaintenanceError::ArithmeticOverflow)?;
756 }
757 let intermediate_path = store.directory.join(merge_id.file_name());
758 let intermediate = SegmentReader::open(store.vfs.as_ref(), &intermediate_path, merge_id)
759 .map_err(StoreError::Segment)
760 .map_err(MaintenanceError::Store)?;
761 let epoch = lease
762 .epoch_alias()
763 .ok_or(MaintenanceError::Graph(GraphBuildError::Profile(
764 crate::graph::search::GraphProfileError::EpochUnstamped,
765 )))?;
766 let profile = lease
767 .graph_profile()
768 .map_err(GraphBuildError::Profile)
769 .map_err(MaintenanceError::Graph)?;
770 let params = profile
771 .build_params()
772 .with_checkpoint_batch_rows(MAINTENANCE_CHECKPOINT_ROWS as u32)
773 .map_err(MaintenanceError::Parameters)?;
774 let remaining = budget.bytes.saturating_sub(report.bytes_consumed);
775 let (stride, admitted_rows) = admit_maintenance_rows(&intermediate, params, remaining)?;
776 if admitted_rows == 0 {
777 report.status = MaintenanceStatus::BudgetExhausted;
778 return Ok(());
779 }
780 let (graph_checkpoint, graph_resumed) = probe_checkpoint_resume(
781 store.vfs.as_ref(),
782 checkpoint_path(&store.directory, merge_id),
783 )?;
784 report.checkpoints_resumed = report
785 .checkpoints_resumed
786 .checked_add(u64::from(graph_resumed))
787 .ok_or(MaintenanceError::ArithmeticOverflow)?;
788 let artifact = match build_graph_checkpointed(
789 store,
790 &intermediate,
791 CheckpointedGraphBuild::new(
792 params,
793 MAINTENANCE_SEED,
794 GraphBuildPasses::One,
795 &graph_checkpoint,
796 control,
797 )
798 .with_max_work_rows(admitted_rows),
799 &lease,
800 ) {
801 Ok(artifact) => artifact,
802 Err(GraphBuildError::BudgetExhausted { rows_completed }) => {
803 let consumed = rows_completed
804 .checked_mul(stride)
805 .ok_or(MaintenanceError::ArithmeticOverflow)?;
806 report.bytes_consumed = report
807 .bytes_consumed
808 .checked_add(consumed)
809 .ok_or(MaintenanceError::ArithmeticOverflow)?;
810 report.status = MaintenanceStatus::BudgetExhausted;
811 return Ok(());
812 }
813 Err(GraphBuildError::Timeout { .. }) => {
814 report.status = MaintenanceStatus::BudgetExhausted;
815 return Ok(());
816 }
817 Err(error) => return Err(MaintenanceError::Graph(error)),
818 };
819 let consumed = artifact
820 .work_rows_completed()
821 .checked_mul(stride)
822 .and_then(|bytes| bytes.checked_add(crate::graph::block::NODE_BLOCK_TRAILER_LEN as u64))
823 .ok_or(MaintenanceError::ArithmeticOverflow)?;
824 report.bytes_consumed = report
825 .bytes_consumed
826 .checked_add(consumed)
827 .ok_or(MaintenanceError::ArithmeticOverflow)?;
828 let clustering = consolidation_clustering(&intermediate)?;
829 let meta = artifact
830 .write_segment_with_graph(
831 store.vfs.as_ref(),
832 &store.directory,
833 &intermediate,
834 output_id,
835 store.durability_policy,
836 )
837 .map_err(MaintenanceError::Graph)?;
838 drop(intermediate);
839 match publish_consolidation(store, &input_ids, meta, clustering, epoch_id, merge_id)? {
840 Some(generation) => {
841 report.consolidations = report
842 .consolidations
843 .checked_add(1)
844 .ok_or(MaintenanceError::ArithmeticOverflow)?;
845 report.consolidation_generation = Some(generation);
846 report.graph_profiles.push(GraphBuildProfileReport {
847 segment_id: output_id,
848 epoch,
849 profile,
850 });
851 }
852 None => {
853 discard_consolidation_artifacts(store, merge_id, Some(output_id))?;
857 }
858 }
859 Ok(())
860}
861
862fn consolidation_store_stats(
863 inputs: &[SegmentReader],
864 thresholds: Option<TierThresholds>,
865) -> Result<StoreStats, MaintenanceError> {
866 let graph_segment_count =
867 u32::try_from(inputs.iter().filter(|segment| has_graph(segment)).count())
868 .map_err(|_| MaintenanceError::ArithmeticOverflow)?;
869 let scan_segment_count =
870 u32::try_from(inputs.iter().filter(|segment| !has_graph(segment)).count())
871 .map_err(|_| MaintenanceError::ArithmeticOverflow)?;
872 let total_graph_rows = inputs
873 .iter()
874 .filter(|segment| has_graph(segment))
875 .try_fold(0_u64, |total, segment| {
876 total.checked_add(u64::from(segment.meta().row_count))
877 })
878 .ok_or(MaintenanceError::ArithmeticOverflow)?;
879 let largest_graph_segment_rows = inputs
880 .iter()
881 .filter(|segment| has_graph(segment))
882 .map(|segment| u64::from(segment.meta().row_count))
883 .max()
884 .unwrap_or(0);
885 let total_scan_rows = inputs
886 .iter()
887 .filter(|segment| !has_graph(segment))
888 .try_fold(0_u64, |total, segment| {
889 total.checked_add(u64::from(segment.meta().row_count))
890 })
891 .ok_or(MaintenanceError::ArithmeticOverflow)?;
892 let scan_graph_min_rows = match thresholds {
893 Some(thresholds) => thresholds.graph_min_rows,
894 None => inputs
895 .iter()
896 .find(|segment| !has_graph(segment))
897 .map(|segment| {
898 super::thresholds::for_bucket(segment.meta().dims, segment.meta().scheme)
899 .graph_min_rows
900 })
901 .unwrap_or(0),
902 };
903 Ok(StoreStats {
904 graph_segment_count,
905 total_graph_rows,
906 largest_graph_segment_rows,
907 scan_segment_count,
908 total_scan_rows,
909 scan_graph_min_rows,
910 })
911}
912
913fn consolidation_deferrals(inputs: &[&SegmentReader]) -> Vec<MaintenanceDeferral> {
915 let any_documents = inputs.iter().any(|segment| {
916 segment
917 .directory()
918 .iter()
919 .any(|entry| entry.kind == RegionKind::DocumentVersions.id())
920 });
921 let mut deferrals = Vec::new();
922 for segment in inputs {
923 let mut uncarried = segment
924 .directory()
925 .iter()
926 .filter(|entry| !consolidation_supports_region(entry.kind))
927 .map(|entry| {
928 RegionKind::from_id(entry.kind).map_or(
929 UncarriedRegionKind::Unknown(entry.kind),
930 UncarriedRegionKind::Known,
931 )
932 })
933 .collect::<Vec<_>>();
934 let has_postings = segment
935 .directory()
936 .iter()
937 .any(|entry| entry.kind == RegionKind::Postings.id());
938 let has_text = segment
939 .directory()
940 .iter()
941 .any(|entry| entry.kind == RegionKind::StoredText.id());
942 let has_documents = segment
943 .directory()
944 .iter()
945 .any(|entry| entry.kind == RegionKind::DocumentVersions.id());
946 if has_postings && !has_text {
947 uncarried.push(UncarriedRegionKind::Known(RegionKind::Postings));
951 }
952 if any_documents && !has_documents {
953 uncarried.push(UncarriedRegionKind::Known(RegionKind::DocumentVersions));
954 }
955 if !uncarried.is_empty() {
956 deferrals.push(MaintenanceDeferral {
957 segment_id: segment.meta().id,
958 uncarried_regions: uncarried,
959 });
960 }
961 }
962 deferrals
963}
964
965fn consolidation_epoch(
966 inputs: &[&SegmentReader],
967) -> Result<Option<crate::epoch::EpochId>, MaintenanceError> {
968 let mut epoch = None;
969 for segment in inputs {
970 let candidate = segment.meta().epoch_id;
971 if epoch.is_some_and(|expected: Option<crate::epoch::EpochId>| expected != candidate) {
972 return Err(MaintenanceError::Consolidate(ConsolidateError::Geometry(
973 "consolidation inputs disagree on their embedding epoch".to_owned(),
974 )));
975 }
976 epoch = Some(candidate);
977 }
978 Ok(epoch.flatten())
979}
980
981fn probe_consolidation_resume(
987 store: &Store,
988 input_ids: &[SegmentId],
989 merge_id: SegmentId,
990 output_id: SegmentId,
991) -> Result<bool, MaintenanceError> {
992 let checkpoint = match read_consolidation_checkpoint(store.vfs.as_ref(), &store.directory) {
993 Ok(checkpoint) => checkpoint,
994 Err(ConsolidateError::CheckpointCorrupt(_)) => {
995 remove_consolidation_checkpoint(store.vfs.as_ref(), &store.directory)
996 .map_err(MaintenanceError::Consolidate)?;
997 return Ok(false);
998 }
999 Err(error) => return Err(MaintenanceError::Consolidate(error)),
1000 };
1001 let Some(checkpoint) = checkpoint else {
1002 return Ok(false);
1003 };
1004 if checkpoint.inputs != input_ids
1005 || checkpoint.merge_id != merge_id
1006 || checkpoint.output_id != output_id
1007 {
1008 clear_stale_consolidation(store)?;
1009 return Ok(false);
1010 }
1011 let intermediate_path = store.directory.join(merge_id.file_name());
1012 let matches = match store.vfs.read(&intermediate_path) {
1013 Ok(bytes) => xxhash_rust::xxh3::xxh3_64(&bytes) == checkpoint.output_hash,
1014 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1015 Err(source) => {
1016 return Err(MaintenanceError::Store(StoreError::Io {
1017 path: intermediate_path,
1018 source,
1019 }));
1020 }
1021 };
1022 if !matches {
1023 clear_stale_consolidation(store)?;
1024 return Ok(false);
1025 }
1026 Ok(true)
1027}
1028
1029fn clear_stale_consolidation(store: &Store) -> Result<(), MaintenanceError> {
1032 let checkpoint = match read_consolidation_checkpoint(store.vfs.as_ref(), &store.directory) {
1033 Ok(None) => return Ok(()),
1034 Ok(Some(checkpoint)) => Some(checkpoint),
1035 Err(ConsolidateError::CheckpointCorrupt(_)) => None,
1036 Err(error) => return Err(MaintenanceError::Consolidate(error)),
1037 };
1038 if let Some(checkpoint) = checkpoint {
1039 discard_consolidation_artifacts(store, checkpoint.merge_id, None)?;
1040 }
1041 remove_consolidation_checkpoint(store.vfs.as_ref(), &store.directory)
1042 .map_err(MaintenanceError::Consolidate)
1043}
1044
1045fn discard_consolidation_artifacts(
1048 store: &Store,
1049 merge_id: SegmentId,
1050 output_id: Option<SegmentId>,
1051) -> Result<(), MaintenanceError> {
1052 let mut paths = vec![
1053 store.directory.join(merge_id.file_name()),
1054 checkpoint_path(&store.directory, merge_id),
1055 ];
1056 if let Some(output_id) = output_id {
1057 paths.push(store.directory.join(output_id.file_name()));
1058 }
1059 for path in paths {
1060 match store.vfs.delete(&path) {
1061 Ok(()) => {}
1062 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1063 Err(source) => {
1064 return Err(MaintenanceError::Store(StoreError::Io { path, source }));
1065 }
1066 }
1067 }
1068 remove_consolidation_checkpoint(store.vfs.as_ref(), &store.directory)
1069 .map_err(MaintenanceError::Consolidate)
1070}
1071
1072fn consolidation_clustering(
1073 intermediate: &SegmentReader,
1074) -> Result<ClusteringKeyRange, MaintenanceError> {
1075 let columns = intermediate
1076 .columns()
1077 .map_err(StoreError::Segment)
1078 .map_err(MaintenanceError::Store)?;
1079 let alive = intermediate
1080 .alive()
1081 .map_err(StoreError::Segment)
1082 .map_err(MaintenanceError::Store)?;
1083 merged_clustering(&columns, &alive).map_err(MaintenanceError::Consolidate)
1084}
1085
1086fn consolidation_may_continue(
1089 control: &QueryControl,
1090 lease: &SnapshotLease,
1091) -> Result<bool, MaintenanceError> {
1092 match QueryCancellation::new(control, lease).check_graph() {
1093 Ok(()) => Ok(true),
1094 Err(crate::scan::ScanError::Timeout { .. }) => Ok(false),
1095 Err(crate::scan::ScanError::Cancelled { .. }) => {
1096 Err(MaintenanceError::Graph(GraphBuildError::Cancelled {
1097 partial: false,
1098 }))
1099 }
1100 Err(crate::scan::ScanError::ReadCancelled { .. }) => {
1101 Err(MaintenanceError::Graph(GraphBuildError::ReadCancelled {
1102 partial: false,
1103 }))
1104 }
1105 Err(error) => Err(MaintenanceError::Consolidate(ConsolidateError::Geometry(
1106 format!("cancellation seam returned non-cancellation error: {error}"),
1107 ))),
1108 }
1109}
1110
1111fn publish_consolidation(
1118 store: &Store,
1119 input_ids: &[SegmentId],
1120 mut replacement: crate::segment::SegmentMeta,
1121 clustering: ClusteringKeyRange,
1122 epoch_id: Option<crate::epoch::EpochId>,
1123 merge_id: SegmentId,
1124) -> Result<Option<u64>, MaintenanceError> {
1125 let state = store
1126 .state
1127 .lock()
1128 .map_err(|_| MaintenanceError::Store(StoreError::Synchronization { component: "state" }))?;
1129 match *state {
1130 crate::lifecycle::StoreState::Open => {}
1131 crate::lifecycle::StoreState::Closing => {
1132 return Err(MaintenanceError::Store(StoreError::Closing));
1133 }
1134 crate::lifecycle::StoreState::Closed => {
1135 return Err(MaintenanceError::Store(StoreError::Closed));
1136 }
1137 }
1138 let writer = store.writer_lock.lock().map_err(|_| {
1139 MaintenanceError::Store(StoreError::Synchronization {
1140 component: "writer lock",
1141 })
1142 })?;
1143 if writer.is_none() {
1144 return Err(MaintenanceError::Store(StoreError::ReadOnly));
1145 }
1146 let wal = store.wal_writer.lock().map_err(|_| {
1147 MaintenanceError::Store(StoreError::Synchronization {
1148 component: "WAL writer",
1149 })
1150 })?;
1151 let durable_end = wal
1152 .as_ref()
1153 .ok_or(MaintenanceError::Store(StoreError::ReadOnly))?
1154 .durable_end();
1155 let manifest_path = store.directory.join(MANIFEST_FILE);
1156 let mut manifest = load_manifest(store.vfs.as_ref(), &manifest_path, durable_end)
1157 .map_err(StoreError::Manifest)
1158 .map_err(MaintenanceError::Store)?;
1159 let all_present = input_ids
1160 .iter()
1161 .all(|input| manifest.segments.iter().any(|segment| segment.id == *input));
1162 if !all_present {
1163 return Ok(None);
1164 }
1165 replacement.clustering_key_range = clustering;
1166 replacement.epoch_id = epoch_id;
1167 let mut segments = Vec::with_capacity(manifest.segments.len());
1168 let mut replaced = false;
1169 for segment in manifest.segments.drain(..) {
1170 if input_ids.contains(&segment.id) {
1171 if !replaced {
1172 segments.push(replacement.clone());
1173 replaced = true;
1174 }
1175 } else {
1176 segments.push(segment);
1177 }
1178 }
1179 if !replaced {
1180 return Ok(None);
1181 }
1182 manifest.segments = segments;
1183 let mut active = store.active.lock().map_err(|_| {
1184 MaintenanceError::Store(StoreError::Synchronization {
1185 component: "active segment",
1186 })
1187 })?;
1188 let active_state = active
1189 .as_mut()
1190 .ok_or(MaintenanceError::Store(StoreError::Closed))?;
1191 manifest.generation = manifest
1192 .generation
1193 .max(active_state.generation)
1194 .checked_add(1)
1195 .ok_or(MaintenanceError::Store(StoreError::GenerationOverflow))?;
1196 manifest.epochs = store.epoch_registry(&manifest.epochs);
1197 commit_manifest(
1198 store.vfs.as_ref(),
1199 &store.directory,
1200 &manifest,
1201 store.durability_policy,
1202 )
1203 .map_err(StoreError::Manifest)
1204 .map_err(MaintenanceError::Store)?;
1205 let remapped =
1206 PublishedSnapshot::load_on_vfs(&store.directory, &store.accounting, store.vfs.as_ref())
1207 .map_err(MaintenanceError::Store)?;
1208 let mut published = store.snapshot.write().map_err(|_| {
1209 MaintenanceError::Store(StoreError::Synchronization {
1210 component: "published snapshot",
1211 })
1212 })?;
1213 let previous = published.replace(Arc::new(remapped));
1214 active_state.generation = manifest.generation;
1215 let generation = manifest.generation;
1216 drop(published);
1217 drop(previous);
1218 drop(active);
1219 drop(wal);
1220 drop(writer);
1221 drop(state);
1222 for input in input_ids {
1223 if store.has_snapshot_views() {
1224 break;
1225 }
1226 let path = store.directory.join(input.file_name());
1227 store
1228 .vfs
1229 .delete(&path)
1230 .map_err(|error| SegmentError::io(&path, error))
1231 .map_err(StoreError::Segment)
1232 .map_err(MaintenanceError::Store)?;
1233 }
1234 discard_consolidation_artifacts(store, merge_id, None)?;
1235 Ok(Some(generation))
1236}
1237
1238fn promotion_deferral(segment: &SegmentReader) -> Option<MaintenanceDeferral> {
1239 let uncarried_regions = segment
1240 .directory()
1241 .iter()
1242 .filter(|entry| graph_rewrite_source_region(entry.kind) == GraphRewriteRegion::Unsupported)
1243 .map(|entry| {
1244 RegionKind::from_id(entry.kind).map_or(
1245 UncarriedRegionKind::Unknown(entry.kind),
1246 UncarriedRegionKind::Known,
1247 )
1248 })
1249 .collect::<Vec<_>>();
1250 (!uncarried_regions.is_empty()).then_some(MaintenanceDeferral {
1251 segment_id: segment.meta().id,
1252 uncarried_regions,
1253 })
1254}
1255
1256fn transition_due(segment: &SegmentReader, thresholds: Option<TierThresholds>) -> bool {
1257 let actual = if has_graph(segment) {
1258 SegmentTier::SealedGraph
1259 } else {
1260 SegmentTier::SealedScan
1261 };
1262 let stats = SegmentStats {
1263 tier: actual,
1264 row_count: segment.meta().row_count,
1265 dimensions: segment.meta().dims,
1266 scheme: segment.meta().scheme,
1267 };
1268 let plan = thresholds.map_or_else(
1269 || decide(stats, StoreStats::default()),
1270 |thresholds| decide_with_thresholds(stats, thresholds),
1271 );
1272 matches!(
1273 plan,
1274 TierPlan::Transition {
1275 from: SegmentTier::SealedScan,
1276 to: SegmentTier::SealedGraph,
1277 }
1278 )
1279}
1280
1281fn has_graph(segment: &SegmentReader) -> bool {
1282 segment
1283 .directory()
1284 .iter()
1285 .any(|entry| entry.kind == RegionKind::GraphNodeBlocks.id())
1286}
1287
1288fn graph_work_stride(
1289 segment: &SegmentReader,
1290 params: crate::graph::GraphParams,
1291) -> Result<u64, MaintenanceError> {
1292 let padded_dims = segment
1293 .meta()
1294 .dims
1295 .checked_add(127)
1296 .map(|dimensions| dimensions / 128 * 128)
1297 .ok_or(MaintenanceError::ArithmeticOverflow)?;
1298 let layout =
1299 crate::graph::block::GraphNodeLayout::new(segment.meta().dims, padded_dims, params.r_max())
1300 .map_err(|error| MaintenanceError::Graph(GraphBuildError::NodeBlock(error)))?;
1301 Ok(u64::from(layout.stride()))
1302}
1303
1304fn checkpoint_path(directory: &Path, segment_id: SegmentId) -> std::path::PathBuf {
1305 directory.join(format!(".tier-{segment_id}.graph.checkpoint"))
1306}
1307
1308fn graph_segment_id(source: SegmentId, generation: u64) -> SegmentId {
1309 let mut bytes = [0_u8; 16];
1310 if let Some(prefix) = bytes.get_mut(..8) {
1311 prefix.copy_from_slice(&generation.saturating_add(1).to_be_bytes());
1312 }
1313 let hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes()).to_be_bytes();
1314 if let Some(suffix) = bytes.get_mut(8..) {
1315 suffix.copy_from_slice(&hash);
1316 }
1317 SegmentId::from_bytes(bytes)
1318}
1319
1320fn publish_transition(
1321 store: &Store,
1322 source_id: SegmentId,
1323 mut replacement: crate::segment::SegmentMeta,
1324) -> Result<bool, MaintenanceError> {
1325 let state = store
1326 .state
1327 .lock()
1328 .map_err(|_| MaintenanceError::Store(StoreError::Synchronization { component: "state" }))?;
1329 match *state {
1330 crate::lifecycle::StoreState::Open => {}
1331 crate::lifecycle::StoreState::Closing => {
1332 return Err(MaintenanceError::Store(StoreError::Closing));
1333 }
1334 crate::lifecycle::StoreState::Closed => {
1335 return Err(MaintenanceError::Store(StoreError::Closed));
1336 }
1337 }
1338 let writer = store.writer_lock.lock().map_err(|_| {
1339 MaintenanceError::Store(StoreError::Synchronization {
1340 component: "writer lock",
1341 })
1342 })?;
1343 if writer.is_none() {
1344 return Err(MaintenanceError::Store(StoreError::ReadOnly));
1345 }
1346 let wal = store.wal_writer.lock().map_err(|_| {
1347 MaintenanceError::Store(StoreError::Synchronization {
1348 component: "WAL writer",
1349 })
1350 })?;
1351 let durable_end = wal
1352 .as_ref()
1353 .ok_or(MaintenanceError::Store(StoreError::ReadOnly))?
1354 .durable_end();
1355 let manifest_path = store.directory.join(MANIFEST_FILE);
1356 let mut manifest = load_manifest(store.vfs.as_ref(), &manifest_path, durable_end)
1357 .map_err(StoreError::Manifest)
1358 .map_err(MaintenanceError::Store)?;
1359 let Some(slot) = manifest
1360 .segments
1361 .iter_mut()
1362 .find(|meta| meta.id == source_id)
1363 else {
1364 return Ok(false);
1365 };
1366 replacement.clustering_key_range = slot.clustering_key_range;
1367 replacement.epoch_id = slot.epoch_id;
1368 *slot = replacement;
1369 let mut active = store.active.lock().map_err(|_| {
1370 MaintenanceError::Store(StoreError::Synchronization {
1371 component: "active segment",
1372 })
1373 })?;
1374 let active_state = active
1375 .as_mut()
1376 .ok_or(MaintenanceError::Store(StoreError::Closed))?;
1377 manifest.generation = manifest
1378 .generation
1379 .max(active_state.generation)
1380 .checked_add(1)
1381 .ok_or(MaintenanceError::Store(StoreError::GenerationOverflow))?;
1382 manifest.epochs = store.epoch_registry(&manifest.epochs);
1383 commit_manifest(
1384 store.vfs.as_ref(),
1385 &store.directory,
1386 &manifest,
1387 store.durability_policy,
1388 )
1389 .map_err(StoreError::Manifest)
1390 .map_err(MaintenanceError::Store)?;
1391 let remapped =
1392 PublishedSnapshot::load_on_vfs(&store.directory, &store.accounting, store.vfs.as_ref())
1393 .map_err(MaintenanceError::Store)?;
1394 let mut published = store.snapshot.write().map_err(|_| {
1395 MaintenanceError::Store(StoreError::Synchronization {
1396 component: "published snapshot",
1397 })
1398 })?;
1399 let previous = published.replace(Arc::new(remapped));
1400 active_state.generation = manifest.generation;
1401 drop(published);
1402 drop(previous);
1403 drop(active);
1404 drop(wal);
1405 drop(writer);
1406 drop(state);
1407 if store.has_snapshot_views() {
1408 return Ok(true);
1409 }
1410 let source_path = store.directory.join(source_id.file_name());
1411 store
1412 .vfs
1413 .delete(&source_path)
1414 .map_err(|error| SegmentError::io(&source_path, error))
1415 .map_err(StoreError::Segment)
1416 .map_err(MaintenanceError::Store)?;
1417 Ok(true)
1418}
1419
1420#[cfg(test)]
1421#[allow(clippy::expect_used)]
1422mod tests {
1423 use super::{
1424 MaintenanceBudget, MaintenanceError, MaintenanceStatus, checkpoint_path, graph_segment_id,
1425 };
1426 use crate::graph::search::GraphSearchProfile;
1427 use crate::graph::{GraphParams, build::GraphBuildError};
1428 use crate::lifecycle::{
1429 CancelToken, DeadlineError, InMemorySegment, InMemorySegmentFactors, OpenOptions,
1430 QueryControl, SearchOptions, Store, StoreError,
1431 };
1432 use crate::meta::{AliveSet, ColumnStoreBuilder, Schema};
1433 use crate::quant::quantize_bit4;
1434 use crate::segment::SegmentId;
1435 use crate::tier::TierThresholds;
1436 use std::time::Duration;
1437
1438 #[test]
1439 fn maintenance_budget_lifecycle_and_identity_fail_closed() {
1440 let directory = tempfile::tempdir().expect("maintenance test directory");
1441 let store = Store::open(directory.path(), OpenOptions::default()).expect("open writer");
1442
1443 for budget in [
1444 MaintenanceBudget {
1445 wall_time: Duration::ZERO,
1446 bytes: u64::MAX,
1447 },
1448 MaintenanceBudget {
1449 wall_time: Duration::from_secs(1),
1450 bytes: 0,
1451 },
1452 ] {
1453 let report = store.maintain(budget);
1454 assert_eq!(report.graphs_built, 0);
1455 assert_eq!(report.bytes_consumed, 0);
1456 assert_eq!(report.checkpoints_resumed, 0);
1457 assert!(matches!(report.status, MaintenanceStatus::BudgetExhausted));
1458 }
1459
1460 let source = SegmentId::new(19, [7; 10]);
1461 let first = graph_segment_id(source, 41);
1462 assert_eq!(first, graph_segment_id(source, 41));
1463 assert_ne!(first, graph_segment_id(source, 42));
1464 assert_eq!(
1465 checkpoint_path(directory.path(), source),
1466 directory
1467 .path()
1468 .join(format!(".tier-{source}.graph.checkpoint"))
1469 );
1470
1471 store.close().expect("close writer");
1472 let closed = store.maintain(MaintenanceBudget {
1473 wall_time: Duration::from_secs(1),
1474 bytes: 1,
1475 });
1476 assert!(matches!(
1477 closed.status,
1478 MaintenanceStatus::Failed(MaintenanceError::Store(StoreError::Closed))
1479 ));
1480
1481 let read_only =
1482 Store::open(directory.path(), OpenOptions::read_only()).expect("open read-only store");
1483 let refused = read_only.maintain(MaintenanceBudget {
1484 wall_time: Duration::from_secs(1),
1485 bytes: 1,
1486 });
1487 assert!(matches!(
1488 refused.status,
1489 MaintenanceStatus::Failed(MaintenanceError::Store(StoreError::ReadOnly))
1490 ));
1491 }
1492
1493 #[test]
1494 fn maintenance_errors_preserve_typed_sources_and_messages() {
1495 let parameter =
1496 GraphParams::new(0, 1, 1.0, 1.0, 1, 1).expect_err("zero target degree is invalid");
1497 let errors = [
1498 MaintenanceError::Store(StoreError::Closed),
1499 MaintenanceError::Parameters(parameter),
1500 MaintenanceError::Graph(GraphBuildError::Cancelled { partial: false }),
1501 MaintenanceError::Consolidate(crate::graph::consolidate::ConsolidateError::Geometry(
1502 "probe".to_owned(),
1503 )),
1504 MaintenanceError::Deadline(DeadlineError::OutOfRange),
1505 MaintenanceError::ArithmeticOverflow,
1506 ];
1507 for error in errors {
1508 assert!(!error.to_string().is_empty());
1509 assert_eq!(
1510 std::error::Error::source(&error).is_some(),
1511 !matches!(error, MaintenanceError::ArithmeticOverflow)
1512 );
1513 }
1514 }
1515
1516 #[test]
1517 fn maintain_builds_with_the_same_profile_auto_query_selects() {
1518 let directory = tempfile::tempdir().expect("profile parity directory");
1519 let epoch = sift_epoch();
1520 let store = Store::open(
1521 directory.path(),
1522 OpenOptions::default().with_epoch(epoch.clone()),
1523 )
1524 .expect("open profile parity store");
1525 let vectors = (0..4)
1526 .flat_map(|row| std::iter::repeat_n(row as f32, 128))
1527 .collect::<Vec<_>>();
1528 let mut codes = vec![0_u8; 4 * 64];
1529 let factors = vectors
1530 .chunks_exact(128)
1531 .zip(codes.chunks_exact_mut(64))
1532 .map(|(row, encoded)| quantize_bit4(row, encoded))
1533 .collect::<Result<Vec<_>, _>>()
1534 .expect("quantize profile parity rows");
1535 let schema = Schema::new(Vec::new()).expect("timestamp-only schema");
1536 let mut columns = ColumnStoreBuilder::new(schema);
1537 for row in 0..4 {
1538 columns
1539 .push_row(row, &[])
1540 .expect("profile parity column row");
1541 }
1542 let columns = columns.finish().expect("profile parity columns");
1543 let alive = AliveSet::new(4);
1544 let prepared = store
1545 .prepare_segment(InMemorySegment {
1546 id: SegmentId::new(0x0016, [0x16; 10]),
1547 scheme: 4,
1548 dims: 128,
1549 codes,
1550 factors: InMemorySegmentFactors::Bit4(factors),
1551 rescore: vectors,
1552 columns: &columns,
1553 alive: &alive,
1554 })
1555 .expect("prepare profile parity segment");
1556 store
1557 .seal_snapshot(prepared)
1558 .expect("publish profile parity segment");
1559
1560 let maintenance = store.maintain_with_test_thresholds(
1561 MaintenanceBudget {
1562 wall_time: Duration::from_secs(30),
1563 bytes: u64::MAX,
1564 },
1565 TierThresholds { graph_min_rows: 1 },
1566 );
1567 assert!(matches!(maintenance.status, MaintenanceStatus::Complete));
1568 assert_eq!(maintenance.graphs_built, 1);
1569 let built = maintenance
1570 .graph_profiles
1571 .first()
1572 .expect("published graph profile report");
1573
1574 let outcome = store
1575 .search(
1576 crate::ingest::SearchRequest::new(&[0.0_f32; 128]),
1577 1,
1578 SearchOptions::default(),
1579 QueryControl::Cancel(CancelToken::new()),
1580 )
1581 .expect("Auto query after profile-selected build");
1582 let queried = outcome
1583 .diagnostics
1584 .plan
1585 .first()
1586 .and_then(|plan| plan.graph_profile)
1587 .expect("Auto query graph profile report");
1588
1589 assert_eq!(built.epoch, epoch.identity());
1590 assert_eq!(built.profile.search_profile(), queried);
1591 assert_eq!(queried, GraphSearchProfile::SiftClass);
1592 store.close().expect("close profile parity store");
1593 }
1594
1595 fn sift_epoch() -> crate::epoch::StoreEpoch {
1596 let document = crate::epoch::EmbeddingTower {
1597 model_id: "sift-profile-fixture".to_owned(),
1598 model_version: "1".to_owned(),
1599 weights_digest: vec![0x16],
1600 dims: 128,
1601 normalization: crate::epoch::Normalization::None,
1602 prompt_prefix: String::new(),
1603 max_tokens: 512,
1604 runtime: crate::epoch::EmbeddingRuntime::CpuReference,
1605 compute_units: crate::epoch::ComputeUnits::Cpu,
1606 os_build: None,
1607 };
1608 crate::epoch::StoreEpoch {
1609 embedding: crate::epoch::EmbeddingEpoch {
1610 query: document.clone(),
1611 document,
1612 alignment_digest: Vec::new(),
1613 },
1614 tokenizer: crate::fts::tokenizer::TokenizerConfig::text_default().epoch(),
1615 }
1616 }
1617}