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_snapshot()
294            .expire_older_than(snapshot_expiration_timestamp_ms)
295            .clear_expire_files(iceberg_config.snapshot_expiration_clear_expired_files)
296            .clear_expired_meta_data(iceberg_config.snapshot_expiration_clear_expired_meta_data);
297
298        if let Some(retain_last) = iceberg_config.snapshot_expiration_retain_last {
299            expired_snapshots = expired_snapshots.retain_last(retain_last);
300        }
301
302        let before_metadata = table.metadata_ref();
303        let tx = expired_snapshots
304            .apply(txn)
305            .map_err(|e| SinkError::Iceberg(e.into()))?;
306        table = tx
307            .commit(catalog.as_ref())
308            .await
309            .map_err(|e| SinkError::Iceberg(e.into()))?;
310
311        if iceberg_config.snapshot_expiration_clear_expired_files {
312            table
313                .cleanup_expired_files(&before_metadata)
314                .await
315                .map_err(|e| SinkError::Iceberg(e.into()))?;
316        }
317
318        tracing::info!(
319            catalog_name = iceberg_config.catalog_name(),
320            table_name = iceberg_config.full_table_name()?.to_string(),
321            %sink_id,
322            "Expired snapshots for iceberg table",
323        );
324
325        Ok(())
326    }
327
328    pub async fn check_and_rewrite_manifests(&self, sink_id: SinkId) -> MetaResult<()> {
329        let iceberg_config = self.load_iceberg_config(sink_id).await?;
330        if !iceberg_config.enable_manifest_rewrite {
331            self.inner
332                .write()
333                .manifest_rewrite_sink_ids
334                .remove(&sink_id);
335            return Ok(());
336        }
337
338        let catalog = iceberg_config.create_catalog().await?;
339        let table_ident = iceberg_config.full_table_name()?;
340        let table = catalog
341            .load_table(&table_ident)
342            .await
343            .map_err(|e| SinkError::Iceberg(e.into()))?;
344
345        if table.metadata().format_version() >= FormatVersion::V3 {
346            // Iceberg format upgrades cannot be downgraded, so stop retrying
347            // periodic rewrites for this sink.
348            self.inner
349                .write()
350                .manifest_rewrite_sink_ids
351                .remove(&sink_id);
352            tracing::warn!(
353                iceberg_component = "manifest_maintenance",
354                iceberg_operation = "rewrite_manifests",
355                catalog_name = iceberg_config.catalog_name(),
356                table = %table_ident,
357                %sink_id,
358                format_version = %table.metadata().format_version(),
359                "Skipping manifest rewrite because Iceberg row lineage is enabled",
360            );
361            return Ok(());
362        }
363
364        let branch = commit_branch(iceberg_config.r#type.as_str(), iceberg_config.write_mode);
365        let Some(current_snapshot) = table.metadata().snapshot_for_ref(&branch) else {
366            tracing::debug!(
367                iceberg_component = "manifest_maintenance",
368                iceberg_operation = "rewrite_manifests",
369                table = %table_ident,
370                %sink_id,
371                %branch,
372                "Skipping manifest rewrite because the target branch has no snapshot",
373            );
374            return Ok(());
375        };
376        let current_snapshot_id = current_snapshot.snapshot_id();
377        let manifest_list = current_snapshot
378            .load_manifest_list(table.file_io(), table.metadata())
379            .await
380            .map_err(|e| SinkError::Iceberg(e.into()))?;
381
382        let target_size_bytes = iceberg_config.manifest_rewrite_target_size_bytes();
383        let min_count_to_merge = iceberg_config.manifest_rewrite_min_count_to_merge();
384        let plan = plan_manifest_rewrite(
385            manifest_list.entries(),
386            target_size_bytes,
387            min_count_to_merge,
388        );
389        if plan.rewrite_paths.is_empty() {
390            tracing::debug!(
391                iceberg_component = "manifest_maintenance",
392                iceberg_operation = "rewrite_manifests",
393                table = %table_ident,
394                %sink_id,
395                %branch,
396                data_manifest_count = plan.data_manifest_count,
397                target_size_bytes,
398                min_count_to_merge,
399                "Skipping manifest rewrite because manifests are not fragmented enough",
400            );
401            return Ok(());
402        }
403
404        tracing::info!(
405            iceberg_component = "manifest_maintenance",
406            iceberg_operation = "rewrite_manifests",
407            catalog_name = iceberg_config.catalog_name(),
408            table = %table_ident,
409            %sink_id,
410            %branch,
411            current_snapshot_id,
412            data_manifest_count = plan.data_manifest_count,
413            selected_manifest_count = plan.selected_manifest_count,
414            estimated_output_manifest_count = plan.estimated_output_manifest_count,
415            target_size_bytes,
416            min_count_to_merge,
417            "Starting Iceberg manifest rewrite",
418        );
419
420        let selected_manifest_count = plan.selected_manifest_count;
421        let estimated_output_manifest_count = plan.estimated_output_manifest_count;
422        let rewrite_paths = plan.rewrite_paths;
423        let txn = Transaction::new(&table);
424        let tx = txn
425            .rewrite_manifests()
426            .rewrite_if(Box::new(move |manifest| {
427                rewrite_paths.contains(&manifest.manifest_path)
428            }))
429            .cluster_by(Box::new(|_| "risingwave-maintenance".to_owned()))
430            .set_target_branch(branch.clone())
431            .apply(txn)
432            .map_err(|e| SinkError::Iceberg(e.into()))?;
433        let table = tx
434            .commit(catalog.as_ref())
435            .await
436            .map_err(|e| SinkError::Iceberg(e.into()))?;
437
438        let Some(rewritten_snapshot) = table.metadata().snapshot_for_ref(&branch) else {
439            return Err(anyhow!(
440                "Iceberg branch {} disappeared after manifest rewrite for sink {}",
441                branch,
442                sink_id
443            )
444            .into());
445        };
446        if rewritten_snapshot.snapshot_id() == current_snapshot_id {
447            tracing::warn!(
448                iceberg_component = "manifest_maintenance",
449                iceberg_operation = "rewrite_manifests",
450                table = %table_ident,
451                %sink_id,
452                %branch,
453                current_snapshot_id,
454                selected_manifest_count,
455                "Manifest rewrite completed without creating a new snapshot",
456            );
457            return Ok(());
458        }
459
460        let summary = &rewritten_snapshot.summary().additional_properties;
461        tracing::info!(
462            iceberg_component = "manifest_maintenance",
463            iceberg_operation = "rewrite_manifests",
464            catalog_name = iceberg_config.catalog_name(),
465            table = %table_ident,
466            %sink_id,
467            %branch,
468            previous_snapshot_id = current_snapshot_id,
469            snapshot_id = rewritten_snapshot.snapshot_id(),
470            selected_manifest_count,
471            estimated_output_manifest_count,
472            manifests_created = summary.get("manifests-created").map(String::as_str),
473            manifests_replaced = summary.get("manifests-replaced").map(String::as_str),
474            manifests_kept = summary.get("manifests-kept").map(String::as_str),
475            entries_processed = summary.get("entries-processed").map(String::as_str),
476            "Iceberg manifest rewrite succeeded",
477        );
478
479        Ok(())
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    fn manifest(
488        path: &str,
489        manifest_length: i64,
490        partition_spec_id: i32,
491        content: ManifestContentType,
492    ) -> ManifestFile {
493        ManifestFile {
494            manifest_path: path.to_owned(),
495            manifest_length,
496            partition_spec_id,
497            content,
498            sequence_number: 1,
499            min_sequence_number: 1,
500            added_snapshot_id: 1,
501            added_files_count: Some(1),
502            existing_files_count: Some(0),
503            deleted_files_count: Some(0),
504            added_rows_count: Some(1),
505            existing_rows_count: Some(0),
506            deleted_rows_count: Some(0),
507            partitions: None,
508            key_metadata: None,
509            first_row_id: None,
510        }
511    }
512
513    #[test]
514    fn test_plan_manifest_rewrite_selects_fragmented_data_manifests_per_spec() {
515        let manifests = vec![
516            manifest("small-a", 40, 0, ManifestContentType::Data),
517            manifest("small-b", 40, 0, ManifestContentType::Data),
518            manifest("single-other-spec", 40, 1, ManifestContentType::Data),
519            manifest("already-large", 100, 0, ManifestContentType::Data),
520            manifest("delete", 10, 0, ManifestContentType::Deletes),
521        ];
522
523        let plan = plan_manifest_rewrite(&manifests, 100, 2);
524
525        assert_eq!(plan.data_manifest_count, 4);
526        assert_eq!(plan.selected_manifest_count, 2);
527        assert_eq!(plan.estimated_output_manifest_count, 1);
528        assert_eq!(
529            plan.rewrite_paths,
530            ["small-a".to_owned(), "small-b".to_owned()]
531                .into_iter()
532                .collect()
533        );
534    }
535
536    #[test]
537    fn test_plan_manifest_rewrite_skips_when_packing_cannot_reduce_count() {
538        let manifests = vec![
539            manifest("almost-full-a", 60, 0, ManifestContentType::Data),
540            manifest("almost-full-b", 60, 0, ManifestContentType::Data),
541        ];
542
543        let plan = plan_manifest_rewrite(&manifests, 100, 2);
544
545        assert!(plan.rewrite_paths.is_empty());
546        assert_eq!(plan.selected_manifest_count, 0);
547        assert_eq!(plan.estimated_output_manifest_count, 0);
548    }
549
550    #[test]
551    fn test_plan_manifest_rewrite_limits_each_spec_to_one_target_sized_batch() {
552        let manifests = vec![
553            manifest("small-a", 40, 0, ManifestContentType::Data),
554            manifest("small-b", 40, 0, ManifestContentType::Data),
555            manifest("small-c", 40, 0, ManifestContentType::Data),
556        ];
557
558        let plan = plan_manifest_rewrite(&manifests, 100, 2);
559
560        assert_eq!(plan.selected_manifest_count, 2);
561        assert_eq!(plan.estimated_output_manifest_count, 1);
562        assert_eq!(
563            plan.rewrite_paths,
564            ["small-a".to_owned(), "small-b".to_owned()]
565                .into_iter()
566                .collect()
567        );
568    }
569
570    #[test]
571    fn test_plan_manifest_rewrite_selects_oldest_sequence_first() {
572        let mut newest = manifest("newest", 40, 0, ManifestContentType::Data);
573        newest.sequence_number = 3;
574        let mut oldest_a = manifest("oldest-a", 40, 0, ManifestContentType::Data);
575        oldest_a.sequence_number = 1;
576        let mut middle = manifest("middle", 40, 0, ManifestContentType::Data);
577        middle.sequence_number = 2;
578        let mut oldest_b = manifest("oldest-b", 40, 0, ManifestContentType::Data);
579        oldest_b.sequence_number = 1;
580
581        let plan = plan_manifest_rewrite(&[newest, oldest_a, middle, oldest_b], 100, 2);
582
583        assert_eq!(plan.selected_manifest_count, 2);
584        assert_eq!(
585            plan.rewrite_paths,
586            ["oldest-a".to_owned(), "oldest-b".to_owned()]
587                .into_iter()
588                .collect()
589        );
590    }
591
592    #[test]
593    fn test_plan_manifest_rewrite_selects_completed_bin_below_min_count() {
594        let manifests = (0..17)
595            .map(|index| {
596                manifest(
597                    &format!("small-{index:03}"),
598                    512 * 1024,
599                    0,
600                    ManifestContentType::Data,
601                )
602            })
603            .collect_vec();
604
605        let plan = plan_manifest_rewrite(&manifests, 8 * 1024 * 1024, 100);
606
607        assert_eq!(plan.data_manifest_count, 17);
608        assert_eq!(plan.selected_manifest_count, 16);
609        assert_eq!(plan.estimated_output_manifest_count, 1);
610        assert!(!plan.rewrite_paths.contains("small-016"));
611    }
612
613    #[test]
614    fn test_plan_manifest_rewrite_waits_for_underfilled_bin_min_count() {
615        let manifests = (0..99)
616            .map(|index| {
617                manifest(
618                    &format!("small-{index:03}"),
619                    1024,
620                    0,
621                    ManifestContentType::Data,
622                )
623            })
624            .collect_vec();
625
626        let plan = plan_manifest_rewrite(&manifests, 1024 * 1024, 100);
627
628        assert_eq!(plan.data_manifest_count, 99);
629        assert_eq!(plan.selected_manifest_count, 0);
630        assert_eq!(plan.estimated_output_manifest_count, 0);
631    }
632}