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 total_plans: usize,
131 remaining_plans: usize,
132 successful_plans: usize,
133 failed_plans: usize,
134 first_error: Option<String>,
135 pk_index_results: Vec<PkIndexCompactionResult>,
138}
139
140impl IcebergTaskTracker {
141 pub(crate) fn new(sink_id: u32, remaining_plans: usize) -> Self {
142 Self {
143 sink_id,
144 total_plans: remaining_plans,
145 remaining_plans,
146 successful_plans: 0,
147 failed_plans: 0,
148 first_error: None,
149 pk_index_results: Vec::new(),
150 }
151 }
152
153 pub(crate) fn record_completion(
154 &mut self,
155 error_message: Option<String>,
156 pk_index_result: Option<PkIndexCompactionResult>,
157 ) {
158 debug_assert!(self.remaining_plans > 0);
159 self.remaining_plans -= 1;
160 if let Some(error_message) = error_message {
161 self.failed_plans += 1;
162 if self.first_error.is_none() {
163 self.first_error = Some(error_message);
164 }
165 } else {
166 self.successful_plans += 1;
167 if let Some(pk_index_result) = pk_index_result {
168 self.pk_index_results.push(pk_index_result);
169 }
170 }
171 }
172
173 pub(crate) fn is_finished(&self) -> bool {
174 self.remaining_plans == 0
175 }
176
177 pub(crate) fn sink_id(&self) -> u32 {
178 self.sink_id
179 }
180
181 pub(crate) fn total_plans(&self) -> usize {
182 self.total_plans
183 }
184
185 pub(crate) fn successful_plans(&self) -> usize {
186 self.successful_plans
187 }
188
189 pub(crate) fn failed_plans(&self) -> usize {
190 self.failed_plans
191 }
192
193 pub(crate) fn into_report(self, task_id: IcebergCompactionTaskId) -> IcebergTaskReport {
194 let error_message = if self.successful_plans > 0 {
195 None
196 } else {
197 Some(
198 self.first_error
199 .unwrap_or_else(|| "All admitted iceberg compaction plans failed".to_owned()),
200 )
201 };
202 let mut report = build_iceberg_task_report(task_id, self.sink_id, error_message);
203 populate_pk_index_report_fields(&mut report, self.pk_index_results);
204 report
205 }
206}
207
208fn populate_pk_index_report_fields(
210 report: &mut IcebergTaskReport,
211 pk_index_results: Vec<PkIndexCompactionResult>,
212) {
213 if pk_index_results.is_empty() {
214 return;
215 }
216
217 let read_snapshot_id = pk_index_results[0].read_snapshot_id;
218 let schema_id = pk_index_results[0].schema_id;
219 let partition_spec_id = pk_index_results[0].partition_spec_id;
220 if !pk_index_results.iter().all(|result| {
224 result.read_snapshot_id == read_snapshot_id
225 && result.schema_id == schema_id
226 && result.partition_spec_id == partition_spec_id
227 }) {
228 return fail_pk_index_report(
229 report,
230 "pk_index_result.metadata",
231 "coordinated compaction plans must share one read snapshot, schema, and partition spec",
232 );
233 }
234
235 let mut output_files: Vec<SerializedDataFile> = Vec::new();
236 let mut input_file_paths: Vec<String> = Vec::new();
237 for pk_index_result in pk_index_results {
238 output_files.extend(pk_index_result.output_files);
239 input_file_paths.extend(pk_index_result.input_file_paths);
240 }
241
242 let output_files = match SinkMetadata::try_from(&IcebergCommitResult {
247 schema_id,
248 partition_spec_id,
249 data_files: output_files,
250 }) {
251 Ok(metadata) => metadata,
252 Err(e) => return fail_pk_index_report(report, "pk_index_result.output_files", e),
253 };
254 report.pk_index_result = Some(PbPkIndexCompactionResult {
255 output_files: Some(output_files),
256 input_file_paths,
257 read_snapshot_id,
258 });
259}
260
261fn fail_pk_index_report(
264 report: &mut IcebergTaskReport,
265 field_name: &str,
266 error: impl std::fmt::Display,
267) {
268 tracing::warn!(
269 %error,
270 task_id = %report.task_id,
271 sink_id = report.sink_id,
272 "Failed to build {field_name}; failing pk-index compaction report"
273 );
274 report.pk_index_result = None;
275 report.status = subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32;
276 report.error_message = Some(format!(
277 "invalid pk-index compaction report payload ({field_name}): {}",
278 error
279 ));
280}
281
282pub(crate) fn build_iceberg_task_report(
283 task_id: IcebergCompactionTaskId,
284 sink_id: u32,
285 error_message: Option<String>,
286) -> IcebergTaskReport {
287 subscribe_iceberg_compaction_event_request::ReportTask {
288 task_id,
289 sink_id,
290 status: if error_message.is_some() {
291 subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32
292 } else {
293 subscribe_iceberg_compaction_event_request::report_task::Status::Success as i32
294 },
295 error_message,
296 pk_index_result: None,
297 }
298}
299
300pub(crate) fn send_iceberg_task_report(
301 request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
302 report_event: IcebergTaskReport,
303) -> Result<(), IcebergTaskReport> {
304 if let Err(e) = request_sender.send(SubscribeIcebergCompactionEventRequest {
305 event: Some(
306 subscribe_iceberg_compaction_event_request::Event::ReportTask(report_event.clone()),
307 ),
308 create_at: SystemTime::now()
309 .duration_since(std::time::UNIX_EPOCH)
310 .expect("Clock may have gone backwards")
311 .as_millis() as u64,
312 }) {
313 tracing::warn!(
314 iceberg_component = "compaction_worker",
315 iceberg_operation = "report_task",
316 error = %e.as_report(),
317 task_id = %report_event.task_id,
318 sink_id = report_event.sink_id,
319 "iceberg_compaction_task_report_send_failed",
320 );
321 return Err(report_event);
322 }
323
324 Ok(())
325}
326
327pub(crate) fn send_or_buffer_iceberg_task_report(
328 request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
329 pending_task_reports: &mut VecDeque<IcebergTaskReport>,
330 report: IcebergTaskReport,
331) -> ReportSendResult {
332 if let Err(report) = send_iceberg_task_report(request_sender, report) {
333 pending_task_reports.push_back(report);
334 return ReportSendResult::RestartStream;
335 }
336 ReportSendResult::Sent
337}
338
339pub(crate) fn flush_pending_iceberg_task_reports(
340 request_sender: &mpsc::UnboundedSender<SubscribeIcebergCompactionEventRequest>,
341 pending_task_reports: &mut VecDeque<IcebergTaskReport>,
342) -> ReportSendResult {
343 while let Some(report_event) = pending_task_reports.pop_front() {
344 if let Err(report_event) = send_iceberg_task_report(request_sender, report_event) {
345 pending_task_reports.push_front(report_event);
346 return ReportSendResult::RestartStream;
347 }
348 }
349 ReportSendResult::Sent
350}
351
352#[cfg(test)]
353mod tests {
354 use risingwave_pb::iceberg_compaction::subscribe_iceberg_compaction_event_request;
355
356 use super::*;
357
358 #[test]
359 fn test_send_iceberg_task_report_returns_payload_on_send_failure() {
360 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
361 drop(rx);
362
363 let report = build_iceberg_task_report(7.into(), 9, Some("send failure".to_owned()));
364 let failed_report = send_iceberg_task_report(&tx, report.clone()).unwrap_err();
365
366 assert_eq!(failed_report.task_id, report.task_id);
367 assert_eq!(failed_report.sink_id, report.sink_id);
368 assert_eq!(failed_report.error_message, report.error_message);
369 }
370
371 #[test]
372 fn test_build_iceberg_task_result_partial_enqueue_is_success_if_admitted_plan_succeeds() {
373 let mut tracker = IcebergTaskTracker::new(9, 1);
374 tracker.record_completion(None, None);
375
376 let report = tracker.into_report(7.into());
377
378 assert_eq!(
379 report.status,
380 subscribe_iceberg_compaction_event_request::report_task::Status::Success as i32
381 );
382 assert!(report.error_message.is_none());
383 }
384
385 #[test]
386 fn test_into_report_populates_pk_index_fields_when_pk_index_result_present() {
387 let mut tracker = IcebergTaskTracker::new(9, 2);
388 tracker.record_completion(
389 None,
390 Some(PkIndexCompactionResult {
391 output_files: vec![],
392 schema_id: 1,
393 partition_spec_id: 2,
394 input_file_paths: vec![],
395 read_snapshot_id: 42,
396 }),
397 );
398 tracker.record_completion(
399 None,
400 Some(PkIndexCompactionResult {
401 output_files: vec![],
402 schema_id: 1,
403 partition_spec_id: 2,
404 input_file_paths: vec![],
405 read_snapshot_id: 42,
406 }),
407 );
408
409 let report = tracker.into_report(7.into());
410
411 let result = report.pk_index_result.unwrap();
412 assert!(result.output_files.is_some());
413 assert!(result.input_file_paths.is_empty());
414 assert_eq!(result.read_snapshot_id, 42);
415 }
416
417 #[test]
418 fn test_into_report_rejects_mismatched_pk_index_plan_metadata() {
419 for (read_snapshot_id, schema_id, partition_spec_id) in [(43, 1, 2), (42, 3, 2), (42, 1, 4)]
420 {
421 let mut tracker = IcebergTaskTracker::new(9, 2);
422 tracker.record_completion(
423 None,
424 Some(PkIndexCompactionResult {
425 output_files: vec![],
426 schema_id: 1,
427 partition_spec_id: 2,
428 input_file_paths: vec![],
429 read_snapshot_id: 42,
430 }),
431 );
432 tracker.record_completion(
433 None,
434 Some(PkIndexCompactionResult {
435 output_files: vec![],
436 schema_id,
437 partition_spec_id,
438 input_file_paths: vec![],
439 read_snapshot_id,
440 }),
441 );
442
443 let report = tracker.into_report(7.into());
444
445 assert_eq!(
446 report.status,
447 subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32
448 );
449 assert!(report.pk_index_result.is_none());
450 }
451 }
452
453 #[test]
454 fn test_into_report_leaves_pk_index_fields_none_for_non_pk_index_task() {
455 let mut tracker = IcebergTaskTracker::new(9, 1);
456 tracker.record_completion(None, None);
457
458 let report = tracker.into_report(7.into());
459
460 assert!(report.pk_index_result.is_none());
461 }
462
463 #[test]
464 fn test_build_iceberg_task_result_fails_if_all_admitted_plans_fail() {
465 let mut tracker = IcebergTaskTracker::new(9, 2);
466 tracker.record_completion(Some("first failure".to_owned()), None);
467 tracker.record_completion(Some("second failure".to_owned()), None);
468
469 let report = tracker.into_report(7.into());
470
471 assert_eq!(
472 report.status,
473 subscribe_iceberg_compaction_event_request::report_task::Status::Failed as i32
474 );
475 assert_eq!(report.error_message.as_deref(), Some("first failure"));
476 }
477}