Skip to main content

zeppelin_embed/tier/
maintain.rs

1//! Host-invoked execution of due tier transitions.
2
3#![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/// Work admitted by one host call.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub struct MaintenanceBudget {
51    /// Maximum monotonic elapsed time for this call.
52    pub wall_time: Duration,
53    /// Maximum graph work bytes for this call.
54    pub bytes: u64,
55}
56
57/// Why one maintenance call stopped.
58#[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    /// Every non-deferred transition considered by this call is complete or not due.
68    Complete,
69    /// Work remains for a later call because this call spent its budget.
70    BudgetExhausted,
71    /// A typed failure prevented maintenance from continuing.
72    Failed(MaintenanceError),
73}
74
75/// One segment promotion refused because its source regions would not survive the rewrite.
76#[derive(Debug, Eq, PartialEq)]
77pub struct MaintenanceDeferral {
78    /// Immutable source segment that was left unchanged.
79    pub segment_id: SegmentId,
80    /// Every source region the current graph rewrite cannot carry forward.
81    pub uncarried_regions: Vec<UncarriedRegionKind>,
82}
83
84/// A source region kind the current graph rewrite cannot carry forward.
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum UncarriedRegionKind {
87    /// A known or reserved region kind.
88    Known(RegionKind),
89    /// A forward-compatible region kind unknown to this engine version.
90    Unknown(u16),
91}
92
93/// Typed maintenance failure retained in [`MaintenanceReport`].
94#[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 lifecycle, publication, or accounting failed.
104    Store(StoreError),
105    /// Graph parameters could not be constructed.
106    Parameters(GraphParamsError),
107    /// Checkpointed graph construction or segment writing failed.
108    Graph(GraphBuildError),
109    /// The consolidation merge pass or its checkpoint failed.
110    Consolidate(ConsolidateError),
111    /// The wall-time deadline was outside the monotonic clock range.
112    Deadline(DeadlineError),
113    /// Property graph maintenance failed.
114    #[cfg(feature = "graph-cypher")]
115    PropertyGraph(crate::property_graph::GraphStoreError),
116    /// Checked maintenance byte arithmetic overflowed.
117    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/// The epoch-selected profile used for one graph published by maintenance.
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub struct GraphBuildProfileReport {
157    /// Replacement graph segment atomically published by this call.
158    pub segment_id: SegmentId,
159    /// Persisted embedding and tokenizer epoch that selected the profile.
160    pub epoch: crate::epoch::EpochIdentity,
161    /// End-to-end build/query profile selected by the shared policy.
162    pub profile: crate::graph::search::EpochGraphProfile,
163}
164
165/// Catalog-specific counts for post-consolidation refinement publications.
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167pub struct RefinementPassCounters {
168    /// Published Gorder-lite row-renumbering passes.
169    pub renumber: u64,
170    /// Published true-neighborhood alpha re-prunes.
171    pub alpha_reprune: u64,
172    /// Published exact-medoid entry-seed refits.
173    pub seed_refit: u64,
174    /// Published owner-distance neighbor reorders.
175    pub neighbor_reorder: u64,
176    /// Published angular connectivity repairs.
177    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/// Deterministic work counters returned by one maintenance call.
197#[derive(Debug)]
198pub struct MaintenanceReport {
199    /// Graph generations atomically published by this call.
200    pub graphs_built: u64,
201    /// Graph construction work charged to the byte budget.
202    pub bytes_consumed: u64,
203    /// Checkpointed graph builds resumed by this call.
204    pub checkpoints_resumed: u64,
205    /// Due promotions refused because their source regions would not survive the rewrite.
206    pub promotion_deferrals: Vec<MaintenanceDeferral>,
207    /// Epoch-selected profile for every graph published by this call.
208    pub graph_profiles: Vec<GraphBuildProfileReport>,
209    /// N-to-1 graph-segment consolidations atomically published by this call.
210    pub consolidations: u64,
211    /// Final manifest generation of this call when it published a consolidation.
212    pub consolidation_generation: Option<u64>,
213    /// Due consolidations refused because an input region cannot be carried.
214    pub consolidation_deferrals: Vec<MaintenanceDeferral>,
215    /// Post-consolidation refinement artifacts atomically published by this call.
216    pub passes_applied: u64,
217    /// Per-pass publication counts for this call.
218    pub pass_counters: RefinementPassCounters,
219    /// Final manifest generation of this call when it published a refinement.
220    pub refinement_generation: Option<u64>,
221    /// Successful property graph maintenance steps.
222    #[cfg(feature = "graph-cypher")]
223    pub graph_steps: u64,
224    /// Combined property graph progress.
225    #[cfg(feature = "graph-cypher")]
226    pub graph: Option<crate::property_graph::GraphMaintenanceReport>,
227    /// Last generation published by either phase; absent when nothing was published.
228    pub generation: Option<u64>,
229    /// Final disposition of the call.
230    pub status: MaintenanceStatus,
231}
232
233impl Default for MaintenanceReport {
234    fn default() -> Self {
235        empty_report(MaintenanceStatus::Complete)
236    }
237}
238
239impl MaintenanceReport {
240    /// Accumulates property graph counters and the last publication for outer adapters.
241    /// Byte and segment counters are merged separately by the adapter.
242    /// # Errors
243    /// Returns checked counter overflow.
244    #[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    /// Runs due tier transitions within a host-supplied work budget.
294    #[must_use]
295    pub fn maintain(&self, budget: MaintenanceBudget) -> MaintenanceReport {
296        self.maintain_with_optional_thresholds(budget, None)
297    }
298
299    /// Runs maintenance with an explicit reachability threshold for deterministic tests.
300    #[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            // One deadline reserves ten percent for publication in both phases.
329            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
851/// Runs the store-level N-to-1 graph consolidation when it is due.
852///
853/// The decision consults a fresh published snapshot so segments promoted by
854/// this same call participate. A merge is admitted whole against the byte
855/// budget; the graph phase resumes across calls through the existing graph
856/// checkpoint. Nothing is published until one manifest commit replaces the
857/// N input records with the single output record; the commit precedes every
858/// unlink.
859fn 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            // Every input row is tombstoned; there is nothing to carry, and
930            // dropping empty segments belongs to retention, not this path.
931            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            // A concurrent mutation replaced or dropped an input after the
1049            // merge began; the prepared output is stale and is discarded
1050            // without publishing anything.
1051            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
1108/// Reports every input region the merge pass cannot carry to new row ids.
1109fn 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            // Postings cannot be copy-forwarded across a merge: row ids
1143            // change and document frequencies must be re-aggregated from
1144            // the stored text this segment does not carry.
1145            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
1176/// Returns whether a valid checkpoint lets this call skip the merge pass.
1177///
1178/// A checkpoint for a different input set, a corrupt checkpoint, or an
1179/// intermediate whose bytes no longer match the recorded hash is refused
1180/// and cleared so the next attempt restarts deterministically.
1181fn 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
1224/// Removes a checkpoint (and the artifacts it names) that no longer
1225/// matches the published graph segment set.
1226fn 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
1240/// Deletes the unpublished intermediate, its graph checkpoint, the
1241/// consolidation checkpoint, and optionally an unpublished output segment.
1242fn 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
1281/// Maps the shared cancellation seam to the maintenance disposition:
1282/// deadline expiry stops cleanly, cancellation is a typed failure.
1283fn 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
1306/// Commits one manifest replacing the N input records with the output
1307/// record, publishes the snapshot, and only then unlinks the inputs, the
1308/// intermediate, and the consolidation checkpoint.
1309///
1310/// Returns the generation the mutation changed, or `None` when any input
1311/// is no longer in the manifest (a concurrent mutation superseded it).
1312fn 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}