Skip to main content

mito2/series_index/
maintenance.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Worker-owned series-index reconciliation and publication.
16
17use std::collections::HashSet;
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use common_telemetry::{debug, info};
22use common_time::TimeToLive;
23use object_store::ObjectStore;
24use store_api::metadata::RegionMetadataRef;
25use store_api::storage::{FileId, RegionId};
26
27use crate::error::Result;
28use crate::metrics::{SERIES_INDEX_RECONCILE_ELAPSED, SERIES_INDEX_RECONCILE_TOTAL};
29use crate::read::series_candidate::is_sparse_metric_metadata;
30use crate::region::{MitoRegionRef, RegionLeaderState, RegionRoleState};
31use crate::series_index::bucket::{
32    group_files_into_series_buckets, plan_series_indexes, rounded_bucket_width,
33};
34use crate::series_index::builder::{build_range_index, build_series_index};
35use crate::series_index::catalog::{
36    RangeIndexCatalog, SeriesIndexCatalog, delete_catalogs, range_catalog_path,
37    series_catalog_path, store_catalog,
38};
39use crate::series_index::purger::IndexFilePurger;
40use crate::series_index::version::{SeriesIndexFileHandle, SeriesIndexVersion};
41use crate::sst::file::FileHandle;
42
43/// Captured index-build inputs that retain SSTs without retaining memtables.
44struct ReconcileSnapshot {
45    metadata: RegionMetadataRef,
46    files: Vec<FileHandle>,
47    compaction_time_window: Option<Duration>,
48    ttl: Option<TimeToLive>,
49}
50
51/// Retires newly completed series files unless their snapshot is published.
52#[derive(Default)]
53struct UnpublishedSeriesFiles(Vec<SeriesIndexFileHandle>);
54
55impl UnpublishedSeriesFiles {
56    fn disarm(&mut self) {
57        self.0.clear();
58    }
59}
60
61impl Drop for UnpublishedSeriesFiles {
62    fn drop(&mut self) {
63        for handle in &self.0 {
64            handle.mark_deleted();
65        }
66    }
67}
68
69#[derive(Debug, Default)]
70pub(crate) struct ReconcileStats {
71    pub(crate) source_files: usize,
72    pub(crate) built_range: usize,
73    pub(crate) built_series: usize,
74    pub(crate) removed_range: usize,
75    pub(crate) removed_series: usize,
76    pub(crate) computed_buckets: usize,
77    pub(crate) skipped_buckets: usize,
78}
79
80impl ReconcileStats {
81    fn changed(&self) -> bool {
82        self.built_range + self.built_series + self.removed_range + self.removed_series > 0
83    }
84}
85
86/// Reconciles indexes for one region snapshot, persists catalogs, then atomically publishes it.
87#[allow(clippy::too_many_arguments)]
88pub(crate) async fn reconcile_series_indexes(
89    worker_id: u32,
90    store: ObjectStore,
91    region: MitoRegionRef,
92    requested_bucket_width: Duration,
93    now_ms: i64,
94    purger: IndexFilePurger,
95    enable_range_index: bool,
96    allow_builds: bool,
97) -> Result<ReconcileStats> {
98    let total_start = Instant::now();
99    // Retaining the full version during I/O would pin flushed memtables and delay
100    // write-buffer reclamation. Capture consistent build inputs without the memtables.
101    let snapshot = {
102        let version = region.version_control.current().version;
103        if !is_sparse_metric_metadata(&version.metadata) {
104            SERIES_INDEX_RECONCILE_TOTAL
105                .with_label_values(&["noop"])
106                .inc();
107            return Ok(ReconcileStats::default());
108        }
109        ReconcileSnapshot {
110            metadata: version.metadata.clone(),
111            files: version
112                .ssts
113                .levels()
114                .iter()
115                .flat_map(|level| level.files())
116                .cloned()
117                .collect(),
118            compaction_time_window: version.compaction_time_window,
119            ttl: version.options.ttl,
120        }
121    };
122    let build_start = Instant::now();
123    let mut unpublished = UnpublishedSeriesFiles::default();
124    let (next, mut stats) = build_index_version(
125        worker_id,
126        &store,
127        &region,
128        &snapshot,
129        requested_bucket_width,
130        now_ms,
131        &purger,
132        &mut unpublished,
133        enable_range_index,
134        allow_builds,
135    )
136    .await?;
137    SERIES_INDEX_RECONCILE_ELAPSED
138        .with_label_values(&["build"])
139        .observe(build_start.elapsed().as_secs_f64());
140    // Persist changed catalogs before making the new snapshot visible to readers.
141    let publish_result: Result<()> = async {
142        if let Some(next) = next {
143            persist_index_catalogs(&store, region.region_id, &next, &stats).await?;
144            publish_index_version(&region, Arc::new(next));
145            unpublished.disarm();
146        }
147        Ok(())
148    }
149    .await;
150    // Drop may have cleaned up while catalogs were being written, even if a write failed.
151    // If dropping starts after this check, the normal drop path cleans up our publication.
152    // This assumes the region ID is not reopened or replaced during cleanup.
153    if region.state() == RegionRoleState::Leader(RegionLeaderState::Dropping) {
154        delete_catalogs(&store, region.region_id).await;
155        region.series_index_version_control.mark_dropped();
156        stats = ReconcileStats::default();
157    }
158    publish_result?;
159    let result = if stats.changed() { "changed" } else { "noop" };
160    SERIES_INDEX_RECONCILE_TOTAL
161        .with_label_values(&[result])
162        .inc();
163    SERIES_INDEX_RECONCILE_ELAPSED
164        .with_label_values(&["total"])
165        .observe(total_start.elapsed().as_secs_f64());
166    if stats.changed() {
167        info!(
168            "Reconciled series-index snapshot, worker: {worker_id}, region: {}, elapsed: {:?}, stats: {:?}",
169            region.region_id,
170            total_start.elapsed(),
171            stats
172        );
173    } else {
174        debug!(
175            "Series-index reconciliation made no changes, worker: {worker_id}, region: {}",
176            region.region_id
177        );
178    }
179    Ok(stats)
180}
181
182/// Builds a changed snapshot, retaining reusable indexes and removing obsolete coverage.
183/// Returns `None` when no range or series indexes were added or removed.
184#[allow(clippy::too_many_arguments)]
185async fn build_index_version(
186    worker_id: u32,
187    store: &ObjectStore,
188    region: &MitoRegionRef,
189    snapshot: &ReconcileSnapshot,
190    requested_bucket_width: Duration,
191    now_ms: i64,
192    purger: &IndexFilePurger,
193    unpublished: &mut UnpublishedSeriesFiles,
194    enable_range_index: bool,
195    allow_builds: bool,
196) -> Result<(Option<SeriesIndexVersion>, ReconcileStats)> {
197    let mut stats = ReconcileStats::default();
198    let ReconcileSnapshot {
199        metadata,
200        files,
201        compaction_time_window,
202        ttl,
203    } = snapshot;
204    stats.source_files = files.len();
205    let visible = files
206        .iter()
207        .map(|file| file.file_id().file_id())
208        .collect::<HashSet<_>>();
209    let current = region.series_index_version();
210    // Empty build inputs still let the planner expire established bucket coverage.
211    let buckets = match (allow_builds, *compaction_time_window) {
212        (true, Some(window)) => rounded_bucket_width(requested_bucket_width, window)
213            // Successful rounding guarantees the window fits in i64; subsecond windows
214            // use the same one-second minimum as rounded_bucket_width.
215            .map(|width| {
216                group_files_into_series_buckets(files, width, (window.as_secs() as i64).max(1))
217            })
218            .unwrap_or_default(),
219        (true, None) => {
220            debug!(
221                "Deferring series indexes without compaction window, worker: {worker_id}, region: {}",
222                region.region_id
223            );
224            Vec::new()
225        }
226        (false, _) => Vec::new(),
227    };
228    let plan = plan_series_indexes(buckets, current.index_buckets.clone(), *ttl, now_ms);
229    stats.computed_buckets = plan.computed_buckets;
230    stats.skipped_buckets = plan.skipped_buckets;
231    let mut next = prune_index_version(&current, &visible, &plan.expired_index_ids, &mut stats);
232    if !allow_builds {
233        if let Some(next) = &mut next {
234            next.index_buckets = plan.index_buckets;
235        }
236        return Ok((next, stats));
237    }
238    if plan.builds.is_empty()
239        && next.is_none()
240        && (!enable_range_index || current.range_indexes.len() == visible.len())
241    {
242        return Ok((None, stats));
243    }
244    let SeriesIndexVersion {
245        mut range_indexes,
246        mut series_indexes,
247        ..
248    } = next.unwrap_or_else(|| SeriesIndexVersion {
249        range_indexes: current.range_indexes.clone(),
250        series_indexes: current.series_indexes.clone(),
251        index_buckets: Default::default(),
252    });
253    let index_buckets = plan.index_buckets;
254    for id in &plan.superseded_index_ids {
255        series_indexes.remove(id);
256    }
257    for (bucket, expected) in plan.builds {
258        // Complete companion indexes independently so a failed series build preserves them.
259        for file in &bucket.files {
260            if enable_range_index
261                && !range_indexes.contains_key(&file.file_id().file_id())
262                && let Some(entry) =
263                    build_range_index(store, region, metadata, file.clone()).await?
264            {
265                stats.built_range += 1;
266                range_indexes.insert(entry.file_id, entry);
267            }
268        }
269        let series_handle =
270            build_series_index(store, region, metadata, &bucket, &expected, purger).await?;
271        unpublished.0.push(series_handle.clone());
272        stats.built_series += 1;
273        series_indexes.insert(expected.index_uuid, series_handle);
274    }
275    // Cover SSTs outside planned aggregate builds, including skipped buckets.
276    if enable_range_index {
277        for file in files {
278            let file_id = file.file_id().file_id();
279            if range_indexes.contains_key(&file_id) {
280                continue;
281            }
282            if let Some(entry) = build_range_index(store, region, metadata, file.clone()).await? {
283                stats.built_range += 1;
284                range_indexes.insert(entry.file_id, entry);
285            }
286        }
287    }
288    stats.removed_series = current
289        .series_indexes
290        .keys()
291        .filter(|id| !series_indexes.contains_key(id))
292        .count();
293    // Bucket reconciliation is speculative until indexes change. Recompute its coverage
294    // next time rather than replacing the published snapshot on a no-op pass.
295    if !stats.changed() {
296        return Ok((None, stats));
297    }
298    let next = SeriesIndexVersion {
299        range_indexes,
300        series_indexes,
301        index_buckets,
302    };
303    Ok((Some(next), stats))
304}
305
306/// Prunes obsolete metadata without building indexes or retiring published handles.
307/// Returns `None` without cloning the current version when no entries need removal.
308/// Physical range deletion belongs to the SST purger; series handles are retired only
309/// after the cleaned catalogs and snapshot have been published successfully.
310fn prune_index_version(
311    current: &SeriesIndexVersion,
312    visible: &HashSet<FileId>,
313    expired_index_ids: &[FileId],
314    stats: &mut ReconcileStats,
315) -> Option<SeriesIndexVersion> {
316    if current.range_indexes.keys().all(|id| visible.contains(id))
317        && expired_index_ids
318            .iter()
319            .all(|id| !current.series_indexes.contains_key(id))
320    {
321        return None;
322    }
323    let mut next = SeriesIndexVersion {
324        range_indexes: current.range_indexes.clone(),
325        series_indexes: current.series_indexes.clone(),
326        index_buckets: current.index_buckets.clone(),
327    };
328    next.range_indexes
329        .retain(|file_id, _| visible.contains(file_id));
330    for id in expired_index_ids {
331        next.series_indexes.remove(id);
332    }
333    stats.removed_range = current.range_indexes.len() - next.range_indexes.len();
334    stats.removed_series = current.series_indexes.len() - next.series_indexes.len();
335    Some(next)
336}
337
338/// Writes changed catalogs in a stable order; the two writes are not atomic together.
339async fn persist_index_catalogs(
340    store: &ObjectStore,
341    region_id: RegionId,
342    next: &SeriesIndexVersion,
343    stats: &ReconcileStats,
344) -> Result<()> {
345    if stats.built_range + stats.removed_range > 0 {
346        let mut range_entries = next.range_indexes.values().copied().collect::<Vec<_>>();
347        range_entries
348            .sort_unstable_by(|left, right| left.file_id.as_bytes().cmp(right.file_id.as_bytes()));
349        store_catalog(
350            store,
351            &range_catalog_path(region_id),
352            &RangeIndexCatalog {
353                indexes: range_entries,
354            },
355        )
356        .await?;
357    }
358    if stats.built_series + stats.removed_series > 0 {
359        let mut series_entries = next
360            .series_indexes
361            .values()
362            .map(|handle| handle.entry().clone())
363            .collect::<Vec<_>>();
364        series_entries.sort_unstable_by_key(|entry| {
365            (
366                entry.bucket_start,
367                entry.bucket_end,
368                entry.min_file_sequence,
369                entry.max_file_sequence,
370            )
371        });
372        store_catalog(
373            store,
374            &series_catalog_path(region_id),
375            &SeriesIndexCatalog {
376                indexes: series_entries,
377            },
378        )
379        .await?;
380    }
381    Ok(())
382}
383
384/// Publishes the snapshot and retires series files absent from the new version.
385fn publish_index_version(region: &MitoRegionRef, next: Arc<SeriesIndexVersion>) {
386    let previous = region.series_index_version_control.publish(next.clone());
387    for (id, handle) in &previous.series_indexes {
388        if !next.series_indexes.contains_key(id) {
389            // Purge only after readers release their retained handles.
390            handle.mark_deleted();
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use std::collections::HashMap;
398
399    use common_time::Timestamp;
400    use object_store::services::Memory;
401
402    use super::*;
403    use crate::series_index::catalog::{RangeIndexEntry, SeriesIndexEntry};
404    use crate::series_index::purger::series_index_channel;
405
406    #[rstest::rstest]
407    fn test_prune_index_version(
408        #[values(false, true)] remove_range: bool,
409        #[values(false, true)] remove_series: bool,
410    ) {
411        let range_id = FileId::random();
412        let series_id = FileId::random();
413        let store = ObjectStore::new(Memory::default()).unwrap();
414        let (purger, mut receiver) = series_index_channel(store);
415        let current = SeriesIndexVersion::new(
416            HashMap::from([(
417                range_id,
418                RangeIndexEntry {
419                    file_id: range_id,
420                    file_size: 10,
421                },
422            )]),
423            HashMap::from([(
424                series_id,
425                SeriesIndexFileHandle::new(
426                    RegionId::new(1, 1),
427                    SeriesIndexEntry {
428                        index_uuid: series_id,
429                        file_size: 20,
430                        bucket_start: Timestamp::new_second(0),
431                        bucket_end: Timestamp::new_second(100),
432                        source_file_ids: vec![range_id],
433                        min_file_sequence: 1,
434                        max_file_sequence: 1,
435                        compaction_window_secs: 100,
436                        window_sequences: Default::default(),
437                    },
438                    purger,
439                ),
440            )]),
441        );
442        let visible = if remove_range {
443            HashSet::new()
444        } else {
445            HashSet::from([range_id])
446        };
447        // Unknown and repeated IDs must not inflate removal counts.
448        let mut expired = vec![FileId::random()];
449        if remove_series {
450            expired.extend([series_id, series_id]);
451        }
452        let mut stats = ReconcileStats::default();
453        let next = prune_index_version(&current, &visible, &expired, &mut stats);
454        assert_eq!(remove_range || remove_series, next.is_some());
455        assert_eq!(usize::from(remove_range), stats.removed_range);
456        assert_eq!(usize::from(remove_series), stats.removed_series);
457        if let Some(next) = next {
458            assert_eq!(!remove_range, next.range_indexes.contains_key(&range_id));
459            assert_eq!(!remove_series, next.series_indexes.contains_key(&series_id));
460        }
461        assert_eq!(1, current.range_indexes.len());
462        assert_eq!(1, current.series_indexes.len());
463        drop(current);
464        // Pruning alone must never retire published files.
465        assert!(receiver.try_recv().is_err());
466    }
467}