Skip to main content

risingwave_meta/hummock/manager/compaction/
compaction_group_schedule.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, BTreeSet, HashMap, HashSet};
16use std::ops::{Deref, DerefMut};
17use std::sync::Arc;
18
19use bytes::Bytes;
20use itertools::Itertools;
21use risingwave_common::catalog::TableId;
22use risingwave_common::hash::VirtualNode;
23use risingwave_common::monitor::MonitoredRwLock;
24use risingwave_hummock_sdk::compact_task::{ReportTask, is_compaction_task_expired};
25use risingwave_hummock_sdk::compaction_group::{
26    StateTableId, StaticCompactionGroupId, group_split,
27};
28use risingwave_hummock_sdk::version::{GroupDelta, GroupDeltas, HummockVersion};
29use risingwave_hummock_sdk::{CompactionGroupId, can_concat};
30use risingwave_pb::hummock::compact_task::TaskStatus;
31use risingwave_pb::hummock::rise_ctl_update_compaction_config_request::mutable_config::MutableConfig;
32use risingwave_pb::hummock::{
33    CompatibilityVersion, PbGroupConstruct, PbGroupMerge, PbStateTableInfoDelta,
34};
35use thiserror_ext::AsReport;
36
37use super::compaction_group_manager::CompactionGroupManager;
38use super::{CompactionGroupStatistic, GroupStateValidator};
39use crate::hummock::error::{Error, Result};
40use crate::hummock::manager::transaction::HummockVersionTransaction;
41use crate::hummock::manager::versioning::Versioning;
42use crate::hummock::manager::{HummockManager, commit_multi_var};
43use crate::hummock::metrics_utils::remove_compaction_group_metrics;
44use crate::hummock::sequence::{next_compaction_group_id, next_sstable_id};
45use crate::hummock::table_write_throughput_statistic::{
46    TableWriteThroughputStatistic, TableWriteThroughputStatisticManager,
47};
48use crate::manager::MetaOpts;
49
50#[derive(Debug, PartialEq, Eq)]
51struct NormalizePlan {
52    parent_group_id: CompactionGroupId,
53    parent_table_ids: Vec<StateTableId>,
54    boundary_table_id: StateTableId,
55}
56
57impl NormalizePlan {
58    fn split_key(&self) -> Bytes {
59        group_split::build_split_full_key(self.boundary_table_id, VirtualNode::ZERO)
60            .encode()
61            .into()
62    }
63
64    fn split_table_ids(&self) -> (Vec<StateTableId>, Vec<StateTableId>) {
65        let split_full_key =
66            group_split::build_split_full_key(self.boundary_table_id, VirtualNode::ZERO);
67        let (table_ids_left, table_ids_right) =
68            group_split::split_table_ids_with_table_id_and_vnode(
69                &self.parent_table_ids,
70                split_full_key.user_key.table_id,
71                split_full_key.user_key.get_vnode_id(),
72            );
73        assert!(!table_ids_left.is_empty() && !table_ids_right.is_empty());
74        (table_ids_left, table_ids_right)
75    }
76}
77
78fn gen_normalize_plan(
79    left: &CompactionGroupStatistic,
80    right: &CompactionGroupStatistic,
81) -> Option<NormalizePlan> {
82    let left_table_ids = left.table_statistic.keys().copied().collect_vec();
83
84    if left_table_ids.len() <= 1 {
85        return None;
86    }
87
88    let left_max = *left_table_ids.last().unwrap();
89    let right_min = *right.table_statistic.keys().next().unwrap();
90    if left_max < right_min {
91        return None;
92    }
93
94    let boundary_index = left_table_ids.partition_point(|&table_id| table_id < right_min);
95    if boundary_index == 0 || boundary_index >= left_table_ids.len() {
96        return None;
97    }
98    let boundary_table_id = left_table_ids[boundary_index];
99
100    Some(NormalizePlan {
101        parent_group_id: left.group_id,
102        parent_table_ids: left_table_ids,
103        boundary_table_id,
104    })
105}
106
107fn build_normalize_plan_from_group_statistics(
108    groups: &[CompactionGroupStatistic],
109) -> Option<NormalizePlan> {
110    // `calculate_compaction_group_statistic()` iterates all version levels, so newly created or
111    // transiently empty groups can appear here without any member tables.
112    let mut groups = groups
113        .iter()
114        .filter(|group| !group.table_statistic.is_empty())
115        .collect_vec();
116    groups.sort_by_key(|group| *group.table_statistic.keys().next().unwrap());
117
118    groups
119        .split(|group| {
120            group
121                .compaction_group_config
122                .compaction_config
123                .disable_auto_group_scheduling
124                .unwrap_or(false)
125        })
126        .find_map(|segment| {
127            segment
128                .windows(2)
129                .find_map(|pair| gen_normalize_plan(pair[0], pair[1]))
130        })
131}
132
133fn collect_normalize_group_statistics(
134    version: &HummockVersion,
135    compaction_group_manager: &CompactionGroupManager,
136) -> Result<Vec<CompactionGroupStatistic>> {
137    let mut groups = vec![];
138    for group_id in version.levels.keys() {
139        let table_ids = version
140            .state_table_info
141            .compaction_group_member_table_ids(*group_id)
142            .iter()
143            .copied()
144            .collect_vec();
145        if table_ids.is_empty() {
146            continue;
147        }
148
149        let group_config = compaction_group_manager
150            .try_get_compaction_group_config(*group_id)
151            .ok_or_else(|| {
152                Error::CompactionGroup(format!(
153                    "group {} config not found during normalize",
154                    group_id
155                ))
156            })?;
157        groups.push(CompactionGroupStatistic {
158            group_id: *group_id,
159            group_size: 0,
160            table_statistic: table_ids
161                .into_iter()
162                .map(|table_id| (table_id, 0))
163                .collect(),
164            compaction_group_config: group_config,
165        });
166    }
167    Ok(groups)
168}
169
170impl HummockManager {
171    pub async fn merge_compaction_group(
172        &self,
173        group_1: CompactionGroupId,
174        group_2: CompactionGroupId,
175    ) -> Result<()> {
176        self.merge_compaction_group_impl(group_1, group_2, None)
177            .await
178    }
179
180    pub async fn merge_compaction_group_for_test(
181        &self,
182        group_1: CompactionGroupId,
183        group_2: CompactionGroupId,
184        created_tables: HashSet<TableId>,
185    ) -> Result<()> {
186        self.merge_compaction_group_impl(group_1, group_2, Some(created_tables))
187            .await
188    }
189
190    pub async fn merge_compaction_group_impl(
191        &self,
192        group_1: CompactionGroupId,
193        group_2: CompactionGroupId,
194        created_tables: Option<HashSet<TableId>>,
195    ) -> Result<()> {
196        let compaction_guard = self
197            .compaction
198            .write_with_process_name("merge_compaction_group_impl")
199            .await;
200        let mut versioning_guard = self
201            .versioning
202            .write_with_process_name("merge_compaction_group_impl")
203            .await;
204        let versioning = versioning_guard.deref_mut();
205        // Validate parameters.
206        if !versioning.current_version.levels.contains_key(&group_1) {
207            return Err(Error::CompactionGroup(format!("invalid group {}", group_1)));
208        }
209
210        if !versioning.current_version.levels.contains_key(&group_2) {
211            return Err(Error::CompactionGroup(format!("invalid group {}", group_2)));
212        }
213
214        let state_table_info = versioning.current_version.state_table_info.clone();
215        let mut member_table_ids_1 = state_table_info
216            .compaction_group_member_table_ids(group_1)
217            .iter()
218            .cloned()
219            .collect_vec();
220
221        if member_table_ids_1.is_empty() {
222            return Err(Error::CompactionGroup(format!(
223                "group_1 {} is empty",
224                group_1
225            )));
226        }
227
228        let mut member_table_ids_2 = state_table_info
229            .compaction_group_member_table_ids(group_2)
230            .iter()
231            .cloned()
232            .collect_vec();
233
234        if member_table_ids_2.is_empty() {
235            return Err(Error::CompactionGroup(format!(
236                "group_2 {} is empty",
237                group_2
238            )));
239        }
240
241        debug_assert!(!member_table_ids_1.is_empty());
242        debug_assert!(!member_table_ids_2.is_empty());
243        assert!(member_table_ids_1.is_sorted());
244        assert!(member_table_ids_2.is_sorted());
245
246        let created_tables = if let Some(created_tables) = created_tables {
247            // if the created_tables is provided, use it directly, most for test
248            #[expect(clippy::assertions_on_constants)]
249            {
250                assert!(cfg!(debug_assertions));
251            }
252            created_tables
253        } else {
254            match self.metadata_manager.get_created_table_ids().await {
255                Ok(created_tables) => HashSet::from_iter(created_tables),
256                Err(err) => {
257                    tracing::warn!(error = %err.as_report(), "failed to fetch created table ids");
258                    return Err(Error::CompactionGroup(format!(
259                        "merge group_1 {} group_2 {} failed to fetch created table ids",
260                        group_1, group_2
261                    )));
262                }
263            }
264        };
265
266        fn contains_creating_table(
267            table_ids: &Vec<TableId>,
268            created_tables: &HashSet<TableId>,
269        ) -> bool {
270            table_ids
271                .iter()
272                .any(|table_id| !created_tables.contains(table_id))
273        }
274
275        // do not merge the compaction group which is creating
276        if contains_creating_table(&member_table_ids_1, &created_tables)
277            || contains_creating_table(&member_table_ids_2, &created_tables)
278        {
279            return Err(Error::CompactionGroup(format!(
280                "Cannot merge creating group {} next_group {} member_table_ids_1 {:?} member_table_ids_2 {:?}",
281                group_1, group_2, member_table_ids_1, member_table_ids_2
282            )));
283        }
284
285        // Make sure `member_table_ids_1` is smaller than `member_table_ids_2`
286        let (left_group_id, right_group_id) =
287            if member_table_ids_1.first().unwrap() < member_table_ids_2.first().unwrap() {
288                (group_1, group_2)
289            } else {
290                std::mem::swap(&mut member_table_ids_1, &mut member_table_ids_2);
291                (group_2, group_1)
292            };
293
294        // We can only merge two groups with non-overlapping member table ids.
295        // After the swap above, member_table_ids_1 has the smaller first element.
296        // If the last element of member_table_ids_1 >= the first element of member_table_ids_2,
297        // the two groups' table id ranges overlap and cannot be merged.
298        if member_table_ids_1.last().unwrap() >= member_table_ids_2.first().unwrap() {
299            return Err(Error::CompactionGroup(format!(
300                "invalid merge group_1 {} group_2 {}: table id ranges overlap",
301                left_group_id, right_group_id
302            )));
303        }
304
305        let combined_member_table_ids = member_table_ids_1
306            .iter()
307            .chain(member_table_ids_2.iter())
308            .collect_vec();
309        assert!(combined_member_table_ids.is_sorted());
310
311        // check duplicated sst_id
312        let mut sst_id_set = HashSet::new();
313        for sst_id in versioning
314            .current_version
315            .get_sst_ids_by_group_id(left_group_id)
316            .chain(
317                versioning
318                    .current_version
319                    .get_sst_ids_by_group_id(right_group_id),
320            )
321        {
322            if !sst_id_set.insert(sst_id) {
323                return Err(Error::CompactionGroup(format!(
324                    "invalid merge group_1 {} group_2 {} duplicated sst_id {}",
325                    left_group_id, right_group_id, sst_id
326                )));
327            }
328        }
329
330        // check branched sst on non-overlap level
331        {
332            let left_levels = versioning
333                .current_version
334                .get_compaction_group_levels(group_1);
335
336            let right_levels = versioning
337                .current_version
338                .get_compaction_group_levels(group_2);
339
340            // we can not check the l0 sub level, because the sub level id will be rewritten when merge
341            // This check will ensure that other non-overlapping level ssts can be concat and that the key_range is correct.
342            let max_level = std::cmp::max(left_levels.levels.len(), right_levels.levels.len());
343            for level_idx in 1..=max_level {
344                let left_level = left_levels.get_level(level_idx);
345                let right_level = right_levels.get_level(level_idx);
346                if left_level.table_infos.is_empty() || right_level.table_infos.is_empty() {
347                    continue;
348                }
349
350                let left_last_sst = left_level.table_infos.last().unwrap().clone();
351                let right_first_sst = right_level.table_infos.first().unwrap().clone();
352                let left_sst_id = left_last_sst.sst_id;
353                let right_sst_id = right_first_sst.sst_id;
354                let left_obj_id = left_last_sst.object_id;
355                let right_obj_id = right_first_sst.object_id;
356
357                // Since the sst key_range within a group is legal, we only need to check the ssts adjacent to the two groups.
358                if !can_concat(&[left_last_sst, right_first_sst]) {
359                    return Err(Error::CompactionGroup(format!(
360                        "invalid merge group_1 {} group_2 {} level_idx {} left_last_sst_id {} right_first_sst_id {} left_obj_id {} right_obj_id {}",
361                        left_group_id,
362                        right_group_id,
363                        level_idx,
364                        left_sst_id,
365                        right_sst_id,
366                        left_obj_id,
367                        right_obj_id
368                    )));
369                }
370            }
371        }
372
373        let mut version = HummockVersionTransaction::new(
374            &mut versioning.current_version,
375            &mut versioning.hummock_version_deltas,
376            &mut versioning.table_change_log,
377            self.env.notification_manager(),
378            None,
379            &self.metrics,
380            &self.env.opts,
381            &self.version_stat_tx,
382        );
383        let mut new_version_delta = version.new_delta();
384
385        let target_compaction_group_id = {
386            // merge right_group_id to left_group_id and remove right_group_id
387            new_version_delta.group_deltas.insert(
388                left_group_id,
389                GroupDeltas {
390                    group_deltas: vec![GroupDelta::GroupMerge(PbGroupMerge {
391                        left_group_id,
392                        right_group_id,
393                    })],
394                },
395            );
396            left_group_id
397        };
398
399        // TODO: remove compaciton group_id from state_table_info
400        // rewrite compaction_group_id for all tables
401        new_version_delta.with_latest_version(|version, new_version_delta| {
402            for &table_id in combined_member_table_ids {
403                let info = version
404                    .state_table_info
405                    .info()
406                    .get(&table_id)
407                    .expect("have check exist previously");
408                assert!(
409                    new_version_delta
410                        .state_table_info_delta
411                        .insert(
412                            table_id,
413                            PbStateTableInfoDelta {
414                                committed_epoch: info.committed_epoch,
415                                compaction_group_id: target_compaction_group_id,
416                            }
417                        )
418                        .is_none()
419                );
420            }
421        });
422
423        {
424            let mut compaction_group_manager = self
425                .compaction_group_manager
426                .write_with_process_name("merge_compaction_group_impl")
427                .await;
428            let mut compaction_groups_txn = compaction_group_manager.start_compaction_groups_txn();
429
430            // for metrics reclaim
431            {
432                let right_group_max_level = new_version_delta
433                    .latest_version()
434                    .get_compaction_group_levels(right_group_id)
435                    .levels
436                    .len();
437
438                remove_compaction_group_metrics(
439                    &self.metrics,
440                    right_group_id,
441                    right_group_max_level,
442                );
443            }
444
445            // clean up compaction schedule state for the merged group
446            self.compaction_state
447                .remove_compaction_group(right_group_id);
448
449            // clear `partition_vnode_count` for the hybrid group
450            {
451                if let Err(err) = compaction_groups_txn.update_compaction_config(
452                    &[left_group_id],
453                    &[MutableConfig::SplitWeightByVnode(0)], // default
454                ) {
455                    tracing::error!(
456                        error = %err.as_report(),
457                        "failed to update compaction config for group-{}",
458                        left_group_id
459                    );
460                }
461            }
462
463            new_version_delta.pre_apply();
464
465            // remove right_group_id
466            compaction_groups_txn.remove(right_group_id);
467            commit_multi_var!(self.meta_store_ref(), version, compaction_groups_txn)?;
468        }
469
470        // Instead of handling DeltaType::GroupConstruct for time travel, simply enforce a version snapshot.
471        versioning.mark_next_time_travel_version_snapshot();
472
473        // cancel tasks
474        let mut canceled_tasks = vec![];
475        // after merge, all tasks in right_group_id should be canceled
476        // Failure of cancel does not cause correctness problems, the report task will have better interception, and the operation here is designed to free up compactor compute resources more quickly.
477        let compact_task_assignments =
478            compaction_guard.get_compact_task_assignments_by_group_id(right_group_id);
479        compact_task_assignments
480            .into_iter()
481            .for_each(|task_assignment| {
482                let task = &task_assignment.compact_task;
483                assert_eq!(task.compaction_group_id, right_group_id);
484                canceled_tasks.push(ReportTask {
485                    task_id: task.task_id,
486                    task_status: TaskStatus::ManualCanceled,
487                    table_stats_change: HashMap::default(),
488                    sorted_output_ssts: vec![],
489                    object_timestamps: HashMap::default(),
490                });
491            });
492
493        if !canceled_tasks.is_empty() {
494            self.report_compact_tasks_impl(canceled_tasks, compaction_guard, versioning_guard)
495                .await?;
496        } else {
497            drop(versioning_guard);
498            drop(compaction_guard);
499        }
500
501        self.try_update_write_limits(&[left_group_id, right_group_id])
502            .await;
503
504        self.metrics
505            .merge_compaction_group_count
506            .with_label_values(&[&left_group_id.to_string()])
507            .inc();
508
509        Ok(())
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use std::collections::BTreeMap;
516
517    use risingwave_hummock_sdk::CompactionGroupId;
518    use risingwave_pb::hummock::CompactionConfig;
519
520    use super::{
521        CompactionGroupStatistic, NormalizePlan, build_normalize_plan_from_group_statistics,
522        gen_normalize_plan,
523    };
524    use crate::hummock::model::CompactionGroup;
525
526    fn group(
527        group_id: CompactionGroupId,
528        table_ids: &[u32],
529        disable_auto_group_scheduling: bool,
530    ) -> CompactionGroupStatistic {
531        let config = CompactionConfig {
532            disable_auto_group_scheduling: Some(disable_auto_group_scheduling),
533            ..Default::default()
534        };
535        CompactionGroupStatistic {
536            group_id,
537            group_size: 0,
538            table_statistic: table_ids
539                .iter()
540                .copied()
541                .map(|table_id| (table_id.into(), 0_u64))
542                .collect::<BTreeMap<_, _>>(),
543            compaction_group_config: CompactionGroup::new(group_id, config),
544        }
545    }
546
547    #[test]
548    fn test_gen_normalize_plan_returns_none_for_single_table_group() {
549        let left = group(1.into(), &[10], false);
550        let right = group(2.into(), &[5, 20], false);
551
552        assert_eq!(None, gen_normalize_plan(&left, &right));
553    }
554
555    #[test]
556    fn test_gen_normalize_plan_returns_none_for_non_overlapping_groups() {
557        let left = group(1.into(), &[1, 2, 3], false);
558        let right = group(2.into(), &[4, 5, 6], false);
559
560        assert_eq!(None, gen_normalize_plan(&left, &right));
561    }
562
563    #[test]
564    fn test_gen_normalize_plan_returns_none_when_boundary_cannot_split_parent() {
565        let left = group(1.into(), &[5, 6, 7], false);
566        let right = group(2.into(), &[4, 8], false);
567
568        assert_eq!(None, gen_normalize_plan(&left, &right));
569    }
570
571    #[test]
572    fn test_gen_normalize_plan_generates_expected_boundary() {
573        let left = group(1.into(), &[1, 4, 7], false);
574        let right = group(2.into(), &[2, 5, 8], false);
575
576        assert_eq!(
577            Some(NormalizePlan {
578                parent_group_id: 1.into(),
579                parent_table_ids: vec![1.into(), 4.into(), 7.into()],
580                boundary_table_id: 4.into(),
581            }),
582            gen_normalize_plan(&left, &right)
583        );
584    }
585
586    #[test]
587    fn test_build_normalize_plan_skips_disabled_boundary_and_continues_later_segment() {
588        let groups = vec![
589            group(1.into(), &[1, 4, 7], false),
590            group(2.into(), &[2, 5, 8], true),
591            group(3.into(), &[10, 13, 16], false),
592            group(4.into(), &[11, 14, 17], false),
593        ];
594
595        assert_eq!(
596            Some(NormalizePlan {
597                parent_group_id: 3.into(),
598                parent_table_ids: vec![10.into(), 13.into(), 16.into()],
599                boundary_table_id: 13.into(),
600            }),
601            build_normalize_plan_from_group_statistics(&groups)
602        );
603    }
604}
605
606impl HummockManager {
607    /// Split `table_ids` to a dedicated compaction group.(will be split by the `table_id` and `vnode`.)
608    /// Returns the compaction group id containing the `table_ids` and the mapping of compaction group id to table ids.
609    /// The split will follow the following rules
610    /// 1. ssts with `key_range.left` greater than `split_key` will be split to the right group
611    /// 2. the sst containing `split_key` will be split into two separate ssts and their `key_range` will be changed `sst_1`: [`sst.key_range.left`, `split_key`) `sst_2`: [`split_key`, `sst.key_range.right`]
612    /// 3. currently only `vnode` 0 and `vnode` max is supported. (Due to the above rule, vnode max will be rewritten as `table_id` + 1, `vnode` 0)
613    ///   - `parent_group_id`: the `group_id` to split
614    ///   - `split_table_ids`: the `table_ids` to split, now we still support to split multiple tables to one group at once, pass `split_table_ids` for per `split` operation for checking
615    ///   - `table_id_to_split`: the `table_id` to split
616    ///   - `vnode_to_split`: the `vnode` to split
617    ///   - `partition_vnode_count`: the partition count for the single table group if need
618    async fn split_compaction_group_impl(
619        &self,
620        parent_group_id: CompactionGroupId,
621        split_table_ids: &[StateTableId],
622        table_id_to_split: StateTableId,
623        vnode_to_split: VirtualNode,
624        partition_vnode_count: Option<u32>,
625    ) -> Result<Vec<(CompactionGroupId, Vec<StateTableId>)>> {
626        let mut result = vec![];
627        let compaction_guard = self
628            .compaction
629            .write_with_process_name("split_compaction_group_impl")
630            .await;
631        let mut versioning_guard = self
632            .versioning
633            .write_with_process_name("split_compaction_group_impl")
634            .await;
635        let versioning = versioning_guard.deref_mut();
636        // Validate parameters.
637        if !versioning
638            .current_version
639            .levels
640            .contains_key(&parent_group_id)
641        {
642            return Err(Error::CompactionGroup(format!(
643                "invalid group {}",
644                parent_group_id
645            )));
646        }
647
648        let member_table_ids = versioning
649            .current_version
650            .state_table_info
651            .compaction_group_member_table_ids(parent_group_id)
652            .iter()
653            .copied()
654            .collect::<BTreeSet<_>>();
655
656        if !member_table_ids.contains(&table_id_to_split) {
657            return Err(Error::CompactionGroup(format!(
658                "table {} doesn't in group {}",
659                table_id_to_split, parent_group_id
660            )));
661        }
662
663        let split_full_key = group_split::build_split_full_key(table_id_to_split, vnode_to_split);
664
665        // change to vec for partition
666        let table_ids = member_table_ids.into_iter().collect_vec();
667        if table_ids == split_table_ids {
668            return Err(Error::CompactionGroup(format!(
669                "invalid split attempt for group {}: all member tables are moved",
670                parent_group_id
671            )));
672        }
673        // avoid decode split_key when caller is aware of the table_id and vnode
674        let (table_ids_left, table_ids_right) =
675            group_split::split_table_ids_with_table_id_and_vnode(
676                &table_ids,
677                split_full_key.user_key.table_id,
678                split_full_key.user_key.get_vnode_id(),
679            );
680        if table_ids_left.is_empty() || table_ids_right.is_empty() {
681            // not need to split group if all tables are in the same side
682            if !table_ids_left.is_empty() {
683                result.push((parent_group_id, table_ids_left));
684            }
685
686            if !table_ids_right.is_empty() {
687                result.push((parent_group_id, table_ids_right));
688            }
689            return Ok(result);
690        }
691
692        result.push((parent_group_id, table_ids_left));
693
694        let split_key: Bytes = split_full_key.encode().into();
695
696        let mut version = HummockVersionTransaction::new(
697            &mut versioning.current_version,
698            &mut versioning.hummock_version_deltas,
699            &mut versioning.table_change_log,
700            self.env.notification_manager(),
701            None,
702            &self.metrics,
703            &self.env.opts,
704            &self.version_stat_tx,
705        );
706        let mut new_version_delta = version.new_delta();
707
708        let split_sst_count = new_version_delta
709            .latest_version()
710            .count_new_ssts_in_group_split(parent_group_id, split_key.clone());
711
712        let new_sst_start_id = next_sstable_id(&self.env, split_sst_count).await?;
713        let (new_compaction_group_id, config) = {
714            // All NewCompactionGroup pairs are mapped to one new compaction group.
715            let new_compaction_group_id = next_compaction_group_id(&self.env).await?;
716            // Inherit config from parent group
717            let config = self
718                .compaction_group_manager
719                .read_with_process_name("split_compaction_group_impl")
720                .await
721                .try_get_compaction_group_config(parent_group_id)
722                .ok_or_else(|| {
723                    Error::CompactionGroup(format!(
724                        "parent group {} config not found",
725                        parent_group_id
726                    ))
727                })?
728                .compaction_config()
729                .as_ref()
730                .clone();
731
732            #[expect(deprecated)]
733            // fill the deprecated field with default value
734            new_version_delta.group_deltas.insert(
735                new_compaction_group_id,
736                GroupDeltas {
737                    group_deltas: vec![GroupDelta::GroupConstruct(Box::new(PbGroupConstruct {
738                        group_config: Some(config.clone()),
739                        group_id: new_compaction_group_id,
740                        parent_group_id,
741                        new_sst_start_id,
742                        table_ids: vec![],
743                        version: CompatibilityVersion::LATEST as _, // for compatibility
744                        split_key: Some(split_key.into()),
745                    }))],
746                },
747            );
748            (new_compaction_group_id, config)
749        };
750
751        new_version_delta.with_latest_version(|version, new_version_delta| {
752            for &table_id in &table_ids_right {
753                let info = version
754                    .state_table_info
755                    .info()
756                    .get(&table_id)
757                    .expect("have check exist previously");
758                assert!(
759                    new_version_delta
760                        .state_table_info_delta
761                        .insert(
762                            table_id,
763                            PbStateTableInfoDelta {
764                                committed_epoch: info.committed_epoch,
765                                compaction_group_id: new_compaction_group_id,
766                            }
767                        )
768                        .is_none()
769                );
770            }
771        });
772
773        result.push((new_compaction_group_id, table_ids_right));
774
775        {
776            let mut compaction_group_manager = self
777                .compaction_group_manager
778                .write_with_process_name("split_compaction_group_impl")
779                .await;
780            let mut compaction_groups_txn = compaction_group_manager.start_compaction_groups_txn();
781            compaction_groups_txn
782                .create_compaction_groups(new_compaction_group_id, Arc::new(config));
783
784            // check if need to update the compaction config for the single table group and guarantee the operation atomicity
785            // `partition_vnode_count` only works inside a table, to avoid a lot of slicing sst, we only enable it in groups with high throughput and only one table.
786            // The target `table_ids` might be split to an existing group, so we need to try to update its config
787            for (cg_id, table_ids) in &result {
788                // check the split_tables had been place to the dedicated compaction group
789                if let Some(partition_vnode_count) = partition_vnode_count
790                    && table_ids.len() == 1
791                    && table_ids == split_table_ids
792                    && let Err(err) = compaction_groups_txn.update_compaction_config(
793                        &[*cg_id],
794                        &[MutableConfig::SplitWeightByVnode(partition_vnode_count)],
795                    )
796                {
797                    tracing::error!(
798                        error = %err.as_report(),
799                        "failed to update compaction config for group-{}",
800                        cg_id
801                    );
802                }
803            }
804
805            new_version_delta.pre_apply();
806            commit_multi_var!(self.meta_store_ref(), version, compaction_groups_txn)?;
807        }
808        // Instead of handling DeltaType::GroupConstruct for time travel, simply enforce a version snapshot.
809        versioning.mark_next_time_travel_version_snapshot();
810
811        // The expired compact tasks will be canceled.
812        // Failure of cancel does not cause correctness problems, the report task will have better interception, and the operation here is designed to free up compactor compute resources more quickly.
813        let mut canceled_tasks = vec![];
814        let compact_task_assignments =
815            compaction_guard.get_compact_task_assignments_by_group_id(parent_group_id);
816        let levels = versioning
817            .current_version
818            .get_compaction_group_levels(parent_group_id);
819        compact_task_assignments
820            .into_iter()
821            .for_each(|task_assignment| {
822                let task = &task_assignment.compact_task;
823                let is_expired = is_compaction_task_expired(
824                    task.compaction_group_version_id,
825                    levels.compaction_group_version_id,
826                );
827                if is_expired {
828                    canceled_tasks.push(ReportTask {
829                        task_id: task.task_id,
830                        task_status: TaskStatus::ManualCanceled,
831                        table_stats_change: HashMap::default(),
832                        sorted_output_ssts: vec![],
833                        object_timestamps: HashMap::default(),
834                    });
835                }
836            });
837
838        if !canceled_tasks.is_empty() {
839            self.report_compact_tasks_impl(canceled_tasks, compaction_guard, versioning_guard)
840                .await?;
841        } else {
842            drop(versioning_guard);
843            drop(compaction_guard);
844        }
845
846        let affected_group_ids = result.iter().map(|(cg_id, _)| *cg_id).collect_vec();
847        self.try_update_write_limits(&affected_group_ids).await;
848
849        self.metrics
850            .split_compaction_group_count
851            .with_label_values(&[&parent_group_id.to_string()])
852            .inc();
853
854        Ok(result)
855    }
856
857    /// Split `table_ids` to a dedicated compaction group.
858    /// Returns the compaction group id containing the `table_ids` and the mapping of compaction group id to table ids.
859    pub async fn move_state_tables_to_dedicated_compaction_group(
860        &self,
861        parent_group_id: CompactionGroupId,
862        table_ids: &[StateTableId],
863        partition_vnode_count: Option<u32>,
864    ) -> Result<(
865        CompactionGroupId,
866        BTreeMap<CompactionGroupId, Vec<StateTableId>>,
867    )> {
868        if table_ids.is_empty() {
869            return Err(Error::CompactionGroup(
870                "table_ids must not be empty".to_owned(),
871            ));
872        }
873
874        if !table_ids.is_sorted() {
875            return Err(Error::CompactionGroup(
876                "table_ids must be sorted".to_owned(),
877            ));
878        }
879
880        let parent_table_ids = {
881            let versioning_guard = self
882                .versioning
883                .read_with_process_name("move_state_tables_to_dedicated_compaction_group")
884                .await;
885            versioning_guard
886                .current_version
887                .state_table_info
888                .compaction_group_member_table_ids(parent_group_id)
889                .iter()
890                .copied()
891                .collect_vec()
892        };
893
894        if parent_table_ids == table_ids {
895            return Err(Error::CompactionGroup(format!(
896                "invalid split attempt for group {}: all member tables are moved",
897                parent_group_id
898            )));
899        }
900
901        fn check_table_ids_valid(cg_id_to_table_ids: &BTreeMap<CompactionGroupId, Vec<TableId>>) {
902            // 1. table_ids in different cg are sorted.
903            {
904                cg_id_to_table_ids
905                    .iter()
906                    .for_each(|(_cg_id, table_ids)| assert!(table_ids.is_sorted()));
907            }
908
909            // 2.table_ids in different cg are non-overlapping
910            {
911                let mut table_table_ids_vec = cg_id_to_table_ids.values().cloned().collect_vec();
912                table_table_ids_vec.sort_by(|a, b| a[0].cmp(&b[0]));
913                assert!(table_table_ids_vec.concat().is_sorted());
914            }
915
916            // 3.table_ids belong to one and only one cg.
917            {
918                let mut all_table_ids = HashSet::new();
919                for table_ids in cg_id_to_table_ids.values() {
920                    for table_id in table_ids {
921                        assert!(all_table_ids.insert(*table_id));
922                    }
923                }
924            }
925        }
926
927        // move [3,4,5,6]
928        // [1,2,3,4,5,6,7,8,9,10] -> [1,2] [3,4,5,6] [7,8,9,10]
929        // split key
930        // 1. table_id = 3, vnode = 0, epoch = MAX
931        // 2. table_id = 7, vnode = 0, epoch = MAX
932
933        // The new compaction group id is always generate on the right side
934        // Hence, we return the first compaction group id as the result
935        // split 1
936        let mut cg_id_to_table_ids: BTreeMap<CompactionGroupId, Vec<TableId>> = BTreeMap::new();
937        let table_id_to_split = *table_ids.first().unwrap();
938        let mut target_compaction_group_id: CompactionGroupId = 0.into();
939        let result_vec = self
940            .split_compaction_group_impl(
941                parent_group_id,
942                table_ids,
943                table_id_to_split,
944                VirtualNode::ZERO,
945                partition_vnode_count,
946            )
947            .await?;
948        assert!(result_vec.len() <= 2);
949
950        let mut finish_move = false;
951        for (cg_id, table_ids_after_split) in result_vec {
952            if table_ids_after_split.contains(&table_id_to_split) {
953                target_compaction_group_id = cg_id;
954            }
955
956            if table_ids_after_split == table_ids {
957                finish_move = true;
958            }
959
960            cg_id_to_table_ids.insert(cg_id, table_ids_after_split);
961        }
962        check_table_ids_valid(&cg_id_to_table_ids);
963
964        if finish_move {
965            return Ok((target_compaction_group_id, cg_id_to_table_ids));
966        }
967
968        // split 2
969        // See the example above and the split rule in `split_compaction_group_impl`.
970        let table_id_to_split = *table_ids.last().unwrap();
971        let result_vec = self
972            .split_compaction_group_impl(
973                target_compaction_group_id,
974                table_ids,
975                table_id_to_split,
976                VirtualNode::MAX_REPRESENTABLE,
977                partition_vnode_count,
978            )
979            .await?;
980        assert!(result_vec.len() <= 2);
981        for (cg_id, table_ids_after_split) in result_vec {
982            if table_ids_after_split.contains(&table_id_to_split) {
983                target_compaction_group_id = cg_id;
984            }
985            cg_id_to_table_ids.insert(cg_id, table_ids_after_split);
986        }
987        check_table_ids_valid(&cg_id_to_table_ids);
988
989        Ok((target_compaction_group_id, cg_id_to_table_ids))
990    }
991}
992
993impl HummockManager {
994    async fn build_normalize_plan(&self) -> Option<NormalizePlan> {
995        let groups = self.calculate_compaction_group_statistic().await;
996        build_normalize_plan_from_group_statistics(&groups)
997    }
998
999    async fn apply_normalize_plan(&self, plan: &NormalizePlan) -> Result<bool> {
1000        let (table_ids_right, boundary_table_id, new_compaction_group_id) = {
1001            let mut versioning_guard = self
1002                .versioning
1003                .write_with_process_name("apply_normalize_plan")
1004                .await;
1005            let versioning = versioning_guard.deref_mut();
1006            let mut compaction_group_manager = self
1007                .compaction_group_manager
1008                .write_with_process_name("apply_normalize_plan")
1009                .await;
1010
1011            let groups = collect_normalize_group_statistics(
1012                &versioning.current_version,
1013                &compaction_group_manager,
1014            )?;
1015            let Some(current_plan) = build_normalize_plan_from_group_statistics(&groups) else {
1016                return Ok(false);
1017            };
1018
1019            if &current_plan != plan {
1020                return Ok(false);
1021            }
1022
1023            let (_table_ids_left, table_ids_right) = plan.split_table_ids();
1024
1025            let config = compaction_group_manager
1026                .try_get_compaction_group_config(plan.parent_group_id)
1027                .ok_or_else(|| {
1028                    Error::CompactionGroup(format!(
1029                        "parent group {} config not found",
1030                        plan.parent_group_id
1031                    ))
1032                })?
1033                .compaction_config()
1034                .as_ref()
1035                .clone();
1036
1037            let mut compaction_groups_txn = compaction_group_manager.start_compaction_groups_txn();
1038            let mut version = HummockVersionTransaction::new(
1039                &mut versioning.current_version,
1040                &mut versioning.hummock_version_deltas,
1041                &mut versioning.table_change_log,
1042                self.env.notification_manager(),
1043                None,
1044                &self.metrics,
1045                &self.env.opts,
1046                &self.version_stat_tx,
1047            );
1048            let mut new_version_delta = version.new_delta();
1049            let split_key = plan.split_key();
1050            let split_sst_count = new_version_delta
1051                .latest_version()
1052                .count_new_ssts_in_group_split(plan.parent_group_id, split_key.clone());
1053            let new_sst_start_id = next_sstable_id(&self.env, split_sst_count).await?;
1054            let new_compaction_group_id = next_compaction_group_id(&self.env).await?;
1055
1056            #[expect(deprecated)]
1057            new_version_delta.group_deltas.insert(
1058                new_compaction_group_id,
1059                GroupDeltas {
1060                    group_deltas: vec![GroupDelta::GroupConstruct(Box::new(PbGroupConstruct {
1061                        group_config: Some(config.clone()),
1062                        group_id: new_compaction_group_id,
1063                        parent_group_id: plan.parent_group_id,
1064                        new_sst_start_id,
1065                        table_ids: vec![],
1066                        version: CompatibilityVersion::LATEST as _,
1067                        split_key: Some(split_key.into()),
1068                    }))],
1069                },
1070            );
1071
1072            new_version_delta.with_latest_version(|version, new_version_delta| {
1073                for &table_id in &table_ids_right {
1074                    let info = version
1075                        .state_table_info
1076                        .info()
1077                        .get(&table_id)
1078                        .expect("table should exist before normalize split");
1079                    assert!(
1080                        new_version_delta
1081                            .state_table_info_delta
1082                            .insert(
1083                                table_id,
1084                                PbStateTableInfoDelta {
1085                                    committed_epoch: info.committed_epoch,
1086                                    compaction_group_id: new_compaction_group_id,
1087                                }
1088                            )
1089                            .is_none()
1090                    );
1091                }
1092            });
1093            new_version_delta.pre_apply();
1094            compaction_groups_txn
1095                .create_compaction_groups(new_compaction_group_id, Arc::new(config));
1096
1097            commit_multi_var!(self.meta_store_ref(), version, compaction_groups_txn)?;
1098            versioning.mark_next_time_travel_version_snapshot();
1099
1100            (
1101                table_ids_right,
1102                plan.boundary_table_id,
1103                new_compaction_group_id,
1104            )
1105        };
1106
1107        self.cancel_expired_normalize_split_tasks(plan.parent_group_id)
1108            .await?;
1109        self.try_update_write_limits(&[plan.parent_group_id, new_compaction_group_id])
1110            .await;
1111        self.metrics
1112            .split_compaction_group_count
1113            .with_label_values(&[&plan.parent_group_id.to_string()])
1114            .inc();
1115        tracing::info!(
1116            "normalize split success: parent_group={} boundary_table_id={} moved_tables={:?} new_group_id={}",
1117            plan.parent_group_id,
1118            boundary_table_id,
1119            table_ids_right,
1120            new_compaction_group_id
1121        );
1122
1123        Ok(true)
1124    }
1125
1126    async fn cancel_expired_normalize_split_tasks(
1127        &self,
1128        parent_group_id: CompactionGroupId,
1129    ) -> Result<()> {
1130        let mut canceled_tasks = vec![];
1131        let compaction_guard = self
1132            .compaction
1133            .write_with_process_name("cancel_expired_normalize_split_tasks")
1134            .await;
1135        let mut versioning_guard = self
1136            .versioning
1137            .write_with_process_name("cancel_expired_normalize_split_tasks")
1138            .await;
1139        let versioning = versioning_guard.deref_mut();
1140        let compact_task_assignments =
1141            compaction_guard.get_compact_task_assignments_by_group_id(parent_group_id);
1142        let levels = versioning
1143            .current_version
1144            .get_compaction_group_levels(parent_group_id);
1145        compact_task_assignments
1146            .into_iter()
1147            .for_each(|task_assignment| {
1148                let task = &task_assignment.compact_task;
1149                if is_compaction_task_expired(
1150                    task.compaction_group_version_id,
1151                    levels.compaction_group_version_id,
1152                ) {
1153                    canceled_tasks.push(ReportTask {
1154                        task_id: task.task_id,
1155                        task_status: TaskStatus::ManualCanceled,
1156                        table_stats_change: HashMap::default(),
1157                        sorted_output_ssts: vec![],
1158                        object_timestamps: HashMap::default(),
1159                    });
1160                }
1161            });
1162        canceled_tasks.sort_by_key(|task| task.task_id);
1163        canceled_tasks.dedup_by_key(|task| task.task_id);
1164
1165        if !canceled_tasks.is_empty() {
1166            self.report_compact_tasks_impl(canceled_tasks, compaction_guard, versioning_guard)
1167                .await?;
1168        }
1169
1170        Ok(())
1171    }
1172
1173    /// Normalize overlapping adjacent compaction groups by split only.
1174    ///
1175    /// The algorithm repeatedly scans adjacent groups by `min(table_id)` and if
1176    /// `max(left) >= min(right)`, it splits `left` at the first table id `>= min(right)`.
1177    /// Each step is planned from a read snapshot, then revalidated and applied with a short write
1178    /// transaction.
1179    pub async fn normalize_overlapping_compaction_groups(&self) -> Result<usize> {
1180        self.normalize_overlapping_compaction_groups_with_limit(usize::MAX)
1181            .await
1182    }
1183
1184    pub async fn normalize_overlapping_compaction_groups_with_limit(
1185        &self,
1186        max_splits: usize,
1187    ) -> Result<usize> {
1188        let mut split_count = 0usize;
1189        while split_count < max_splits {
1190            let Some(plan) = self.build_normalize_plan().await else {
1191                break;
1192            };
1193
1194            if !self.apply_normalize_plan(&plan).await? {
1195                tracing::debug!(
1196                    parent_group_id = %plan.parent_group_id,
1197                    boundary_table_id = %plan.boundary_table_id,
1198                    "normalize plan became stale before apply"
1199                );
1200                break;
1201            }
1202            split_count += 1;
1203        }
1204
1205        Ok(split_count)
1206    }
1207
1208    /// Split the compaction group if the group is too large or contains high throughput tables.
1209    pub async fn try_split_compaction_group(
1210        &self,
1211        table_write_throughput_statistic_manager: &TableWriteThroughputStatisticManager,
1212        group: CompactionGroupStatistic,
1213    ) {
1214        if group
1215            .compaction_group_config
1216            .compaction_config
1217            .disable_auto_group_scheduling
1218            .unwrap_or(false)
1219        {
1220            return;
1221        }
1222        // split high throughput table to dedicated compaction group
1223        for (table_id, table_size) in &group.table_statistic {
1224            self.try_move_high_throughput_table_to_dedicated_cg(
1225                table_write_throughput_statistic_manager,
1226                *table_id,
1227                table_size,
1228                group.group_id,
1229            )
1230            .await;
1231        }
1232
1233        // split the huge group to multiple groups
1234        self.try_split_huge_compaction_group(group).await;
1235    }
1236
1237    /// Try to move the high throughput table to a dedicated compaction group.
1238    pub async fn try_move_high_throughput_table_to_dedicated_cg(
1239        &self,
1240        table_write_throughput_statistic_manager: &TableWriteThroughputStatisticManager,
1241        table_id: TableId,
1242        _table_size: &u64,
1243        parent_group_id: CompactionGroupId,
1244    ) {
1245        let mut table_throughput = table_write_throughput_statistic_manager
1246            .get_table_throughput_descending(
1247                table_id,
1248                self.env.opts.table_stat_throuput_window_seconds_for_split as i64,
1249            )
1250            .peekable();
1251
1252        if table_throughput.peek().is_none() {
1253            return;
1254        }
1255
1256        let is_high_write_throughput = GroupMergeValidator::is_table_high_write_throughput(
1257            table_throughput,
1258            self.env.opts.table_high_write_throughput_threshold,
1259            self.env
1260                .opts
1261                .table_stat_high_write_throughput_ratio_for_split,
1262        );
1263
1264        // do not split a table to dedicated compaction group if it is not high write throughput
1265        if !is_high_write_throughput {
1266            return;
1267        }
1268
1269        let ret = self
1270            .move_state_tables_to_dedicated_compaction_group(
1271                parent_group_id,
1272                &[table_id],
1273                Some(self.env.opts.partition_vnode_count),
1274            )
1275            .await;
1276        match ret {
1277            Ok(split_result) => {
1278                tracing::info!(
1279                    "split state table [{}] from group-{} success table_vnode_partition_count {:?} split result {:?}",
1280                    table_id,
1281                    parent_group_id,
1282                    self.env.opts.partition_vnode_count,
1283                    split_result
1284                );
1285            }
1286            Err(e) => {
1287                tracing::info!(
1288                    error = %e.as_report(),
1289                    "failed to split state table [{}] from group-{}",
1290                    table_id,
1291                    parent_group_id,
1292                )
1293            }
1294        }
1295    }
1296
1297    pub async fn try_split_huge_compaction_group(&self, group: CompactionGroupStatistic) {
1298        let group_max_size = (group.compaction_group_config.max_estimated_group_size() as f64
1299            * self.env.opts.split_group_size_ratio) as u64;
1300        let is_huge_hybrid_group =
1301            group.group_size > group_max_size && group.table_statistic.len() > 1; // avoid split single table group
1302        if is_huge_hybrid_group {
1303            let mut accumulated_size = 0;
1304            let mut table_ids = Vec::default();
1305            for (table_id, table_size) in &group.table_statistic {
1306                accumulated_size += table_size;
1307                table_ids.push(*table_id);
1308                // split if the accumulated size is greater than half of the group size
1309                // avoid split a small table to dedicated compaction group and trigger multiple merge
1310                assert!(table_ids.is_sorted());
1311                let remaining_size = group.group_size.saturating_sub(accumulated_size);
1312                if accumulated_size > group_max_size / 2
1313                    && remaining_size > 0
1314                    && table_ids.len() < group.table_statistic.len()
1315                {
1316                    let ret = self
1317                        .move_state_tables_to_dedicated_compaction_group(
1318                            group.group_id,
1319                            &table_ids,
1320                            None,
1321                        )
1322                        .await;
1323                    match ret {
1324                        Ok(split_result) => {
1325                            tracing::info!(
1326                                "split_huge_compaction_group success {:?}",
1327                                split_result
1328                            );
1329                            self.metrics
1330                                .split_compaction_group_count
1331                                .with_label_values(&[&group.group_id.to_string()])
1332                                .inc();
1333                            return;
1334                        }
1335                        Err(e) => {
1336                            tracing::error!(
1337                                error = %e.as_report(),
1338                                "failed to split_huge_compaction_group table {:?} from group-{}",
1339                                table_ids,
1340                                group.group_id
1341                            );
1342
1343                            return;
1344                        }
1345                    }
1346                }
1347            }
1348        }
1349    }
1350
1351    pub async fn try_merge_compaction_group(
1352        &self,
1353        table_write_throughput_statistic_manager: &TableWriteThroughputStatisticManager,
1354        group: &CompactionGroupStatistic,
1355        next_group: &CompactionGroupStatistic,
1356        created_tables: &HashSet<TableId>,
1357    ) -> Result<()> {
1358        GroupMergeValidator::validate_group_merge(
1359            group,
1360            next_group,
1361            created_tables,
1362            table_write_throughput_statistic_manager,
1363            &self.env.opts,
1364            &self.versioning,
1365        )
1366        .await?;
1367
1368        let result = self
1369            .merge_compaction_group(group.group_id, next_group.group_id)
1370            .await;
1371
1372        match &result {
1373            Ok(()) => {
1374                tracing::info!(
1375                    "merge group-{} to group-{}",
1376                    next_group.group_id,
1377                    group.group_id,
1378                );
1379
1380                self.metrics
1381                    .merge_compaction_group_count
1382                    .with_label_values(&[&group.group_id.to_string()])
1383                    .inc();
1384            }
1385            Err(e) => {
1386                tracing::info!(
1387                    error = %e.as_report(),
1388                    "failed to merge group-{} group-{}",
1389                    next_group.group_id,
1390                    group.group_id,
1391                );
1392            }
1393        }
1394
1395        result
1396    }
1397}
1398
1399#[derive(Debug, Default)]
1400struct GroupMergeValidator {}
1401
1402impl GroupMergeValidator {
1403    /// Check if two groups have compatible compaction configs for merging.
1404    /// Ignores `split_weight_by_vnode` since it's per-table and will be reset after merge.
1405    fn is_merge_compatible_by_semantics(
1406        group: &CompactionGroupStatistic,
1407        next_group: &CompactionGroupStatistic,
1408    ) -> bool {
1409        let (mut left, mut right) = (
1410            group
1411                .compaction_group_config
1412                .compaction_config
1413                .as_ref()
1414                .clone(),
1415            next_group
1416                .compaction_group_config
1417                .compaction_config
1418                .as_ref()
1419                .clone(),
1420        );
1421        left.split_weight_by_vnode = 0;
1422        right.split_weight_by_vnode = 0;
1423        left == right
1424    }
1425
1426    /// Check if the table is high write throughput with the given threshold and ratio.
1427    pub fn is_table_high_write_throughput(
1428        table_throughput: impl Iterator<Item = &TableWriteThroughputStatistic>,
1429        threshold: u64,
1430        high_write_throughput_ratio: f64,
1431    ) -> bool {
1432        let mut sample_size = 0;
1433        let mut high_write_throughput_count = 0;
1434        for statistic in table_throughput {
1435            sample_size += 1;
1436            if statistic.throughput > threshold {
1437                high_write_throughput_count += 1;
1438            }
1439        }
1440
1441        high_write_throughput_count as f64 > sample_size as f64 * high_write_throughput_ratio
1442    }
1443
1444    pub fn is_table_low_write_throughput(
1445        table_throughput: impl Iterator<Item = &TableWriteThroughputStatistic>,
1446        threshold: u64,
1447        low_write_throughput_ratio: f64,
1448    ) -> bool {
1449        let mut sample_size = 0;
1450        let mut low_write_throughput_count = 0;
1451        for statistic in table_throughput {
1452            sample_size += 1;
1453            if statistic.throughput <= threshold {
1454                low_write_throughput_count += 1;
1455            }
1456        }
1457
1458        low_write_throughput_count as f64 > sample_size as f64 * low_write_throughput_ratio
1459    }
1460
1461    fn check_is_low_write_throughput_compaction_group(
1462        table_write_throughput_statistic_manager: &TableWriteThroughputStatisticManager,
1463        group: &CompactionGroupStatistic,
1464        opts: &Arc<MetaOpts>,
1465    ) -> bool {
1466        let mut table_with_statistic = Vec::with_capacity(group.table_statistic.len());
1467        for table_id in group.table_statistic.keys() {
1468            let mut table_throughput = table_write_throughput_statistic_manager
1469                .get_table_throughput_descending(
1470                    *table_id,
1471                    opts.table_stat_throuput_window_seconds_for_merge as i64,
1472                )
1473                .peekable();
1474            if table_throughput.peek().is_none() {
1475                continue;
1476            }
1477
1478            table_with_statistic.push(table_throughput);
1479        }
1480
1481        // if all tables in the group do not have enough statistics, return true
1482        if table_with_statistic.is_empty() {
1483            return true;
1484        }
1485
1486        // check if all tables in the group are low write throughput with enough statistics
1487        table_with_statistic.into_iter().all(|table_throughput| {
1488            Self::is_table_low_write_throughput(
1489                table_throughput,
1490                opts.table_low_write_throughput_threshold,
1491                opts.table_stat_low_write_throughput_ratio_for_merge,
1492            )
1493        })
1494    }
1495
1496    fn check_is_creating_compaction_group(
1497        group: &CompactionGroupStatistic,
1498        created_tables: &HashSet<TableId>,
1499    ) -> bool {
1500        group
1501            .table_statistic
1502            .keys()
1503            .any(|table_id| !created_tables.contains(table_id))
1504    }
1505
1506    async fn validate_group_merge(
1507        group: &CompactionGroupStatistic,
1508        next_group: &CompactionGroupStatistic,
1509        created_tables: &HashSet<TableId>,
1510        table_write_throughput_statistic_manager: &TableWriteThroughputStatisticManager,
1511        opts: &Arc<MetaOpts>,
1512        versioning: &MonitoredRwLock<Versioning>,
1513    ) -> Result<()> {
1514        // TODO: remove this check after refactor group id
1515        if (group.group_id == StaticCompactionGroupId::StateDefault
1516            && next_group.group_id == StaticCompactionGroupId::MaterializedView)
1517            || (group.group_id == StaticCompactionGroupId::MaterializedView
1518                && next_group.group_id == StaticCompactionGroupId::StateDefault)
1519        {
1520            return Err(Error::CompactionGroup(format!(
1521                "group-{} and group-{} are both StaticCompactionGroupId",
1522                group.group_id, next_group.group_id
1523            )));
1524        }
1525
1526        if group.table_statistic.is_empty() || next_group.table_statistic.is_empty() {
1527            return Err(Error::CompactionGroup(format!(
1528                "group-{} or group-{} is empty",
1529                group.group_id, next_group.group_id
1530            )));
1531        }
1532
1533        // Check non-overlapping table ids early to avoid acquiring heavyweight write locks
1534        // in merge_compaction_group_impl only to fail at the overlap check.
1535        // Sort both sides and ensure table_ids_1 has the smaller first element,
1536        // then reject if table_ids_1's last element >= table_ids_2's first element (overlap).
1537        {
1538            let mut table_ids_1: Vec<TableId> = group.table_statistic.keys().cloned().collect_vec();
1539            let mut table_ids_2: Vec<TableId> =
1540                next_group.table_statistic.keys().cloned().collect_vec();
1541            table_ids_1.sort();
1542            table_ids_2.sort();
1543            if table_ids_1.first().unwrap() > table_ids_2.first().unwrap() {
1544                std::mem::swap(&mut table_ids_1, &mut table_ids_2);
1545            }
1546            if table_ids_1.last().unwrap() >= table_ids_2.first().unwrap() {
1547                return Err(Error::CompactionGroup(format!(
1548                    "group-{} and group-{} have overlapping table id ranges, not mergeable",
1549                    group.group_id, next_group.group_id
1550                )));
1551            }
1552        }
1553
1554        if group
1555            .compaction_group_config
1556            .compaction_config
1557            .disable_auto_group_scheduling
1558            .unwrap_or(false)
1559            || next_group
1560                .compaction_group_config
1561                .compaction_config
1562                .disable_auto_group_scheduling
1563                .unwrap_or(false)
1564        {
1565            return Err(Error::CompactionGroup(format!(
1566                "group-{} or group-{} disable_auto_group_scheduling",
1567                group.group_id, next_group.group_id
1568            )));
1569        }
1570
1571        // Keep merge compatibility as a feature, but ignore split_weight_by_vnode, because it is
1572        // only used for per-table split behavior and will be reset after merge.
1573        if !Self::is_merge_compatible_by_semantics(group, next_group) {
1574            let left_config = group.compaction_group_config.compaction_config.as_ref();
1575            let right_config = next_group
1576                .compaction_group_config
1577                .compaction_config
1578                .as_ref();
1579
1580            tracing::warn!(
1581                group_id = %group.group_id,
1582                next_group_id = %next_group.group_id,
1583                left_config = ?left_config,
1584                right_config = ?right_config,
1585                "compaction config semantic mismatch detected while merging compaction groups"
1586            );
1587
1588            return Err(Error::CompactionGroup(format!(
1589                "Cannot merge group {} and next_group {} with different compaction config (split_weight_by_vnode is excluded from comparison). left_config: {:?}, right_config: {:?}",
1590                group.group_id, next_group.group_id, left_config, right_config
1591            )));
1592        }
1593
1594        // do not merge the compaction group which is creating
1595        if Self::check_is_creating_compaction_group(group, created_tables) {
1596            return Err(Error::CompactionGroup(format!(
1597                "Cannot merge creating group {} next_group {}",
1598                group.group_id, next_group.group_id
1599            )));
1600        }
1601
1602        // do not merge high throughput group
1603        if !Self::check_is_low_write_throughput_compaction_group(
1604            table_write_throughput_statistic_manager,
1605            group,
1606            opts,
1607        ) {
1608            return Err(Error::CompactionGroup(format!(
1609                "Cannot merge high throughput group {} next_group {}",
1610                group.group_id, next_group.group_id
1611            )));
1612        }
1613
1614        let size_limit = (group.compaction_group_config.max_estimated_group_size() as f64
1615            * opts.split_group_size_ratio) as u64;
1616
1617        if (group.group_size + next_group.group_size) > size_limit {
1618            return Err(Error::CompactionGroup(format!(
1619                "Cannot merge huge group {} group_size {} next_group {} next_group_size {} size_limit {}",
1620                group.group_id,
1621                group.group_size,
1622                next_group.group_id,
1623                next_group.group_size,
1624                size_limit
1625            )));
1626        }
1627
1628        if Self::check_is_creating_compaction_group(next_group, created_tables) {
1629            return Err(Error::CompactionGroup(format!(
1630                "Cannot merge creating group {} next group {}",
1631                group.group_id, next_group.group_id
1632            )));
1633        }
1634
1635        if !Self::check_is_low_write_throughput_compaction_group(
1636            table_write_throughput_statistic_manager,
1637            next_group,
1638            opts,
1639        ) {
1640            return Err(Error::CompactionGroup(format!(
1641                "Cannot merge high throughput group {} next group {}",
1642                group.group_id, next_group.group_id
1643            )));
1644        }
1645
1646        {
1647            // Avoid merge when the group is in emergency state
1648            let versioning_guard = versioning
1649                .read_with_process_name("validate_group_merge")
1650                .await;
1651            let levels = &versioning_guard.current_version.levels;
1652            if !levels.contains_key(&group.group_id) {
1653                return Err(Error::CompactionGroup(format!(
1654                    "cannot merge compaction group {} because it does not exist",
1655                    group.group_id
1656                )));
1657            }
1658
1659            if !levels.contains_key(&next_group.group_id) {
1660                return Err(Error::CompactionGroup(format!(
1661                    "cannot merge next compaction group {} because it does not exist",
1662                    next_group.group_id
1663                )));
1664            }
1665
1666            let group_levels = versioning_guard
1667                .current_version
1668                .get_compaction_group_levels(group.group_id);
1669
1670            let next_group_levels = versioning_guard
1671                .current_version
1672                .get_compaction_group_levels(next_group.group_id);
1673
1674            let group_state = GroupStateValidator::group_state(
1675                group_levels,
1676                group.compaction_group_config.compaction_config().deref(),
1677            );
1678
1679            if group_state.is_write_stop() || group_state.is_emergency() {
1680                return Err(Error::CompactionGroup(format!(
1681                    "Cannot merge write limit group {} next group {}",
1682                    group.group_id, next_group.group_id
1683                )));
1684            }
1685
1686            let next_group_state = GroupStateValidator::group_state(
1687                next_group_levels,
1688                next_group
1689                    .compaction_group_config
1690                    .compaction_config()
1691                    .deref(),
1692            );
1693
1694            if next_group_state.is_write_stop() || next_group_state.is_emergency() {
1695                return Err(Error::CompactionGroup(format!(
1696                    "Cannot merge write limit next group {} group {}",
1697                    next_group.group_id, group.group_id
1698                )));
1699            }
1700
1701            // check whether the group is in the write stop state after merge
1702            let l0_sub_level_count_after_merge =
1703                group_levels.l0.sub_levels.len() + next_group_levels.l0.sub_levels.len();
1704            if GroupStateValidator::write_stop_sub_level_count(
1705                (l0_sub_level_count_after_merge as f64
1706                    * opts.compaction_group_merge_dimension_threshold) as usize,
1707                group.compaction_group_config.compaction_config().deref(),
1708            ) {
1709                return Err(Error::CompactionGroup(format!(
1710                    "Cannot merge write limit group {} next group {}, will trigger write stop after merge",
1711                    group.group_id, next_group.group_id
1712                )));
1713            }
1714
1715            let l0_file_count_after_merge = group_levels
1716                .l0
1717                .sub_levels
1718                .iter()
1719                .chain(next_group_levels.l0.sub_levels.iter())
1720                .map(|level| level.table_infos.len())
1721                .sum::<usize>();
1722            if GroupStateValidator::write_stop_l0_file_count(
1723                (l0_file_count_after_merge as f64 * opts.compaction_group_merge_dimension_threshold)
1724                    as usize,
1725                group.compaction_group_config.compaction_config().deref(),
1726            ) {
1727                return Err(Error::CompactionGroup(format!(
1728                    "Cannot merge write limit next group {} group {}, will trigger write stop after merge",
1729                    next_group.group_id, group.group_id
1730                )));
1731            }
1732
1733            let l0_size_after_merge =
1734                group_levels.l0.total_file_size + next_group_levels.l0.total_file_size;
1735
1736            if GroupStateValidator::write_stop_l0_size(
1737                (l0_size_after_merge as f64 * opts.compaction_group_merge_dimension_threshold)
1738                    as u64,
1739                group.compaction_group_config.compaction_config().deref(),
1740            ) {
1741                return Err(Error::CompactionGroup(format!(
1742                    "Cannot merge write limit next group {} group {}, will trigger write stop after merge",
1743                    next_group.group_id, group.group_id
1744                )));
1745            }
1746
1747            // check whether the group is in the emergency state after merge
1748            if GroupStateValidator::emergency_l0_file_count(
1749                (l0_file_count_after_merge as f64 * opts.compaction_group_merge_dimension_threshold)
1750                    as usize,
1751                group.compaction_group_config.compaction_config().deref(),
1752            ) {
1753                return Err(Error::CompactionGroup(format!(
1754                    "Cannot merge emergency group {} next group {}, will trigger emergency after merge",
1755                    group.group_id, next_group.group_id
1756                )));
1757            }
1758        }
1759
1760        Ok(())
1761    }
1762}