1use std::collections::VecDeque;
16#[cfg(madsim)]
17use std::sync::{LazyLock, Mutex};
18use std::time::SystemTime;
19
20use iceberg::spec::{DataFile, SerializedDataFile};
21use iceberg::table::Table;
22use risingwave_connector::sink::iceberg::IcebergCommitResult;
23use risingwave_pb::connector_service::SinkMetadata;
24use risingwave_pb::iceberg_compaction::{
25 PkIndexCompactionResult as PbPkIndexCompactionResult, SubscribeIcebergCompactionEventRequest,
26 subscribe_iceberg_compaction_event_request,
27};
28use risingwave_pb::id::IcebergCompactionTaskId;
29use thiserror_ext::AsReport;
30use tokio::sync::mpsc;
31
32use super::TaskKey;
33use crate::hummock::{HummockError, HummockResult};
34
35#[derive(Clone)]
42pub(crate) struct PkIndexCompactionResult {
43 pub(crate) output_files: Vec<SerializedDataFile>,
45 pub(crate) schema_id: i32,
46 pub(crate) partition_spec_id: i32,
47 pub(crate) input_file_paths: Vec<String>,
50 pub(crate) read_snapshot_id: i64,
52}
53
54impl std::fmt::Debug for PkIndexCompactionResult {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("PkIndexCompactionResult")
58 .field("output_file_count", &self.output_files.len())
59 .field("input_file_count", &self.input_file_paths.len())
60 .field("read_snapshot_id", &self.read_snapshot_id)
61 .finish()
62 }
63}
64
65pub(crate) fn build_pk_index_compaction_result(
73 table: &Table,
74 data_files: Vec<DataFile>,
75 input_file_paths: Vec<String>,
76 read_snapshot_id: i64,
77) -> HummockResult<PkIndexCompactionResult> {
78 let partition_type = table.metadata().default_partition_type();
79 let format_version = table.metadata().format_version();
80
81 let output_files = data_files
82 .into_iter()
83 .map(|data_file| {
84 SerializedDataFile::try_from(data_file, partition_type, format_version)
85 .map_err(|e| HummockError::compaction_executor(e.as_report()))
86 })
87 .collect::<HummockResult<Vec<_>>>()?;
88
89 Ok(PkIndexCompactionResult {
90 output_files,
91 schema_id: table.metadata().current_schema_id(),
92 partition_spec_id: table.metadata().default_partition_spec_id(),
93 input_file_paths,
94 read_snapshot_id,
95 })
96}
97
98#[derive(Debug)]
99pub(crate) struct IcebergPlanCompletion {
100 pub(crate) task_key: TaskKey,
101 pub(crate) error_message: Option<String>,
102 pub(crate) pk_index_result: Option<PkIndexCompactionResult>,
104}
105
106pub(crate) type IcebergTaskReport = subscribe_iceberg_compaction_event_request::ReportTask;
107
108#[cfg(madsim)]
109static SIMULATED_PK_INDEX_RESULT: LazyLock<Mutex<Option<PbPkIndexCompactionResult>>> =
110 LazyLock::new(|| Mutex::new(None));
111
112#[cfg(madsim)]
113pub fn set_simulated_pk_index_compaction_result(result: Option<PbPkIndexCompactionResult>) {
114 *SIMULATED_PK_INDEX_RESULT.lock().unwrap() = result;
115}
116
117#[cfg(madsim)]
118pub(crate) fn simulated_pk_index_compaction_result() -> Option<PbPkIndexCompactionResult> {
119 SIMULATED_PK_INDEX_RESULT.lock().unwrap().clone()
120}
121
122pub(crate) enum ReportSendResult {
123 Sent,
124 RestartStream,
125}
126
127pub(crate) struct IcebergTaskTracker {
128 sink_id: u32,
129 admitted_plans: usize,
130 pk_index_coordinated: bool,
131 fully_admitted_bounded_round: bool,
132 remaining_admitted_plans: usize,
133 successful_plans: usize,
134 failed_plans: usize,
135 first_error: Option<String>,
136 pk_index_results: Vec<PkIndexCompactionResult>,
139}
140
141impl IcebergTaskTracker {
142 pub(crate) fn new(
143 sink_id: u32,
144 admitted_plans: usize,
145 pk_index_coordinated: bool,
146 fully_admitted_bounded_round: bool,
147 ) -> Self {
148 Self {
149 sink_id,
150 admitted_plans,
151 pk_index_coordinated,
152 fully_admitted_bounded_round,
153 remaining_admitted_plans: admitted_plans,
154 successful_plans: 0,
155 failed_plans: 0,
156 first_error: None,
157 pk_index_results: Vec::new(),
158 }
159 }
160
161 pub(crate) fn record_completion(
162 &mut self,
163 error_message: Option<String>,
164 pk_index_result: Option<PkIndexCompactionResult>,
165 ) {
166 debug_assert!(self.remaining_admitted_plans > 0);
167 self.remaining_admitted_plans -= 1;
168 let error_message =
169 error_message.or_else(|| match (self.pk_index_coordinated, &pk_index_result) {
170 (true, None) => {
171 Some("Coordinated compaction completed without an apply payload".to_owned())
172 }
173 (false, Some(_)) => Some(
174 "Worker-committed compaction returned a coordinated apply payload".to_owned(),
175 ),
176 _ => None,
177 });
178 if let Some(error_message) = error_message {
179 self.failed_plans += 1;
180 if self.first_error.is_none() {
181 self.first_error = Some(error_message);
182 }
183 } else {
184 self.successful_plans += 1;
185 if let Some(pk_index_result) = pk_index_result {
186 self.pk_index_results.push(pk_index_result);
187 }
188 }
189 }
190
191 pub(crate) fn is_finished(&self) -> bool {
192 self.remaining_admitted_plans == 0
195 }
196
197 pub(crate) fn sink_id(&self) -> u32 {
198 self.sink_id
199 }
200
201 pub(crate) fn admitted_plans(&self) -> usize {
202 self.admitted_plans
203 }
204
205 pub(crate) fn successful_plans(&self) -> usize {
206 self.successful_plans
207 }
208
209 pub(crate) fn failed_plans(&self) -> usize {
210 self.failed_plans
211 }
212
213 pub(crate) fn into_report(self, task_id: IcebergCompactionTaskId) -> IcebergTaskReport {
214 let is_drained = !self.pk_index_coordinated
217 && self.fully_admitted_bounded_round
218 && self.successful_plans == self.admitted_plans;
219 let error_message = if self.successful_plans > 0 {
220 None
221 } else {
222 Some(
223 self.first_error
224 .unwrap_or_else(|| "All admitted iceberg compaction plans failed".to_owned()),
225 )
226 };
227 let mut report = build_iceberg_task_report(task_id, self.sink_id, error_message);
228 if is_drained {
229 report.status =
230 subscribe_iceberg_compaction_event_request::report_task::Status::Drained as i32;
231 }
232 populate_pk_index_report_fields(&mut report, self.pk_index_results);
233 report
234 }
235}
236
237fn populate_pk_index_report_fields(
239 report: &mut IcebergTaskReport,
240 pk_index_results: Vec<PkIndexCompactionResult>,
241) {
242 if pk_index_results.is_empty() {
243 return;
244 }
245
246 let read_snapshot_id = pk_index_results[0].read_snapshot_id;
247 let schema_id = pk_index_results[0].schema_id;
248 let partition_spec_id = pk_index_results[0].partition_spec_id;
249 if !pk_index_results.iter().all(|result| {
253 result.read_snapshot_id == read_snapshot_id
254 && result.schema_id == schema_id
255 && result.partition_spec_id == partition_spec_id
256 }) {
257 return fail_pk_index_report(
258 report,
259 "pk_index_result.metadata",
260 "coordinated compaction plans must share one read snapshot, schema, and partition spec",
261 );
262 }
263
264 let mut output_files: Vec<SerializedDataFile> = Vec::new();
265 let mut input_file_paths: Vec<String> = Vec::new();
266 for pk_index_result in pk_index_results {
267 output_files.extend(pk_index_result.output_files);
268 input_file_paths.extend(pk_index_result.input_file_paths);
269 }
270
271 let output_files = match SinkMetadata::try_from(&IcebergCommitResult {
276 schema_id,
277 partition_spec_id,
278 data_files: output_files,
279 }) {
280 Ok(metadata) => metadata,
281 Err(e) => return fail_pk_index_report(report, "pk_index_result.output_files", e),
282 };
283 report.pk_index_result = Some(PbPkIndexCompactionResult {
284 output_files: Some(output_files),
285 input_file_paths,
286 read_snapshot_id,
287 });
288}
289
290fn fail_pk_index_report(
293 report: &mut IcebergTaskReport,
294 field_name: &str,
295 error: impl std::fmt::Display,
296) {
297 tracing::warn!(
298 %error,
299 task_id = %report.task_id,
300 sink_id = %report.sink_id,
301 "Failed to build {field_name}; failing pk-index compaction report"
302 );
303 report.pk_index_result = None;
304 report.status = subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32;
305 report.error_message = Some(format!(
306 "invalid pk-index compaction report payload ({field_name}): {}",
307 error
308 ));
309}
310
311pub(crate) fn build_iceberg_task_report(
312 task_id: IcebergCompactionTaskId,
313 sink_id: u32,
314 error_message: Option<String>,
315) -> IcebergTaskReport {
316 subscribe_iceberg_compaction_event_request::ReportTask {
317 task_id,
318 sink_id: sink_id.into(),
319 status: if error_message.is_some() {
320 subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32
321 } else {
322 subscribe_iceberg_compaction_event_request::report_task::Status::Success as i32
323 },
324 error_message,
325 pk_index_result: None,
326 }
327}
328
329pub(crate) fn build_drained_iceberg_task_report(
330 task_id: IcebergCompactionTaskId,
331 sink_id: u32,
332) -> IcebergTaskReport {
333 let mut report = build_iceberg_task_report(task_id, sink_id, None);
334 report.status = subscribe_iceberg_compaction_event_request::report_task::Status::Drained as i32;
335 report
336}
337
338pub(crate) fn send_iceberg_task_report(
339 request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
340 report_event: IcebergTaskReport,
341) -> Result<(), IcebergTaskReport> {
342 if let Err(e) = request_sender.send(SubscribeIcebergCompactionEventRequest {
343 event: Some(
344 subscribe_iceberg_compaction_event_request::Event::ReportTask(report_event.clone()),
345 ),
346 create_at: SystemTime::now()
347 .duration_since(std::time::UNIX_EPOCH)
348 .expect("Clock may have gone backwards")
349 .as_millis() as u64,
350 }) {
351 tracing::warn!(
352 iceberg_component = "compaction_worker",
353 iceberg_operation = "report_task",
354 error = %e.as_report(),
355 task_id = %report_event.task_id,
356 sink_id = %report_event.sink_id,
357 "iceberg_compaction_task_report_send_failed",
358 );
359 return Err(report_event);
360 }
361
362 Ok(())
363}
364
365pub(crate) fn send_or_buffer_iceberg_task_report(
366 request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
367 pending_task_reports: &mut VecDeque<IcebergTaskReport>,
368 report: IcebergTaskReport,
369) -> ReportSendResult {
370 if let Err(report) = send_iceberg_task_report(request_sender, report) {
371 pending_task_reports.push_back(report);
372 return ReportSendResult::RestartStream;
373 }
374 ReportSendResult::Sent
375}
376
377pub(crate) fn flush_pending_iceberg_task_reports(
378 request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
379 pending_task_reports: &mut VecDeque<IcebergTaskReport>,
380) -> ReportSendResult {
381 while let Some(report_event) = pending_task_reports.pop_front() {
382 if let Err(report_event) = send_iceberg_task_report(request_sender, report_event) {
383 pending_task_reports.push_front(report_event);
384 return ReportSendResult::RestartStream;
385 }
386 }
387 ReportSendResult::Sent
388}
389
390#[cfg(test)]
391mod tests {
392 use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request;
393
394 use super::*;
395
396 #[test]
397 fn test_send_iceberg_task_report_returns_payload_on_send_failure() {
398 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
399 drop(rx);
400
401 let report = build_iceberg_task_report(7.into(), 9, Some("send failure".to_owned()));
402 let failed_report = send_iceberg_task_report(&tx, report.clone()).unwrap_err();
403
404 assert_eq!(failed_report.task_id, report.task_id);
405 assert_eq!(failed_report.sink_id, report.sink_id);
406 assert_eq!(failed_report.error_message, report.error_message);
407 }
408
409 #[test]
410 fn test_build_iceberg_task_result_partial_enqueue_is_success_if_admitted_plan_succeeds() {
411 let mut tracker = IcebergTaskTracker::new(9, 1, false, false);
412 tracker.record_completion(None, None);
413
414 let report = tracker.into_report(7.into());
415
416 assert_eq!(
417 report.status,
418 subscribe_iceberg_compaction_event_request::report_task::Status::Success as i32
419 );
420 assert!(report.error_message.is_none());
421 }
422
423 #[test]
424 fn test_fully_admitted_bounded_round_reports_drained_after_success() {
425 let mut tracker = IcebergTaskTracker::new(9, 2, false, true);
426 tracker.record_completion(None, None);
427 tracker.record_completion(None, None);
428
429 let report = tracker.into_report(7.into());
430
431 assert_eq!(
432 report.status,
433 subscribe_iceberg_compaction_event_request::report_task::Status::Drained as i32
434 );
435 assert!(report.error_message.is_none());
436 }
437
438 #[test]
439 fn test_fully_admitted_bounded_round_with_partial_failure_reports_success() {
440 let mut tracker = IcebergTaskTracker::new(9, 2, false, true);
441 tracker.record_completion(None, None);
442 tracker.record_completion(Some("failure".to_owned()), None);
443
444 let report = tracker.into_report(7.into());
445
446 assert_eq!(
447 report.status,
448 subscribe_iceberg_compaction_event_request::report_task::Status::Success as i32
449 );
450 assert!(report.error_message.is_none());
451 }
452
453 #[test]
454 fn test_bounded_empty_planning_is_reported_as_drained() {
455 let report = build_drained_iceberg_task_report(7.into(), 9);
456
457 assert_eq!(
458 report.status,
459 subscribe_iceberg_compaction_event_request::report_task::Status::Drained as i32
460 );
461 assert!(report.error_message.is_none());
462 }
463
464 #[test]
465 fn test_into_report_populates_pk_index_fields_when_pk_index_result_present() {
466 let mut tracker = IcebergTaskTracker::new(9, 2, true, false);
467 tracker.record_completion(
468 None,
469 Some(PkIndexCompactionResult {
470 output_files: vec![],
471 schema_id: 1,
472 partition_spec_id: 2,
473 input_file_paths: vec![],
474 read_snapshot_id: 42,
475 }),
476 );
477 tracker.record_completion(
478 None,
479 Some(PkIndexCompactionResult {
480 output_files: vec![],
481 schema_id: 1,
482 partition_spec_id: 2,
483 input_file_paths: vec![],
484 read_snapshot_id: 42,
485 }),
486 );
487
488 let report = tracker.into_report(7.into());
489
490 let result = report.pk_index_result.unwrap();
491 assert!(result.output_files.is_some());
492 assert!(result.input_file_paths.is_empty());
493 assert_eq!(result.read_snapshot_id, 42);
494 }
495
496 #[test]
497 fn test_into_report_rejects_mismatched_pk_index_plan_metadata() {
498 for (read_snapshot_id, schema_id, partition_spec_id) in [(43, 1, 2), (42, 3, 2), (42, 1, 4)]
499 {
500 let mut tracker = IcebergTaskTracker::new(9, 2, true, false);
501 tracker.record_completion(
502 None,
503 Some(PkIndexCompactionResult {
504 output_files: vec![],
505 schema_id: 1,
506 partition_spec_id: 2,
507 input_file_paths: vec![],
508 read_snapshot_id: 42,
509 }),
510 );
511 tracker.record_completion(
512 None,
513 Some(PkIndexCompactionResult {
514 output_files: vec![],
515 schema_id,
516 partition_spec_id,
517 input_file_paths: vec![],
518 read_snapshot_id,
519 }),
520 );
521
522 let report = tracker.into_report(7.into());
523
524 assert_eq!(
525 report.status,
526 subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32
527 );
528 assert!(report.pk_index_result.is_none());
529 }
530 }
531
532 #[test]
533 fn test_into_report_leaves_pk_index_fields_none_for_non_pk_index_task() {
534 let mut tracker = IcebergTaskTracker::new(9, 1, false, false);
535 tracker.record_completion(None, None);
536
537 let report = tracker.into_report(7.into());
538
539 assert!(report.pk_index_result.is_none());
540 }
541
542 #[test]
543 fn test_build_iceberg_task_result_fails_if_all_admitted_plans_fail() {
544 let mut tracker = IcebergTaskTracker::new(9, 2, false, false);
545 tracker.record_completion(Some("first failure".to_owned()), None);
546 tracker.record_completion(Some("second failure".to_owned()), None);
547
548 let report = tracker.into_report(7.into());
549
550 assert_eq!(
551 report.status,
552 subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32
553 );
554 assert_eq!(report.error_message.as_deref(), Some("first failure"));
555 }
556}