Skip to main content

risingwave_meta/hummock/manager/
timer_task.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::cmp::Reverse;
16use std::collections::{HashMap, HashSet};
17use std::future::Future;
18use std::sync::Arc;
19use std::time::Duration;
20
21use futures::stream::{BoxStream, FuturesUnordered};
22use futures::{StreamExt, pin_mut};
23use itertools::Itertools;
24use risingwave_hummock_sdk::CompactionGroupId;
25use risingwave_pb::hummock::compact_task::{self, TaskStatus};
26use risingwave_pb::hummock::level_handler::RunningCompactTask;
27use rw_futures_util::select_all;
28use thiserror_ext::AsReport;
29use tokio::sync::oneshot::{Receiver, Sender};
30use tokio::sync::watch;
31use tokio::task::JoinHandle;
32use tokio_stream::wrappers::IntervalStream;
33use tracing::warn;
34
35use crate::backup_restore::BackupManagerRef;
36use crate::hummock::metrics_utils::{trigger_lsm_stat, trigger_mv_stat};
37use crate::hummock::{HummockManager, TASK_NORMAL};
38
39#[derive(Clone, Copy)]
40enum SchedulingEvent {
41    DynamicCompaction,
42    SpaceReclaimCompaction,
43    TtlCompaction,
44    TombstoneCompaction,
45    GroupSplit,
46    GroupMerge,
47}
48
49#[derive(Clone, Copy)]
50enum MaintenanceEvent {
51    CheckDeadTask,
52    FullGc,
53}
54
55fn interval_stream<E>(period: Duration, event: E) -> BoxStream<'static, E>
56where
57    E: Clone + Send + 'static,
58{
59    let mut interval = tokio::time::interval(period);
60    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
61    interval.reset();
62    Box::pin(IntervalStream::new(interval).map(move |_| event.clone()))
63}
64
65/// Spawn a periodic background loop for a single timer.
66///
67/// A handler is never re-entered: if it runs longer than its period, the next tick is delayed.
68/// Shutdown is observed between handler runs, which matches the legacy timer loop semantics.
69fn spawn_periodic_loop<F, Fut>(
70    name: &'static str,
71    period: Duration,
72    shutdown_rx: watch::Receiver<bool>,
73    mut handler: F,
74) -> JoinHandle<()>
75where
76    F: FnMut() -> Fut + Send + 'static,
77    Fut: Future<Output = ()> + Send + 'static,
78{
79    spawn_serial_timer_lane(
80        name,
81        vec![interval_stream(period, ())],
82        shutdown_rx,
83        move |()| handler(),
84    )
85}
86
87/// Spawn a lane with multiple timers whose handlers must stay serialized.
88fn spawn_serial_timer_lane<E, F, Fut>(
89    name: &'static str,
90    triggers: Vec<BoxStream<'static, E>>,
91    mut shutdown_rx: watch::Receiver<bool>,
92    mut handler: F,
93) -> JoinHandle<()>
94where
95    E: Send + 'static,
96    F: FnMut(E) -> Fut + Send + 'static,
97    Fut: Future<Output = ()> + Send + 'static,
98{
99    tokio::spawn(async move {
100        if triggers.is_empty() {
101            tracing::info!(
102                "Hummock timer handler loop [{}] is disabled (no intervals configured)",
103                name
104            );
105            return;
106        }
107
108        let event_stream = select_all(triggers);
109        pin_mut!(event_stream);
110
111        loop {
112            tokio::select! {
113                maybe_event = event_stream.next() => {
114                    let Some(event) = maybe_event else {
115                        tracing::warn!(
116                            "Hummock timer handler loop [{}] event stream ended unexpectedly",
117                            name
118                        );
119                        break;
120                    };
121                    handler(event).await;
122                }
123                changed = shutdown_rx.changed() => {
124                    if changed.is_err() || *shutdown_rx.borrow() {
125                        tracing::info!("Hummock timer handler loop [{}] is stopped", name);
126                        break;
127                    }
128                }
129            }
130        }
131    })
132}
133
134fn spawn_scheduling_loop(
135    hummock_manager: Arc<HummockManager>,
136    split_interval_sec: u64,
137    merge_interval_sec: u64,
138    shutdown_rx: watch::Receiver<bool>,
139) -> JoinHandle<()> {
140    let mut triggers = vec![
141        interval_stream(
142            Duration::from_secs(hummock_manager.env.opts.periodic_compaction_interval_sec),
143            SchedulingEvent::DynamicCompaction,
144        ),
145        interval_stream(
146            Duration::from_secs(
147                hummock_manager
148                    .env
149                    .opts
150                    .periodic_space_reclaim_compaction_interval_sec,
151            ),
152            SchedulingEvent::SpaceReclaimCompaction,
153        ),
154        interval_stream(
155            Duration::from_secs(
156                hummock_manager
157                    .env
158                    .opts
159                    .periodic_ttl_reclaim_compaction_interval_sec,
160            ),
161            SchedulingEvent::TtlCompaction,
162        ),
163        interval_stream(
164            Duration::from_secs(
165                hummock_manager
166                    .env
167                    .opts
168                    .periodic_tombstone_reclaim_compaction_interval_sec,
169            ),
170            SchedulingEvent::TombstoneCompaction,
171        ),
172    ];
173
174    if split_interval_sec > 0 {
175        triggers.push(interval_stream(
176            Duration::from_secs(split_interval_sec),
177            SchedulingEvent::GroupSplit,
178        ));
179    }
180    if merge_interval_sec > 0 {
181        triggers.push(interval_stream(
182            Duration::from_secs(merge_interval_sec),
183            SchedulingEvent::GroupMerge,
184        ));
185    }
186
187    spawn_serial_timer_lane("scheduling", triggers, shutdown_rx, move |event| {
188        let hummock_manager = hummock_manager.clone();
189        async move {
190            if hummock_manager.env.opts.compaction_deterministic_test {
191                return;
192            }
193
194            match event {
195                SchedulingEvent::DynamicCompaction => {
196                    hummock_manager
197                        .on_handle_trigger_multi_group(compact_task::TaskType::Dynamic)
198                        .await;
199                }
200                SchedulingEvent::SpaceReclaimCompaction => {
201                    hummock_manager
202                        .on_handle_trigger_multi_group(compact_task::TaskType::SpaceReclaim)
203                        .await;
204                    // Share the same trigger with SpaceReclaim.
205                    hummock_manager
206                        .on_handle_trigger_multi_group(compact_task::TaskType::VnodeWatermark)
207                        .await;
208                }
209                SchedulingEvent::TtlCompaction => {
210                    hummock_manager
211                        .on_handle_trigger_multi_group(compact_task::TaskType::Ttl)
212                        .await;
213                }
214                SchedulingEvent::TombstoneCompaction => {
215                    hummock_manager
216                        .on_handle_trigger_multi_group(compact_task::TaskType::Tombstone)
217                        .await;
218                }
219                SchedulingEvent::GroupSplit => {
220                    hummock_manager.on_handle_schedule_group_split().await;
221                }
222                SchedulingEvent::GroupMerge => {
223                    hummock_manager.on_handle_schedule_group_merge().await;
224                }
225            }
226        }
227    })
228}
229
230fn spawn_maintenance_loop(
231    hummock_manager: Arc<HummockManager>,
232    backup_manager: Option<BackupManagerRef>,
233    shutdown_rx: watch::Receiver<bool>,
234) -> JoinHandle<()> {
235    const CHECK_PENDING_TASK_PERIOD_SEC: u64 = 300;
236
237    let triggers = vec![
238        interval_stream(
239            Duration::from_secs(CHECK_PENDING_TASK_PERIOD_SEC),
240            MaintenanceEvent::CheckDeadTask,
241        ),
242        interval_stream(
243            Duration::from_secs(hummock_manager.env.opts.full_gc_interval_sec),
244            MaintenanceEvent::FullGc,
245        ),
246    ];
247
248    spawn_serial_timer_lane("maintenance", triggers, shutdown_rx, move |event| {
249        let hummock_manager = hummock_manager.clone();
250        let backup_manager = backup_manager.clone();
251        async move {
252            match event {
253                MaintenanceEvent::CheckDeadTask => {
254                    if hummock_manager.env.opts.compaction_deterministic_test {
255                        return;
256                    }
257                    hummock_manager.check_dead_task().await;
258                }
259                MaintenanceEvent::FullGc => {
260                    let retention_sec = hummock_manager.env.opts.min_sst_retention_time_sec;
261                    tokio::task::spawn(async move {
262                        let _ = hummock_manager
263                            .start_full_gc(Duration::from_secs(retention_sec), None, backup_manager)
264                            .await
265                            .inspect_err(|e| warn!(error = %e.as_report(), "Failed to start GC."));
266                    });
267                }
268            }
269        }
270    })
271}
272
273async fn supervise_child_loops(
274    mut shutdown_rx: Receiver<()>,
275    child_shutdown_tx: watch::Sender<bool>,
276    child_handles: Vec<(&'static str, JoinHandle<()>)>,
277) {
278    let mut child_handles = child_handles
279        .into_iter()
280        .map(|(name, handle)| async move { (name, handle.await) })
281        .collect::<FuturesUnordered<_>>();
282
283    // Child loops are expected to run until the parent timer task is shutting down. If any of
284    // them finishes first, the timer task is in a partial-failure state and should fail fast.
285    tokio::select! {
286        _ = &mut shutdown_rx => {
287            let _ = child_shutdown_tx.send(true);
288            while let Some((name, result)) = child_handles.next().await {
289                if let Err(e) = result {
290                    warn!(
291                        handler = name,
292                        error = %e.as_report(),
293                        "Hummock timer handler loop failed during shutdown"
294                    );
295                }
296            }
297        }
298        Some((name, result)) = child_handles.next() => {
299            match result {
300                Ok(()) => {
301                    panic!("Hummock timer handler loop [{name}] exited unexpectedly");
302                }
303                // A cancellation means the runtime is aborting detached tasks during shutdown;
304                // treat it as a benign shutdown signal and drain the rest instead of failing.
305                Err(e) if e.is_cancelled() => {
306                    let _ = child_shutdown_tx.send(true);
307                    while let Some((name, result)) = child_handles.next().await {
308                        if let Err(e) = result {
309                            warn!(
310                                handler = name,
311                                error = %e.as_report(),
312                                "Hummock timer handler loop failed during shutdown"
313                            );
314                        }
315                    }
316                }
317                Err(e) => {
318                    panic!("Hummock timer handler loop [{name}] failed: {}", e.as_report());
319                }
320            }
321        }
322    }
323}
324
325impl HummockManager {
326    pub fn hummock_timer_task(
327        hummock_manager: Arc<Self>,
328        backup_manager: Option<BackupManagerRef>,
329    ) -> (JoinHandle<()>, Sender<()>) {
330        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
331        let join_handle = tokio::spawn(async move {
332            const STAT_REPORT_PERIOD_SEC: u64 = 20;
333            const COMPACTION_HEARTBEAT_PERIOD_SEC: u64 = 1;
334
335            let split_interval_sec = hummock_manager
336                .env
337                .opts
338                .periodic_scheduling_compaction_group_split_interval_sec;
339            let merge_interval_sec = hummock_manager
340                .env
341                .opts
342                .periodic_scheduling_compaction_group_merge_interval_sec;
343
344            tracing::info!(
345                split_interval_sec,
346                merge_interval_sec,
347                report_interval_sec = STAT_REPORT_PERIOD_SEC,
348                heartbeat_interval_sec = COMPACTION_HEARTBEAT_PERIOD_SEC,
349                "Starting Hummock timer task"
350            );
351
352            let (child_shutdown_tx, child_shutdown_rx) = watch::channel(false);
353            let mut child_handles: Vec<(&'static str, JoinHandle<()>)> = Vec::new();
354
355            // Latency-sensitive liveness detection. Keep it isolated from slow maintenance/report
356            // handlers to avoid head-of-line blocking.
357            {
358                let hummock_manager = hummock_manager.clone();
359                child_handles.push((
360                    "heartbeat",
361                    spawn_periodic_loop(
362                        "heartbeat",
363                        Duration::from_secs(COMPACTION_HEARTBEAT_PERIOD_SEC),
364                        child_shutdown_rx.clone(),
365                        move || {
366                            let hummock_manager = hummock_manager.clone();
367                            async move {
368                                hummock_manager
369                                    .handle_timer_compaction_heartbeat_expired_check()
370                                    .await;
371                            }
372                        },
373                    ),
374                ));
375            }
376
377            // Metrics reporting can be slow, but it should not block heartbeat checks.
378            {
379                let hummock_manager = hummock_manager.clone();
380                child_handles.push((
381                    "report",
382                    spawn_periodic_loop(
383                        "report",
384                        Duration::from_secs(STAT_REPORT_PERIOD_SEC),
385                        child_shutdown_rx.clone(),
386                        move || {
387                            let hummock_manager = hummock_manager.clone();
388                            async move {
389                                hummock_manager.handle_timer_report().await;
390                            }
391                        },
392                    ),
393                ));
394            }
395
396            // Compaction triggers and group split/merge all touch scheduling state. Keep them in
397            // one serialized lane to preserve the legacy main-loop mutual exclusion semantics and
398            // avoid adding timer-vs-timer scheduling lock contention.
399            child_handles.push((
400                "scheduling",
401                spawn_scheduling_loop(
402                    hummock_manager.clone(),
403                    split_interval_sec,
404                    merge_interval_sec,
405                    child_shutdown_rx.clone(),
406                ),
407            ));
408
409            // Low-frequency background maintenance. These tasks are not latency-sensitive and
410            // should not block heartbeat checks.
411            child_handles.push((
412                "maintenance",
413                spawn_maintenance_loop(
414                    hummock_manager.clone(),
415                    backup_manager,
416                    child_shutdown_rx.clone(),
417                ),
418            ));
419
420            supervise_child_loops(shutdown_rx, child_shutdown_tx, child_handles).await;
421
422            tracing::info!("Hummock timer loop is stopped");
423        });
424        (join_handle, shutdown_tx)
425    }
426
427    async fn handle_timer_report(&self) {
428        let mv_id_to_all_table_ids = self
429            .metadata_manager
430            .get_job_id_to_internal_table_ids_mapping()
431            .await;
432        let table_write_throughput_statistic_manager =
433            self.table_write_throughput_statistic_manager.read().clone();
434        let id_to_config = self.get_compaction_group_map().await;
435
436        // Clone the in-memory snapshot while holding locks, then emit metrics outside the locks.
437        // This keeps the report lane from blocking writers on `versioning` or
438        // `compaction_group_manager` while doing potentially heavy metric iteration.
439        let (current_version, version_stats) = {
440            let versioning_guard = self.versioning.read().await;
441            (
442                versioning_guard.current_version.clone(),
443                versioning_guard.version_stats.clone(),
444            )
445        };
446
447        if let Some(mv_id_to_all_table_ids) = mv_id_to_all_table_ids {
448            trigger_mv_stat(&self.metrics, &version_stats, mv_id_to_all_table_ids);
449        }
450
451        let compaction_group_count = current_version.levels.len();
452        self.metrics
453            .compaction_group_count
454            .set(compaction_group_count as i64);
455
456        let max_statistic_expired_time = std::cmp::max(
457            self.env.opts.table_stat_throuput_window_seconds_for_split,
458            self.env.opts.table_stat_throuput_window_seconds_for_merge,
459        );
460
461        for (group_id, group_levels) in &current_version.levels {
462            let Some(compaction_group_config) = id_to_config.get(group_id) else {
463                warn!(
464                    compaction_group_id = %group_id,
465                    "Missing compaction group config when reporting metrics. Skip."
466                );
467                continue;
468            };
469
470            trigger_lsm_stat(
471                &self.metrics,
472                compaction_group_config.compaction_config(),
473                group_levels,
474                *group_id,
475            );
476
477            let member_table_ids = current_version
478                .state_table_info
479                .compaction_group_member_table_ids(*group_id);
480            let group_size = member_table_ids
481                .iter()
482                .map(|table_id| {
483                    version_stats
484                        .table_stats
485                        .get(table_id)
486                        .map(|stats| stats.total_key_size + stats.total_value_size)
487                        .unwrap_or(0)
488                        .max(0) as u64
489                })
490                .sum::<u64>();
491            self.metrics
492                .compaction_group_size
493                .with_label_values(&[&group_id.to_string()])
494                .set(group_size as _);
495
496            let mut avg_throughput = 0;
497            for table_id in member_table_ids {
498                avg_throughput += table_write_throughput_statistic_manager
499                    .avg_write_throughput(*table_id, max_statistic_expired_time as i64)
500                    as u64;
501            }
502
503            self.metrics
504                .compaction_group_throughput
505                .with_label_values(&[&group_id.to_string()])
506                .set(avg_throughput as _);
507
508            let file_count = group_levels.count_ssts();
509            self.metrics
510                .compaction_group_file_count
511                .with_label_values(&[&group_id.to_string()])
512                .set(file_count as _);
513        }
514    }
515
516    async fn handle_timer_compaction_heartbeat_expired_check(&self) {
517        // TODO: add metrics to track expired tasks
518        // The cancel task has two paths
519        // 1. compactor heartbeat cancels the expired task based on task
520        // progress (meta + compactor)
521        // 2. meta periodically scans the task and performs a cancel on
522        // the meta side for tasks that are not updated by heartbeat
523        let expired_tasks: Vec<u64> = self
524            .compactor_manager
525            .get_heartbeat_expired_tasks()
526            .into_iter()
527            .map(|task| task.task_id)
528            .collect();
529        if !expired_tasks.is_empty() {
530            tracing::info!(
531                expired_tasks = ?expired_tasks,
532                "Heartbeat expired compaction tasks detected. Attempting to cancel tasks.",
533            );
534            if let Err(e) = self
535                .cancel_compact_tasks(expired_tasks.clone(), TaskStatus::HeartbeatCanceled)
536                .await
537            {
538                tracing::error!(
539                    expired_tasks = ?expired_tasks,
540                    error = %e.as_report(),
541                    "Attempt to remove compaction task due to elapsed heartbeat failed. We will continue to track its heartbeat
542                    until we can successfully report its status",
543                );
544            }
545        }
546    }
547}
548
549impl HummockManager {
550    async fn maybe_normalize_compaction_groups_before_merge(&self) {
551        if !self.env.opts.enable_compaction_group_normalize {
552            return;
553        }
554
555        match self
556            .normalize_overlapping_compaction_groups_with_limit(
557                self.env
558                    .opts
559                    .max_normalize_splits_per_round
560                    .try_into()
561                    .unwrap_or(usize::MAX),
562            )
563            .await
564        {
565            Ok(split_count) => {
566                if split_count > 0 {
567                    tracing::info!(
568                        "normalize compaction groups finished with {} split(s) before merge scheduling",
569                        split_count
570                    );
571                }
572            }
573            Err(e) => {
574                tracing::warn!(
575                    error = %e.as_report(),
576                    "failed to normalize compaction groups before merge scheduling"
577                );
578            }
579        }
580    }
581
582    async fn check_dead_task(&self) {
583        const MAX_COMPACTION_L0_MULTIPLIER: u64 = 32;
584        const MAX_COMPACTION_DURATION_SEC: u64 = 20 * 60;
585        let slowdown_groups = {
586            let versioning_guard = self.versioning.read().await;
587            let compaction_group_manager = self.compaction_group_manager.read().await;
588            let mut slowdown_groups: HashMap<CompactionGroupId, u64> = HashMap::default();
589
590            for (group_id, group_levels) in &versioning_guard.current_version.levels {
591                let compaction_group_config = compaction_group_manager
592                    .try_get_compaction_group_config(*group_id)
593                    .expect(
594                        "compaction group config should exist for every group in current version",
595                    );
596                let l0_file_size = group_levels
597                    .l0
598                    .sub_levels
599                    .iter()
600                    .map(|level| level.total_file_size)
601                    .sum::<u64>();
602                if l0_file_size
603                    > MAX_COMPACTION_L0_MULTIPLIER
604                        * compaction_group_config
605                            .compaction_config
606                            .max_bytes_for_level_base
607                {
608                    slowdown_groups.insert(*group_id, l0_file_size);
609                }
610            }
611
612            slowdown_groups
613        };
614        if slowdown_groups.is_empty() {
615            return;
616        }
617        let mut pending_tasks: HashMap<u64, (CompactionGroupId, usize, RunningCompactTask)> =
618            HashMap::default();
619        {
620            let compaction_guard = self.compaction.read().await;
621            for group_id in slowdown_groups.keys() {
622                if let Some(status) = compaction_guard.compaction_statuses.get(group_id) {
623                    for (idx, level_handler) in status.level_handlers.iter().enumerate() {
624                        let tasks = level_handler.pending_tasks().to_vec();
625                        if tasks.is_empty() {
626                            continue;
627                        }
628                        for task in tasks {
629                            pending_tasks.insert(task.task_id, (*group_id, idx, task));
630                        }
631                    }
632                }
633            }
634        }
635        let task_ids = pending_tasks.keys().cloned().collect_vec();
636        let task_infos = self
637            .compactor_manager
638            .check_tasks_status(&task_ids, Duration::from_secs(MAX_COMPACTION_DURATION_SEC));
639        for (task_id, (compact_time, status)) in task_infos {
640            if status == TASK_NORMAL {
641                continue;
642            }
643            if let Some((group_id, level_id, task)) = pending_tasks.get(&task_id) {
644                let group_size = *slowdown_groups.get(group_id).unwrap();
645                warn!(
646                    "COMPACTION SLOW: the task-{} of group-{}(size: {}MB) level-{} has not finished after {:?}, {}, it may cause pending sstable files({:?}) blocking other task.",
647                    task_id,
648                    group_id,
649                    group_size / 1024 / 1024,
650                    *level_id,
651                    compact_time,
652                    status,
653                    task.ssts
654                );
655            }
656        }
657    }
658
659    /// Try to schedule a compaction `split` for the given compaction groups.
660    /// The `split` will be triggered if the following conditions are met:
661    /// 1. `state table throughput`: If the table is in a high throughput state and it belongs to a multi table group, then an attempt will be made to split the table into separate compaction groups to increase its throughput and reduce the impact on write amplification.
662    /// 2. `group size`: If the group size has exceeded the set upper limit, e.g. `max_group_size` * `split_group_size_ratio`
663    async fn on_handle_schedule_group_split(&self) {
664        let table_write_throughput = self.table_write_throughput_statistic_manager.read().clone();
665
666        let mut group_infos = self.calculate_compaction_group_statistic().await;
667        group_infos.sort_by_key(|group| Reverse(group.group_size));
668
669        for group in group_infos {
670            if group.table_statistic.len() == 1 {
671                // no need to handle the separate compaciton group
672                continue;
673            }
674
675            self.try_split_compaction_group(&table_write_throughput, group)
676                .await;
677        }
678    }
679
680    #[cfg(test)]
681    pub async fn schedule_group_split_for_test(&self) {
682        self.on_handle_schedule_group_split().await;
683    }
684
685    #[cfg(test)]
686    pub async fn schedule_group_merge_for_test(&self) {
687        self.on_handle_schedule_group_merge().await;
688    }
689
690    async fn on_handle_trigger_multi_group(&self, task_type: compact_task::TaskType) {
691        for cg_id in self.compaction_group_ids().await {
692            self.compaction_state.try_sched_compaction(
693                cg_id,
694                task_type,
695                super::compaction::ScheduleTrigger::Periodic,
696            );
697        }
698    }
699
700    /// Try to schedule a compaction merge for the given compaction groups.
701    /// The merge will be triggered if the following conditions are met:
702    /// 1. The compaction group is not contains creating table.
703    /// 2. The compaction group is a small group.
704    /// 3. All tables in compaction group is in a low throughput state.
705    async fn on_handle_schedule_group_merge(&self) {
706        self.maybe_normalize_compaction_groups_before_merge().await;
707
708        let created_tables = match self.metadata_manager.get_created_table_ids().await {
709            Ok(created_tables) => HashSet::from_iter(created_tables),
710            Err(err) => {
711                tracing::warn!(error = %err.as_report(), "failed to fetch created table ids");
712                return;
713            }
714        };
715        let table_write_throughput_statistic_manager =
716            self.table_write_throughput_statistic_manager.read().clone();
717        let mut group_infos = self.calculate_compaction_group_statistic().await;
718        // sort by first table id for deterministic merge order
719        group_infos.sort_by_key(|group| group.table_statistic.keys().next().copied());
720
721        let group_count = group_infos.len();
722        if group_count < 2 {
723            return;
724        }
725
726        let mut base = 0;
727        let mut candidate = 1;
728
729        while candidate < group_count {
730            let group = &group_infos[base];
731            let next_group = &group_infos[candidate];
732            match self
733                .try_merge_compaction_group(
734                    &table_write_throughput_statistic_manager,
735                    group,
736                    next_group,
737                    &created_tables,
738                )
739                .await
740            {
741                Ok(_) => candidate += 1,
742                Err(e) => {
743                    tracing::debug!(
744                        error = %e.as_report(),
745                        "Failed to merge compaction group",
746                    );
747                    base = candidate;
748                    candidate = base + 1;
749                }
750            }
751        }
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    mod merge_scheduling {
758        use std::sync::Arc;
759        use std::time::Duration;
760
761        use itertools::Itertools;
762        use risingwave_common::catalog::TableId;
763        use risingwave_hummock_sdk::CompactionGroupId;
764        use risingwave_hummock_sdk::version::HummockVersion;
765        use risingwave_meta_model::WorkerId;
766        use risingwave_pb::common::worker_node::Property;
767        use risingwave_pb::common::{HostAddress, WorkerType};
768
769        use crate::controller::catalog::CatalogController;
770        use crate::controller::cluster::{ClusterController, ClusterControllerRef};
771        use crate::hummock::compaction::compaction_config::CompactionConfigBuilder;
772        use crate::hummock::{CompactorManager, HummockManager, HummockManagerRef};
773        use crate::manager::{MetaOpts, MetaSrvEnv};
774
775        async fn setup_compute_env_with_meta_opts(
776            port: i32,
777            opts: MetaOpts,
778        ) -> (
779            MetaSrvEnv,
780            HummockManagerRef,
781            ClusterControllerRef,
782            WorkerId,
783        ) {
784            let env = MetaSrvEnv::for_test_opts(opts, |_| ()).await;
785            let cluster_ctl = Arc::new(
786                ClusterController::new(env.clone(), Duration::from_secs(1))
787                    .await
788                    .unwrap(),
789            );
790            let catalog_ctl = Arc::new(CatalogController::new(env.clone()).await.unwrap());
791            let compactor_manager = Arc::new(CompactorManager::for_test());
792            let (compactor_streams_change_tx, _compactor_streams_change_rx) =
793                tokio::sync::mpsc::unbounded_channel();
794            let config = CompactionConfigBuilder::new()
795                .level0_tier_compact_file_number(1)
796                .level0_max_compact_file_number(130)
797                .level0_sub_level_compact_level_count(1)
798                .level0_overlapping_sub_level_compact_level_count(1)
799                .build();
800            let hummock_manager = HummockManager::with_config(
801                env.clone(),
802                cluster_ctl.clone(),
803                catalog_ctl,
804                Arc::new(Default::default()),
805                compactor_manager,
806                config,
807                compactor_streams_change_tx,
808            )
809            .await;
810
811            let worker_id = cluster_ctl
812                .add_worker(
813                    WorkerType::ComputeNode,
814                    HostAddress {
815                        host: "127.0.0.1".to_owned(),
816                        port,
817                    },
818                    Property {
819                        is_streaming: true,
820                        is_serving: true,
821                        parallelism: 4,
822                        ..Default::default()
823                    },
824                    Default::default(),
825                )
826                .await
827                .unwrap();
828            (env, hummock_manager, cluster_ctl, worker_id)
829        }
830
831        async fn get_compaction_group_id_by_table_id(
832            hummock_manager: HummockManagerRef,
833            table_id: u32,
834        ) -> CompactionGroupId {
835            hummock_manager
836                .get_current_version()
837                .await
838                .state_table_info
839                .info()
840                .get(&TableId::new(table_id))
841                .unwrap()
842                .compaction_group_id
843        }
844
845        fn member_table_ids(version: &HummockVersion, group_id: CompactionGroupId) -> Vec<u32> {
846            version
847                .state_table_info
848                .compaction_group_member_table_ids(group_id)
849                .iter()
850                .map(|table_id| table_id.as_raw_id())
851                .collect_vec()
852        }
853
854        fn assert_no_group_overlap(version: &HummockVersion) {
855            let mut ranges = version
856                .levels
857                .keys()
858                .filter_map(|group_id| {
859                    let members = member_table_ids(version, *group_id);
860                    (!members.is_empty())
861                        .then(|| (*members.first().unwrap(), *members.last().unwrap()))
862                })
863                .collect_vec();
864            ranges.sort_by_key(|(min_table_id, _)| *min_table_id);
865            assert!(ranges.windows(2).all(|window| window[0].1 < window[1].0));
866        }
867
868        #[tokio::test]
869        async fn test_merge_scheduling_normalizes_when_split_scheduling_is_disabled() {
870            let mut opts = MetaOpts::test(false);
871            opts.enable_compaction_group_normalize = true;
872            opts.periodic_scheduling_compaction_group_split_interval_sec = 0;
873
874            let (_env, hummock_manager, _, _worker_id) =
875                setup_compute_env_with_meta_opts(80, opts).await;
876            hummock_manager
877                .register_table_ids_for_test(&[(64, 2.into()), (80, 2.into())])
878                .await
879                .unwrap();
880            hummock_manager
881                .register_table_ids_for_test(&[(65, 3.into()), (81, 3.into()), (83, 3.into())])
882                .await
883                .unwrap();
884
885            let cg_64 = get_compaction_group_id_by_table_id(hummock_manager.clone(), 64).await;
886            let cg_65 = get_compaction_group_id_by_table_id(hummock_manager.clone(), 65).await;
887
888            hummock_manager.on_handle_schedule_group_merge().await;
889
890            let version = hummock_manager.get_current_version().await;
891            assert_eq!(member_table_ids(&version, cg_64), vec![64]);
892            assert_eq!(member_table_ids(&version, cg_65), vec![65]);
893            assert_no_group_overlap(&version);
894        }
895    }
896
897    mod periodic_loop {
898        use std::sync::Arc;
899        use std::sync::atomic::{AtomicUsize, Ordering};
900        use std::time::Duration;
901
902        use tokio::sync::watch;
903
904        use super::super::{interval_stream, spawn_serial_timer_lane};
905
906        #[tokio::test(start_paused = true)]
907        async fn test_serial_timer_lane_does_not_run_handlers_concurrently() {
908            #[derive(Clone, Copy)]
909            enum TestEvent {
910                Split,
911                Merge,
912            }
913
914            struct DecrOnDrop(Arc<AtomicUsize>);
915
916            impl Drop for DecrOnDrop {
917                fn drop(&mut self) {
918                    self.0.fetch_sub(1, Ordering::SeqCst);
919                }
920            }
921
922            let (shutdown_tx, shutdown_rx) = watch::channel(false);
923            let in_handler = Arc::new(AtomicUsize::new(0));
924            let max_in_handler = Arc::new(AtomicUsize::new(0));
925            let handled = Arc::new(AtomicUsize::new(0));
926
927            let handle = spawn_serial_timer_lane(
928                "test_serial_lane",
929                vec![
930                    interval_stream(Duration::from_millis(10), TestEvent::Split),
931                    interval_stream(Duration::from_millis(10), TestEvent::Merge),
932                ],
933                shutdown_rx,
934                {
935                    let in_handler = in_handler.clone();
936                    let max_in_handler = max_in_handler.clone();
937                    let handled = handled.clone();
938                    move |_event| {
939                        let in_handler = in_handler.clone();
940                        let max_in_handler = max_in_handler.clone();
941                        let handled = handled.clone();
942                        async move {
943                            handled.fetch_add(1, Ordering::SeqCst);
944                            let now = in_handler.fetch_add(1, Ordering::SeqCst) + 1;
945                            let _decr = DecrOnDrop(in_handler);
946                            max_in_handler.fetch_max(now, Ordering::SeqCst);
947                            tokio::time::sleep(Duration::from_millis(15)).await;
948                        }
949                    }
950                },
951            );
952
953            tokio::time::sleep(Duration::from_millis(120)).await;
954            shutdown_tx.send(true).unwrap();
955            handle.await.unwrap();
956
957            assert!(handled.load(Ordering::SeqCst) > 0);
958            assert_eq!(max_in_handler.load(Ordering::SeqCst), 1);
959        }
960    }
961}