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