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::BTreeMap;
16use std::collections::hash_map::Entry;
17use std::sync::Arc;
18use std::time::{Duration, Instant, SystemTime};
19
20use futures::StreamExt;
21use itertools::Itertools;
22use parking_lot::RwLock;
23use risingwave_common::license::Feature;
24use risingwave_connector::connector_common::{
25    IcebergCommittedSnapshot, IcebergSinkCompactionUpdate,
26};
27use risingwave_connector::sink::SinkParam;
28use risingwave_connector::sink::catalog::{SinkCatalog, SinkId};
29use risingwave_connector::sink::iceberg::{
30    CompactionType, IcebergConfig, IcebergWriteMode, commit_branch,
31};
32use risingwave_hummock_sdk::HummockContextId;
33use risingwave_pb::catalog::PbStreamJobStatus;
34use risingwave_pb::iceberg_compaction::IcebergCompactionTask;
35use risingwave_pb::iceberg_compaction::iceberg_compaction_task::TaskType;
36use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::ReportTask as IcebergReportTask;
37use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request::report_task::Status as IcebergReportTaskStatus;
38use risingwave_pb::id::IcebergCompactionTaskId;
39use thiserror_ext::AsReport;
40use tokio::sync::oneshot;
41use tokio::task::JoinHandle;
42
43use super::*;
44use crate::manager::iceberg_pk_index_sink::IcebergPkIndexSinkRegistration;
45
46mod report;
47
48const COMPACTION_RETRY_BACKOFF: Duration = Duration::from_secs(1);
49
50/// Scheduler lifecycle for one sink.
51#[derive(Debug, Clone)]
52enum CompactionTrackState {
53    /// Ready to accept commits and check for trigger conditions.
54    ///
55    /// `Idle` is not an active attempt state. A manual request may leave a
56    /// one-shot task here for the next scheduler selection.
57    Idle {
58        next_compaction_time: Instant,
59        /// A one-shot manual task, consumed when the next attempt starts.
60        manual_task_type: Option<TaskType>,
61    },
62    /// An attempt has been selected, but its task has not been sent to a compactor.
63    PendingDispatch { attempt: Arc<CompactionAttempt> },
64    /// Compaction task is in-flight. `report_deadline` acts as a lease; if it
65    /// expires before a report arrives, the task becomes retryable.
66    InFlight {
67        task_id: IcebergCompactionTaskId,
68        compactor_context_id: HummockContextId,
69        attempt: Arc<CompactionAttempt>,
70        report_deadline: Instant,
71    },
72    /// A validated rewrite is being applied. Recovery discards the track and independently
73    /// protects GC until the new coordinator has settled durable pending commits.
74    Applying {
75        task_id: IcebergCompactionTaskId,
76        attempt: Arc<CompactionAttempt>,
77    },
78    /// An admitted apply has an unknown outcome; do not retry before recovery.
79    NeedsRecovery {
80        task_id: IcebergCompactionTaskId,
81        attempt: Arc<CompactionAttempt>,
82    },
83}
84
85/// Immutable task parameters captured when the scheduler selects an attempt.
86///
87/// The track and async dispatch handle share these parameters so later commits
88/// or config updates cannot change the task being dispatched.
89#[derive(Debug)]
90struct CompactionAttempt {
91    policy: CompactionPolicy,
92    pk_index_coordinated: bool,
93    max_file_sequence_number: Option<i64>,
94    pending_commit_count_at_start: usize,
95    gc_watermark_snapshot: Option<IcebergCommittedSnapshot>,
96}
97
98#[derive(Debug, Clone, Copy)]
99struct ScheduledCompactionTask {
100    task_id: IcebergCompactionTaskId,
101    compactor_context_id: HummockContextId,
102}
103
104/// Outcome history of one sink's compaction attempts.
105///
106/// This is observability-only state: it never affects scheduling decisions, and it
107/// is lost when meta restarts or the track is removed.
108#[derive(Debug, Clone, Default)]
109struct CompactionHealth {
110    last_success_at: Option<SystemTime>,
111    last_failure_at: Option<SystemTime>,
112    last_error: Option<String>,
113    consecutive_failures: u32,
114    /// Latest observed snapshot whose commits have been consumed by a completed
115    /// compaction.
116    compacted_snapshot: Option<IcebergCommittedSnapshot>,
117    /// Snapshot that started the active sequence-bounded round. It becomes
118    /// `compacted_snapshot` when the round drains.
119    round_start_snapshot: Option<IcebergCommittedSnapshot>,
120}
121
122impl CompactionHealth {
123    fn record_progress(&mut self) {
124        self.last_success_at = Some(SystemTime::now());
125        self.consecutive_failures = 0;
126    }
127
128    fn record_completion(&mut self, compacted_snapshot: Option<IcebergCommittedSnapshot>) {
129        self.record_progress();
130        if compacted_snapshot.is_some() {
131            self.compacted_snapshot = compacted_snapshot;
132        }
133    }
134
135    fn record_failure(&mut self, error: String) {
136        self.last_failure_at = Some(SystemTime::now());
137        self.last_error = Some(error);
138        self.consecutive_failures = self.consecutive_failures.saturating_add(1);
139    }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143enum CompactionTrackFinishAction {
144    KeepTrack,
145    RemoveTrack,
146}
147
148/// Selection parameters are typed in memory and serialized only at dispatch.
149#[derive(Debug, Clone, PartialEq, Eq)]
150struct CompactionPolicy {
151    task_type: TaskType,
152    small_files_threshold_mb: u64,
153    target_file_size_mb: u64,
154    delete_ratio_percent: u32,
155}
156
157impl CompactionPolicy {
158    fn properties(&self) -> BTreeMap<String, String> {
159        BTreeMap::from([
160            (
161                "compaction.small_files_threshold_mb".to_owned(),
162                self.small_files_threshold_mb.to_string(),
163            ),
164            (
165                "compaction.target_file_size_mb".to_owned(),
166                self.target_file_size_mb.to_string(),
167            ),
168            (
169                "compaction.delete_ratio_percent".to_owned(),
170                self.delete_ratio_percent.to_string(),
171            ),
172        ])
173    }
174}
175
176/// Latest catalog configuration, independent of an in-flight attempt's frozen policy.
177#[derive(Debug, Clone)]
178struct CompactionScheduleConfig {
179    policy: CompactionPolicy,
180    write_mode: IcebergWriteMode,
181    pk_index_coordinated: bool,
182    automatic_enabled: bool,
183    trigger_interval_sec: u64,
184    trigger_snapshot_count: usize,
185    report_timeout: Duration,
186    refreshed_at: Instant,
187}
188
189#[derive(Debug, Clone, Default)]
190struct CompactionDemand {
191    commit_count: usize,
192    since: Option<Instant>,
193    reconciliation_pending: bool,
194}
195
196impl CompactionDemand {
197    fn record_commit(&mut self, now: Instant) {
198        self.since.get_or_insert(now);
199        self.commit_count = self.commit_count.saturating_add(1);
200    }
201
202    fn consume_for_round(&mut self) {
203        *self = Self::default();
204    }
205
206    fn force_check(&mut self, now: Instant, pk_index_coordinated: bool) {
207        self.commit_count = self.commit_count.max(1);
208        self.since.get_or_insert(now);
209        self.reconciliation_pending |= pk_index_coordinated;
210    }
211
212    fn is_ready(&self, config: &CompactionScheduleConfig, now: Instant) -> bool {
213        self.reconciliation_pending
214            || self.commit_count >= config.trigger_snapshot_count
215            || self.since.is_some_and(|since| {
216                now.saturating_duration_since(since)
217                    >= Duration::from_secs(config.trigger_interval_sec)
218            })
219    }
220}
221
222/// A boundary always exists while work is active. Beneficial PK-index batches can
223/// advance it for new writes; PK-index Full keeps its boundary and policy until drained.
224#[derive(Debug, Clone)]
225struct CompactionRound {
226    max_file_sequence_number: i64,
227    policy: Option<CompactionPolicy>,
228}
229
230#[derive(Debug, Clone)]
231struct CompactionBlocked {
232    reason: IcebergReportTaskStatus,
233    since: Instant,
234}
235
236#[derive(Debug, Clone, Default)]
237struct CompactionRetry {
238    not_before: Option<Instant>,
239    consecutive_failures: u32,
240    blocked: Option<CompactionBlocked>,
241}
242
243impl CompactionRetry {
244    fn is_waiting(&self, now: Instant) -> bool {
245        self.not_before.is_some_and(|deadline| now < deadline)
246    }
247
248    fn record_failure(&mut self, pk_index_coordinated: bool, now: Instant) -> Instant {
249        let delay = if pk_index_coordinated {
250            let delay = Self::delay(self.consecutive_failures, &mut rand::rng());
251            self.consecutive_failures = self.consecutive_failures.saturating_add(1);
252            delay
253        } else {
254            COMPACTION_RETRY_BACKOFF
255        };
256        let retry_at = now + delay;
257        if pk_index_coordinated {
258            self.not_before = Some(retry_at);
259        }
260        retry_at
261    }
262
263    fn record_blocked(&mut self, reason: IcebergReportTaskStatus, now: Instant) -> Instant {
264        let delay = if reason == IcebergReportTaskStatus::Busy {
265            COMPACTION_RETRY_BACKOFF
266        } else {
267            let since = self.blocked.as_ref().map_or(now, |blocked| blocked.since);
268            self.blocked = Some(CompactionBlocked { reason, since });
269            Duration::from_secs(60)
270        };
271        let retry_at = now + delay;
272        self.not_before = Some(retry_at);
273        retry_at
274    }
275
276    fn reset(&mut self) {
277        *self = Self::default();
278    }
279
280    fn delay(failures: u32, rng: &mut impl rand::Rng) -> Duration {
281        let base = Duration::from_secs(1_u64 << failures.min(6));
282        let jitter = Duration::from_millis(rng.random_range(0..=base.as_millis() as u64 / 4));
283        (base + jitter).min(Duration::from_secs(60))
284    }
285}
286
287#[derive(Debug, Clone)]
288pub(super) struct CompactionTrack {
289    config: CompactionScheduleConfig,
290    pub(super) registration: Option<Arc<IcebergPkIndexSinkRegistration>>,
291    demand: CompactionDemand,
292    round: Option<CompactionRound>,
293    retry: CompactionRetry,
294    latest_observed_snapshot: Option<IcebergCommittedSnapshot>,
295    /// Minimum interval between new PK-index rounds, not between continuations.
296    next_check_not_before: Option<Instant>,
297    /// Legacy temporary/disabled tracks are removed after their task finishes.
298    finish_action: CompactionTrackFinishAction,
299    state: CompactionTrackState,
300    health: CompactionHealth,
301}
302
303impl CompactionTrack {
304    fn new(config: CompactionScheduleConfig, now: Instant) -> Self {
305        let next_compaction_time = now + Duration::from_secs(config.trigger_interval_sec);
306        Self {
307            config,
308            registration: None,
309            demand: CompactionDemand::default(),
310            round: None,
311            retry: CompactionRetry::default(),
312            latest_observed_snapshot: None,
313            next_check_not_before: None,
314            finish_action: CompactionTrackFinishAction::KeepTrack,
315            state: CompactionTrackState::Idle {
316                next_compaction_time,
317                manual_task_type: None,
318            },
319            health: CompactionHealth::default(),
320        }
321    }
322
323    pub(super) fn source_is_ready(&self) -> bool {
324        !self.config.pk_index_coordinated
325            || self
326                .registration
327                .as_ref()
328                .is_some_and(|registration| registration.is_ready())
329    }
330
331    pub(super) fn pk_index_registration(&self) -> Option<&Arc<IcebergPkIndexSinkRegistration>> {
332        self.registration
333            .as_ref()
334            .filter(|_| self.config.pk_index_coordinated)
335    }
336
337    /// Determines if compaction should be triggered.
338    ///
339    /// Trigger conditions:
340    /// - An active automatic round follows its next-attempt time. PK-index batches
341    ///   can also continue without new commits or trigger early at the commit threshold.
342    /// - Otherwise, pending commit threshold or an interval with pending commits
343    ///   can trigger a task.
344    ///
345    /// This ensures:
346    /// - The configured commit threshold is an early trigger
347    /// - The configured interval is the maximum dirty wait
348    /// - Force compaction works by setting `next_compaction_time` to now
349    /// - No legacy empty compaction runs (an active automatic round is intentionally retryable)
350    fn should_trigger(&self, now: Instant) -> bool {
351        if self.retry.is_waiting(now) {
352            return false;
353        }
354        let next_compaction_time = match &self.state {
355            CompactionTrackState::Idle {
356                next_compaction_time,
357                manual_task_type,
358            } => {
359                if self.config.pk_index_coordinated && manual_task_type.is_some() {
360                    return true;
361                }
362                if self.config.pk_index_coordinated && !self.config.automatic_enabled {
363                    return false;
364                }
365                *next_compaction_time
366            }
367            CompactionTrackState::PendingDispatch { .. }
368            | CompactionTrackState::InFlight { .. }
369            | CompactionTrackState::Applying { .. }
370            | CompactionTrackState::NeedsRecovery { .. } => return false,
371        };
372
373        let time_ready = now >= next_compaction_time;
374        let commit_ready = self.demand.commit_count >= self.config.trigger_snapshot_count;
375        let has_commits = self.demand.commit_count > 0;
376        if self.round.is_some() {
377            // Continue immediately below the same PK-index boundary when no new
378            // commits are pending. Advancing the boundary must honor the cooldown
379            // or commit threshold, including commits received after success.
380            // Neither continuation nor new demand can bypass failure backoff.
381            return time_ready
382                || (self.config.pk_index_coordinated && (commit_ready || !has_commits));
383        }
384
385        if self.config.pk_index_coordinated {
386            // An empty manual Full can finish before the first snapshot is observed.
387            return self.latest_observed_snapshot.is_some()
388                && !self
389                    .next_check_not_before
390                    .is_some_and(|deadline| now < deadline)
391                && self.demand.is_ready(&self.config, now);
392        }
393        commit_ready || (time_ready && has_commits)
394    }
395
396    fn record_observed_snapshot(&mut self, observed_snapshot: IcebergCommittedSnapshot) -> bool {
397        if self.config.pk_index_coordinated
398            && let Some(previous) = &self.latest_observed_snapshot
399            && previous.branch == observed_snapshot.branch
400            && (previous.snapshot_id == observed_snapshot.snapshot_id
401                || matches!(
402                    (previous.max_file_sequence_number, observed_snapshot.max_file_sequence_number),
403                    (Some(previous), Some(current)) if current <= previous
404                ))
405        {
406            return false;
407        }
408        self.latest_observed_snapshot = Some(observed_snapshot);
409        true
410    }
411
412    #[cfg(test)]
413    fn record_commit(&mut self) {
414        self.record_commit_at(Instant::now());
415    }
416
417    fn record_commit_at(&mut self, now: Instant) {
418        self.demand.record_commit(now);
419    }
420
421    fn record_force_compaction(&mut self, now: Instant, forced_task_type: Option<TaskType>) {
422        if let CompactionTrackState::Idle {
423            next_compaction_time,
424            manual_task_type,
425        } = &mut self.state
426        {
427            if let Some(task_type) = forced_task_type {
428                *manual_task_type = Some(task_type);
429            }
430            *next_compaction_time = now;
431            // A force signal during active work advances the next attempt without
432            // adding another demand event.
433            if self.round.is_none() {
434                self.demand
435                    .force_check(now, self.config.pk_index_coordinated);
436            }
437        }
438    }
439
440    fn needs_config_refresh(&self, now: Instant, refresh_interval: Duration) -> bool {
441        now.saturating_duration_since(self.config.refreshed_at) >= refresh_interval
442    }
443
444    fn effective_task_type(&self) -> TaskType {
445        match &self.state {
446            CompactionTrackState::Idle {
447                manual_task_type, ..
448            } => manual_task_type.unwrap_or(self.config.policy.task_type),
449            CompactionTrackState::PendingDispatch { attempt }
450            | CompactionTrackState::InFlight { attempt, .. }
451            | CompactionTrackState::Applying { attempt, .. }
452            | CompactionTrackState::NeedsRecovery { attempt, .. } => attempt.policy.task_type,
453        }
454    }
455
456    fn start_attempt(&mut self) -> Arc<CompactionAttempt> {
457        let CompactionTrackState::Idle {
458            manual_task_type, ..
459        } = &mut self.state
460        else {
461            unreachable!("Cannot start an attempt while another attempt is active")
462        };
463
464        let manual_task_type = manual_task_type.take();
465        let mut policy = self
466            .round
467            .as_ref()
468            .and_then(|round| round.policy.as_ref())
469            .unwrap_or(&self.config.policy)
470            .clone();
471        if let Some(task_type) = manual_task_type {
472            policy.task_type = task_type;
473        }
474        let starts_round = self.round.is_none();
475        let refresh_pk_index_batch = self.config.pk_index_coordinated
476            && policy.task_type != TaskType::Full
477            && (self.demand.commit_count > 0 || self.demand.reconciliation_pending);
478        let new_round_boundary = if manual_task_type.is_none()
479            && self.config.write_mode == IcebergWriteMode::MergeOnRead
480            && (starts_round || refresh_pk_index_batch)
481        {
482            self.latest_observed_snapshot
483                .as_ref()
484                .expect("automatic merge-on-read compaction must start from an observed snapshot")
485                .max_file_sequence_number
486        } else {
487            None
488        };
489
490        // Each PK-index batch uses the latest write demand and configured policy.
491        // Maintenance alone must not advance the boundary, or a small-file rewrite
492        // could repeatedly select its own outputs without any new ingestion.
493        // Full must also retain a fixed scope because every file remains eligible.
494        if let Some(max_file_sequence_number) = new_round_boundary {
495            self.health.round_start_snapshot = self.latest_observed_snapshot.clone();
496            self.round = Some(CompactionRound {
497                max_file_sequence_number,
498                policy: (self.config.pk_index_coordinated && policy.task_type == TaskType::Full)
499                    .then(|| policy.clone()),
500            });
501            self.demand.consume_for_round();
502            if self.config.pk_index_coordinated && starts_round {
503                self.next_check_not_before = Some(Instant::now() + Duration::from_secs(10));
504            }
505        } else if self.config.pk_index_coordinated
506            && policy.task_type == TaskType::Full
507            && let Some(round) = &mut self.round
508        {
509            round.policy = Some(policy.clone());
510        }
511        let attempt = Arc::new(CompactionAttempt {
512            policy,
513            pk_index_coordinated: self.config.pk_index_coordinated,
514            max_file_sequence_number: self.round_boundary(),
515            pending_commit_count_at_start: self.demand.commit_count,
516            gc_watermark_snapshot: self.latest_observed_snapshot.clone(),
517        });
518        self.state = CompactionTrackState::PendingDispatch {
519            attempt: attempt.clone(),
520        };
521        attempt
522    }
523
524    fn mark_dispatched(
525        &mut self,
526        task_id: IcebergCompactionTaskId,
527        compactor_context_id: HummockContextId,
528        now: Instant,
529    ) {
530        let CompactionTrackState::PendingDispatch { attempt } = &self.state else {
531            unreachable!("Only a pending attempt can be marked as dispatched")
532        };
533        self.state = CompactionTrackState::InFlight {
534            task_id,
535            compactor_context_id,
536            attempt: attempt.clone(),
537            report_deadline: now + self.config.report_timeout,
538        };
539    }
540
541    pub(super) fn active_attempt_gc_watermark_snapshot(
542        &self,
543    ) -> Option<Option<&IcebergCommittedSnapshot>> {
544        match &self.state {
545            CompactionTrackState::PendingDispatch { attempt }
546            | CompactionTrackState::InFlight { attempt, .. }
547            | CompactionTrackState::Applying { attempt, .. }
548            | CompactionTrackState::NeedsRecovery { attempt, .. } => {
549                Some(attempt.gc_watermark_snapshot.as_ref())
550            }
551            CompactionTrackState::Idle { .. } => None,
552        }
553    }
554
555    fn is_pending_dispatch(&self) -> bool {
556        matches!(self.state, CompactionTrackState::PendingDispatch { .. })
557    }
558
559    fn removes_track_after_finish(&self) -> bool {
560        self.finish_action == CompactionTrackFinishAction::RemoveTrack
561    }
562
563    fn has_queued_task_or_active_attempt(&self) -> bool {
564        matches!(
565            &self.state,
566            CompactionTrackState::Idle {
567                manual_task_type: Some(_),
568                ..
569            } | CompactionTrackState::PendingDispatch { .. }
570                | CompactionTrackState::InFlight { .. }
571                | CompactionTrackState::Applying { .. }
572                | CompactionTrackState::NeedsRecovery { .. }
573        )
574    }
575
576    pub(super) fn is_in_flight_task(&self, task_id: IcebergCompactionTaskId) -> bool {
577        matches!(
578            &self.state,
579            CompactionTrackState::InFlight {
580                task_id: current_task_id,
581                ..
582            } if *current_task_id == task_id
583        )
584    }
585
586    fn scheduled_task(&self) -> Option<ScheduledCompactionTask> {
587        match &self.state {
588            CompactionTrackState::InFlight {
589                task_id,
590                compactor_context_id,
591                ..
592            } => Some(ScheduledCompactionTask {
593                task_id: *task_id,
594                compactor_context_id: *compactor_context_id,
595            }),
596            CompactionTrackState::Idle { .. }
597            | CompactionTrackState::PendingDispatch { .. }
598            | CompactionTrackState::Applying { .. }
599            | CompactionTrackState::NeedsRecovery { .. } => None,
600        }
601    }
602
603    fn is_report_timed_out(&self, now: Instant) -> bool {
604        matches!(
605            &self.state,
606            CompactionTrackState::InFlight {
607                report_deadline,
608                ..
609            } if now >= *report_deadline
610        )
611    }
612
613    fn finish_failed(&mut self, now: Instant, error: String) -> CompactionTrackFinishAction {
614        if !matches!(
615            self.state,
616            CompactionTrackState::InFlight { .. } | CompactionTrackState::Applying { .. }
617        ) {
618            unreachable!("Only an in-flight or applying attempt can finish")
619        }
620        self.health.record_failure(error);
621        let retry_at = self
622            .retry
623            .record_failure(self.config.pk_index_coordinated, now);
624        self.state = CompactionTrackState::Idle {
625            next_compaction_time: retry_at,
626            manual_task_type: None,
627        };
628        self.end_disabled_round();
629        self.finish_action
630    }
631
632    fn finish_blocked(
633        &mut self,
634        reason: IcebergReportTaskStatus,
635        now: Instant,
636    ) -> CompactionTrackFinishAction {
637        let retry_at = self.retry.record_blocked(reason, now);
638        self.state = CompactionTrackState::Idle {
639            next_compaction_time: retry_at,
640            manual_task_type: None,
641        };
642        self.end_disabled_round();
643        self.finish_action
644    }
645
646    /// Re-queue the track as idle after a pre-dispatch failure.
647    ///
648    /// Pending demand is intentionally preserved so commits that arrive
649    /// while the track is pending dispatch are not lost if task dispatch fails
650    /// before the compactor accepts the task.
651    ///
652    /// `next_compaction_time` starts a short retry backoff rather than restoring
653    /// its previous timestamp. Candidates are dispatched in ascending order of
654    /// this field, so restoring a stale timestamp would let a repeatedly-failing
655    /// track sort ahead of every healthy sink and monopolize dispatch slots.
656    fn revert_pre_dispatch_failure(&mut self, now: Instant) -> CompactionTrackFinishAction {
657        if !self.is_pending_dispatch() {
658            unreachable!("Only a pending attempt can be reverted")
659        }
660        self.health
661            .record_failure("compaction task failed before dispatch to a compactor".to_owned());
662        let retry_at = self
663            .retry
664            .record_failure(self.config.pk_index_coordinated, now);
665        self.state = CompactionTrackState::Idle {
666            next_compaction_time: retry_at,
667            manual_task_type: None,
668        };
669        self.end_disabled_round();
670        self.finish_action
671    }
672
673    fn update_interval(&mut self, new_interval_sec: u64, now: Instant) {
674        if self.config.trigger_interval_sec == new_interval_sec {
675            return;
676        }
677
678        self.config.trigger_interval_sec = new_interval_sec;
679        if self.round.is_some() {
680            // The new interval applies after the next successful batch or when the
681            // round drains. Preserve the current continuation/cooldown/retry deadline.
682            return;
683        }
684
685        match &mut self.state {
686            CompactionTrackState::Idle {
687                next_compaction_time,
688                ..
689            } => {
690                *next_compaction_time = now + Duration::from_secs(new_interval_sec);
691            }
692            CompactionTrackState::PendingDispatch { .. }
693            | CompactionTrackState::InFlight { .. }
694            | CompactionTrackState::Applying { .. }
695            | CompactionTrackState::NeedsRecovery { .. } => {}
696        }
697    }
698
699    fn finish_success(&mut self, now: Instant) -> CompactionTrackFinishAction {
700        self.retry.reset();
701        let (CompactionTrackState::InFlight { attempt, .. }
702        | CompactionTrackState::Applying { attempt, .. }) = &self.state
703        else {
704            unreachable!("Only an in-flight or applying attempt can finish")
705        };
706        if attempt.max_file_sequence_number.is_some() {
707            // Success means this attempt made progress, but only `Drained`
708            // proves that no work remains below this attempt's boundary.
709            self.health.record_progress();
710            // Beneficial PK-index batches can advance their boundary on new commits,
711            // so continuous ingestion may prevent the round from ever draining.
712            // Keep the cooldown even if commits arrive only after this success.
713            // should_trigger still allows immediate continuation without new commits
714            // to finish deferred plans and the empty scan. Full keeps a fixed boundary.
715            let delay =
716                if attempt.pk_index_coordinated && attempt.policy.task_type != TaskType::Full {
717                    Duration::from_secs(self.config.trigger_interval_sec)
718                } else {
719                    Duration::ZERO
720                };
721            self.state = CompactionTrackState::Idle {
722                next_compaction_time: now + delay,
723                manual_task_type: None,
724            };
725        } else {
726            self.demand.commit_count = self
727                .demand
728                .commit_count
729                .saturating_sub(attempt.pending_commit_count_at_start);
730            self.health
731                .record_completion(attempt.gc_watermark_snapshot.clone());
732            self.state = CompactionTrackState::Idle {
733                next_compaction_time: now + Duration::from_secs(self.config.trigger_interval_sec),
734                manual_task_type: None,
735            };
736        }
737        self.end_disabled_round();
738        self.finish_action
739    }
740
741    fn end_disabled_round(&mut self) {
742        if self.config.pk_index_coordinated
743            && !self.config.automatic_enabled
744            && matches!(self.state, CompactionTrackState::Idle { .. })
745        {
746            self.round = None;
747            self.demand.reconciliation_pending = true;
748        }
749    }
750
751    /// Completes a sequence-bounded round while preserving commits that arrived
752    /// after the last attempt's boundary for subsequent work.
753    fn finish_drained(&mut self, now: Instant) -> CompactionTrackFinishAction {
754        self.retry.reset();
755        let (CompactionTrackState::InFlight { attempt, .. }
756        | CompactionTrackState::Applying { attempt, .. }) = &self.state
757        else {
758            unreachable!("Only an in-flight or applying attempt can finish")
759        };
760        debug_assert_eq!(attempt.max_file_sequence_number, self.round_boundary());
761        debug_assert!(attempt.max_file_sequence_number.is_some());
762        let round_start_snapshot = self.health.round_start_snapshot.take();
763        self.health.record_completion(round_start_snapshot);
764        self.round = None;
765        self.state = CompactionTrackState::Idle {
766            next_compaction_time: now + Duration::from_secs(self.config.trigger_interval_sec),
767            manual_task_type: None,
768        };
769        self.finish_action
770    }
771
772    fn compaction_lag(&self) -> Option<Duration> {
773        let latest = self.latest_observed_snapshot.as_ref()?;
774        let compacted = self.health.compacted_snapshot.as_ref()?;
775        let lag_ms = latest.timestamp_ms.saturating_sub(compacted.timestamp_ms);
776        Some(Duration::from_millis(lag_ms.max(0) as u64))
777    }
778
779    fn round_boundary(&self) -> Option<i64> {
780        self.round
781            .as_ref()
782            .map(|round| round.max_file_sequence_number)
783    }
784
785    fn is_bounded_attempt(&self) -> bool {
786        matches!(
787            &self.state,
788            CompactionTrackState::InFlight { attempt, .. }
789                | CompactionTrackState::Applying { attempt, .. }
790                if attempt.max_file_sequence_number.is_some()
791        )
792    }
793}
794
795pub(crate) struct IcebergCompactionHandle {
796    sink_id: SinkId,
797    attempt: Arc<CompactionAttempt>,
798    inner: Arc<RwLock<IcebergCompactionManagerInner>>,
799    metadata_manager: MetadataManager,
800    dispatched: bool,
801}
802
803impl IcebergCompactionHandle {
804    fn new(
805        sink_id: SinkId,
806        attempt: Arc<CompactionAttempt>,
807        inner: Arc<RwLock<IcebergCompactionManagerInner>>,
808        metadata_manager: MetadataManager,
809    ) -> Self {
810        Self {
811            sink_id,
812            attempt,
813            inner,
814            metadata_manager,
815            dispatched: false,
816        }
817    }
818
819    pub async fn send_compact_task(
820        mut self,
821        compactor: Arc<crate::hummock::IcebergCompactor>,
822        task_id: IcebergCompactionTaskId,
823    ) -> MetaResult<()> {
824        let Some(prost_sink_catalog) = self
825            .metadata_manager
826            .catalog_controller
827            .get_sink_by_id(self.sink_id)
828            .await?
829        else {
830            tracing::warn!(
831                iceberg_component = "compaction_scheduler",
832                iceberg_operation = "dispatch_task",
833                sink_id = %self.sink_id,
834                task_id = %task_id,
835                "iceberg_compaction_dispatch_sink_not_found",
836            );
837            return Ok(());
838        };
839        let sink_catalog = SinkCatalog::from(prost_sink_catalog);
840        let mut param = SinkParam::try_from_sink_catalog(sink_catalog)?;
841        if self.attempt.pk_index_coordinated && self.attempt.max_file_sequence_number.is_some() {
842            param.properties.extend(self.attempt.policy.properties());
843        }
844
845        self.try_dispatch_task(
846            &compactor,
847            IcebergCompactionTask {
848                task_id,
849                sink_id: self.sink_id.as_raw_id(),
850                props: param.properties,
851                task_type: self.attempt.policy.task_type as i32,
852                pk_index_coordinated: self.attempt.pk_index_coordinated,
853                max_file_sequence_number: self.attempt.max_file_sequence_number,
854            },
855        )
856    }
857
858    fn try_dispatch_task(
859        &mut self,
860        compactor: &crate::hummock::IcebergCompactor,
861        task: IcebergCompactionTask,
862    ) -> MetaResult<()> {
863        use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_response::Event as IcebergResponseEvent;
864
865        let task_id = task.task_id;
866        // Validate and send under the same lock so a cleared schedule cannot
867        // dispatch a task after the sink has been removed.
868        let mut guard = self.inner.write();
869        let Some(track) = guard.sink_schedules.get_mut(&self.sink_id).filter(|track| {
870            track.source_is_ready()
871                && matches!(
872                    &track.state,
873                    CompactionTrackState::PendingDispatch { attempt }
874                        if Arc::ptr_eq(attempt, &self.attempt)
875                )
876        }) else {
877            tracing::warn!(
878                iceberg_component = "compaction_scheduler",
879                iceberg_operation = "dispatch_task",
880                sink_id = %self.sink_id,
881                task_id = %task_id,
882                "iceberg_compaction_dispatch_track_not_pending",
883            );
884            return Ok(());
885        };
886
887        let result = compactor.send_event(IcebergResponseEvent::CompactTask(task));
888        if result.is_ok() {
889            track.mark_dispatched(task_id, compactor.context_id(), Instant::now());
890            self.dispatched = true;
891        }
892        result
893    }
894}
895
896impl Drop for IcebergCompactionHandle {
897    fn drop(&mut self) {
898        let waiter = {
899            let mut guard = self.inner.write();
900            let finish_action = if !self.dispatched
901                && let Some(track) = guard.sink_schedules.get_mut(&self.sink_id)
902                && matches!(
903                    &track.state,
904                    CompactionTrackState::PendingDispatch { attempt }
905                        if Arc::ptr_eq(attempt, &self.attempt)
906                ) {
907                Some(track.revert_pre_dispatch_failure(Instant::now()))
908            } else {
909                None
910            };
911            if let Some(finish_action) = finish_action {
912                let waiter = guard.manual_compaction_waiters.remove(&self.sink_id);
913                IcebergCompactionManager::apply_track_finish_action(
914                    &mut guard,
915                    self.sink_id,
916                    finish_action,
917                );
918                waiter
919            } else {
920                None
921            }
922        };
923
924        if let Some(waiter) = waiter {
925            let _ = waiter.send(Err(anyhow!(
926                "Iceberg compaction task failed before dispatch for sink {}",
927                self.sink_id
928            )
929            .into()));
930        }
931    }
932}
933
934#[derive(Debug, Clone)]
935enum SinkUpdateKind {
936    /// A normal sink commit. It increases the pending snapshot count.
937    Commit {
938        observed_snapshot: IcebergCommittedSnapshot,
939    },
940    Maintenance {
941        observed_snapshot: IcebergCommittedSnapshot,
942    },
943    /// A force signal from the sink update path. It triggers the configured
944    /// compaction type and still follows the automatic-compaction config gate.
945    ForceCompaction {
946        observed_snapshot: IcebergCommittedSnapshot,
947    },
948    /// A user-triggered manual request. It can bypass disabled automatic
949    /// compaction and supplies the task type selected for this request.
950    ManualForceCompaction { task_type: TaskType },
951    /// Refresh policy without counting a commit or replacing the observed head.
952    ConfigChange,
953}
954
955impl SinkUpdateKind {
956    fn apply_to_track(self, track: &mut CompactionTrack, now: Instant) {
957        match self {
958            SinkUpdateKind::Commit { observed_snapshot } => {
959                if track.record_observed_snapshot(observed_snapshot) {
960                    track.record_commit_at(now);
961                }
962            }
963            SinkUpdateKind::Maintenance { observed_snapshot } => {
964                track.record_observed_snapshot(observed_snapshot);
965            }
966            SinkUpdateKind::ForceCompaction { observed_snapshot } => {
967                track.record_observed_snapshot(observed_snapshot);
968                track.record_force_compaction(now, None);
969            }
970            SinkUpdateKind::ManualForceCompaction { task_type } => {
971                track.record_force_compaction(now, Some(task_type))
972            }
973            SinkUpdateKind::ConfigChange => {}
974        }
975    }
976
977    fn allows_disabled_compaction(&self) -> bool {
978        matches!(self, SinkUpdateKind::ManualForceCompaction { .. })
979    }
980}
981
982/// Result of the read-only preparation step before applying a sink update.
983///
984/// This bundles the original update intent together with the metadata loaded
985/// across the async gap, so the apply step can consume a single object.
986///
987/// `allow_track_initialization` stays `true` only when the sink had no track
988/// before the async config load. This lets the apply step initialize a new
989/// track for first-time updates, while preventing a stale update from
990/// resurrecting a track that disappeared during the async gap.
991/// `recovery_generation` also rejects updates that cross recovery or registration.
992struct PreparedSinkUpdate {
993    sink_id: SinkId,
994    kind: SinkUpdateKind,
995    now: Instant,
996    allow_track_initialization: bool,
997    recovery_generation: u64,
998    loaded_config: Option<IcebergConfig>,
999}
1000
1001#[derive(Debug, Clone)]
1002pub struct IcebergCompactionScheduleStatus {
1003    pub sink_id: SinkId,
1004    pub task_type: String,
1005    pub trigger_interval_sec: u64,
1006    pub trigger_snapshot_count: usize,
1007    pub schedule_state: String,
1008    pub next_compaction_after_sec: Option<u64>,
1009    pub pending_snapshot_count: Option<usize>,
1010    pub is_triggerable: bool,
1011    pub last_success_at: Option<SystemTime>,
1012    pub last_failure_at: Option<SystemTime>,
1013    pub last_error: Option<String>,
1014    pub consecutive_failures: u32,
1015    /// Commit-time gap between the latest observed snapshot and the snapshot
1016    /// consumed by the last completed compaction. For copy-on-write sinks, this
1017    /// is how far `main` lags behind the ingestion branch.
1018    pub compaction_lag: Option<Duration>,
1019    pub wait_reason: String,
1020    pub retry_after_sec: Option<u64>,
1021    pub blocked_age_sec: Option<u64>,
1022}
1023
1024impl IcebergCompactionManager {
1025    fn apply_track_finish_action(
1026        guard: &mut IcebergCompactionManagerInner,
1027        sink_id: SinkId,
1028        finish_action: CompactionTrackFinishAction,
1029    ) {
1030        match finish_action {
1031            CompactionTrackFinishAction::KeepTrack => {}
1032            CompactionTrackFinishAction::RemoveTrack => {
1033                guard.sink_schedules.remove(&sink_id);
1034            }
1035        }
1036    }
1037
1038    pub(super) fn refresh_schedule_config(
1039        &self,
1040        track: &mut CompactionTrack,
1041        iceberg_config: &IcebergConfig,
1042        now: Instant,
1043    ) {
1044        let config = self.resolve_schedule_config(iceberg_config, now);
1045        debug_assert_eq!(
1046            track.config.write_mode, config.write_mode,
1047            "Iceberg write mode cannot change while a schedule track exists"
1048        );
1049        debug_assert_eq!(
1050            track.config.pk_index_coordinated,
1051            config.pk_index_coordinated
1052        );
1053        if track.config.pk_index_coordinated
1054            && track.latest_observed_snapshot.is_some()
1055            && (track.config.policy != config.policy
1056                || (!track.config.automatic_enabled && config.automatic_enabled))
1057        {
1058            track.demand.reconciliation_pending = true;
1059        }
1060        track.update_interval(config.trigger_interval_sec, now);
1061        track.config = config;
1062        track.end_disabled_round();
1063    }
1064
1065    /// Schedules existing Iceberg sinks once after meta starts. See
1066    /// `iceberg_compaction_schedule_on_startup`.
1067    pub fn schedule_on_startup_task(manager: Arc<Self>) -> (JoinHandle<()>, oneshot::Sender<()>) {
1068        let (shutdown_tx, shutdown_rx) = oneshot::channel();
1069        let join_handle = tokio::spawn(async move {
1070            tokio::select! {
1071                _ = manager.schedule_existing_sinks() => {}
1072                _ = shutdown_rx => {}
1073            }
1074        });
1075        (join_handle, shutdown_tx)
1076    }
1077
1078    async fn schedule_existing_sinks(&self) {
1079        let sinks = match self.metadata_manager.catalog_controller.list_sinks().await {
1080            Ok(sinks) => sinks,
1081            Err(e) => {
1082                tracing::warn!(
1083                    error = %e.as_report(),
1084                    "failed to list sinks to schedule iceberg compaction on startup"
1085                );
1086                return;
1087            }
1088        };
1089        let sink_ids = sinks
1090            .into_iter()
1091            .filter(|sink| {
1092                sink.stream_job_status() == PbStreamJobStatus::Created
1093                    && is_iceberg_sink(&sink.properties)
1094            })
1095            .map(|sink| sink.id);
1096        futures::stream::iter(sink_ids)
1097            .for_each_concurrent(16, |sink_id| async move {
1098                if let Err(e) = self.schedule_sink_on_startup(sink_id).await {
1099                    tracing::warn!(
1100                        %sink_id,
1101                        error = %e.as_report(),
1102                        "failed to schedule iceberg compaction on startup"
1103                    );
1104                }
1105            })
1106            .await;
1107    }
1108
1109    /// Records the head of the commit branch as one sink commit, so the sink is compacted after
1110    /// the compaction interval. The compaction commits nothing if the table is already clean.
1111    async fn schedule_sink_on_startup(&self, sink_id: SinkId) -> MetaResult<()> {
1112        // A commit after the restart has already scheduled the sink.
1113        if self.inner.read().sink_schedules.contains_key(&sink_id) {
1114            return Ok(());
1115        }
1116        let config = self.load_iceberg_config(sink_id).await?;
1117        // PK-index scheduling is initialized only after its coordinator has settled recovery.
1118        if !config.enable_compaction || config.enable_pk_index {
1119            return Ok(());
1120        }
1121        let table = config.load_table().await?;
1122        let branch = commit_branch(config.r#type.as_str(), config.write_mode);
1123        // A branch without a snapshot has nothing to compact.
1124        let Some(observed_snapshot) =
1125            IcebergCommittedSnapshot::from_branch_head(table.metadata(), &branch)
1126        else {
1127            return Ok(());
1128        };
1129        let prepared_update = PreparedSinkUpdate {
1130            sink_id,
1131            kind: SinkUpdateKind::Commit { observed_snapshot },
1132            now: Instant::now(),
1133            allow_track_initialization: true,
1134            recovery_generation: 0,
1135            loaded_config: Some(config),
1136        };
1137        let mut guard = self.inner.write();
1138        // A commit during the loads above may have recorded a newer snapshot. Do not overwrite
1139        // it with the head read here.
1140        if !guard.sink_schedules.contains_key(&sink_id) {
1141            self.apply_sink_update(&mut guard, prepared_update);
1142        }
1143        Ok(())
1144    }
1145
1146    pub async fn update_iceberg_commit_info(&self, msg: IcebergSinkCompactionUpdate) {
1147        let IcebergSinkCompactionUpdate {
1148            sink_id,
1149            force_compaction,
1150            has_new_data,
1151            observed_snapshot,
1152        } = msg;
1153        let kind = if force_compaction {
1154            SinkUpdateKind::ForceCompaction { observed_snapshot }
1155        } else if has_new_data {
1156            SinkUpdateKind::Commit { observed_snapshot }
1157        } else {
1158            SinkUpdateKind::Maintenance { observed_snapshot }
1159        };
1160        let prepared_update = self
1161            .prepare_sink_update(sink_id, kind, Instant::now())
1162            .await;
1163
1164        let mut guard = self.inner.write();
1165        self.apply_sink_update(&mut guard, prepared_update);
1166    }
1167
1168    /// Apply maintenance-only ALTER options to a registered PK-index sink, even when
1169    /// ingestion is idle. Readiness publication remains the only initialization path.
1170    pub fn on_sink_config_changed(&self, sink_id: SinkId, config: IcebergConfig) {
1171        debug_assert!(config.enable_pk_index);
1172        let mut guard = self.inner.write();
1173        if guard.is_recovering(sink_id) || !guard.sink_schedules.contains_key(&sink_id) {
1174            return;
1175        }
1176        let recovery_generation = guard.recovery_generation(sink_id);
1177        self.apply_sink_update(
1178            &mut guard,
1179            PreparedSinkUpdate {
1180                sink_id,
1181                kind: SinkUpdateKind::ConfigChange,
1182                now: Instant::now(),
1183                allow_track_initialization: false,
1184                recovery_generation,
1185                loaded_config: Some(config),
1186            },
1187        );
1188    }
1189
1190    /// Discard PK-index scheduling before replacing coordinators. Persisted sink state, not the
1191    /// old attempt, determines which rewrites recovery commits.
1192    pub fn on_recovery_start(&self, sink_ids: Option<&[SinkId]>) {
1193        let mut guard = self.inner.write();
1194        let mut affected = guard
1195            .sink_schedules
1196            .iter()
1197            .filter(|(_, track)| track.config.pk_index_coordinated)
1198            .map(|(&sink_id, _)| sink_id)
1199            .chain(
1200                guard
1201                    .sink_recovery
1202                    .iter()
1203                    .filter(|(_, state)| state.recovering)
1204                    .map(|(&sink_id, _)| sink_id),
1205            )
1206            .filter(|sink_id| sink_ids.is_none_or(|ids| ids.contains(sink_id)))
1207            .collect::<Vec<_>>();
1208        affected.sort();
1209        affected.dedup();
1210        let mut cancel = Vec::new();
1211        for sink_id in affected {
1212            let generation = guard.next_recovery_generation();
1213            let state = guard.sink_recovery.entry(sink_id).or_default();
1214            state.generation = generation;
1215            state.recovering = true;
1216            if let Some(task) = Self::remove_sink_schedule(&mut guard, sink_id) {
1217                cancel.push((sink_id, task));
1218            }
1219            if let Some(waiter) = guard.manual_compaction_waiters.remove(&sink_id) {
1220                let _ = waiter.send(Err(anyhow!(
1221                    "manual compaction interrupted by recovery for sink {}",
1222                    sink_id
1223                )
1224                .into()));
1225            }
1226        }
1227        drop(guard);
1228        for (id, task) in cancel {
1229            self.cancel_scheduled_task_if_any(id, Some(task));
1230        }
1231    }
1232
1233    /// Publish only after CREATE post-collect or successful database recovery unblocks its queue.
1234    /// The coordinator registry serializes publication with retirement. Keeping its registration
1235    /// also prevents updates/dispatch from using a retired instance before schedule cleanup.
1236    pub(crate) fn on_sink_ready(
1237        &self,
1238        sink_id: SinkId,
1239        registration: Arc<IcebergPkIndexSinkRegistration>,
1240        observed_snapshot: Option<IcebergCommittedSnapshot>,
1241    ) -> bool {
1242        let mut guard = self.inner.write();
1243        if !registration.is_ready() {
1244            return false;
1245        }
1246        let config = registration.config();
1247        debug_assert!(config.enable_pk_index);
1248        let now = Instant::now();
1249        let mut track = self.create_compaction_track(&config, now);
1250        // Reconcile existing data, but let an empty table's future ingestion follow
1251        // the normal commit threshold and interval.
1252        if let Some(snapshot) = observed_snapshot {
1253            track.record_observed_snapshot(snapshot);
1254            track.demand.reconciliation_pending = true;
1255        }
1256        track.registration = Some(registration);
1257        track.state = CompactionTrackState::Idle {
1258            next_compaction_time: now,
1259            manual_task_type: None,
1260        };
1261        let generation = guard.next_recovery_generation();
1262        let state = guard.sink_recovery.entry(sink_id).or_default();
1263        state.generation = generation;
1264        state.recovering = false;
1265        if config.enable_snapshot_expiration {
1266            guard.snapshot_expiration_sink_ids.insert(sink_id);
1267        } else {
1268            guard.snapshot_expiration_sink_ids.remove(&sink_id);
1269        }
1270        if config.enable_manifest_rewrite {
1271            guard.manifest_rewrite_sink_ids.insert(sink_id);
1272        } else {
1273            guard.manifest_rewrite_sink_ids.remove(&sink_id);
1274        }
1275        if config.enable_orphan_file_cleanup {
1276            guard.orphan_file_cleanup_sink_ids.insert(sink_id);
1277        } else {
1278            guard.orphan_file_cleanup_sink_ids.remove(&sink_id);
1279        }
1280        let task = Self::remove_sink_schedule(&mut guard, sink_id);
1281        guard.sink_schedules.insert(sink_id, track);
1282        if let Some(waiter) = guard.manual_compaction_waiters.remove(&sink_id) {
1283            let _ = waiter.send(Err(anyhow!(
1284                "manual compaction interrupted by coordinator registration for sink {}",
1285                sink_id
1286            )
1287            .into()));
1288        }
1289        drop(guard);
1290        self.cancel_scheduled_task_if_any(sink_id, task);
1291        true
1292    }
1293
1294    async fn prepare_sink_update(
1295        &self,
1296        sink_id: SinkId,
1297        kind: SinkUpdateKind,
1298        now: Instant,
1299    ) -> PreparedSinkUpdate {
1300        let refresh_interval = self.config_refresh_interval();
1301        let (allow_track_initialization, should_refresh_config, recovery_generation) = {
1302            let guard = self.inner.read();
1303            let (allow_track_initialization, should_refresh_config) =
1304                match guard.sink_schedules.get(&sink_id) {
1305                    Some(track) => (false, track.needs_config_refresh(now, refresh_interval)),
1306                    None => (true, true),
1307                };
1308            (
1309                allow_track_initialization,
1310                should_refresh_config,
1311                guard.recovery_generation(sink_id),
1312            )
1313        };
1314
1315        let loaded_config = if should_refresh_config {
1316            match self.load_iceberg_config(sink_id).await {
1317                Ok(config) => Some(config),
1318                Err(e) => {
1319                    tracing::warn!(
1320                        error = ?e.as_report(),
1321                        "Failed to load iceberg config for sink {}",
1322                        sink_id
1323                    );
1324                    None
1325                }
1326            }
1327        } else {
1328            None
1329        };
1330
1331        PreparedSinkUpdate {
1332            sink_id,
1333            kind,
1334            now,
1335            allow_track_initialization,
1336            recovery_generation,
1337            loaded_config,
1338        }
1339    }
1340
1341    fn apply_sink_update(
1342        &self,
1343        guard: &mut IcebergCompactionManagerInner,
1344        prepared_update: PreparedSinkUpdate,
1345    ) -> bool {
1346        let PreparedSinkUpdate {
1347            sink_id,
1348            kind,
1349            now,
1350            allow_track_initialization,
1351            recovery_generation,
1352            loaded_config,
1353        } = prepared_update;
1354        if guard.is_recovering(sink_id) || guard.recovery_generation(sink_id) != recovery_generation
1355        {
1356            return false;
1357        }
1358        // A config load started before ALTER must not overwrite the newer policy.
1359        // Its commit observation is still applied below.
1360        let loaded_config = loaded_config.filter(|_| {
1361            guard
1362                .sink_schedules
1363                .get(&sink_id)
1364                .is_none_or(|track| track.config.refreshed_at <= now)
1365        });
1366        // Commit/manual/maintenance updates must never create PK-index scheduling or enable
1367        // metadata maintenance before the authoritative coordinator registration is ready.
1368        let track = guard.sink_schedules.get(&sink_id);
1369        if track.is_some_and(|track| !track.source_is_ready())
1370            || (loaded_config
1371                .as_ref()
1372                .is_some_and(|config| config.enable_pk_index)
1373                && !track.is_some_and(|track| {
1374                    track.config.pk_index_coordinated && track.source_is_ready()
1375                }))
1376        {
1377            return false;
1378        }
1379        let refresh_interval = self.config_refresh_interval();
1380
1381        if let Some(config) = loaded_config.as_ref() {
1382            if config.enable_snapshot_expiration {
1383                guard.snapshot_expiration_sink_ids.insert(sink_id);
1384            } else {
1385                guard.snapshot_expiration_sink_ids.remove(&sink_id);
1386            }
1387            if config.enable_manifest_rewrite {
1388                guard.manifest_rewrite_sink_ids.insert(sink_id);
1389            } else {
1390                guard.manifest_rewrite_sink_ids.remove(&sink_id);
1391            }
1392            if config.enable_orphan_file_cleanup {
1393                guard.orphan_file_cleanup_sink_ids.insert(sink_id);
1394            } else {
1395                guard.orphan_file_cleanup_sink_ids.remove(&sink_id);
1396            }
1397
1398            if !config.enable_compaction
1399                && !config.enable_pk_index
1400                && !kind.allows_disabled_compaction()
1401            {
1402                let keep_until_task_finishes =
1403                    guard.sink_schedules.get_mut(&sink_id).is_some_and(|track| {
1404                        let keep = track.has_queued_task_or_active_attempt();
1405                        if keep {
1406                            // Preserve the selected attempt, including its round boundary, so
1407                            // its report is interpreted consistently. The disabled track is
1408                            // removed as soon as that one-shot task finishes.
1409                            track.finish_action = CompactionTrackFinishAction::RemoveTrack;
1410                        }
1411                        keep
1412                    });
1413                if !keep_until_task_finishes {
1414                    guard.sink_schedules.remove(&sink_id);
1415                }
1416                return false;
1417            }
1418        }
1419
1420        match guard.sink_schedules.entry(sink_id) {
1421            Entry::Occupied(entry) => {
1422                let track = entry.into_mut();
1423                if track.removes_track_after_finish()
1424                    && !kind.allows_disabled_compaction()
1425                    && !loaded_config
1426                        .as_ref()
1427                        .is_some_and(|config| config.enable_compaction)
1428                {
1429                    return false;
1430                }
1431                if (track.config.pk_index_coordinated
1432                    || track.needs_config_refresh(now, refresh_interval))
1433                    && let Some(config) = loaded_config.as_ref()
1434                {
1435                    self.refresh_schedule_config(track, config, now);
1436                }
1437                if let Some(config) = loaded_config.as_ref() {
1438                    track.finish_action = if kind.allows_disabled_compaction()
1439                        && !config.enable_compaction
1440                        && !config.enable_pk_index
1441                    {
1442                        CompactionTrackFinishAction::RemoveTrack
1443                    } else {
1444                        CompactionTrackFinishAction::KeepTrack
1445                    };
1446                }
1447
1448                kind.apply_to_track(track, now);
1449                true
1450            }
1451            Entry::Vacant(entry) => {
1452                if !allow_track_initialization {
1453                    tracing::warn!(
1454                        iceberg_component = "compaction_scheduler",
1455                        iceberg_operation = "apply_sink_update",
1456                        sink_id = %sink_id,
1457                        "iceberg_compaction_update_ignored_track_missing",
1458                    );
1459                    return false;
1460                }
1461
1462                let Some(config) = loaded_config.as_ref() else {
1463                    tracing::warn!(
1464                        iceberg_component = "compaction_scheduler",
1465                        iceberg_operation = "apply_sink_update",
1466                        sink_id = %sink_id,
1467                        "iceberg_compaction_update_ignored_config_unavailable",
1468                    );
1469                    return false;
1470                };
1471
1472                let track = entry.insert(self.create_compaction_track(config, now));
1473                track.finish_action = if kind.allows_disabled_compaction()
1474                    && !config.enable_compaction
1475                    && !config.enable_pk_index
1476                {
1477                    CompactionTrackFinishAction::RemoveTrack
1478                } else {
1479                    CompactionTrackFinishAction::KeepTrack
1480                };
1481                kind.apply_to_track(track, now);
1482                true
1483            }
1484        }
1485    }
1486
1487    pub(super) fn create_compaction_track(
1488        &self,
1489        iceberg_config: &IcebergConfig,
1490        now: Instant,
1491    ) -> CompactionTrack {
1492        CompactionTrack::new(self.resolve_schedule_config(iceberg_config, now), now)
1493    }
1494
1495    fn resolve_schedule_config(
1496        &self,
1497        iceberg_config: &IcebergConfig,
1498        now: Instant,
1499    ) -> CompactionScheduleConfig {
1500        // COW compaction type is an internal policy. Ignore the legacy persisted value so that
1501        // both existing and new COW sinks follow the license-based default.
1502        let configured_type = match iceberg_config.write_mode {
1503            IcebergWriteMode::CopyOnWrite => None,
1504            IcebergWriteMode::MergeOnRead => iceberg_config.compaction_type,
1505        };
1506        let compaction_type = match configured_type {
1507            Some(compaction_type) => compaction_type,
1508            None if Feature::IcebergCompaction.check_available().is_ok() => CompactionType::Auto,
1509            None => CompactionType::Full,
1510        };
1511
1512        CompactionScheduleConfig {
1513            policy: CompactionPolicy {
1514                task_type: match compaction_type {
1515                    CompactionType::Auto => TaskType::Auto,
1516                    CompactionType::Full => TaskType::Full,
1517                    CompactionType::SmallFiles => TaskType::SmallFiles,
1518                    CompactionType::FilesWithDelete => TaskType::FilesWithDelete,
1519                },
1520                small_files_threshold_mb: iceberg_config.small_files_threshold_mb(),
1521                target_file_size_mb: iceberg_config.target_file_size_mb(),
1522                delete_ratio_percent: iceberg_config.delete_ratio_percent(),
1523            },
1524            write_mode: iceberg_config.write_mode,
1525            pk_index_coordinated: iceberg_config.enable_pk_index,
1526            automatic_enabled: iceberg_config.enable_compaction,
1527            trigger_interval_sec: iceberg_config.compaction_interval_sec(),
1528            trigger_snapshot_count: iceberg_config.trigger_snapshot_count(),
1529            report_timeout: self.report_timeout(),
1530            refreshed_at: now,
1531        }
1532    }
1533
1534    pub(super) async fn start_manual_compaction(
1535        &self,
1536        sink_id: SinkId,
1537    ) -> MetaResult<oneshot::Receiver<MetaResult<IcebergCompactionTaskId>>> {
1538        let prepared_update = self
1539            .prepare_sink_update(
1540                sink_id,
1541                SinkUpdateKind::ManualForceCompaction {
1542                    task_type: TaskType::Full,
1543                },
1544                Instant::now(),
1545            )
1546            .await;
1547        let mut guard = self.inner.write();
1548        let now = Instant::now();
1549        if guard.manual_compaction_waiters.contains_key(&sink_id) {
1550            return Err(anyhow!(
1551                "manual iceberg compaction is already waiting for sink {}",
1552                sink_id
1553            )
1554            .into());
1555        }
1556
1557        if guard.is_recovering(sink_id) {
1558            return Err(anyhow!(
1559                "manual compaction is rejected during recovery for sink {}",
1560                sink_id
1561            )
1562            .into());
1563        }
1564        if let Some(track) = guard.sink_schedules.get(&sink_id) {
1565            if track.round.is_some() {
1566                return Err(anyhow!(
1567                    "manual Full compaction is rejected while an automatic round is active for sink {}",
1568                    sink_id
1569                )
1570                .into());
1571            }
1572            match &track.state {
1573                CompactionTrackState::PendingDispatch { attempt } => {
1574                    return Err(anyhow!(
1575                        "iceberg compaction task is already running for sink {} \
1576                         (state=pending_dispatch, pending_commit_count_at_start={}, \
1577                         pending_commit_count={})",
1578                        sink_id,
1579                        attempt.pending_commit_count_at_start,
1580                        track.demand.commit_count
1581                    )
1582                    .into());
1583                }
1584                CompactionTrackState::InFlight {
1585                    task_id,
1586                    attempt,
1587                    report_deadline,
1588                    ..
1589                } => {
1590                    return Err(anyhow!(
1591                        "iceberg compaction task is already running for sink {} \
1592                         (state=in_flight, task_id={}, pending_commit_count_at_start={}, \
1593                         pending_commit_count={}, report_timeout_after_sec={})",
1594                        sink_id,
1595                        task_id,
1596                        attempt.pending_commit_count_at_start,
1597                        track.demand.commit_count,
1598                        report_deadline.saturating_duration_since(now).as_secs()
1599                    )
1600                    .into());
1601                }
1602                CompactionTrackState::Idle { .. } => {}
1603                CompactionTrackState::Applying { task_id, .. }
1604                | CompactionTrackState::NeedsRecovery { task_id, .. } => {
1605                    return Err(anyhow!(
1606                        "iceberg compaction task {} is already applying for sink {}",
1607                        task_id,
1608                        sink_id
1609                    )
1610                    .into());
1611                }
1612            }
1613        }
1614
1615        if self.apply_sink_update(&mut guard, prepared_update) {
1616            let (tx, rx) = oneshot::channel();
1617            guard.manual_compaction_waiters.insert(sink_id, tx);
1618            Ok(rx)
1619        } else {
1620            Err(anyhow!(
1621                "failed to trigger manual iceberg compaction for sink {}",
1622                sink_id
1623            )
1624            .into())
1625        }
1626    }
1627
1628    #[cfg(test)]
1629    pub(super) fn cancel_manual_compaction_waiter(&self, sink_id: SinkId) {
1630        self.inner
1631            .write()
1632            .manual_compaction_waiters
1633            .remove(&sink_id);
1634    }
1635
1636    fn finish_timed_out_compaction_tasks(
1637        guard: &mut IcebergCompactionManagerInner,
1638        now: Instant,
1639    ) -> Vec<(SinkId, ManualCompactionWaiter)> {
1640        let mut timed_out_tasks = Vec::new();
1641        for (&sink_id, track) in &mut guard.sink_schedules {
1642            if track.is_report_timed_out(now) {
1643                tracing::warn!(
1644                    iceberg_component = "compaction_scheduler",
1645                    iceberg_operation = "report_timeout",
1646                    sink_id = %sink_id,
1647                    "iceberg_compaction_task_report_timed_out",
1648                );
1649                let error = format!(
1650                    "compaction task report timed out after {}s",
1651                    track.config.report_timeout.as_secs()
1652                );
1653                timed_out_tasks.push((sink_id, track.finish_failed(now, error)));
1654            }
1655        }
1656
1657        let mut timed_out_waiters = Vec::new();
1658        for (sink_id, finish_action) in timed_out_tasks {
1659            if let Some(waiter) = guard.manual_compaction_waiters.remove(&sink_id) {
1660                timed_out_waiters.push((sink_id, waiter));
1661            }
1662            Self::apply_track_finish_action(guard, sink_id, finish_action);
1663        }
1664        timed_out_waiters
1665    }
1666
1667    pub(crate) fn get_top_n_iceberg_commit_sink_ids(
1668        &self,
1669        n: usize,
1670    ) -> Vec<IcebergCompactionHandle> {
1671        let now = Instant::now();
1672        let (handles, timed_out_waiters) = {
1673            let mut guard = self.inner.write();
1674            let timed_out_waiters = Self::finish_timed_out_compaction_tasks(&mut guard, now);
1675
1676            let mut candidates = Vec::new();
1677            for (sink_id, track) in &guard.sink_schedules {
1678                if !guard.is_recovering(*sink_id)
1679                    && track.source_is_ready()
1680                    && track.should_trigger(now)
1681                    && let CompactionTrackState::Idle {
1682                        next_compaction_time,
1683                        ..
1684                    } = &track.state
1685                {
1686                    // Active PK-index work can be eligible before its cooldown.
1687                    // Sort it as ready now without changing the stored deadline.
1688                    let eligible_at = if track.round.is_some() && track.config.pk_index_coordinated
1689                    {
1690                        (*next_compaction_time).min(now)
1691                    } else {
1692                        *next_compaction_time
1693                    };
1694                    candidates.push((*sink_id, eligible_at));
1695                }
1696            }
1697
1698            candidates.sort_by_key(|c| (c.1, c.0));
1699            let handles = candidates
1700                .into_iter()
1701                .take(n)
1702                .filter_map(|(sink_id, _)| {
1703                    let track = guard.sink_schedules.get_mut(&sink_id)?;
1704                    let attempt = track.start_attempt();
1705
1706                    Some(IcebergCompactionHandle::new(
1707                        sink_id,
1708                        attempt,
1709                        self.inner.clone(),
1710                        self.metadata_manager.clone(),
1711                    ))
1712                })
1713                .collect();
1714
1715            (handles, timed_out_waiters)
1716        };
1717
1718        for (sink_id, waiter) in timed_out_waiters {
1719            let _ = waiter.send(Err(anyhow!(
1720                "Iceberg compaction task report timed out for sink {}",
1721                sink_id
1722            )
1723            .into()));
1724        }
1725
1726        handles
1727    }
1728
1729    pub fn clear_iceberg_maintenance_by_sink_id(&self, sink_id: SinkId) {
1730        let (task_to_cancel, waiter) = {
1731            let mut guard = self.inner.write();
1732            let task_to_cancel = Self::remove_sink_schedule(&mut guard, sink_id);
1733            guard.sink_recovery.remove(&sink_id);
1734            guard.snapshot_expiration_sink_ids.remove(&sink_id);
1735            guard.manifest_rewrite_sink_ids.remove(&sink_id);
1736            guard.orphan_file_cleanup_sink_ids.remove(&sink_id);
1737            let waiter = guard.manual_compaction_waiters.remove(&sink_id);
1738            (task_to_cancel, waiter)
1739        };
1740        self.orphan_file_cleanup_locks.lock().remove(&sink_id);
1741        self.cancel_scheduled_task_if_any(sink_id, task_to_cancel);
1742
1743        if let Some(waiter) = waiter {
1744            let _ = waiter.send(Err(anyhow!(
1745                "Iceberg compaction maintenance was cleared for sink {}",
1746                sink_id
1747            )
1748            .into()));
1749        }
1750    }
1751
1752    fn remove_sink_schedule(
1753        guard: &mut IcebergCompactionManagerInner,
1754        sink_id: SinkId,
1755    ) -> Option<ScheduledCompactionTask> {
1756        guard
1757            .sink_schedules
1758            .remove(&sink_id)
1759            .and_then(|track| track.scheduled_task())
1760    }
1761
1762    fn cancel_scheduled_task_if_any(&self, sink_id: SinkId, task: Option<ScheduledCompactionTask>) {
1763        let Some(ScheduledCompactionTask {
1764            task_id,
1765            compactor_context_id,
1766        }) = task
1767        else {
1768            return;
1769        };
1770
1771        let Some(compactor) = self
1772            .iceberg_compactor_manager
1773            .get_compactor(compactor_context_id)
1774        else {
1775            tracing::warn!(
1776                sink_id = %sink_id,
1777                task_id = %task_id,
1778                compactor_context_id = %compactor_context_id,
1779                "Unable to cancel iceberg compaction task because compactor is no longer registered",
1780            );
1781            return;
1782        };
1783
1784        tracing::info!(
1785            sink_id = %sink_id,
1786            task_id = %task_id,
1787            compactor_context_id = %compactor_context_id,
1788            "Cancelling iceberg compaction task for removed schedule",
1789        );
1790
1791        if let Err(e) = compactor.cancel_task(task_id) {
1792            tracing::warn!(
1793                error = %e.as_report(),
1794                sink_id = %sink_id,
1795                task_id = %task_id,
1796                compactor_context_id = %compactor_context_id,
1797                "Failed to cancel iceberg compaction task for removed schedule",
1798            );
1799        }
1800    }
1801
1802    pub fn list_compaction_statuses(&self) -> Vec<IcebergCompactionScheduleStatus> {
1803        let now = Instant::now();
1804        let schedules = {
1805            let guard = self.inner.read();
1806            guard
1807                .sink_schedules
1808                .iter()
1809                .map(|(&sink_id, track)| (sink_id, track.clone()))
1810                .collect_vec()
1811        };
1812
1813        let mut statuses = schedules
1814            .into_iter()
1815            .map(|(sink_id, track)| {
1816                let next_compaction_after_sec = match &track.state {
1817                    CompactionTrackState::Idle {
1818                        next_compaction_time,
1819                        ..
1820                    } => Some(
1821                        next_compaction_time
1822                            .saturating_duration_since(now)
1823                            .as_secs(),
1824                    ),
1825                    CompactionTrackState::PendingDispatch { .. }
1826                    | CompactionTrackState::InFlight { .. }
1827                    | CompactionTrackState::Applying { .. }
1828                    | CompactionTrackState::NeedsRecovery { .. } => None,
1829                };
1830                let is_triggerable = track.should_trigger(now);
1831                let compaction_lag = track.compaction_lag();
1832
1833                IcebergCompactionScheduleStatus {
1834                    sink_id,
1835                    task_type: track
1836                        .effective_task_type()
1837                        .as_str_name()
1838                        .to_ascii_lowercase(),
1839                    trigger_interval_sec: track.config.trigger_interval_sec,
1840                    trigger_snapshot_count: track.config.trigger_snapshot_count,
1841                    schedule_state: match track.state {
1842                        CompactionTrackState::Idle { .. } => "idle".to_owned(),
1843                        CompactionTrackState::PendingDispatch { .. }
1844                        | CompactionTrackState::InFlight { .. } => "processing".to_owned(),
1845                        CompactionTrackState::Applying { .. } => "applying".to_owned(),
1846                        CompactionTrackState::NeedsRecovery { .. } => "needs_recovery".to_owned(),
1847                    },
1848                    next_compaction_after_sec,
1849                    pending_snapshot_count: Some(track.demand.commit_count),
1850                    is_triggerable,
1851                    last_success_at: track.health.last_success_at,
1852                    last_failure_at: track.health.last_failure_at,
1853                    last_error: track.health.last_error,
1854                    consecutive_failures: track.health.consecutive_failures,
1855                    compaction_lag,
1856                    wait_reason: if matches!(
1857                        track.state,
1858                        CompactionTrackState::NeedsRecovery { .. }
1859                    ) {
1860                        "unknown_application_outcome"
1861                    } else if let Some(blocked) = &track.retry.blocked {
1862                        match blocked.reason {
1863                            IcebergReportTaskStatus::BlockedCapacity => "worker_capacity",
1864                            _ => "statistics",
1865                        }
1866                    } else if track.retry.is_waiting(now) {
1867                        "retry_backoff"
1868                    } else if !track.config.automatic_enabled {
1869                        "automatic_disabled"
1870                    } else {
1871                        "none"
1872                    }
1873                    .to_owned(),
1874                    retry_after_sec: track
1875                        .retry
1876                        .not_before
1877                        .map(|deadline| deadline.saturating_duration_since(now).as_secs()),
1878                    blocked_age_sec: track
1879                        .retry
1880                        .blocked
1881                        .as_ref()
1882                        .map(|blocked| now.saturating_duration_since(blocked.since).as_secs()),
1883                }
1884            })
1885            .collect_vec();
1886
1887        statuses.sort_by_key(|status| status.sink_id);
1888        statuses
1889    }
1890
1891    fn finish_report_task(&self, report: IcebergReportTask, applying: bool) {
1892        let sink_id = report.sink_id;
1893        let task_id = report.task_id;
1894        let status = IcebergReportTaskStatus::try_from(report.status)
1895            .unwrap_or(IcebergReportTaskStatus::Unspecified);
1896        let now = Instant::now();
1897
1898        let waiter = {
1899            let mut guard = self.inner.write();
1900            let mut waiter = None;
1901
1902            match guard.sink_schedules.get_mut(&sink_id) {
1903                Some(track)
1904                    if if applying {
1905                        matches!(
1906                            &track.state,
1907                            CompactionTrackState::Applying { task_id: current_task_id, .. }
1908                                if *current_task_id == task_id
1909                        )
1910                    } else {
1911                        track.is_in_flight_task(task_id)
1912                    } =>
1913                {
1914                    let finish_action = match status {
1915                        IcebergReportTaskStatus::Success => track.finish_success(now),
1916                        IcebergReportTaskStatus::Drained if track.is_bounded_attempt() => {
1917                            track.finish_drained(now)
1918                        }
1919                        IcebergReportTaskStatus::BlockedCapacity
1920                        | IcebergReportTaskStatus::Busy
1921                        | IcebergReportTaskStatus::BlockedStatistics
1922                            if track.config.pk_index_coordinated =>
1923                        {
1924                            track.finish_blocked(status, now)
1925                        }
1926                        IcebergReportTaskStatus::BlockedCapacity
1927                        | IcebergReportTaskStatus::Busy
1928                        | IcebergReportTaskStatus::BlockedStatistics
1929                        | IcebergReportTaskStatus::Drained
1930                        | IcebergReportTaskStatus::Failed
1931                        | IcebergReportTaskStatus::Unspecified => {
1932                            tracing::warn!(
1933                                iceberg_component = "compaction_scheduler",
1934                                iceberg_operation = "handle_report",
1935                                sink_id = %sink_id,
1936                                task_id = %task_id,
1937                                status = ?status,
1938                                error_message = report.error_message.as_deref().unwrap_or_default(),
1939                                "iceberg_compaction_task_reported_failure",
1940                            );
1941                            let error = report.error_message.clone().unwrap_or_else(|| {
1942                                format!("compaction task reported {}", status.as_str_name())
1943                            });
1944                            track.finish_failed(now, error)
1945                        }
1946                    };
1947
1948                    Self::apply_track_finish_action(&mut guard, sink_id, finish_action);
1949                    waiter = guard.manual_compaction_waiters.remove(&sink_id);
1950                }
1951                Some(_) => {
1952                    tracing::warn!(
1953                        iceberg_component = "compaction_scheduler",
1954                        iceberg_operation = "handle_report",
1955                        sink_id = %sink_id,
1956                        task_id = %task_id,
1957                        status = ?status,
1958                        "iceberg_compaction_report_ignored_stale",
1959                    );
1960                }
1961                None => {
1962                    tracing::warn!(
1963                        iceberg_component = "compaction_scheduler",
1964                        iceberg_operation = "handle_report",
1965                        sink_id = %sink_id,
1966                        task_id = %task_id,
1967                        status = ?status,
1968                        "iceberg_compaction_report_unknown_sink",
1969                    );
1970                }
1971            }
1972
1973            waiter
1974        };
1975
1976        if let Some(waiter) = waiter {
1977            Self::complete_manual_task_waiter(waiter, &report);
1978        }
1979    }
1980}
1981
1982#[cfg(test)]
1983mod tests;