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_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 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}