Skip to main content

risingwave_meta/manager/iceberg_compaction/
gc.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{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    // The pinned iceberg-rust action does not enforce this table-level protection.
39    // Check before discovering or deleting files, which may be shared with other tables.
40    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    // The action keeps the current metadata file only when the table carries its
46    // location; without it, cleanup would delete the file the catalog points to.
47    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        // The iceberg-rust version currently pinned by RisingWave creates one
95        // output manifest per cluster key and partition spec. Limit each spec
96        // to one target-sized batch so the rewrite cannot replace many small
97        // manifests with a single oversized manifest. Later maintenance runs
98        // will consume the remaining candidates.
99        //
100        // Pack from the oldest end so completed bins are rewritten first and
101        // the newest under-filled bin can accumulate across maintenance runs.
102        // A stable sort preserves manifest-list order for V1 manifests and
103        // manifests written by the same snapshot.
104        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        // A completed target-sized bin is always worth merging. The newest
125        // under-filled bin is merged only after it reaches the configured
126        // count threshold. Either path must reduce at least two manifests to
127        // one; otherwise the rewrite would only create snapshot churn.
128        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        // Rewrite first so snapshot expiration in the same tick can clean up
254        // manifests replaced by the new snapshot.
255        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            // The old track is discarded during recovery, but durable pending rewrites still
344            // need protection until the new coordinator has settled them.
345            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        // Outer `None` means there is no active compaction attempt. Inner `None`
359        // means an active attempt has no safe snapshot watermark, so GC skips.
360        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                // A running compaction task may still need snapshots up to its
373                // captured watermark, so GC must not expire newer snapshots.
374                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    /// Remove orphan files immediately, independently of the periodic cleanup switch.
474    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        // Storage catalog relies on metadata/version-hint.text to load and
489        // commit the table, but that catalog-owned file is not tracked by
490        // Iceberg table metadata. Do not expose orphan cleanup for this catalog.
491        iceberg_config.ensure_orphan_file_cleanup_supported()?;
492
493        // Serialize scheduled and manual cleanup for the same sink. Deleting
494        // the same candidate concurrently can otherwise turn an idempotent
495        // maintenance request into a partial failure.
496        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            // Iceberg format upgrades cannot be downgraded, so stop retrying
565            // periodic rewrites for this sink.
566            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// Iceberg's runtime and file IO need a real Tokio runtime, which madsim does not provide.
854#[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}