Skip to main content

risingwave_storage/hummock/compactor/iceberg_compaction/
report.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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/// Per-plan result of a pk-index coordinated compaction run (rewrite without commit).
36///
37/// Produced by the compactor when the dispatched task has `pk_index_coordinated == true`. The
38/// actual iceberg commit is performed later by meta's iceberg pk-index sink coordinator, so the
39/// compactor only surfaces the rewrite output, the input file paths, and the snapshot it read
40/// from.
41#[derive(Clone)]
42pub(crate) struct PkIndexCompactionResult {
43    /// Newly written data files produced by the rewrite.
44    pub(crate) output_files: Vec<SerializedDataFile>,
45    pub(crate) schema_id: i32,
46    pub(crate) partition_spec_id: i32,
47    /// Paths of all input files (data + delete) consumed by the rewrite, taken directly from the
48    /// compaction plan's `FileGroup`. No manifest walk required.
49    pub(crate) input_file_paths: Vec<String>,
50    /// Snapshot the rewrite plan read from.
51    pub(crate) read_snapshot_id: i64,
52}
53
54// Manual Debug: `SerializedDataFile` does not implement `Debug`.
55impl 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
65/// Builds the pk-index coordinated report payload from a no-commit rewrite.
66///
67/// - Output files come from `data_files` (the rewrite's output, taken from `CompactionResult`)
68///   and are converted to [`SerializedDataFile`] for JSON serialization, using `table`'s partition
69///   type and format version.
70/// - `input_file_paths` and `read_snapshot_id` must be captured by the caller *before* the
71///   compaction plan is consumed by `compact_with_plan` (the plan's `FileGroup` already carries
72///   each input file's path via `FileScanTask::data_file_path` — no manifest walk needed).
73pub(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    /// Present only for pk-index coordinated plans that completed successfully.
104    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 coordinated rewrite results, aggregated across all plans of the task. Empty for
137    /// non-coordinated tasks.
138    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        // This only proves that the admitted batch finished. The report also
181        // considers whether this task covered every plan in a bounded round.
182        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
222/// Flattens the per-plan pk-index coordinated results into the `ReportTask` payload fields.
223fn 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    // The planner builds all plans of a task from one branch snapshot, so they must agree.
235    // Reject inconsistencies in release builds as well: using the first plan's metadata to encode
236    // files produced against another snapshot or spec would create an invalid report payload.
237    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    // This task is pk-index coordinated (that's why we're populating these fields at all), so
257    // meta's sink coordinator relies on this payload to perform the actual iceberg commit.
258    // Reporting Success without it would make meta silently treat the rewrite as done while
259    // dropping the output files entirely. Fail the report instead so meta retries the task.
260    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
275/// Marks `report` as failed after a pk-index payload field failed validation or serialization, so
276/// meta retries the task instead of silently dropping the compaction output.
277fn 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}