1use 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 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 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 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 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 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 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}