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