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    /// Data files replaced by the rewrite. Shared delete files remain live; meta handles
48    /// removal of the replaced data files' file-scoped deletes.
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 `RewriteResult`)
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` come from the plan carried by `RewriteResult`.
71///   Input paths include only replaced data files, never shared delete files.
72pub(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    /// Present only for pk-index coordinated plans that completed successfully.
103    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 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        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        // This only proves that the admitted batch finished. The report also
193        // considers whether this task covered every plan in a bounded round.
194        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        // A coordinated rewrite is only progress. Deletes may have arrived
215        // since planning, so only a fresh, empty scan after application can drain.
216        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
237/// Flattens the per-plan pk-index coordinated results into the `ReportTask` payload fields.
238fn 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    // The planner builds all plans of a task from one branch snapshot, so they must agree.
250    // Reject inconsistencies in release builds as well: using the first plan's metadata to encode
251    // files produced against another snapshot or spec would create an invalid report payload.
252    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    // This task is pk-index coordinated (that's why we're populating these fields at all), so
272    // meta's sink coordinator relies on this payload to perform the actual iceberg commit.
273    // Reporting Success without it would make meta silently treat the rewrite as done while
274    // dropping the output files entirely. Fail the report instead so meta retries the task.
275    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
290/// Marks `report` as failed after a pk-index payload field failed validation or serialization, so
291/// meta retries the task instead of silently dropping the compaction output.
292fn 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}