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