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