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