Skip to main content

risingwave_meta/hummock/manager/compaction/compaction_group_schedule/
merge_policy.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//! Shared merge predicates. Locking and topology mutations belong to `HummockManager`.
16
17use std::collections::HashSet;
18
19use risingwave_common::catalog::TableId;
20use risingwave_hummock_sdk::compaction_group::StaticCompactionGroupId;
21use risingwave_hummock_sdk::version::HummockVersion;
22
23use super::CompactionGroupStatistic;
24use crate::hummock::error::{Error, Result};
25use crate::hummock::manager::compaction::GroupStateValidator;
26use crate::hummock::model::CompactionGroup;
27use crate::hummock::table_write_throughput_statistic::TableWriteThroughputStatisticManager;
28use crate::manager::MetaOpts;
29
30pub(super) fn check_is_low_write_throughput(
31    table_write_throughput_statistic_manager: &TableWriteThroughputStatisticManager,
32    mut table_ids: impl Iterator<Item = TableId>,
33    opts: &MetaOpts,
34) -> bool {
35    let now = tokio::time::Instant::now();
36    let threshold = opts
37        .table_low_write_throughput_threshold
38        .min(opts.table_high_write_throughput_threshold);
39    table_ids.all(|table_id| {
40        // The peak dominates the current rate used by split. Require both thresholds so
41        // custom threshold orderings cannot make a table simultaneously hot and cold.
42        table_write_throughput_statistic_manager
43            .max_write_throughput(table_id, now)
44            .is_some_and(|peak| peak <= threshold)
45    })
46}
47
48pub(super) fn validate_group_merge(
49    group: &CompactionGroupStatistic,
50    next_group: &CompactionGroupStatistic,
51    created_tables: &HashSet<TableId>,
52    table_write_throughput_statistic_manager: &TableWriteThroughputStatisticManager,
53    opts: &MetaOpts,
54) -> Result<()> {
55    if group.table_statistic.is_empty() || next_group.table_statistic.is_empty() {
56        return Err(Error::CompactionGroup(format!(
57            "group-{} or group-{} is empty",
58            group.group_id, next_group.group_id
59        )));
60    }
61
62    // BTreeMap keys already provide sorted endpoints; no allocation or sorting is needed.
63    let mut left = &group.table_statistic;
64    let mut right = &next_group.table_statistic;
65    if left.first_key_value().unwrap().0 > right.first_key_value().unwrap().0 {
66        std::mem::swap(&mut left, &mut right);
67    }
68    if left.last_key_value().unwrap().0 >= right.first_key_value().unwrap().0 {
69        return Err(Error::CompactionGroup(format!(
70            "group-{} and group-{} have overlapping table id ranges, not mergeable",
71            group.group_id, next_group.group_id
72        )));
73    }
74
75    validate_group_config(
76        &group.compaction_group_config,
77        &next_group.compaction_group_config,
78        group.group_size.saturating_add(next_group.group_size),
79        opts,
80    )?;
81
82    for candidate in [group, next_group] {
83        if candidate
84            .table_statistic
85            .keys()
86            .any(|table_id| !created_tables.contains(table_id))
87        {
88            return Err(Error::CompactionGroup(format!(
89                "Cannot merge groups {} and {}: group {} contains creating tables",
90                group.group_id, next_group.group_id, candidate.group_id
91            )));
92        }
93        if !check_is_low_write_throughput(
94            table_write_throughput_statistic_manager,
95            candidate.table_statistic.keys().copied(),
96            opts,
97        ) {
98            return Err(Error::CompactionGroup(format!(
99                "Cannot merge groups {} and {}: group {} without sufficiently observed low throughput",
100                group.group_id, next_group.group_id, candidate.group_id
101            )));
102        }
103    }
104
105    Ok(())
106}
107
108pub(super) fn validate_group_config(
109    group: &CompactionGroup,
110    next_group: &CompactionGroup,
111    combined_size: u64,
112    opts: &MetaOpts,
113) -> Result<()> {
114    // TODO: remove this check after refactor group id
115    if (group.group_id == StaticCompactionGroupId::StateDefault
116        && next_group.group_id == StaticCompactionGroupId::MaterializedView)
117        || (group.group_id == StaticCompactionGroupId::MaterializedView
118            && next_group.group_id == StaticCompactionGroupId::StateDefault)
119    {
120        return Err(Error::CompactionGroup(format!(
121            "group-{} and group-{} are both StaticCompactionGroupId",
122            group.group_id, next_group.group_id
123        )));
124    }
125
126    if group
127        .compaction_config
128        .disable_auto_group_scheduling
129        .unwrap_or(false)
130        || next_group
131            .compaction_config
132            .disable_auto_group_scheduling
133            .unwrap_or(false)
134    {
135        return Err(Error::CompactionGroup(format!(
136            "group-{} or group-{} disable_auto_group_scheduling",
137            group.group_id, next_group.group_id
138        )));
139    }
140
141    // This per-table setting is reset after merge; every other config field must match.
142    let mut left = group.compaction_config.as_ref().clone();
143    let mut right = next_group.compaction_config.as_ref().clone();
144    left.split_weight_by_vnode = 0;
145    right.split_weight_by_vnode = 0;
146    if left != right {
147        let left_config = group.compaction_config.as_ref();
148        let right_config = next_group.compaction_config.as_ref();
149
150        tracing::warn!(
151            group_id = %group.group_id,
152            next_group_id = %next_group.group_id,
153            left_config = ?left_config,
154            right_config = ?right_config,
155            "compaction config semantic mismatch detected while merging compaction groups"
156        );
157
158        return Err(Error::CompactionGroup(format!(
159            "Cannot merge group {} and next_group {} with different compaction config (split_weight_by_vnode is excluded from comparison). left_config: {:?}, right_config: {:?}",
160            group.group_id, next_group.group_id, left_config, right_config
161        )));
162    }
163
164    let size_limit = (group.max_estimated_group_size() as f64 * opts.split_group_size_ratio) as u64;
165    if combined_size > size_limit {
166        return Err(Error::CompactionGroup(format!(
167            "Cannot merge huge groups {} and {}: combined_size {} size_limit {}",
168            group.group_id, next_group.group_id, combined_size, size_limit
169        )));
170    }
171    Ok(())
172}
173
174pub(super) fn validate_group_levels(
175    group: &CompactionGroup,
176    next_group: &CompactionGroup,
177    opts: &MetaOpts,
178    version: &HummockVersion,
179) -> Result<()> {
180    let group_levels = version.levels.get(&group.group_id).ok_or_else(|| {
181        Error::CompactionGroup(format!(
182            "cannot merge compaction group {} because it does not exist",
183            group.group_id
184        ))
185    })?;
186    let next_group_levels = version.levels.get(&next_group.group_id).ok_or_else(|| {
187        Error::CompactionGroup(format!(
188            "cannot merge compaction group {} because it does not exist",
189            next_group.group_id
190        ))
191    })?;
192    for (candidate, levels) in [(group, group_levels), (next_group, next_group_levels)] {
193        let state = GroupStateValidator::group_state(levels, &candidate.compaction_config);
194        if state.is_write_stop() || state.is_emergency() {
195            return Err(Error::CompactionGroup(format!(
196                "Cannot merge groups {} and {}: write limit on group {}",
197                group.group_id, next_group.group_id, candidate.group_id
198            )));
199        }
200    }
201
202    // check whether the group is in the write stop state after merge
203    let l0_sub_level_count_after_merge =
204        group_levels.l0.sub_levels.len() + next_group_levels.l0.sub_levels.len();
205    if GroupStateValidator::write_stop_sub_level_count(
206        (l0_sub_level_count_after_merge as f64 * opts.compaction_group_merge_dimension_threshold)
207            as usize,
208        &group.compaction_config,
209    ) {
210        return Err(Error::CompactionGroup(format!(
211            "Cannot merge write limit group {} next group {}, will trigger write stop after merge",
212            group.group_id, next_group.group_id
213        )));
214    }
215
216    let l0_file_count_after_merge = group_levels
217        .l0
218        .sub_levels
219        .iter()
220        .chain(next_group_levels.l0.sub_levels.iter())
221        .map(|level| level.table_infos.len())
222        .sum::<usize>();
223    if GroupStateValidator::write_stop_l0_file_count(
224        (l0_file_count_after_merge as f64 * opts.compaction_group_merge_dimension_threshold)
225            as usize,
226        &group.compaction_config,
227    ) {
228        return Err(Error::CompactionGroup(format!(
229            "Cannot merge write limit next group {} group {}, will trigger write stop after merge",
230            next_group.group_id, group.group_id
231        )));
232    }
233
234    let l0_size_after_merge =
235        group_levels.l0.total_file_size + next_group_levels.l0.total_file_size;
236
237    if GroupStateValidator::write_stop_l0_size(
238        (l0_size_after_merge as f64 * opts.compaction_group_merge_dimension_threshold) as u64,
239        &group.compaction_config,
240    ) {
241        return Err(Error::CompactionGroup(format!(
242            "Cannot merge write limit next group {} group {}, will trigger write stop after merge",
243            next_group.group_id, group.group_id
244        )));
245    }
246
247    // check whether the group is in the emergency state after merge
248    if GroupStateValidator::emergency_l0_file_count(
249        (l0_file_count_after_merge as f64 * opts.compaction_group_merge_dimension_threshold)
250            as usize,
251        &group.compaction_config,
252    ) {
253        return Err(Error::CompactionGroup(format!(
254            "Cannot merge emergency group {} next group {}, will trigger emergency after merge",
255            group.group_id, next_group.group_id
256        )));
257    }
258    Ok(())
259}