Skip to main content

risingwave_meta/barrier/checkpoint/independent_job/iceberg_v3_job/
mod.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
15mod checkpoint;
16mod render;
17
18use std::cmp::max;
19use std::collections::{HashMap, HashSet, VecDeque, hash_map};
20use std::mem::replace;
21use std::ops::Bound::{Excluded, Unbounded};
22use std::sync::atomic::AtomicU32;
23
24use risingwave_common::catalog::TableId;
25use risingwave_common::id::{JobId, SinkId};
26use risingwave_common::metrics::{LabelGuardedHistogram, LabelGuardedIntGauge};
27use risingwave_common::util::epoch::{Epoch, EpochPair};
28use risingwave_common::util::stream_graph_visitor::visit_stream_node_cont;
29use risingwave_connector::sink::decouple_checkpoint_log_sink::COMMIT_CHECKPOINT_INTERVAL;
30use risingwave_meta_model::{WorkerId, streaming_job};
31use risingwave_pb::common::WorkerNode;
32use risingwave_pb::ddl_service::PbBackfillType;
33use risingwave_pb::hummock::HummockVersionStats;
34use risingwave_pb::id::{ActorId, FragmentId, IcebergCompactionTaskId};
35use risingwave_pb::stream_plan::IcebergPkIndexCompactionContext;
36use risingwave_pb::stream_plan::barrier_mutation::Mutation;
37use risingwave_pb::stream_plan::iceberg_pk_index_compaction_context::{Phase, ResolverTaskInput};
38use risingwave_pb::stream_service::BarrierCompleteResponse;
39use tracing::info;
40
41use self::checkpoint::CommitCheckpointInterval;
42use self::render::{
43    CompactionTransition, IcebergV3FragmentPartition, RenderedResolver,
44    build_compaction_transitions, partition_resolver,
45};
46use super::{
47    BatchRefreshRenderResult, CreatingStreamingJobControl, IndependentCheckpointJob,
48    IndependentCheckpointJobControl, IndependentCheckpointJobStatus, IndependentJobControl,
49    IndependentJobInfo, InitialPartialGraphRequest, RenderedIndependentJobActors,
50    SnapshotPhaseControl, add_initial_partial_graph, build_initial_add_mutation,
51};
52use crate::MetaResult;
53use crate::barrier::command::{
54    IcebergPkIndexCompactionOverwrite, PostCollectCommand, ThrottleConfigMap,
55    UpstreamTableLogEpochs, extract_throttle_config,
56};
57use crate::barrier::context::CreateIndependentStreamingJobCommandInfo;
58use crate::barrier::info::BarrierInfo;
59use crate::barrier::partial_graph::{
60    CollectedBarrier, PartialGraphBarrierInfo, PartialGraphManager, PartialGraphRecoverer,
61    PartialGraphStat,
62};
63use crate::barrier::progress::TrackingJob;
64use crate::barrier::rpc::{ControlStreamManager, to_partial_graph_id};
65use crate::barrier::{BackfillProgress, BarrierKind, FragmentBackfillProgress, TracedEpoch};
66use crate::controller::fragment::InflightFragmentInfo;
67use crate::controller::scale::LoadedFragment;
68use crate::manager::iceberg_pk_index_sink::CompactionOverwrite;
69use crate::model::{FragmentDownstreamRelation, StreamActor};
70use crate::notification::{CollectionNotifier, NotifierStarter};
71use crate::rpc::metrics::GLOBAL_META_METRICS;
72use crate::stream::ExtendedFragmentBackfillOrder;
73
74struct IcebergV3BarrierStats {
75    consuming_snapshot_barrier_latency: LabelGuardedHistogram,
76    consuming_log_store_barrier_latency: LabelGuardedHistogram,
77    inflight_barrier_num: LabelGuardedIntGauge,
78    snapshot_epoch: u64,
79}
80
81fn synthetic_compaction_epoch(prev_epoch: Epoch, curr_epoch: Epoch) -> Epoch {
82    let synthetic_physical_time = prev_epoch
83        .physical_time()
84        .checked_add(1)
85        .expect("synthetic compaction epoch should not overflow");
86    let synthetic = Epoch::from_physical_time(synthetic_physical_time);
87    // Normally generated upstream barriers use `Epoch::next()`, which reserves at least one
88    // physical epoch between `prev_epoch` and `curr_epoch` for a synthetic boundary.
89    assert!(
90        synthetic > prev_epoch && synthetic < curr_epoch,
91        "synthetic compaction epoch must be between {} and {}",
92        prev_epoch.0,
93        curr_epoch.0
94    );
95    synthetic
96}
97
98impl IcebergV3BarrierStats {
99    fn new(job_id: JobId, snapshot_epoch: u64) -> Self {
100        let table_id_str = format!("{}", job_id);
101        Self {
102            snapshot_epoch,
103            consuming_snapshot_barrier_latency: GLOBAL_META_METRICS
104                .snapshot_backfill_barrier_latency
105                .with_guarded_label_values(&[table_id_str.as_str(), "consuming_snapshot"]),
106            consuming_log_store_barrier_latency: GLOBAL_META_METRICS
107                .snapshot_backfill_barrier_latency
108                .with_guarded_label_values(&[table_id_str.as_str(), "consuming_log_store"]),
109            inflight_barrier_num: GLOBAL_META_METRICS
110                .snapshot_backfill_inflight_barrier_num
111                .with_guarded_label_values(&[&table_id_str]),
112        }
113    }
114}
115
116impl PartialGraphStat for IcebergV3BarrierStats {
117    fn observe_barrier_latency(&self, epoch: EpochPair, barrier_latency_secs: f64) {
118        let barrier_latency_metrics = if epoch.prev < self.snapshot_epoch {
119            &self.consuming_snapshot_barrier_latency
120        } else {
121            &self.consuming_log_store_barrier_latency
122        };
123        barrier_latency_metrics.observe(barrier_latency_secs);
124    }
125
126    fn observe_barrier_num(&self, inflight_barrier_num: usize, _collected_barrier_num: usize) {
127        self.inflight_barrier_num.set(inflight_barrier_num as _);
128    }
129}
130
131#[derive(Debug)]
132struct IcebergV3Input {
133    phase: IcebergV3InputPhase,
134    /// Original upstream barriers retained during snapshot consumption and then replayed from the
135    /// log store after the snapshot finishes.
136    pending_upstream_barriers: VecDeque<BarrierInfo>,
137}
138
139#[derive(Debug)]
140enum IcebergV3InputPhase {
141    Snapshot(SnapshotPhaseControl),
142    LogStore { tracking_job: Option<TrackingJob> },
143}
144
145#[derive(Debug)]
146enum IcebergV3JobStatus {
147    Running(IcebergV3Input),
148    Compacting {
149        input: IcebergV3Input,
150        apply: CompactionApply,
151    },
152}
153
154impl IcebergV3JobStatus {
155    fn next_compaction_barriers(
156        &mut self,
157        job_id: JobId,
158        upstream_barrier: &BarrierInfo,
159    ) -> MetaResult<(Vec<BarrierInfo>, BarrierInfo, BarrierInfo)> {
160        match self {
161            IcebergV3JobStatus::Running(input) => {
162                let IcebergV3Input {
163                    phase,
164                    pending_upstream_barriers,
165                } = input;
166                match phase {
167                    IcebergV3InputPhase::Snapshot(snapshot) => {
168                        pending_upstream_barriers.push_back(upstream_barrier.clone());
169                        let begin = snapshot.next_fake_barrier(&BarrierKind::Checkpoint(vec![]));
170                        let end = snapshot.next_fake_barrier(&BarrierKind::Checkpoint(vec![]));
171                        Ok((vec![], begin, end))
172                    }
173                    IcebergV3InputPhase::LogStore { .. } => {
174                        if !upstream_barrier.kind.is_checkpoint() {
175                            return Err(anyhow::anyhow!(
176                                "Iceberg pk-index compaction requires a checkpoint command barrier"
177                            )
178                            .into());
179                        }
180                        pending_upstream_barriers.push_back(upstream_barrier.clone());
181
182                        let checkpoint_index = pending_upstream_barriers
183                            .iter()
184                            .position(|barrier| barrier.kind.is_checkpoint())
185                            .expect("new upstream barrier should be a checkpoint");
186
187                        let barriers_before_compaction = pending_upstream_barriers
188                            .drain(..checkpoint_index)
189                            .collect();
190                        let BarrierInfo {
191                            prev_epoch,
192                            curr_epoch,
193                            kind: BarrierKind::Checkpoint(checkpoint_epochs),
194                        } = pending_upstream_barriers
195                            .pop_front()
196                            .expect("selected checkpoint should exist")
197                        else {
198                            unreachable!("selected barrier should be a checkpoint")
199                        };
200                        let synthetic_epoch = TracedEpoch::new(synthetic_compaction_epoch(
201                            prev_epoch.value(),
202                            curr_epoch.value(),
203                        ));
204                        let begin_barrier = BarrierInfo {
205                            prev_epoch,
206                            curr_epoch: synthetic_epoch.clone(),
207                            kind: BarrierKind::Checkpoint(checkpoint_epochs),
208                        };
209                        let end_barrier = BarrierInfo {
210                            prev_epoch: synthetic_epoch.clone(),
211                            curr_epoch,
212                            kind: BarrierKind::Checkpoint(vec![synthetic_epoch.value().0]),
213                        };
214
215                        Ok((barriers_before_compaction, begin_barrier, end_barrier))
216                    }
217                }
218            }
219            IcebergV3JobStatus::Compacting { .. } => Err(anyhow::anyhow!(
220                "Iceberg V3 job {} is already applying compaction",
221                job_id
222            )
223            .into()),
224        }
225    }
226}
227
228#[derive(Debug)]
229struct CompactionApply {
230    notifier: CollectionNotifier,
231    phase: CompactionApplyPhase,
232}
233
234#[derive(Debug)]
235enum CompactionApplyPhase {
236    /// B1 is in flight. Its acknowledgement injects B2 and transfers to
237    /// `EndCompletion`.
238    BeginAck {
239        task_id: IcebergCompactionTaskId,
240        begin_epoch: u64,
241        end_barrier: BarrierInfo,
242        /// Actors from the normal-input, resolver, and long-running partitions.
243        node_actors: HashMap<WorkerId, HashSet<ActorId>>,
244        end_transition: CompactionTransition,
245        overwrite: CompactionOverwrite,
246    },
247    /// B2 is in flight and the state machine still owns the overwrite. Starting B2 completion
248    /// moves the overwrite into `CompleteBarrierTask` and transfers to `EndAck`.
249    EndCompletion {
250        end_epoch: u64,
251        overwrite: CompactionOverwrite,
252    },
253    /// B2 and its overwrite are being committed by `CompleteBarrierTask`.
254    EndAck { end_epoch: u64 },
255}
256
257#[derive(Debug)]
258pub(crate) struct IcebergV3RenderResult {
259    active: BatchRefreshRenderResult,
260    active_stream_actors: HashMap<FragmentId, Vec<StreamActor>>,
261    active_downstreams: FragmentDownstreamRelation,
262    resolver: RenderedResolver,
263    partition: IcebergV3FragmentPartition,
264}
265
266impl IcebergV3RenderResult {
267    pub(crate) fn active_fragment_infos(&self) -> &HashMap<FragmentId, InflightFragmentInfo> {
268        &self.active.fragment_infos
269    }
270
271    pub(crate) fn active_downstreams(&self) -> &FragmentDownstreamRelation {
272        &self.active_downstreams
273    }
274
275    pub(crate) fn all_fragment_infos(&self) -> impl Iterator<Item = &InflightFragmentInfo> + '_ {
276        self.active
277            .fragment_infos
278            .values()
279            .chain(std::iter::once(&self.resolver.fragment_info))
280    }
281}
282
283/// Independent control for an Iceberg V3 sink. It owns snapshot and log-store progress for the
284/// job's entire lifetime and retains the inactive resolver graph for future compaction commands.
285pub(crate) struct IcebergV3JobCheckpointControl {
286    control: IndependentJobControl,
287    commit_checkpoint_interval: CommitCheckpointInterval,
288    status: IcebergV3JobStatus,
289    fragment_infos: HashMap<FragmentId, InflightFragmentInfo>,
290    node_actors: HashMap<WorkerId, HashSet<ActorId>>,
291    max_pending_barrier_num: usize,
292    upstream_lag: risingwave_common::metrics::LabelGuardedIntGauge,
293    /// Current actor definitions for the normal-input and long-running partitions. Scaling must
294    /// update these together with `fragment_infos`.
295    active_stream_actors: HashMap<FragmentId, Vec<StreamActor>>,
296    /// Fragment-level relations of the active graph. Actor-level transition edges are derived from
297    /// these relations for each compaction command.
298    active_downstreams: FragmentDownstreamRelation,
299    resolver: RenderedResolver,
300    partition: IcebergV3FragmentPartition,
301}
302
303impl IcebergV3JobCheckpointControl {
304    pub(crate) fn render_actors_and_build_job_info(
305        fragments: &HashMap<FragmentId, LoadedFragment>,
306        downstreams: &FragmentDownstreamRelation,
307        definition: &str,
308        actor_id_generator: &AtomicU32,
309        worker_nodes: &HashMap<WorkerId, WorkerNode>,
310        control_stream_manager: &ControlStreamManager,
311        database_resource_group: &str,
312        streaming_job_model: &streaming_job::Model,
313        partial_graph_id: risingwave_pb::id::PartialGraphId,
314    ) -> MetaResult<IcebergV3RenderResult> {
315        let rendered = RenderedIndependentJobActors::render(
316            fragments,
317            downstreams,
318            definition,
319            actor_id_generator,
320            worker_nodes,
321            database_resource_group,
322            streaming_job_model,
323        )?;
324        let mut active_downstreams = downstreams.clone();
325        let (active, resolver, partition) = partition_resolver(rendered, &mut active_downstreams)?;
326        let active_stream_actors = active.stream_actors.clone();
327        let active = active.build_job_info(
328            &active_downstreams,
329            partial_graph_id,
330            control_stream_manager,
331        )?;
332        Ok(IcebergV3RenderResult {
333            active,
334            active_stream_actors,
335            active_downstreams,
336            resolver,
337            partition,
338        })
339    }
340
341    pub(crate) fn create<'a>(
342        entry: hash_map::VacantEntry<'a, JobId, IndependentCheckpointJobControl>,
343        create_info: CreateIndependentStreamingJobCommandInfo,
344        notifier: Option<&mut NotifierStarter>,
345        snapshot_backfill_upstream_tables: HashSet<TableId>,
346        snapshot_epoch: u64,
347        version_stat: &HummockVersionStats,
348        term_id: &str,
349        partial_graph_manager: &mut PartialGraphManager,
350        render_result: IcebergV3RenderResult,
351    ) -> MetaResult<&'a mut Self> {
352        let info = create_info.info.clone();
353        let job_id = info.stream_job_fragments.stream_job_id();
354        let database_id = info.streaming_job.database_id();
355        let partial_graph_id = to_partial_graph_id(database_id, Some(job_id));
356
357        let IcebergV3RenderResult {
358            active:
359                BatchRefreshRenderResult {
360                    fragment_infos,
361                    node_actors,
362                    actors_to_create,
363                },
364            active_stream_actors,
365            active_downstreams,
366            resolver,
367            partition,
368        } = render_result;
369        let (snapshot, initial_barrier_info) =
370            SnapshotPhaseControl::for_new_job(snapshot_epoch, &fragment_infos, &info, version_stat);
371        assert!(
372            info.cdc_table_snapshot_splits.is_none(),
373            "should not have cdc backfill for snapshot backfill job"
374        );
375        let initial_mutation = build_initial_add_mutation(
376            &fragment_infos,
377            &info.fragment_backfill_ordering,
378            Default::default(),
379        );
380
381        let opts = &partial_graph_manager.control_stream_manager().env.opts;
382        let max_pending_barrier_num = super::snapshot_backfill_max_pending_barrier_num(
383            opts.in_flight_barrier_nums,
384            opts.snapshot_backfill_barrier_amplification_factor,
385        );
386        let commit_checkpoint_interval = CommitCheckpointInterval::from_fragment_nodes(
387            fragment_infos.values().map(|fragment| &fragment.nodes),
388        )?;
389
390        let iceberg_job = Self {
391            commit_checkpoint_interval,
392            control: IndependentJobControl::new(IndependentJobInfo::from_fragment_infos(
393                database_id,
394                job_id,
395                snapshot_epoch,
396                snapshot_backfill_upstream_tables,
397                fragment_infos
398                    .values()
399                    .chain(std::iter::once(&resolver.fragment_info)),
400            )),
401            status: IcebergV3JobStatus::Running(IcebergV3Input {
402                phase: IcebergV3InputPhase::Snapshot(snapshot),
403                pending_upstream_barriers: VecDeque::new(),
404            }),
405            fragment_infos,
406            upstream_lag: GLOBAL_META_METRICS
407                .snapshot_backfill_lag
408                .with_guarded_label_values(&[&format!("{}", job_id)]),
409            node_actors,
410            max_pending_barrier_num,
411            active_stream_actors,
412            active_downstreams,
413            resolver,
414            partition,
415        };
416
417        let mut graph_adder = partial_graph_manager.add_partial_graph(
418            partial_graph_id,
419            term_id,
420            IcebergV3BarrierStats::new(job_id, snapshot_epoch),
421        );
422        if let Err(error) = add_initial_partial_graph(
423            &mut graph_adder,
424            partial_graph_id,
425            InitialPartialGraphRequest {
426                node_actors: &iceberg_job.node_actors,
427                state_table_ids: &iceberg_job.control.info.state_table_ids,
428                barrier_info: initial_barrier_info,
429                actors_to_create,
430                mutation: initial_mutation,
431                notifier,
432                create_info,
433            },
434        ) {
435            graph_adder.failed();
436            entry.insert(IndependentCheckpointJobControl::resetting(
437                iceberg_job.control.info.snapshot_backfill_upstream_tables,
438            ));
439            return Err(error);
440        }
441        graph_adder.added();
442
443        let control = entry.insert(IndependentCheckpointJobControl::iceberg_v3(
444            job_id,
445            partial_graph_id,
446            IndependentCheckpointJobStatus::Initial { snapshot_epoch },
447            iceberg_job,
448        ));
449        let Some(IndependentCheckpointJob::IcebergV3(job)) = control.running_mut() else {
450            unreachable!()
451        };
452        Ok(job)
453    }
454
455    fn recover_snapshot_phase(
456        job_id: JobId,
457        snapshot_backfill_upstream_tables: &HashSet<TableId>,
458        upstream_table_log_epochs: &UpstreamTableLogEpochs,
459        snapshot_epoch: u64,
460        committed_epoch: u64,
461        upstream_barrier_info: &BarrierInfo,
462        fragment_infos: &HashMap<FragmentId, InflightFragmentInfo>,
463        backfill_order: &ExtendedFragmentBackfillOrder,
464        version_stat: &HummockVersionStats,
465    ) -> MetaResult<(IcebergV3Input, BarrierInfo)> {
466        let (snapshot, first_barrier_info) = SnapshotPhaseControl::for_recovery(
467            job_id,
468            snapshot_epoch,
469            committed_epoch,
470            fragment_infos,
471            backfill_order,
472            version_stat,
473        );
474        Ok((
475            IcebergV3Input {
476                phase: IcebergV3InputPhase::Snapshot(snapshot),
477                pending_upstream_barriers:
478                    CreatingStreamingJobControl::resolve_checkpoint_aligned_upstream_log_barriers(
479                        snapshot_backfill_upstream_tables,
480                        upstream_table_log_epochs,
481                        snapshot_epoch,
482                        upstream_barrier_info,
483                    )?
484                    .into(),
485            },
486            first_barrier_info,
487        ))
488    }
489
490    fn recover_log_store_phase(
491        job_id: JobId,
492        snapshot_backfill_upstream_tables: &HashSet<TableId>,
493        upstream_table_log_epochs: &UpstreamTableLogEpochs,
494        committed_epoch: u64,
495        upstream_barrier_info: &BarrierInfo,
496    ) -> MetaResult<(IcebergV3Input, BarrierInfo)> {
497        let (checkpoint_epoch, upstream_log_epochs) =
498            CreatingStreamingJobControl::resolve_upstream_log_epochs(
499                snapshot_backfill_upstream_tables,
500                upstream_table_log_epochs,
501                committed_epoch,
502                upstream_barrier_info,
503            )?;
504        if checkpoint_epoch != committed_epoch {
505            info!(
506                %job_id,
507                committed_epoch,
508                preceding_checkpoint_epoch = checkpoint_epoch,
509                "recovering Iceberg V3 job from an epoch lagged from upstream"
510            );
511        }
512        let mut pending_upstream_barriers: VecDeque<_> =
513            CreatingStreamingJobControl::compose_upstream_log_barriers(
514                committed_epoch,
515                upstream_log_epochs,
516                upstream_barrier_info,
517            )?
518            .into();
519        let mut first_barrier_info = pending_upstream_barriers
520            .pop_front()
521            .expect("resolved upstream log epochs should not be empty");
522        assert!(first_barrier_info.kind.is_checkpoint());
523        first_barrier_info.kind = BarrierKind::Initial;
524        Ok((
525            IcebergV3Input {
526                phase: IcebergV3InputPhase::LogStore { tracking_job: None },
527                pending_upstream_barriers,
528            },
529            first_barrier_info,
530        ))
531    }
532
533    pub(crate) fn recover(
534        control: IndependentJobControl,
535        upstream_table_log_epochs: &UpstreamTableLogEpochs,
536        upstream_barrier_info: &BarrierInfo,
537        backfill_order: ExtendedFragmentBackfillOrder,
538        version_stat: &HummockVersionStats,
539        initial_mutation: Mutation,
540        term_id: &str,
541        partial_graph_recoverer: &mut PartialGraphRecoverer<'_>,
542        render_result: IcebergV3RenderResult,
543    ) -> MetaResult<Self> {
544        let job_id = control.info.job_id;
545        let partial_graph_id = control.info.partial_graph_id;
546        let snapshot_epoch = control.info.snapshot_epoch;
547        let committed_epoch = control
548            .max_committed_epoch
549            .expect("recovered independent job should have committed");
550        let IcebergV3RenderResult {
551            active:
552                BatchRefreshRenderResult {
553                    fragment_infos,
554                    node_actors,
555                    actors_to_create,
556                },
557            active_stream_actors,
558            active_downstreams,
559            resolver,
560            partition,
561        } = render_result;
562
563        let (status, first_barrier_info) = if committed_epoch < snapshot_epoch {
564            Self::recover_snapshot_phase(
565                job_id,
566                &control.info.snapshot_backfill_upstream_tables,
567                upstream_table_log_epochs,
568                snapshot_epoch,
569                committed_epoch,
570                upstream_barrier_info,
571                &fragment_infos,
572                &backfill_order,
573                version_stat,
574            )?
575        } else {
576            Self::recover_log_store_phase(
577                job_id,
578                &control.info.snapshot_backfill_upstream_tables,
579                upstream_table_log_epochs,
580                committed_epoch,
581                upstream_barrier_info,
582            )?
583        };
584
585        let commit_checkpoint_interval = CommitCheckpointInterval::from_fragment_nodes(
586            fragment_infos.values().map(|fragment| &fragment.nodes),
587        )?;
588        partial_graph_recoverer.recover_graph(
589            partial_graph_id,
590            term_id,
591            initial_mutation,
592            &first_barrier_info,
593            &node_actors,
594            control.info.state_table_ids.iter().copied(),
595            actors_to_create,
596            IcebergV3BarrierStats::new(job_id, snapshot_epoch),
597        )?;
598        let opts = &partial_graph_recoverer.control_stream_manager().env.opts;
599        let max_pending_barrier_num = super::snapshot_backfill_max_pending_barrier_num(
600            opts.in_flight_barrier_nums,
601            opts.snapshot_backfill_barrier_amplification_factor,
602        );
603        Ok(Self {
604            control,
605            commit_checkpoint_interval,
606            status: IcebergV3JobStatus::Running(status),
607            fragment_infos,
608            node_actors,
609            max_pending_barrier_num,
610            upstream_lag: GLOBAL_META_METRICS
611                .snapshot_backfill_lag
612                .with_guarded_label_values(&[&format!("{}", job_id)]),
613            active_stream_actors,
614            active_downstreams,
615            resolver,
616            partition,
617        })
618    }
619
620    pub(crate) fn pinned_upstream_tables(&self) -> &HashSet<TableId> {
621        &self.control.info.snapshot_backfill_upstream_tables
622    }
623
624    pub(crate) fn gen_backfill_progress(&self) -> Option<BackfillProgress> {
625        match &self.status {
626            IcebergV3JobStatus::Running(IcebergV3Input {
627                phase: IcebergV3InputPhase::Snapshot(snapshot),
628                ..
629            })
630            | IcebergV3JobStatus::Compacting {
631                input:
632                    IcebergV3Input {
633                        phase: IcebergV3InputPhase::Snapshot(snapshot),
634                        ..
635                    },
636                ..
637            } => {
638                let progress = if snapshot.create_mview_tracker.is_finished() {
639                    "Snapshot finished".to_owned()
640                } else {
641                    format!(
642                        "Snapshot [{}]",
643                        snapshot.create_mview_tracker.gen_backfill_progress()
644                    )
645                };
646                Some(BackfillProgress {
647                    progress,
648                    backfill_type: PbBackfillType::SnapshotBackfill,
649                })
650            }
651            IcebergV3JobStatus::Running(IcebergV3Input {
652                phase: IcebergV3InputPhase::LogStore { .. },
653                ..
654            })
655            | IcebergV3JobStatus::Compacting {
656                input:
657                    IcebergV3Input {
658                        phase: IcebergV3InputPhase::LogStore { .. },
659                        ..
660                    },
661                ..
662            } => None,
663        }
664    }
665
666    pub(crate) fn gen_fragment_backfill_progress(&self) -> Vec<FragmentBackfillProgress> {
667        match &self.status {
668            IcebergV3JobStatus::Running(IcebergV3Input {
669                phase: IcebergV3InputPhase::Snapshot(snapshot),
670                ..
671            })
672            | IcebergV3JobStatus::Compacting {
673                input:
674                    IcebergV3Input {
675                        phase: IcebergV3InputPhase::Snapshot(snapshot),
676                        ..
677                    },
678                ..
679            } => snapshot
680                .create_mview_tracker
681                .collect_fragment_progress(&self.fragment_infos, true),
682            IcebergV3JobStatus::Running(IcebergV3Input {
683                phase: IcebergV3InputPhase::LogStore { .. },
684                ..
685            })
686            | IcebergV3JobStatus::Compacting {
687                input:
688                    IcebergV3Input {
689                        phase: IcebergV3InputPhase::LogStore { .. },
690                        ..
691                    },
692                ..
693            } => vec![],
694        }
695    }
696
697    pub(crate) fn fragment_infos(&self) -> &HashMap<FragmentId, InflightFragmentInfo> {
698        &self.fragment_infos
699    }
700
701    pub(crate) fn resolver_fragment_info(&self) -> &InflightFragmentInfo {
702        &self.resolver.fragment_info
703    }
704
705    pub(crate) fn update_commit_checkpoint_interval(
706        &mut self,
707        properties: &HashMap<String, String>,
708    ) -> MetaResult<()> {
709        self.commit_checkpoint_interval.update(
710            properties
711                .get(COMMIT_CHECKPOINT_INTERVAL)
712                .map(String::as_str),
713        )
714    }
715
716    pub(crate) fn on_new_upstream_barrier(
717        &mut self,
718        partial_graph_manager: &mut PartialGraphManager,
719        barrier_info: &BarrierInfo,
720        mutation: Option<(
721            risingwave_pb::stream_plan::barrier_mutation::Mutation,
722            Option<&mut NotifierStarter>,
723        )>,
724    ) -> MetaResult<()> {
725        match &mut self.status {
726            IcebergV3JobStatus::Running(input) => {
727                let (mut mutation, mut notifier) = mutation
728                    .map(|(mutation, notifier)| (Some(mutation), notifier))
729                    .unwrap_or_default();
730                let progress_epoch = self
731                    .control
732                    .max_committed_epoch
733                    .map_or(self.control.info.snapshot_epoch, |committed_epoch| {
734                        max(committed_epoch, self.control.info.snapshot_epoch)
735                    });
736                self.upstream_lag.set(
737                    barrier_info
738                        .prev_epoch
739                        .value()
740                        .0
741                        .saturating_sub(progress_epoch) as _,
742                );
743                let available = self.max_pending_barrier_num.saturating_sub(
744                    partial_graph_manager.pending_barrier_num(self.control.info.partial_graph_id),
745                );
746                input
747                    .pending_upstream_barriers
748                    .push_back(barrier_info.clone());
749                let barriers_to_inject = match &mut input.phase {
750                    IcebergV3InputPhase::Snapshot(snapshot) => {
751                        mutation = mutation.or_else(|| snapshot.take_start_backfill_mutation());
752                        if available == 0 && mutation.is_none() {
753                            vec![]
754                        } else {
755                            vec![snapshot.next_fake_barrier(&barrier_info.kind)]
756                        }
757                    }
758                    IcebergV3InputPhase::LogStore { .. } => {
759                        let barrier_num_to_inject = available.max(usize::from(mutation.is_some()));
760                        let barrier_num_to_inject =
761                            barrier_num_to_inject.min(input.pending_upstream_barriers.len());
762                        input
763                            .pending_upstream_barriers
764                            .drain(..barrier_num_to_inject)
765                            .collect()
766                    }
767                };
768
769                for barrier_to_inject in barriers_to_inject {
770                    let barrier_mutation = mutation.take();
771                    let barrier_notifier = notifier.take();
772                    // Snapshot completion must remain a checkpoint so creation can finish.
773                    let force_checkpoint = barrier_mutation.is_some()
774                        || barrier_to_inject.prev_epoch() == self.control.info.snapshot_epoch
775                        || barrier_to_inject.curr_epoch.value().0
776                            == self.control.info.snapshot_epoch;
777                    let barrier_to_inject = self
778                        .commit_checkpoint_interval
779                        .transform(barrier_to_inject, force_checkpoint);
780                    partial_graph_manager.inject_barrier(
781                        self.control.info.partial_graph_id,
782                        barrier_mutation,
783                        &self.node_actors,
784                        self.control.info.state_table_ids.iter().copied(),
785                        self.node_actors.keys().copied(),
786                        None,
787                        PartialGraphBarrierInfo::new(
788                            PostCollectCommand::barrier(),
789                            barrier_to_inject,
790                            barrier_notifier,
791                            self.control.info.state_table_ids.clone(),
792                        ),
793                    )?;
794                }
795            }
796            IcebergV3JobStatus::Compacting { input, .. } => {
797                assert!(
798                    mutation.is_none(),
799                    "active Iceberg compaction must not receive a second mutation"
800                );
801                input
802                    .pending_upstream_barriers
803                    .push_back(barrier_info.clone());
804            }
805        }
806        Ok(())
807    }
808
809    pub(crate) fn pre_apply_throttle(
810        &mut self,
811        throttle_config: &mut ThrottleConfigMap,
812    ) -> Option<risingwave_pb::stream_plan::barrier_mutation::Mutation> {
813        let mutation = extract_throttle_config(throttle_config, |fragment_id, stream_node| {
814            if let Some(fragment_info) = self.fragment_infos.get_mut(&fragment_id) {
815                fragment_info.nodes = stream_node.clone();
816                true
817            } else {
818                false
819            }
820        });
821        match &self.status {
822            IcebergV3JobStatus::Running(_) => mutation,
823            // The normal input is stopped while compacting. Retain the updated plan so actors
824            // restarted by the End barrier use it, but do not inject a throttle mutation now.
825            IcebergV3JobStatus::Compacting { .. } => None,
826        }
827    }
828
829    pub(crate) fn collect(&mut self, collected_barrier: CollectedBarrier<'_>) -> bool {
830        let (input, can_finish_snapshot) = match &mut self.status {
831            IcebergV3JobStatus::Running(input) => (input, true),
832            IcebergV3JobStatus::Compacting { input, .. } => (input, false),
833        };
834        match &mut input.phase {
835            IcebergV3InputPhase::Snapshot(snapshot) => {
836                let progress = collected_barrier
837                    .resps
838                    .values()
839                    .flat_map(|response| &response.create_mview_progress);
840                let snapshot_finished = snapshot.apply_progress(progress);
841                // B1 may contain tail progress produced before the normal-input actors stop. No
842                // backfill actor runs after B1, so a compacting job records that progress but
843                // defers the Snapshot -> LogStore transition until normal execution resumes.
844                if can_finish_snapshot && snapshot_finished {
845                    let IcebergV3InputPhase::Snapshot(mut snapshot) = replace(
846                        &mut input.phase,
847                        IcebergV3InputPhase::LogStore { tracking_job: None },
848                    ) else {
849                        unreachable!("snapshot finished outside the snapshot phase")
850                    };
851                    input
852                        .pending_upstream_barriers
853                        .push_front(snapshot.finish_snapshot_barrier());
854                    input.phase = IcebergV3InputPhase::LogStore {
855                        tracking_job: Some(snapshot.create_mview_tracker.into_tracking_job()),
856                    };
857                }
858            }
859            IcebergV3InputPhase::LogStore { .. } => {}
860        }
861        // Ordinary snapshot jobs force a checkpoint to merge into the database graph. Iceberg V3
862        // remains independent after catch-up, so its normal checkpoint cadence is sufficient.
863        false
864    }
865
866    pub(crate) fn start_apply_compaction(
867        &mut self,
868        partial_graph_manager: &mut PartialGraphManager,
869        upstream_barrier: &BarrierInfo,
870        task_id: IcebergCompactionTaskId,
871        overwrite: IcebergPkIndexCompactionOverwrite,
872        notifier: &mut NotifierStarter,
873    ) -> MetaResult<()> {
874        let output_data_file_paths = output_file_paths(&overwrite.output_result.data_files)?;
875        let transitions = build_compaction_transitions(
876            &self.fragment_infos,
877            &self.active_stream_actors,
878            &self.active_downstreams,
879            &self.resolver,
880            &self.partition,
881            self.control.info.partial_graph_id,
882            partial_graph_manager.control_stream_manager(),
883        )?;
884        let (barriers_before_compaction, begin_barrier, end_barrier) =
885            self.next_compaction_barriers(upstream_barrier)?;
886        for barrier in barriers_before_compaction {
887            partial_graph_manager.inject_barrier(
888                self.control.info.partial_graph_id,
889                None, // no mutation
890                &self.node_actors,
891                self.control.info.state_table_ids.iter().copied(),
892                self.node_actors.keys().copied(),
893                None, // no actors to create
894                PartialGraphBarrierInfo::new(
895                    PostCollectCommand::barrier(),
896                    barrier,
897                    None, // no command notifier
898                    self.control.info.state_table_ids.clone(),
899                ),
900            )?;
901        }
902        let sink_id = SinkId::new(self.control.info.job_id.as_raw_id());
903        let resolver_task_input = ResolverTaskInput {
904            output_data_file_paths,
905            input_data_file_paths: overwrite.input_file_paths.clone(),
906            read_snapshot_id: overwrite.read_snapshot_id,
907        };
908        let compaction_overwrite = CompactionOverwrite {
909            sink_id,
910            epoch: end_barrier.prev_epoch(),
911            schema_id: overwrite.output_result.schema_id,
912            partition_spec_id: overwrite.output_result.partition_spec_id,
913            output_files: overwrite.output_result.data_files,
914            input_file_paths: overwrite.input_file_paths,
915            read_snapshot_id: overwrite.read_snapshot_id,
916        };
917        let mut mutation = transitions.begin_compaction.mutation;
918        mutation.iceberg_pk_index_compaction = Some(IcebergPkIndexCompactionContext {
919            sink_id,
920            task_id,
921            phase: Phase::Begin as i32,
922            resolver_task_input: Some(resolver_task_input),
923        });
924        partial_graph_manager.inject_barrier(
925            self.control.info.partial_graph_id,
926            Some(Mutation::Update(mutation)),
927            &transitions.node_actors,
928            self.control.info.state_table_ids.iter().copied(),
929            transitions.node_actors.keys().copied(),
930            Some(transitions.begin_compaction.actors_to_create),
931            PartialGraphBarrierInfo::new(
932                PostCollectCommand::barrier(),
933                begin_barrier.clone(),
934                None,
935                self.control.info.state_table_ids.clone(),
936            ),
937        )?;
938
939        // This empty Running value is only an ownership placeholder while moving `input`
940        // into Compacting. It is replaced before the method returns and is never observed.
941        let input = match std::mem::replace(
942            &mut self.status,
943            IcebergV3JobStatus::Running(IcebergV3Input {
944                phase: IcebergV3InputPhase::LogStore { tracking_job: None },
945                pending_upstream_barriers: VecDeque::new(),
946            }),
947        ) {
948            IcebergV3JobStatus::Running(input) => input,
949            IcebergV3JobStatus::Compacting { .. } => {
950                unreachable!("compacting status was rejected before barrier injection")
951            }
952        };
953        self.status = IcebergV3JobStatus::Compacting {
954            input,
955            apply: CompactionApply {
956                notifier: notifier.add_notify(),
957                phase: CompactionApplyPhase::BeginAck {
958                    task_id,
959                    begin_epoch: begin_barrier.prev_epoch(),
960                    end_barrier,
961                    node_actors: transitions.node_actors,
962                    end_transition: transitions.end_compaction,
963                    overwrite: compaction_overwrite,
964                },
965            },
966        };
967        Ok(())
968    }
969
970    fn next_compaction_barriers(
971        &mut self,
972        upstream_barrier: &BarrierInfo,
973    ) -> MetaResult<(Vec<BarrierInfo>, BarrierInfo, BarrierInfo)> {
974        let (before, begin, end) = self
975            .status
976            .next_compaction_barriers(self.control.info.job_id, upstream_barrier)?;
977        let before = before
978            .into_iter()
979            .map(|barrier| self.commit_checkpoint_interval.transform(barrier, false))
980            .collect();
981        // Begin flushes foreground writes and End durably applies the overwrite, regardless of
982        // the normal commit interval.
983        let begin = self.commit_checkpoint_interval.transform(begin, true);
984        let end = self.commit_checkpoint_interval.transform(end, true);
985        Ok((before, begin, end))
986    }
987
988    #[expect(clippy::type_complexity)]
989    pub(crate) fn start_completing(
990        &mut self,
991        partial_graph_manager: &mut PartialGraphManager,
992        min_upstream_inflight_barrier: Option<u64>,
993    ) -> Option<(
994        u64,
995        HashMap<WorkerId, BarrierCompleteResponse>,
996        PartialGraphBarrierInfo,
997        Option<TrackingJob>,
998        Option<CompactionOverwrite>,
999    )> {
1000        let epoch_end_bound = min_upstream_inflight_barrier
1001            .map(Excluded)
1002            .unwrap_or(Unbounded);
1003        let (epoch, responses, info) = partial_graph_manager.start_completing(
1004            self.control.info.partial_graph_id,
1005            epoch_end_bound,
1006            |_non_checkpoint_epoch, _, _| {},
1007        )?;
1008
1009        let tracking_job = match &mut self.status {
1010            IcebergV3JobStatus::Running(IcebergV3Input {
1011                phase: IcebergV3InputPhase::LogStore { tracking_job },
1012                ..
1013            })
1014            | IcebergV3JobStatus::Compacting {
1015                input:
1016                    IcebergV3Input {
1017                        phase: IcebergV3InputPhase::LogStore { tracking_job },
1018                        ..
1019                    },
1020                ..
1021            } if epoch == self.control.info.snapshot_epoch => tracking_job.take(),
1022            _ => None,
1023        };
1024
1025        let overwrite = match &mut self.status {
1026            IcebergV3JobStatus::Running(_) => None,
1027            IcebergV3JobStatus::Compacting {
1028                apply: CompactionApply { phase, .. },
1029                ..
1030            } => match phase {
1031                CompactionApplyPhase::BeginAck { begin_epoch, .. } => {
1032                    if epoch <= *begin_epoch {
1033                        None
1034                    } else {
1035                        unreachable!(
1036                            "collected epoch {epoch} passed unacknowledged compaction Begin epoch {begin_epoch}"
1037                        )
1038                    }
1039                }
1040                CompactionApplyPhase::EndCompletion { end_epoch, .. } => {
1041                    let end_epoch = *end_epoch;
1042                    match epoch.cmp(&end_epoch) {
1043                        std::cmp::Ordering::Less => None,
1044                        std::cmp::Ordering::Equal => {
1045                            let CompactionApplyPhase::EndCompletion { overwrite, .. } =
1046                                std::mem::replace(
1047                                    phase,
1048                                    CompactionApplyPhase::EndAck { end_epoch },
1049                                )
1050                            else {
1051                                unreachable!()
1052                            };
1053                            Some(overwrite)
1054                        }
1055                        std::cmp::Ordering::Greater => unreachable!(
1056                            "collected epoch {epoch} passed compaction End epoch {end_epoch}"
1057                        ),
1058                    }
1059                }
1060                CompactionApplyPhase::EndAck { end_epoch } => unreachable!(
1061                    "started another completion while compaction End epoch {end_epoch} is committing"
1062                ),
1063            },
1064        };
1065        Some((epoch, responses, info, tracking_job, overwrite))
1066    }
1067
1068    pub(crate) fn ack_completed(
1069        &mut self,
1070        partial_graph_manager: &mut PartialGraphManager,
1071        completed_epoch: u64,
1072    ) -> MetaResult<()> {
1073        match &mut self.status {
1074            IcebergV3JobStatus::Running(input) => {
1075                partial_graph_manager
1076                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
1077                self.control.ack_completed(completed_epoch);
1078                if completed_epoch == self.control.info.snapshot_epoch {
1079                    let IcebergV3InputPhase::LogStore { tracking_job } = &input.phase else {
1080                        unreachable!("snapshot epoch completed outside the log-store phase")
1081                    };
1082                    assert!(
1083                        tracking_job.is_none(),
1084                        "tracking job should have been taken at start_completing"
1085                    );
1086                }
1087            }
1088            IcebergV3JobStatus::Compacting {
1089                apply:
1090                    CompactionApply {
1091                        phase: CompactionApplyPhase::BeginAck { begin_epoch, .. },
1092                        ..
1093                    },
1094                ..
1095            } if completed_epoch < *begin_epoch => {
1096                partial_graph_manager
1097                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
1098                self.control.ack_completed(completed_epoch);
1099            }
1100            IcebergV3JobStatus::Compacting {
1101                apply:
1102                    CompactionApply {
1103                        phase:
1104                            CompactionApplyPhase::BeginAck {
1105                                task_id,
1106                                begin_epoch,
1107                                end_barrier,
1108                                node_actors,
1109                                end_transition,
1110                                ..
1111                            },
1112                        ..
1113                    },
1114                ..
1115            } if completed_epoch == *begin_epoch => {
1116                partial_graph_manager
1117                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
1118                self.control.ack_completed(completed_epoch);
1119
1120                fail::fail_point!("iceberg_pk_index_compaction_before_end_reached");
1121                fail::fail_point!("iceberg_pk_index_compaction_before_end", |_| Err(
1122                    anyhow::anyhow!("injected failure before compaction End barrier").into()
1123                ));
1124
1125                let end_epoch = end_barrier.prev_epoch();
1126                let sink_id = SinkId::new(self.control.info.job_id.as_raw_id());
1127                let mut mutation = end_transition.mutation.clone();
1128                mutation.iceberg_pk_index_compaction = Some(IcebergPkIndexCompactionContext {
1129                    sink_id,
1130                    task_id: *task_id,
1131                    phase: Phase::End as i32,
1132                    resolver_task_input: None,
1133                });
1134                partial_graph_manager.inject_barrier(
1135                    self.control.info.partial_graph_id,
1136                    Some(Mutation::Update(mutation)),
1137                    node_actors,
1138                    self.control.info.state_table_ids.iter().copied(),
1139                    node_actors.keys().copied(),
1140                    Some(end_transition.actors_to_create.clone()),
1141                    PartialGraphBarrierInfo::new(
1142                        PostCollectCommand::barrier(),
1143                        end_barrier.clone(),
1144                        None,
1145                        self.control.info.state_table_ids.clone(),
1146                    ),
1147                )?;
1148
1149                // B2 injection succeeded. Temporarily install an empty Running state to move the
1150                // overwrite into the next explicit compaction phase.
1151                let IcebergV3JobStatus::Compacting { input, apply } = std::mem::replace(
1152                    &mut self.status,
1153                    IcebergV3JobStatus::Running(IcebergV3Input {
1154                        phase: IcebergV3InputPhase::LogStore { tracking_job: None },
1155                        pending_upstream_barriers: VecDeque::new(),
1156                    }),
1157                ) else {
1158                    unreachable!()
1159                };
1160                let CompactionApplyPhase::BeginAck { overwrite, .. } = apply.phase else {
1161                    unreachable!()
1162                };
1163                self.status = IcebergV3JobStatus::Compacting {
1164                    input,
1165                    apply: CompactionApply {
1166                        notifier: apply.notifier,
1167                        phase: CompactionApplyPhase::EndCompletion {
1168                            end_epoch,
1169                            overwrite,
1170                        },
1171                    },
1172                };
1173            }
1174            IcebergV3JobStatus::Compacting {
1175                apply:
1176                    CompactionApply {
1177                        phase: CompactionApplyPhase::BeginAck { begin_epoch, .. },
1178                        ..
1179                    },
1180                ..
1181            } => unreachable!(
1182                "acknowledged epoch {completed_epoch} after compaction Begin epoch {begin_epoch}"
1183            ),
1184            IcebergV3JobStatus::Compacting {
1185                apply:
1186                    CompactionApply {
1187                        phase: CompactionApplyPhase::EndCompletion { end_epoch, .. },
1188                        ..
1189                    },
1190                ..
1191            } => unreachable!(
1192                "acknowledged epoch {completed_epoch} before compaction End epoch {end_epoch} entered the completion task"
1193            ),
1194            IcebergV3JobStatus::Compacting {
1195                apply:
1196                    CompactionApply {
1197                        phase: CompactionApplyPhase::EndAck { end_epoch },
1198                        ..
1199                    },
1200                ..
1201            } if completed_epoch < *end_epoch => {
1202                partial_graph_manager
1203                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
1204                self.control.ack_completed(completed_epoch);
1205            }
1206            IcebergV3JobStatus::Compacting {
1207                apply:
1208                    CompactionApply {
1209                        phase: CompactionApplyPhase::EndAck { end_epoch },
1210                        ..
1211                    },
1212                ..
1213            } if completed_epoch == *end_epoch => {
1214                partial_graph_manager
1215                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
1216                self.control.ack_completed(completed_epoch);
1217                let IcebergV3JobStatus::Compacting { input, apply } = std::mem::replace(
1218                    &mut self.status,
1219                    IcebergV3JobStatus::Running(IcebergV3Input {
1220                        phase: IcebergV3InputPhase::LogStore { tracking_job: None },
1221                        pending_upstream_barriers: VecDeque::new(),
1222                    }),
1223                ) else {
1224                    unreachable!()
1225                };
1226                self.status = IcebergV3JobStatus::Running(input);
1227                apply.notifier.notify_collected();
1228            }
1229            IcebergV3JobStatus::Compacting {
1230                apply:
1231                    CompactionApply {
1232                        phase: CompactionApplyPhase::EndAck { end_epoch },
1233                        ..
1234                    },
1235                ..
1236            } => unreachable!(
1237                "acknowledged epoch {completed_epoch} after compaction End epoch {end_epoch}"
1238            ),
1239        }
1240        Ok(())
1241    }
1242}
1243
1244fn output_file_paths(files: &[iceberg::spec::SerializedDataFile]) -> MetaResult<Vec<String>> {
1245    files
1246        .iter()
1247        .map(|file| {
1248            let value = serde_json::to_value(file).map_err(anyhow::Error::from)?;
1249            value
1250                .get("file_path")
1251                .and_then(|path| path.as_str())
1252                .map(str::to_owned)
1253                .ok_or_else(|| {
1254                    anyhow::anyhow!("compaction output file is missing file_path").into()
1255                })
1256        })
1257        .collect()
1258}
1259
1260pub(crate) fn is_iceberg_v3_fragment_nodes<'a>(
1261    nodes: impl IntoIterator<Item = &'a risingwave_pb::stream_plan::PbStreamNode>,
1262) -> bool {
1263    nodes.into_iter().any(|root| {
1264        let mut found = false;
1265        visit_stream_node_cont(root, |node| {
1266            if matches!(
1267                node.node_body,
1268                Some(
1269                    risingwave_pb::stream_plan::stream_node::NodeBody::IcebergWithPkIndexWriter(_)
1270                )
1271            ) {
1272                found = true;
1273            }
1274            !found
1275        });
1276        found
1277    })
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use risingwave_common::util::epoch::EPOCH_SPILL_TIME_MASK;
1283
1284    use super::*;
1285
1286    fn barrier_info(prev_physical: u64, curr_physical: u64, kind: BarrierKind) -> BarrierInfo {
1287        BarrierInfo {
1288            prev_epoch: TracedEpoch::new(Epoch::from_physical_time(prev_physical)),
1289            curr_epoch: TracedEpoch::new(Epoch::from_physical_time(curr_physical)),
1290            kind,
1291        }
1292    }
1293
1294    fn assert_epoch(barrier: &BarrierInfo, prev_physical: u64, curr_physical: u64) {
1295        assert_eq!(
1296            barrier.epoch(),
1297            EpochPair::new(
1298                Epoch::from_physical_time(curr_physical).0,
1299                Epoch::from_physical_time(prev_physical).0,
1300            )
1301        );
1302    }
1303
1304    #[test]
1305    fn test_synthetic_compaction_epoch() {
1306        let prev_epoch = Epoch::from_physical_time(10);
1307        let curr_epoch = Epoch::from_physical_time(12);
1308
1309        let synthetic = synthetic_compaction_epoch(prev_epoch, curr_epoch);
1310
1311        assert_eq!(synthetic, Epoch::from_physical_time(11));
1312        assert_eq!(synthetic.0 & EPOCH_SPILL_TIME_MASK, 0);
1313        assert!(prev_epoch < synthetic);
1314        assert!(synthetic < curr_epoch);
1315    }
1316
1317    #[test]
1318    #[should_panic(expected = "synthetic compaction epoch must be between")]
1319    fn test_synthetic_compaction_epoch_rejects_tight_gap() {
1320        let prev_epoch = Epoch::from_physical_time(10);
1321        let curr_epoch = Epoch::from_physical_time(11);
1322
1323        synthetic_compaction_epoch(prev_epoch, curr_epoch);
1324    }
1325
1326    #[test]
1327    fn test_recover_log_store_phase_from_synthetic_epoch() {
1328        let table_id = TableId::new(1);
1329        let upstream_tables = HashSet::from([table_id]);
1330        let upstream_log_epochs = HashMap::from([(
1331            table_id,
1332            vec![
1333                (vec![], Epoch::from_physical_time(10).0),
1334                (vec![], Epoch::from_physical_time(12).0),
1335            ],
1336        )]);
1337        let upstream_barrier = barrier_info(12, 14, BarrierKind::Checkpoint(vec![]));
1338
1339        let (input, initial_barrier) = IcebergV3JobCheckpointControl::recover_log_store_phase(
1340            JobId::new(1),
1341            &upstream_tables,
1342            &upstream_log_epochs,
1343            Epoch::from_physical_time(11).0,
1344            &upstream_barrier,
1345        )
1346        .unwrap();
1347
1348        assert_epoch(&initial_barrier, 11, 12);
1349        assert_eq!(initial_barrier.kind, BarrierKind::Initial);
1350        let IcebergV3Input {
1351            phase: IcebergV3InputPhase::LogStore { tracking_job },
1352            pending_upstream_barriers,
1353        } = input
1354        else {
1355            unreachable!()
1356        };
1357        assert!(tracking_job.is_none());
1358        assert_eq!(pending_upstream_barriers.len(), 1);
1359        assert_epoch(&pending_upstream_barriers[0], 12, 14);
1360    }
1361
1362    #[test]
1363    fn test_log_store_compaction_uses_first_pending_checkpoint() {
1364        let mut status = IcebergV3JobStatus::Running(IcebergV3Input {
1365            phase: IcebergV3InputPhase::LogStore { tracking_job: None },
1366            pending_upstream_barriers: VecDeque::from([
1367                barrier_info(10, 12, BarrierKind::Barrier),
1368                barrier_info(12, 14, BarrierKind::Checkpoint(vec![10])),
1369                barrier_info(14, 16, BarrierKind::Barrier),
1370            ]),
1371        });
1372        let upstream = barrier_info(16, 18, BarrierKind::Checkpoint(vec![14]));
1373
1374        let (before, begin, end) = status
1375            .next_compaction_barriers(JobId::new(1), &upstream)
1376            .unwrap();
1377
1378        assert_eq!(before.len(), 1);
1379        assert_epoch(&before[0], 10, 12);
1380        assert_epoch(&begin, 12, 13);
1381        assert_eq!(begin.kind, BarrierKind::Checkpoint(vec![10]));
1382        assert_epoch(&end, 13, 14);
1383        assert_eq!(
1384            end.kind,
1385            BarrierKind::Checkpoint(vec![Epoch::from_physical_time(13).0])
1386        );
1387        let IcebergV3JobStatus::Running(IcebergV3Input {
1388            pending_upstream_barriers: pending,
1389            ..
1390        }) = status
1391        else {
1392            unreachable!()
1393        };
1394        assert_eq!(pending.len(), 2);
1395        assert_epoch(&pending[0], 14, 16);
1396        assert_epoch(&pending[1], 16, 18);
1397    }
1398}