Skip to main content

risingwave_meta/hummock/manager/compaction/
mod.rs

1// Copyright 2024 RisingWave Labs
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
15use std::collections::{BTreeMap, HashMap, HashSet};
16use std::ops::DerefMut;
17use std::sync::{Arc, LazyLock};
18use std::time::Instant;
19
20use anyhow::Context;
21use compaction_event_loop::{
22    HummockCompactionEventDispatcher, HummockCompactionEventHandler, HummockCompactionEventLoop,
23    HummockCompactorDedicatedEventLoop,
24};
25use fail::fail_point;
26use itertools::Itertools;
27use parking_lot::Mutex;
28use rand::rng as thread_rng;
29use rand::seq::SliceRandom;
30use risingwave_common::catalog::TableId;
31use risingwave_common::config::meta::default::compaction_config;
32use risingwave_common::util::epoch::Epoch;
33use risingwave_hummock_sdk::compact_task::{CompactTask, CompactTaskAssignment, ReportTask};
34use risingwave_hummock_sdk::compaction_group::StateTableId;
35use risingwave_hummock_sdk::compaction_group::hummock_version_ext::safe_epoch_table_watermarks_impl;
36use risingwave_hummock_sdk::level::Levels;
37use risingwave_hummock_sdk::sstable_info::SstableInfo;
38use risingwave_hummock_sdk::table_stats::{
39    PbTableStatsMap, add_prost_table_stats_map, purge_prost_table_stats,
40};
41use risingwave_hummock_sdk::table_watermark::TableWatermarks;
42use risingwave_hummock_sdk::version::{GroupDelta, IntraLevelDelta};
43use risingwave_hummock_sdk::{
44    CompactionGroupId, HummockCompactionTaskId, HummockContextId, HummockSstableId,
45    HummockSstableObjectId, HummockVersionId, compact_task_to_string, statistics_compact_task,
46};
47use risingwave_meta_model::hummock_sequence::COMPACTION_TASK_ID;
48use risingwave_pb::hummock::compact_task::{TaskStatus, TaskType};
49use risingwave_pb::hummock::subscribe_compaction_event_response::Event as ResponseEvent;
50use risingwave_pb::hummock::{
51    CompactTaskAssignment as PbCompactTaskAssignment, CompactionConfig, PbCompactStatus,
52    SubscribeCompactionEventRequest, TableOption, compact_task,
53};
54use thiserror_ext::AsReport;
55use tokio::sync::mpsc::UnboundedReceiver;
56use tokio::sync::oneshot::{Receiver, Sender};
57use tokio::task::JoinHandle;
58use tonic::Streaming;
59use tracing::warn;
60
61use crate::hummock::compaction::in_progress_compaction::InProgressCompactionView;
62use crate::hummock::compaction::selector::level_selector::PickerInfo;
63use crate::hummock::compaction::selector::{
64    DynamicLevelSelector, DynamicLevelSelectorCore, LocalSelectorStatistic, ManualCompactionOption,
65    ManualCompactionSelector, SpaceReclaimCompactionSelector, TombstoneCompactionSelector,
66    TtlCompactionSelector, VnodeWatermarkCompactionSelector,
67};
68use crate::hummock::compaction::{
69    CompactStatus, CompactionDeveloperConfig, CompactionSelector,
70    CompactionTask as PickedCompactionTask,
71};
72use crate::hummock::error::{Error, Result};
73use crate::hummock::manager::CompactionTaskReportResult;
74use crate::hummock::manager::compaction::compact_task_builder::{
75    CompactTaskBuildContext, attach_compact_task_table_metadata, build_base_compact_task,
76};
77use crate::hummock::manager::transaction::{
78    HummockVersionStatsTransaction, HummockVersionTransaction,
79};
80use crate::hummock::manager::versioning::Versioning;
81use crate::hummock::metrics_utils::{
82    build_compact_task_level_type_metrics_label, trigger_compact_tasks_stat,
83    trigger_local_table_stat,
84};
85use crate::hummock::model::CompactionGroup;
86use crate::hummock::{HummockManager, commit_multi_var};
87use crate::manager::META_NODE_ID;
88use crate::model::BTreeMapTransaction;
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum ManualCompactionTriggerResult {
92    Submitted,
93    Retry,
94}
95
96mod compact_task_builder;
97pub mod compaction_event_loop;
98pub mod compaction_group_manager;
99pub mod compaction_group_schedule;
100
101static CANCEL_STATUS_SET: LazyLock<HashSet<TaskStatus>> = LazyLock::new(|| {
102    [
103        TaskStatus::ManualCanceled,
104        TaskStatus::SendFailCanceled,
105        TaskStatus::AssignFailCanceled,
106        TaskStatus::HeartbeatCanceled,
107        TaskStatus::InvalidGroupCanceled,
108        TaskStatus::NoAvailMemoryResourceCanceled,
109        TaskStatus::NoAvailCpuResourceCanceled,
110        TaskStatus::HeartbeatProgressCanceled,
111    ]
112    .into_iter()
113    .collect()
114});
115
116fn init_selectors() -> HashMap<compact_task::TaskType, Box<dyn CompactionSelector>> {
117    let mut compaction_selectors: HashMap<compact_task::TaskType, Box<dyn CompactionSelector>> =
118        HashMap::default();
119    compaction_selectors.insert(
120        compact_task::TaskType::Dynamic,
121        Box::<DynamicLevelSelector>::default(),
122    );
123    compaction_selectors.insert(
124        compact_task::TaskType::SpaceReclaim,
125        Box::<SpaceReclaimCompactionSelector>::default(),
126    );
127    compaction_selectors.insert(
128        compact_task::TaskType::Ttl,
129        Box::<TtlCompactionSelector>::default(),
130    );
131    compaction_selectors.insert(
132        compact_task::TaskType::Tombstone,
133        Box::<TombstoneCompactionSelector>::default(),
134    );
135    compaction_selectors.insert(
136        compact_task::TaskType::VnodeWatermark,
137        Box::<VnodeWatermarkCompactionSelector>::default(),
138    );
139    compaction_selectors
140}
141
142enum BuiltCompactTask {
143    MetaFinished(CompactTask),
144    PendingAssignment(CompactTask),
145}
146
147impl HummockVersionTransaction<'_> {
148    fn apply_compact_task(&mut self, compact_task: &CompactTask) {
149        let mut version_delta = self.new_delta();
150        let trivial_move = compact_task.is_trivial_move_task();
151        version_delta.trivial_move = trivial_move;
152
153        let group_deltas = &mut version_delta
154            .group_deltas
155            .entry(compact_task.compaction_group_id)
156            .or_default()
157            .group_deltas;
158        let mut removed_table_ids_map: BTreeMap<u32, HashSet<HummockSstableId>> =
159            BTreeMap::default();
160
161        for level in &compact_task.input_ssts {
162            let level_idx = level.level_idx;
163
164            removed_table_ids_map
165                .entry(level_idx)
166                .or_default()
167                .extend(level.table_infos.iter().map(|sst| sst.sst_id));
168        }
169
170        for (level_idx, removed_table_ids) in removed_table_ids_map {
171            let group_delta = GroupDelta::IntraLevel(IntraLevelDelta::new(
172                level_idx,
173                0, // default
174                removed_table_ids,
175                vec![], // default
176                0,      // default
177                compact_task.compaction_group_version_id,
178            ));
179
180            group_deltas.push(group_delta);
181        }
182
183        let group_delta = GroupDelta::IntraLevel(IntraLevelDelta::new(
184            compact_task.target_level,
185            compact_task.target_sub_level_id,
186            HashSet::new(), // default
187            compact_task.sorted_output_ssts.clone(),
188            compact_task.split_weight_by_vnode,
189            compact_task.compaction_group_version_id,
190        ));
191
192        group_deltas.push(group_delta);
193        version_delta.pre_apply();
194    }
195}
196
197#[derive(Default)]
198pub struct Compaction {
199    /// Compaction task that is already assigned to a compactor
200    pub compact_task_assignment: BTreeMap<HummockCompactionTaskId, CompactTaskAssignment>,
201    /// `CompactStatus` of each compaction group
202    pub compaction_statuses: BTreeMap<CompactionGroupId, CompactStatus>,
203
204    pub _deterministic_mode: bool,
205}
206
207impl HummockManager {
208    pub async fn get_assigned_compact_task_num(&self) -> u64 {
209        self.compaction.read().await.compact_task_assignment.len() as u64
210    }
211
212    pub async fn list_compaction_status(
213        &self,
214    ) -> (Vec<PbCompactStatus>, Vec<PbCompactTaskAssignment>) {
215        let (compaction_statuses, compact_task_assignments) = {
216            let compaction = self.compaction.read().await;
217            (
218                compaction
219                    .compaction_statuses
220                    .values()
221                    .map_into()
222                    .collect_vec(),
223                compaction
224                    .compact_task_assignment
225                    .values()
226                    .cloned()
227                    .collect_vec(),
228            )
229        };
230
231        (
232            compaction_statuses,
233            compact_task_assignments
234                .into_iter()
235                .map(PbCompactTaskAssignment::from)
236                .collect(),
237        )
238    }
239
240    pub async fn get_compaction_scores(
241        &self,
242        compaction_group_id: CompactionGroupId,
243    ) -> Vec<PickerInfo> {
244        let (status, levels, group) = {
245            let compaction = self.compaction.read().await;
246            let versioning = self.versioning.read().await;
247            let config_manager = self.compaction_group_manager.read().await;
248            match (
249                compaction.compaction_statuses.get(&compaction_group_id),
250                versioning.current_version.levels.get(&compaction_group_id),
251                config_manager.try_get_compaction_group_config(compaction_group_id),
252            ) {
253                (Some(cs), Some(v), Some(cf)) => (cs.to_owned(), v.to_owned(), cf),
254                _ => {
255                    return vec![];
256                }
257            }
258        };
259        let dynamic_level_core = DynamicLevelSelectorCore::new(
260            group.compaction_config,
261            Arc::new(CompactionDeveloperConfig::default()),
262        );
263        let ctx = dynamic_level_core.get_priority_levels(&levels, &status.level_handlers);
264        ctx.score_levels
265    }
266}
267
268impl HummockManager {
269    pub fn compaction_event_loop(
270        hummock_manager: Arc<Self>,
271        compactor_streams_change_rx: UnboundedReceiver<(
272            HummockContextId,
273            Streaming<SubscribeCompactionEventRequest>,
274        )>,
275    ) -> Vec<(JoinHandle<()>, Sender<()>)> {
276        let mut join_handle_vec = Vec::default();
277
278        let hummock_compaction_event_handler =
279            HummockCompactionEventHandler::new(hummock_manager.clone());
280
281        let dedicated_event_loop = HummockCompactorDedicatedEventLoop::new(
282            hummock_manager.clone(),
283            hummock_compaction_event_handler.clone(),
284        );
285
286        let (dedicated_event_loop_join_handle, event_tx, shutdown_tx) = dedicated_event_loop.run();
287        join_handle_vec.push((dedicated_event_loop_join_handle, shutdown_tx));
288
289        let hummock_compaction_event_dispatcher = HummockCompactionEventDispatcher::new(
290            hummock_manager.env.opts.clone(),
291            hummock_compaction_event_handler,
292            Some(event_tx),
293        );
294
295        let event_loop = HummockCompactionEventLoop::new(
296            hummock_compaction_event_dispatcher,
297            hummock_manager.metrics.clone(),
298            compactor_streams_change_rx,
299        );
300
301        let (event_loop_join_handle, event_loop_shutdown_tx) = event_loop.run();
302        join_handle_vec.push((event_loop_join_handle, event_loop_shutdown_tx));
303
304        join_handle_vec
305    }
306
307    pub fn add_compactor_stream(
308        &self,
309        context_id: HummockContextId,
310        req_stream: Streaming<SubscribeCompactionEventRequest>,
311    ) {
312        self.compactor_streams_change_tx
313            .send((context_id, req_stream))
314            .unwrap();
315    }
316}
317
318impl HummockManager {
319    /// Gets one compaction task id with best-effort batching while ensuring concurrent callers
320    /// share the same refill instead of wasting an allocated range.
321    async fn next_compaction_task_id_with_prefetch(&self, refill_capacity: u32) -> Result<u64> {
322        self.prefetched_compaction_task_ids
323            .next(refill_capacity, |count| async move {
324                self.env
325                    .hummock_seq
326                    .next_interval(COMPACTION_TASK_ID, count)
327                    .await
328            })
329            .await
330    }
331
332    pub async fn get_compact_tasks_impl(
333        &self,
334        compaction_groups: Vec<CompactionGroupId>,
335        max_select_count: usize,
336        selector: &mut dyn CompactionSelector,
337    ) -> Result<(Vec<CompactTask>, Vec<CompactionGroupId>)> {
338        let deterministic_mode = self.env.opts.compaction_deterministic_test;
339
340        let mut compaction_guard = self
341            .compaction
342            .write_with_process_name("get_compact_tasks_impl")
343            .await;
344        let mut versioning_guard = self
345            .versioning
346            .write_with_process_name("get_compact_tasks_impl")
347            .await;
348        let compaction: &mut Compaction = &mut compaction_guard;
349        let versioning: &mut Versioning = &mut versioning_guard;
350
351        let start_time = Instant::now();
352        let mut compaction_statuses = BTreeMapTransaction::new(&mut compaction.compaction_statuses);
353
354        let mut compact_task_assignment =
355            BTreeMapTransaction::new(&mut compaction.compact_task_assignment);
356
357        let mut version = HummockVersionTransaction::new(
358            &mut versioning.current_version,
359            &mut versioning.hummock_version_deltas,
360            &mut versioning.table_change_log,
361            self.env.notification_manager(),
362            None,
363            &self.metrics,
364            &self.env.opts,
365            &self.version_stat_tx,
366        );
367        // Apply stats changes.
368        let mut version_stats = HummockVersionStatsTransaction::new(
369            &mut versioning.version_stats,
370            self.env.notification_manager(),
371        );
372
373        if deterministic_mode {
374            version.disable_apply_to_txn();
375        }
376        let all_versioned_table_schemas = if self.env.opts.enable_dropped_column_reclaim {
377            self.metadata_manager
378                .catalog_controller
379                .get_versioned_table_schemas()
380                .await
381                .map_err(|e| Error::Internal(e.into()))?
382        } else {
383            HashMap::default()
384        };
385        let mut unschedule_groups = vec![];
386        let mut trivial_tasks = vec![];
387        let mut pick_tasks = vec![];
388        let developer_config = Arc::new(CompactionDeveloperConfig::new_from_meta_opts(
389            &self.env.opts,
390        ));
391        // Reuse prefetched task ids from previous loops.
392        // Each group consumes at most one task_id (trivial tasks share the same id with normal
393        // task). When prefetched ids are exhausted, refill in fixed-size chunks to avoid
394        // per-group SQL transactions while keeping the in-memory cache small.
395        'outside: for compaction_group_id in compaction_groups {
396            if pick_tasks.len() >= max_select_count {
397                break;
398            }
399
400            if !version
401                .latest_version()
402                .levels
403                .contains_key(&compaction_group_id)
404            {
405                continue;
406            }
407
408            // When the last table of a compaction group is deleted, the compaction group (and its
409            // config) is destroyed as well. Then a compaction task for this group may come later and
410            // cannot find its config.
411            let group_config = {
412                let config_manager = self.compaction_group_manager.read().await;
413
414                match config_manager.try_get_compaction_group_config(compaction_group_id) {
415                    Some(config) => config,
416                    None => continue,
417                }
418            };
419
420            // Use prefetched task id if available; when exhausted, refill in chunks first.
421            let task_id = self
422                .next_compaction_task_id_with_prefetch(
423                    self.env.opts.compaction_task_id_refill_capacity,
424                )
425                .await?;
426
427            if !compaction_statuses.contains_key(&compaction_group_id) {
428                // lazy initialize.
429                compaction_statuses.insert(
430                    compaction_group_id,
431                    CompactStatus::new(
432                        compaction_group_id,
433                        group_config.compaction_config.max_level,
434                    ),
435                );
436            }
437            let mut compact_status = compaction_statuses.get_mut(compaction_group_id).unwrap();
438
439            let mut stats = LocalSelectorStatistic::default();
440            let member_table_ids: Vec<_> = version
441                .latest_version()
442                .state_table_info
443                .compaction_group_member_table_ids(compaction_group_id)
444                .iter()
445                .copied()
446                .collect();
447
448            let mut table_id_to_option: HashMap<TableId, _> = HashMap::default();
449
450            {
451                let guard = self.table_id_to_table_option.read();
452                for table_id in &member_table_ids {
453                    if let Some(opts) = guard.get(table_id) {
454                        table_id_to_option.insert(*table_id, *opts);
455                    }
456                }
457            }
458
459            let in_progress_compactions = InProgressCompactionView::for_group(
460                compact_task_assignment.tree_ref().values(),
461                compaction_group_id,
462            );
463
464            while let Some(picked_task) = compact_status.get_compact_task(
465                version
466                    .latest_version()
467                    .get_compaction_group_levels(compaction_group_id),
468                version
469                    .latest_version()
470                    .state_table_info
471                    .compaction_group_member_table_ids(compaction_group_id),
472                task_id as HummockCompactionTaskId,
473                &group_config,
474                &mut stats,
475                selector,
476                &table_id_to_option,
477                developer_config.clone(),
478                &version.latest_version().table_watermarks,
479                &version.latest_version().state_table_info,
480                &in_progress_compactions,
481            ) {
482                let compaction_group_levels = version
483                    .latest_version()
484                    .get_compaction_group_levels(compaction_group_id);
485                let target_level_id = picked_task.input.target_level as u32;
486                let is_target_level_last = compaction_group_levels.is_last_level(target_level_id);
487                let table_options = table_id_to_option
488                    .iter()
489                    .map(|(table_id, table_option)| (*table_id, TableOption::from(table_option)))
490                    .collect();
491                let built_compact_task = self.build_ready_compact_task(
492                    picked_task,
493                    CompactTaskBuildContext {
494                        task_id,
495                        compaction_group_id: group_config.group_id,
496                        compaction_group_version_id: compaction_group_levels
497                            .compaction_group_version_id,
498                        existing_table_ids: member_table_ids.clone(),
499                        table_options,
500                        is_target_level_last,
501                        compaction_config: group_config.compaction_config.clone(),
502                        current_epoch_time: Epoch::now().0,
503                    },
504                    &version.latest_version().table_watermarks,
505                    &all_versioned_table_schemas,
506                );
507
508                match built_compact_task {
509                    BuiltCompactTask::MetaFinished(compact_task) => {
510                        let label = compact_task.task_label();
511                        tracing::debug!(
512                            "{} for compaction group {}: input: {:?}, cost time: {:?}",
513                            label,
514                            compact_task.compaction_group_id,
515                            compact_task.input_ssts,
516                            start_time.elapsed()
517                        );
518                        compact_status.report_compact_task(&compact_task);
519                        update_table_stats_for_vnode_watermark_trivial_reclaim(
520                            &mut version_stats.table_stats,
521                            &compact_task,
522                        );
523                        self.metrics
524                            .compact_frequency
525                            .with_label_values(&[
526                                label,
527                                &compact_task.compaction_group_id.to_string(),
528                                selector.task_type().as_str_name(),
529                                "SUCCESS",
530                            ])
531                            .inc();
532
533                        version.apply_compact_task(&compact_task);
534                        trivial_tasks.push(compact_task);
535                        if trivial_tasks.len() >= self.env.opts.max_trivial_move_task_count_per_loop
536                        {
537                            break 'outside;
538                        }
539                    }
540                    BuiltCompactTask::PendingAssignment(compact_task) => {
541                        compact_task_assignment.insert(
542                            compact_task.task_id,
543                            CompactTaskAssignment {
544                                compact_task: compact_task.clone(),
545                                context_id: META_NODE_ID, // deprecated
546                            },
547                        );
548
549                        pick_tasks.push(compact_task);
550                        break;
551                    }
552                }
553
554                stats.report_to_metrics(compaction_group_id, self.metrics.as_ref());
555                stats = LocalSelectorStatistic::default();
556            }
557            if pick_tasks
558                .last()
559                .map(|task| task.compaction_group_id != compaction_group_id)
560                .unwrap_or(true)
561            {
562                unschedule_groups.push(compaction_group_id);
563            }
564            stats.report_to_metrics(compaction_group_id, self.metrics.as_ref());
565        }
566
567        if !trivial_tasks.is_empty() {
568            commit_multi_var!(
569                self.meta_store_ref(),
570                compaction_statuses,
571                compact_task_assignment,
572                version,
573                version_stats
574            )?;
575            self.metrics
576                .compact_task_batch_count
577                .with_label_values(&["batch_trivial_move"])
578                .observe(trivial_tasks.len() as f64);
579
580            for trivial_task in &trivial_tasks {
581                self.metrics
582                    .compact_task_trivial_move_sst_count
583                    .with_label_values(&[&trivial_task.compaction_group_id.to_string()])
584                    .observe(trivial_task.input_ssts[0].table_infos.len() as _);
585            }
586
587            drop(versioning_guard);
588        } else {
589            // We are using a single transaction to ensure that each task has progress when it is
590            // created.
591            drop(versioning_guard);
592            commit_multi_var!(
593                self.meta_store_ref(),
594                compaction_statuses,
595                compact_task_assignment
596            )?;
597        }
598        drop(compaction_guard);
599        if !pick_tasks.is_empty() {
600            self.metrics
601                .compact_task_batch_count
602                .with_label_values(&["batch_get_compact_task"])
603                .observe(pick_tasks.len() as f64);
604        }
605
606        for compact_task in &mut pick_tasks {
607            let compaction_group_id = compact_task.compaction_group_id;
608
609            // Initiate heartbeat for the task to track its progress.
610            self.compactor_manager
611                .initiate_task_heartbeat(compact_task.clone());
612
613            // this task has been finished.
614            compact_task.task_status = TaskStatus::Pending;
615            let compact_task_statistics = statistics_compact_task(compact_task);
616
617            let level_type_label = build_compact_task_level_type_metrics_label(
618                compact_task.input_ssts[0].level_idx as usize,
619                compact_task.input_ssts.last().unwrap().level_idx as usize,
620            );
621
622            let level_count = compact_task.input_ssts.len();
623            if compact_task.input_ssts[0].level_idx == 0 {
624                self.metrics
625                    .l0_compact_level_count
626                    .with_label_values(&[&compaction_group_id.to_string(), &level_type_label])
627                    .observe(level_count as _);
628            }
629
630            self.metrics
631                .compact_task_size
632                .with_label_values(&[&compaction_group_id.to_string(), &level_type_label])
633                .observe(compact_task_statistics.total_file_size as _);
634
635            self.metrics
636                .compact_task_size
637                .with_label_values(&[
638                    &compaction_group_id.to_string(),
639                    &format!("{} uncompressed", level_type_label),
640                ])
641                .observe(compact_task_statistics.total_uncompressed_file_size as _);
642
643            self.metrics
644                .compact_task_file_count
645                .with_label_values(&[&compaction_group_id.to_string(), &level_type_label])
646                .observe(compact_task_statistics.total_file_count as _);
647
648            tracing::trace!(
649                "For compaction group {}: pick up {} {} sub_level in level {} to compact to target {}. cost time: {:?} compact_task_statistics {:?}",
650                compaction_group_id,
651                level_count,
652                compact_task.input_ssts[0].level_type.as_str_name(),
653                compact_task.input_ssts[0].level_idx,
654                compact_task.target_level,
655                start_time.elapsed(),
656                compact_task_statistics
657            );
658        }
659
660        #[cfg(test)]
661        {
662            self.check_state_consistency().await;
663        }
664        pick_tasks.extend(trivial_tasks);
665        Ok((pick_tasks, unschedule_groups))
666    }
667
668    /// Cancels a compaction task no matter it's assigned or unassigned.
669    pub async fn cancel_compact_task(&self, task_id: u64, task_status: TaskStatus) -> Result<bool> {
670        fail_point!("fp_cancel_compact_task", |_| Err(Error::MetaStore(
671            anyhow::anyhow!("failpoint metastore err")
672        )));
673        let ret = self
674            .cancel_compact_task_impl(vec![task_id], task_status)
675            .await?;
676        Ok(ret[0])
677    }
678
679    pub async fn cancel_compact_tasks(
680        &self,
681        tasks: Vec<u64>,
682        task_status: TaskStatus,
683    ) -> Result<Vec<bool>> {
684        self.cancel_compact_task_impl(tasks, task_status).await
685    }
686
687    async fn cancel_compact_task_impl(
688        &self,
689        task_ids: Vec<u64>,
690        task_status: TaskStatus,
691    ) -> Result<Vec<bool>> {
692        assert!(CANCEL_STATUS_SET.contains(&task_status));
693        let tasks = task_ids
694            .into_iter()
695            .map(|task_id| ReportTask {
696                task_id,
697                task_status,
698                sorted_output_ssts: vec![],
699                table_stats_change: HashMap::default(),
700                object_timestamps: HashMap::default(),
701            })
702            .collect_vec();
703        let rets = self.report_compact_tasks(tasks).await?;
704        #[cfg(test)]
705        {
706            self.check_state_consistency().await;
707        }
708        Ok(rets)
709    }
710
711    async fn get_compact_tasks(
712        &self,
713        mut compaction_groups: Vec<CompactionGroupId>,
714        max_select_count: usize,
715        selector: &mut dyn CompactionSelector,
716    ) -> Result<(Vec<CompactTask>, Vec<CompactionGroupId>)> {
717        fail_point!("fp_get_compact_task", |_| Err(Error::MetaStore(
718            anyhow::anyhow!("failpoint metastore error")
719        )));
720        compaction_groups.shuffle(&mut thread_rng());
721        let (mut tasks, groups) = self
722            .get_compact_tasks_impl(compaction_groups, max_select_count, selector)
723            .await?;
724        tasks.retain(|task| {
725            if task.task_status == TaskStatus::Success {
726                debug_assert!(task.is_trivial_reclaim() || task.is_trivial_move_task());
727                false
728            } else {
729                true
730            }
731        });
732        Ok((tasks, groups))
733    }
734
735    pub async fn get_compact_task(
736        &self,
737        compaction_group_id: CompactionGroupId,
738        selector: &mut dyn CompactionSelector,
739    ) -> Result<Option<CompactTask>> {
740        fail_point!("fp_get_compact_task", |_| Err(Error::MetaStore(
741            anyhow::anyhow!("failpoint metastore error")
742        )));
743
744        let (normal_tasks, _) = self
745            .get_compact_tasks_impl(vec![compaction_group_id], 1, selector)
746            .await?;
747        for task in normal_tasks {
748            if task.task_status != TaskStatus::Success {
749                return Ok(Some(task));
750            }
751            debug_assert!(task.is_trivial_reclaim() || task.is_trivial_move_task());
752        }
753        Ok(None)
754    }
755
756    pub async fn manual_get_compact_task(
757        &self,
758        compaction_group_id: CompactionGroupId,
759        manual_compaction_option: ManualCompactionOption,
760    ) -> Result<Option<CompactTask>> {
761        let (task, _) = self
762            .manual_get_compact_task_with_info(compaction_group_id, manual_compaction_option)
763            .await?;
764        Ok(task)
765    }
766
767    pub async fn manual_get_compact_task_with_info(
768        &self,
769        compaction_group_id: CompactionGroupId,
770        manual_compaction_option: ManualCompactionOption,
771    ) -> Result<(Option<CompactTask>, bool)> {
772        let mut selector = ManualCompactionSelector::new(manual_compaction_option);
773        let task = self
774            .get_compact_task(compaction_group_id, &mut selector)
775            .await?;
776        if let Some(err) = selector.validation_error() {
777            return Err(Error::InvalidManualCompactionOption(err.to_owned()));
778        }
779        Ok((task, selector.blocked_by_pending()))
780    }
781
782    pub async fn report_compact_task(
783        &self,
784        task_id: u64,
785        task_status: TaskStatus,
786        sorted_output_ssts: Vec<SstableInfo>,
787        table_stats_change: Option<PbTableStatsMap>,
788        object_timestamps: HashMap<HummockSstableObjectId, u64>,
789    ) -> Result<bool> {
790        let rets = self
791            .report_compact_tasks(vec![ReportTask {
792                task_id,
793                task_status,
794                sorted_output_ssts,
795                table_stats_change: table_stats_change.unwrap_or_default(),
796                object_timestamps,
797            }])
798            .await?;
799        Ok(rets[0])
800    }
801
802    pub async fn report_compact_tasks(&self, report_tasks: Vec<ReportTask>) -> Result<Vec<bool>> {
803        let compaction_guard = self
804            .compaction
805            .write_with_process_name("report_compact_tasks")
806            .await;
807        let versioning_guard = self
808            .versioning
809            .write_with_process_name("report_compact_tasks")
810            .await;
811
812        self.report_compact_tasks_impl(report_tasks, compaction_guard, versioning_guard)
813            .await
814    }
815
816    /// Finishes or cancels a compaction task, according to `task_status`.
817    ///
818    /// If `context_id` is not None, its validity will be checked when writing meta store.
819    /// Its ownership of the task is checked as well.
820    ///
821    /// Return Ok(false) indicates either the task is not found,
822    /// or the task is not owned by `context_id` when `context_id` is not None.
823    pub async fn report_compact_tasks_impl(
824        &self,
825        report_tasks: Vec<ReportTask>,
826        mut compaction_guard: impl DerefMut<Target = Compaction>,
827        mut versioning_guard: impl DerefMut<Target = Versioning>,
828    ) -> Result<Vec<bool>> {
829        let deterministic_mode = self.env.opts.compaction_deterministic_test;
830        let compaction: &mut Compaction = &mut compaction_guard;
831        let start_time = Instant::now();
832        let original_keys = compaction.compaction_statuses.keys().cloned().collect_vec();
833        let mut compact_statuses = BTreeMapTransaction::new(&mut compaction.compaction_statuses);
834        let mut rets = vec![false; report_tasks.len()];
835        let mut compact_task_assignment =
836            BTreeMapTransaction::new(&mut compaction.compact_task_assignment);
837        // The compaction task is finished.
838        let versioning: &mut Versioning = &mut versioning_guard;
839
840        // purge stale compact_status
841        for group_id in original_keys {
842            if !versioning.current_version.levels.contains_key(&group_id) {
843                compact_statuses.remove(group_id);
844            }
845        }
846        let mut tasks = vec![];
847
848        let mut version = HummockVersionTransaction::new(
849            &mut versioning.current_version,
850            &mut versioning.hummock_version_deltas,
851            &mut versioning.table_change_log,
852            self.env.notification_manager(),
853            None,
854            &self.metrics,
855            &self.env.opts,
856            &self.version_stat_tx,
857        );
858
859        if deterministic_mode {
860            version.disable_apply_to_txn();
861        }
862
863        let mut version_stats = HummockVersionStatsTransaction::new(
864            &mut versioning.version_stats,
865            self.env.notification_manager(),
866        );
867        let mut success_count = 0;
868        let mut report_results = Vec::with_capacity(rets.len());
869        for (idx, task) in report_tasks.into_iter().enumerate() {
870            rets[idx] = true;
871            let task_id = task.task_id;
872            let mut task_status = task.task_status;
873            let mut compact_task = match compact_task_assignment.remove(task.task_id) {
874                Some(compact_task_assignment) => compact_task_assignment.compact_task,
875                None => {
876                    tracing::warn!("{}", format!("compact task {} not found", task.task_id));
877                    rets[idx] = false;
878                    report_results.push(CompactionTaskReportResult {
879                        task_id,
880                        task_status,
881                        reported: false,
882                    });
883                    continue;
884                }
885            };
886
887            {
888                // apply result
889                compact_task.task_status = task.task_status;
890                compact_task.sorted_output_ssts = task.sorted_output_ssts;
891            }
892
893            match compact_statuses.get_mut(compact_task.compaction_group_id) {
894                Some(mut compact_status) => {
895                    compact_status.report_compact_task(&compact_task);
896                }
897                None => {
898                    // When the group_id is not found in the compaction_statuses, it means the group has been removed.
899                    // The task is invalid and should be canceled.
900                    // e.g.
901                    // 1. The group is removed by the user unregistering the tables
902                    // 2. The group is removed by the group scheduling algorithm
903                    compact_task.task_status = TaskStatus::InvalidGroupCanceled;
904                }
905            }
906
907            let is_success = if let TaskStatus::Success = compact_task.task_status {
908                match self
909                    .report_compaction_sanity_check(&task.object_timestamps)
910                    .await
911                {
912                    Err(e) => {
913                        warn!(
914                            "failed to commit compaction task {} {}",
915                            compact_task.task_id,
916                            e.as_report()
917                        );
918                        compact_task.task_status = TaskStatus::RetentionTimeRejected;
919                        false
920                    }
921                    _ => {
922                        let group = version
923                            .latest_version()
924                            .levels
925                            .get(&compact_task.compaction_group_id)
926                            .unwrap();
927                        let is_expired = compact_task.is_expired(group.compaction_group_version_id);
928                        if is_expired {
929                            compact_task.task_status = TaskStatus::InputOutdatedCanceled;
930                            warn!(
931                                "The task may be expired because of group split, task:\n {:?}",
932                                compact_task_to_string(&compact_task)
933                            );
934                        }
935                        !is_expired
936                    }
937                }
938            } else {
939                false
940            };
941            if is_success {
942                success_count += 1;
943                version.apply_compact_task(&compact_task);
944                if purge_prost_table_stats(
945                    &mut version_stats.table_stats,
946                    version.latest_version(),
947                    &HashSet::default(),
948                ) {
949                    self.metrics.version_stats.reset();
950                    versioning.local_metrics.clear();
951                }
952                add_prost_table_stats_map(&mut version_stats.table_stats, &task.table_stats_change);
953                trigger_local_table_stat(
954                    &self.metrics,
955                    &mut versioning.local_metrics,
956                    &version_stats,
957                    &task.table_stats_change,
958                );
959            }
960            task_status = compact_task.task_status;
961            report_results.push(CompactionTaskReportResult {
962                task_id,
963                task_status,
964                reported: rets[idx],
965            });
966            tasks.push(compact_task);
967        }
968        if success_count > 0 {
969            commit_multi_var!(
970                self.meta_store_ref(),
971                compact_statuses,
972                compact_task_assignment,
973                version,
974                version_stats
975            )?;
976
977            self.metrics
978                .compact_task_batch_count
979                .with_label_values(&["batch_report_task"])
980                .observe(success_count as f64);
981        } else {
982            // The compaction task is cancelled or failed.
983            commit_multi_var!(
984                self.meta_store_ref(),
985                compact_statuses,
986                compact_task_assignment
987            )?;
988        }
989
990        self.notify_compaction_task_report_waiters(report_results);
991
992        let mut success_groups = vec![];
993        for compact_task in &tasks {
994            self.compactor_manager
995                .remove_task_heartbeat(compact_task.task_id);
996            tracing::trace!(
997                "Reported compaction task. {}. cost time: {:?}",
998                compact_task_to_string(compact_task),
999                start_time.elapsed(),
1000            );
1001
1002            if !deterministic_mode
1003                && (matches!(compact_task.task_type, compact_task::TaskType::Dynamic)
1004                    || matches!(compact_task.task_type, compact_task::TaskType::Emergency))
1005            {
1006                // only try send Dynamic compaction
1007                self.try_send_compaction_request(
1008                    compact_task.compaction_group_id,
1009                    compact_task::TaskType::Dynamic,
1010                );
1011            }
1012
1013            if compact_task.task_status == TaskStatus::Success {
1014                success_groups.push(compact_task.compaction_group_id);
1015            }
1016        }
1017
1018        trigger_compact_tasks_stat(
1019            &self.metrics,
1020            &tasks,
1021            &compaction.compaction_statuses,
1022            &versioning_guard.current_version,
1023        );
1024        drop(versioning_guard);
1025        if !success_groups.is_empty() {
1026            self.try_update_write_limits(&success_groups).await;
1027        }
1028        Ok(rets)
1029    }
1030
1031    /// Triggers compacitons to specified compaction groups.
1032    /// Don't wait for compaction finish
1033    pub async fn trigger_compaction_deterministic(
1034        &self,
1035        _base_version_id: HummockVersionId,
1036        compaction_groups: Vec<CompactionGroupId>,
1037    ) -> Result<()> {
1038        self.on_current_version(|old_version| {
1039            tracing::info!(
1040                "Trigger compaction for version {}, groups {:?}",
1041                old_version.id,
1042                compaction_groups
1043            );
1044        })
1045        .await;
1046
1047        if compaction_groups.is_empty() {
1048            return Ok(());
1049        }
1050        for compaction_group in compaction_groups {
1051            self.try_send_compaction_request(compaction_group, compact_task::TaskType::Dynamic);
1052        }
1053        Ok(())
1054    }
1055
1056    pub async fn trigger_manual_compaction(
1057        &self,
1058        compaction_group: CompactionGroupId,
1059        manual_compaction_option: ManualCompactionOption,
1060    ) -> Result<ManualCompactionTriggerResult> {
1061        let start_time = Instant::now();
1062        let exclusive = manual_compaction_option.exclusive;
1063
1064        // 1. Get idle compactor.
1065        let compactor = match self.compactor_manager.next_compactor() {
1066            Some(compactor) => compactor,
1067            None => {
1068                tracing::warn!("trigger_manual_compaction No compactor is available.");
1069                return Err(anyhow::anyhow!(
1070                    "trigger_manual_compaction No compactor is available. compaction_group {}",
1071                    compaction_group
1072                )
1073                .into());
1074            }
1075        };
1076
1077        // 2. Get manual compaction task.
1078        let compact_task = self
1079            .manual_get_compact_task_with_info(compaction_group, manual_compaction_option)
1080            .await;
1081        let (compact_task, blocked_by_pending) = match compact_task {
1082            Ok((compact_task, blocked_by_pending)) => (compact_task, blocked_by_pending),
1083            Err(err) => {
1084                tracing::warn!(error = %err.as_report(), "Failed to get compaction task");
1085                if matches!(err, Error::InvalidManualCompactionOption(_)) {
1086                    return Err(err);
1087                }
1088
1089                return Err(anyhow::anyhow!(err)
1090                    .context(format!(
1091                        "Failed to get compaction task for compaction_group {}",
1092                        compaction_group,
1093                    ))
1094                    .into());
1095            }
1096        };
1097        let compact_task = match compact_task {
1098            Some(compact_task) => compact_task,
1099            None => {
1100                if exclusive && blocked_by_pending {
1101                    return Ok(ManualCompactionTriggerResult::Retry);
1102                }
1103                // No compaction task available.
1104                return Err(anyhow::anyhow!(
1105                    "trigger_manual_compaction No compaction_task is available. compaction_group {}",
1106                    compaction_group
1107                )
1108                .into());
1109            }
1110        };
1111
1112        // 3. send task to compactor
1113        let task_id = compact_task.task_id;
1114        let compact_task_string = compact_task_to_string(&compact_task);
1115        tracing::info!(
1116            compact_task_string,
1117            duration = ?start_time.elapsed(),
1118            "Triggered manual compaction task."
1119        );
1120
1121        let report_rx = self.register_compaction_task_report_waiter(task_id);
1122        if let Err(err) = compactor
1123            .send_event(ResponseEvent::CompactTask(compact_task.into()))
1124            .with_context(|| {
1125                format!(
1126                    "Failed to trigger compaction task for compaction_group {}",
1127                    compaction_group,
1128                )
1129            })
1130        {
1131            self.remove_compaction_task_report_waiter(task_id);
1132            return Err(err.into());
1133        }
1134
1135        let report_result = match report_rx.await {
1136            Ok(result) => result,
1137            Err(_) => {
1138                self.remove_compaction_task_report_waiter(task_id);
1139                return Err(anyhow::anyhow!(
1140                    "trigger_manual_compaction wait report failed. compaction_group {}",
1141                    compaction_group
1142                )
1143                .into());
1144            }
1145        };
1146        if !report_result.reported {
1147            return Err(anyhow::anyhow!(
1148                "trigger_manual_compaction report not accepted. task_id {}",
1149                report_result.task_id
1150            )
1151            .into());
1152        }
1153
1154        if report_result.task_status == TaskStatus::NoAvailCpuResourceCanceled
1155            || report_result.task_status == TaskStatus::NoAvailMemoryResourceCanceled
1156        {
1157            return Ok(ManualCompactionTriggerResult::Retry);
1158        }
1159
1160        tracing::info!(
1161            ?report_result,
1162            duration = ?start_time.elapsed(),
1163            "Completed manual compaction task."
1164        );
1165
1166        Ok(ManualCompactionTriggerResult::Submitted)
1167    }
1168
1169    /// Sends a compaction request for new data (clears cooldown).
1170    pub fn try_send_compaction_request(
1171        &self,
1172        compaction_group: CompactionGroupId,
1173        task_type: compact_task::TaskType,
1174    ) -> bool {
1175        self.compaction_state.try_sched_compaction(
1176            compaction_group,
1177            task_type,
1178            ScheduleTrigger::NewData,
1179        )
1180    }
1181
1182    /// Apply `split_weight_by_vnode` based partition strategy.
1183    /// This handles dynamic partitioning based on table size and write throughput.
1184    fn apply_split_weight_by_vnode_partition(
1185        &self,
1186        compact_task: &mut CompactTask,
1187        compaction_config: &CompactionConfig,
1188        compact_table_ids: &[TableId],
1189    ) {
1190        if compaction_config.split_weight_by_vnode > 0 {
1191            for table_id in compact_table_ids {
1192                compact_task
1193                    .table_vnode_partition
1194                    .insert(*table_id, compact_task.split_weight_by_vnode);
1195            }
1196
1197            return;
1198        }
1199
1200        // Calculate per-table size from normalized input SSTs.
1201        let mut table_size_info: HashMap<TableId, u64> = HashMap::default();
1202        for input_ssts in &compact_task.input_ssts {
1203            for sst in &input_ssts.table_infos {
1204                for table_id in &sst.table_ids {
1205                    *table_size_info.entry(*table_id).or_default() +=
1206                        sst.sst_size / (sst.table_ids.len() as u64);
1207                }
1208            }
1209        }
1210
1211        let hybrid_vnode_count = self.env.opts.hybrid_partition_node_count;
1212        let default_partition_count = self.env.opts.partition_vnode_count;
1213        let compact_task_table_size_partition_threshold_low = self
1214            .env
1215            .opts
1216            .compact_task_table_size_partition_threshold_low;
1217        let compact_task_table_size_partition_threshold_high = self
1218            .env
1219            .opts
1220            .compact_task_table_size_partition_threshold_high;
1221
1222        // Check latest write throughput
1223        let table_write_throughput_statistic_manager =
1224            self.table_write_throughput_statistic_manager.read();
1225        let timestamp = chrono::Utc::now().timestamp();
1226
1227        for (table_id, compact_table_size) in table_size_info {
1228            let write_throughput = table_write_throughput_statistic_manager
1229                .get_table_throughput_descending(table_id, timestamp)
1230                .peekable()
1231                .peek()
1232                .map(|item| item.throughput)
1233                .unwrap_or(0);
1234
1235            if compact_table_size > compact_task_table_size_partition_threshold_high
1236                && default_partition_count > 0
1237            {
1238                compact_task
1239                    .table_vnode_partition
1240                    .insert(table_id, default_partition_count);
1241            } else if (compact_table_size > compact_task_table_size_partition_threshold_low
1242                || (write_throughput > self.env.opts.table_high_write_throughput_threshold
1243                    && compact_table_size > compaction_config.target_file_size_base))
1244                && hybrid_vnode_count > 0
1245            {
1246                compact_task
1247                    .table_vnode_partition
1248                    .insert(table_id, hybrid_vnode_count);
1249            } else if compact_table_size > compaction_config.target_file_size_base {
1250                compact_task.table_vnode_partition.insert(table_id, 1);
1251            }
1252        }
1253
1254        compact_task
1255            .table_vnode_partition
1256            .retain(|table_id, _| compact_table_ids.contains(table_id));
1257    }
1258
1259    pub(crate) fn calculate_vnode_partition(
1260        &self,
1261        compact_task: &mut CompactTask,
1262        compaction_config: &CompactionConfig,
1263        compact_table_ids: &[TableId],
1264    ) {
1265        // Do not split sst by vnode partition when target_level > base_level
1266        // The purpose of data alignment is mainly to improve the parallelism of base level compaction
1267        // and reduce write amplification. However, at high level, the size of the sst file is often
1268        // larger and only contains the data of a single table_id, so there is no need to cut it.
1269        if compact_task.target_level > compact_task.base_level {
1270            return;
1271        }
1272
1273        // Apply split_weight_by_vnode based partition strategy
1274        self.apply_split_weight_by_vnode_partition(
1275            compact_task,
1276            compaction_config,
1277            compact_table_ids,
1278        );
1279    }
1280
1281    fn build_ready_compact_task(
1282        &self,
1283        picked_task: PickedCompactionTask,
1284        context: CompactTaskBuildContext,
1285        table_watermarks: &HashMap<TableId, Arc<TableWatermarks>>,
1286        all_versioned_table_schemas: &HashMap<TableId, Vec<i32>>,
1287    ) -> BuiltCompactTask {
1288        let compaction_config = context.compaction_config.clone();
1289        let (mut compact_task, compact_table_ids) = build_base_compact_task(picked_task, context);
1290
1291        if compact_task.is_trivial_reclaim() {
1292            compact_task.task_status = TaskStatus::Success;
1293            compact_task.sorted_output_ssts.clear();
1294            return BuiltCompactTask::MetaFinished(compact_task);
1295        }
1296
1297        if compact_task.is_trivial_move_task() {
1298            compact_task.task_status = TaskStatus::Success;
1299            compact_task.sorted_output_ssts = compact_task.input_ssts[0]
1300                .read_sstable_infos()
1301                .cloned()
1302                .collect();
1303            return BuiltCompactTask::MetaFinished(compact_task);
1304        }
1305
1306        self.prepare_compact_task_for_assignment(
1307            &mut compact_task,
1308            compaction_config.as_ref(),
1309            &compact_table_ids,
1310            safe_epoch_table_watermarks_impl(table_watermarks, &compact_table_ids),
1311            all_versioned_table_schemas,
1312        );
1313
1314        BuiltCompactTask::PendingAssignment(compact_task)
1315    }
1316
1317    fn prepare_compact_task_for_assignment(
1318        &self,
1319        compact_task: &mut CompactTask,
1320        compaction_config: &CompactionConfig,
1321        compact_table_ids: &[TableId],
1322        table_watermarks: BTreeMap<TableId, TableWatermarks>,
1323        all_versioned_table_schemas: &HashMap<TableId, Vec<i32>>,
1324    ) {
1325        self.calculate_vnode_partition(compact_task, compaction_config, compact_table_ids);
1326        attach_compact_task_table_metadata(
1327            compact_task,
1328            compact_table_ids,
1329            table_watermarks,
1330            all_versioned_table_schemas,
1331        );
1332    }
1333
1334    pub fn compactor_manager_ref(&self) -> crate::hummock::CompactorManagerRef {
1335        self.compactor_manager.clone()
1336    }
1337
1338    fn register_compaction_task_report_waiter(
1339        &self,
1340        task_id: HummockCompactionTaskId,
1341    ) -> Receiver<CompactionTaskReportResult> {
1342        let (tx, rx) = tokio::sync::oneshot::channel();
1343        self.compaction_task_report_notifiers
1344            .lock()
1345            .register(task_id, tx);
1346        rx
1347    }
1348
1349    fn remove_compaction_task_report_waiter(&self, task_id: HummockCompactionTaskId) {
1350        self.compaction_task_report_notifiers.lock().remove(task_id);
1351    }
1352
1353    fn notify_compaction_task_report_waiters(&self, results: Vec<CompactionTaskReportResult>) {
1354        let mut guard = self.compaction_task_report_notifiers.lock();
1355        for result in results {
1356            guard.notify(result);
1357        }
1358    }
1359}
1360
1361#[cfg(any(test, feature = "test"))]
1362impl HummockManager {
1363    pub async fn compaction_task_from_assignment_for_test(
1364        &self,
1365        task_id: u64,
1366    ) -> Option<CompactTaskAssignment> {
1367        let compaction_guard = self.compaction.read().await;
1368        let assignment_ref = &compaction_guard.compact_task_assignment;
1369        assignment_ref.get(&task_id).cloned()
1370    }
1371
1372    pub async fn report_compact_task_for_test(
1373        &self,
1374        task_id: u64,
1375        compact_task: Option<CompactTask>,
1376        task_status: TaskStatus,
1377        sorted_output_ssts: Vec<SstableInfo>,
1378        table_stats_change: Option<PbTableStatsMap>,
1379    ) -> Result<()> {
1380        if let Some(task) = compact_task {
1381            let mut guard = self.compaction.write().await;
1382            guard.compact_task_assignment.insert(
1383                task_id,
1384                CompactTaskAssignment {
1385                    compact_task: task,
1386                    context_id: 0.into(),
1387                },
1388            );
1389        }
1390
1391        // In the test, the contents of the compact task may have been modified directly, while the contents of compact_task_assignment were not modified.
1392        // So we pass the modified compact_task directly into the `report_compact_task_impl`
1393        self.report_compact_tasks(vec![ReportTask {
1394            task_id,
1395            task_status,
1396            sorted_output_ssts,
1397            table_stats_change: table_stats_change.unwrap_or_default(),
1398            object_timestamps: HashMap::default(),
1399        }])
1400        .await?;
1401        Ok(())
1402    }
1403}
1404
1405/// What triggered the compaction schedule request.
1406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1407pub enum ScheduleTrigger {
1408    /// New data arrived (e.g., `commit_epoch`). Clears cooldown.
1409    NewData,
1410    /// Periodic timer. Respects cooldown for Dynamic type.
1411    Periodic,
1412}
1413
1414/// A point-in-time snapshot of the compaction schedule state.
1415///
1416/// `snapshot_time` is used by `unschedule()` to detect whether new data arrived
1417/// after the snapshot was taken, preventing incorrect cooldown.
1418pub struct CompactionScheduleSnapshot {
1419    scheduled: HashSet<(CompactionGroupId, compact_task::TaskType)>,
1420    snapshot_time: Instant,
1421}
1422
1423impl CompactionScheduleSnapshot {
1424    /// Task type priority order for scheduling (checked first = higher priority).
1425    const TASK_TYPE_PRIORITY: &[TaskType] = &[
1426        TaskType::Dynamic,
1427        TaskType::SpaceReclaim,
1428        TaskType::Ttl,
1429        TaskType::Tombstone,
1430        TaskType::VnodeWatermark,
1431    ];
1432
1433    pub fn snapshot_time(&self) -> Instant {
1434        self.snapshot_time
1435    }
1436
1437    /// Pick compaction groups and task type from this snapshot.
1438    ///
1439    /// Returns groups in shuffled order. Non-Dynamic types have higher priority
1440    /// and return a single group; Dynamic groups are batched together.
1441    pub fn pick_compaction_groups_and_type(&self) -> Option<(Vec<CompactionGroupId>, TaskType)> {
1442        let group_ids = self.group_ids_shuffled();
1443        let mut normal_groups = vec![];
1444        for cg_id in group_ids {
1445            if let Some(pick_type) = self.pick_type(cg_id) {
1446                if pick_type == TaskType::Dynamic {
1447                    normal_groups.push(cg_id);
1448                } else if normal_groups.is_empty() {
1449                    return Some((vec![cg_id], pick_type));
1450                }
1451            }
1452        }
1453        if normal_groups.is_empty() {
1454            None
1455        } else {
1456            Some((normal_groups, TaskType::Dynamic))
1457        }
1458    }
1459
1460    fn group_ids_shuffled(&self) -> Vec<CompactionGroupId> {
1461        let mut group_ids: Vec<_> = self.scheduled.iter().map(|(g, _)| *g).unique().collect();
1462        group_ids.shuffle(&mut thread_rng());
1463        group_ids
1464    }
1465
1466    fn pick_type(&self, group: CompactionGroupId) -> Option<TaskType> {
1467        Self::TASK_TYPE_PRIORITY
1468            .iter()
1469            .find(|t| self.scheduled.contains(&(group, **t)))
1470            .copied()
1471    }
1472}
1473
1474/// Tracks which (`compaction_group`, `task_type`) pairs are scheduled for compaction.
1475///
1476/// For `Dynamic` type, includes a cooldown mechanism: groups with no compaction work
1477/// are skipped by periodic triggers until new data arrives via `commit_epoch`.
1478#[derive(Debug, Default)]
1479pub struct CompactionState {
1480    inner: Mutex<CompactionStateInner>,
1481}
1482
1483#[derive(Debug, Default)]
1484struct CompactionStateInner {
1485    scheduled: HashSet<(CompactionGroupId, compact_task::TaskType)>,
1486    /// Groups skipped by periodic Dynamic trigger until new data arrives.
1487    dynamic_cooldown: HashSet<CompactionGroupId>,
1488    /// Tracks new-data arrival time per group for cooldown race detection.
1489    last_new_data_time: HashMap<CompactionGroupId, Instant>,
1490}
1491
1492impl CompactionState {
1493    pub fn new() -> Self {
1494        Self {
1495            inner: Default::default(),
1496        }
1497    }
1498
1499    /// Enqueues a compaction request. Returns `true` if newly scheduled.
1500    ///
1501    /// `trigger` only affects `Dynamic` type — see [`ScheduleTrigger`].
1502    pub fn try_sched_compaction(
1503        &self,
1504        compaction_group: CompactionGroupId,
1505        task_type: TaskType,
1506        trigger: ScheduleTrigger,
1507    ) -> bool {
1508        let mut guard = self.inner.lock();
1509        if task_type == TaskType::Dynamic {
1510            match trigger {
1511                ScheduleTrigger::NewData => {
1512                    guard.dynamic_cooldown.remove(&compaction_group);
1513                    guard
1514                        .last_new_data_time
1515                        .insert(compaction_group, Instant::now());
1516                }
1517                ScheduleTrigger::Periodic => {
1518                    if guard.dynamic_cooldown.contains(&compaction_group) {
1519                        return false;
1520                    }
1521                }
1522            }
1523        }
1524        guard.scheduled.insert((compaction_group, task_type))
1525    }
1526
1527    /// Removes a scheduled entry. For Dynamic type, adds to cooldown unless
1528    /// new data arrived after `snapshot_time`.
1529    pub fn unschedule(
1530        &self,
1531        compaction_group: CompactionGroupId,
1532        task_type: compact_task::TaskType,
1533        snapshot_time: Instant,
1534    ) {
1535        let mut guard = self.inner.lock();
1536        guard.scheduled.remove(&(compaction_group, task_type));
1537        if task_type == TaskType::Dynamic {
1538            let has_new_data = guard
1539                .last_new_data_time
1540                .get(&compaction_group)
1541                .is_some_and(|t| *t > snapshot_time);
1542            if !has_new_data {
1543                guard.dynamic_cooldown.insert(compaction_group);
1544            }
1545        }
1546    }
1547
1548    /// Takes a snapshot of the current schedule state.
1549    pub fn snapshot(&self) -> CompactionScheduleSnapshot {
1550        let guard = self.inner.lock();
1551        // Record time after lock to ensure accurate ordering vs. try_sched_compaction
1552        let snapshot_time = Instant::now();
1553        CompactionScheduleSnapshot {
1554            scheduled: guard.scheduled.clone(),
1555            snapshot_time,
1556        }
1557    }
1558
1559    /// Removes all schedule state for a deleted or merged group.
1560    pub fn remove_compaction_group(&self, compaction_group: CompactionGroupId) {
1561        let mut guard = self.inner.lock();
1562        guard
1563            .scheduled
1564            .retain(|(group, _)| *group != compaction_group);
1565        guard.dynamic_cooldown.remove(&compaction_group);
1566        guard.last_new_data_time.remove(&compaction_group);
1567    }
1568}
1569
1570impl Compaction {
1571    pub fn get_compact_task_assignments_by_group_id(
1572        &self,
1573        compaction_group_id: CompactionGroupId,
1574    ) -> Vec<CompactTaskAssignment> {
1575        self.compact_task_assignment
1576            .values()
1577            .filter_map(|assignment| {
1578                if assignment.compact_task.compaction_group_id == compaction_group_id {
1579                    Some(assignment.clone())
1580                } else {
1581                    None
1582                }
1583            })
1584            .collect()
1585    }
1586}
1587
1588#[derive(Clone, Default)]
1589pub struct CompactionGroupStatistic {
1590    pub group_id: CompactionGroupId,
1591    pub group_size: u64,
1592    pub table_statistic: BTreeMap<StateTableId, u64>,
1593    pub compaction_group_config: CompactionGroup,
1594}
1595
1596/// Updates table stats caused by vnode watermark trivial reclaim compaction.
1597fn update_table_stats_for_vnode_watermark_trivial_reclaim(
1598    table_stats: &mut PbTableStatsMap,
1599    task: &CompactTask,
1600) {
1601    if task.task_type != TaskType::VnodeWatermark {
1602        return;
1603    }
1604    let mut deleted_table_keys: HashMap<TableId, u64> = HashMap::default();
1605    for s in task.input_ssts.iter().flat_map(|l| l.table_infos.iter()) {
1606        assert_eq!(s.table_ids.len(), 1);
1607        let e = deleted_table_keys.entry(s.table_ids[0]).or_insert(0);
1608        *e += s.total_key_count;
1609    }
1610    for (table_id, delete_count) in deleted_table_keys {
1611        let Some(stats) = table_stats.get_mut(&table_id) else {
1612            continue;
1613        };
1614        if stats.total_key_count == 0 {
1615            continue;
1616        }
1617        let new_total_key_count = stats.total_key_count.saturating_sub(delete_count as i64);
1618        let ratio = new_total_key_count as f64 / stats.total_key_count as f64;
1619        // total_key_count is updated accurately.
1620        stats.total_key_count = new_total_key_count;
1621        // others are updated approximately.
1622        stats.total_key_size = (stats.total_key_size as f64 * ratio).ceil() as i64;
1623        stats.total_value_size = (stats.total_value_size as f64 * ratio).ceil() as i64;
1624    }
1625}
1626
1627#[derive(Debug, Clone)]
1628pub enum GroupState {
1629    /// The compaction group is not in emergency state.
1630    Normal,
1631
1632    /// The compaction group is in emergency state.
1633    Emergency(String), // reason
1634
1635    /// The compaction group is in write stop state.
1636    WriteStop(String), // reason
1637}
1638
1639impl GroupState {
1640    pub fn is_write_stop(&self) -> bool {
1641        matches!(self, Self::WriteStop(_))
1642    }
1643
1644    pub fn is_emergency(&self) -> bool {
1645        matches!(self, Self::Emergency(_))
1646    }
1647
1648    pub fn reason(&self) -> Option<&str> {
1649        match self {
1650            Self::Emergency(reason) | Self::WriteStop(reason) => Some(reason),
1651            _ => None,
1652        }
1653    }
1654}
1655
1656#[derive(Clone, Default)]
1657pub struct GroupStateValidator;
1658
1659impl GroupStateValidator {
1660    pub fn write_stop_sub_level_count(
1661        level_count: usize,
1662        compaction_config: &CompactionConfig,
1663    ) -> bool {
1664        let threshold = compaction_config.level0_stop_write_threshold_sub_level_number as usize;
1665        level_count > threshold
1666    }
1667
1668    pub fn write_stop_l0_size(l0_size: u64, compaction_config: &CompactionConfig) -> bool {
1669        l0_size
1670            > compaction_config
1671                .level0_stop_write_threshold_max_size
1672                .unwrap_or(compaction_config::level0_stop_write_threshold_max_size())
1673    }
1674
1675    pub fn write_stop_l0_file_count(
1676        l0_file_count: usize,
1677        compaction_config: &CompactionConfig,
1678    ) -> bool {
1679        l0_file_count
1680            > compaction_config
1681                .level0_stop_write_threshold_max_sst_count
1682                .unwrap_or(compaction_config::level0_stop_write_threshold_max_sst_count())
1683                as usize
1684    }
1685
1686    pub fn emergency_l0_file_count(
1687        l0_file_count: usize,
1688        compaction_config: &CompactionConfig,
1689    ) -> bool {
1690        l0_file_count
1691            > compaction_config
1692                .emergency_level0_sst_file_count
1693                .unwrap_or(compaction_config::emergency_level0_sst_file_count())
1694                as usize
1695    }
1696
1697    pub fn emergency_l0_partition_count(
1698        last_l0_sub_level_partition_count: usize,
1699        compaction_config: &CompactionConfig,
1700    ) -> bool {
1701        last_l0_sub_level_partition_count
1702            > compaction_config
1703                .emergency_level0_sub_level_partition
1704                .unwrap_or(compaction_config::emergency_level0_sub_level_partition())
1705                as usize
1706    }
1707
1708    pub fn check_single_group_write_stop(
1709        levels: &Levels,
1710        compaction_config: &CompactionConfig,
1711    ) -> GroupState {
1712        if Self::write_stop_sub_level_count(levels.l0.sub_levels.len(), compaction_config) {
1713            return GroupState::WriteStop(format!(
1714                "WriteStop(l0_level_count: {}, threshold: {}) too many L0 sub levels",
1715                levels.l0.sub_levels.len(),
1716                compaction_config.level0_stop_write_threshold_sub_level_number
1717            ));
1718        }
1719
1720        if Self::write_stop_l0_file_count(
1721            levels
1722                .l0
1723                .sub_levels
1724                .iter()
1725                .map(|l| l.table_infos.len())
1726                .sum(),
1727            compaction_config,
1728        ) {
1729            return GroupState::WriteStop(format!(
1730                "WriteStop(l0_sst_count: {}, threshold: {}) too many L0 sst files",
1731                levels
1732                    .l0
1733                    .sub_levels
1734                    .iter()
1735                    .map(|l| l.table_infos.len())
1736                    .sum::<usize>(),
1737                compaction_config
1738                    .level0_stop_write_threshold_max_sst_count
1739                    .unwrap_or(compaction_config::level0_stop_write_threshold_max_sst_count())
1740            ));
1741        }
1742
1743        if Self::write_stop_l0_size(levels.l0.total_file_size, compaction_config) {
1744            return GroupState::WriteStop(format!(
1745                "WriteStop(l0_size: {}, threshold: {}) too large L0 size",
1746                levels.l0.total_file_size,
1747                compaction_config
1748                    .level0_stop_write_threshold_max_size
1749                    .unwrap_or(compaction_config::level0_stop_write_threshold_max_size())
1750            ));
1751        }
1752
1753        GroupState::Normal
1754    }
1755
1756    pub fn check_single_group_emergency(
1757        levels: &Levels,
1758        compaction_config: &CompactionConfig,
1759    ) -> GroupState {
1760        if Self::emergency_l0_file_count(
1761            levels
1762                .l0
1763                .sub_levels
1764                .iter()
1765                .map(|l| l.table_infos.len())
1766                .sum(),
1767            compaction_config,
1768        ) {
1769            return GroupState::Emergency(format!(
1770                "Emergency(l0_sst_count: {}, threshold: {}) too many L0 sst files",
1771                levels
1772                    .l0
1773                    .sub_levels
1774                    .iter()
1775                    .map(|l| l.table_infos.len())
1776                    .sum::<usize>(),
1777                compaction_config
1778                    .emergency_level0_sst_file_count
1779                    .unwrap_or(compaction_config::emergency_level0_sst_file_count())
1780            ));
1781        }
1782
1783        if Self::emergency_l0_partition_count(
1784            levels
1785                .l0
1786                .sub_levels
1787                .first()
1788                .map(|l| l.table_infos.len())
1789                .unwrap_or(0),
1790            compaction_config,
1791        ) {
1792            return GroupState::Emergency(format!(
1793                "Emergency(l0_partition_count: {}, threshold: {}) too many L0 partitions",
1794                levels
1795                    .l0
1796                    .sub_levels
1797                    .first()
1798                    .map(|l| l.table_infos.len())
1799                    .unwrap_or(0),
1800                compaction_config
1801                    .emergency_level0_sub_level_partition
1802                    .unwrap_or(compaction_config::emergency_level0_sub_level_partition())
1803            ));
1804        }
1805
1806        GroupState::Normal
1807    }
1808
1809    pub fn group_state(levels: &Levels, compaction_config: &CompactionConfig) -> GroupState {
1810        let state = Self::check_single_group_write_stop(levels, compaction_config);
1811        if state.is_write_stop() {
1812            return state;
1813        }
1814
1815        Self::check_single_group_emergency(levels, compaction_config)
1816    }
1817}
1818
1819#[cfg(test)]
1820mod prefetched_task_id_tests {
1821    use crate::manager::MetaOpts;
1822
1823    #[test]
1824    fn test_compaction_task_id_refill_capacity_default() {
1825        assert_eq!(MetaOpts::test(false).compaction_task_id_refill_capacity, 64);
1826    }
1827}
1828
1829#[cfg(test)]
1830mod compaction_state_tests {
1831    use risingwave_pb::hummock::compact_task::TaskType;
1832
1833    use super::*;
1834
1835    #[test]
1836    fn test_basic_schedule_and_unschedule() {
1837        let state = CompactionState::new();
1838        let group_id: CompactionGroupId = 1.into();
1839
1840        // First schedule should succeed
1841        assert!(state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1842        // Duplicate schedule should fail
1843        assert!(!state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1844        // Different task type should succeed
1845        assert!(state.try_sched_compaction(group_id, TaskType::Ttl, ScheduleTrigger::Periodic));
1846
1847        // Snapshot should contain both
1848        let snapshot = state.snapshot();
1849        assert!(snapshot.scheduled.contains(&(group_id, TaskType::Dynamic)));
1850        assert!(snapshot.scheduled.contains(&(group_id, TaskType::Ttl)));
1851
1852        // Unschedule removes from scheduled set
1853        state.unschedule(group_id, TaskType::Dynamic, snapshot.snapshot_time());
1854        let snapshot2 = state.snapshot();
1855        assert!(!snapshot2.scheduled.contains(&(group_id, TaskType::Dynamic)));
1856        assert!(snapshot2.scheduled.contains(&(group_id, TaskType::Ttl)));
1857    }
1858
1859    #[test]
1860    fn test_cooldown_blocks_periodic_trigger() {
1861        let state = CompactionState::new();
1862        let group_id: CompactionGroupId = 1.into();
1863
1864        // Schedule then unschedule - should add to cooldown
1865        assert!(state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1866        let snapshot = state.snapshot();
1867        state.unschedule(group_id, TaskType::Dynamic, snapshot.snapshot_time());
1868
1869        // Verify in cooldown
1870        assert!(state.inner.lock().dynamic_cooldown.contains(&group_id));
1871
1872        // Periodic trigger should be blocked
1873        assert!(!state.try_sched_compaction(
1874            group_id,
1875            TaskType::Dynamic,
1876            ScheduleTrigger::Periodic
1877        ));
1878    }
1879
1880    #[test]
1881    fn test_new_data_clears_cooldown() {
1882        let state = CompactionState::new();
1883        let group_id: CompactionGroupId = 1.into();
1884
1885        // Put group in cooldown
1886        assert!(state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1887        let snapshot = state.snapshot();
1888        state.unschedule(group_id, TaskType::Dynamic, snapshot.snapshot_time());
1889        assert!(state.inner.lock().dynamic_cooldown.contains(&group_id));
1890
1891        // NewData trigger should clear cooldown and schedule
1892        assert!(state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1893        assert!(!state.inner.lock().dynamic_cooldown.contains(&group_id));
1894    }
1895
1896    #[test]
1897    fn test_cooldown_only_affects_dynamic_type() {
1898        let state = CompactionState::new();
1899        let group_id: CompactionGroupId = 1.into();
1900
1901        // Put group in cooldown for Dynamic
1902        assert!(state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1903        let snapshot = state.snapshot();
1904        state.unschedule(group_id, TaskType::Dynamic, snapshot.snapshot_time());
1905
1906        // Ttl unschedule should NOT add to cooldown
1907        let group_id_2: CompactionGroupId = 2.into();
1908        assert!(state.try_sched_compaction(group_id_2, TaskType::Ttl, ScheduleTrigger::Periodic));
1909        let snapshot2 = state.snapshot();
1910        state.unschedule(group_id_2, TaskType::Ttl, snapshot2.snapshot_time());
1911        assert!(!state.inner.lock().dynamic_cooldown.contains(&group_id_2));
1912
1913        // Other task types should work regardless of cooldown
1914        assert!(state.try_sched_compaction(group_id, TaskType::Ttl, ScheduleTrigger::Periodic));
1915        assert!(state.try_sched_compaction(
1916            group_id,
1917            TaskType::SpaceReclaim,
1918            ScheduleTrigger::Periodic
1919        ));
1920    }
1921
1922    #[test]
1923    fn test_race_condition_new_data_after_snapshot() {
1924        let state = CompactionState::new();
1925        let group_id: CompactionGroupId = 1.into();
1926
1927        assert!(state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1928        let snapshot = state.snapshot();
1929
1930        // Simulate new data arriving AFTER snapshot
1931        {
1932            let mut guard = state.inner.lock();
1933            guard.last_new_data_time.insert(group_id, Instant::now());
1934        }
1935
1936        // Unschedule should NOT add to cooldown (new data arrived after snapshot)
1937        state.unschedule(group_id, TaskType::Dynamic, snapshot.snapshot_time());
1938        assert!(
1939            !state.inner.lock().dynamic_cooldown.contains(&group_id),
1940            "Should skip cooldown when new data arrived after snapshot"
1941        );
1942    }
1943
1944    #[test]
1945    fn test_remove_compaction_group_cleans_all_state() {
1946        let state = CompactionState::new();
1947        let group_id: CompactionGroupId = 1.into();
1948
1949        // Set up state
1950        assert!(state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData));
1951        assert!(state.try_sched_compaction(group_id, TaskType::Ttl, ScheduleTrigger::Periodic));
1952        state.inner.lock().dynamic_cooldown.insert(group_id);
1953
1954        // Remove group
1955        state.remove_compaction_group(group_id);
1956
1957        // Verify all state cleaned up
1958        let guard = state.inner.lock();
1959        assert!(!guard.scheduled.contains(&(group_id, TaskType::Dynamic)));
1960        assert!(!guard.scheduled.contains(&(group_id, TaskType::Ttl)));
1961        assert!(!guard.dynamic_cooldown.contains(&group_id));
1962        assert!(!guard.last_new_data_time.contains_key(&group_id));
1963    }
1964
1965    #[test]
1966    fn test_snapshot_pick_type_priority() {
1967        let state = CompactionState::new();
1968        let group_id: CompactionGroupId = 1.into();
1969
1970        // Empty group returns None
1971        assert_eq!(state.snapshot().pick_type(group_id), None);
1972
1973        // Priority order: Dynamic > SpaceReclaim > Ttl > Tombstone > VnodeWatermark
1974        state.try_sched_compaction(
1975            group_id,
1976            TaskType::VnodeWatermark,
1977            ScheduleTrigger::Periodic,
1978        );
1979        assert_eq!(
1980            state.snapshot().pick_type(group_id),
1981            Some(TaskType::VnodeWatermark)
1982        );
1983
1984        state.try_sched_compaction(group_id, TaskType::Tombstone, ScheduleTrigger::Periodic);
1985        assert_eq!(
1986            state.snapshot().pick_type(group_id),
1987            Some(TaskType::Tombstone)
1988        );
1989
1990        state.try_sched_compaction(group_id, TaskType::Ttl, ScheduleTrigger::Periodic);
1991        assert_eq!(state.snapshot().pick_type(group_id), Some(TaskType::Ttl));
1992
1993        state.try_sched_compaction(group_id, TaskType::SpaceReclaim, ScheduleTrigger::Periodic);
1994        assert_eq!(
1995            state.snapshot().pick_type(group_id),
1996            Some(TaskType::SpaceReclaim)
1997        );
1998
1999        state.try_sched_compaction(group_id, TaskType::Dynamic, ScheduleTrigger::NewData);
2000        assert_eq!(
2001            state.snapshot().pick_type(group_id),
2002            Some(TaskType::Dynamic)
2003        );
2004    }
2005
2006    #[test]
2007    fn test_multiple_groups_independent_cooldown() {
2008        let state = CompactionState::new();
2009        let g1: CompactionGroupId = 1.into();
2010        let g2: CompactionGroupId = 2.into();
2011
2012        state.try_sched_compaction(g1, TaskType::Dynamic, ScheduleTrigger::NewData);
2013        state.try_sched_compaction(g2, TaskType::Dynamic, ScheduleTrigger::NewData);
2014        let snapshot = state.snapshot();
2015
2016        // Only unschedule g1
2017        state.unschedule(g1, TaskType::Dynamic, snapshot.snapshot_time());
2018
2019        let guard = state.inner.lock();
2020        assert!(guard.dynamic_cooldown.contains(&g1));
2021        assert!(!guard.dynamic_cooldown.contains(&g2));
2022    }
2023
2024    #[test]
2025    fn test_pick_compaction_groups_empty() {
2026        let state = CompactionState::new();
2027        let snapshot = state.snapshot();
2028        // No scheduled groups → returns None
2029        assert!(snapshot.pick_compaction_groups_and_type().is_none());
2030    }
2031
2032    #[test]
2033    fn test_pick_compaction_groups_mixed_types() {
2034        let state = CompactionState::new();
2035        let g1: CompactionGroupId = 1.into();
2036        let g2: CompactionGroupId = 2.into();
2037        let g3: CompactionGroupId = 3.into();
2038
2039        // g1: Dynamic, g2: Ttl, g3: Dynamic
2040        state.try_sched_compaction(g1, TaskType::Dynamic, ScheduleTrigger::NewData);
2041        state.try_sched_compaction(g2, TaskType::Ttl, ScheduleTrigger::Periodic);
2042        state.try_sched_compaction(g3, TaskType::Dynamic, ScheduleTrigger::NewData);
2043
2044        let snapshot = state.snapshot();
2045        let (groups, task_type) = snapshot.pick_compaction_groups_and_type().unwrap();
2046
2047        // Due to shuffle, either:
2048        // - Ttl group is encountered first → returns (vec![g2], Ttl)
2049        // - Dynamic group is encountered first → collects all Dynamic, skips Ttl
2050        //   → returns ([g1, g3] in some order, Dynamic)
2051        if task_type == TaskType::Dynamic {
2052            assert!(groups.contains(&g1));
2053            assert!(groups.contains(&g3));
2054            assert!(!groups.contains(&g2)); // Ttl group excluded from Dynamic result
2055        } else {
2056            assert_eq!(task_type, TaskType::Ttl);
2057            assert_eq!(groups, vec![g2]);
2058        }
2059    }
2060
2061    #[test]
2062    fn test_pick_compaction_groups_all_dynamic() {
2063        let state = CompactionState::new();
2064        let g1: CompactionGroupId = 1.into();
2065        let g2: CompactionGroupId = 2.into();
2066
2067        state.try_sched_compaction(g1, TaskType::Dynamic, ScheduleTrigger::NewData);
2068        state.try_sched_compaction(g2, TaskType::Dynamic, ScheduleTrigger::NewData);
2069
2070        let snapshot = state.snapshot();
2071        let (groups, task_type) = snapshot.pick_compaction_groups_and_type().unwrap();
2072        assert_eq!(task_type, TaskType::Dynamic);
2073        assert!(groups.contains(&g1));
2074        assert!(groups.contains(&g2));
2075    }
2076
2077    #[test]
2078    fn test_pick_compaction_groups_single_non_dynamic() {
2079        let state = CompactionState::new();
2080        let g1: CompactionGroupId = 1.into();
2081
2082        state.try_sched_compaction(g1, TaskType::SpaceReclaim, ScheduleTrigger::Periodic);
2083
2084        let snapshot = state.snapshot();
2085        let (groups, task_type) = snapshot.pick_compaction_groups_and_type().unwrap();
2086        assert_eq!(task_type, TaskType::SpaceReclaim);
2087        assert_eq!(groups, vec![g1]);
2088    }
2089}