Skip to main content

risingwave_meta/hummock/manager/compaction/compaction_group_schedule/
topology.rs

1// Copyright 2026 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
15//! Split and merge transactions. Lock acquisition, commit, candidate publication, and
16//! post-commit cleanup stay together so their ordering is visible at each operation.
17
18use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
19use std::ops::DerefMut;
20use std::sync::Arc;
21
22use bytes::Bytes;
23use itertools::Itertools;
24use risingwave_common::catalog::TableId;
25use risingwave_common::hash::VirtualNode;
26use risingwave_hummock_sdk::compact_task::{ReportTask, is_compaction_task_expired};
27use risingwave_hummock_sdk::compaction_group::{StateTableId, group_split};
28use risingwave_hummock_sdk::version::{GroupDelta, GroupDeltas};
29use risingwave_hummock_sdk::{CompactionGroupId, can_concat};
30use risingwave_pb::hummock::compact_task::{TaskStatus, TaskType};
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::{CompactionGroupStatistic, merge_policy};
38use crate::hummock::error::{Error, Result};
39use crate::hummock::manager::transaction::HummockVersionTransaction;
40use crate::hummock::manager::{HummockManager, commit_multi_var};
41use crate::hummock::metrics_utils::remove_compaction_group_metrics;
42use crate::hummock::sequence::{next_compaction_group_id, next_sstable_id};
43
44impl HummockManager {
45    pub async fn merge_compaction_group(
46        &self,
47        group_1: CompactionGroupId,
48        group_2: CompactionGroupId,
49    ) -> Result<()> {
50        self.merge_compaction_group_impl(group_1, group_2, None, false)
51            .await
52            .map(|_| ())
53    }
54
55    pub async fn merge_compaction_group_for_test(
56        &self,
57        group_1: CompactionGroupId,
58        group_2: CompactionGroupId,
59        created_tables: HashSet<TableId>,
60    ) -> Result<()> {
61        self.merge_compaction_group_impl(group_1, group_2, Some(&created_tables), false)
62            .await
63            .map(|_| ())
64    }
65
66    pub async fn merge_compaction_group_impl(
67        &self,
68        group_1: CompactionGroupId,
69        group_2: CompactionGroupId,
70        created_tables: Option<&HashSet<TableId>>,
71        validate_policy: bool,
72    ) -> Result<CompactionGroupStatistic> {
73        // Catalog access can wait for DDL or the database. Do it before taking Hummock write
74        // locks, then conservatively check this snapshot against the current group members.
75        let fetched_created_tables;
76        let created_tables = if let Some(created_tables) = created_tables {
77            // Reuse the batch snapshot; a newly added table is conservatively rejected by
78            // the current-membership check below, without another catalog query per merge.
79            created_tables
80        } else {
81            fetched_created_tables = match self.metadata_manager.get_created_table_ids().await {
82                Ok(created_tables) => HashSet::from_iter(created_tables),
83                Err(err) => {
84                    tracing::warn!(target: super::TRACE_TARGET, error = %err.as_report(), "failed to fetch created table ids");
85                    return Err(Error::CompactionGroup(format!(
86                        "merge group_1 {} group_2 {} failed to fetch created table ids",
87                        group_1, group_2
88                    )));
89                }
90            };
91            &fetched_created_tables
92        };
93        let compaction_guard = self
94            .compaction
95            .write_with_process_name("merge_compaction_group_impl")
96            .await;
97        let mut versioning_guard = self
98            .versioning
99            .write_with_process_name("merge_compaction_group_impl")
100            .await;
101        let versioning = versioning_guard.deref_mut();
102        // Validate parameters.
103        if !versioning.current_version.levels.contains_key(&group_1) {
104            return Err(Error::CompactionGroup(format!("invalid group {}", group_1)));
105        }
106
107        if !versioning.current_version.levels.contains_key(&group_2) {
108            return Err(Error::CompactionGroup(format!("invalid group {}", group_2)));
109        }
110
111        let state_table_info = &versioning.current_version.state_table_info;
112        let mut member_table_ids_1 = state_table_info
113            .compaction_group_member_table_ids(group_1)
114            .iter()
115            .cloned()
116            .collect_vec();
117
118        if member_table_ids_1.is_empty() {
119            return Err(Error::CompactionGroup(format!(
120                "group_1 {} is empty",
121                group_1
122            )));
123        }
124
125        let mut member_table_ids_2 = state_table_info
126            .compaction_group_member_table_ids(group_2)
127            .iter()
128            .cloned()
129            .collect_vec();
130
131        if member_table_ids_2.is_empty() {
132            return Err(Error::CompactionGroup(format!(
133                "group_2 {} is empty",
134                group_2
135            )));
136        }
137
138        debug_assert!(!member_table_ids_1.is_empty());
139        debug_assert!(!member_table_ids_2.is_empty());
140        assert!(member_table_ids_1.is_sorted());
141        assert!(member_table_ids_2.is_sorted());
142
143        // Do not merge a group containing a table that is still being created.
144        if member_table_ids_1
145            .iter()
146            .chain(&member_table_ids_2)
147            .any(|table_id| !created_tables.contains(table_id))
148        {
149            return Err(Error::CompactionGroup(format!(
150                "Cannot merge creating group {} next_group {} member_table_ids_1 {:?} member_table_ids_2 {:?}",
151                group_1, group_2, member_table_ids_1, member_table_ids_2
152            )));
153        }
154
155        // Make sure `member_table_ids_1` is smaller than `member_table_ids_2`
156        let (left_group_id, right_group_id) =
157            if member_table_ids_1.first().unwrap() < member_table_ids_2.first().unwrap() {
158                (group_1, group_2)
159            } else {
160                std::mem::swap(&mut member_table_ids_1, &mut member_table_ids_2);
161                (group_2, group_1)
162            };
163
164        // We can only merge two groups with non-overlapping member table ids.
165        // After the swap above, member_table_ids_1 has the smaller first element.
166        // If the last element of member_table_ids_1 >= the first element of member_table_ids_2,
167        // the two groups' table id ranges overlap and cannot be merged.
168        if member_table_ids_1.last().unwrap() >= member_table_ids_2.first().unwrap() {
169            return Err(Error::CompactionGroup(format!(
170                "invalid merge group_1 {} group_2 {}: table id ranges overlap",
171                left_group_id, right_group_id
172            )));
173        }
174
175        let combined_member_table_ids = member_table_ids_1
176            .iter()
177            .chain(member_table_ids_2.iter())
178            .collect_vec();
179        assert!(combined_member_table_ids.is_sorted());
180
181        // These members and their sizes are stable under versioning. Reuse the same
182        // per-table sizes for the policy check and the returned survivor statistics.
183        let survivor_tables = combined_member_table_ids
184            .iter()
185            .map(|&&table_id| {
186                let size = versioning
187                    .version_stats
188                    .table_stats
189                    .get(&table_id)
190                    .map(|stats| (stats.total_key_size + stats.total_value_size).max(0) as u64)
191                    .unwrap_or(0);
192                (table_id, size)
193            })
194            .collect_vec();
195
196        let mut compaction_group_manager = self
197            .compaction_group_manager
198            .write_with_process_name("merge_compaction_group_impl")
199            .await;
200        if validate_policy {
201            let group = compaction_group_manager
202                .try_get_compaction_group_config(group_1)
203                .ok_or_else(|| Error::CompactionGroup(format!("invalid group {group_1}")))?;
204            let next_group = compaction_group_manager
205                .try_get_compaction_group_config(group_2)
206                .ok_or_else(|| Error::CompactionGroup(format!("invalid group {group_2}")))?;
207            let current_size = survivor_tables.iter().map(|(_, size)| size).sum();
208            // The scheduling snapshot is only a prefilter. Check all mutable policy inputs
209            // under the locks already needed to apply the merge, before scanning SSTs.
210            merge_policy::validate_group_config(&group, &next_group, current_size, &self.env.opts)?;
211            merge_policy::validate_group_levels(
212                &group,
213                &next_group,
214                &self.env.opts,
215                &versioning.current_version,
216            )?;
217            // Commit hands off to statistics before releasing versioning. This read cannot
218            // pair the new version with throughput from before its commit.
219            if !merge_policy::check_is_low_write_throughput(
220                &self.table_write_throughput_statistic_manager.read(),
221                combined_member_table_ids.iter().map(|&&table_id| table_id),
222                &self.env.opts,
223            ) {
224                return Err(Error::CompactionGroup(format!(
225                    "Cannot merge groups {group_1} and {group_2}: current throughput is not observed cold"
226                )));
227            }
228        }
229
230        // check duplicated sst_id
231        let mut sst_id_set = HashSet::new();
232        for sst_id in versioning
233            .current_version
234            .get_sst_ids_by_group_id(left_group_id)
235            .chain(
236                versioning
237                    .current_version
238                    .get_sst_ids_by_group_id(right_group_id),
239            )
240        {
241            if !sst_id_set.insert(sst_id) {
242                return Err(Error::CompactionGroup(format!(
243                    "invalid merge group_1 {} group_2 {} duplicated sst_id {}",
244                    left_group_id, right_group_id, sst_id
245                )));
246            }
247        }
248
249        // check branched sst on non-overlap level
250        {
251            let left_levels = versioning
252                .current_version
253                .get_compaction_group_levels(left_group_id);
254
255            let right_levels = versioning
256                .current_version
257                .get_compaction_group_levels(right_group_id);
258
259            // we can not check the l0 sub level, because the sub level id will be rewritten when merge
260            // This check will ensure that other non-overlapping level ssts can be concat and that the key_range is correct.
261            let max_level = std::cmp::max(left_levels.levels.len(), right_levels.levels.len());
262            for level_idx in 1..=max_level {
263                let left_level = left_levels.get_level(level_idx);
264                let right_level = right_levels.get_level(level_idx);
265                if left_level.table_infos.is_empty() || right_level.table_infos.is_empty() {
266                    continue;
267                }
268
269                let left_last_sst = left_level.table_infos.last().unwrap().clone();
270                let right_first_sst = right_level.table_infos.first().unwrap().clone();
271                let left_sst_id = left_last_sst.sst_id;
272                let right_sst_id = right_first_sst.sst_id;
273                let left_obj_id = left_last_sst.object_id;
274                let right_obj_id = right_first_sst.object_id;
275
276                // Since the sst key_range within a group is legal, we only need to check the ssts adjacent to the two groups.
277                if !can_concat(&[left_last_sst, right_first_sst]) {
278                    return Err(Error::CompactionGroup(format!(
279                        "invalid merge group_1 {} group_2 {} level_idx {} left_last_sst_id {} right_first_sst_id {} left_obj_id {} right_obj_id {}",
280                        left_group_id,
281                        right_group_id,
282                        level_idx,
283                        left_sst_id,
284                        right_sst_id,
285                        left_obj_id,
286                        right_obj_id
287                    )));
288                }
289            }
290        }
291
292        let mut version = HummockVersionTransaction::new(
293            &mut versioning.current_version,
294            &mut versioning.hummock_version_deltas,
295            &mut versioning.table_change_log,
296            &self.gc_manager.change_log_retention,
297            self.env.notification_manager(),
298            None,
299            &self.metrics,
300            &self.env.opts,
301            &self.version_stat_tx,
302        );
303        let mut new_version_delta = version.new_delta();
304
305        let target_compaction_group_id = {
306            // merge right_group_id to left_group_id and remove right_group_id
307            new_version_delta.group_deltas.insert(
308                left_group_id,
309                GroupDeltas {
310                    group_deltas: vec![GroupDelta::GroupMerge(PbGroupMerge {
311                        left_group_id,
312                        right_group_id,
313                    })],
314                },
315            );
316            left_group_id
317        };
318
319        // TODO: remove compaciton group_id from state_table_info
320        // rewrite compaction_group_id for all tables
321        new_version_delta.with_latest_version(|version, new_version_delta| {
322            for &table_id in combined_member_table_ids {
323                let info = version
324                    .state_table_info
325                    .info()
326                    .get(&table_id)
327                    .expect("have check exist previously");
328                assert!(
329                    new_version_delta
330                        .state_table_info_delta
331                        .insert(
332                            table_id,
333                            PbStateTableInfoDelta {
334                                committed_epoch: info.committed_epoch,
335                                compaction_group_id: target_compaction_group_id,
336                            }
337                        )
338                        .is_none()
339                );
340            }
341        });
342
343        let survivor_config = {
344            let mut compaction_groups_txn = compaction_group_manager.start_compaction_groups_txn();
345
346            // for metrics reclaim
347            {
348                let right_group_max_level = new_version_delta
349                    .latest_version()
350                    .get_compaction_group_levels(right_group_id)
351                    .levels
352                    .len();
353
354                remove_compaction_group_metrics(
355                    &self.metrics,
356                    right_group_id,
357                    right_group_max_level,
358                );
359            }
360
361            // clear `partition_vnode_count` for the hybrid group
362            {
363                if let Err(err) = compaction_groups_txn.update_compaction_config(
364                    &[left_group_id],
365                    &[MutableConfig::SplitWeightByVnode(0)], // default
366                ) {
367                    tracing::error!(target: super::TRACE_TARGET,
368                        error = %err.as_report(),
369                        "failed to update compaction config for group-{}",
370                        left_group_id
371                    );
372                }
373            }
374
375            new_version_delta.pre_apply();
376
377            // remove right_group_id
378            compaction_groups_txn.remove(right_group_id);
379            commit_multi_var!(self.meta_store_ref(), version, compaction_groups_txn)?;
380            compaction_group_manager
381                .try_get_compaction_group_config(left_group_id)
382                .expect("merged group config should exist")
383        };
384
385        drop(compaction_group_manager);
386
387        // Update candidates only after commit, while versioning still protects group membership.
388        self.compaction_state
389            .remove_compaction_group(right_group_id);
390        if !self.env.opts.compaction_deterministic_test {
391            self.try_send_compaction_request(left_group_id, TaskType::Dynamic);
392        }
393
394        // Instead of handling DeltaType::GroupConstruct for time travel, simply enforce a version snapshot.
395        versioning.mark_next_time_travel_version_snapshot();
396
397        // cancel tasks
398        let mut canceled_tasks = vec![];
399        // after merge, all tasks in right_group_id should be canceled
400        // 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.
401        let compact_task_assignments =
402            compaction_guard.get_compact_task_assignments_by_group_id(right_group_id);
403        compact_task_assignments
404            .into_iter()
405            .for_each(|task_assignment| {
406                let task = &task_assignment.compact_task;
407                assert_eq!(task.compaction_group_id, right_group_id);
408                canceled_tasks.push(ReportTask {
409                    task_id: task.task_id,
410                    task_status: TaskStatus::ManualCanceled,
411                    table_stats_change: HashMap::default(),
412                    sorted_output_ssts: vec![],
413                    object_timestamps: HashMap::default(),
414                });
415            });
416
417        if !canceled_tasks.is_empty() {
418            self.report_compact_tasks_impl(canceled_tasks, compaction_guard, versioning_guard)
419                .await?;
420        } else {
421            drop(versioning_guard);
422            drop(compaction_guard);
423        }
424
425        self.try_update_write_limits(&[left_group_id, right_group_id])
426            .await;
427
428        // Count successful merges here for both manual and automatic callers.
429        self.metrics
430            .merge_compaction_group_count
431            .with_label_values(&[&left_group_id.to_string()])
432            .inc();
433
434        // This is the actual survivor at the topology commit, including its current members and
435        // config. Build its map after releasing the write locks, without rescanning other groups.
436        Ok(CompactionGroupStatistic {
437            group_id: left_group_id,
438            group_size: survivor_tables.iter().map(|(_, size)| size).sum(),
439            table_statistic: survivor_tables.into_iter().collect(),
440            compaction_group_config: survivor_config,
441        })
442    }
443}
444
445impl HummockManager {
446    /// Split `table_ids` to a dedicated compaction group.(will be split by the `table_id` and `vnode`.)
447    /// Returns the compaction group id containing the `table_ids` and the mapping of compaction group id to table ids.
448    /// The split will follow the following rules
449    /// 1. ssts with `key_range.left` greater than `split_key` will be split to the right group
450    /// 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`]
451    /// 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)
452    ///   - `parent_group_id`: the `group_id` to split
453    ///   - `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
454    ///   - `table_id_to_split`: the `table_id` to split
455    ///   - `vnode_to_split`: the `vnode` to split
456    ///   - `partition_vnode_count`: the partition count for the single table group if need
457    async fn split_compaction_group_impl(
458        &self,
459        parent_group_id: CompactionGroupId,
460        split_table_ids: &[StateTableId],
461        table_id_to_split: StateTableId,
462        vnode_to_split: VirtualNode,
463        partition_vnode_count: Option<u32>,
464        validate_policy: bool,
465    ) -> Result<Vec<(CompactionGroupId, Vec<StateTableId>)>> {
466        let mut result = vec![];
467        let compaction_guard = self
468            .compaction
469            .write_with_process_name("split_compaction_group_impl")
470            .await;
471        let mut versioning_guard = self
472            .versioning
473            .write_with_process_name("split_compaction_group_impl")
474            .await;
475        let versioning = versioning_guard.deref_mut();
476        // Validate parameters.
477        if !versioning
478            .current_version
479            .levels
480            .contains_key(&parent_group_id)
481        {
482            return Err(Error::CompactionGroup(format!(
483                "invalid group {}",
484                parent_group_id
485            )));
486        }
487
488        let member_table_ids = versioning
489            .current_version
490            .state_table_info
491            .compaction_group_member_table_ids(parent_group_id)
492            .iter()
493            .copied()
494            .collect::<BTreeSet<_>>();
495
496        if !member_table_ids.contains(&table_id_to_split) {
497            return Err(Error::CompactionGroup(format!(
498                "table {} doesn't in group {}",
499                table_id_to_split, parent_group_id
500            )));
501        }
502
503        let split_full_key = group_split::build_split_full_key(table_id_to_split, vnode_to_split);
504
505        // change to vec for partition
506        let table_ids = member_table_ids.into_iter().collect_vec();
507        if table_ids == split_table_ids {
508            return Err(Error::CompactionGroup(format!(
509                "invalid split attempt for group {}: all member tables are moved",
510                parent_group_id
511            )));
512        }
513        // avoid decode split_key when caller is aware of the table_id and vnode
514        let (table_ids_left, table_ids_right) =
515            group_split::split_table_ids_with_table_id_and_vnode(
516                &table_ids,
517                split_full_key.user_key.table_id,
518                split_full_key.user_key.get_vnode_id(),
519            );
520        if table_ids_left.is_empty() || table_ids_right.is_empty() {
521            // not need to split group if all tables are in the same side
522            if !table_ids_left.is_empty() {
523                result.push((parent_group_id, table_ids_left));
524            }
525
526            if !table_ids_right.is_empty() {
527                result.push((parent_group_id, table_ids_right));
528            }
529            return Ok(result);
530        }
531
532        result.push((parent_group_id, table_ids_left));
533
534        let split_key: Bytes = split_full_key.encode().into();
535
536        let mut version = HummockVersionTransaction::new(
537            &mut versioning.current_version,
538            &mut versioning.hummock_version_deltas,
539            &mut versioning.table_change_log,
540            &self.gc_manager.change_log_retention,
541            self.env.notification_manager(),
542            None,
543            &self.metrics,
544            &self.env.opts,
545            &self.version_stat_tx,
546        );
547        let mut new_version_delta = version.new_delta();
548
549        let split_sst_count = new_version_delta
550            .latest_version()
551            .count_new_ssts_in_group_split(parent_group_id, split_key.clone());
552
553        let new_sst_start_id = next_sstable_id(&self.env, split_sst_count).await?;
554        let (new_compaction_group_id, config) = {
555            // All NewCompactionGroup pairs are mapped to one new compaction group.
556            let new_compaction_group_id = next_compaction_group_id(&self.env).await?;
557            // Inherit config from parent group
558            let config = self
559                .compaction_group_manager
560                .read_with_process_name("split_compaction_group_impl")
561                .await
562                .try_get_compaction_group_config(parent_group_id)
563                .ok_or_else(|| {
564                    Error::CompactionGroup(format!(
565                        "parent group {} config not found",
566                        parent_group_id
567                    ))
568                })?
569                .compaction_config()
570                .as_ref()
571                .clone();
572
573            #[expect(deprecated)]
574            // fill the deprecated field with default value
575            new_version_delta.group_deltas.insert(
576                new_compaction_group_id,
577                GroupDeltas {
578                    group_deltas: vec![GroupDelta::GroupConstruct(Box::new(PbGroupConstruct {
579                        group_config: Some(config.clone()),
580                        group_id: new_compaction_group_id,
581                        parent_group_id,
582                        new_sst_start_id,
583                        table_ids: vec![],
584                        version: CompatibilityVersion::LATEST as _, // for compatibility
585                        split_key: Some(split_key.into()),
586                    }))],
587                },
588            );
589            (new_compaction_group_id, config)
590        };
591
592        new_version_delta.with_latest_version(|version, new_version_delta| {
593            for &table_id in &table_ids_right {
594                let info = version
595                    .state_table_info
596                    .info()
597                    .get(&table_id)
598                    .expect("have check exist previously");
599                assert!(
600                    new_version_delta
601                        .state_table_info_delta
602                        .insert(
603                            table_id,
604                            PbStateTableInfoDelta {
605                                committed_epoch: info.committed_epoch,
606                                compaction_group_id: new_compaction_group_id,
607                            }
608                        )
609                        .is_none()
610                );
611            }
612        });
613
614        result.push((new_compaction_group_id, table_ids_right));
615
616        {
617            let mut compaction_group_manager = self
618                .compaction_group_manager
619                .write_with_process_name("split_compaction_group_impl")
620                .await;
621            // Check automatic policy at each committed step, while config changes are
622            // excluded by the same write guard used to create the child group.
623            if validate_policy
624                && compaction_group_manager
625                    .try_get_compaction_group_config(parent_group_id)
626                    .expect("current parent config should exist")
627                    .compaction_config
628                    .disable_auto_group_scheduling
629                    .unwrap_or(false)
630            {
631                return Err(Error::CompactionGroup(format!(
632                    "group {parent_group_id} disables automatic splitting"
633                )));
634            }
635            let mut compaction_groups_txn = compaction_group_manager.start_compaction_groups_txn();
636            compaction_groups_txn
637                .create_compaction_groups(new_compaction_group_id, Arc::new(config));
638
639            // check if need to update the compaction config for the single table group and guarantee the operation atomicity
640            // `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.
641            // The target `table_ids` might be split to an existing group, so we need to try to update its config
642            for (cg_id, table_ids) in &result {
643                // check the split_tables had been place to the dedicated compaction group
644                if let Some(partition_vnode_count) = partition_vnode_count
645                    && table_ids.len() == 1
646                    && table_ids == split_table_ids
647                    && let Err(err) = compaction_groups_txn.update_compaction_config(
648                        &[*cg_id],
649                        &[MutableConfig::SplitWeightByVnode(partition_vnode_count)],
650                    )
651                {
652                    tracing::error!(target: super::TRACE_TARGET,
653                        error = %err.as_report(),
654                        "failed to update compaction config for group-{}",
655                        cg_id
656                    );
657                }
658            }
659
660            new_version_delta.pre_apply();
661            commit_multi_var!(self.meta_store_ref(), version, compaction_groups_txn)?;
662        }
663        if !self.env.opts.compaction_deterministic_test {
664            for (group_id, _) in &result {
665                self.try_send_compaction_request(*group_id, TaskType::Dynamic);
666            }
667        }
668        // Instead of handling DeltaType::GroupConstruct for time travel, simply enforce a version snapshot.
669        versioning.mark_next_time_travel_version_snapshot();
670
671        // The expired compact tasks will be canceled.
672        // 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.
673        let mut canceled_tasks = vec![];
674        let compact_task_assignments =
675            compaction_guard.get_compact_task_assignments_by_group_id(parent_group_id);
676        let levels = versioning
677            .current_version
678            .get_compaction_group_levels(parent_group_id);
679        compact_task_assignments
680            .into_iter()
681            .for_each(|task_assignment| {
682                let task = &task_assignment.compact_task;
683                let is_expired = is_compaction_task_expired(
684                    task.compaction_group_version_id,
685                    levels.compaction_group_version_id,
686                );
687                if is_expired {
688                    canceled_tasks.push(ReportTask {
689                        task_id: task.task_id,
690                        task_status: TaskStatus::ManualCanceled,
691                        table_stats_change: HashMap::default(),
692                        sorted_output_ssts: vec![],
693                        object_timestamps: HashMap::default(),
694                    });
695                }
696            });
697
698        if !canceled_tasks.is_empty() {
699            self.report_compact_tasks_impl(canceled_tasks, compaction_guard, versioning_guard)
700                .await?;
701        } else {
702            drop(versioning_guard);
703            drop(compaction_guard);
704        }
705
706        let affected_group_ids = result.iter().map(|(cg_id, _)| *cg_id).collect_vec();
707        self.try_update_write_limits(&affected_group_ids).await;
708
709        self.metrics
710            .split_compaction_group_count
711            .with_label_values(&[&parent_group_id.to_string()])
712            .inc();
713
714        Ok(result)
715    }
716
717    /// Split `table_ids` to a dedicated compaction group.
718    /// Returns the compaction group id containing the `table_ids` and the mapping of compaction group id to table ids.
719    pub async fn move_state_tables_to_dedicated_compaction_group(
720        &self,
721        parent_group_id: CompactionGroupId,
722        table_ids: &[StateTableId],
723        partition_vnode_count: Option<u32>,
724    ) -> Result<(
725        CompactionGroupId,
726        BTreeMap<CompactionGroupId, Vec<StateTableId>>,
727    )> {
728        self.move_state_tables_to_dedicated_compaction_group_impl(
729            parent_group_id,
730            table_ids,
731            partition_vnode_count,
732            false,
733        )
734        .await
735    }
736
737    pub(super) async fn move_state_tables_to_dedicated_compaction_group_impl(
738        &self,
739        parent_group_id: CompactionGroupId,
740        table_ids: &[StateTableId],
741        partition_vnode_count: Option<u32>,
742        validate_policy: bool,
743    ) -> Result<(
744        CompactionGroupId,
745        BTreeMap<CompactionGroupId, Vec<StateTableId>>,
746    )> {
747        if table_ids.is_empty() {
748            return Err(Error::CompactionGroup(
749                "table_ids must not be empty".to_owned(),
750            ));
751        }
752
753        if !table_ids.is_sorted() {
754            return Err(Error::CompactionGroup(
755                "table_ids must be sorted".to_owned(),
756            ));
757        }
758
759        let parent_table_ids = {
760            let versioning_guard = self
761                .versioning
762                .read_with_process_name("move_state_tables_to_dedicated_compaction_group")
763                .await;
764            versioning_guard
765                .current_version
766                .state_table_info
767                .compaction_group_member_table_ids(parent_group_id)
768                .iter()
769                .copied()
770                .collect_vec()
771        };
772
773        if parent_table_ids == table_ids {
774            return Err(Error::CompactionGroup(format!(
775                "invalid split attempt for group {}: all member tables are moved",
776                parent_group_id
777            )));
778        }
779
780        fn check_table_ids_valid(cg_id_to_table_ids: &BTreeMap<CompactionGroupId, Vec<TableId>>) {
781            // 1. table_ids in different cg are sorted.
782            {
783                cg_id_to_table_ids
784                    .iter()
785                    .for_each(|(_cg_id, table_ids)| assert!(table_ids.is_sorted()));
786            }
787
788            // 2.table_ids in different cg are non-overlapping
789            {
790                let mut table_table_ids_vec = cg_id_to_table_ids.values().cloned().collect_vec();
791                table_table_ids_vec.sort_by(|a, b| a[0].cmp(&b[0]));
792                assert!(table_table_ids_vec.concat().is_sorted());
793            }
794
795            // 3.table_ids belong to one and only one cg.
796            {
797                let mut all_table_ids = HashSet::new();
798                for table_ids in cg_id_to_table_ids.values() {
799                    for table_id in table_ids {
800                        assert!(all_table_ids.insert(*table_id));
801                    }
802                }
803            }
804        }
805
806        // move [3,4,5,6]
807        // [1,2,3,4,5,6,7,8,9,10] -> [1,2] [3,4,5,6] [7,8,9,10]
808        // split key
809        // 1. table_id = 3, vnode = 0, epoch = MAX
810        // 2. table_id = 7, vnode = 0, epoch = MAX
811
812        // The new compaction group id is always generate on the right side
813        // Hence, we return the first compaction group id as the result
814        // split 1
815        let mut cg_id_to_table_ids: BTreeMap<CompactionGroupId, Vec<TableId>> = BTreeMap::new();
816        let table_id_to_split = *table_ids.first().unwrap();
817        let mut target_compaction_group_id: CompactionGroupId = 0.into();
818        let result_vec = self
819            .split_compaction_group_impl(
820                parent_group_id,
821                table_ids,
822                table_id_to_split,
823                VirtualNode::ZERO,
824                partition_vnode_count,
825                validate_policy,
826            )
827            .await?;
828        assert!(result_vec.len() <= 2);
829
830        let mut finish_move = false;
831        for (cg_id, table_ids_after_split) in result_vec {
832            if table_ids_after_split.contains(&table_id_to_split) {
833                target_compaction_group_id = cg_id;
834            }
835
836            if table_ids_after_split == table_ids {
837                finish_move = true;
838            }
839
840            cg_id_to_table_ids.insert(cg_id, table_ids_after_split);
841        }
842        check_table_ids_valid(&cg_id_to_table_ids);
843
844        if finish_move {
845            return Ok((target_compaction_group_id, cg_id_to_table_ids));
846        }
847
848        // split 2
849        // See the example above and the split rule in `split_compaction_group_impl`.
850        let table_id_to_split = *table_ids.last().unwrap();
851        let result_vec = self
852            .split_compaction_group_impl(
853                target_compaction_group_id,
854                table_ids,
855                table_id_to_split,
856                VirtualNode::MAX_REPRESENTABLE,
857                partition_vnode_count,
858                validate_policy,
859            )
860            .await?;
861        assert!(result_vec.len() <= 2);
862        for (cg_id, table_ids_after_split) in result_vec {
863            if table_ids_after_split.contains(&table_id_to_split) {
864                target_compaction_group_id = cg_id;
865            }
866            cg_id_to_table_ids.insert(cg_id, table_ids_after_split);
867        }
868        check_table_ids_valid(&cg_id_to_table_ids);
869
870        Ok((target_compaction_group_id, cg_id_to_table_ids))
871    }
872}