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::spec::{FormatVersion, ManifestContentType, ManifestFile};
18use iceberg::transaction::{ApplyTransactionAction, Transaction};
19use itertools::Itertools;
20use risingwave_connector::sink::SinkError;
21use risingwave_connector::sink::catalog::SinkId;
22use risingwave_connector::sink::iceberg::commit_branch;
23use thiserror_ext::AsReport;
24use tokio::sync::oneshot::Sender;
25use tokio::task::JoinHandle;
26
27use super::*;
28
29const MAX_SNAPSHOT_AGE_MS_DEFAULT: i64 = 24 * 60 * 60 * 1000;
30
31#[derive(Debug, PartialEq, Eq)]
32struct ManifestRewritePlan {
33    rewrite_paths: HashSet<String>,
34    data_manifest_count: usize,
35    selected_manifest_count: usize,
36    estimated_output_manifest_count: usize,
37}
38
39fn plan_manifest_rewrite(
40    manifests: &[ManifestFile],
41    target_size_bytes: u64,
42    min_count_to_merge: usize,
43) -> ManifestRewritePlan {
44    debug_assert!(target_size_bytes > 0);
45    debug_assert!(min_count_to_merge > 0);
46
47    let mut candidates_by_spec = HashMap::<i32, Vec<&ManifestFile>>::new();
48    let mut data_manifest_count = 0;
49    for manifest in manifests {
50        if manifest.content != ManifestContentType::Data {
51            continue;
52        }
53        data_manifest_count += 1;
54        if manifest.manifest_length >= 0 && (manifest.manifest_length as u64) < target_size_bytes {
55            candidates_by_spec
56                .entry(manifest.partition_spec_id)
57                .or_default()
58                .push(manifest);
59        }
60    }
61
62    let mut rewrite_paths = HashSet::new();
63    let mut estimated_output_manifest_count = 0;
64    for mut candidates in candidates_by_spec.into_values() {
65        // The iceberg-rust version currently pinned by RisingWave creates one
66        // output manifest per cluster key and partition spec. Limit each spec
67        // to one target-sized batch so the rewrite cannot replace many small
68        // manifests with a single oversized manifest. Later maintenance runs
69        // will consume the remaining candidates.
70        //
71        // Pack from the oldest end so completed bins are rewritten first and
72        // the newest under-filled bin can accumulate across maintenance runs.
73        // A stable sort preserves manifest-list order for V1 manifests and
74        // manifests written by the same snapshot.
75        candidates.sort_by_key(|manifest| manifest.sequence_number);
76        let mut current_bin = Vec::new();
77        let mut current_bin_size_bytes = 0_u64;
78        let mut completed_bin = None;
79        for candidate in candidates {
80            let candidate_size_bytes = candidate.manifest_length as u64;
81            let next_size_bytes = current_bin_size_bytes.saturating_add(candidate_size_bytes);
82            if !current_bin.is_empty() && next_size_bytes > target_size_bytes {
83                if current_bin.len() >= 2 {
84                    completed_bin = Some(std::mem::take(&mut current_bin));
85                    break;
86                }
87                current_bin = Vec::new();
88                current_bin_size_bytes = 0;
89            }
90
91            current_bin.push(candidate);
92            current_bin_size_bytes = current_bin_size_bytes.saturating_add(candidate_size_bytes);
93        }
94
95        // A completed target-sized bin is always worth merging. The newest
96        // under-filled bin is merged only after it reaches the configured
97        // count threshold. Either path must reduce at least two manifests to
98        // one; otherwise the rewrite would only create snapshot churn.
99        let selected = if let Some(completed_bin) = completed_bin {
100            completed_bin
101        } else if current_bin.len() >= 2
102            && (current_bin_size_bytes >= target_size_bytes
103                || current_bin.len() >= min_count_to_merge)
104        {
105            current_bin
106        } else {
107            continue;
108        };
109
110        estimated_output_manifest_count += 1;
111        rewrite_paths.extend(
112            selected
113                .into_iter()
114                .map(|manifest| manifest.manifest_path.clone()),
115        );
116    }
117
118    ManifestRewritePlan {
119        selected_manifest_count: rewrite_paths.len(),
120        rewrite_paths,
121        data_manifest_count,
122        estimated_output_manifest_count,
123    }
124}
125
126fn snapshot_expiration_cutoff_ms(iceberg_config: &IcebergConfig, now: i64) -> i64 {
127    iceberg_config
128        .snapshot_expiration_timestamp_ms(now)
129        .unwrap_or(now - MAX_SNAPSHOT_AGE_MS_DEFAULT)
130}
131
132impl IcebergCompactionManager {
133    pub fn gc_loop(manager: Arc<Self>, interval_sec: u64) -> (JoinHandle<()>, Sender<()>) {
134        assert!(
135            interval_sec > 0,
136            "Iceberg GC interval must be greater than 0"
137        );
138        let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
139        let join_handle = tokio::spawn(async move {
140            tracing::info!(
141                interval_sec = interval_sec,
142                "Starting Iceberg GC loop with configurable interval"
143            );
144            let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_sec));
145
146            loop {
147                tokio::select! {
148                    _ = interval.tick() => {
149                        if let Err(e) = manager.perform_gc_operations().await {
150                            tracing::error!(error = ?e.as_report(), "GC operations failed");
151                        }
152                    },
153                    _ = &mut shutdown_rx => {
154                        tracing::info!("Iceberg GC loop is stopped");
155                        return;
156                    }
157                }
158            }
159        });
160
161        (join_handle, shutdown_tx)
162    }
163
164    async fn perform_gc_operations(&self) -> MetaResult<()> {
165        let (snapshot_expiration_sink_ids, manifest_rewrite_sink_ids) = {
166            let guard = self.inner.read();
167            (
168                guard
169                    .snapshot_expiration_sink_ids
170                    .iter()
171                    .copied()
172                    .collect::<Vec<_>>(),
173                guard
174                    .manifest_rewrite_sink_ids
175                    .iter()
176                    .copied()
177                    .collect::<Vec<_>>(),
178            )
179        };
180
181        tracing::info!(
182            snapshot_expiration_sink_count = snapshot_expiration_sink_ids.len(),
183            manifest_rewrite_sink_count = manifest_rewrite_sink_ids.len(),
184            "Starting Iceberg metadata maintenance operations",
185        );
186
187        // Rewrite first so snapshot expiration in the same tick can clean up
188        // manifests replaced by the new snapshot.
189        for sink_id in manifest_rewrite_sink_ids {
190            if let Err(e) = self.check_and_rewrite_manifests(sink_id).await {
191                tracing::error!(
192                    error = ?e.as_report(),
193                    %sink_id,
194                    "Failed to rewrite Iceberg manifests",
195                );
196            }
197        }
198
199        for sink_id in snapshot_expiration_sink_ids {
200            if let Err(e) = self.check_and_expire_snapshots(sink_id).await {
201                tracing::error!(error = ?e.as_report(), "Failed to perform GC for sink {}", sink_id);
202            }
203        }
204
205        tracing::info!("Iceberg metadata maintenance operations completed");
206        Ok(())
207    }
208
209    pub async fn check_and_expire_snapshots(&self, sink_id: SinkId) -> MetaResult<()> {
210        let now = chrono::Utc::now().timestamp_millis();
211
212        let iceberg_config = self.load_iceberg_config(sink_id).await?;
213        if !iceberg_config.enable_snapshot_expiration {
214            let mut guard = self.inner.write();
215            guard.snapshot_expiration_sink_ids.remove(&sink_id);
216            return Ok(());
217        }
218
219        let processing_gc_watermark_snapshot = {
220            let guard = self.inner.read();
221            guard
222                .sink_schedules
223                .get(&sink_id)
224                .and_then(|track| track.processing_gc_watermark_snapshot())
225                .map(|snapshot| snapshot.cloned())
226        };
227
228        let mut snapshot_expiration_timestamp_ms =
229            snapshot_expiration_cutoff_ms(&iceberg_config, now);
230
231        // Outer `None` means no active compaction task. Inner `None` means an
232        // active task exists without a safe snapshot watermark, so GC skips.
233        match processing_gc_watermark_snapshot {
234            None => {}
235            Some(None) => {
236                tracing::info!(
237                    catalog_name = iceberg_config.catalog_name(),
238                    table_name = iceberg_config.full_table_name()?.to_string(),
239                    %sink_id,
240                    "Skip snapshots expiration because an iceberg compaction task has no observed GC watermark",
241                );
242                return Ok(());
243            }
244            Some(Some(snapshot)) => {
245                // A running compaction task may still need snapshots up to its
246                // captured watermark, so GC must not expire newer snapshots.
247                snapshot_expiration_timestamp_ms =
248                    snapshot_expiration_timestamp_ms.min(snapshot.timestamp_ms);
249                tracing::info!(
250                    catalog_name = iceberg_config.catalog_name(),
251                    table_name = iceberg_config.full_table_name()?.to_string(),
252                    %sink_id,
253                    gc_watermark_branch = %snapshot.branch,
254                    gc_watermark_snapshot_id = snapshot.snapshot_id,
255                    gc_watermark_timestamp_ms = snapshot.timestamp_ms,
256                    protected_snapshot_expiration_timestamp_ms = snapshot_expiration_timestamp_ms,
257                    "Protect snapshots expiration with iceberg compaction GC watermark",
258                );
259            }
260        }
261
262        let catalog = iceberg_config.create_catalog().await?;
263        let mut table = catalog
264            .load_table(&iceberg_config.full_table_name()?)
265            .await
266            .map_err(|e| SinkError::Iceberg(e.into()))?;
267
268        let metadata = table.metadata();
269        let mut snapshots = metadata.snapshots().collect_vec();
270        snapshots.sort_by_key(|s| s.timestamp_ms());
271
272        if snapshots.is_empty()
273            || snapshots.first().unwrap().timestamp_ms() > snapshot_expiration_timestamp_ms
274        {
275            return Ok(());
276        }
277
278        tracing::info!(
279            catalog_name = iceberg_config.catalog_name(),
280            table_name = iceberg_config.full_table_name()?.to_string(),
281            %sink_id,
282            snapshots_len = snapshots.len(),
283            snapshot_expiration_timestamp_ms = snapshot_expiration_timestamp_ms,
284            snapshot_expiration_retain_last = ?iceberg_config.snapshot_expiration_retain_last,
285            clear_expired_files = ?iceberg_config.snapshot_expiration_clear_expired_files,
286            clear_expired_meta_data = ?iceberg_config.snapshot_expiration_clear_expired_meta_data,
287            "try trigger snapshots expiration",
288        );
289
290        let txn = Transaction::new(&table);
291
292        let mut expired_snapshots = txn
293            .expire_snapshots()
294            .expire_older_than_ms(snapshot_expiration_timestamp_ms)
295            .clear_expired_meta_data(iceberg_config.snapshot_expiration_clear_expired_meta_data);
296
297        if let Some(retain_last) = iceberg_config.snapshot_expiration_retain_last {
298            expired_snapshots = expired_snapshots.retain_last(
299                usize::try_from(retain_last).map_err(|e| SinkError::Config(e.into()))?,
300            );
301        }
302
303        let before_metadata = table.metadata_ref();
304        let tx = expired_snapshots
305            .apply(txn)
306            .map_err(|e| SinkError::Iceberg(e.into()))?;
307        table = tx
308            .commit(catalog.as_ref())
309            .await
310            .map_err(|e| SinkError::Iceberg(e.into()))?;
311
312        if iceberg_config.snapshot_expiration_clear_expired_files {
313            table
314                .cleanup_expired_files(&before_metadata)
315                .await
316                .map_err(|e| SinkError::Iceberg(e.into()))?;
317        }
318
319        tracing::info!(
320            catalog_name = iceberg_config.catalog_name(),
321            table_name = iceberg_config.full_table_name()?.to_string(),
322            %sink_id,
323            "Expired snapshots for iceberg table",
324        );
325
326        Ok(())
327    }
328
329    pub async fn check_and_rewrite_manifests(&self, sink_id: SinkId) -> MetaResult<()> {
330        let iceberg_config = self.load_iceberg_config(sink_id).await?;
331        if !iceberg_config.enable_manifest_rewrite {
332            self.inner
333                .write()
334                .manifest_rewrite_sink_ids
335                .remove(&sink_id);
336            return Ok(());
337        }
338
339        let catalog = iceberg_config.create_catalog().await?;
340        let table_ident = iceberg_config.full_table_name()?;
341        let table = catalog
342            .load_table(&table_ident)
343            .await
344            .map_err(|e| SinkError::Iceberg(e.into()))?;
345
346        if table.metadata().format_version() >= FormatVersion::V3 {
347            // Iceberg format upgrades cannot be downgraded, so stop retrying
348            // periodic rewrites for this sink.
349            self.inner
350                .write()
351                .manifest_rewrite_sink_ids
352                .remove(&sink_id);
353            tracing::warn!(
354                iceberg_component = "manifest_maintenance",
355                iceberg_operation = "rewrite_manifests",
356                catalog_name = iceberg_config.catalog_name(),
357                table = %table_ident,
358                %sink_id,
359                format_version = %table.metadata().format_version(),
360                "Skipping manifest rewrite because Iceberg row lineage is enabled",
361            );
362            return Ok(());
363        }
364
365        let branch = commit_branch(iceberg_config.r#type.as_str(), iceberg_config.write_mode);
366        let Some(current_snapshot) = table.metadata().snapshot_for_ref(&branch) else {
367            tracing::debug!(
368                iceberg_component = "manifest_maintenance",
369                iceberg_operation = "rewrite_manifests",
370                table = %table_ident,
371                %sink_id,
372                %branch,
373                "Skipping manifest rewrite because the target branch has no snapshot",
374            );
375            return Ok(());
376        };
377        let current_snapshot_id = current_snapshot.snapshot_id();
378        let manifest_list = table
379            .object_cache()
380            .get_manifest_list(current_snapshot, &table.metadata_ref())
381            .await
382            .map_err(|e| SinkError::Iceberg(e.into()))?;
383
384        let target_size_bytes = iceberg_config.manifest_rewrite_target_size_bytes();
385        let min_count_to_merge = iceberg_config.manifest_rewrite_min_count_to_merge();
386        let plan = plan_manifest_rewrite(
387            manifest_list.entries(),
388            target_size_bytes,
389            min_count_to_merge,
390        );
391        if plan.rewrite_paths.is_empty() {
392            tracing::debug!(
393                iceberg_component = "manifest_maintenance",
394                iceberg_operation = "rewrite_manifests",
395                table = %table_ident,
396                %sink_id,
397                %branch,
398                data_manifest_count = plan.data_manifest_count,
399                target_size_bytes,
400                min_count_to_merge,
401                "Skipping manifest rewrite because manifests are not fragmented enough",
402            );
403            return Ok(());
404        }
405
406        tracing::info!(
407            iceberg_component = "manifest_maintenance",
408            iceberg_operation = "rewrite_manifests",
409            catalog_name = iceberg_config.catalog_name(),
410            table = %table_ident,
411            %sink_id,
412            %branch,
413            current_snapshot_id,
414            data_manifest_count = plan.data_manifest_count,
415            selected_manifest_count = plan.selected_manifest_count,
416            estimated_output_manifest_count = plan.estimated_output_manifest_count,
417            target_size_bytes,
418            min_count_to_merge,
419            "Starting Iceberg manifest rewrite",
420        );
421
422        let selected_manifest_count = plan.selected_manifest_count;
423        let estimated_output_manifest_count = plan.estimated_output_manifest_count;
424        let rewrite_paths = plan.rewrite_paths;
425        let txn = Transaction::new(&table);
426        let tx = txn
427            .rewrite_manifests()
428            .rewrite_if(Box::new(move |manifest| {
429                rewrite_paths.contains(&manifest.manifest_path)
430            }))
431            .cluster_by(Box::new(|_| "risingwave-maintenance".to_owned()))
432            .set_target_branch(branch.clone())
433            .apply(txn)
434            .map_err(|e| SinkError::Iceberg(e.into()))?;
435        let table = tx
436            .commit(catalog.as_ref())
437            .await
438            .map_err(|e| SinkError::Iceberg(e.into()))?;
439
440        let Some(rewritten_snapshot) = table.metadata().snapshot_for_ref(&branch) else {
441            return Err(anyhow!(
442                "Iceberg branch {} disappeared after manifest rewrite for sink {}",
443                branch,
444                sink_id
445            )
446            .into());
447        };
448        if rewritten_snapshot.snapshot_id() == current_snapshot_id {
449            tracing::warn!(
450                iceberg_component = "manifest_maintenance",
451                iceberg_operation = "rewrite_manifests",
452                table = %table_ident,
453                %sink_id,
454                %branch,
455                current_snapshot_id,
456                selected_manifest_count,
457                "Manifest rewrite completed without creating a new snapshot",
458            );
459            return Ok(());
460        }
461
462        let summary = &rewritten_snapshot.summary().additional_properties;
463        tracing::info!(
464            iceberg_component = "manifest_maintenance",
465            iceberg_operation = "rewrite_manifests",
466            catalog_name = iceberg_config.catalog_name(),
467            table = %table_ident,
468            %sink_id,
469            %branch,
470            previous_snapshot_id = current_snapshot_id,
471            snapshot_id = rewritten_snapshot.snapshot_id(),
472            selected_manifest_count,
473            estimated_output_manifest_count,
474            manifests_created = summary.get("manifests-created").map(String::as_str),
475            manifests_replaced = summary.get("manifests-replaced").map(String::as_str),
476            manifests_kept = summary.get("manifests-kept").map(String::as_str),
477            entries_processed = summary.get("entries-processed").map(String::as_str),
478            "Iceberg manifest rewrite succeeded",
479        );
480
481        Ok(())
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn manifest(
490        path: &str,
491        manifest_length: i64,
492        partition_spec_id: i32,
493        content: ManifestContentType,
494    ) -> ManifestFile {
495        ManifestFile {
496            manifest_path: path.to_owned(),
497            manifest_length,
498            partition_spec_id,
499            content,
500            sequence_number: 1,
501            min_sequence_number: 1,
502            added_snapshot_id: 1,
503            added_files_count: Some(1),
504            existing_files_count: Some(0),
505            deleted_files_count: Some(0),
506            added_rows_count: Some(1),
507            existing_rows_count: Some(0),
508            deleted_rows_count: Some(0),
509            partitions: None,
510            key_metadata: None,
511            first_row_id: None,
512        }
513    }
514
515    #[test]
516    fn test_plan_manifest_rewrite_selects_fragmented_data_manifests_per_spec() {
517        let manifests = vec![
518            manifest("small-a", 40, 0, ManifestContentType::Data),
519            manifest("small-b", 40, 0, ManifestContentType::Data),
520            manifest("single-other-spec", 40, 1, ManifestContentType::Data),
521            manifest("already-large", 100, 0, ManifestContentType::Data),
522            manifest("delete", 10, 0, ManifestContentType::Deletes),
523        ];
524
525        let plan = plan_manifest_rewrite(&manifests, 100, 2);
526
527        assert_eq!(plan.data_manifest_count, 4);
528        assert_eq!(plan.selected_manifest_count, 2);
529        assert_eq!(plan.estimated_output_manifest_count, 1);
530        assert_eq!(
531            plan.rewrite_paths,
532            ["small-a".to_owned(), "small-b".to_owned()]
533                .into_iter()
534                .collect()
535        );
536    }
537
538    #[test]
539    fn test_plan_manifest_rewrite_skips_when_packing_cannot_reduce_count() {
540        let manifests = vec![
541            manifest("almost-full-a", 60, 0, ManifestContentType::Data),
542            manifest("almost-full-b", 60, 0, ManifestContentType::Data),
543        ];
544
545        let plan = plan_manifest_rewrite(&manifests, 100, 2);
546
547        assert!(plan.rewrite_paths.is_empty());
548        assert_eq!(plan.selected_manifest_count, 0);
549        assert_eq!(plan.estimated_output_manifest_count, 0);
550    }
551
552    #[test]
553    fn test_plan_manifest_rewrite_limits_each_spec_to_one_target_sized_batch() {
554        let manifests = vec![
555            manifest("small-a", 40, 0, ManifestContentType::Data),
556            manifest("small-b", 40, 0, ManifestContentType::Data),
557            manifest("small-c", 40, 0, ManifestContentType::Data),
558        ];
559
560        let plan = plan_manifest_rewrite(&manifests, 100, 2);
561
562        assert_eq!(plan.selected_manifest_count, 2);
563        assert_eq!(plan.estimated_output_manifest_count, 1);
564        assert_eq!(
565            plan.rewrite_paths,
566            ["small-a".to_owned(), "small-b".to_owned()]
567                .into_iter()
568                .collect()
569        );
570    }
571
572    #[test]
573    fn test_plan_manifest_rewrite_selects_oldest_sequence_first() {
574        let mut newest = manifest("newest", 40, 0, ManifestContentType::Data);
575        newest.sequence_number = 3;
576        let mut oldest_a = manifest("oldest-a", 40, 0, ManifestContentType::Data);
577        oldest_a.sequence_number = 1;
578        let mut middle = manifest("middle", 40, 0, ManifestContentType::Data);
579        middle.sequence_number = 2;
580        let mut oldest_b = manifest("oldest-b", 40, 0, ManifestContentType::Data);
581        oldest_b.sequence_number = 1;
582
583        let plan = plan_manifest_rewrite(&[newest, oldest_a, middle, oldest_b], 100, 2);
584
585        assert_eq!(plan.selected_manifest_count, 2);
586        assert_eq!(
587            plan.rewrite_paths,
588            ["oldest-a".to_owned(), "oldest-b".to_owned()]
589                .into_iter()
590                .collect()
591        );
592    }
593
594    #[test]
595    fn test_plan_manifest_rewrite_selects_completed_bin_below_min_count() {
596        let manifests = (0..17)
597            .map(|index| {
598                manifest(
599                    &format!("small-{index:03}"),
600                    512 * 1024,
601                    0,
602                    ManifestContentType::Data,
603                )
604            })
605            .collect_vec();
606
607        let plan = plan_manifest_rewrite(&manifests, 8 * 1024 * 1024, 100);
608
609        assert_eq!(plan.data_manifest_count, 17);
610        assert_eq!(plan.selected_manifest_count, 16);
611        assert_eq!(plan.estimated_output_manifest_count, 1);
612        assert!(!plan.rewrite_paths.contains("small-016"));
613    }
614
615    #[test]
616    fn test_plan_manifest_rewrite_waits_for_underfilled_bin_min_count() {
617        let manifests = (0..99)
618            .map(|index| {
619                manifest(
620                    &format!("small-{index:03}"),
621                    1024,
622                    0,
623                    ManifestContentType::Data,
624                )
625            })
626            .collect_vec();
627
628        let plan = plan_manifest_rewrite(&manifests, 1024 * 1024, 100);
629
630        assert_eq!(plan.data_manifest_count, 99);
631        assert_eq!(plan.selected_manifest_count, 0);
632        assert_eq!(plan.estimated_output_manifest_count, 0);
633    }
634}