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