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    total_plans: usize,
131    remaining_plans: usize,
132    successful_plans: usize,
133    failed_plans: usize,
134    first_error: Option<String>,
135    /// Pk-index coordinated rewrite results, aggregated across all plans of the task. Empty for
136    /// non-coordinated tasks.
137    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
208/// Flattens the per-plan pk-index coordinated results into the `ReportTask` payload fields.
209fn 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    // The planner builds all plans of a task from one branch snapshot, so they must agree.
221    // Reject inconsistencies in release builds as well: using the first plan's metadata to encode
222    // files produced against another snapshot or spec would create an invalid report payload.
223    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    // This task is pk-index coordinated (that's why we're populating these fields at all), so
243    // meta's sink coordinator relies on this payload to perform the actual iceberg commit.
244    // Reporting Success without it would make meta silently treat the rewrite as done while
245    // dropping the output files entirely. Fail the report instead so meta retries the task.
246    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
261/// Marks `report` as failed after a pk-index payload field failed validation or serialization, so
262/// meta retries the task instead of silently dropping the compaction output.
263fn 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}