Skip to main content

risingwave_meta/hummock/manager/compaction/compaction_group_schedule/
normalize.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//! Normalize overlapping table ranges with the existing plan, recheck, and apply loop.
16
17use std::collections::HashMap;
18use std::ops::DerefMut;
19use std::sync::Arc;
20
21use bytes::Bytes;
22use itertools::Itertools;
23use risingwave_common::hash::VirtualNode;
24use risingwave_hummock_sdk::CompactionGroupId;
25use risingwave_hummock_sdk::compact_task::{ReportTask, is_compaction_task_expired};
26use risingwave_hummock_sdk::compaction_group::{StateTableId, group_split};
27use risingwave_hummock_sdk::version::{GroupDelta, GroupDeltas, HummockVersion};
28use risingwave_pb::hummock::compact_task::{TaskStatus, TaskType};
29use risingwave_pb::hummock::{CompatibilityVersion, PbGroupConstruct, PbStateTableInfoDelta};
30
31use super::super::compaction_group_manager::CompactionGroupManager;
32use super::CompactionGroupStatistic;
33use crate::hummock::error::{Error, Result};
34use crate::hummock::manager::transaction::HummockVersionTransaction;
35use crate::hummock::manager::{HummockManager, commit_multi_var};
36use crate::hummock::sequence::{next_compaction_group_id, next_sstable_id};
37
38#[derive(Debug, PartialEq, Eq)]
39struct NormalizePlan {
40    parent_group_id: CompactionGroupId,
41    parent_table_ids: Vec<StateTableId>,
42    boundary_table_id: StateTableId,
43}
44
45impl NormalizePlan {
46    fn split_key(&self) -> Bytes {
47        group_split::build_split_full_key(self.boundary_table_id, VirtualNode::ZERO)
48            .encode()
49            .into()
50    }
51
52    fn split_table_ids(&self) -> (Vec<StateTableId>, Vec<StateTableId>) {
53        let split_full_key =
54            group_split::build_split_full_key(self.boundary_table_id, VirtualNode::ZERO);
55        let (table_ids_left, table_ids_right) =
56            group_split::split_table_ids_with_table_id_and_vnode(
57                &self.parent_table_ids,
58                split_full_key.user_key.table_id,
59                split_full_key.user_key.get_vnode_id(),
60            );
61        assert!(!table_ids_left.is_empty() && !table_ids_right.is_empty());
62        (table_ids_left, table_ids_right)
63    }
64}
65
66fn gen_normalize_plan(
67    left: &CompactionGroupStatistic,
68    right: &CompactionGroupStatistic,
69) -> Option<NormalizePlan> {
70    let left_table_ids = left.table_statistic.keys().copied().collect_vec();
71
72    if left_table_ids.len() <= 1 {
73        return None;
74    }
75
76    let left_max = *left_table_ids.last().unwrap();
77    let right_min = *right.table_statistic.keys().next().unwrap();
78    if left_max < right_min {
79        return None;
80    }
81
82    let boundary_index = left_table_ids.partition_point(|&table_id| table_id < right_min);
83    if boundary_index == 0 || boundary_index >= left_table_ids.len() {
84        return None;
85    }
86    let boundary_table_id = left_table_ids[boundary_index];
87
88    Some(NormalizePlan {
89        parent_group_id: left.group_id,
90        parent_table_ids: left_table_ids,
91        boundary_table_id,
92    })
93}
94
95fn build_normalize_plan_from_group_statistics(
96    groups: &[CompactionGroupStatistic],
97) -> Option<NormalizePlan> {
98    // `calculate_compaction_group_statistic()` iterates all version levels, so newly created or
99    // transiently empty groups can appear here without any member tables.
100    let mut groups = groups
101        .iter()
102        .filter(|group| !group.table_statistic.is_empty())
103        .collect_vec();
104    groups.sort_by_key(|group| *group.table_statistic.keys().next().unwrap());
105
106    groups
107        .split(|group| {
108            group
109                .compaction_group_config
110                .compaction_config
111                .disable_auto_group_scheduling
112                .unwrap_or(false)
113        })
114        .find_map(|segment| {
115            segment
116                .windows(2)
117                .find_map(|pair| gen_normalize_plan(pair[0], pair[1]))
118        })
119}
120
121fn collect_normalize_group_statistics(
122    version: &HummockVersion,
123    compaction_group_manager: &CompactionGroupManager,
124) -> Result<Vec<CompactionGroupStatistic>> {
125    let mut groups = vec![];
126    for group_id in version.levels.keys() {
127        let table_ids = version
128            .state_table_info
129            .compaction_group_member_table_ids(*group_id)
130            .iter()
131            .copied()
132            .collect_vec();
133        if table_ids.is_empty() {
134            continue;
135        }
136
137        let group_config = compaction_group_manager
138            .try_get_compaction_group_config(*group_id)
139            .ok_or_else(|| {
140                Error::CompactionGroup(format!(
141                    "group {} config not found during normalize",
142                    group_id
143                ))
144            })?;
145        groups.push(CompactionGroupStatistic {
146            group_id: *group_id,
147            group_size: 0,
148            table_statistic: table_ids
149                .into_iter()
150                .map(|table_id| (table_id, 0))
151                .collect(),
152            compaction_group_config: group_config,
153        });
154    }
155    Ok(groups)
156}
157
158impl HummockManager {
159    async fn build_normalize_plan(&self) -> Option<NormalizePlan> {
160        let groups = self.calculate_compaction_group_statistic().await;
161        build_normalize_plan_from_group_statistics(&groups)
162    }
163
164    async fn apply_normalize_plan(&self, plan: &NormalizePlan) -> Result<bool> {
165        let (table_ids_right, boundary_table_id, new_compaction_group_id) = {
166            let mut versioning_guard = self
167                .versioning
168                .write_with_process_name("apply_normalize_plan")
169                .await;
170            let versioning = versioning_guard.deref_mut();
171            let mut compaction_group_manager = self
172                .compaction_group_manager
173                .write_with_process_name("apply_normalize_plan")
174                .await;
175
176            let groups = collect_normalize_group_statistics(
177                &versioning.current_version,
178                &compaction_group_manager,
179            )?;
180            let Some(current_plan) = build_normalize_plan_from_group_statistics(&groups) else {
181                return Ok(false);
182            };
183
184            if &current_plan != plan {
185                return Ok(false);
186            }
187
188            let (_table_ids_left, table_ids_right) = plan.split_table_ids();
189
190            let config = compaction_group_manager
191                .try_get_compaction_group_config(plan.parent_group_id)
192                .ok_or_else(|| {
193                    Error::CompactionGroup(format!(
194                        "parent group {} config not found",
195                        plan.parent_group_id
196                    ))
197                })?
198                .compaction_config()
199                .as_ref()
200                .clone();
201
202            let mut compaction_groups_txn = compaction_group_manager.start_compaction_groups_txn();
203            let mut version = HummockVersionTransaction::new(
204                &mut versioning.current_version,
205                &mut versioning.hummock_version_deltas,
206                &mut versioning.table_change_log,
207                &self.gc_manager.change_log_retention,
208                self.env.notification_manager(),
209                None,
210                &self.metrics,
211                &self.env.opts,
212                &self.version_stat_tx,
213            );
214            let mut new_version_delta = version.new_delta();
215            let split_key = plan.split_key();
216            let split_sst_count = new_version_delta
217                .latest_version()
218                .count_new_ssts_in_group_split(plan.parent_group_id, split_key.clone());
219            let new_sst_start_id = next_sstable_id(&self.env, split_sst_count).await?;
220            let new_compaction_group_id = next_compaction_group_id(&self.env).await?;
221
222            #[expect(deprecated)]
223            new_version_delta.group_deltas.insert(
224                new_compaction_group_id,
225                GroupDeltas {
226                    group_deltas: vec![GroupDelta::GroupConstruct(Box::new(PbGroupConstruct {
227                        group_config: Some(config.clone()),
228                        group_id: new_compaction_group_id,
229                        parent_group_id: plan.parent_group_id,
230                        new_sst_start_id,
231                        table_ids: vec![],
232                        version: CompatibilityVersion::LATEST as _,
233                        split_key: Some(split_key.into()),
234                    }))],
235                },
236            );
237
238            new_version_delta.with_latest_version(|version, new_version_delta| {
239                for &table_id in &table_ids_right {
240                    let info = version
241                        .state_table_info
242                        .info()
243                        .get(&table_id)
244                        .expect("table should exist before normalize split");
245                    assert!(
246                        new_version_delta
247                            .state_table_info_delta
248                            .insert(
249                                table_id,
250                                PbStateTableInfoDelta {
251                                    committed_epoch: info.committed_epoch,
252                                    compaction_group_id: new_compaction_group_id,
253                                }
254                            )
255                            .is_none()
256                    );
257                }
258            });
259            new_version_delta.pre_apply();
260            compaction_groups_txn
261                .create_compaction_groups(new_compaction_group_id, Arc::new(config));
262
263            commit_multi_var!(self.meta_store_ref(), version, compaction_groups_txn)?;
264            versioning.mark_next_time_travel_version_snapshot();
265            if !self.env.opts.compaction_deterministic_test {
266                for group_id in [plan.parent_group_id, new_compaction_group_id] {
267                    self.try_send_compaction_request(group_id, TaskType::Dynamic);
268                }
269            }
270
271            (
272                table_ids_right,
273                plan.boundary_table_id,
274                new_compaction_group_id,
275            )
276        };
277
278        self.cancel_expired_normalize_split_tasks(plan.parent_group_id)
279            .await?;
280        self.try_update_write_limits(&[plan.parent_group_id, new_compaction_group_id])
281            .await;
282        self.metrics
283            .split_compaction_group_count
284            .with_label_values(&[&plan.parent_group_id.to_string()])
285            .inc();
286        tracing::info!(target: super::TRACE_TARGET,
287            "normalize split success: parent_group={} boundary_table_id={} moved_tables={:?} new_group_id={}",
288            plan.parent_group_id,
289            boundary_table_id,
290            table_ids_right,
291            new_compaction_group_id
292        );
293
294        Ok(true)
295    }
296
297    async fn cancel_expired_normalize_split_tasks(
298        &self,
299        parent_group_id: CompactionGroupId,
300    ) -> Result<()> {
301        let mut canceled_tasks = vec![];
302        let compaction_guard = self
303            .compaction
304            .write_with_process_name("cancel_expired_normalize_split_tasks")
305            .await;
306        let mut versioning_guard = self
307            .versioning
308            .write_with_process_name("cancel_expired_normalize_split_tasks")
309            .await;
310        let versioning = versioning_guard.deref_mut();
311        let compact_task_assignments =
312            compaction_guard.get_compact_task_assignments_by_group_id(parent_group_id);
313        let Some(levels) = versioning.current_version.levels.get(&parent_group_id) else {
314            return Ok(());
315        };
316        compact_task_assignments
317            .into_iter()
318            .for_each(|task_assignment| {
319                let task = &task_assignment.compact_task;
320                if is_compaction_task_expired(
321                    task.compaction_group_version_id,
322                    levels.compaction_group_version_id,
323                ) {
324                    canceled_tasks.push(ReportTask {
325                        task_id: task.task_id,
326                        task_status: TaskStatus::ManualCanceled,
327                        table_stats_change: HashMap::default(),
328                        sorted_output_ssts: vec![],
329                        object_timestamps: HashMap::default(),
330                    });
331                }
332            });
333        canceled_tasks.sort_by_key(|task| task.task_id);
334        canceled_tasks.dedup_by_key(|task| task.task_id);
335
336        if !canceled_tasks.is_empty() {
337            self.report_compact_tasks_impl(canceled_tasks, compaction_guard, versioning_guard)
338                .await?;
339        }
340
341        Ok(())
342    }
343
344    /// Normalize overlapping adjacent compaction groups by split only.
345    ///
346    /// The algorithm repeatedly scans adjacent groups by `min(table_id)` and if
347    /// `max(left) >= min(right)`, it splits `left` at the first table id `>= min(right)`.
348    /// Each step is planned from a read snapshot, then revalidated and applied with a short write
349    /// transaction.
350    pub async fn normalize_overlapping_compaction_groups(&self) -> Result<usize> {
351        self.normalize_overlapping_compaction_groups_with_limit(usize::MAX)
352            .await
353    }
354
355    pub async fn normalize_overlapping_compaction_groups_with_limit(
356        &self,
357        max_splits: usize,
358    ) -> Result<usize> {
359        let mut split_count = 0usize;
360        while split_count < max_splits {
361            let Some(plan) = self.build_normalize_plan().await else {
362                break;
363            };
364
365            if !self.apply_normalize_plan(&plan).await? {
366                tracing::debug!(target: super::TRACE_TARGET,
367                    parent_group_id = %plan.parent_group_id,
368                    boundary_table_id = %plan.boundary_table_id,
369                    "normalize plan became stale before apply"
370                );
371                break;
372            }
373            split_count += 1;
374        }
375
376        Ok(split_count)
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::super::tests::group;
383    use super::{NormalizePlan, build_normalize_plan_from_group_statistics, gen_normalize_plan};
384
385    #[test]
386    fn test_gen_normalize_plan_returns_none_for_single_table_group() {
387        let left = group(1.into(), &[10], false);
388        let right = group(2.into(), &[5, 20], false);
389
390        assert_eq!(None, gen_normalize_plan(&left, &right));
391    }
392
393    #[test]
394    fn test_gen_normalize_plan_returns_none_for_non_overlapping_groups() {
395        let left = group(1.into(), &[1, 2, 3], false);
396        let right = group(2.into(), &[4, 5, 6], false);
397
398        assert_eq!(None, gen_normalize_plan(&left, &right));
399    }
400
401    #[test]
402    fn test_gen_normalize_plan_returns_none_when_boundary_cannot_split_parent() {
403        let left = group(1.into(), &[5, 6, 7], false);
404        let right = group(2.into(), &[4, 8], false);
405
406        assert_eq!(None, gen_normalize_plan(&left, &right));
407    }
408
409    #[test]
410    fn test_gen_normalize_plan_generates_expected_boundary() {
411        let left = group(1.into(), &[1, 4, 7], false);
412        let right = group(2.into(), &[2, 5, 8], false);
413
414        assert_eq!(
415            Some(NormalizePlan {
416                parent_group_id: 1.into(),
417                parent_table_ids: vec![1.into(), 4.into(), 7.into()],
418                boundary_table_id: 4.into(),
419            }),
420            gen_normalize_plan(&left, &right)
421        );
422    }
423
424    #[test]
425    fn test_build_normalize_plan_skips_disabled_boundary_and_continues_later_segment() {
426        let groups = vec![
427            group(1.into(), &[1, 4, 7], false),
428            group(2.into(), &[2, 5, 8], true),
429            group(3.into(), &[10, 13, 16], false),
430            group(4.into(), &[11, 14, 17], false),
431        ];
432
433        assert_eq!(
434            Some(NormalizePlan {
435                parent_group_id: 3.into(),
436                parent_table_ids: vec![10.into(), 13.into(), 16.into()],
437                boundary_table_id: 13.into(),
438            }),
439            build_normalize_plan_from_group_statistics(&groups)
440        );
441    }
442}