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_pull_task_count = std::cmp::min(
420        max_task_parallelism,
421        compactor_context
422            .storage_opts
423            .iceberg_compaction_max_pull_task_count,
424    );
425
426    assert_ge!(
427        compactor_context.storage_opts.compactor_max_task_multiplier,
428        0.0
429    );
430
431    let join_handle = tokio::spawn(async move {
432        // Initialize task queue with event-driven scheduling
433        let pending_parallelism_budget = (max_task_parallelism as f32
434            * compactor_context
435                .storage_opts
436                .iceberg_compaction_pending_parallelism_budget_multiplier)
437            .ceil() as u32;
438        let mut task_queue = IcebergTaskQueue::new_with_metrics(
439            max_task_parallelism,
440            pending_parallelism_budget,
441            iceberg_compaction_memory_limit_bytes,
442            compactor_context.compactor_metrics.clone(),
443        );
444
445        // Shutdown tracking for running tasks (task_key -> shutdown_sender)
446        let shutdown_map = Arc::new(Mutex::new(HashMap::<TaskKey, Sender<()>>::new()));
447
448        // Channel for task completion notifications
449        let (task_completion_tx, mut task_completion_rx) =
450            tokio::sync::mpsc::unbounded_channel::<IcebergPlanCompletion>();
451        let mut task_trackers = HashMap::<IcebergCompactionTaskId, IcebergTaskTracker>::new();
452        // Buffers task reports that failed to send on the current stream.
453        // The queue is flushed in FIFO order after the stream reconnects.
454        let mut pending_task_reports = VecDeque::<IcebergTaskReport>::new();
455
456        let mut min_interval = tokio::time::interval(stream_retry_interval);
457        let mut periodic_event_interval = tokio::time::interval(periodic_event_update_interval);
458
459        // Track last logged state to avoid duplicate logs
460        let mut log_throttler =
461            LogThrottler::<IcebergCompactionLogState>::new(COMPACTION_HEARTBEAT_LOG_INTERVAL);
462
463        // This outer loop is to recreate stream.
464        'start_stream: loop {
465            // reset state
466            // pull_task_ack.store(true, Ordering::SeqCst);
467            let mut pull_task_ack = true;
468            tokio::select! {
469                // Wait for interval.
470                _ = min_interval.tick() => {},
471                // Shutdown compactor.
472                _ = &mut shutdown_rx => {
473                    tracing::info!("Compactor is shutting down");
474                    return;
475                }
476            }
477
478            let (request_sender, response_event_stream) = match hummock_meta_client
479                .subscribe_iceberg_compaction_event()
480                .await
481            {
482                Ok((request_sender, response_event_stream)) => {
483                    tracing::debug!("Succeeded subscribe_iceberg_compaction_event.");
484                    (request_sender, response_event_stream)
485                }
486
487                Err(e) => {
488                    tracing::warn!(
489                        error = %e.as_report(),
490                        "Subscribing to iceberg compaction tasks failed with error. Will retry.",
491                    );
492                    continue 'start_stream;
493                }
494            };
495
496            if matches!(
497                flush_pending_iceberg_task_reports(&request_sender, &mut pending_task_reports),
498                ReportSendResult::RestartStream
499            ) {
500                continue 'start_stream;
501            }
502
503            pin_mut!(response_event_stream);
504
505            let _executor = compactor_context.compaction_executor.clone();
506
507            // This inner loop is to consume stream or report task progress.
508            let mut event_loop_iteration_now = Instant::now();
509            'consume_stream: loop {
510                {
511                    // report
512                    compactor_context
513                        .compactor_metrics
514                        .compaction_event_loop_iteration_latency
515                        .observe(event_loop_iteration_now.elapsed().as_millis() as _);
516                    event_loop_iteration_now = Instant::now();
517                }
518
519                let request_sender = request_sender.clone();
520                let event: Option<Result<SubscribeIcebergCompactionEventResponse, _>> = tokio::select! {
521                    // Handle task completion notifications
522                    Some(plan_completion) = task_completion_rx.recv() => {
523                        let task_key = plan_completion.task_key;
524                        let error_message = plan_completion.error_message;
525                        let pk_index_result = plan_completion.pk_index_result;
526                        tracing::debug!(
527                            task_id = %task_key.0,
528                            plan_index = task_key.1,
529                            success = error_message.is_none(),
530                            "Plan completed, updating queue state"
531                        );
532                        task_queue.finish_running(task_key);
533
534                        let completed_task_id = task_key.0;
535                        let Entry::Occupied(mut tracker_entry) =
536                            task_trackers.entry(completed_task_id)
537                        else {
538                            continue 'consume_stream;
539                        };
540                        tracker_entry
541                            .get_mut()
542                            .record_completion(error_message, pk_index_result);
543                        if !tracker_entry.get().is_finished() {
544                            continue 'consume_stream;
545                        }
546
547                        let tracker = tracker_entry.remove();
548                        let sink_id = tracker.sink_id();
549                        let admitted_plans = tracker.admitted_plans();
550                        let successful_plans = tracker.successful_plans();
551                        let failed_plans = tracker.failed_plans();
552                        let report = tracker.into_report(completed_task_id);
553                        let task_succeeded = report.error_message.is_none();
554                        if matches!(
555                            send_or_buffer_iceberg_task_report(
556                                &request_sender,
557                                &mut pending_task_reports,
558                                report,
559                            ),
560                            ReportSendResult::RestartStream
561                        ) {
562                            continue 'start_stream;
563                        }
564                        if task_succeeded {
565                            tracing::info!(
566                                iceberg_component = "compaction_worker",
567                                iceberg_operation = "report_task",
568                                task_id = %completed_task_id,
569                                sink_id = sink_id,
570                                admitted_plans = admitted_plans,
571                                successful_plans = successful_plans,
572                                failed_plans = failed_plans,
573                                "iceberg_compaction_task_succeeded",
574                            );
575                        }
576                        continue 'consume_stream;
577                    }
578
579                    // Event-driven task scheduling - wait for tasks to become schedulable
580                    _ = task_queue.wait_schedulable() => {
581                        schedule_queued_tasks(
582                            &mut task_queue,
583                            &compactor_context,
584                            &shutdown_map,
585                            &task_completion_tx,
586                        );
587                        continue 'consume_stream;
588                    }
589
590                    _ = periodic_event_interval.tick() => {
591                        // Only handle meta task pulling in periodic tick
592                        let should_restart_stream = handle_meta_task_pulling(
593                            &mut pull_task_ack,
594                            &task_queue,
595                            max_task_parallelism,
596                            max_pull_task_count,
597                            &request_sender,
598                            &mut log_throttler,
599                        );
600
601                        if should_restart_stream {
602                            continue 'start_stream;
603                        }
604                        continue;
605                    }
606                    event = response_event_stream.next() => {
607                        event
608                    }
609
610                    _ = &mut shutdown_rx => {
611                        tracing::info!("Iceberg Compactor is shutting down");
612                        return
613                    }
614                };
615
616                match event {
617                    Some(Ok(SubscribeIcebergCompactionEventResponse {
618                        event,
619                        create_at: _create_at,
620                    })) => {
621                        let event = match event {
622                            Some(event) => event,
623                            None => continue 'consume_stream,
624                        };
625
626                        match event {
627                            risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event::CompactTask(iceberg_compaction_task) => {
628                                let task_id = iceberg_compaction_task.task_id;
629                                let sink_id = iceberg_compaction_task.sink_id;
630                                let is_bounded_round = iceberg_compaction_task.max_file_sequence_number.is_some();
631                                let pk_index_coordinated = iceberg_compaction_task.pk_index_coordinated;
632                                // iceberg-rust internally spawns on native Tokio, which is not
633                                // available inside a madsim node. Tests can replace only the
634                                // rewrite result while retaining the real compactor stream and
635                                // all downstream resolver/commit/recovery behavior.
636                                #[cfg(madsim)]
637                                if iceberg_compaction_task.pk_index_coordinated
638                                    && fail::eval(
639                                        "iceberg_pk_index_simulated_compaction_report",
640                                        |_| (),
641                                    )
642                                    .is_some()
643                                {
644                                    // Consume each rewrite once. Later bounded probes report
645                                    // no remaining plans instead of reapplying stale inputs.
646                                    let result = simulated_pk_index_compaction_result();
647                                    let mut report = if result.is_none() && is_bounded_round {
648                                        build_drained_iceberg_task_report(task_id, sink_id)
649                                    } else {
650                                        build_iceberg_task_report(task_id, sink_id, None)
651                                    };
652                                    report.pk_index_result = result;
653                                    if matches!(
654                                        send_or_buffer_iceberg_task_report(
655                                            &request_sender,
656                                            &mut pending_task_reports,
657                                            report,
658                                        ),
659                                        ReportSendResult::RestartStream
660                                    ) {
661                                        continue 'start_stream;
662                                    }
663                                    continue 'consume_stream;
664                                }
665                                // Note: write_parquet_properties is now built from sink config (IcebergConfig) in create_task_execution
666                                let compactor_runner_config = match IcebergCompactorRunnerConfigBuilder::default()
667                                    .max_parallelism((worker_num as f32 * compactor_context.storage_opts.iceberg_compaction_task_parallelism_ratio) as u32)
668                                    .min_size_per_partition(compactor_context.storage_opts.iceberg_compaction_min_size_per_partition_mb as u64 * 1024 * 1024)
669                                    .max_file_count_per_partition(compactor_context.storage_opts.iceberg_compaction_max_file_count_per_partition)
670                                    .enable_validate_compaction(compactor_context.storage_opts.iceberg_compaction_enable_validate)
671                                    .max_record_batch_rows(compactor_context.storage_opts.iceberg_compaction_max_record_batch_rows)
672                                    .enable_heuristic_output_parallelism(compactor_context.storage_opts.iceberg_compaction_enable_heuristic_output_parallelism)
673                                    .max_concurrent_closes(compactor_context.storage_opts.iceberg_compaction_max_concurrent_closes)
674                                    .enable_prefetch(compactor_context.storage_opts.iceberg_compaction_enable_prefetch)
675                                    .target_binpack_group_size_mb(
676                                        compactor_context.storage_opts.iceberg_compaction_target_binpack_group_size_mb
677                                    )
678                                    .min_group_size_mb(
679                                        compactor_context.storage_opts.iceberg_compaction_min_group_size_mb
680                                    )
681                                    .min_group_file_count(
682                                        compactor_context.storage_opts.iceberg_compaction_min_group_file_count
683                                    )
684                                    .build() {
685                                    Ok(config) => config,
686                                    Err(e) => {
687                                        tracing::warn!(
688                                            iceberg_component = "compaction_worker",
689                                            iceberg_operation = "build_runner_config",
690                                            error = %e.as_report(),
691                                            task_id = %task_id,
692                                            sink_id = sink_id,
693                                            "iceberg_compaction_runner_config_failed",
694                                        );
695                                        let report = build_iceberg_task_report(
696                                            task_id,
697                                            sink_id,
698                                            Some(format!(
699                                                "Failed to build iceberg compactor runner config: {}",
700                                                e.as_report()
701                                            )),
702                                        );
703                                        if matches!(
704                                            send_or_buffer_iceberg_task_report(
705                                                &request_sender,
706                                                &mut pending_task_reports,
707                                                report,
708                                            ),
709                                            ReportSendResult::RestartStream
710                                        ) {
711                                            continue 'start_stream;
712                                        }
713                                        continue 'consume_stream;
714                                    }
715                                };
716
717                                // Create task execution context and plan runners from the task
718                                let task_execution = match create_task_execution(
719                                    iceberg_compaction_task,
720                                    compactor_runner_config,
721                                    compactor_context.compactor_metrics.clone(),
722                                ).await {
723                                    Ok(task_execution) => task_execution,
724                                    Err(e) => {
725                                        tracing::warn!(
726                                            iceberg_component = "compaction_worker",
727                                            iceberg_operation = "plan_task",
728                                            error = %e.as_report(),
729                                            task_id = %task_id,
730                                            sink_id = sink_id,
731                                            "iceberg_compaction_task_plan_failed",
732                                        );
733                                        let report = build_iceberg_task_report(
734                                            task_id,
735                                            sink_id,
736                                            Some(format!(
737                                                "Failed to create iceberg compaction task execution: {}",
738                                                e.as_report()
739                                            )),
740                                        );
741                                        if matches!(
742                                            send_or_buffer_iceberg_task_report(
743                                                &request_sender,
744                                                &mut pending_task_reports,
745                                                report,
746                                            ),
747                                            ReportSendResult::RestartStream
748                                        ) {
749                                            continue 'start_stream;
750                                        }
751                                        continue 'consume_stream;
752                                    }
753                                };
754
755                                let (sink_id, plan_runners) = match task_execution {
756                                    IcebergTaskExecution::Ready { sink_id, plan_runners } => {
757                                        (sink_id, plan_runners)
758                                    }
759                                    IcebergTaskExecution::Blocked { sink_id, status, reason } => {
760                                        let mut report = build_iceberg_task_report(task_id, sink_id, Some(reason));
761                                        report.status = status as i32;
762                                        if matches!(
763                                            send_or_buffer_iceberg_task_report(&request_sender, &mut pending_task_reports, report),
764                                            ReportSendResult::RestartStream
765                                        ) {
766                                            continue 'start_stream;
767                                        }
768                                        continue 'consume_stream;
769                                    }
770                                    IcebergTaskExecution::NoWork { sink_id } => {
771                                        // Only validated empty planning proves a bounded round drained.
772                                        let report = if is_bounded_round {
773                                            build_drained_iceberg_task_report(task_id, sink_id)
774                                        } else {
775                                            build_iceberg_task_report(task_id, sink_id, None)
776                                        };
777                                        if matches!(
778                                            send_or_buffer_iceberg_task_report(
779                                                &request_sender,
780                                                &mut pending_task_reports,
781                                                report,
782                                            ),
783                                            ReportSendResult::RestartStream
784                                        ) {
785                                            continue 'start_stream;
786                                        }
787                                        continue 'consume_stream;
788                                    }
789                                };
790
791                                // Enqueue each plan runner independently
792                                let planned_count = plan_runners.len();
793                                let mut admitted_count = 0;
794                                let mut rejection_reason = None;
795                                let mut rejection_status = None;
796
797                                for runner in plan_runners {
798                                    let meta = runner.to_meta();
799                                    let plan_index = meta.plan_index;
800                                    let required_parallelism = runner.required_parallelism();
801                                    let memory_reservation_bytes = meta.memory_reservation_bytes;
802                                    let runner_sink_id = runner.sink_id;
803                                    let runner_compaction_kind = runner.compaction_kind();
804                                    let runner_table = runner.table_ident.to_string();
805                                    let push_result = task_queue.push(meta, Some(runner));
806
807                                    match push_result {
808                                        PushResult::Added => {
809                                            admitted_count += 1;
810                                            tracing::debug!(
811                                                iceberg_component = "compaction_worker",
812                                                iceberg_operation = "enqueue_plan",
813                                                task_id = %task_id,
814                                                sink_id = runner_sink_id,
815                                                plan_index = plan_index,
816                                                task_type = ?runner_compaction_kind,
817                                                table = %runner_table,
818                                                required_parallelism = required_parallelism,
819                                                "iceberg_compaction_plan_enqueued",
820                                            );
821                                        },
822                                        PushResult::RejectedCapacity => {
823                                            rejection_status = Some(risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::report_task::Status::Busy);
824                                            tracing::warn!(
825                                                iceberg_component = "compaction_worker",
826                                                iceberg_operation = "enqueue_plan",
827                                                task_id = %task_id,
828                                                sink_id = runner_sink_id,
829                                                plan_index = plan_index,
830                                                task_type = ?runner_compaction_kind,
831                                                table = %runner_table,
832                                                required_parallelism = required_parallelism,
833                                                pending_budget = pending_parallelism_budget,
834                                                admitted_count = admitted_count,
835                                                planned_count = planned_count,
836                                                "iceberg_compaction_plan_rejected_capacity",
837                                            );
838                                            // Stop enqueuing remaining plans
839                                            break;
840                                        },
841                                        PushResult::RejectedTooLarge => {
842                                            rejection_status = Some(risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::report_task::Status::BlockedCapacity);
843                                            rejection_reason.get_or_insert_with(|| {
844                                                format!(
845                                                    "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"
846                                                )
847                                            });
848                                            tracing::error!(
849                                                iceberg_component = "compaction_worker",
850                                                iceberg_operation = "enqueue_plan",
851                                                task_id = %task_id,
852                                                sink_id = runner_sink_id,
853                                                plan_index = plan_index,
854                                                task_type = ?runner_compaction_kind,
855                                                table = %runner_table,
856                                                required_parallelism = required_parallelism,
857                                                max_parallelism = max_task_parallelism,
858                                                memory_reservation_bytes,
859                                                memory_budget_bytes = iceberg_compaction_memory_limit_bytes,
860                                                "iceberg_compaction_plan_rejected_too_large",
861                                            );
862                                        },
863                                        PushResult::RejectedInvalidParallelism => {
864                                            tracing::error!(
865                                                iceberg_component = "compaction_worker",
866                                                iceberg_operation = "enqueue_plan",
867                                                task_id = %task_id,
868                                                sink_id = runner_sink_id,
869                                                plan_index = plan_index,
870                                                task_type = ?runner_compaction_kind,
871                                                table = %runner_table,
872                                                required_parallelism = required_parallelism,
873                                                "iceberg_compaction_plan_rejected_invalid_parallelism",
874                                            );
875                                        },
876                                        PushResult::RejectedDuplicate => {
877                                            tracing::error!(
878                                                iceberg_component = "compaction_worker",
879                                                iceberg_operation = "enqueue_plan",
880                                                task_id = %task_id,
881                                                sink_id = runner_sink_id,
882                                                plan_index = plan_index,
883                                                task_type = ?runner_compaction_kind,
884                                                table = %runner_table,
885                                                "iceberg_compaction_plan_rejected_duplicate",
886                                            );
887                                        }
888                                    }
889                                }
890
891                                if admitted_count == 0 {
892                                    let mut report = build_iceberg_task_report(
893                                        task_id,
894                                        sink_id,
895                                        Some(rejection_reason.unwrap_or_else(|| {
896                                            "Failed to enqueue all iceberg compaction plans".to_owned()
897                                        })),
898                                    );
899                                    if let Some(status) = rejection_status {
900                                        report.status = status as i32;
901                                    }
902                                    if matches!(
903                                        send_or_buffer_iceberg_task_report(
904                                            &request_sender,
905                                            &mut pending_task_reports,
906                                            report,
907                                        ),
908                                        ReportSendResult::RestartStream
909                                    ) {
910                                        continue 'start_stream;
911                                    }
912                                } else {
913                                    // A bounded round can drain with this task only if every planned
914                                    // runner was admitted. Otherwise an immediate re-plan
915                                    // rediscovers the deferred work.
916                                    let fully_admitted_bounded_round =
917                                        is_bounded_round
918                                            && admitted_count == planned_count;
919                                    task_trackers.insert(
920                                        task_id,
921                                        IcebergTaskTracker::new(
922                                            sink_id,
923                                            admitted_count,
924                                            pk_index_coordinated,
925                                            fully_admitted_bounded_round,
926                                        ),
927                                    );
928                                }
929
930                                tracing::info!(
931                                    iceberg_component = "compaction_worker",
932                                    iceberg_operation = "enqueue_plan",
933                                    task_id = %task_id,
934                                    sink_id = sink_id,
935                                    planned_count = planned_count,
936                                    admitted_count = admitted_count,
937                                    "iceberg_compaction_task_enqueue_finished",
938                                );
939                            },
940                            risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event::PullTaskAck(_) => {
941                                // set flag
942                                pull_task_ack = true;
943                            },
944                            risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event::CancelCompactTask(cancel_compact_task) => {
945                                cancel_iceberg_task(
946                                    cancel_compact_task.task_id,
947                                    &mut task_queue,
948                                    &shutdown_map,
949                                    &mut task_trackers,
950                                );
951                            },
952                        }
953                    }
954                    Some(Err(e)) => {
955                        tracing::warn!("Failed to consume stream. {}", e.message());
956                        continue 'start_stream;
957                    }
958                    _ => {
959                        // The stream is exhausted
960                        continue 'start_stream;
961                    }
962                }
963            }
964        }
965    });
966
967    (join_handle, shutdown_tx)
968}
969
970/// The background compaction thread that receives compaction tasks from hummock compaction
971/// manager and runs compaction tasks.
972#[must_use]
973pub fn start_compactor(
974    compactor_context: CompactorContext,
975    hummock_meta_client: Arc<dyn HummockMetaClient>,
976    object_id_manager: Arc<ObjectIdManager>,
977    compaction_catalog_manager_ref: CompactionCatalogManagerRef,
978) -> (JoinHandle<()>, Sender<()>) {
979    type CompactionShutdownMap = Arc<Mutex<HashMap<u64, Sender<()>>>>;
980    let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
981    let stream_retry_interval = Duration::from_secs(30);
982    let task_progress = compactor_context.task_progress_manager.clone();
983    let periodic_event_update_interval = Duration::from_millis(1000);
984
985    let max_task_parallelism: u32 = (compactor_context.compaction_executor.worker_num() as f32
986        * compactor_context.storage_opts.compactor_max_task_multiplier)
987        .ceil() as u32;
988    let running_task_parallelism = Arc::new(AtomicU32::new(0));
989
990    const MAX_PULL_TASK_COUNT: u32 = 4;
991    let max_pull_task_count = std::cmp::min(max_task_parallelism, MAX_PULL_TASK_COUNT);
992
993    assert_ge!(
994        compactor_context.storage_opts.compactor_max_task_multiplier,
995        0.0
996    );
997
998    let join_handle = tokio::spawn(async move {
999        let shutdown_map = CompactionShutdownMap::default();
1000        let mut min_interval = tokio::time::interval(stream_retry_interval);
1001        let mut periodic_event_interval = tokio::time::interval(periodic_event_update_interval);
1002
1003        // Track last logged state to avoid duplicate logs
1004        let mut log_throttler =
1005            LogThrottler::<CompactionLogState>::new(COMPACTION_HEARTBEAT_LOG_INTERVAL);
1006
1007        // This outer loop is to recreate stream.
1008        'start_stream: loop {
1009            // reset state
1010            // pull_task_ack.store(true, Ordering::SeqCst);
1011            let mut pull_task_ack = true;
1012            tokio::select! {
1013                // Wait for interval.
1014                _ = min_interval.tick() => {},
1015                // Shutdown compactor.
1016                _ = &mut shutdown_rx => {
1017                    tracing::info!("Compactor is shutting down");
1018                    return;
1019                }
1020            }
1021
1022            let (request_sender, response_event_stream) =
1023                match hummock_meta_client.subscribe_compaction_event().await {
1024                    Ok((request_sender, response_event_stream)) => {
1025                        tracing::debug!("Succeeded subscribe_compaction_event.");
1026                        (request_sender, response_event_stream)
1027                    }
1028
1029                    Err(e) => {
1030                        tracing::warn!(
1031                            error = %e.as_report(),
1032                            "Subscribing to compaction tasks failed with error. Will retry.",
1033                        );
1034                        continue 'start_stream;
1035                    }
1036                };
1037
1038            pin_mut!(response_event_stream);
1039
1040            let executor = compactor_context.compaction_executor.clone();
1041            let object_id_manager = object_id_manager.clone();
1042
1043            // This inner loop is to consume stream or report task progress.
1044            let mut event_loop_iteration_now = Instant::now();
1045            'consume_stream: loop {
1046                {
1047                    // report
1048                    compactor_context
1049                        .compactor_metrics
1050                        .compaction_event_loop_iteration_latency
1051                        .observe(event_loop_iteration_now.elapsed().as_millis() as _);
1052                    event_loop_iteration_now = Instant::now();
1053                }
1054
1055                let running_task_parallelism = running_task_parallelism.clone();
1056                let request_sender = request_sender.clone();
1057                let event: Option<Result<SubscribeCompactionEventResponse, _>> = tokio::select! {
1058                    _ = periodic_event_interval.tick() => {
1059                        let progress_list = get_task_progress(task_progress.clone());
1060
1061                        if let Err(e) = request_sender.send(SubscribeCompactionEventRequest {
1062                            event: Some(RequestEvent::HeartBeat(
1063                                HeartBeat {
1064                                    progress: progress_list
1065                                }
1066                            )),
1067                            create_at: SystemTime::now()
1068                                .duration_since(std::time::UNIX_EPOCH)
1069                                .expect("Clock may have gone backwards")
1070                                .as_millis() as u64,
1071                        }) {
1072                            tracing::warn!(error = %e.as_report(), "Failed to report task progress");
1073                            // re subscribe stream
1074                            continue 'start_stream;
1075                        }
1076
1077
1078                        let mut pending_pull_task_count = 0;
1079                        if pull_task_ack {
1080                            // TODO: Compute parallelism on meta side
1081                            pending_pull_task_count = (max_task_parallelism - running_task_parallelism.load(Ordering::SeqCst)).min(max_pull_task_count);
1082
1083                            if pending_pull_task_count > 0 {
1084                                if let Err(e) = request_sender.send(SubscribeCompactionEventRequest {
1085                                    event: Some(RequestEvent::PullTask(
1086                                        PullTask {
1087                                            pull_task_count: pending_pull_task_count,
1088                                        }
1089                                    )),
1090                                    create_at: SystemTime::now()
1091                                        .duration_since(std::time::UNIX_EPOCH)
1092                                        .expect("Clock may have gone backwards")
1093                                        .as_millis() as u64,
1094                                }) {
1095                                    tracing::warn!(error = %e.as_report(), "Failed to pull task");
1096
1097                                    // re subscribe stream
1098                                    continue 'start_stream;
1099                                } else {
1100                                    pull_task_ack = false;
1101                                }
1102                            }
1103                        }
1104
1105                        let running_count = running_task_parallelism.load(Ordering::SeqCst);
1106                        let current_state = CompactionLogState {
1107                            running_parallelism: running_count,
1108                            pull_task_ack,
1109                            pending_pull_task_count,
1110                        };
1111
1112                        // Log only when state changes or periodically as heartbeat
1113                        if log_throttler.should_log(&current_state) {
1114                            tracing::info!(
1115                                running_parallelism_count = %current_state.running_parallelism,
1116                                pull_task_ack = %current_state.pull_task_ack,
1117                                pending_pull_task_count = %current_state.pending_pull_task_count
1118                            );
1119                            log_throttler.update(current_state);
1120                        }
1121
1122                        continue;
1123                    }
1124                    event = response_event_stream.next() => {
1125                        event
1126                    }
1127
1128                    _ = &mut shutdown_rx => {
1129                        tracing::info!("Compactor is shutting down");
1130                        return
1131                    }
1132                };
1133
1134                fn send_report_task_event(
1135                    compact_task: &CompactTask,
1136                    table_stats: TableStatsMap,
1137                    object_timestamps: HashMap<HummockSstableObjectId, u64>,
1138                    request_sender: &mpsc::UnboundedSender<SubscribeCompactionEventRequest>,
1139                ) {
1140                    if let Err(e) = request_sender.send(SubscribeCompactionEventRequest {
1141                        event: Some(RequestEvent::ReportTask(ReportTask {
1142                            task_id: compact_task.task_id,
1143                            task_status: compact_task.task_status.into(),
1144                            sorted_output_ssts: compact_task
1145                                .sorted_output_ssts
1146                                .iter()
1147                                .map(|sst| sst.into())
1148                                .collect(),
1149                            table_stats_change: to_prost_table_stats_map(table_stats),
1150                            object_timestamps,
1151                        })),
1152                        create_at: SystemTime::now()
1153                            .duration_since(std::time::UNIX_EPOCH)
1154                            .expect("Clock may have gone backwards")
1155                            .as_millis() as u64,
1156                    }) {
1157                        let task_id = compact_task.task_id;
1158                        tracing::warn!(error = %e.as_report(), "Failed to report task {task_id:?}");
1159                    }
1160                }
1161
1162                match event {
1163                    Some(Ok(SubscribeCompactionEventResponse { event, create_at })) => {
1164                        let event = match event {
1165                            Some(event) => event,
1166                            None => continue 'consume_stream,
1167                        };
1168                        let shutdown = shutdown_map.clone();
1169                        let context = compactor_context.clone();
1170                        let consumed_latency_ms = SystemTime::now()
1171                            .duration_since(std::time::UNIX_EPOCH)
1172                            .expect("Clock may have gone backwards")
1173                            .as_millis() as u64
1174                            - create_at;
1175                        context
1176                            .compactor_metrics
1177                            .compaction_event_consumed_latency
1178                            .observe(consumed_latency_ms as _);
1179
1180                        let object_id_manager = object_id_manager.clone();
1181                        let compaction_catalog_manager_ref = compaction_catalog_manager_ref.clone();
1182
1183                        match event {
1184                            ResponseEvent::CompactTask(compact_task) => {
1185                                let compact_task = CompactTask::from(compact_task);
1186                                let parallelism =
1187                                    calculate_task_parallelism(&compact_task, &context);
1188
1189                                assert_ne!(parallelism, 0, "splits cannot be empty");
1190
1191                                if (max_task_parallelism
1192                                    - running_task_parallelism.load(Ordering::SeqCst))
1193                                    < parallelism as u32
1194                                {
1195                                    tracing::warn!(
1196                                        "Not enough core parallelism to serve the task {} task_parallelism {} running_task_parallelism {} max_task_parallelism {}",
1197                                        compact_task.task_id,
1198                                        parallelism,
1199                                        max_task_parallelism,
1200                                        running_task_parallelism.load(Ordering::Relaxed),
1201                                    );
1202                                    let (compact_task, table_stats, object_timestamps) =
1203                                        compact_done(
1204                                            compact_task,
1205                                            context.clone(),
1206                                            vec![],
1207                                            TaskStatus::NoAvailCpuResourceCanceled,
1208                                        );
1209
1210                                    send_report_task_event(
1211                                        &compact_task,
1212                                        table_stats,
1213                                        object_timestamps,
1214                                        &request_sender,
1215                                    );
1216
1217                                    continue 'consume_stream;
1218                                }
1219
1220                                running_task_parallelism
1221                                    .fetch_add(parallelism as u32, Ordering::SeqCst);
1222                                executor.spawn(async move {
1223                                    let (tx, rx) = tokio::sync::oneshot::channel();
1224                                    let task_id = compact_task.task_id;
1225                                    shutdown.lock().unwrap().insert(task_id, tx);
1226
1227                                    let (
1228                                        (compact_task, table_stats, object_timestamps),
1229                                        _memory_tracker,
1230                                    ) = compactor_runner::compact(
1231                                        context.clone(),
1232                                        compact_task,
1233                                        rx,
1234                                        object_id_manager.clone(),
1235                                        compaction_catalog_manager_ref.clone(),
1236                                    )
1237                                    .await;
1238
1239                                    shutdown.lock().unwrap().remove(&task_id);
1240                                    running_task_parallelism
1241                                        .fetch_sub(parallelism as u32, Ordering::SeqCst);
1242
1243                                    send_report_task_event(
1244                                        &compact_task,
1245                                        table_stats,
1246                                        object_timestamps,
1247                                        &request_sender,
1248                                    );
1249
1250                                    let enable_check_compaction_result =
1251                                        context.storage_opts.check_compaction_result;
1252                                    let need_check_task =
1253                                        !compact_task.sorted_output_ssts.is_empty()
1254                                            && compact_task.task_status == TaskStatus::Success;
1255
1256                                    if enable_check_compaction_result && need_check_task {
1257                                        let read_table_ids = compact_task
1258                                            .get_table_ids_from_input_ssts()
1259                                            .collect::<Vec<_>>();
1260                                        match compaction_catalog_manager_ref.acquire(read_table_ids).await {
1261                                            Ok(compaction_catalog_agent_ref) =>  {
1262                                                match check_compaction_result(&compact_task, context.clone(), compaction_catalog_agent_ref).await
1263                                                {
1264                                                    Err(e) => {
1265                                                        tracing::warn!(error = %e.as_report(), "Failed to check compaction task {}",compact_task.task_id);
1266                                                    }
1267                                                    Ok(true) => (),
1268                                                    Ok(false) => {
1269                                                        panic!("Failed to pass consistency check for result of compaction task:\n{:?}", compact_task_to_string(&compact_task));
1270                                                    }
1271                                                }
1272                                            },
1273                                            Err(e) => {
1274                                                tracing::warn!(error = %e.as_report(), "failed to acquire compaction catalog agent");
1275                                            }
1276                                        }
1277                                    }
1278                                });
1279                            }
1280                            #[expect(deprecated)]
1281                            ResponseEvent::VacuumTask(_) => {
1282                                unreachable!("unexpected vacuum task");
1283                            }
1284                            #[expect(deprecated)]
1285                            ResponseEvent::FullScanTask(_) => {
1286                                unreachable!("unexpected scan task");
1287                            }
1288                            #[expect(deprecated)]
1289                            ResponseEvent::ValidationTask(validation_task) => {
1290                                let validation_task = ValidationTask::from(validation_task);
1291                                executor.spawn(async move {
1292                                    validate_ssts(validation_task, context.sstable_store.clone())
1293                                        .await;
1294                                });
1295                            }
1296                            ResponseEvent::CancelCompactTask(cancel_compact_task) => match shutdown
1297                                .lock()
1298                                .unwrap()
1299                                .remove(&cancel_compact_task.task_id)
1300                            {
1301                                Some(tx) => {
1302                                    if tx.send(()).is_err() {
1303                                        tracing::warn!(
1304                                            "Cancellation of compaction task failed. task_id: {}",
1305                                            cancel_compact_task.task_id
1306                                        );
1307                                    }
1308                                }
1309                                _ => {
1310                                    tracing::warn!(
1311                                        "Attempting to cancel non-existent compaction task. task_id: {}",
1312                                        cancel_compact_task.task_id
1313                                    );
1314                                }
1315                            },
1316
1317                            ResponseEvent::PullTaskAck(_pull_task_ack) => {
1318                                // set flag
1319                                pull_task_ack = true;
1320                            }
1321                        }
1322                    }
1323                    Some(Err(e)) => {
1324                        tracing::warn!("Failed to consume stream. {}", e.message());
1325                        continue 'start_stream;
1326                    }
1327                    _ => {
1328                        // The stream is exhausted
1329                        continue 'start_stream;
1330                    }
1331                }
1332            }
1333        }
1334    });
1335
1336    (join_handle, shutdown_tx)
1337}
1338
1339/// The background compaction thread that receives compaction tasks from hummock compaction
1340/// manager and runs compaction tasks.
1341#[must_use]
1342pub fn start_shared_compactor(
1343    grpc_proxy_client: GrpcCompactorProxyClient,
1344    mut receiver: mpsc::UnboundedReceiver<Request<DispatchCompactionTaskRequest>>,
1345    context: CompactorContext,
1346) -> (JoinHandle<()>, Sender<()>) {
1347    type CompactionShutdownMap = Arc<Mutex<HashMap<u64, Sender<()>>>>;
1348    let task_progress = context.task_progress_manager.clone();
1349    let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
1350    let periodic_event_update_interval = Duration::from_millis(1000);
1351
1352    let join_handle = tokio::spawn(async move {
1353        let shutdown_map = CompactionShutdownMap::default();
1354
1355        let mut periodic_event_interval = tokio::time::interval(periodic_event_update_interval);
1356        let executor = context.compaction_executor.clone();
1357        let report_heartbeat_client = grpc_proxy_client.clone();
1358        'consume_stream: loop {
1359            let request: Option<Request<DispatchCompactionTaskRequest>> = tokio::select! {
1360                _ = periodic_event_interval.tick() => {
1361                    let progress_list = get_task_progress(task_progress.clone());
1362                    let report_compaction_task_request = ReportCompactionTaskRequest{
1363                        event: Some(ReportCompactionTaskEvent::HeartBeat(
1364                            SharedHeartBeat {
1365                                progress: progress_list
1366                            }
1367                        )),
1368                     };
1369                    if let Err(e) = report_heartbeat_client.report_compaction_task(report_compaction_task_request).await{
1370                        tracing::warn!(error = %e.as_report(), "Failed to report heartbeat");
1371                    }
1372                    continue
1373                }
1374
1375
1376                _ = &mut shutdown_rx => {
1377                    tracing::info!("Compactor is shutting down");
1378                    return
1379                }
1380
1381                request = receiver.recv() => {
1382                    request
1383                }
1384
1385            };
1386            match request {
1387                Some(request) => {
1388                    let context = context.clone();
1389                    let shutdown = shutdown_map.clone();
1390
1391                    let cloned_grpc_proxy_client = grpc_proxy_client.clone();
1392                    executor.spawn(async move {
1393                        let DispatchCompactionTaskRequest {
1394                            tables,
1395                            output_object_ids,
1396                            task: dispatch_task,
1397                        } = request.into_inner();
1398                        let table_id_to_catalog = tables.into_iter().fold(HashMap::new(), |mut acc, table| {
1399                            acc.insert(table.id, table);
1400                            acc
1401                        });
1402
1403                        let mut output_object_ids_deque: VecDeque<_> = VecDeque::new();
1404                        output_object_ids_deque.extend(output_object_ids.into_iter().map(Into::<HummockSstableObjectId>::into));
1405                        let shared_compactor_object_id_manager =
1406                            SharedComapctorObjectIdManager::new(output_object_ids_deque, cloned_grpc_proxy_client.clone(), context.storage_opts.sstable_id_remote_fetch_number);
1407                            match dispatch_task.unwrap() {
1408                                dispatch_compaction_task_request::Task::CompactTask(compact_task) => {
1409                                    let compact_task = CompactTask::from(&compact_task);
1410                                    let (tx, rx) = tokio::sync::oneshot::channel();
1411                                    let task_id = compact_task.task_id;
1412                                    shutdown.lock().unwrap().insert(task_id, tx);
1413
1414                                    let compaction_catalog_manager_ref =
1415                                        Arc::new(CompactionCatalogManager::new_preloaded(
1416                                            table_id_to_catalog,
1417                                        ));
1418                                    let ((compact_task, table_stats, object_timestamps), _memory_tracker)= compactor_runner::compact(
1419                                        context.clone(),
1420                                        compact_task,
1421                                        rx,
1422                                        shared_compactor_object_id_manager,
1423                                        compaction_catalog_manager_ref.clone(),
1424                                    )
1425                                    .await;
1426                                    shutdown.lock().unwrap().remove(&task_id);
1427                                    let report_compaction_task_request = ReportCompactionTaskRequest {
1428                                        event: Some(ReportCompactionTaskEvent::ReportTask(ReportSharedTask {
1429                                            compact_task: Some(PbCompactTask::from(&compact_task)),
1430                                            table_stats_change: to_prost_table_stats_map(table_stats),
1431                                            object_timestamps,
1432                                    })),
1433                                    };
1434
1435                                    match cloned_grpc_proxy_client
1436                                        .report_compaction_task(report_compaction_task_request)
1437                                        .await
1438                                    {
1439                                        Ok(_) => {
1440                                            // TODO: remove this method after we have running risingwave cluster with fast compact algorithm stably for a long time.
1441                                            let enable_check_compaction_result =
1442                                                context.storage_opts.check_compaction_result;
1443                                            let need_check_task =
1444                                                !compact_task.sorted_output_ssts.is_empty()
1445                                                    && compact_task.task_status
1446                                                        == TaskStatus::Success;
1447                                            if enable_check_compaction_result && need_check_task {
1448                                                let read_table_ids = compact_task
1449                                                    .get_table_ids_from_input_ssts()
1450                                                    .collect::<Vec<_>>();
1451                                                match compaction_catalog_manager_ref.acquire(read_table_ids).await {
1452                                                    Ok(compaction_catalog_agent_ref) => {
1453                                                        match check_compaction_result(&compact_task, context.clone(), compaction_catalog_agent_ref).await
1454                                                        {
1455                                                            Err(e) => {
1456                                                                tracing::warn!(error = %e.as_report(), "Failed to check compaction task {}", task_id);
1457                                                            }
1458                                                            Ok(true) => (),
1459                                                            Ok(false) => {
1460                                                                panic!("Failed to pass consistency check for result of compaction task:\n{:?}", compact_task_to_string(&compact_task));
1461                                                            }
1462                                                        }
1463                                                    }
1464                                                    Err(e) => {
1465                                                        tracing::warn!(error = %e.as_report(), "failed to acquire compaction catalog agent");
1466                                                    }
1467                                                }
1468                                            }
1469                                        }
1470                                        Err(e) => tracing::warn!(error = %e.as_report(), "Failed to report task {task_id:?}"),
1471                                    }
1472
1473                                }
1474                                dispatch_compaction_task_request::Task::VacuumTask(_) => {
1475                                    unreachable!("unexpected vacuum task");
1476                                }
1477                                dispatch_compaction_task_request::Task::FullScanTask(_) => {
1478                                    unreachable!("unexpected scan task");
1479                                }
1480                                dispatch_compaction_task_request::Task::ValidationTask(validation_task) => {
1481                                    let validation_task = ValidationTask::from(validation_task);
1482                                    validate_ssts(validation_task, context.sstable_store.clone()).await;
1483                                }
1484                                dispatch_compaction_task_request::Task::CancelCompactTask(cancel_compact_task) => {
1485                                    match shutdown
1486                                        .lock()
1487                                        .unwrap()
1488                                        .remove(&cancel_compact_task.task_id)
1489                                    { Some(tx) => {
1490                                        if tx.send(()).is_err() {
1491                                            tracing::warn!(
1492                                                "Cancellation of compaction task failed. task_id: {}",
1493                                                cancel_compact_task.task_id
1494                                            );
1495                                        }
1496                                    } _ => {
1497                                        tracing::warn!(
1498                                            "Attempting to cancel non-existent compaction task. task_id: {}",
1499                                            cancel_compact_task.task_id
1500                                        );
1501                                    }}
1502                                }
1503                            }
1504                    });
1505                }
1506                None => continue 'consume_stream,
1507            }
1508        }
1509    });
1510    (join_handle, shutdown_tx)
1511}
1512
1513fn get_task_progress(
1514    task_progress: Arc<
1515        parking_lot::lock_api::Mutex<parking_lot::RawMutex, HashMap<u64, Arc<TaskProgress>>>,
1516    >,
1517) -> Vec<CompactTaskProgress> {
1518    let mut progress_list = Vec::new();
1519    for (&task_id, progress) in &*task_progress.lock() {
1520        progress_list.push(progress.snapshot(task_id));
1521    }
1522    progress_list
1523}
1524
1525/// Schedule queued tasks if we have capacity
1526fn schedule_queued_tasks(
1527    task_queue: &mut IcebergTaskQueue,
1528    compactor_context: &CompactorContext,
1529    shutdown_map: &Arc<Mutex<HashMap<TaskKey, Sender<()>>>>,
1530    task_completion_tx: &tokio::sync::mpsc::UnboundedSender<IcebergPlanCompletion>,
1531) {
1532    while let Some(popped_task) = task_queue.pop() {
1533        let task_id = popped_task.meta.task_id;
1534        let plan_index = popped_task.meta.plan_index;
1535        let task_key = (task_id, plan_index);
1536
1537        // Get unique_ident before moving runner
1538        let unique_ident = popped_task.runner.as_ref().map(|r| r.unique_ident());
1539
1540        let Some(runner) = popped_task.runner else {
1541            tracing::error!(
1542                iceberg_component = "compaction_worker",
1543                iceberg_operation = "schedule_plan",
1544                task_id = %task_id,
1545                plan_index = plan_index,
1546                "iceberg_compaction_plan_missing_runner",
1547            );
1548            task_queue.finish_running(task_key);
1549            continue;
1550        };
1551        let runner_sink_id = runner.sink_id;
1552        let runner_compaction_kind = runner.compaction_kind();
1553        let runner_table = runner.table_ident.to_string();
1554
1555        let executor = compactor_context.compaction_executor.clone();
1556        let shutdown_map_clone = shutdown_map.clone();
1557        let completion_tx_clone = task_completion_tx.clone();
1558        let (tx, rx) = tokio::sync::oneshot::channel();
1559
1560        {
1561            let mut shutdown_guard = shutdown_map.lock().unwrap();
1562            shutdown_guard.insert(task_key, tx);
1563        }
1564
1565        tracing::info!(
1566            iceberg_component = "compaction_worker",
1567            iceberg_operation = "schedule_plan",
1568            task_id = %task_id,
1569            sink_id = runner_sink_id,
1570            plan_index = plan_index,
1571            task_type = ?runner_compaction_kind,
1572            table = %runner_table,
1573            unique_ident = ?unique_ident,
1574            required_parallelism = popped_task.meta.required_parallelism,
1575            "iceberg_compaction_plan_started_from_queue",
1576        );
1577
1578        executor.spawn(async move {
1579            let _cleanup_guard = scopeguard::guard(shutdown_map_clone, move |shutdown_map| {
1580                let mut shutdown_guard = shutdown_map.lock().unwrap();
1581                shutdown_guard.remove(&task_key);
1582            });
1583
1584            let result = Box::pin(runner.compact(rx)).await;
1585
1586            let completion = match result {
1587                Ok((_stats, pk_index_result)) => IcebergPlanCompletion {
1588                    task_key,
1589                    error_message: None,
1590                    pk_index_result,
1591                },
1592                Err(e) => {
1593                    if is_cancelled_iceberg_compaction_error(&e) {
1594                        tracing::info!(
1595                            iceberg_component = "compaction_worker",
1596                            iceberg_operation = "execute_plan",
1597                            task_id = %task_key.0,
1598                            sink_id = runner_sink_id,
1599                            plan_index = task_key.1,
1600                            task_type = ?runner_compaction_kind,
1601                            table = %runner_table,
1602                            "iceberg_compaction_plan_cancelled",
1603                        );
1604                    } else {
1605                        tracing::warn!(
1606                            iceberg_component = "compaction_worker",
1607                            iceberg_operation = "execute_plan",
1608                            error = %e.as_report(),
1609                            task_id = %task_key.0,
1610                            sink_id = runner_sink_id,
1611                            plan_index = task_key.1,
1612                            task_type = ?runner_compaction_kind,
1613                            table = %runner_table,
1614                            "iceberg_compaction_plan_failed",
1615                        );
1616                    }
1617                    IcebergPlanCompletion {
1618                        task_key,
1619                        error_message: Some(e.to_report_string()),
1620                        pk_index_result: None,
1621                    }
1622                }
1623            };
1624
1625            if completion_tx_clone.send(completion).is_err() {
1626                tracing::warn!(
1627                    iceberg_component = "compaction_worker",
1628                    iceberg_operation = "notify_plan_completion",
1629                    task_id = %task_key.0,
1630                    sink_id = runner_sink_id,
1631                    plan_index = task_key.1,
1632                    task_type = ?runner_compaction_kind,
1633                    table = %runner_table,
1634                    "iceberg_compaction_plan_completion_send_failed",
1635                );
1636            }
1637        });
1638    }
1639}
1640
1641fn is_cancelled_iceberg_compaction_error(error: &crate::hummock::HummockError) -> bool {
1642    matches!(
1643        error.inner(),
1644        HummockErrorInner::CompactionExecutor(message) if message == "Plan cancelled"
1645    )
1646}
1647
1648fn cancel_iceberg_task(
1649    task_id: IcebergCompactionTaskId,
1650    task_queue: &mut IcebergTaskQueue,
1651    shutdown_map: &Arc<Mutex<HashMap<TaskKey, Sender<()>>>>,
1652    task_trackers: &mut HashMap<IcebergCompactionTaskId, IcebergTaskTracker>,
1653) {
1654    // Meta assigns one task id to an Iceberg compact task, but the compactor
1655    // splits it into multiple plan runners tracked by `(task_id, plan_index)`.
1656    // A cancel event only carries `task_id`, so it must cancel all waiting and
1657    // running plan runners that belong to that task.
1658    let cancelled_waiting = task_queue.cancel_waiting_task(task_id);
1659    let removed_tracker = task_trackers.remove(&task_id).is_some();
1660
1661    let cancelled_running = {
1662        let mut shutdown_guard = shutdown_map.lock().unwrap();
1663        let task_keys: Vec<_> = shutdown_guard
1664            .keys()
1665            .filter(|(running_task_id, _)| *running_task_id == task_id)
1666            .copied()
1667            .collect();
1668
1669        for task_key in &task_keys {
1670            if let Some(tx) = shutdown_guard.remove(task_key)
1671                && tx.send(()).is_err()
1672            {
1673                tracing::debug!(
1674                    task_id = %task_key.0,
1675                    plan_index = task_key.1,
1676                    "Iceberg compaction plan shutdown receiver already closed during cancellation"
1677                );
1678            }
1679        }
1680
1681        task_keys.len()
1682    };
1683
1684    if cancelled_waiting == 0 && cancelled_running == 0 && !removed_tracker {
1685        tracing::warn!(
1686            task_id = %task_id,
1687            "Attempting to cancel non-existent iceberg compaction task"
1688        );
1689    } else {
1690        tracing::info!(
1691            task_id = %task_id,
1692            cancelled_waiting = cancelled_waiting,
1693            cancelled_running = cancelled_running,
1694            removed_tracker = removed_tracker,
1695            "Cancelled iceberg compaction task"
1696        );
1697    }
1698}
1699
1700/// Handle pulling new tasks from meta service
1701/// Returns true if the stream should be restarted
1702fn handle_meta_task_pulling(
1703    pull_task_ack: &mut bool,
1704    task_queue: &IcebergTaskQueue,
1705    max_task_parallelism: u32,
1706    max_pull_task_count: u32,
1707    request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
1708    log_throttler: &mut LogThrottler<IcebergCompactionLogState>,
1709) -> bool {
1710    let mut pending_pull_task_count = 0;
1711    if *pull_task_ack {
1712        // Treat both running and queued plans as reserved capacity. The waiting budget can make
1713        // this exceed `max_task_parallelism`; saturating subtraction then returns 0 to stop this
1714        // backlogged worker from pulling more tasks. The final `min` only caps the request batch
1715        // size (to 1 by default).
1716        let current_reserved_parallelism = task_queue
1717            .running_parallelism_sum()
1718            .saturating_add(task_queue.waiting_parallelism_sum());
1719        pending_pull_task_count = max_task_parallelism
1720            .saturating_sub(current_reserved_parallelism)
1721            .min(max_pull_task_count);
1722
1723        if pending_pull_task_count > 0 {
1724            if let Err(e) = request_sender.send(SubscribeIcebergCompactionEventRequest {
1725                event: Some(subscribe_iceberg_compaction_event_request::Event::PullTask(
1726                    subscribe_iceberg_compaction_event_request::PullTask {
1727                        pull_task_count: pending_pull_task_count,
1728                    },
1729                )),
1730                create_at: SystemTime::now()
1731                    .duration_since(std::time::UNIX_EPOCH)
1732                    .expect("Clock may have gone backwards")
1733                    .as_millis() as u64,
1734            }) {
1735                tracing::warn!(error = %e.as_report(), "Failed to pull task - will retry on stream restart");
1736                return true; // Signal to restart stream
1737            } else {
1738                *pull_task_ack = false;
1739            }
1740        }
1741    }
1742
1743    let running_count = task_queue.running_parallelism_sum();
1744    let waiting_count = task_queue.waiting_parallelism_sum();
1745    let available_count = max_task_parallelism.saturating_sub(running_count);
1746    let current_state = IcebergCompactionLogState {
1747        running_parallelism: running_count,
1748        waiting_parallelism: waiting_count,
1749        available_parallelism: available_count,
1750        pull_task_ack: *pull_task_ack,
1751        pending_pull_task_count,
1752    };
1753
1754    // Log only when state changes or periodically as heartbeat
1755    if log_throttler.should_log(&current_state) {
1756        tracing::info!(
1757            running_parallelism_count = %current_state.running_parallelism,
1758            waiting_parallelism_count = %current_state.waiting_parallelism,
1759            available_parallelism = %current_state.available_parallelism,
1760            pull_task_ack = %current_state.pull_task_ack,
1761            pending_pull_task_count = %current_state.pending_pull_task_count
1762        );
1763        log_throttler.update(current_state);
1764    }
1765
1766    false // No need to restart stream
1767}
1768
1769#[cfg(test)]
1770mod tests {
1771    use super::*;
1772
1773    #[test]
1774    fn test_cancel_iceberg_task_removes_waiting_plans_and_tracker() {
1775        let task_id = IcebergCompactionTaskId::new(42);
1776        let mut task_queue = IcebergTaskQueue::new(10, 30, 1024);
1777        assert_eq!(
1778            task_queue.push(
1779                iceberg_compaction::IcebergTaskMeta {
1780                    task_id,
1781                    plan_index: 0,
1782                    required_parallelism: 3,
1783                    memory_reservation_bytes: 64,
1784                },
1785                None,
1786            ),
1787            PushResult::Added
1788        );
1789        assert_eq!(
1790            task_queue.push(
1791                iceberg_compaction::IcebergTaskMeta {
1792                    task_id,
1793                    plan_index: 1,
1794                    required_parallelism: 4,
1795                    memory_reservation_bytes: 64,
1796                },
1797                None,
1798            ),
1799            PushResult::Added
1800        );
1801        assert_eq!(task_queue.waiting_parallelism_sum(), 7);
1802
1803        let shutdown_map = Arc::new(Mutex::new(HashMap::new()));
1804        let mut task_trackers =
1805            HashMap::from([(task_id, IcebergTaskTracker::new(10, 2, false, false))]);
1806
1807        cancel_iceberg_task(task_id, &mut task_queue, &shutdown_map, &mut task_trackers);
1808
1809        assert_eq!(task_queue.waiting_parallelism_sum(), 0);
1810        assert!(!task_trackers.contains_key(&task_id));
1811    }
1812
1813    #[test]
1814    fn test_cancel_iceberg_task_shuts_down_running_plans_and_tracker() {
1815        let task_id = IcebergCompactionTaskId::new(43);
1816        let task_key = (task_id, 0);
1817        let mut task_queue = IcebergTaskQueue::new(10, 30, 1024);
1818        let shutdown_map = Arc::new(Mutex::new(HashMap::new()));
1819        let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
1820        shutdown_map.lock().unwrap().insert(task_key, shutdown_tx);
1821        let mut task_trackers =
1822            HashMap::from([(task_id, IcebergTaskTracker::new(10, 1, false, false))]);
1823
1824        cancel_iceberg_task(task_id, &mut task_queue, &shutdown_map, &mut task_trackers);
1825
1826        assert!(shutdown_rx.try_recv().is_ok());
1827        assert!(shutdown_map.lock().unwrap().is_empty());
1828        assert!(!task_trackers.contains_key(&task_id));
1829    }
1830
1831    #[test]
1832    fn test_log_state_equality() {
1833        // Test CompactionLogState
1834        let state1 = CompactionLogState {
1835            running_parallelism: 10,
1836            pull_task_ack: true,
1837            pending_pull_task_count: 2,
1838        };
1839        let state2 = CompactionLogState {
1840            running_parallelism: 10,
1841            pull_task_ack: true,
1842            pending_pull_task_count: 2,
1843        };
1844        let state3 = CompactionLogState {
1845            running_parallelism: 11,
1846            pull_task_ack: true,
1847            pending_pull_task_count: 2,
1848        };
1849        assert_eq!(state1, state2);
1850        assert_ne!(state1, state3);
1851
1852        // Test IcebergCompactionLogState
1853        let ice_state1 = IcebergCompactionLogState {
1854            running_parallelism: 10,
1855            waiting_parallelism: 5,
1856            available_parallelism: 15,
1857            pull_task_ack: true,
1858            pending_pull_task_count: 2,
1859        };
1860        let ice_state2 = IcebergCompactionLogState {
1861            running_parallelism: 10,
1862            waiting_parallelism: 6,
1863            available_parallelism: 15,
1864            pull_task_ack: true,
1865            pending_pull_task_count: 2,
1866        };
1867        assert_ne!(ice_state1, ice_state2);
1868    }
1869
1870    #[test]
1871    fn test_log_throttler_state_change_detection() {
1872        let mut throttler = LogThrottler::<CompactionLogState>::new(Duration::from_secs(60));
1873        let state1 = CompactionLogState {
1874            running_parallelism: 10,
1875            pull_task_ack: true,
1876            pending_pull_task_count: 2,
1877        };
1878        let state2 = CompactionLogState {
1879            running_parallelism: 11,
1880            pull_task_ack: true,
1881            pending_pull_task_count: 2,
1882        };
1883
1884        // First call should always log
1885        assert!(throttler.should_log(&state1));
1886        throttler.update(state1.clone());
1887
1888        // Same state should not log
1889        assert!(!throttler.should_log(&state1));
1890
1891        // Changed state should log
1892        assert!(throttler.should_log(&state2));
1893        throttler.update(state2.clone());
1894
1895        // Same state again should not log
1896        assert!(!throttler.should_log(&state2));
1897    }
1898
1899    #[test]
1900    fn test_log_throttler_heartbeat() {
1901        let mut throttler = LogThrottler::<CompactionLogState>::new(Duration::from_millis(10));
1902        let state = CompactionLogState {
1903            running_parallelism: 10,
1904            pull_task_ack: true,
1905            pending_pull_task_count: 2,
1906        };
1907
1908        // First call should log
1909        assert!(throttler.should_log(&state));
1910        throttler.update(state.clone());
1911
1912        // Same state immediately should not log
1913        assert!(!throttler.should_log(&state));
1914
1915        // Wait for heartbeat interval to pass
1916        std::thread::sleep(Duration::from_millis(15));
1917
1918        // Same state after interval should log (heartbeat)
1919        assert!(throttler.should_log(&state));
1920    }
1921}