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