Skip to main content

risingwave_storage/hummock/compactor/
mod.rs

1// Copyright 2022 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
15mod block_stream;
16mod compaction_executor;
17mod compaction_filter;
18pub mod compaction_utils;
19mod iceberg_compaction;
20use risingwave_hummock_sdk::compact_task::{CompactTask, ValidationTask};
21use risingwave_pb::compactor::{DispatchCompactionTaskRequest, dispatch_compaction_task_request};
22use risingwave_pb::hummock::PbCompactTask;
23use risingwave_pb::hummock::report_compaction_task_request::{
24    Event as ReportCompactionTaskEvent, HeartBeat as SharedHeartBeat,
25    ReportTask as ReportSharedTask,
26};
27use risingwave_pb::iceberg_compaction::{
28    SubscribeIcebergCompactionEventRequest, SubscribeIcebergCompactionEventResponse,
29    subscribe_iceberg_compaction_event_request,
30};
31use risingwave_rpc_client::GrpcCompactorProxyClient;
32use thiserror_ext::AsReport;
33use tokio::sync::mpsc;
34use tonic::Request;
35
36pub mod compactor_runner;
37mod context;
38pub mod fast_compactor_runner;
39mod iterator;
40mod shared_buffer_compact;
41pub(super) mod task_progress;
42
43use std::collections::hash_map::Entry;
44use std::collections::{HashMap, VecDeque};
45use std::marker::PhantomData;
46use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
47use std::sync::{Arc, Mutex};
48use std::time::{Duration, SystemTime};
49
50use await_tree::{InstrumentAwait, SpanExt};
51pub use compaction_executor::CompactionExecutor;
52pub use compaction_filter::{
53    CompactionFilter, DummyCompactionFilter, MultiCompactionFilter, TtlCompactionFilter,
54};
55pub use context::{
56    CompactionAwaitTreeRegRef, CompactorContext, await_tree_key, new_compaction_await_tree_reg_ref,
57};
58use futures::{StreamExt, pin_mut};
59// Import iceberg compactor runner types from the local `iceberg_compaction` module.
60use iceberg_compaction::iceberg_compactor_runner::IcebergCompactorRunnerConfigBuilder;
61#[cfg(madsim)]
62pub use iceberg_compaction::set_simulated_pk_index_compaction_result;
63#[cfg(madsim)]
64use iceberg_compaction::simulated_pk_index_compaction_result;
65use iceberg_compaction::{
66    IcebergPlanCompletion, IcebergTaskExecution, IcebergTaskQueue, IcebergTaskReport,
67    IcebergTaskTracker, PushResult, ReportSendResult, build_drained_iceberg_task_report,
68    build_iceberg_task_report, create_task_execution, flush_pending_iceberg_task_reports,
69    send_or_buffer_iceberg_task_report,
70};
71pub use iterator::{ConcatSstableIterator, SstableStreamIterator};
72use more_asserts::assert_ge;
73use risingwave_hummock_sdk::table_stats::{TableStatsMap, to_prost_table_stats_map};
74use risingwave_hummock_sdk::{
75    HummockCompactionTaskId, HummockSstableObjectId, LocalSstableInfo, compact_task_to_string,
76};
77use risingwave_pb::hummock::compact_task::TaskStatus;
78use risingwave_pb::hummock::subscribe_compaction_event_request::{
79    Event as RequestEvent, HeartBeat, PullTask, ReportTask,
80};
81use risingwave_pb::hummock::subscribe_compaction_event_response::Event as ResponseEvent;
82use risingwave_pb::hummock::{
83    CompactTaskProgress, PbSstableFilterLayout, PbSstableFilterType, ReportCompactionTaskRequest,
84    SubscribeCompactionEventRequest, SubscribeCompactionEventResponse,
85};
86use risingwave_pb::id::IcebergCompactionTaskId;
87use risingwave_rpc_client::HummockMetaClient;
88pub use shared_buffer_compact::compact;
89use tokio::sync::oneshot::Sender;
90use tokio::task::JoinHandle;
91use tokio::time::Instant;
92
93pub use self::compaction_utils::{
94    CompactionStatistics, RemoteBuilderFactory, TaskConfig, check_compaction_result,
95    check_flush_result,
96};
97pub use self::task_progress::TaskProgress;
98use super::multi_builder::CapacitySplitTableBuilder;
99use super::{
100    GetObjectId, HummockError, HummockErrorInner, HummockResult, ObjectIdManager,
101    SstableBuilderOptions, Xor8FilterBuilder, Xor16FilterBuilder,
102};
103use crate::compaction_catalog_manager::{
104    CompactionCatalogAgentRef, CompactionCatalogManager, CompactionCatalogManagerRef,
105};
106use crate::hummock::compactor::compaction_utils::calculate_task_parallelism;
107use crate::hummock::compactor::compactor_runner::{compact_and_build_sst, compact_done};
108use crate::hummock::compactor::iceberg_compaction::TaskKey;
109use crate::hummock::iterator::{Forward, HummockIterator};
110use crate::hummock::{
111    BlockedXor8FilterBuilder, BlockedXor16FilterBuilder, FilterBuilder, NoneFilterBuilder,
112    SharedComapctorObjectIdManager, SstableWriterFactory, StreamingSstableWriterFactory,
113    validate_ssts,
114};
115use crate::monitor::CompactorMetrics;
116
117/// Heartbeat logging interval for compaction tasks
118const COMPACTION_HEARTBEAT_LOG_INTERVAL: Duration = Duration::from_secs(60);
119
120/// Represents the compaction task state for logging purposes
121#[derive(Debug, Clone, PartialEq, Eq)]
122struct CompactionLogState {
123    running_parallelism: u32,
124    pull_task_ack: bool,
125    pending_pull_task_count: u32,
126}
127
128/// Represents the iceberg compaction task state for logging purposes
129#[derive(Debug, Clone, PartialEq, Eq)]
130struct IcebergCompactionLogState {
131    running_parallelism: u32,
132    waiting_parallelism: u32,
133    available_parallelism: u32,
134    pull_task_ack: bool,
135    pending_pull_task_count: u32,
136}
137
138/// Controls periodic logging with state change detection
139struct LogThrottler<T: PartialEq> {
140    last_logged_state: Option<T>,
141    last_heartbeat: Instant,
142    heartbeat_interval: Duration,
143}
144
145impl<T: PartialEq> LogThrottler<T> {
146    fn new(heartbeat_interval: Duration) -> Self {
147        Self {
148            last_logged_state: None,
149            last_heartbeat: Instant::now(),
150            heartbeat_interval,
151        }
152    }
153
154    /// Returns true if logging should occur (state changed or heartbeat interval elapsed)
155    fn should_log(&self, current_state: &T) -> bool {
156        self.last_logged_state.as_ref() != Some(current_state)
157            || self.last_heartbeat.elapsed() >= self.heartbeat_interval
158    }
159
160    /// Updates the state and heartbeat timestamp after logging
161    fn update(&mut self, current_state: T) {
162        self.last_logged_state = Some(current_state);
163        self.last_heartbeat = Instant::now();
164    }
165}
166
167/// Implementation of Hummock compaction.
168pub struct Compactor {
169    /// The context of the compactor.
170    context: CompactorContext,
171    object_id_getter: Arc<dyn GetObjectId>,
172    task_config: TaskConfig,
173    options: SstableBuilderOptions,
174    get_id_time: Arc<AtomicU64>,
175}
176
177pub type CompactOutput = (usize, Vec<LocalSstableInfo>, CompactionStatistics);
178
179impl Compactor {
180    /// Create a new compactor.
181    pub fn new(
182        context: CompactorContext,
183        options: SstableBuilderOptions,
184        task_config: TaskConfig,
185        object_id_getter: Arc<dyn GetObjectId>,
186    ) -> Self {
187        Self {
188            context,
189            options,
190            task_config,
191            get_id_time: Arc::new(AtomicU64::new(0)),
192            object_id_getter,
193        }
194    }
195
196    /// Compact the given key range and merge iterator.
197    /// Upon a successful return, the built SSTs are already uploaded to object store.
198    ///
199    /// `task_progress` is only used for tasks on the compactor.
200    async fn compact_key_range(
201        &self,
202        iter: impl HummockIterator<Direction = Forward>,
203        compaction_filter: impl CompactionFilter,
204        compaction_catalog_agent_ref: CompactionCatalogAgentRef,
205        task_progress: Option<Arc<TaskProgress>>,
206        task_id: Option<HummockCompactionTaskId>,
207        split_index: Option<usize>,
208    ) -> HummockResult<(Vec<LocalSstableInfo>, CompactionStatistics)> {
209        // Monitor time cost building shared buffer to SSTs.
210        let compact_timer = if self.context.is_share_buffer_compact {
211            self.context
212                .compactor_metrics
213                .write_build_l0_sst_duration
214                .start_timer()
215        } else {
216            self.context
217                .compactor_metrics
218                .compact_sst_duration
219                .start_timer()
220        };
221
222        let (split_table_outputs, table_stats_map) = {
223            let factory = StreamingSstableWriterFactory::new(self.context.sstable_store.clone());
224            match (
225                self.task_config.sstable_filter_type,
226                self.task_config.sstable_filter_layout,
227            ) {
228                (PbSstableFilterType::SstableFilterNone, _) => {
229                    self.compact_key_range_impl::<_, NoneFilterBuilder>(
230                        factory,
231                        iter,
232                        compaction_filter,
233                        compaction_catalog_agent_ref,
234                        task_progress.clone(),
235                        self.object_id_getter.clone(),
236                    )
237                    .instrument_await("compact".verbose())
238                    .await?
239                }
240                (PbSstableFilterType::SstableFilterXor8, PbSstableFilterLayout::Blocked) => {
241                    self.compact_key_range_impl::<_, BlockedXor8FilterBuilder>(
242                        factory,
243                        iter,
244                        compaction_filter,
245                        compaction_catalog_agent_ref,
246                        task_progress.clone(),
247                        self.object_id_getter.clone(),
248                    )
249                    .instrument_await("compact".verbose())
250                    .await?
251                }
252                (PbSstableFilterType::SstableFilterXor8, PbSstableFilterLayout::Plain) => {
253                    self.compact_key_range_impl::<_, Xor8FilterBuilder>(
254                        factory,
255                        iter,
256                        compaction_filter,
257                        compaction_catalog_agent_ref,
258                        task_progress.clone(),
259                        self.object_id_getter.clone(),
260                    )
261                    .instrument_await("compact".verbose())
262                    .await?
263                }
264                (PbSstableFilterType::SstableFilterXor16, PbSstableFilterLayout::Blocked) => {
265                    self.compact_key_range_impl::<_, BlockedXor16FilterBuilder>(
266                        factory,
267                        iter,
268                        compaction_filter,
269                        compaction_catalog_agent_ref,
270                        task_progress.clone(),
271                        self.object_id_getter.clone(),
272                    )
273                    .instrument_await("compact".verbose())
274                    .await?
275                }
276                (PbSstableFilterType::SstableFilterXor16, PbSstableFilterLayout::Plain) => {
277                    self.compact_key_range_impl::<_, Xor16FilterBuilder>(
278                        factory,
279                        iter,
280                        compaction_filter,
281                        compaction_catalog_agent_ref,
282                        task_progress.clone(),
283                        self.object_id_getter.clone(),
284                    )
285                    .instrument_await("compact".verbose())
286                    .await?
287                }
288                (filter_type, layout) => {
289                    return Err(HummockError::compaction_executor(format!(
290                        "unresolved SST filter layout in task config: {:?}, {:?}",
291                        filter_type, layout
292                    )));
293                }
294            }
295        };
296
297        compact_timer.observe_duration();
298
299        Self::report_progress(
300            self.context.compactor_metrics.clone(),
301            task_progress,
302            &split_table_outputs,
303            self.context.is_share_buffer_compact,
304        );
305
306        self.context
307            .compactor_metrics
308            .get_table_id_total_time_duration
309            .observe(self.get_id_time.load(Ordering::Relaxed) as f64 / 1000.0 / 1000.0);
310
311        debug_assert!(
312            split_table_outputs
313                .iter()
314                .all(|table_info| table_info.sst_info.table_ids.is_sorted())
315        );
316
317        if task_id.is_some() {
318            // skip shared buffer compaction
319            tracing::info!(
320                "Finish Task {:?} split_index {:?} sst count {}",
321                task_id,
322                split_index,
323                split_table_outputs.len()
324            );
325        }
326        Ok((split_table_outputs, table_stats_map))
327    }
328
329    pub fn report_progress(
330        metrics: Arc<CompactorMetrics>,
331        task_progress: Option<Arc<TaskProgress>>,
332        ssts: &Vec<LocalSstableInfo>,
333        is_share_buffer_compact: bool,
334    ) {
335        for sst_info in ssts {
336            let sst_size = sst_info.file_size();
337            if let Some(tracker) = &task_progress {
338                tracker.inc_ssts_uploaded();
339                tracker.dec_num_pending_write_io();
340            }
341            if is_share_buffer_compact {
342                metrics.shared_buffer_to_sstable_size.observe(sst_size as _);
343            } else {
344                metrics.compaction_upload_sst_counts.inc();
345            }
346        }
347    }
348
349    async fn compact_key_range_impl<F: SstableWriterFactory, B: FilterBuilder>(
350        &self,
351        writer_factory: F,
352        iter: impl HummockIterator<Direction = Forward>,
353        compaction_filter: impl CompactionFilter,
354        compaction_catalog_agent_ref: CompactionCatalogAgentRef,
355        task_progress: Option<Arc<TaskProgress>>,
356        object_id_getter: Arc<dyn GetObjectId>,
357    ) -> HummockResult<(Vec<LocalSstableInfo>, CompactionStatistics)> {
358        let builder_factory = RemoteBuilderFactory::<F, B> {
359            object_id_getter,
360            limiter: self.context.memory_limiter.clone(),
361            options: self.options.clone(),
362            policy: self.task_config.cache_policy,
363            remote_rpc_cost: self.get_id_time.clone(),
364            compaction_catalog_agent_ref: compaction_catalog_agent_ref.clone(),
365            sstable_writer_factory: writer_factory,
366            _phantom: PhantomData,
367        };
368
369        let mut sst_builder = CapacitySplitTableBuilder::new(
370            builder_factory,
371            self.context.compactor_metrics.clone(),
372            task_progress.clone(),
373            self.task_config.table_vnode_partition.clone(),
374            self.context
375                .storage_opts
376                .compactor_concurrent_uploading_sst_count,
377            compaction_catalog_agent_ref,
378        );
379        let compaction_statistics = compact_and_build_sst(
380            &mut sst_builder,
381            &self.task_config,
382            self.context.compactor_metrics.clone(),
383            iter,
384            compaction_filter,
385        )
386        .instrument_await("compact_and_build_sst".verbose())
387        .await?;
388
389        let ssts = sst_builder
390            .finish()
391            .instrument_await("builder_finish".verbose())
392            .await?;
393
394        Ok((ssts, compaction_statistics))
395    }
396}
397
398/// The background compaction thread that receives compaction tasks from hummock compaction
399/// manager and runs compaction tasks.
400#[must_use]
401pub fn start_iceberg_compactor(
402    compactor_context: CompactorContext,
403    hummock_meta_client: Arc<dyn HummockMetaClient>,
404    iceberg_compaction_memory_limit_bytes: usize,
405) -> (JoinHandle<()>, Sender<()>) {
406    let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
407    let stream_retry_interval = Duration::from_secs(30);
408    let periodic_event_update_interval = Duration::from_millis(
409        compactor_context
410            .storage_opts
411            .iceberg_compaction_pull_interval_ms,
412    );
413    let worker_num = compactor_context.compaction_executor.worker_num();
414
415    let max_task_parallelism: u32 = (worker_num as f32
416        * compactor_context.storage_opts.compactor_max_task_multiplier)
417        .ceil() as u32;
418
419    let max_plan_parallelism = iceberg_compaction_max_plan_parallelism(
420        worker_num,
421        compactor_context
422            .storage_opts
423            .iceberg_compaction_task_parallelism_ratio,
424        max_task_parallelism,
425    );
426
427    let max_pull_task_count = std::cmp::min(
428        max_task_parallelism,
429        compactor_context
430            .storage_opts
431            .iceberg_compaction_max_pull_task_count,
432    );
433
434    assert_ge!(
435        compactor_context.storage_opts.compactor_max_task_multiplier,
436        0.0
437    );
438
439    let join_handle = tokio::spawn(async move {
440        // Initialize task queue with event-driven scheduling
441        let pending_parallelism_budget = (max_task_parallelism as f32
442            * compactor_context
443                .storage_opts
444                .iceberg_compaction_pending_parallelism_budget_multiplier)
445            .ceil() as u32;
446        let mut task_queue = IcebergTaskQueue::new_with_metrics(
447            max_task_parallelism,
448            pending_parallelism_budget,
449            iceberg_compaction_memory_limit_bytes,
450            compactor_context.compactor_metrics.clone(),
451        );
452
453        // Shutdown tracking for running tasks (task_key -> shutdown_sender)
454        let shutdown_map = Arc::new(Mutex::new(HashMap::<TaskKey, Sender<()>>::new()));
455
456        // Channel for task completion notifications
457        let (task_completion_tx, mut task_completion_rx) =
458            tokio::sync::mpsc::unbounded_channel::<IcebergPlanCompletion>();
459        let mut task_trackers = HashMap::<IcebergCompactionTaskId, IcebergTaskTracker>::new();
460        // Buffers task reports that failed to send on the current stream.
461        // The queue is flushed in FIFO order after the stream reconnects.
462        let mut pending_task_reports = VecDeque::<IcebergTaskReport>::new();
463
464        let mut min_interval = tokio::time::interval(stream_retry_interval);
465        let mut periodic_event_interval = tokio::time::interval(periodic_event_update_interval);
466
467        // Track last logged state to avoid duplicate logs
468        let mut log_throttler =
469            LogThrottler::<IcebergCompactionLogState>::new(COMPACTION_HEARTBEAT_LOG_INTERVAL);
470
471        // This outer loop is to recreate stream.
472        'start_stream: loop {
473            // reset state
474            // pull_task_ack.store(true, Ordering::SeqCst);
475            let mut pull_task_ack = true;
476            tokio::select! {
477                // Wait for interval.
478                _ = min_interval.tick() => {},
479                // Shutdown compactor.
480                _ = &mut shutdown_rx => {
481                    tracing::info!("Compactor is shutting down");
482                    return;
483                }
484            }
485
486            let (request_sender, response_event_stream) = match hummock_meta_client
487                .subscribe_iceberg_compaction_event()
488                .await
489            {
490                Ok((request_sender, response_event_stream)) => {
491                    tracing::debug!("Succeeded subscribe_iceberg_compaction_event.");
492                    (request_sender, response_event_stream)
493                }
494
495                Err(e) => {
496                    tracing::warn!(
497                        error = %e.as_report(),
498                        "Subscribing to iceberg compaction tasks failed with error. Will retry.",
499                    );
500                    continue 'start_stream;
501                }
502            };
503
504            if matches!(
505                flush_pending_iceberg_task_reports(&request_sender, &mut pending_task_reports),
506                ReportSendResult::RestartStream
507            ) {
508                continue 'start_stream;
509            }
510
511            pin_mut!(response_event_stream);
512
513            let _executor = compactor_context.compaction_executor.clone();
514
515            // This inner loop is to consume stream or report task progress.
516            let mut event_loop_iteration_now = Instant::now();
517            'consume_stream: loop {
518                {
519                    // report
520                    compactor_context
521                        .compactor_metrics
522                        .compaction_event_loop_iteration_latency
523                        .observe(event_loop_iteration_now.elapsed().as_millis() as _);
524                    event_loop_iteration_now = Instant::now();
525                }
526
527                let request_sender = request_sender.clone();
528                let event: Option<Result<SubscribeIcebergCompactionEventResponse, _>> = tokio::select! {
529                    // Handle task completion notifications
530                    Some(plan_completion) = task_completion_rx.recv() => {
531                        let task_key = plan_completion.task_key;
532                        let error_message = plan_completion.error_message;
533                        let pk_index_result = plan_completion.pk_index_result;
534                        tracing::debug!(
535                            task_id = %task_key.0,
536                            plan_index = task_key.1,
537                            success = error_message.is_none(),
538                            "Plan completed, updating queue state"
539                        );
540                        task_queue.finish_running(task_key);
541
542                        let completed_task_id = task_key.0;
543                        let Entry::Occupied(mut tracker_entry) =
544                            task_trackers.entry(completed_task_id)
545                        else {
546                            continue 'consume_stream;
547                        };
548                        tracker_entry
549                            .get_mut()
550                            .record_completion(error_message, pk_index_result);
551                        if !tracker_entry.get().is_finished() {
552                            continue 'consume_stream;
553                        }
554
555                        let tracker = tracker_entry.remove();
556                        let sink_id = tracker.sink_id();
557                        let admitted_plans = tracker.admitted_plans();
558                        let successful_plans = tracker.successful_plans();
559                        let failed_plans = tracker.failed_plans();
560                        let report = tracker.into_report(completed_task_id);
561                        let task_succeeded = report.error_message.is_none();
562                        if matches!(
563                            send_or_buffer_iceberg_task_report(
564                                &request_sender,
565                                &mut pending_task_reports,
566                                report,
567                            ),
568                            ReportSendResult::RestartStream
569                        ) {
570                            continue 'start_stream;
571                        }
572                        if task_succeeded {
573                            tracing::info!(
574                                iceberg_component = "compaction_worker",
575                                iceberg_operation = "report_task",
576                                task_id = %completed_task_id,
577                                sink_id = sink_id,
578                                admitted_plans = admitted_plans,
579                                successful_plans = successful_plans,
580                                failed_plans = failed_plans,
581                                "iceberg_compaction_task_succeeded",
582                            );
583                        }
584                        continue 'consume_stream;
585                    }
586
587                    // Event-driven task scheduling - wait for tasks to become schedulable
588                    _ = task_queue.wait_schedulable() => {
589                        schedule_queued_tasks(
590                            &mut task_queue,
591                            &compactor_context,
592                            &shutdown_map,
593                            &task_completion_tx,
594                        );
595                        continue 'consume_stream;
596                    }
597
598                    _ = periodic_event_interval.tick() => {
599                        // Only handle meta task pulling in periodic tick
600                        let should_restart_stream = handle_meta_task_pulling(
601                            &mut pull_task_ack,
602                            &task_queue,
603                            max_task_parallelism,
604                            max_pull_task_count,
605                            &request_sender,
606                            &mut log_throttler,
607                        );
608
609                        if should_restart_stream {
610                            continue 'start_stream;
611                        }
612                        continue;
613                    }
614                    event = response_event_stream.next() => {
615                        event
616                    }
617
618                    _ = &mut shutdown_rx => {
619                        tracing::info!("Iceberg Compactor is shutting down");
620                        return
621                    }
622                };
623
624                match event {
625                    Some(Ok(SubscribeIcebergCompactionEventResponse {
626                        event,
627                        create_at: _create_at,
628                    })) => {
629                        let event = match event {
630                            Some(event) => event,
631                            None => continue 'consume_stream,
632                        };
633
634                        match event {
635                            risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event::CompactTask(iceberg_compaction_task) => {
636                                let task_id = iceberg_compaction_task.task_id;
637                                let sink_id = iceberg_compaction_task.sink_id;
638                                let is_bounded_round = iceberg_compaction_task.max_file_sequence_number.is_some();
639                                let pk_index_coordinated = iceberg_compaction_task.pk_index_coordinated;
640                                // iceberg-rust internally spawns on native Tokio, which is not
641                                // available inside a madsim node. Tests can replace only the
642                                // rewrite result while retaining the real compactor stream and
643                                // all downstream resolver/commit/recovery behavior.
644                                #[cfg(madsim)]
645                                if iceberg_compaction_task.pk_index_coordinated
646                                    && fail::eval(
647                                        "iceberg_pk_index_simulated_compaction_report",
648                                        |_| (),
649                                    )
650                                    .is_some()
651                                {
652                                    // Consume each rewrite once. Later bounded probes report
653                                    // no remaining plans instead of reapplying stale inputs.
654                                    let result = simulated_pk_index_compaction_result();
655                                    let mut report = if result.is_none() && is_bounded_round {
656                                        build_drained_iceberg_task_report(task_id, sink_id)
657                                    } else {
658                                        build_iceberg_task_report(task_id, sink_id, None)
659                                    };
660                                    report.pk_index_result = result;
661                                    if matches!(
662                                        send_or_buffer_iceberg_task_report(
663                                            &request_sender,
664                                            &mut pending_task_reports,
665                                            report,
666                                        ),
667                                        ReportSendResult::RestartStream
668                                    ) {
669                                        continue 'start_stream;
670                                    }
671                                    continue 'consume_stream;
672                                }
673                                // Note: write_parquet_properties is now built from sink config (IcebergConfig) in create_task_execution
674                                let compactor_runner_config = match IcebergCompactorRunnerConfigBuilder::default()
675                                    .max_parallelism(max_plan_parallelism)
676                                    .min_size_per_partition(compactor_context.storage_opts.iceberg_compaction_min_size_per_partition_mb as u64 * 1024 * 1024)
677                                    .max_file_count_per_partition(compactor_context.storage_opts.iceberg_compaction_max_file_count_per_partition)
678                                    .enable_validate_compaction(compactor_context.storage_opts.iceberg_compaction_enable_validate)
679                                    .max_record_batch_rows(compactor_context.storage_opts.iceberg_compaction_max_record_batch_rows)
680                                    .enable_heuristic_output_parallelism(compactor_context.storage_opts.iceberg_compaction_enable_heuristic_output_parallelism)
681                                    .max_concurrent_closes(compactor_context.storage_opts.iceberg_compaction_max_concurrent_closes)
682                                    .enable_prefetch(compactor_context.storage_opts.iceberg_compaction_enable_prefetch)
683                                    .target_binpack_group_size_mb(
684                                        compactor_context.storage_opts.iceberg_compaction_target_binpack_group_size_mb
685                                    )
686                                    .min_group_size_mb(
687                                        compactor_context.storage_opts.iceberg_compaction_min_group_size_mb
688                                    )
689                                    .min_group_file_count(
690                                        compactor_context.storage_opts.iceberg_compaction_min_group_file_count
691                                    )
692                                    .build() {
693                                    Ok(config) => config,
694                                    Err(e) => {
695                                        tracing::warn!(
696                                            iceberg_component = "compaction_worker",
697                                            iceberg_operation = "build_runner_config",
698                                            error = %e.as_report(),
699                                            task_id = %task_id,
700                                            sink_id = sink_id,
701                                            "iceberg_compaction_runner_config_failed",
702                                        );
703                                        let report = build_iceberg_task_report(
704                                            task_id,
705                                            sink_id,
706                                            Some(format!(
707                                                "Failed to build iceberg compactor runner config: {}",
708                                                e.as_report()
709                                            )),
710                                        );
711                                        if matches!(
712                                            send_or_buffer_iceberg_task_report(
713                                                &request_sender,
714                                                &mut pending_task_reports,
715                                                report,
716                                            ),
717                                            ReportSendResult::RestartStream
718                                        ) {
719                                            continue 'start_stream;
720                                        }
721                                        continue 'consume_stream;
722                                    }
723                                };
724
725                                // Create task execution context and plan runners from the task
726                                let task_execution = match create_task_execution(
727                                    iceberg_compaction_task,
728                                    compactor_runner_config,
729                                    compactor_context.compactor_metrics.clone(),
730                                ).await {
731                                    Ok(task_execution) => task_execution,
732                                    Err(e) => {
733                                        tracing::warn!(
734                                            iceberg_component = "compaction_worker",
735                                            iceberg_operation = "plan_task",
736                                            error = %e.as_report(),
737                                            task_id = %task_id,
738                                            sink_id = sink_id,
739                                            "iceberg_compaction_task_plan_failed",
740                                        );
741                                        let report = build_iceberg_task_report(
742                                            task_id,
743                                            sink_id,
744                                            Some(format!(
745                                                "Failed to create iceberg compaction task execution: {}",
746                                                e.as_report()
747                                            )),
748                                        );
749                                        if matches!(
750                                            send_or_buffer_iceberg_task_report(
751                                                &request_sender,
752                                                &mut pending_task_reports,
753                                                report,
754                                            ),
755                                            ReportSendResult::RestartStream
756                                        ) {
757                                            continue 'start_stream;
758                                        }
759                                        continue 'consume_stream;
760                                    }
761                                };
762
763                                let (sink_id, plan_runners) = match task_execution {
764                                    IcebergTaskExecution::Ready { sink_id, plan_runners } => {
765                                        (sink_id, plan_runners)
766                                    }
767                                    IcebergTaskExecution::Blocked { sink_id, status, reason } => {
768                                        let mut report = build_iceberg_task_report(task_id, sink_id, Some(reason));
769                                        report.status = status as i32;
770                                        if matches!(
771                                            send_or_buffer_iceberg_task_report(&request_sender, &mut pending_task_reports, report),
772                                            ReportSendResult::RestartStream
773                                        ) {
774                                            continue 'start_stream;
775                                        }
776                                        continue 'consume_stream;
777                                    }
778                                    IcebergTaskExecution::NoWork { sink_id } => {
779                                        // Only validated empty planning proves a bounded round drained.
780                                        let report = if is_bounded_round {
781                                            build_drained_iceberg_task_report(task_id, sink_id)
782                                        } else {
783                                            build_iceberg_task_report(task_id, sink_id, None)
784                                        };
785                                        if matches!(
786                                            send_or_buffer_iceberg_task_report(
787                                                &request_sender,
788                                                &mut pending_task_reports,
789                                                report,
790                                            ),
791                                            ReportSendResult::RestartStream
792                                        ) {
793                                            continue 'start_stream;
794                                        }
795                                        continue 'consume_stream;
796                                    }
797                                };
798
799                                // Enqueue each plan runner independently
800                                let planned_count = plan_runners.len();
801                                let mut admitted_count = 0;
802                                let mut rejection_reason = None;
803                                let mut rejection_status = None;
804
805                                for runner in plan_runners {
806                                    let meta = runner.to_meta();
807                                    let plan_index = meta.plan_index;
808                                    let required_parallelism = runner.required_parallelism();
809                                    let memory_reservation_bytes = meta.memory_reservation_bytes;
810                                    let runner_sink_id = runner.sink_id;
811                                    let runner_compaction_kind = runner.compaction_kind();
812                                    let runner_table = runner.table_ident.to_string();
813                                    let push_result = task_queue.push(meta, Some(runner));
814
815                                    match push_result {
816                                        PushResult::Added => {
817                                            admitted_count += 1;
818                                            tracing::debug!(
819                                                iceberg_component = "compaction_worker",
820                                                iceberg_operation = "enqueue_plan",
821                                                task_id = %task_id,
822                                                sink_id = runner_sink_id,
823                                                plan_index = plan_index,
824                                                task_type = ?runner_compaction_kind,
825                                                table = %runner_table,
826                                                required_parallelism = required_parallelism,
827                                                "iceberg_compaction_plan_enqueued",
828                                            );
829                                        },
830                                        PushResult::RejectedCapacity => {
831                                            rejection_status = Some(risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::report_task::Status::Busy);
832                                            tracing::warn!(
833                                                iceberg_component = "compaction_worker",
834                                                iceberg_operation = "enqueue_plan",
835                                                task_id = %task_id,
836                                                sink_id = runner_sink_id,
837                                                plan_index = plan_index,
838                                                task_type = ?runner_compaction_kind,
839                                                table = %runner_table,
840                                                required_parallelism = required_parallelism,
841                                                pending_budget = pending_parallelism_budget,
842                                                admitted_count = admitted_count,
843                                                planned_count = planned_count,
844                                                "iceberg_compaction_plan_rejected_capacity",
845                                            );
846                                            // Stop enqueuing remaining plans
847                                            break;
848                                        },
849                                        PushResult::RejectedTooLarge => {
850                                            rejection_status = Some(risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::report_task::Status::BlockedCapacity);
851                                            rejection_reason.get_or_insert_with(|| {
852                                                format!(
853                                                    "Iceberg compaction plan {plan_index} requires parallelism {required_parallelism} and an estimated {memory_reservation_bytes} bytes of memory, exceeding worker capacities of {max_task_parallelism} parallelism and {iceberg_compaction_memory_limit_bytes} bytes"
854                                                )
855                                            });
856                                            tracing::error!(
857                                                iceberg_component = "compaction_worker",
858                                                iceberg_operation = "enqueue_plan",
859                                                task_id = %task_id,
860                                                sink_id = runner_sink_id,
861                                                plan_index = plan_index,
862                                                task_type = ?runner_compaction_kind,
863                                                table = %runner_table,
864                                                required_parallelism = required_parallelism,
865                                                max_parallelism = max_task_parallelism,
866                                                memory_reservation_bytes,
867                                                memory_budget_bytes = iceberg_compaction_memory_limit_bytes,
868                                                "iceberg_compaction_plan_rejected_too_large",
869                                            );
870                                        },
871                                        PushResult::RejectedInvalidParallelism => {
872                                            tracing::error!(
873                                                iceberg_component = "compaction_worker",
874                                                iceberg_operation = "enqueue_plan",
875                                                task_id = %task_id,
876                                                sink_id = runner_sink_id,
877                                                plan_index = plan_index,
878                                                task_type = ?runner_compaction_kind,
879                                                table = %runner_table,
880                                                required_parallelism = required_parallelism,
881                                                "iceberg_compaction_plan_rejected_invalid_parallelism",
882                                            );
883                                        },
884                                        PushResult::RejectedDuplicate => {
885                                            tracing::error!(
886                                                iceberg_component = "compaction_worker",
887                                                iceberg_operation = "enqueue_plan",
888                                                task_id = %task_id,
889                                                sink_id = runner_sink_id,
890                                                plan_index = plan_index,
891                                                task_type = ?runner_compaction_kind,
892                                                table = %runner_table,
893                                                "iceberg_compaction_plan_rejected_duplicate",
894                                            );
895                                        }
896                                    }
897                                }
898
899                                if admitted_count == 0 {
900                                    let mut report = build_iceberg_task_report(
901                                        task_id,
902                                        sink_id,
903                                        Some(rejection_reason.unwrap_or_else(|| {
904                                            "Failed to enqueue all iceberg compaction plans".to_owned()
905                                        })),
906                                    );
907                                    if let Some(status) = rejection_status {
908                                        report.status = status as i32;
909                                    }
910                                    if matches!(
911                                        send_or_buffer_iceberg_task_report(
912                                            &request_sender,
913                                            &mut pending_task_reports,
914                                            report,
915                                        ),
916                                        ReportSendResult::RestartStream
917                                    ) {
918                                        continue 'start_stream;
919                                    }
920                                } else {
921                                    // A bounded round can drain with this task only if every planned
922                                    // runner was admitted. Otherwise an immediate re-plan
923                                    // rediscovers the deferred work.
924                                    let fully_admitted_bounded_round =
925                                        is_bounded_round
926                                            && admitted_count == planned_count;
927                                    task_trackers.insert(
928                                        task_id,
929                                        IcebergTaskTracker::new(
930                                            sink_id,
931                                            admitted_count,
932                                            pk_index_coordinated,
933                                            fully_admitted_bounded_round,
934                                        ),
935                                    );
936                                }
937
938                                tracing::info!(
939                                    iceberg_component = "compaction_worker",
940                                    iceberg_operation = "enqueue_plan",
941                                    task_id = %task_id,
942                                    sink_id = sink_id,
943                                    planned_count = planned_count,
944                                    admitted_count = admitted_count,
945                                    "iceberg_compaction_task_enqueue_finished",
946                                );
947                            },
948                            risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event::PullTaskAck(_) => {
949                                // set flag
950                                pull_task_ack = true;
951                            },
952                            risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event::CancelCompactTask(cancel_compact_task) => {
953                                cancel_iceberg_task(
954                                    cancel_compact_task.task_id,
955                                    &mut task_queue,
956                                    &shutdown_map,
957                                    &mut task_trackers,
958                                );
959                            },
960                        }
961                    }
962                    Some(Err(e)) => {
963                        tracing::warn!("Failed to consume stream. {}", e.message());
964                        continue 'start_stream;
965                    }
966                    _ => {
967                        // The stream is exhausted
968                        continue 'start_stream;
969                    }
970                }
971            }
972        }
973    });
974
975    (join_handle, shutdown_tx)
976}
977
978/// The background compaction thread that receives compaction tasks from hummock compaction
979/// manager and runs compaction tasks.
980#[must_use]
981pub fn start_compactor(
982    compactor_context: CompactorContext,
983    hummock_meta_client: Arc<dyn HummockMetaClient>,
984    object_id_manager: Arc<ObjectIdManager>,
985    compaction_catalog_manager_ref: CompactionCatalogManagerRef,
986) -> (JoinHandle<()>, Sender<()>) {
987    type CompactionShutdownMap = Arc<Mutex<HashMap<u64, Sender<()>>>>;
988    let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
989    let stream_retry_interval = Duration::from_secs(30);
990    let task_progress = compactor_context.task_progress_manager.clone();
991    let periodic_event_update_interval = Duration::from_millis(1000);
992
993    let max_task_parallelism: u32 = (compactor_context.compaction_executor.worker_num() as f32
994        * compactor_context.storage_opts.compactor_max_task_multiplier)
995        .ceil() as u32;
996    let running_task_parallelism = Arc::new(AtomicU32::new(0));
997
998    const MAX_PULL_TASK_COUNT: u32 = 4;
999    let max_pull_task_count = std::cmp::min(max_task_parallelism, MAX_PULL_TASK_COUNT);
1000
1001    assert_ge!(
1002        compactor_context.storage_opts.compactor_max_task_multiplier,
1003        0.0
1004    );
1005
1006    let join_handle = tokio::spawn(async move {
1007        let shutdown_map = CompactionShutdownMap::default();
1008        let mut min_interval = tokio::time::interval(stream_retry_interval);
1009        let mut periodic_event_interval = tokio::time::interval(periodic_event_update_interval);
1010
1011        // Track last logged state to avoid duplicate logs
1012        let mut log_throttler =
1013            LogThrottler::<CompactionLogState>::new(COMPACTION_HEARTBEAT_LOG_INTERVAL);
1014
1015        // This outer loop is to recreate stream.
1016        'start_stream: loop {
1017            // reset state
1018            // pull_task_ack.store(true, Ordering::SeqCst);
1019            let mut pull_task_ack = true;
1020            tokio::select! {
1021                // Wait for interval.
1022                _ = min_interval.tick() => {},
1023                // Shutdown compactor.
1024                _ = &mut shutdown_rx => {
1025                    tracing::info!("Compactor is shutting down");
1026                    return;
1027                }
1028            }
1029
1030            let (request_sender, response_event_stream) =
1031                match hummock_meta_client.subscribe_compaction_event().await {
1032                    Ok((request_sender, response_event_stream)) => {
1033                        tracing::debug!("Succeeded subscribe_compaction_event.");
1034                        (request_sender, response_event_stream)
1035                    }
1036
1037                    Err(e) => {
1038                        tracing::warn!(
1039                            error = %e.as_report(),
1040                            "Subscribing to compaction tasks failed with error. Will retry.",
1041                        );
1042                        continue 'start_stream;
1043                    }
1044                };
1045
1046            pin_mut!(response_event_stream);
1047
1048            let executor = compactor_context.compaction_executor.clone();
1049            let object_id_manager = object_id_manager.clone();
1050
1051            // This inner loop is to consume stream or report task progress.
1052            let mut event_loop_iteration_now = Instant::now();
1053            'consume_stream: loop {
1054                {
1055                    // report
1056                    compactor_context
1057                        .compactor_metrics
1058                        .compaction_event_loop_iteration_latency
1059                        .observe(event_loop_iteration_now.elapsed().as_millis() as _);
1060                    event_loop_iteration_now = Instant::now();
1061                }
1062
1063                let running_task_parallelism = running_task_parallelism.clone();
1064                let request_sender = request_sender.clone();
1065                let event: Option<Result<SubscribeCompactionEventResponse, _>> = tokio::select! {
1066                    _ = periodic_event_interval.tick() => {
1067                        let progress_list = get_task_progress(task_progress.clone());
1068
1069                        if let Err(e) = request_sender.send(SubscribeCompactionEventRequest {
1070                            event: Some(RequestEvent::HeartBeat(
1071                                HeartBeat {
1072                                    progress: progress_list
1073                                }
1074                            )),
1075                            create_at: SystemTime::now()
1076                                .duration_since(std::time::UNIX_EPOCH)
1077                                .expect("Clock may have gone backwards")
1078                                .as_millis() as u64,
1079                        }) {
1080                            tracing::warn!(error = %e.as_report(), "Failed to report task progress");
1081                            // re subscribe stream
1082                            continue 'start_stream;
1083                        }
1084
1085
1086                        let mut pending_pull_task_count = 0;
1087                        if pull_task_ack {
1088                            // TODO: Compute parallelism on meta side
1089                            pending_pull_task_count = (max_task_parallelism - running_task_parallelism.load(Ordering::SeqCst)).min(max_pull_task_count);
1090
1091                            if pending_pull_task_count > 0 {
1092                                if let Err(e) = request_sender.send(SubscribeCompactionEventRequest {
1093                                    event: Some(RequestEvent::PullTask(
1094                                        PullTask {
1095                                            pull_task_count: pending_pull_task_count,
1096                                        }
1097                                    )),
1098                                    create_at: SystemTime::now()
1099                                        .duration_since(std::time::UNIX_EPOCH)
1100                                        .expect("Clock may have gone backwards")
1101                                        .as_millis() as u64,
1102                                }) {
1103                                    tracing::warn!(error = %e.as_report(), "Failed to pull task");
1104
1105                                    // re subscribe stream
1106                                    continue 'start_stream;
1107                                } else {
1108                                    pull_task_ack = false;
1109                                }
1110                            }
1111                        }
1112
1113                        let running_count = running_task_parallelism.load(Ordering::SeqCst);
1114                        let current_state = CompactionLogState {
1115                            running_parallelism: running_count,
1116                            pull_task_ack,
1117                            pending_pull_task_count,
1118                        };
1119
1120                        // Log only when state changes or periodically as heartbeat
1121                        if log_throttler.should_log(&current_state) {
1122                            tracing::info!(
1123                                running_parallelism_count = %current_state.running_parallelism,
1124                                pull_task_ack = %current_state.pull_task_ack,
1125                                pending_pull_task_count = %current_state.pending_pull_task_count
1126                            );
1127                            log_throttler.update(current_state);
1128                        }
1129
1130                        continue;
1131                    }
1132                    event = response_event_stream.next() => {
1133                        event
1134                    }
1135
1136                    _ = &mut shutdown_rx => {
1137                        tracing::info!("Compactor is shutting down");
1138                        return
1139                    }
1140                };
1141
1142                fn send_report_task_event(
1143                    compact_task: &CompactTask,
1144                    table_stats: TableStatsMap,
1145                    object_timestamps: HashMap<HummockSstableObjectId, u64>,
1146                    request_sender: &mpsc::UnboundedSender<SubscribeCompactionEventRequest>,
1147                ) {
1148                    if let Err(e) = request_sender.send(SubscribeCompactionEventRequest {
1149                        event: Some(RequestEvent::ReportTask(ReportTask {
1150                            task_id: compact_task.task_id,
1151                            task_status: compact_task.task_status.into(),
1152                            sorted_output_ssts: compact_task
1153                                .sorted_output_ssts
1154                                .iter()
1155                                .map(|sst| sst.into())
1156                                .collect(),
1157                            table_stats_change: to_prost_table_stats_map(table_stats),
1158                            object_timestamps,
1159                        })),
1160                        create_at: SystemTime::now()
1161                            .duration_since(std::time::UNIX_EPOCH)
1162                            .expect("Clock may have gone backwards")
1163                            .as_millis() as u64,
1164                    }) {
1165                        let task_id = compact_task.task_id;
1166                        tracing::warn!(error = %e.as_report(), "Failed to report task {task_id:?}");
1167                    }
1168                }
1169
1170                match event {
1171                    Some(Ok(SubscribeCompactionEventResponse { event, create_at })) => {
1172                        let event = match event {
1173                            Some(event) => event,
1174                            None => continue 'consume_stream,
1175                        };
1176                        let shutdown = shutdown_map.clone();
1177                        let context = compactor_context.clone();
1178                        let consumed_latency_ms = SystemTime::now()
1179                            .duration_since(std::time::UNIX_EPOCH)
1180                            .expect("Clock may have gone backwards")
1181                            .as_millis() as u64
1182                            - create_at;
1183                        context
1184                            .compactor_metrics
1185                            .compaction_event_consumed_latency
1186                            .observe(consumed_latency_ms as _);
1187
1188                        let object_id_manager = object_id_manager.clone();
1189                        let compaction_catalog_manager_ref = compaction_catalog_manager_ref.clone();
1190
1191                        match event {
1192                            ResponseEvent::CompactTask(compact_task) => {
1193                                let compact_task = CompactTask::from(compact_task);
1194                                let parallelism =
1195                                    calculate_task_parallelism(&compact_task, &context);
1196
1197                                assert_ne!(parallelism, 0, "splits cannot be empty");
1198
1199                                if (max_task_parallelism
1200                                    - running_task_parallelism.load(Ordering::SeqCst))
1201                                    < parallelism as u32
1202                                {
1203                                    tracing::warn!(
1204                                        "Not enough core parallelism to serve the task {} task_parallelism {} running_task_parallelism {} max_task_parallelism {}",
1205                                        compact_task.task_id,
1206                                        parallelism,
1207                                        max_task_parallelism,
1208                                        running_task_parallelism.load(Ordering::Relaxed),
1209                                    );
1210                                    let (compact_task, table_stats, object_timestamps) =
1211                                        compact_done(
1212                                            compact_task,
1213                                            context.clone(),
1214                                            vec![],
1215                                            TaskStatus::NoAvailCpuResourceCanceled,
1216                                        );
1217
1218                                    send_report_task_event(
1219                                        &compact_task,
1220                                        table_stats,
1221                                        object_timestamps,
1222                                        &request_sender,
1223                                    );
1224
1225                                    continue 'consume_stream;
1226                                }
1227
1228                                running_task_parallelism
1229                                    .fetch_add(parallelism as u32, Ordering::SeqCst);
1230                                executor.spawn(async move {
1231                                    let (tx, rx) = tokio::sync::oneshot::channel();
1232                                    let task_id = compact_task.task_id;
1233                                    shutdown.lock().unwrap().insert(task_id, tx);
1234
1235                                    let (
1236                                        (compact_task, table_stats, object_timestamps),
1237                                        _memory_tracker,
1238                                    ) = compactor_runner::compact(
1239                                        context.clone(),
1240                                        compact_task,
1241                                        rx,
1242                                        object_id_manager.clone(),
1243                                        compaction_catalog_manager_ref.clone(),
1244                                    )
1245                                    .await;
1246
1247                                    shutdown.lock().unwrap().remove(&task_id);
1248                                    running_task_parallelism
1249                                        .fetch_sub(parallelism as u32, Ordering::SeqCst);
1250
1251                                    send_report_task_event(
1252                                        &compact_task,
1253                                        table_stats,
1254                                        object_timestamps,
1255                                        &request_sender,
1256                                    );
1257
1258                                    let enable_check_compaction_result =
1259                                        context.storage_opts.check_compaction_result;
1260                                    let need_check_task =
1261                                        !compact_task.sorted_output_ssts.is_empty()
1262                                            && compact_task.task_status == TaskStatus::Success;
1263
1264                                    if enable_check_compaction_result && need_check_task {
1265                                        let read_table_ids = compact_task
1266                                            .get_table_ids_from_input_ssts()
1267                                            .collect::<Vec<_>>();
1268                                        match compaction_catalog_manager_ref.acquire(read_table_ids).await {
1269                                            Ok(compaction_catalog_agent_ref) =>  {
1270                                                match check_compaction_result(&compact_task, context.clone(), compaction_catalog_agent_ref).await
1271                                                {
1272                                                    Err(e) => {
1273                                                        tracing::warn!(error = %e.as_report(), "Failed to check compaction task {}",compact_task.task_id);
1274                                                    }
1275                                                    Ok(true) => (),
1276                                                    Ok(false) => {
1277                                                        panic!("Failed to pass consistency check for result of compaction task:\n{:?}", compact_task_to_string(&compact_task));
1278                                                    }
1279                                                }
1280                                            },
1281                                            Err(e) => {
1282                                                tracing::warn!(error = %e.as_report(), "failed to acquire compaction catalog agent");
1283                                            }
1284                                        }
1285                                    }
1286                                });
1287                            }
1288                            #[expect(deprecated)]
1289                            ResponseEvent::VacuumTask(_) => {
1290                                unreachable!("unexpected vacuum task");
1291                            }
1292                            #[expect(deprecated)]
1293                            ResponseEvent::FullScanTask(_) => {
1294                                unreachable!("unexpected scan task");
1295                            }
1296                            #[expect(deprecated)]
1297                            ResponseEvent::ValidationTask(validation_task) => {
1298                                let validation_task = ValidationTask::from(validation_task);
1299                                executor.spawn(async move {
1300                                    validate_ssts(validation_task, context.sstable_store.clone())
1301                                        .await;
1302                                });
1303                            }
1304                            ResponseEvent::CancelCompactTask(cancel_compact_task) => match shutdown
1305                                .lock()
1306                                .unwrap()
1307                                .remove(&cancel_compact_task.task_id)
1308                            {
1309                                Some(tx) => {
1310                                    if tx.send(()).is_err() {
1311                                        tracing::warn!(
1312                                            "Cancellation of compaction task failed. task_id: {}",
1313                                            cancel_compact_task.task_id
1314                                        );
1315                                    }
1316                                }
1317                                _ => {
1318                                    tracing::warn!(
1319                                        "Attempting to cancel non-existent compaction task. task_id: {}",
1320                                        cancel_compact_task.task_id
1321                                    );
1322                                }
1323                            },
1324
1325                            ResponseEvent::PullTaskAck(_pull_task_ack) => {
1326                                // set flag
1327                                pull_task_ack = true;
1328                            }
1329                        }
1330                    }
1331                    Some(Err(e)) => {
1332                        tracing::warn!("Failed to consume stream. {}", e.message());
1333                        continue 'start_stream;
1334                    }
1335                    _ => {
1336                        // The stream is exhausted
1337                        continue 'start_stream;
1338                    }
1339                }
1340            }
1341        }
1342    });
1343
1344    (join_handle, shutdown_tx)
1345}
1346
1347/// The background compaction thread that receives compaction tasks from hummock compaction
1348/// manager and runs compaction tasks.
1349#[must_use]
1350pub fn start_shared_compactor(
1351    grpc_proxy_client: GrpcCompactorProxyClient,
1352    mut receiver: mpsc::UnboundedReceiver<Request<DispatchCompactionTaskRequest>>,
1353    context: CompactorContext,
1354) -> (JoinHandle<()>, Sender<()>) {
1355    type CompactionShutdownMap = Arc<Mutex<HashMap<u64, Sender<()>>>>;
1356    let task_progress = context.task_progress_manager.clone();
1357    let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
1358    let periodic_event_update_interval = Duration::from_millis(1000);
1359
1360    let join_handle = tokio::spawn(async move {
1361        let shutdown_map = CompactionShutdownMap::default();
1362
1363        let mut periodic_event_interval = tokio::time::interval(periodic_event_update_interval);
1364        let executor = context.compaction_executor.clone();
1365        let report_heartbeat_client = grpc_proxy_client.clone();
1366        'consume_stream: loop {
1367            let request: Option<Request<DispatchCompactionTaskRequest>> = tokio::select! {
1368                _ = periodic_event_interval.tick() => {
1369                    let progress_list = get_task_progress(task_progress.clone());
1370                    let report_compaction_task_request = ReportCompactionTaskRequest{
1371                        event: Some(ReportCompactionTaskEvent::HeartBeat(
1372                            SharedHeartBeat {
1373                                progress: progress_list
1374                            }
1375                        )),
1376                     };
1377                    if let Err(e) = report_heartbeat_client.report_compaction_task(report_compaction_task_request).await{
1378                        tracing::warn!(error = %e.as_report(), "Failed to report heartbeat");
1379                    }
1380                    continue
1381                }
1382
1383
1384                _ = &mut shutdown_rx => {
1385                    tracing::info!("Compactor is shutting down");
1386                    return
1387                }
1388
1389                request = receiver.recv() => {
1390                    request
1391                }
1392
1393            };
1394            match request {
1395                Some(request) => {
1396                    let context = context.clone();
1397                    let shutdown = shutdown_map.clone();
1398
1399                    let cloned_grpc_proxy_client = grpc_proxy_client.clone();
1400                    executor.spawn(async move {
1401                        let DispatchCompactionTaskRequest {
1402                            tables,
1403                            output_object_ids,
1404                            task: dispatch_task,
1405                        } = request.into_inner();
1406                        let table_id_to_catalog = tables.into_iter().fold(HashMap::new(), |mut acc, table| {
1407                            acc.insert(table.id, table);
1408                            acc
1409                        });
1410
1411                        let mut output_object_ids_deque: VecDeque<_> = VecDeque::new();
1412                        output_object_ids_deque.extend(output_object_ids.into_iter().map(Into::<HummockSstableObjectId>::into));
1413                        let shared_compactor_object_id_manager =
1414                            SharedComapctorObjectIdManager::new(output_object_ids_deque, cloned_grpc_proxy_client.clone(), context.storage_opts.sstable_id_remote_fetch_number);
1415                            match dispatch_task.unwrap() {
1416                                dispatch_compaction_task_request::Task::CompactTask(compact_task) => {
1417                                    let compact_task = CompactTask::from(&compact_task);
1418                                    let (tx, rx) = tokio::sync::oneshot::channel();
1419                                    let task_id = compact_task.task_id;
1420                                    shutdown.lock().unwrap().insert(task_id, tx);
1421
1422                                    let compaction_catalog_manager_ref =
1423                                        Arc::new(CompactionCatalogManager::new_preloaded(
1424                                            table_id_to_catalog,
1425                                        ));
1426                                    let ((compact_task, table_stats, object_timestamps), _memory_tracker)= compactor_runner::compact(
1427                                        context.clone(),
1428                                        compact_task,
1429                                        rx,
1430                                        shared_compactor_object_id_manager,
1431                                        compaction_catalog_manager_ref.clone(),
1432                                    )
1433                                    .await;
1434                                    shutdown.lock().unwrap().remove(&task_id);
1435                                    let report_compaction_task_request = ReportCompactionTaskRequest {
1436                                        event: Some(ReportCompactionTaskEvent::ReportTask(ReportSharedTask {
1437                                            // Keep the existing wire format, but only send the task fields consumed by the proxy.
1438                                            compact_task: Some(PbCompactTask {
1439                                                task_id: compact_task.task_id,
1440                                                task_status: compact_task.task_status.into(),
1441                                                sorted_output_ssts: compact_task
1442                                                    .sorted_output_ssts
1443                                                    .iter()
1444                                                    .map(|sst| sst.into())
1445                                                    .collect(),
1446                                                ..Default::default()
1447                                            }),
1448                                            table_stats_change: to_prost_table_stats_map(table_stats),
1449                                            object_timestamps,
1450                                    })),
1451                                    };
1452
1453                                    match cloned_grpc_proxy_client
1454                                        .report_compaction_task(report_compaction_task_request)
1455                                        .await
1456                                    {
1457                                        Ok(_) => {
1458                                            // TODO: remove this method after we have running risingwave cluster with fast compact algorithm stably for a long time.
1459                                            let enable_check_compaction_result =
1460                                                context.storage_opts.check_compaction_result;
1461                                            let need_check_task =
1462                                                !compact_task.sorted_output_ssts.is_empty()
1463                                                    && compact_task.task_status
1464                                                        == TaskStatus::Success;
1465                                            if enable_check_compaction_result && need_check_task {
1466                                                let read_table_ids = compact_task
1467                                                    .get_table_ids_from_input_ssts()
1468                                                    .collect::<Vec<_>>();
1469                                                match compaction_catalog_manager_ref.acquire(read_table_ids).await {
1470                                                    Ok(compaction_catalog_agent_ref) => {
1471                                                        match check_compaction_result(&compact_task, context.clone(), compaction_catalog_agent_ref).await
1472                                                        {
1473                                                            Err(e) => {
1474                                                                tracing::warn!(error = %e.as_report(), "Failed to check compaction task {}", task_id);
1475                                                            }
1476                                                            Ok(true) => (),
1477                                                            Ok(false) => {
1478                                                                panic!("Failed to pass consistency check for result of compaction task:\n{:?}", compact_task_to_string(&compact_task));
1479                                                            }
1480                                                        }
1481                                                    }
1482                                                    Err(e) => {
1483                                                        tracing::warn!(error = %e.as_report(), "failed to acquire compaction catalog agent");
1484                                                    }
1485                                                }
1486                                            }
1487                                        }
1488                                        Err(e) => tracing::warn!(error = %e.as_report(), "Failed to report task {task_id:?}"),
1489                                    }
1490
1491                                }
1492                                dispatch_compaction_task_request::Task::VacuumTask(_) => {
1493                                    unreachable!("unexpected vacuum task");
1494                                }
1495                                dispatch_compaction_task_request::Task::FullScanTask(_) => {
1496                                    unreachable!("unexpected scan task");
1497                                }
1498                                dispatch_compaction_task_request::Task::ValidationTask(validation_task) => {
1499                                    let validation_task = ValidationTask::from(validation_task);
1500                                    validate_ssts(validation_task, context.sstable_store.clone()).await;
1501                                }
1502                                dispatch_compaction_task_request::Task::CancelCompactTask(cancel_compact_task) => {
1503                                    match shutdown
1504                                        .lock()
1505                                        .unwrap()
1506                                        .remove(&cancel_compact_task.task_id)
1507                                    { Some(tx) => {
1508                                        if tx.send(()).is_err() {
1509                                            tracing::warn!(
1510                                                "Cancellation of compaction task failed. task_id: {}",
1511                                                cancel_compact_task.task_id
1512                                            );
1513                                        }
1514                                    } _ => {
1515                                        tracing::warn!(
1516                                            "Attempting to cancel non-existent compaction task. task_id: {}",
1517                                            cancel_compact_task.task_id
1518                                        );
1519                                    }}
1520                                }
1521                            }
1522                    });
1523                }
1524                None => continue 'consume_stream,
1525            }
1526        }
1527    });
1528    (join_handle, shutdown_tx)
1529}
1530
1531fn get_task_progress(
1532    task_progress: Arc<
1533        parking_lot::lock_api::Mutex<parking_lot::RawMutex, HashMap<u64, Arc<TaskProgress>>>,
1534    >,
1535) -> Vec<CompactTaskProgress> {
1536    let mut progress_list = Vec::new();
1537    for (&task_id, progress) in &*task_progress.lock() {
1538        progress_list.push(progress.snapshot(task_id));
1539    }
1540    progress_list
1541}
1542
1543/// Schedule queued tasks if we have capacity
1544fn schedule_queued_tasks(
1545    task_queue: &mut IcebergTaskQueue,
1546    compactor_context: &CompactorContext,
1547    shutdown_map: &Arc<Mutex<HashMap<TaskKey, Sender<()>>>>,
1548    task_completion_tx: &tokio::sync::mpsc::UnboundedSender<IcebergPlanCompletion>,
1549) {
1550    while let Some(popped_task) = task_queue.pop() {
1551        let task_id = popped_task.meta.task_id;
1552        let plan_index = popped_task.meta.plan_index;
1553        let task_key = (task_id, plan_index);
1554
1555        // Get unique_ident before moving runner
1556        let unique_ident = popped_task.runner.as_ref().map(|r| r.unique_ident());
1557
1558        let Some(runner) = popped_task.runner else {
1559            tracing::error!(
1560                iceberg_component = "compaction_worker",
1561                iceberg_operation = "schedule_plan",
1562                task_id = %task_id,
1563                plan_index = plan_index,
1564                "iceberg_compaction_plan_missing_runner",
1565            );
1566            task_queue.finish_running(task_key);
1567            continue;
1568        };
1569        let runner_sink_id = runner.sink_id;
1570        let runner_compaction_kind = runner.compaction_kind();
1571        let runner_table = runner.table_ident.to_string();
1572
1573        let executor = compactor_context.compaction_executor.clone();
1574        let shutdown_map_clone = shutdown_map.clone();
1575        let completion_tx_clone = task_completion_tx.clone();
1576        let (tx, rx) = tokio::sync::oneshot::channel();
1577
1578        {
1579            let mut shutdown_guard = shutdown_map.lock().unwrap();
1580            shutdown_guard.insert(task_key, tx);
1581        }
1582
1583        tracing::info!(
1584            iceberg_component = "compaction_worker",
1585            iceberg_operation = "schedule_plan",
1586            task_id = %task_id,
1587            sink_id = runner_sink_id,
1588            plan_index = plan_index,
1589            task_type = ?runner_compaction_kind,
1590            table = %runner_table,
1591            unique_ident = ?unique_ident,
1592            required_parallelism = popped_task.meta.required_parallelism,
1593            "iceberg_compaction_plan_started_from_queue",
1594        );
1595
1596        executor.spawn(async move {
1597            let _cleanup_guard = scopeguard::guard(shutdown_map_clone, move |shutdown_map| {
1598                let mut shutdown_guard = shutdown_map.lock().unwrap();
1599                shutdown_guard.remove(&task_key);
1600            });
1601
1602            let result = Box::pin(runner.compact(rx)).await;
1603
1604            let completion = match result {
1605                Ok((_stats, pk_index_result)) => IcebergPlanCompletion {
1606                    task_key,
1607                    error_message: None,
1608                    pk_index_result,
1609                },
1610                Err(e) => {
1611                    if is_cancelled_iceberg_compaction_error(&e) {
1612                        tracing::info!(
1613                            iceberg_component = "compaction_worker",
1614                            iceberg_operation = "execute_plan",
1615                            task_id = %task_key.0,
1616                            sink_id = runner_sink_id,
1617                            plan_index = task_key.1,
1618                            task_type = ?runner_compaction_kind,
1619                            table = %runner_table,
1620                            "iceberg_compaction_plan_cancelled",
1621                        );
1622                    } else {
1623                        tracing::warn!(
1624                            iceberg_component = "compaction_worker",
1625                            iceberg_operation = "execute_plan",
1626                            error = %e.as_report(),
1627                            task_id = %task_key.0,
1628                            sink_id = runner_sink_id,
1629                            plan_index = task_key.1,
1630                            task_type = ?runner_compaction_kind,
1631                            table = %runner_table,
1632                            "iceberg_compaction_plan_failed",
1633                        );
1634                    }
1635                    IcebergPlanCompletion {
1636                        task_key,
1637                        error_message: Some(e.to_report_string()),
1638                        pk_index_result: None,
1639                    }
1640                }
1641            };
1642
1643            if completion_tx_clone.send(completion).is_err() {
1644                tracing::warn!(
1645                    iceberg_component = "compaction_worker",
1646                    iceberg_operation = "notify_plan_completion",
1647                    task_id = %task_key.0,
1648                    sink_id = runner_sink_id,
1649                    plan_index = task_key.1,
1650                    task_type = ?runner_compaction_kind,
1651                    table = %runner_table,
1652                    "iceberg_compaction_plan_completion_send_failed",
1653                );
1654            }
1655        });
1656    }
1657}
1658
1659/// The task queue rejects any plan whose parallelism exceeds `max_task_parallelism`, so planning
1660/// above it would make every plan with more input files than that impossible to run.
1661fn iceberg_compaction_max_plan_parallelism(
1662    worker_num: usize,
1663    task_parallelism_ratio: f32,
1664    max_task_parallelism: u32,
1665) -> u32 {
1666    ((worker_num as f32 * task_parallelism_ratio) as u32).min(max_task_parallelism)
1667}
1668
1669fn is_cancelled_iceberg_compaction_error(error: &crate::hummock::HummockError) -> bool {
1670    matches!(
1671        error.inner(),
1672        HummockErrorInner::CompactionExecutor(message) if message == "Plan cancelled"
1673    )
1674}
1675
1676fn cancel_iceberg_task(
1677    task_id: IcebergCompactionTaskId,
1678    task_queue: &mut IcebergTaskQueue,
1679    shutdown_map: &Arc<Mutex<HashMap<TaskKey, Sender<()>>>>,
1680    task_trackers: &mut HashMap<IcebergCompactionTaskId, IcebergTaskTracker>,
1681) {
1682    // Meta assigns one task id to an Iceberg compact task, but the compactor
1683    // splits it into multiple plan runners tracked by `(task_id, plan_index)`.
1684    // A cancel event only carries `task_id`, so it must cancel all waiting and
1685    // running plan runners that belong to that task.
1686    let cancelled_waiting = task_queue.cancel_waiting_task(task_id);
1687    let removed_tracker = task_trackers.remove(&task_id).is_some();
1688
1689    let cancelled_running = {
1690        let mut shutdown_guard = shutdown_map.lock().unwrap();
1691        let task_keys: Vec<_> = shutdown_guard
1692            .keys()
1693            .filter(|(running_task_id, _)| *running_task_id == task_id)
1694            .copied()
1695            .collect();
1696
1697        for task_key in &task_keys {
1698            if let Some(tx) = shutdown_guard.remove(task_key)
1699                && tx.send(()).is_err()
1700            {
1701                tracing::debug!(
1702                    task_id = %task_key.0,
1703                    plan_index = task_key.1,
1704                    "Iceberg compaction plan shutdown receiver already closed during cancellation"
1705                );
1706            }
1707        }
1708
1709        task_keys.len()
1710    };
1711
1712    if cancelled_waiting == 0 && cancelled_running == 0 && !removed_tracker {
1713        tracing::warn!(
1714            task_id = %task_id,
1715            "Attempting to cancel non-existent iceberg compaction task"
1716        );
1717    } else {
1718        tracing::info!(
1719            task_id = %task_id,
1720            cancelled_waiting = cancelled_waiting,
1721            cancelled_running = cancelled_running,
1722            removed_tracker = removed_tracker,
1723            "Cancelled iceberg compaction task"
1724        );
1725    }
1726}
1727
1728/// Handle pulling new tasks from meta service
1729/// Returns true if the stream should be restarted
1730fn handle_meta_task_pulling(
1731    pull_task_ack: &mut bool,
1732    task_queue: &IcebergTaskQueue,
1733    max_task_parallelism: u32,
1734    max_pull_task_count: u32,
1735    request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
1736    log_throttler: &mut LogThrottler<IcebergCompactionLogState>,
1737) -> bool {
1738    let mut pending_pull_task_count = 0;
1739    if *pull_task_ack {
1740        // Treat both running and queued plans as reserved capacity. The waiting budget can make
1741        // this exceed `max_task_parallelism`; saturating subtraction then returns 0 to stop this
1742        // backlogged worker from pulling more tasks. The final `min` only caps the request batch
1743        // size (to 1 by default).
1744        let current_reserved_parallelism = task_queue
1745            .running_parallelism_sum()
1746            .saturating_add(task_queue.waiting_parallelism_sum());
1747        pending_pull_task_count = max_task_parallelism
1748            .saturating_sub(current_reserved_parallelism)
1749            .min(max_pull_task_count);
1750
1751        if pending_pull_task_count > 0 {
1752            if let Err(e) = request_sender.send(SubscribeIcebergCompactionEventRequest {
1753                event: Some(subscribe_iceberg_compaction_event_request::Event::PullTask(
1754                    subscribe_iceberg_compaction_event_request::PullTask {
1755                        pull_task_count: pending_pull_task_count,
1756                    },
1757                )),
1758                create_at: SystemTime::now()
1759                    .duration_since(std::time::UNIX_EPOCH)
1760                    .expect("Clock may have gone backwards")
1761                    .as_millis() as u64,
1762            }) {
1763                tracing::warn!(error = %e.as_report(), "Failed to pull task - will retry on stream restart");
1764                return true; // Signal to restart stream
1765            } else {
1766                *pull_task_ack = false;
1767            }
1768        }
1769    }
1770
1771    let running_count = task_queue.running_parallelism_sum();
1772    let waiting_count = task_queue.waiting_parallelism_sum();
1773    let available_count = max_task_parallelism.saturating_sub(running_count);
1774    let current_state = IcebergCompactionLogState {
1775        running_parallelism: running_count,
1776        waiting_parallelism: waiting_count,
1777        available_parallelism: available_count,
1778        pull_task_ack: *pull_task_ack,
1779        pending_pull_task_count,
1780    };
1781
1782    // Log only when state changes or periodically as heartbeat
1783    if log_throttler.should_log(&current_state) {
1784        tracing::info!(
1785            running_parallelism_count = %current_state.running_parallelism,
1786            waiting_parallelism_count = %current_state.waiting_parallelism,
1787            available_parallelism = %current_state.available_parallelism,
1788            pull_task_ack = %current_state.pull_task_ack,
1789            pending_pull_task_count = %current_state.pending_pull_task_count
1790        );
1791        log_throttler.update(current_state);
1792    }
1793
1794    false // No need to restart stream
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800
1801    #[test]
1802    fn test_iceberg_compaction_max_plan_parallelism_fits_worker_capacity() {
1803        // Default ratio 4.0 with default multiplier 3.0 on 8 workers.
1804        assert_eq!(iceberg_compaction_max_plan_parallelism(8, 4.0, 24), 24);
1805        assert_eq!(iceberg_compaction_max_plan_parallelism(8, 2.0, 24), 16);
1806        assert_eq!(iceberg_compaction_max_plan_parallelism(8, 1.0, 8), 8);
1807    }
1808
1809    #[test]
1810    fn test_cancel_iceberg_task_removes_waiting_plans_and_tracker() {
1811        let task_id = IcebergCompactionTaskId::new(42);
1812        let mut task_queue = IcebergTaskQueue::new(10, 30, 1024);
1813        assert_eq!(
1814            task_queue.push(
1815                iceberg_compaction::IcebergTaskMeta {
1816                    task_id,
1817                    plan_index: 0,
1818                    required_parallelism: 3,
1819                    memory_reservation_bytes: 64,
1820                },
1821                None,
1822            ),
1823            PushResult::Added
1824        );
1825        assert_eq!(
1826            task_queue.push(
1827                iceberg_compaction::IcebergTaskMeta {
1828                    task_id,
1829                    plan_index: 1,
1830                    required_parallelism: 4,
1831                    memory_reservation_bytes: 64,
1832                },
1833                None,
1834            ),
1835            PushResult::Added
1836        );
1837        assert_eq!(task_queue.waiting_parallelism_sum(), 7);
1838
1839        let shutdown_map = Arc::new(Mutex::new(HashMap::new()));
1840        let mut task_trackers =
1841            HashMap::from([(task_id, IcebergTaskTracker::new(10, 2, false, false))]);
1842
1843        cancel_iceberg_task(task_id, &mut task_queue, &shutdown_map, &mut task_trackers);
1844
1845        assert_eq!(task_queue.waiting_parallelism_sum(), 0);
1846        assert!(!task_trackers.contains_key(&task_id));
1847    }
1848
1849    #[test]
1850    fn test_cancel_iceberg_task_shuts_down_running_plans_and_tracker() {
1851        let task_id = IcebergCompactionTaskId::new(43);
1852        let task_key = (task_id, 0);
1853        let mut task_queue = IcebergTaskQueue::new(10, 30, 1024);
1854        let shutdown_map = Arc::new(Mutex::new(HashMap::new()));
1855        let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
1856        shutdown_map.lock().unwrap().insert(task_key, shutdown_tx);
1857        let mut task_trackers =
1858            HashMap::from([(task_id, IcebergTaskTracker::new(10, 1, false, false))]);
1859
1860        cancel_iceberg_task(task_id, &mut task_queue, &shutdown_map, &mut task_trackers);
1861
1862        assert!(shutdown_rx.try_recv().is_ok());
1863        assert!(shutdown_map.lock().unwrap().is_empty());
1864        assert!(!task_trackers.contains_key(&task_id));
1865    }
1866
1867    #[test]
1868    fn test_log_state_equality() {
1869        // Test CompactionLogState
1870        let state1 = CompactionLogState {
1871            running_parallelism: 10,
1872            pull_task_ack: true,
1873            pending_pull_task_count: 2,
1874        };
1875        let state2 = CompactionLogState {
1876            running_parallelism: 10,
1877            pull_task_ack: true,
1878            pending_pull_task_count: 2,
1879        };
1880        let state3 = CompactionLogState {
1881            running_parallelism: 11,
1882            pull_task_ack: true,
1883            pending_pull_task_count: 2,
1884        };
1885        assert_eq!(state1, state2);
1886        assert_ne!(state1, state3);
1887
1888        // Test IcebergCompactionLogState
1889        let ice_state1 = IcebergCompactionLogState {
1890            running_parallelism: 10,
1891            waiting_parallelism: 5,
1892            available_parallelism: 15,
1893            pull_task_ack: true,
1894            pending_pull_task_count: 2,
1895        };
1896        let ice_state2 = IcebergCompactionLogState {
1897            running_parallelism: 10,
1898            waiting_parallelism: 6,
1899            available_parallelism: 15,
1900            pull_task_ack: true,
1901            pending_pull_task_count: 2,
1902        };
1903        assert_ne!(ice_state1, ice_state2);
1904    }
1905
1906    #[test]
1907    fn test_log_throttler_state_change_detection() {
1908        let mut throttler = LogThrottler::<CompactionLogState>::new(Duration::from_secs(60));
1909        let state1 = CompactionLogState {
1910            running_parallelism: 10,
1911            pull_task_ack: true,
1912            pending_pull_task_count: 2,
1913        };
1914        let state2 = CompactionLogState {
1915            running_parallelism: 11,
1916            pull_task_ack: true,
1917            pending_pull_task_count: 2,
1918        };
1919
1920        // First call should always log
1921        assert!(throttler.should_log(&state1));
1922        throttler.update(state1.clone());
1923
1924        // Same state should not log
1925        assert!(!throttler.should_log(&state1));
1926
1927        // Changed state should log
1928        assert!(throttler.should_log(&state2));
1929        throttler.update(state2.clone());
1930
1931        // Same state again should not log
1932        assert!(!throttler.should_log(&state2));
1933    }
1934
1935    #[test]
1936    fn test_log_throttler_heartbeat() {
1937        let mut throttler = LogThrottler::<CompactionLogState>::new(Duration::from_millis(10));
1938        let state = CompactionLogState {
1939            running_parallelism: 10,
1940            pull_task_ack: true,
1941            pending_pull_task_count: 2,
1942        };
1943
1944        // First call should log
1945        assert!(throttler.should_log(&state));
1946        throttler.update(state.clone());
1947
1948        // Same state immediately should not log
1949        assert!(!throttler.should_log(&state));
1950
1951        // Wait for heartbeat interval to pass
1952        std::thread::sleep(Duration::from_millis(15));
1953
1954        // Same state after interval should log (heartbeat)
1955        assert!(throttler.should_log(&state));
1956    }
1957}