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