Skip to main content

zeppelin_embed/tier/
maintain.rs

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