Skip to main content

risingwave_meta/manager/iceberg_compaction/
schedule.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::hash_map::Entry;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use itertools::Itertools;
20use parking_lot::RwLock;
21use risingwave_connector::connector_common::{
22    IcebergCommittedSnapshot, IcebergSinkCompactionUpdate,
23};
24use risingwave_connector::sink::SinkParam;
25use risingwave_connector::sink::catalog::{SinkCatalog, SinkId};
26use risingwave_connector::sink::iceberg::{
27    CompactionType, IcebergConfig, should_enable_iceberg_cow,
28};
29use risingwave_hummock_sdk::HummockContextId;
30use risingwave_pb::iceberg_compaction::IcebergCompactionTask;
31use risingwave_pb::iceberg_compaction::iceberg_compaction_task::TaskType;
32use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::ReportTask as IcebergReportTask;
33use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::report_task::Status as IcebergReportTaskStatus;
34use risingwave_pb::id::IcebergCompactionTaskId;
35use thiserror_ext::AsReport;
36use tokio::sync::oneshot;
37
38use super::*;
39
40/// Compaction track states using type-safe state machine pattern
41#[derive(Debug, Clone)]
42enum CompactionTrackState {
43    /// Ready to accept commits and check for trigger conditions.
44    ///
45    /// `Idle` is not an active task state. A manual request may leave a
46    /// one-shot task type override here for the next scheduler selection.
47    Idle {
48        next_compaction_time: Instant,
49        /// `None` uses the configured track task type; `Some` is consumed when
50        /// the next task enters `PendingDispatch`.
51        next_task_type_override: Option<TaskType>,
52    },
53    /// Task has been selected locally but not yet accepted by a compactor.
54    PendingDispatch {
55        task_type: TaskType,
56        pending_commit_count_at_dispatch: usize,
57        gc_watermark_snapshot: Option<IcebergCommittedSnapshot>,
58    },
59    /// Compaction task is in-flight. `report_deadline` acts as a lease; if it
60    /// expires before a report arrives, the task becomes retryable.
61    InFlight {
62        task_id: IcebergCompactionTaskId,
63        compactor_context_id: HummockContextId,
64        task_type: TaskType,
65        pending_commit_count_at_dispatch: usize,
66        report_deadline: Instant,
67        gc_watermark_snapshot: Option<IcebergCommittedSnapshot>,
68    },
69}
70
71#[derive(Debug, Clone, Copy)]
72struct ScheduledCompactionTask {
73    task_id: IcebergCompactionTaskId,
74    compactor_context_id: HummockContextId,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum CompactionTrackFinishAction {
79    KeepTrack,
80    RemoveTrack,
81}
82
83#[derive(Debug, Clone)]
84pub(super) struct CompactionTrack {
85    task_type: TaskType,
86    trigger_interval_sec: u64,
87    /// Minimum pending commit threshold to trigger compaction early.
88    /// Compaction triggers when `pending_commit_count` >= this threshold, even before interval expires.
89    trigger_snapshot_count: usize,
90    report_timeout: Duration,
91    last_config_refresh_at: Instant,
92    pending_commit_count: usize,
93    latest_observed_snapshot: Option<IcebergCommittedSnapshot>,
94    /// Track lifecycle policy after a selected task finishes. Manual compaction
95    /// uses `RemoveTrack` only while automatic compaction is disabled; a later
96    /// config refresh can turn the temporary track into a normal schedule track.
97    finish_action: CompactionTrackFinishAction,
98    state: CompactionTrackState,
99}
100
101impl CompactionTrack {
102    fn new(
103        task_type: TaskType,
104        trigger_interval_sec: u64,
105        trigger_snapshot_count: usize,
106        report_timeout: Duration,
107        now: Instant,
108    ) -> Self {
109        Self {
110            task_type,
111            trigger_interval_sec,
112            trigger_snapshot_count,
113            report_timeout,
114            last_config_refresh_at: now,
115            pending_commit_count: 0,
116            latest_observed_snapshot: None,
117            finish_action: CompactionTrackFinishAction::KeepTrack,
118            state: CompactionTrackState::Idle {
119                next_compaction_time: now + Duration::from_secs(trigger_interval_sec),
120                next_task_type_override: None,
121            },
122        }
123    }
124
125    /// Determines if compaction should be triggered.
126    ///
127    /// Trigger conditions (OR logic):
128    /// 1. `commit_ready` - Pending commit count >= threshold (early trigger)
129    /// 2. `time_ready && has_commits` - Interval expired and there's at least 1 pending commit
130    ///
131    /// This ensures:
132    /// - `trigger_snapshot_count` is an early trigger threshold
133    /// - `compaction_interval_sec` is the maximum wait time (as long as there are new snapshots)
134    /// - Force compaction works by setting `next_compaction_time` to now
135    /// - No empty compaction runs (requires at least 1 snapshot for time-based trigger)
136    fn should_trigger(&self, now: Instant) -> bool {
137        let next_compaction_time = match &self.state {
138            CompactionTrackState::Idle {
139                next_compaction_time,
140                ..
141            } => *next_compaction_time,
142            CompactionTrackState::PendingDispatch { .. }
143            | CompactionTrackState::InFlight { .. } => return false,
144        };
145
146        let time_ready = now >= next_compaction_time;
147        let commit_ready = self.pending_commit_count >= self.trigger_snapshot_count;
148        let has_commits = self.pending_commit_count > 0;
149
150        commit_ready || (time_ready && has_commits)
151    }
152
153    fn record_observed_snapshot(&mut self, observed_snapshot: IcebergCommittedSnapshot) {
154        self.latest_observed_snapshot = Some(observed_snapshot);
155    }
156
157    fn record_commit(&mut self) {
158        self.pending_commit_count = self.pending_commit_count.saturating_add(1);
159    }
160
161    fn record_force_compaction(&mut self, now: Instant, forced_task_type: Option<TaskType>) {
162        if let CompactionTrackState::Idle {
163            next_compaction_time,
164            next_task_type_override,
165        } = &mut self.state
166        {
167            if let Some(task_type) = forced_task_type {
168                *next_task_type_override = Some(task_type);
169            }
170            *next_compaction_time = now;
171            self.pending_commit_count = self.pending_commit_count.max(1);
172        }
173    }
174
175    fn needs_config_refresh(&self, now: Instant, refresh_interval: Duration) -> bool {
176        now.saturating_duration_since(self.last_config_refresh_at) >= refresh_interval
177    }
178
179    fn should_refresh_config(&self, now: Instant, refresh_interval: Duration) -> bool {
180        matches!(self.state, CompactionTrackState::Idle { .. })
181            && self.needs_config_refresh(now, refresh_interval)
182    }
183
184    fn mark_config_refreshed(&mut self, now: Instant) {
185        self.last_config_refresh_at = now;
186    }
187
188    fn current_task_type(&self) -> TaskType {
189        match &self.state {
190            CompactionTrackState::Idle {
191                next_task_type_override,
192                ..
193            } => next_task_type_override.unwrap_or(self.task_type),
194            CompactionTrackState::PendingDispatch { task_type, .. }
195            | CompactionTrackState::InFlight { task_type, .. } => *task_type,
196        }
197    }
198
199    fn start_processing(&mut self) -> TaskType {
200        match &mut self.state {
201            CompactionTrackState::Idle {
202                next_task_type_override,
203                ..
204            } => {
205                let task_type = next_task_type_override.take().unwrap_or(self.task_type);
206                self.state = CompactionTrackState::PendingDispatch {
207                    task_type,
208                    pending_commit_count_at_dispatch: self.pending_commit_count,
209                    gc_watermark_snapshot: self.latest_observed_snapshot.clone(),
210                };
211                task_type
212            }
213            CompactionTrackState::PendingDispatch { .. }
214            | CompactionTrackState::InFlight { .. } => {
215                unreachable!("Cannot start processing when already processing")
216            }
217        }
218    }
219
220    fn mark_dispatched(
221        &mut self,
222        task_id: IcebergCompactionTaskId,
223        compactor_context_id: HummockContextId,
224        now: Instant,
225    ) {
226        let (task_type, pending_commit_count_at_dispatch, gc_watermark_snapshot) = match &mut self
227            .state
228        {
229            CompactionTrackState::PendingDispatch {
230                task_type,
231                pending_commit_count_at_dispatch,
232                gc_watermark_snapshot,
233                ..
234            } => {
235                let gc_watermark_snapshot = gc_watermark_snapshot.take();
236                (
237                    *task_type,
238                    *pending_commit_count_at_dispatch,
239                    gc_watermark_snapshot,
240                )
241            }
242            CompactionTrackState::Idle { .. } => unreachable!("Cannot mark dispatched when idle"),
243            CompactionTrackState::InFlight { .. } => {
244                unreachable!("Cannot mark dispatched when already in flight")
245            }
246        };
247        self.state = CompactionTrackState::InFlight {
248            task_id,
249            compactor_context_id,
250            task_type,
251            pending_commit_count_at_dispatch,
252            report_deadline: now + self.report_timeout,
253            gc_watermark_snapshot,
254        };
255    }
256
257    pub(super) fn processing_gc_watermark_snapshot(
258        &self,
259    ) -> Option<Option<&IcebergCommittedSnapshot>> {
260        match &self.state {
261            CompactionTrackState::PendingDispatch {
262                gc_watermark_snapshot,
263                ..
264            }
265            | CompactionTrackState::InFlight {
266                gc_watermark_snapshot,
267                ..
268            } => Some(gc_watermark_snapshot.as_ref()),
269            CompactionTrackState::Idle { .. } => None,
270        }
271    }
272
273    fn is_pending_dispatch(&self) -> bool {
274        matches!(self.state, CompactionTrackState::PendingDispatch { .. })
275    }
276
277    fn removes_track_after_finish(&self) -> bool {
278        self.finish_action == CompactionTrackFinishAction::RemoveTrack
279    }
280
281    pub(super) fn is_processing_task(&self, task_id: IcebergCompactionTaskId) -> bool {
282        matches!(
283            &self.state,
284            CompactionTrackState::InFlight {
285                task_id: current_task_id,
286                ..
287            } if *current_task_id == task_id
288        )
289    }
290
291    fn scheduled_task(&self) -> Option<ScheduledCompactionTask> {
292        match &self.state {
293            CompactionTrackState::InFlight {
294                task_id,
295                compactor_context_id,
296                ..
297            } => Some(ScheduledCompactionTask {
298                task_id: *task_id,
299                compactor_context_id: *compactor_context_id,
300            }),
301            CompactionTrackState::Idle { .. } | CompactionTrackState::PendingDispatch { .. } => {
302                None
303            }
304        }
305    }
306
307    fn is_report_timed_out(&self, now: Instant) -> bool {
308        matches!(
309            &self.state,
310            CompactionTrackState::InFlight {
311                report_deadline,
312                ..
313            } if now >= *report_deadline
314        )
315    }
316
317    fn finish_success(&mut self, now: Instant) -> CompactionTrackFinishAction {
318        match &self.state {
319            CompactionTrackState::InFlight {
320                pending_commit_count_at_dispatch,
321                ..
322            } => {
323                self.pending_commit_count = self
324                    .pending_commit_count
325                    .saturating_sub(*pending_commit_count_at_dispatch);
326                self.state = CompactionTrackState::Idle {
327                    next_compaction_time: now + Duration::from_secs(self.trigger_interval_sec),
328                    next_task_type_override: None,
329                };
330                self.finish_action
331            }
332            CompactionTrackState::Idle { .. } => unreachable!("Cannot finish success when idle"),
333            CompactionTrackState::PendingDispatch { .. } => {
334                unreachable!("Cannot finish success before task dispatch")
335            }
336        }
337    }
338
339    fn finish_failed(&mut self, now: Instant) -> CompactionTrackFinishAction {
340        match &self.state {
341            CompactionTrackState::InFlight { .. } => {
342                self.state = CompactionTrackState::Idle {
343                    next_compaction_time: now,
344                    next_task_type_override: None,
345                };
346                self.finish_action
347            }
348            CompactionTrackState::Idle { .. } => unreachable!("Cannot finish failed when idle"),
349            CompactionTrackState::PendingDispatch { .. } => {
350                unreachable!("Cannot finish failed before task dispatch")
351            }
352        }
353    }
354
355    /// Re-queue the track as idle after a pre-dispatch failure.
356    ///
357    /// `pending_commit_count` is intentionally preserved so commits that arrive
358    /// while the track is pending dispatch are not lost if task dispatch fails
359    /// before the compactor accepts the task.
360    ///
361    /// `next_compaction_time` is reset to `now` (like `finish_failed`) rather
362    /// than restored: candidates are dispatched in ascending order of this
363    /// field, so restoring a stale timestamp would let a repeatedly-failing
364    /// track sort ahead of every healthy sink and permanently monopolize
365    /// dispatch slots.
366    fn revert_pre_dispatch_failure(&mut self, now: Instant) -> CompactionTrackFinishAction {
367        match &self.state {
368            CompactionTrackState::PendingDispatch { .. } => {
369                self.state = CompactionTrackState::Idle {
370                    next_compaction_time: now,
371                    next_task_type_override: None,
372                };
373                self.finish_action
374            }
375            CompactionTrackState::Idle { .. } => {
376                unreachable!("Cannot revert a pre-dispatch failure when idle")
377            }
378            CompactionTrackState::InFlight { .. } => {
379                unreachable!("Cannot revert a pre-dispatch failure after dispatch")
380            }
381        }
382    }
383
384    fn update_interval(&mut self, new_interval_sec: u64, now: Instant) {
385        if self.trigger_interval_sec == new_interval_sec {
386            return;
387        }
388
389        self.trigger_interval_sec = new_interval_sec;
390
391        match &mut self.state {
392            CompactionTrackState::Idle {
393                next_compaction_time,
394                ..
395            } => {
396                *next_compaction_time = now + Duration::from_secs(new_interval_sec);
397            }
398            CompactionTrackState::PendingDispatch { .. }
399            | CompactionTrackState::InFlight { .. } => {}
400        }
401    }
402}
403
404pub(crate) struct IcebergCompactionHandle {
405    sink_id: SinkId,
406    task_type: TaskType,
407    inner: Arc<RwLock<IcebergCompactionManagerInner>>,
408    metadata_manager: MetadataManager,
409    handle_success: bool,
410}
411
412impl IcebergCompactionHandle {
413    fn new(
414        sink_id: SinkId,
415        task_type: TaskType,
416        inner: Arc<RwLock<IcebergCompactionManagerInner>>,
417        metadata_manager: MetadataManager,
418    ) -> Self {
419        Self {
420            sink_id,
421            task_type,
422            inner,
423            metadata_manager,
424            handle_success: false,
425        }
426    }
427
428    pub async fn send_compact_task(
429        mut self,
430        compactor: Arc<crate::hummock::IcebergCompactor>,
431        task_id: IcebergCompactionTaskId,
432    ) -> MetaResult<()> {
433        use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event as IcebergResponseEvent;
434
435        let Some(prost_sink_catalog) = self
436            .metadata_manager
437            .catalog_controller
438            .get_sink_by_id(self.sink_id)
439            .await?
440        else {
441            tracing::warn!(
442                iceberg_component = "compaction_scheduler",
443                iceberg_operation = "dispatch_task",
444                sink_id = %self.sink_id,
445                task_id = %task_id,
446                "iceberg_compaction_dispatch_sink_not_found",
447            );
448            return Ok(());
449        };
450        let sink_catalog = SinkCatalog::from(prost_sink_catalog);
451        let param = SinkParam::try_from_sink_catalog(sink_catalog)?;
452
453        let result =
454            compactor.send_event(IcebergResponseEvent::CompactTask(IcebergCompactionTask {
455                task_id,
456                sink_id: self.sink_id.as_raw_id(),
457                props: param.properties,
458                task_type: self.task_type as i32,
459            }));
460
461        if result.is_ok() {
462            let mut should_cancel_sent_task = false;
463            let mut guard = self.inner.write();
464            let mut dispatched = false;
465            if let Some(track) = guard.sink_schedules.get_mut(&self.sink_id)
466                && track.is_pending_dispatch()
467            {
468                track.mark_dispatched(task_id, compactor.context_id(), Instant::now());
469                dispatched = true;
470            }
471            self.handle_success = dispatched;
472            if !dispatched {
473                should_cancel_sent_task = true;
474                tracing::warn!(
475                    iceberg_component = "compaction_scheduler",
476                    iceberg_operation = "dispatch_task",
477                    sink_id = %self.sink_id,
478                    task_id = %task_id,
479                    "iceberg_compaction_dispatch_track_not_pending",
480                );
481            }
482            drop(guard);
483
484            if should_cancel_sent_task {
485                self.cancel_sent_task(&compactor, task_id);
486            }
487        }
488
489        result
490    }
491
492    fn cancel_sent_task(
493        &self,
494        compactor: &crate::hummock::IcebergCompactor,
495        task_id: IcebergCompactionTaskId,
496    ) {
497        if let Err(e) = compactor.cancel_task(task_id) {
498            tracing::warn!(
499                iceberg_component = "compaction_scheduler",
500                iceberg_operation = "cancel_task",
501                error = %e.as_report(),
502                sink_id = %self.sink_id,
503                task_id = %task_id,
504                "iceberg_compaction_cancel_after_schedule_removal_failed",
505            );
506        }
507    }
508}
509
510impl Drop for IcebergCompactionHandle {
511    fn drop(&mut self) {
512        let waiter = {
513            let mut guard = self.inner.write();
514            if !self.handle_success
515                && let Some(track) = guard.sink_schedules.get_mut(&self.sink_id)
516                && track.is_pending_dispatch()
517            {
518                let finish_action = track.revert_pre_dispatch_failure(Instant::now());
519                let waiter = guard.manual_compaction_waiters.remove(&self.sink_id);
520                IcebergCompactionManager::apply_track_finish_action(
521                    &mut guard,
522                    self.sink_id,
523                    finish_action,
524                );
525                waiter
526            } else {
527                None
528            }
529        };
530
531        if let Some(waiter) = waiter {
532            let _ = waiter.send(Err(anyhow!(
533                "Iceberg compaction task failed before dispatch for sink {}",
534                self.sink_id
535            )
536            .into()));
537        }
538    }
539}
540
541#[derive(Debug, Clone)]
542enum SinkUpdateKind {
543    /// A normal sink commit. It increases the pending snapshot count.
544    Commit {
545        observed_snapshot: IcebergCommittedSnapshot,
546    },
547    /// A force signal from the sink update path. It triggers the configured
548    /// compaction type and still follows the automatic-compaction config gate.
549    ForceCompaction {
550        observed_snapshot: IcebergCommittedSnapshot,
551    },
552    /// A user-triggered manual request. It can bypass disabled automatic
553    /// compaction and supplies the task type selected for this request.
554    ManualForceCompaction { task_type: TaskType },
555}
556
557impl SinkUpdateKind {
558    fn apply_to_track(self, track: &mut CompactionTrack, now: Instant) {
559        match self {
560            SinkUpdateKind::Commit { observed_snapshot } => {
561                track.record_observed_snapshot(observed_snapshot);
562                track.record_commit();
563            }
564            SinkUpdateKind::ForceCompaction { observed_snapshot } => {
565                if matches!(track.state, CompactionTrackState::Idle { .. }) {
566                    track.record_observed_snapshot(observed_snapshot);
567                }
568                track.record_force_compaction(now, None);
569            }
570            SinkUpdateKind::ManualForceCompaction { task_type } => {
571                track.record_force_compaction(now, Some(task_type))
572            }
573        }
574    }
575
576    fn allows_disabled_compaction(&self) -> bool {
577        matches!(self, SinkUpdateKind::ManualForceCompaction { .. })
578    }
579}
580
581/// Result of the read-only preparation step before applying a sink update.
582///
583/// This bundles the original update intent together with the metadata loaded
584/// across the async gap, so the apply step can consume a single object.
585///
586/// `allow_track_initialization` stays `true` only when the sink had no track
587/// before the async config load. This lets the apply step initialize a new
588/// track for first-time updates, while preventing a stale update from
589/// resurrecting a track that disappeared during the async gap.
590struct PreparedSinkUpdate {
591    sink_id: SinkId,
592    kind: SinkUpdateKind,
593    now: Instant,
594    allow_track_initialization: bool,
595    loaded_config: Option<IcebergConfig>,
596}
597
598#[derive(Debug, Clone)]
599pub struct IcebergCompactionScheduleStatus {
600    pub sink_id: SinkId,
601    pub task_type: String,
602    pub trigger_interval_sec: u64,
603    pub trigger_snapshot_count: usize,
604    pub schedule_state: String,
605    pub next_compaction_after_sec: Option<u64>,
606    pub pending_snapshot_count: Option<usize>,
607    pub is_triggerable: bool,
608}
609
610impl IcebergCompactionManager {
611    fn apply_track_finish_action(
612        guard: &mut IcebergCompactionManagerInner,
613        sink_id: SinkId,
614        finish_action: CompactionTrackFinishAction,
615    ) {
616        match finish_action {
617            CompactionTrackFinishAction::KeepTrack => {}
618            CompactionTrackFinishAction::RemoveTrack => {
619                guard.sink_schedules.remove(&sink_id);
620            }
621        }
622    }
623
624    pub(super) fn refresh_schedule_config(
625        &self,
626        track: &mut CompactionTrack,
627        iceberg_config: &IcebergConfig,
628        now: Instant,
629    ) {
630        let (task_type, trigger_interval_sec, trigger_snapshot_count) =
631            self.resolve_schedule_values(iceberg_config);
632        track.task_type = task_type;
633        track.trigger_snapshot_count = trigger_snapshot_count;
634        track.update_interval(trigger_interval_sec, now);
635        track.mark_config_refreshed(now);
636    }
637
638    pub async fn update_iceberg_commit_info(&self, msg: IcebergSinkCompactionUpdate) {
639        let IcebergSinkCompactionUpdate {
640            sink_id,
641            force_compaction,
642            observed_snapshot,
643        } = msg;
644        let kind = if force_compaction {
645            SinkUpdateKind::ForceCompaction { observed_snapshot }
646        } else {
647            SinkUpdateKind::Commit { observed_snapshot }
648        };
649        let prepared_update = self
650            .prepare_sink_update(sink_id, kind, Instant::now())
651            .await;
652
653        let mut guard = self.inner.write();
654        self.apply_sink_update(&mut guard, prepared_update);
655    }
656
657    async fn prepare_sink_update(
658        &self,
659        sink_id: SinkId,
660        kind: SinkUpdateKind,
661        now: Instant,
662    ) -> PreparedSinkUpdate {
663        let refresh_interval = self.config_refresh_interval();
664        let (allow_track_initialization, should_refresh_config) = {
665            let guard = self.inner.read();
666            match guard.sink_schedules.get(&sink_id) {
667                Some(track) => (false, track.should_refresh_config(now, refresh_interval)),
668                None => (true, true),
669            }
670        };
671
672        let loaded_config = if should_refresh_config {
673            match self.load_iceberg_config(sink_id).await {
674                Ok(config) => Some(config),
675                Err(e) => {
676                    tracing::warn!(
677                        error = ?e.as_report(),
678                        "Failed to load iceberg config for sink {}",
679                        sink_id
680                    );
681                    None
682                }
683            }
684        } else {
685            None
686        };
687
688        PreparedSinkUpdate {
689            sink_id,
690            kind,
691            now,
692            allow_track_initialization,
693            loaded_config,
694        }
695    }
696
697    fn apply_sink_update(
698        &self,
699        guard: &mut IcebergCompactionManagerInner,
700        prepared_update: PreparedSinkUpdate,
701    ) -> bool {
702        let PreparedSinkUpdate {
703            sink_id,
704            kind,
705            now,
706            allow_track_initialization,
707            loaded_config,
708        } = prepared_update;
709        let refresh_interval = self.config_refresh_interval();
710
711        if let Some(config) = loaded_config.as_ref() {
712            if config.enable_snapshot_expiration {
713                guard.snapshot_expiration_sink_ids.insert(sink_id);
714            } else {
715                guard.snapshot_expiration_sink_ids.remove(&sink_id);
716            }
717            if config.enable_manifest_rewrite {
718                guard.manifest_rewrite_sink_ids.insert(sink_id);
719            } else {
720                guard.manifest_rewrite_sink_ids.remove(&sink_id);
721            }
722
723            if !config.enable_compaction && !kind.allows_disabled_compaction() {
724                if !guard.sink_schedules.get(&sink_id).is_some_and(|track| {
725                    matches!(
726                        &track.state,
727                        CompactionTrackState::PendingDispatch { .. }
728                            | CompactionTrackState::InFlight { .. }
729                    ) || track.removes_track_after_finish()
730                }) {
731                    guard.sink_schedules.remove(&sink_id);
732                }
733                return false;
734            }
735        }
736
737        match guard.sink_schedules.entry(sink_id) {
738            Entry::Occupied(entry) => {
739                let track = entry.into_mut();
740                if track.removes_track_after_finish()
741                    && !kind.allows_disabled_compaction()
742                    && !loaded_config
743                        .as_ref()
744                        .is_some_and(|config| config.enable_compaction)
745                {
746                    return false;
747                }
748                if track.should_refresh_config(now, refresh_interval)
749                    && let Some(config) = loaded_config.as_ref()
750                {
751                    self.refresh_schedule_config(track, config, now);
752                }
753                if let Some(config) = loaded_config.as_ref() {
754                    track.finish_action =
755                        if kind.allows_disabled_compaction() && !config.enable_compaction {
756                            CompactionTrackFinishAction::RemoveTrack
757                        } else {
758                            CompactionTrackFinishAction::KeepTrack
759                        };
760                }
761
762                kind.apply_to_track(track, now);
763                true
764            }
765            Entry::Vacant(entry) => {
766                if !allow_track_initialization {
767                    tracing::warn!(
768                        iceberg_component = "compaction_scheduler",
769                        iceberg_operation = "apply_sink_update",
770                        sink_id = %sink_id,
771                        "iceberg_compaction_update_ignored_track_missing",
772                    );
773                    return false;
774                }
775
776                let Some(config) = loaded_config.as_ref() else {
777                    tracing::warn!(
778                        iceberg_component = "compaction_scheduler",
779                        iceberg_operation = "apply_sink_update",
780                        sink_id = %sink_id,
781                        "iceberg_compaction_update_ignored_config_unavailable",
782                    );
783                    return false;
784                };
785
786                let track = entry.insert(self.create_compaction_track(config, now));
787                track.finish_action =
788                    if kind.allows_disabled_compaction() && !config.enable_compaction {
789                        CompactionTrackFinishAction::RemoveTrack
790                    } else {
791                        CompactionTrackFinishAction::KeepTrack
792                    };
793                kind.apply_to_track(track, now);
794                true
795            }
796        }
797    }
798
799    pub(super) fn create_compaction_track(
800        &self,
801        iceberg_config: &IcebergConfig,
802        now: Instant,
803    ) -> CompactionTrack {
804        let (task_type, trigger_interval_sec, trigger_snapshot_count) =
805            self.resolve_schedule_values(iceberg_config);
806
807        CompactionTrack::new(
808            task_type,
809            trigger_interval_sec,
810            trigger_snapshot_count,
811            self.report_timeout(),
812            now,
813        )
814    }
815
816    fn resolve_schedule_values(&self, iceberg_config: &IcebergConfig) -> (TaskType, u64, usize) {
817        (
818            if should_enable_iceberg_cow(iceberg_config.r#type.as_str(), iceberg_config.write_mode)
819            {
820                TaskType::Full
821            } else {
822                match iceberg_config.compaction_type() {
823                    CompactionType::Full => TaskType::Full,
824                    CompactionType::SmallFiles => TaskType::SmallFiles,
825                    CompactionType::FilesWithDelete => TaskType::FilesWithDelete,
826                }
827            },
828            iceberg_config.compaction_interval_sec(),
829            iceberg_config.trigger_snapshot_count(),
830        )
831    }
832
833    pub(super) async fn start_manual_compaction(
834        &self,
835        sink_id: SinkId,
836    ) -> MetaResult<oneshot::Receiver<MetaResult<IcebergCompactionTaskId>>> {
837        let prepared_update = self
838            .prepare_sink_update(
839                sink_id,
840                SinkUpdateKind::ManualForceCompaction {
841                    task_type: TaskType::Full,
842                },
843                Instant::now(),
844            )
845            .await;
846        let mut guard = self.inner.write();
847        let now = Instant::now();
848        if guard.manual_compaction_waiters.contains_key(&sink_id) {
849            return Err(anyhow!(
850                "manual iceberg compaction is already waiting for sink {}",
851                sink_id
852            )
853            .into());
854        }
855
856        if let Some(track) = guard.sink_schedules.get(&sink_id) {
857            match &track.state {
858                CompactionTrackState::PendingDispatch {
859                    pending_commit_count_at_dispatch,
860                    ..
861                } => {
862                    return Err(anyhow!(
863                        "iceberg compaction task is already running for sink {} \
864                         (state=pending_dispatch, pending_commit_count_at_dispatch={}, \
865                         pending_commit_count={})",
866                        sink_id,
867                        pending_commit_count_at_dispatch,
868                        track.pending_commit_count
869                    )
870                    .into());
871                }
872                CompactionTrackState::InFlight {
873                    task_id,
874                    pending_commit_count_at_dispatch,
875                    report_deadline,
876                    ..
877                } => {
878                    return Err(anyhow!(
879                        "iceberg compaction task is already running for sink {} \
880                         (state=in_flight, task_id={}, pending_commit_count_at_dispatch={}, \
881                         pending_commit_count={}, report_timeout_after_sec={})",
882                        sink_id,
883                        task_id,
884                        pending_commit_count_at_dispatch,
885                        track.pending_commit_count,
886                        report_deadline.saturating_duration_since(now).as_secs()
887                    )
888                    .into());
889                }
890                CompactionTrackState::Idle { .. } => {}
891            }
892        }
893
894        if self.apply_sink_update(&mut guard, prepared_update) {
895            let (tx, rx) = oneshot::channel();
896            guard.manual_compaction_waiters.insert(sink_id, tx);
897            Ok(rx)
898        } else {
899            Err(anyhow!(
900                "failed to trigger manual iceberg compaction for sink {}",
901                sink_id
902            )
903            .into())
904        }
905    }
906
907    pub(super) fn cancel_manual_compaction_waiter(&self, sink_id: SinkId) {
908        self.inner
909            .write()
910            .manual_compaction_waiters
911            .remove(&sink_id);
912    }
913
914    fn finish_timed_out_compaction_tasks(
915        guard: &mut IcebergCompactionManagerInner,
916        now: Instant,
917    ) -> Vec<(SinkId, ManualCompactionWaiter)> {
918        let mut timed_out_tasks = Vec::new();
919        for (&sink_id, track) in &mut guard.sink_schedules {
920            if track.is_report_timed_out(now) {
921                tracing::warn!(
922                    iceberg_component = "compaction_scheduler",
923                    iceberg_operation = "report_timeout",
924                    sink_id = %sink_id,
925                    "iceberg_compaction_task_report_timed_out",
926                );
927                timed_out_tasks.push((sink_id, track.finish_failed(now)));
928            }
929        }
930
931        let mut timed_out_waiters = Vec::new();
932        for (sink_id, finish_action) in timed_out_tasks {
933            if let Some(waiter) = guard.manual_compaction_waiters.remove(&sink_id) {
934                timed_out_waiters.push((sink_id, waiter));
935            }
936            Self::apply_track_finish_action(guard, sink_id, finish_action);
937        }
938        timed_out_waiters
939    }
940
941    pub(crate) fn get_top_n_iceberg_commit_sink_ids(
942        &self,
943        n: usize,
944    ) -> Vec<IcebergCompactionHandle> {
945        let now = Instant::now();
946        let (handles, timed_out_waiters) = {
947            let mut guard = self.inner.write();
948            let timed_out_waiters = Self::finish_timed_out_compaction_tasks(&mut guard, now);
949
950            let mut candidates = Vec::new();
951            for (sink_id, track) in &guard.sink_schedules {
952                if track.should_trigger(now)
953                    && let CompactionTrackState::Idle {
954                        next_compaction_time,
955                        ..
956                    } = &track.state
957                {
958                    candidates.push((*sink_id, *next_compaction_time));
959                }
960            }
961
962            candidates.sort_by_key(|c| c.1);
963
964            let handles = candidates
965                .into_iter()
966                .take(n)
967                .filter_map(|(sink_id, _)| {
968                    let track = guard.sink_schedules.get_mut(&sink_id)?;
969                    let task_type = track.start_processing();
970
971                    Some(IcebergCompactionHandle::new(
972                        sink_id,
973                        task_type,
974                        self.inner.clone(),
975                        self.metadata_manager.clone(),
976                    ))
977                })
978                .collect();
979
980            (handles, timed_out_waiters)
981        };
982
983        for (sink_id, waiter) in timed_out_waiters {
984            let _ = waiter.send(Err(anyhow!(
985                "Iceberg compaction task report timed out for sink {}",
986                sink_id
987            )
988            .into()));
989        }
990
991        handles
992    }
993
994    pub fn clear_iceberg_maintenance_by_sink_id(&self, sink_id: SinkId) {
995        let (task_to_cancel, waiter) = {
996            let mut guard = self.inner.write();
997            let task_to_cancel = Self::remove_sink_schedule(&mut guard, sink_id);
998            guard.snapshot_expiration_sink_ids.remove(&sink_id);
999            guard.manifest_rewrite_sink_ids.remove(&sink_id);
1000            let waiter = guard.manual_compaction_waiters.remove(&sink_id);
1001            (task_to_cancel, waiter)
1002        };
1003        self.cancel_scheduled_task_if_any(sink_id, task_to_cancel);
1004
1005        if let Some(waiter) = waiter {
1006            let _ = waiter.send(Err(anyhow!(
1007                "Iceberg compaction maintenance was cleared for sink {}",
1008                sink_id
1009            )
1010            .into()));
1011        }
1012    }
1013
1014    fn remove_sink_schedule(
1015        guard: &mut IcebergCompactionManagerInner,
1016        sink_id: SinkId,
1017    ) -> Option<ScheduledCompactionTask> {
1018        guard
1019            .sink_schedules
1020            .remove(&sink_id)
1021            .and_then(|track| track.scheduled_task())
1022    }
1023
1024    fn cancel_scheduled_task_if_any(&self, sink_id: SinkId, task: Option<ScheduledCompactionTask>) {
1025        let Some(ScheduledCompactionTask {
1026            task_id,
1027            compactor_context_id,
1028        }) = task
1029        else {
1030            return;
1031        };
1032
1033        let Some(compactor) = self
1034            .iceberg_compactor_manager
1035            .get_compactor(compactor_context_id)
1036        else {
1037            tracing::warn!(
1038                sink_id = %sink_id,
1039                task_id = %task_id,
1040                compactor_context_id = %compactor_context_id,
1041                "Unable to cancel iceberg compaction task because compactor is no longer registered",
1042            );
1043            return;
1044        };
1045
1046        tracing::info!(
1047            sink_id = %sink_id,
1048            task_id = %task_id,
1049            compactor_context_id = %compactor_context_id,
1050            "Cancelling iceberg compaction task for removed schedule",
1051        );
1052
1053        if let Err(e) = compactor.cancel_task(task_id) {
1054            tracing::warn!(
1055                error = %e.as_report(),
1056                sink_id = %sink_id,
1057                task_id = %task_id,
1058                compactor_context_id = %compactor_context_id,
1059                "Failed to cancel iceberg compaction task for removed schedule",
1060            );
1061        }
1062    }
1063
1064    pub fn list_compaction_statuses(&self) -> Vec<IcebergCompactionScheduleStatus> {
1065        let now = Instant::now();
1066        let schedules = {
1067            let guard = self.inner.read();
1068            guard
1069                .sink_schedules
1070                .iter()
1071                .map(|(&sink_id, track)| (sink_id, track.clone()))
1072                .collect_vec()
1073        };
1074
1075        let mut statuses = schedules
1076            .into_iter()
1077            .map(|(sink_id, track)| {
1078                let next_compaction_after_sec = match &track.state {
1079                    CompactionTrackState::Idle {
1080                        next_compaction_time,
1081                        ..
1082                    } => Some(
1083                        next_compaction_time
1084                            .saturating_duration_since(now)
1085                            .as_secs(),
1086                    ),
1087                    CompactionTrackState::PendingDispatch { .. }
1088                    | CompactionTrackState::InFlight { .. } => None,
1089                };
1090                let is_triggerable = track.should_trigger(now);
1091
1092                IcebergCompactionScheduleStatus {
1093                    sink_id,
1094                    task_type: track.current_task_type().as_str_name().to_ascii_lowercase(),
1095                    trigger_interval_sec: track.trigger_interval_sec,
1096                    trigger_snapshot_count: track.trigger_snapshot_count,
1097                    schedule_state: match track.state {
1098                        CompactionTrackState::Idle { .. } => "idle".to_owned(),
1099                        CompactionTrackState::PendingDispatch { .. }
1100                        | CompactionTrackState::InFlight { .. } => "processing".to_owned(),
1101                    },
1102                    next_compaction_after_sec,
1103                    pending_snapshot_count: Some(track.pending_commit_count),
1104                    is_triggerable,
1105                }
1106            })
1107            .collect_vec();
1108
1109        statuses.sort_by_key(|status| status.sink_id);
1110        statuses
1111    }
1112
1113    pub fn handle_report_task(&self, report: IcebergReportTask) {
1114        let sink_id = SinkId::from(report.sink_id);
1115        let task_id = report.task_id;
1116        let status = IcebergReportTaskStatus::try_from(report.status)
1117            .unwrap_or(IcebergReportTaskStatus::Unspecified);
1118        let now = Instant::now();
1119
1120        let waiter = {
1121            let mut guard = self.inner.write();
1122            let mut waiter = None;
1123
1124            match guard.sink_schedules.get_mut(&sink_id) {
1125                Some(track) if track.is_processing_task(task_id) => {
1126                    let finish_action = match status {
1127                        IcebergReportTaskStatus::Success => track.finish_success(now),
1128                        IcebergReportTaskStatus::Failed | IcebergReportTaskStatus::Unspecified => {
1129                            tracing::warn!(
1130                                iceberg_component = "compaction_scheduler",
1131                                iceberg_operation = "handle_report",
1132                                sink_id = %sink_id,
1133                                task_id = %task_id,
1134                                status = ?status,
1135                                error_message = report.error_message.as_deref().unwrap_or_default(),
1136                                "iceberg_compaction_task_reported_failure",
1137                            );
1138                            track.finish_failed(now)
1139                        }
1140                    };
1141
1142                    Self::apply_track_finish_action(&mut guard, sink_id, finish_action);
1143                    waiter = guard.manual_compaction_waiters.remove(&sink_id);
1144                }
1145                Some(_) => {
1146                    tracing::warn!(
1147                        iceberg_component = "compaction_scheduler",
1148                        iceberg_operation = "handle_report",
1149                        sink_id = %sink_id,
1150                        task_id = %task_id,
1151                        status = ?status,
1152                        "iceberg_compaction_report_ignored_stale",
1153                    );
1154                }
1155                None => {
1156                    tracing::warn!(
1157                        iceberg_component = "compaction_scheduler",
1158                        iceberg_operation = "handle_report",
1159                        sink_id = %sink_id,
1160                        task_id = %task_id,
1161                        status = ?status,
1162                        "iceberg_compaction_report_unknown_sink",
1163                    );
1164                }
1165            }
1166
1167            waiter
1168        };
1169
1170        if let Some(waiter) = waiter {
1171            Self::complete_manual_task_waiter(waiter, &report);
1172        }
1173    }
1174}
1175
1176#[cfg(test)]
1177mod tests;