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