1use std::collections::{HashMap, HashSet};
16
17use iceberg::actions::RemoveOrphanFilesAction;
18use iceberg::spec::{FormatVersion, ManifestContentType, ManifestFile};
19use iceberg::table::Table;
20use iceberg::transaction::{ApplyTransactionAction, Transaction};
21use itertools::Itertools;
22use risingwave_connector::sink::SinkError;
23use risingwave_connector::sink::catalog::SinkId;
24use risingwave_connector::sink::iceberg::commit_branch;
25use thiserror_ext::AsReport;
26use tokio::sync::oneshot::Sender;
27use tokio::task::JoinHandle;
28
29use super::*;
30
31const MAX_SNAPSHOT_AGE_MS_DEFAULT: i64 = 24 * 60 * 60 * 1000;
32const ORPHAN_FILE_LOG_SAMPLE_SIZE: usize = 10;
33
34async fn remove_orphan_files_from_table(
35 table: Table,
36 min_age: std::time::Duration,
37) -> risingwave_connector::sink::Result<Vec<String>> {
38 if !table.metadata().table_properties()?.gc_enabled {
41 return Err(SinkError::Config(anyhow!(
42 "Cannot remove orphan files when gc.enabled=false: files may be shared with other tables"
43 )));
44 }
45 if table.metadata_location().is_none() {
48 return Err(SinkError::Config(anyhow!(
49 "Cannot remove orphan files: current metadata location of table {} is unknown",
50 table.identifier()
51 )));
52 }
53
54 Ok(RemoveOrphanFilesAction::new(table)
55 .older_than(min_age)
56 .execute()
57 .await?)
58}
59
60#[derive(Debug, PartialEq, Eq)]
61struct ManifestRewritePlan {
62 rewrite_paths: HashSet<String>,
63 data_manifest_count: usize,
64 selected_manifest_count: usize,
65 estimated_output_manifest_count: usize,
66}
67
68fn plan_manifest_rewrite(
69 manifests: &[ManifestFile],
70 target_size_bytes: u64,
71 min_count_to_merge: usize,
72) -> ManifestRewritePlan {
73 debug_assert!(target_size_bytes > 0);
74 debug_assert!(min_count_to_merge > 0);
75
76 let mut candidates_by_spec = HashMap::<i32, Vec<&ManifestFile>>::new();
77 let mut data_manifest_count = 0;
78 for manifest in manifests {
79 if manifest.content != ManifestContentType::Data {
80 continue;
81 }
82 data_manifest_count += 1;
83 if manifest.manifest_length >= 0 && (manifest.manifest_length as u64) < target_size_bytes {
84 candidates_by_spec
85 .entry(manifest.partition_spec_id)
86 .or_default()
87 .push(manifest);
88 }
89 }
90
91 let mut rewrite_paths = HashSet::new();
92 let mut estimated_output_manifest_count = 0;
93 for mut candidates in candidates_by_spec.into_values() {
94 candidates.sort_by_key(|manifest| manifest.sequence_number);
105 let mut current_bin = Vec::new();
106 let mut current_bin_size_bytes = 0_u64;
107 let mut completed_bin = None;
108 for candidate in candidates {
109 let candidate_size_bytes = candidate.manifest_length as u64;
110 let next_size_bytes = current_bin_size_bytes.saturating_add(candidate_size_bytes);
111 if !current_bin.is_empty() && next_size_bytes > target_size_bytes {
112 if current_bin.len() >= 2 {
113 completed_bin = Some(std::mem::take(&mut current_bin));
114 break;
115 }
116 current_bin = Vec::new();
117 current_bin_size_bytes = 0;
118 }
119
120 current_bin.push(candidate);
121 current_bin_size_bytes = current_bin_size_bytes.saturating_add(candidate_size_bytes);
122 }
123
124 let selected = if let Some(completed_bin) = completed_bin {
129 completed_bin
130 } else if current_bin.len() >= 2
131 && (current_bin_size_bytes >= target_size_bytes
132 || current_bin.len() >= min_count_to_merge)
133 {
134 current_bin
135 } else {
136 continue;
137 };
138
139 estimated_output_manifest_count += 1;
140 rewrite_paths.extend(
141 selected
142 .into_iter()
143 .map(|manifest| manifest.manifest_path.clone()),
144 );
145 }
146
147 ManifestRewritePlan {
148 selected_manifest_count: rewrite_paths.len(),
149 rewrite_paths,
150 data_manifest_count,
151 estimated_output_manifest_count,
152 }
153}
154
155fn snapshot_expiration_cutoff_ms(iceberg_config: &IcebergConfig, now: i64) -> i64 {
156 iceberg_config
157 .snapshot_expiration_timestamp_ms(now)
158 .unwrap_or(now - MAX_SNAPSHOT_AGE_MS_DEFAULT)
159}
160
161impl IcebergCompactionManager {
162 pub fn gc_loop(manager: Arc<Self>, interval_sec: u64) -> (JoinHandle<()>, Sender<()>) {
163 assert!(
164 interval_sec > 0,
165 "Iceberg GC interval must be greater than 0"
166 );
167 let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
168 let join_handle = tokio::spawn(async move {
169 tracing::info!(
170 interval_sec = interval_sec,
171 "Starting Iceberg GC loop with configurable interval"
172 );
173 let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_sec));
174
175 loop {
176 tokio::select! {
177 _ = interval.tick() => {
178 if let Err(e) = manager.perform_gc_operations().await {
179 tracing::error!(error = ?e.as_report(), "GC operations failed");
180 }
181 },
182 _ = &mut shutdown_rx => {
183 tracing::info!("Iceberg GC loop is stopped");
184 return;
185 }
186 }
187 }
188 });
189
190 (join_handle, shutdown_tx)
191 }
192
193 pub fn orphan_file_cleanup_loop(
194 manager: Arc<Self>,
195 interval_sec: u64,
196 ) -> (JoinHandle<()>, Sender<()>) {
197 assert!(
198 interval_sec > 0,
199 "Iceberg orphan file cleanup interval must be greater than 0"
200 );
201 let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
202 let join_handle = tokio::spawn(async move {
203 tracing::info!(interval_sec, "Starting Iceberg orphan file cleanup loop");
204 let period = std::time::Duration::from_secs(interval_sec);
205 let mut interval =
206 tokio::time::interval_at(tokio::time::Instant::now() + period, period);
207 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
208
209 loop {
210 tokio::select! {
211 _ = interval.tick() => {
212 if let Err(e) = manager.perform_orphan_file_cleanup().await {
213 tracing::error!(
214 error = ?e.as_report(),
215 "Iceberg orphan file cleanup failed",
216 );
217 }
218 },
219 _ = &mut shutdown_rx => {
220 tracing::info!("Iceberg orphan file cleanup loop is stopped");
221 return;
222 }
223 }
224 }
225 });
226
227 (join_handle, shutdown_tx)
228 }
229
230 async fn perform_gc_operations(&self) -> MetaResult<()> {
231 let (snapshot_expiration_sink_ids, manifest_rewrite_sink_ids) = {
232 let guard = self.inner.read();
233 (
234 guard
235 .snapshot_expiration_sink_ids
236 .iter()
237 .copied()
238 .collect::<Vec<_>>(),
239 guard
240 .manifest_rewrite_sink_ids
241 .iter()
242 .copied()
243 .collect::<Vec<_>>(),
244 )
245 };
246
247 tracing::info!(
248 snapshot_expiration_sink_count = snapshot_expiration_sink_ids.len(),
249 manifest_rewrite_sink_count = manifest_rewrite_sink_ids.len(),
250 "Starting Iceberg metadata maintenance operations",
251 );
252
253 for sink_id in manifest_rewrite_sink_ids {
256 if let Err(e) = self.check_and_rewrite_manifests(sink_id).await {
257 tracing::error!(
258 error = ?e.as_report(),
259 %sink_id,
260 "Failed to rewrite Iceberg manifests",
261 );
262 }
263 }
264
265 for sink_id in snapshot_expiration_sink_ids {
266 if let Err(e) = self.check_and_expire_snapshots(sink_id).await {
267 tracing::error!(error = ?e.as_report(), "Failed to perform GC for sink {}", sink_id);
268 }
269 }
270
271 tracing::info!("Iceberg metadata maintenance operations completed");
272 Ok(())
273 }
274
275 async fn perform_orphan_file_cleanup(&self) -> MetaResult<()> {
276 let orphan_file_cleanup_sink_ids = {
277 let guard = self.inner.read();
278 guard
279 .orphan_file_cleanup_sink_ids
280 .iter()
281 .copied()
282 .collect::<Vec<_>>()
283 };
284
285 tracing::info!(
286 orphan_file_cleanup_sink_count = orphan_file_cleanup_sink_ids.len(),
287 "Starting Iceberg orphan file cleanup operations",
288 );
289
290 for sink_id in orphan_file_cleanup_sink_ids {
291 if let Err(e) = self.check_and_remove_orphan_files(sink_id).await {
292 tracing::error!(
293 error = ?e.as_report(),
294 %sink_id,
295 "Failed to remove Iceberg orphan files",
296 );
297 }
298 }
299
300 tracing::info!("Iceberg orphan file cleanup operations completed");
301 Ok(())
302 }
303
304 async fn load_maintenance_config(&self, sink_id: SinkId) -> MetaResult<Option<IcebergConfig>> {
305 {
306 let guard = self.inner.read();
307 if guard.is_recovering(sink_id)
308 || guard
309 .sink_schedules
310 .get(&sink_id)
311 .is_some_and(|track| !track.source_is_ready())
312 {
313 return Ok(None);
314 }
315 }
316 let iceberg_config = self.load_iceberg_config(sink_id).await?;
317 if iceberg_config.enable_pk_index
318 && !self
319 .inner
320 .read()
321 .sink_schedules
322 .get(&sink_id)
323 .is_some_and(|track| track.source_is_ready())
324 {
325 return Ok(None);
326 }
327 Ok(Some(iceberg_config))
328 }
329
330 pub async fn check_and_expire_snapshots(&self, sink_id: SinkId) -> MetaResult<()> {
331 let now = chrono::Utc::now().timestamp_millis();
332 let Some(iceberg_config) = self.load_maintenance_config(sink_id).await? else {
333 return Ok(());
334 };
335 if !iceberg_config.enable_snapshot_expiration {
336 let mut guard = self.inner.write();
337 guard.snapshot_expiration_sink_ids.remove(&sink_id);
338 return Ok(());
339 }
340
341 let active_attempt_gc_watermark_snapshot = {
342 let guard = self.inner.read();
343 if guard.is_recovering(sink_id) {
346 return Ok(());
347 }
348 guard
349 .sink_schedules
350 .get(&sink_id)
351 .and_then(|track| track.active_attempt_gc_watermark_snapshot())
352 .map(|snapshot| snapshot.cloned())
353 };
354
355 let mut snapshot_expiration_timestamp_ms =
356 snapshot_expiration_cutoff_ms(&iceberg_config, now);
357
358 match active_attempt_gc_watermark_snapshot {
361 None => {}
362 Some(None) => {
363 tracing::info!(
364 catalog_name = iceberg_config.catalog_name(),
365 table_name = iceberg_config.full_table_name()?.to_string(),
366 %sink_id,
367 "Skip snapshot expiration because an active iceberg compaction attempt has no observed GC watermark",
368 );
369 return Ok(());
370 }
371 Some(Some(snapshot)) => {
372 snapshot_expiration_timestamp_ms =
375 snapshot_expiration_timestamp_ms.min(snapshot.timestamp_ms);
376 tracing::info!(
377 catalog_name = iceberg_config.catalog_name(),
378 table_name = iceberg_config.full_table_name()?.to_string(),
379 %sink_id,
380 gc_watermark_branch = %snapshot.branch,
381 gc_watermark_snapshot_id = snapshot.snapshot_id,
382 gc_watermark_timestamp_ms = snapshot.timestamp_ms,
383 protected_snapshot_expiration_timestamp_ms = snapshot_expiration_timestamp_ms,
384 "Protect snapshots expiration with iceberg compaction GC watermark",
385 );
386 }
387 }
388
389 let catalog = iceberg_config.create_catalog().await?;
390 let mut table = catalog
391 .load_table(&iceberg_config.full_table_name()?)
392 .await
393 .map_err(|e| SinkError::Iceberg(e.into()))?;
394
395 let metadata = table.metadata();
396 let mut snapshots = metadata.snapshots().collect_vec();
397 snapshots.sort_by_key(|s| s.timestamp_ms());
398
399 if snapshots.is_empty()
400 || snapshots.first().unwrap().timestamp_ms() > snapshot_expiration_timestamp_ms
401 {
402 return Ok(());
403 }
404
405 tracing::info!(
406 catalog_name = iceberg_config.catalog_name(),
407 table_name = iceberg_config.full_table_name()?.to_string(),
408 %sink_id,
409 snapshots_len = snapshots.len(),
410 snapshot_expiration_timestamp_ms = snapshot_expiration_timestamp_ms,
411 snapshot_expiration_retain_last = ?iceberg_config.snapshot_expiration_retain_last,
412 clear_expired_files = ?iceberg_config.snapshot_expiration_clear_expired_files,
413 clear_expired_meta_data = ?iceberg_config.snapshot_expiration_clear_expired_meta_data,
414 "try trigger snapshots expiration",
415 );
416
417 let txn = Transaction::new(&table);
418
419 let mut expired_snapshots = txn
420 .expire_snapshots()
421 .expire_older_than_ms(snapshot_expiration_timestamp_ms)
422 .clear_expired_meta_data(iceberg_config.snapshot_expiration_clear_expired_meta_data);
423
424 if let Some(retain_last) = iceberg_config.snapshot_expiration_retain_last {
425 expired_snapshots = expired_snapshots.retain_last(
426 usize::try_from(retain_last).map_err(|e| SinkError::Config(e.into()))?,
427 );
428 }
429
430 let before_metadata = table.metadata_ref();
431 let tx = expired_snapshots
432 .apply(txn)
433 .map_err(|e| SinkError::Iceberg(e.into()))?;
434 table = tx
435 .commit(catalog.as_ref())
436 .await
437 .map_err(|e| SinkError::Iceberg(e.into()))?;
438
439 if iceberg_config.snapshot_expiration_clear_expired_files {
440 table
441 .cleanup_expired_files(&before_metadata)
442 .await
443 .map_err(|e| SinkError::Iceberg(e.into()))?;
444 }
445
446 tracing::info!(
447 catalog_name = iceberg_config.catalog_name(),
448 table_name = iceberg_config.full_table_name()?.to_string(),
449 %sink_id,
450 "Expired snapshots for iceberg table",
451 );
452
453 Ok(())
454 }
455
456 pub async fn check_and_remove_orphan_files(&self, sink_id: SinkId) -> MetaResult<()> {
457 let Some(iceberg_config) = self.load_maintenance_config(sink_id).await? else {
458 return Ok(());
459 };
460 if !iceberg_config.enable_orphan_file_cleanup {
461 self.inner
462 .write()
463 .orphan_file_cleanup_sink_ids
464 .remove(&sink_id);
465 return Ok(());
466 }
467
468 self.remove_orphan_files_with_config(sink_id, iceberg_config)
469 .await?;
470 Ok(())
471 }
472
473 pub async fn remove_orphan_files(&self, sink_id: SinkId) -> MetaResult<u64> {
475 let iceberg_config = self
476 .load_maintenance_config(sink_id)
477 .await?
478 .ok_or_else(|| anyhow!("iceberg sink {} is not ready for orphan cleanup", sink_id))?;
479 self.remove_orphan_files_with_config(sink_id, iceberg_config)
480 .await
481 }
482
483 async fn remove_orphan_files_with_config(
484 &self,
485 sink_id: SinkId,
486 iceberg_config: IcebergConfig,
487 ) -> MetaResult<u64> {
488 iceberg_config.ensure_orphan_file_cleanup_supported()?;
492
493 let cleanup_lock = self.orphan_file_cleanup_lock(sink_id);
497 let _cleanup_guard = cleanup_lock.lock().await;
498
499 let catalog = iceberg_config.create_catalog().await?;
500 let table_ident = iceberg_config.full_table_name()?;
501 let table = catalog
502 .load_table(&table_ident)
503 .await
504 .map_err(|e| SinkError::Iceberg(e.into()))?;
505 let min_age_millis = iceberg_config.orphan_file_cleanup_min_age_millis();
506
507 tracing::info!(
508 iceberg_component = "orphan_file_maintenance",
509 iceberg_operation = "remove_orphan_files",
510 catalog_name = iceberg_config.catalog_name(),
511 table = %table_ident,
512 %sink_id,
513 min_age_millis,
514 "Starting Iceberg orphan file cleanup",
515 );
516
517 let orphan_files =
518 remove_orphan_files_from_table(table, std::time::Duration::from_millis(min_age_millis))
519 .await?;
520
521 let orphan_file_sample = orphan_files
522 .iter()
523 .take(ORPHAN_FILE_LOG_SAMPLE_SIZE)
524 .cloned()
525 .collect_vec();
526 let orphan_file_count = orphan_files.len() as u64;
527
528 tracing::info!(
529 iceberg_component = "orphan_file_maintenance",
530 iceberg_operation = "remove_orphan_files",
531 catalog_name = iceberg_config.catalog_name(),
532 table = %table_ident,
533 %sink_id,
534 min_age_millis,
535 orphan_file_count,
536 deleted_file_count = orphan_file_count,
537 orphan_file_sample = ?orphan_file_sample,
538 "Iceberg orphan file cleanup completed",
539 );
540
541 Ok(orphan_file_count)
542 }
543
544 pub async fn check_and_rewrite_manifests(&self, sink_id: SinkId) -> MetaResult<()> {
545 let Some(iceberg_config) = self.load_maintenance_config(sink_id).await? else {
546 return Ok(());
547 };
548 if !iceberg_config.enable_manifest_rewrite {
549 self.inner
550 .write()
551 .manifest_rewrite_sink_ids
552 .remove(&sink_id);
553 return Ok(());
554 }
555
556 let catalog = iceberg_config.create_catalog().await?;
557 let table_ident = iceberg_config.full_table_name()?;
558 let table = catalog
559 .load_table(&table_ident)
560 .await
561 .map_err(|e| SinkError::Iceberg(e.into()))?;
562
563 if table.metadata().format_version() >= FormatVersion::V3 {
564 self.inner
567 .write()
568 .manifest_rewrite_sink_ids
569 .remove(&sink_id);
570 tracing::warn!(
571 iceberg_component = "manifest_maintenance",
572 iceberg_operation = "rewrite_manifests",
573 catalog_name = iceberg_config.catalog_name(),
574 table = %table_ident,
575 %sink_id,
576 format_version = %table.metadata().format_version(),
577 "Skipping manifest rewrite because Iceberg row lineage is enabled",
578 );
579 return Ok(());
580 }
581
582 let branch = commit_branch(iceberg_config.r#type.as_str(), iceberg_config.write_mode);
583 let Some(current_snapshot) = table.metadata().snapshot_for_ref(&branch) else {
584 tracing::debug!(
585 iceberg_component = "manifest_maintenance",
586 iceberg_operation = "rewrite_manifests",
587 table = %table_ident,
588 %sink_id,
589 %branch,
590 "Skipping manifest rewrite because the target branch has no snapshot",
591 );
592 return Ok(());
593 };
594 let current_snapshot_id = current_snapshot.snapshot_id();
595 let manifest_list = table
596 .object_cache()
597 .get_manifest_list(current_snapshot, &table.metadata_ref())
598 .await
599 .map_err(|e| SinkError::Iceberg(e.into()))?;
600
601 let target_size_bytes = iceberg_config.manifest_rewrite_target_size_bytes();
602 let min_count_to_merge = iceberg_config.manifest_rewrite_min_count_to_merge();
603 let plan = plan_manifest_rewrite(
604 manifest_list.entries(),
605 target_size_bytes,
606 min_count_to_merge,
607 );
608 if plan.rewrite_paths.is_empty() {
609 tracing::debug!(
610 iceberg_component = "manifest_maintenance",
611 iceberg_operation = "rewrite_manifests",
612 table = %table_ident,
613 %sink_id,
614 %branch,
615 data_manifest_count = plan.data_manifest_count,
616 target_size_bytes,
617 min_count_to_merge,
618 "Skipping manifest rewrite because manifests are not fragmented enough",
619 );
620 return Ok(());
621 }
622
623 tracing::info!(
624 iceberg_component = "manifest_maintenance",
625 iceberg_operation = "rewrite_manifests",
626 catalog_name = iceberg_config.catalog_name(),
627 table = %table_ident,
628 %sink_id,
629 %branch,
630 current_snapshot_id,
631 data_manifest_count = plan.data_manifest_count,
632 selected_manifest_count = plan.selected_manifest_count,
633 estimated_output_manifest_count = plan.estimated_output_manifest_count,
634 target_size_bytes,
635 min_count_to_merge,
636 "Starting Iceberg manifest rewrite",
637 );
638
639 let selected_manifest_count = plan.selected_manifest_count;
640 let estimated_output_manifest_count = plan.estimated_output_manifest_count;
641 let rewrite_paths = plan.rewrite_paths;
642 let txn = Transaction::new(&table);
643 let tx = txn
644 .rewrite_manifests()
645 .rewrite_if(Box::new(move |manifest| {
646 rewrite_paths.contains(&manifest.manifest_path)
647 }))
648 .cluster_by(Box::new(|_| "risingwave-maintenance".to_owned()))
649 .set_target_branch(branch.clone())
650 .apply(txn)
651 .map_err(|e| SinkError::Iceberg(e.into()))?;
652 let table = tx
653 .commit(catalog.as_ref())
654 .await
655 .map_err(|e| SinkError::Iceberg(e.into()))?;
656
657 let Some(rewritten_snapshot) = table.metadata().snapshot_for_ref(&branch) else {
658 return Err(anyhow!(
659 "Iceberg branch {} disappeared after manifest rewrite for sink {}",
660 branch,
661 sink_id
662 )
663 .into());
664 };
665 if rewritten_snapshot.snapshot_id() == current_snapshot_id {
666 tracing::warn!(
667 iceberg_component = "manifest_maintenance",
668 iceberg_operation = "rewrite_manifests",
669 table = %table_ident,
670 %sink_id,
671 %branch,
672 current_snapshot_id,
673 selected_manifest_count,
674 "Manifest rewrite completed without creating a new snapshot",
675 );
676 return Ok(());
677 }
678
679 let summary = &rewritten_snapshot.summary().additional_properties;
680 tracing::info!(
681 iceberg_component = "manifest_maintenance",
682 iceberg_operation = "rewrite_manifests",
683 catalog_name = iceberg_config.catalog_name(),
684 table = %table_ident,
685 %sink_id,
686 %branch,
687 previous_snapshot_id = current_snapshot_id,
688 snapshot_id = rewritten_snapshot.snapshot_id(),
689 selected_manifest_count,
690 estimated_output_manifest_count,
691 manifests_created = summary.get("manifests-created").map(String::as_str),
692 manifests_replaced = summary.get("manifests-replaced").map(String::as_str),
693 manifests_kept = summary.get("manifests-kept").map(String::as_str),
694 entries_processed = summary.get("entries-processed").map(String::as_str),
695 "Iceberg manifest rewrite succeeded",
696 );
697
698 Ok(())
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 fn manifest(
707 path: &str,
708 manifest_length: i64,
709 partition_spec_id: i32,
710 content: ManifestContentType,
711 ) -> ManifestFile {
712 ManifestFile {
713 manifest_path: path.to_owned(),
714 manifest_length,
715 partition_spec_id,
716 content,
717 sequence_number: 1,
718 min_sequence_number: 1,
719 added_snapshot_id: 1,
720 added_files_count: Some(1),
721 existing_files_count: Some(0),
722 deleted_files_count: Some(0),
723 added_rows_count: Some(1),
724 existing_rows_count: Some(0),
725 deleted_rows_count: Some(0),
726 partitions: None,
727 key_metadata: None,
728 first_row_id: None,
729 }
730 }
731
732 #[test]
733 fn test_plan_manifest_rewrite_selects_fragmented_data_manifests_per_spec() {
734 let manifests = vec![
735 manifest("small-a", 40, 0, ManifestContentType::Data),
736 manifest("small-b", 40, 0, ManifestContentType::Data),
737 manifest("single-other-spec", 40, 1, ManifestContentType::Data),
738 manifest("already-large", 100, 0, ManifestContentType::Data),
739 manifest("delete", 10, 0, ManifestContentType::Deletes),
740 ];
741
742 let plan = plan_manifest_rewrite(&manifests, 100, 2);
743
744 assert_eq!(plan.data_manifest_count, 4);
745 assert_eq!(plan.selected_manifest_count, 2);
746 assert_eq!(plan.estimated_output_manifest_count, 1);
747 assert_eq!(
748 plan.rewrite_paths,
749 ["small-a".to_owned(), "small-b".to_owned()]
750 .into_iter()
751 .collect()
752 );
753 }
754
755 #[test]
756 fn test_plan_manifest_rewrite_skips_when_packing_cannot_reduce_count() {
757 let manifests = vec![
758 manifest("almost-full-a", 60, 0, ManifestContentType::Data),
759 manifest("almost-full-b", 60, 0, ManifestContentType::Data),
760 ];
761
762 let plan = plan_manifest_rewrite(&manifests, 100, 2);
763
764 assert!(plan.rewrite_paths.is_empty());
765 assert_eq!(plan.selected_manifest_count, 0);
766 assert_eq!(plan.estimated_output_manifest_count, 0);
767 }
768
769 #[test]
770 fn test_plan_manifest_rewrite_limits_each_spec_to_one_target_sized_batch() {
771 let manifests = vec![
772 manifest("small-a", 40, 0, ManifestContentType::Data),
773 manifest("small-b", 40, 0, ManifestContentType::Data),
774 manifest("small-c", 40, 0, ManifestContentType::Data),
775 ];
776
777 let plan = plan_manifest_rewrite(&manifests, 100, 2);
778
779 assert_eq!(plan.selected_manifest_count, 2);
780 assert_eq!(plan.estimated_output_manifest_count, 1);
781 assert_eq!(
782 plan.rewrite_paths,
783 ["small-a".to_owned(), "small-b".to_owned()]
784 .into_iter()
785 .collect()
786 );
787 }
788
789 #[test]
790 fn test_plan_manifest_rewrite_selects_oldest_sequence_first() {
791 let mut newest = manifest("newest", 40, 0, ManifestContentType::Data);
792 newest.sequence_number = 3;
793 let mut oldest_a = manifest("oldest-a", 40, 0, ManifestContentType::Data);
794 oldest_a.sequence_number = 1;
795 let mut middle = manifest("middle", 40, 0, ManifestContentType::Data);
796 middle.sequence_number = 2;
797 let mut oldest_b = manifest("oldest-b", 40, 0, ManifestContentType::Data);
798 oldest_b.sequence_number = 1;
799
800 let plan = plan_manifest_rewrite(&[newest, oldest_a, middle, oldest_b], 100, 2);
801
802 assert_eq!(plan.selected_manifest_count, 2);
803 assert_eq!(
804 plan.rewrite_paths,
805 ["oldest-a".to_owned(), "oldest-b".to_owned()]
806 .into_iter()
807 .collect()
808 );
809 }
810
811 #[test]
812 fn test_plan_manifest_rewrite_selects_completed_bin_below_min_count() {
813 let manifests = (0..17)
814 .map(|index| {
815 manifest(
816 &format!("small-{index:03}"),
817 512 * 1024,
818 0,
819 ManifestContentType::Data,
820 )
821 })
822 .collect_vec();
823
824 let plan = plan_manifest_rewrite(&manifests, 8 * 1024 * 1024, 100);
825
826 assert_eq!(plan.data_manifest_count, 17);
827 assert_eq!(plan.selected_manifest_count, 16);
828 assert_eq!(plan.estimated_output_manifest_count, 1);
829 assert!(!plan.rewrite_paths.contains("small-016"));
830 }
831
832 #[test]
833 fn test_plan_manifest_rewrite_waits_for_underfilled_bin_min_count() {
834 let manifests = (0..99)
835 .map(|index| {
836 manifest(
837 &format!("small-{index:03}"),
838 1024,
839 0,
840 ManifestContentType::Data,
841 )
842 })
843 .collect_vec();
844
845 let plan = plan_manifest_rewrite(&manifests, 1024 * 1024, 100);
846
847 assert_eq!(plan.data_manifest_count, 99);
848 assert_eq!(plan.selected_manifest_count, 0);
849 assert_eq!(plan.estimated_output_manifest_count, 0);
850 }
851}
852
853#[cfg(all(test, not(madsim)))]
855mod orphan_file_tests {
856 use std::collections::HashMap;
857 use std::time::{Duration, SystemTime};
858
859 use iceberg::io::FileIO;
860 use iceberg::spec::{
861 FormatVersion, NestedField, PrimitiveType, Schema, SortOrder, TableMetadataBuilder, Type,
862 UnboundPartitionSpec,
863 };
864 use iceberg::table::Table;
865 use iceberg::{Runtime, TableIdent};
866 use thiserror_ext::AsReport;
867
868 use super::remove_orphan_files_from_table;
869
870 async fn check_orphan_file_cleanup(
871 gc_enabled: Option<&str>,
872 with_metadata_location: bool,
873 expected_error: Option<&str>,
874 ) {
875 let directory = tempfile::tempdir().unwrap();
876 let location = format!("file://{}", directory.path().display());
877 let properties = gc_enabled
878 .map(|value| HashMap::from([("gc.enabled".to_owned(), value.to_owned())]))
879 .unwrap_or_default();
880 let schema = Schema::builder()
881 .with_fields([
882 NestedField::required(1, "id", Type::Primitive(PrimitiveType::Long)).into(),
883 ])
884 .build()
885 .unwrap();
886 let metadata = TableMetadataBuilder::new(
887 schema,
888 UnboundPartitionSpec::default(),
889 SortOrder::unsorted_order(),
890 location.clone(),
891 FormatVersion::V2,
892 properties,
893 )
894 .unwrap()
895 .build()
896 .unwrap()
897 .metadata;
898 let metadata_path = directory.path().join("metadata.json");
899 let old_path = directory.path().join("old.parquet");
900 let recent_path = directory.path().join("recent.parquet");
901 std::fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
902 std::fs::write(&old_path, b"old orphan").unwrap();
903 std::fs::write(&recent_path, b"in-progress write").unwrap();
904 for path in [&old_path, &metadata_path] {
905 std::fs::File::open(path)
906 .unwrap()
907 .set_modified(SystemTime::now() - Duration::from_secs(8 * 24 * 3600))
908 .unwrap();
909 }
910 let mut builder = Table::builder()
911 .metadata(metadata)
912 .identifier(TableIdent::from_strs(["ns", "table"]).unwrap())
913 .file_io(FileIO::new_with_fs())
914 .runtime(Runtime::current());
915 if with_metadata_location {
916 builder = builder.metadata_location(format!("{location}/metadata.json"));
917 }
918 let table = builder.build().unwrap();
919
920 let result =
921 remove_orphan_files_from_table(table, Duration::from_secs(7 * 24 * 3600)).await;
922 match expected_error {
923 None => assert_eq!(result.unwrap(), vec![format!("{location}/old.parquet")]),
924 Some(error) => assert!(result.unwrap_err().to_report_string().contains(error)),
925 }
926 assert_eq!(old_path.exists(), expected_error.is_some());
927 assert!(recent_path.exists());
928 assert!(metadata_path.exists());
929 }
930
931 #[tokio::test]
932 async fn test_orphan_file_cleanup_respects_disabled_gc() {
933 check_orphan_file_cleanup(Some("false"), true, Some("gc.enabled")).await;
934 }
935
936 #[tokio::test]
937 async fn test_orphan_file_cleanup_rejects_invalid_gc_property() {
938 check_orphan_file_cleanup(Some("invalid"), true, Some("gc.enabled")).await;
939 }
940
941 #[tokio::test]
942 async fn test_orphan_file_cleanup_requires_metadata_location() {
943 check_orphan_file_cleanup(Some("true"), false, Some("metadata location")).await;
944 }
945
946 #[tokio::test]
947 async fn test_orphan_file_cleanup_preserves_reachable_and_recent_files() {
948 check_orphan_file_cleanup(Some("true"), true, None).await;
949 check_orphan_file_cleanup(None, true, None).await;
950 }
951}