1use 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#[derive(Debug, Clone)]
52enum CompactionTrackState {
53 Idle {
58 next_compaction_time: Instant,
59 manual_task_type: Option<TaskType>,
61 },
62 PendingDispatch { attempt: Arc<CompactionAttempt> },
64 InFlight {
67 task_id: IcebergCompactionTaskId,
68 compactor_context_id: HummockContextId,
69 attempt: Arc<CompactionAttempt>,
70 report_deadline: Instant,
71 },
72 Applying {
75 task_id: IcebergCompactionTaskId,
76 attempt: Arc<CompactionAttempt>,
77 },
78 NeedsRecovery {
80 task_id: IcebergCompactionTaskId,
81 attempt: Arc<CompactionAttempt>,
82 },
83}
84
85#[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#[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 compacted_snapshot: Option<IcebergCommittedSnapshot>,
117 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#[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#[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#[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 next_check_not_before: Option<Instant>,
297 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 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 return time_ready
382 || (self.config.pk_index_coordinated && (commit_ready || !has_commits));
383 }
384
385 if self.config.pk_index_coordinated {
386 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 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 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 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 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 self.health.record_progress();
710 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 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 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 Commit {
938 observed_snapshot: IcebergCommittedSnapshot,
939 },
940 Maintenance {
941 observed_snapshot: IcebergCommittedSnapshot,
942 },
943 ForceCompaction {
946 observed_snapshot: IcebergCommittedSnapshot,
947 },
948 ManualForceCompaction { task_type: TaskType },
951 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
982struct 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 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 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 async fn schedule_sink_on_startup(&self, sink_id: SinkId) -> MetaResult<()> {
1112 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 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 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 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 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 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 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 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 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 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 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 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 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;