1use 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
43struct ReconcileSnapshot {
45 metadata: RegionMetadataRef,
46 files: Vec<FileHandle>,
47 compaction_time_window: Option<Duration>,
48 ttl: Option<TimeToLive>,
49}
50
51#[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#[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 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 ®ion,
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 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(®ion, Arc::new(next));
145 unpublished.disarm();
146 }
147 Ok(())
148 }
149 .await;
150 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#[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 let buckets = match (allow_builds, *compaction_time_window) {
212 (true, Some(window)) => rounded_bucket_width(requested_bucket_width, window)
213 .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(¤t, &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 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 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 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
306fn 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
338async 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
384fn 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 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 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(¤t, &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 assert!(receiver.try_recv().is_err());
466 }
467}