Skip to main content

risingwave_meta/barrier/checkpoint/independent_job/batch_refresh_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
15//! Batch refresh job checkpoint control for periodically-refreshed materialized views.
16//!
17//! It lives permanently in `DatabaseCheckpointControl.independent_checkpoint_job_controls`
18//! as a running batch-refresh independent job for its entire lifetime.
19//!
20//! Lifecycle:
21//!   DDL → `ConsumingSnapshot` → `FinishingSnapshot` → `Idle`
22//!                                                        ↕  (periodic trigger)
23//!                                        `Idle` ← `ConsumingLogStore`
24
25use std::collections::{HashMap, HashSet, hash_map};
26use std::mem::{replace, take};
27use std::sync::atomic::AtomicU32;
28
29use anyhow::anyhow;
30use itertools::Itertools;
31use risingwave_common::catalog::{DatabaseId, TableId};
32use risingwave_common::id::JobId;
33use risingwave_common::metrics::{LabelGuardedHistogram, LabelGuardedIntGauge};
34use risingwave_common::util::epoch::{Epoch, EpochPair};
35use risingwave_meta_model::{DispatcherType, WorkerId, streaming_job};
36use risingwave_pb::common::WorkerNode;
37use risingwave_pb::ddl_service::PbBackfillType;
38use risingwave_pb::hummock::HummockVersionStats;
39use risingwave_pb::id::{ActorId, FragmentId, PartialGraphId};
40use risingwave_pb::stream_plan::barrier_mutation::Mutation;
41use risingwave_pb::stream_plan::{AddMutation, StopMutation};
42use risingwave_pb::stream_service::BarrierCompleteResponse;
43use tracing::{debug, info};
44
45use crate::MetaResult;
46use crate::barrier::checkpoint::independent_job::{
47    IndependentCheckpointJob, IndependentCheckpointJobControl, IndependentCheckpointJobStatus,
48    IndependentJobControl, IndependentJobInfo, InitialPartialGraphRequest, SnapshotPhaseControl,
49    add_initial_partial_graph, build_initial_add_mutation,
50};
51use crate::barrier::command::{PostCollectCommand, ThrottleConfigMap, extract_throttle_config};
52use crate::barrier::context::CreateIndependentStreamingJobCommandInfo;
53use crate::barrier::edge_builder::FragmentEdgeBuilder;
54use crate::barrier::info::BarrierInfo;
55use crate::barrier::partial_graph::{
56    CollectedBarrier, PartialGraphBarrierInfo, PartialGraphManager, PartialGraphStat,
57};
58use crate::barrier::progress::{TrackingJob, collect_done_fragments};
59use crate::barrier::rpc::{ControlStreamManager, to_partial_graph_id};
60use crate::barrier::{BackfillProgress, BarrierKind, FragmentBackfillProgress, TracedEpoch};
61use crate::controller::fragment::InflightFragmentInfo;
62use crate::controller::scale::{
63    ComponentFragmentAligner, EnsembleActorTemplate, LoadedFragment, NoShuffleEnsemble,
64    build_no_shuffle_fragment_graph_edges, find_no_shuffle_graphs,
65};
66use crate::model::{
67    FragmentDownstreamRelation, StreamActor, StreamJobActorsToCreate, StreamingJobModelContextExt,
68};
69use crate::notification::NotifierStarter;
70use crate::rpc::metrics::GLOBAL_META_METRICS;
71use crate::stream::ExtendedFragmentBackfillOrder;
72
73// ── Public types ──────────────────────────────────────────────────────────────
74
75/// Logical fragment metadata for a batch refresh job.
76///
77/// Contains only catalog-level information: fragment structure, stream plan nodes,
78/// distribution, and downstream relations. No actor IDs, no worker placement.
79///
80/// Used as the uniform input for `render_actors_and_build_job_info()`, which performs
81/// actor rendering (ID allocation, worker placement, vnode assignment) internally.
82/// No-shuffle ensembles are derived from `downstreams` internally.
83#[derive(Debug)]
84pub(crate) struct BatchRefreshLogicalFragments {
85    /// Logical fragments of this job. Keyed by `fragment_id`.
86    pub fragments: HashMap<FragmentId, LoadedFragment>,
87    /// Internal downstream relations (intra-job only; no upstream edges).
88    pub downstreams: FragmentDownstreamRelation,
89}
90
91/// Result of the unified actor rendering for a batch refresh job.
92///
93/// Produced by `render_actors_and_build_job_info()` and consumed by both
94/// `new()` (create) and `recover()`.
95#[derive(Debug)]
96pub(crate) struct BatchRefreshRenderResult {
97    pub fragment_infos: HashMap<FragmentId, InflightFragmentInfo>,
98    pub node_actors: HashMap<WorkerId, HashSet<ActorId>>,
99    pub actors_to_create: StreamJobActorsToCreate,
100}
101
102/// Static actor metadata rendered for a closed independent-job graph.
103///
104/// This does not build edges or decide which fragments are active. In particular,
105/// Iceberg V3 can render all of its actor partitions once and activate only the
106/// input or compaction-resolver partition for a given phase.
107#[derive(Debug)]
108pub(crate) struct RenderedIndependentJobActors {
109    pub fragment_infos: HashMap<FragmentId, InflightFragmentInfo>,
110    pub stream_actors: HashMap<FragmentId, Vec<StreamActor>>,
111}
112
113// ── Batch refresh job metadata ───────────────────────────────────────────────
114
115/// Lightweight metadata for re-rendering actors on each periodic refresh run.
116///
117/// Loaded asynchronously on every trigger via `load_batch_refresh_trigger_context()`.
118/// Contains the pieces consumed by `from_context()` and `render_actors_and_build_job_info()`,
119/// as well as the resolved upstream log epochs and target epoch for this trigger.
120#[derive(Debug)]
121pub(crate) struct BatchRefreshJobTriggerContext {
122    pub fragments: HashMap<FragmentId, LoadedFragment>,
123    pub downstreams: FragmentDownstreamRelation,
124    pub streaming_job_model: streaming_job::Model,
125    pub definition: String,
126    pub database_resource_group: String,
127    /// Changelog entries per upstream table, used to derive log barriers.
128    pub upstream_table_log_epochs: HashMap<TableId, Vec<(Vec<u64>, u64)>>,
129    /// The upstream committed epoch to catch up to.
130    pub target_upstream_epoch: u64,
131}
132
133// ── Status ────────────────────────────────────────────────────────────────────
134
135#[derive(Debug)]
136enum BatchRefreshJobStatus {
137    /// The job is consuming upstream snapshot.
138    ///
139    /// Once snapshot consumption finishes, the final checkpoint + stop barriers are injected
140    /// and the status transitions to `FinishingSnapshot`.
141    ConsumingSnapshot {
142        snapshot: SnapshotPhaseControl,
143        fragment_infos: HashMap<FragmentId, InflightFragmentInfo>,
144        node_actors: HashMap<WorkerId, HashSet<ActorId>>,
145    },
146    /// The job has finished consuming the snapshot.
147    ///
148    /// The final checkpoint barrier (at `snapshot_epoch`) and the stop barrier have been
149    /// injected. Once the stop epoch is committed the job transitions to `Idle`.
150    /// The committed epoch is expected to be the snapshot epoch when the snapshot
151    /// consumption finishes.
152    FinishingSnapshot {
153        tracking_job: Option<TrackingJob>,
154        fragment_infos: HashMap<FragmentId, InflightFragmentInfo>,
155    },
156    /// The job is idle, waiting for the next trigger. No partial graph is held.
157    Idle,
158    /// The job has created a partial graph for periodic refresh and is waiting for
159    /// the initial barrier to bootstrap the newly-created actors.
160    InitializingBatchRefresh {
161        fragment_infos: HashMap<FragmentId, InflightFragmentInfo>,
162        node_actors: HashMap<WorkerId, HashSet<ActorId>>,
163        /// Log barriers to inject after the partial graph is initialized. The
164        /// last one is the checkpoint stop barrier with `curr_epoch = u64::MAX`.
165        pending_log_barriers: Vec<BarrierInfo>,
166        target_upstream_epoch: u64,
167    },
168    /// The job is consuming upstream log store changes (periodic refresh).
169    ///
170    /// All replay barriers have been pre-injected (last with `StopMutation` at
171    /// `curr_epoch = u64::MAX`). When `target_upstream_epoch` commits,
172    /// the partial graph is removed and the job transitions to `Idle`.
173    ConsumingLogStore {
174        fragment_infos: HashMap<FragmentId, InflightFragmentInfo>,
175        /// `prev_epoch` of the stop barrier; becomes `last_committed_epoch` when transitioning to Idle.
176        target_upstream_epoch: u64,
177    },
178}
179
180// ── Complete type ─────────────────────────────────────────────────────────────
181
182// ── Main checkpoint control ───────────────────────────────────────────────────
183
184/// Self-contained checkpoint control for a batch refresh MV.
185///
186/// Unlike `CreatingStreamingJobControl`, this struct handles the full lifecycle
187/// (snapshot → idle → re-run → idle → ...). Both types are stored together in
188/// `DatabaseCheckpointControl.independent_checkpoint_job_controls` as
189/// `IndependentCheckpointJobControl` variants.
190#[derive(Debug)]
191pub(crate) struct BatchRefreshJobCheckpointControl {
192    control: IndependentJobControl,
193    /// Batch refresh interval in seconds. Used to determine when to trigger a refresh run.
194    batch_refresh_seconds: u64,
195
196    status: BatchRefreshJobStatus,
197}
198
199// ── Unified actor rendering ───────────────────────────────────────────────────
200
201/// Render actor IDs, placement, vnode bitmaps, and actor definitions for a closed
202/// independent-job graph.
203///
204/// Every fragment in a no-shuffle ensemble must belong to `fragments`. Independent
205/// jobs consume upstream data through the log store, so their actor placement does
206/// not need to align with an existing upstream fragment.
207impl RenderedIndependentJobActors {
208    pub(crate) fn render(
209        fragments: &HashMap<FragmentId, LoadedFragment>,
210        downstreams: &FragmentDownstreamRelation,
211        definition: &str,
212        actor_id_generator: &AtomicU32,
213        worker_nodes: &HashMap<WorkerId, WorkerNode>,
214        database_resource_group: &str,
215        streaming_job_model: &streaming_job::Model,
216    ) -> MetaResult<Self> {
217        let ensembles =
218            BatchRefreshJobCheckpointControl::resolve_ensembles(fragments, downstreams)?;
219
220        let mut actor_assignments: HashMap<
221            FragmentId,
222            HashMap<ActorId, (WorkerId, Option<risingwave_common::bitmap::Bitmap>)>,
223        > = HashMap::new();
224
225        for ensemble in &ensembles {
226            let first_component = ensemble
227                .component_fragments()
228                .next()
229                .expect("ensemble must have at least one component");
230            let fragment = &fragments[&first_component];
231            let distribution_type = fragment.distribution_type;
232            let vnode_count = fragment.vnode_count;
233
234            for fid in ensemble.component_fragments() {
235                let f = &fragments[&fid];
236                assert_eq!(
237                    vnode_count, f.vnode_count,
238                    "fragments {} and {} in same ensemble have different vnode counts",
239                    first_component, fid,
240                );
241            }
242
243            let entry_fragment_parallelism = Itertools::exactly_one(
244                ensemble
245                    .entry_fragments()
246                    .map(|fid| fragments[&fid].parallelism.clone())
247                    .dedup(),
248            )
249            .map_err(|_| {
250                anyhow!("entry fragments have inconsistent parallelism settings in independent job")
251            })?;
252
253            let actor_template = EnsembleActorTemplate::render_new(
254                streaming_job_model,
255                worker_nodes,
256                entry_fragment_parallelism,
257                database_resource_group.to_owned(),
258                distribution_type,
259                vnode_count,
260            )?;
261
262            for fid in ensemble.component_fragments() {
263                let f = &fragments[&fid];
264                let aligner =
265                    ComponentFragmentAligner::new_persistent(&actor_template, actor_id_generator);
266                let assignments = aligner.align_component_actor(f.distribution_type);
267                actor_assignments.insert(fid, assignments);
268            }
269        }
270
271        let stream_context = streaming_job_model.stream_context();
272        let mut stream_actors: HashMap<FragmentId, Vec<StreamActor>> = HashMap::new();
273        let mut actor_location: HashMap<ActorId, WorkerId> = HashMap::new();
274
275        for (fragment_id, assignments) in &actor_assignments {
276            let mut actors = Vec::with_capacity(assignments.len());
277            for (&actor_id, (worker_id, vnode_bitmap)) in assignments {
278                actor_location.insert(actor_id, *worker_id);
279                actors.push(StreamActor {
280                    actor_id,
281                    fragment_id: *fragment_id,
282                    vnode_bitmap: vnode_bitmap.clone(),
283                    mview_definition: definition.to_owned(),
284                    expr_context: Some(stream_context.to_expr_context()),
285                    config_override: stream_context.config_override.clone(),
286                });
287            }
288            stream_actors.insert(*fragment_id, actors);
289        }
290
291        let fragment_infos = fragments
292            .iter()
293            .map(|(fragment_id, loaded)| {
294                let actors = stream_actors
295                    .get(fragment_id)
296                    .into_iter()
297                    .flatten()
298                    .map(|actor| {
299                        (
300                            actor.actor_id,
301                            crate::controller::fragment::InflightActorInfo {
302                                worker_id: actor_location[&actor.actor_id],
303                                vnode_bitmap: actor.vnode_bitmap.clone(),
304                                splits: vec![],
305                            },
306                        )
307                    })
308                    .collect();
309                (
310                    *fragment_id,
311                    InflightFragmentInfo {
312                        fragment_id: *fragment_id,
313                        distribution_type: loaded.distribution_type,
314                        fragment_type_mask: loaded.fragment_type_mask,
315                        vnode_count: loaded.vnode_count,
316                        nodes: loaded.nodes.clone(),
317                        actors,
318                        state_table_ids: loaded.state_table_ids.clone(),
319                    },
320                )
321            })
322            .collect();
323
324        Ok(Self {
325            fragment_infos,
326            stream_actors,
327        })
328    }
329
330    /// Build the runtime information for the active part of an already-rendered independent job.
331    ///
332    /// Keeping this separate from [`Self::render`] lets jobs with dormant fragments partition the
333    /// rendered graph before any runtime edges are connected.
334    pub(crate) fn build_job_info(
335        self,
336        downstreams: &FragmentDownstreamRelation,
337        partial_graph_id: PartialGraphId,
338        control_stream_manager: &ControlStreamManager,
339    ) -> MetaResult<BatchRefreshRenderResult> {
340        let Self {
341            fragment_infos,
342            stream_actors,
343        } = self;
344
345        let (mut edges, _) = FragmentEdgeBuilder::new()
346            .add_new_fragments(
347                fragment_infos.values(),
348                partial_graph_id,
349                control_stream_manager,
350            )
351            .finish_fragments()
352            .add_relations(downstreams)?
353            .build();
354
355        let actors_to_create =
356            edges.collect_actors_to_create(fragment_infos.values().map(|fragment| {
357                (
358                    fragment.fragment_id,
359                    &fragment.nodes,
360                    fragment.actors.iter().map(|(actor_id, actor)| {
361                        let stream_actor = stream_actors[&fragment.fragment_id]
362                            .iter()
363                            .find(|stream_actor| stream_actor.actor_id == *actor_id)
364                            .expect("rendered actor should exist");
365                        (stream_actor, actor.worker_id)
366                    }),
367                    vec![], // Independent jobs have no actor-level subscribers.
368                )
369            }));
370
371        let node_actors = InflightFragmentInfo::actor_ids_to_collect(fragment_infos.values());
372        Ok(BatchRefreshRenderResult {
373            fragment_infos,
374            node_actors,
375            actors_to_create,
376        })
377    }
378}
379
380impl BatchRefreshJobCheckpointControl {
381    /// Render actors for a batch refresh job from logical metadata only.
382    ///
383    /// Performs the full pipeline:
384    /// 1. Derive no-shuffle ensembles from `downstreams`
385    /// 2. Render actor assignments (ID allocation, worker placement, vnode bitmap)
386    /// 3. Build `StreamActor` structs
387    /// 4. Build internal-only edges (no upstream dispatcher edges)
388    /// 5. Produce `fragment_infos`, `node_actors`, and `actors_to_create`
389    ///
390    /// Shared by both the DDL create path and the recovery path.
391    pub(crate) fn render_actors_and_build_job_info(
392        fragments: &HashMap<FragmentId, LoadedFragment>,
393        downstreams: &FragmentDownstreamRelation,
394        definition: &str,
395        // Actor rendering context:
396        actor_id_generator: &AtomicU32,
397        worker_nodes: &HashMap<WorkerId, WorkerNode>,
398        control_stream_manager: &ControlStreamManager,
399        database_resource_group: &str,
400        streaming_job_model: &streaming_job::Model,
401        // Edge building context:
402        partial_graph_id: PartialGraphId,
403    ) -> MetaResult<BatchRefreshRenderResult> {
404        let rendered = RenderedIndependentJobActors::render(
405            fragments,
406            downstreams,
407            definition,
408            actor_id_generator,
409            worker_nodes,
410            database_resource_group,
411            streaming_job_model,
412        )?;
413        rendered.build_job_info(downstreams, partial_graph_id, control_stream_manager)
414    }
415
416    /// Derive no-shuffle ensembles from fragment downstreams.
417    fn resolve_ensembles(
418        fragments: &HashMap<FragmentId, LoadedFragment>,
419        downstreams: &FragmentDownstreamRelation,
420    ) -> MetaResult<Vec<NoShuffleEnsemble>> {
421        let mut new_no_shuffle: HashMap<_, HashSet<_>> = HashMap::new();
422        for (upstream_fid, relations) in downstreams {
423            for rel in relations {
424                if rel.dispatcher_type == DispatcherType::NoShuffle {
425                    new_no_shuffle
426                        .entry(*upstream_fid)
427                        .or_default()
428                        .insert(rel.downstream_fragment_id);
429                }
430            }
431        }
432
433        let mut ensembles = if new_no_shuffle.is_empty() {
434            Vec::new()
435        } else {
436            let no_shuffle_edges: Vec<(FragmentId, FragmentId)> = new_no_shuffle
437                .iter()
438                .flat_map(|(u, ds)| ds.iter().map(move |d| (*u, *d)))
439                .collect();
440            let all_fragment_ids: Vec<FragmentId> = no_shuffle_edges
441                .iter()
442                .flat_map(|(u, d)| [*u, *d])
443                .collect::<HashSet<_>>()
444                .into_iter()
445                .collect();
446            let (fwd, bwd) = build_no_shuffle_fragment_graph_edges(no_shuffle_edges);
447            find_no_shuffle_graphs(&all_fragment_ids, &fwd, &bwd)?
448        };
449
450        // Add standalone fragments as single-fragment ensembles.
451        let covered: HashSet<FragmentId> = ensembles
452            .iter()
453            .flat_map(|e| e.component_fragments())
454            .collect();
455        for fragment_id in fragments.keys() {
456            if !covered.contains(fragment_id) {
457                ensembles.push(NoShuffleEnsemble::singleton(*fragment_id));
458            }
459        }
460
461        Ok(ensembles)
462    }
463}
464
465// ── Construction ──────────────────────────────────────────────────────────────
466
467impl BatchRefreshJobCheckpointControl {
468    /// Create from DDL command. Starts in `ConsumingSnapshot`.
469    ///
470    /// Internally calls `render_actors_and_build_job_info()` and injects the
471    /// partial-graph initial barrier.
472    #[expect(clippy::too_many_arguments)]
473    pub(crate) fn new<'a>(
474        entry: hash_map::VacantEntry<'a, JobId, IndependentCheckpointJobControl>,
475        database_id: DatabaseId,
476        job_id: JobId,
477        create_info: CreateIndependentStreamingJobCommandInfo,
478        notifier: Option<&mut NotifierStarter>,
479        snapshot_backfill_upstream_tables: HashSet<TableId>,
480        snapshot_epoch: u64,
481        version_stat: &HummockVersionStats,
482        term_id: &str,
483        partial_graph_manager: &mut PartialGraphManager,
484        logical: &BatchRefreshLogicalFragments,
485        worker_nodes: &HashMap<WorkerId, WorkerNode>,
486        batch_refresh_seconds: u64,
487    ) -> MetaResult<&'a mut Self> {
488        debug!(
489            %job_id,
490            "new batch refresh job"
491        );
492
493        let partial_graph_id = to_partial_graph_id(database_id, Some(job_id));
494        let backfill_ordering = &create_info.info.fragment_backfill_ordering;
495        let actor_id_generator = partial_graph_manager
496            .control_stream_manager()
497            .env
498            .actor_id_generator();
499
500        let render_result = Self::render_actors_and_build_job_info(
501            &logical.fragments,
502            &logical.downstreams,
503            &create_info.info.definition,
504            actor_id_generator,
505            worker_nodes,
506            partial_graph_manager.control_stream_manager(),
507            &create_info.info.database_resource_group,
508            &create_info.info.streaming_job_model,
509            partial_graph_id,
510        )?;
511        let initial_partial_graph_mutation = build_initial_add_mutation(
512            &render_result.fragment_infos,
513            backfill_ordering,
514            Default::default(), // No splits: batch refresh jobs reject source fragments.
515        );
516
517        let (snapshot, initial_barrier_info) = SnapshotPhaseControl::for_new_job(
518            snapshot_epoch,
519            &render_result.fragment_infos,
520            &create_info.info,
521            version_stat,
522        );
523
524        let BatchRefreshRenderResult {
525            fragment_infos,
526            node_actors,
527            actors_to_create,
528        } = render_result;
529        let control = IndependentJobControl::new(IndependentJobInfo::from_fragment_infos(
530            database_id,
531            job_id,
532            snapshot_epoch,
533            snapshot_backfill_upstream_tables,
534            fragment_infos.values(),
535        ));
536
537        let mut graph_adder = partial_graph_manager.add_partial_graph(
538            partial_graph_id,
539            term_id,
540            BatchRefreshBarrierStats::new(job_id, snapshot_epoch),
541        );
542        if let Err(e) = add_initial_partial_graph(
543            &mut graph_adder,
544            partial_graph_id,
545            InitialPartialGraphRequest {
546                node_actors: &node_actors,
547                state_table_ids: &control.info.state_table_ids,
548                barrier_info: initial_barrier_info,
549                actors_to_create,
550                mutation: initial_partial_graph_mutation,
551                notifier,
552                create_info,
553            },
554        ) {
555            graph_adder.failed();
556            entry.insert(IndependentCheckpointJobControl::resetting(
557                control.info.snapshot_backfill_upstream_tables,
558            ));
559            return Err(e);
560        }
561        graph_adder.added();
562        let this = Self {
563            control,
564            batch_refresh_seconds,
565            status: BatchRefreshJobStatus::ConsumingSnapshot {
566                snapshot,
567                fragment_infos,
568                node_actors,
569            },
570        };
571        let control = entry.insert(IndependentCheckpointJobControl::batch_refresh(
572            job_id,
573            partial_graph_id,
574            IndependentCheckpointJobStatus::Initial { snapshot_epoch },
575            this,
576        ));
577        let Some(IndependentCheckpointJob::BatchRefresh(job)) = control.running_mut() else {
578            unreachable!()
579        };
580        Ok(job)
581    }
582
583    /// Recover from a persistent state during recovery.
584    ///
585    /// - If `committed_epoch >= snapshot_epoch` → Idle (snapshot completed before crash).
586    /// - If `committed_epoch < snapshot_epoch` → `ConsumingSnapshot` using pre-rendered actors.
587    pub(crate) fn recover(
588        control: IndependentJobControl,
589        backfill_order: ExtendedFragmentBackfillOrder,
590        version_stat: &HummockVersionStats,
591        initial_mutation: Mutation,
592        render_result: BatchRefreshRenderResult,
593        term_id: &str,
594        partial_graph_recoverer: &mut crate::barrier::partial_graph::PartialGraphRecoverer<'_>,
595        batch_refresh_seconds: u64,
596    ) -> MetaResult<Self> {
597        let job_id = control.info.job_id;
598        let partial_graph_id = control.info.partial_graph_id;
599        let snapshot_epoch = control.info.snapshot_epoch;
600        let committed_epoch = control
601            .max_committed_epoch
602            .expect("recovered independent job should have committed");
603
604        if committed_epoch >= snapshot_epoch {
605            // Snapshot completed; recover to Idle.
606            info!(
607                %job_id,
608                committed_epoch,
609                snapshot_epoch,
610                "recovered idle batch refresh job (no partial graph)"
611            );
612            return Ok(Self {
613                control,
614                batch_refresh_seconds,
615                status: BatchRefreshJobStatus::Idle,
616            });
617        }
618
619        // Snapshot still in-progress; recover to ConsumingSnapshot.
620        info!(
621            %job_id,
622            committed_epoch,
623            snapshot_epoch,
624            "recovered batch refresh job to consuming snapshot"
625        );
626
627        let (snapshot, first_barrier_info) = SnapshotPhaseControl::for_recovery(
628            job_id,
629            snapshot_epoch,
630            committed_epoch,
631            &render_result.fragment_infos,
632            &backfill_order,
633            version_stat,
634        );
635
636        partial_graph_recoverer.recover_graph(
637            partial_graph_id,
638            term_id,
639            initial_mutation,
640            &first_barrier_info,
641            &render_result.node_actors,
642            control.info.state_table_ids.iter().copied(),
643            render_result.actors_to_create,
644            BatchRefreshBarrierStats::new(job_id, snapshot_epoch),
645        )?;
646
647        Ok(Self {
648            control,
649            batch_refresh_seconds,
650            status: BatchRefreshJobStatus::ConsumingSnapshot {
651                snapshot,
652                fragment_infos: render_result.fragment_infos,
653                node_actors: render_result.node_actors,
654            },
655        })
656    }
657}
658
659// ── Barrier injection ─────────────────────────────────────────────────────────
660
661impl BatchRefreshJobCheckpointControl {
662    fn inject_barrier(
663        partial_graph_id: PartialGraphId,
664        partial_graph_manager: &mut PartialGraphManager,
665        node_actors: &HashMap<WorkerId, HashSet<ActorId>>,
666        state_table_ids: &HashSet<TableId>,
667        barrier_info: BarrierInfo,
668        new_actors: Option<StreamJobActorsToCreate>,
669        mutation: Option<Mutation>,
670        notifier: Option<&mut NotifierStarter>,
671        first_create_info: Option<CreateIndependentStreamingJobCommandInfo>,
672        is_stop: bool,
673    ) -> MetaResult<()> {
674        if is_stop {
675            assert!(
676                matches!(&mutation, Some(Mutation::Stop(_))),
677                "stop barrier must carry a Stop mutation"
678            );
679        }
680        partial_graph_manager.inject_barrier(
681            partial_graph_id,
682            mutation,
683            node_actors,
684            state_table_ids.iter().copied(),
685            if is_stop {
686                // Stop barrier: data already synced by the prior checkpoint.
687                itertools::Either::Left(std::iter::empty())
688            } else {
689                itertools::Either::Right(node_actors.keys().copied())
690            },
691            new_actors,
692            PartialGraphBarrierInfo::new(
693                first_create_info.map_or_else(
694                    PostCollectCommand::barrier,
695                    CreateIndependentStreamingJobCommandInfo::into_post_collect,
696                ),
697                barrier_info,
698                notifier,
699                state_table_ids.clone(),
700            ),
701        )?;
702        Ok(())
703    }
704}
705
706// ── Barrier forwarding and collection ─────────────────────────────────────────
707
708impl BatchRefreshJobCheckpointControl {
709    pub(crate) fn on_new_upstream_barrier(
710        &mut self,
711        partial_graph_manager: &mut PartialGraphManager,
712        barrier_info: &BarrierInfo,
713        mutation: Option<(Mutation, Option<&mut NotifierStarter>)>,
714    ) -> MetaResult<()> {
715        if !matches!(self.status, BatchRefreshJobStatus::ConsumingSnapshot { .. }) {
716            // ConsumingLogStore has all barriers pre-injected; no forwarding needed.
717            // Idle has no partial graph.
718            return Ok(());
719        }
720        let (mutation, notifier) = match mutation {
721            Some((mutation, notifier)) => (Some(mutation), notifier),
722            None => (None, None),
723        };
724        // Check if snapshot consumption is finished and we need to inject stop barriers.
725        let is_finished = matches!(
726            &self.status,
727            BatchRefreshJobStatus::ConsumingSnapshot { snapshot, .. }
728            if snapshot.create_mview_tracker.is_finished()
729        );
730
731        if is_finished {
732            // Take the status out to destructure and transition to `FinishingSnapshot`.
733            // Use a placeholder; will be overwritten below.
734            let old_status = replace(&mut self.status, BatchRefreshJobStatus::Idle);
735            let BatchRefreshJobStatus::ConsumingSnapshot {
736                mut snapshot,
737                fragment_infos,
738                node_actors,
739            } = old_status
740            else {
741                unreachable!()
742            };
743
744            // Inject final checkpoint at snapshot epoch.
745            let final_checkpoint = snapshot.finish_snapshot_barrier();
746            let tracking_job = snapshot.create_mview_tracker.into_tracking_job();
747
748            // Inject stop barrier with u64::MAX as curr_epoch and empty nodes_to_sync_table.
749            let stop_barrier = BarrierInfo {
750                prev_epoch: TracedEpoch::new(Epoch(snapshot.snapshot_epoch)),
751                curr_epoch: TracedEpoch::new(Epoch(u64::MAX)),
752                kind: BarrierKind::Checkpoint(vec![snapshot.snapshot_epoch]),
753            };
754
755            let stop_actors: Vec<ActorId> = fragment_infos
756                .values()
757                .flat_map(|f| f.actors.keys().copied())
758                .collect();
759
760            Self::inject_barrier(
761                self.control.info.partial_graph_id,
762                partial_graph_manager,
763                &node_actors,
764                &self.control.info.state_table_ids,
765                final_checkpoint,
766                None,
767                None,
768                notifier,
769                None,
770                false,
771            )?;
772            Self::inject_barrier(
773                self.control.info.partial_graph_id,
774                partial_graph_manager,
775                &node_actors,
776                &self.control.info.state_table_ids,
777                stop_barrier,
778                None,
779                Some(Mutation::Stop(StopMutation {
780                    actors: stop_actors,
781                    dropped_sink_fragments: vec![],
782                })),
783                None,
784                None,
785                true,
786            )?;
787
788            self.status = BatchRefreshJobStatus::FinishingSnapshot {
789                tracking_job: Some(tracking_job),
790                fragment_infos,
791            };
792        } else {
793            // Normal barrier — still consuming snapshot.
794            let BatchRefreshJobStatus::ConsumingSnapshot {
795                snapshot,
796                node_actors,
797                ..
798            } = &mut self.status
799            else {
800                unreachable!("is_finished was false, status must be ConsumingSnapshot")
801            };
802
803            // Forward a fake barrier to the partial graph.
804            let mutation = mutation.or_else(|| snapshot.take_start_backfill_mutation());
805            let barrier_to_inject = snapshot.next_fake_barrier(&barrier_info.kind);
806            Self::inject_barrier(
807                self.control.info.partial_graph_id,
808                partial_graph_manager,
809                node_actors,
810                &self.control.info.state_table_ids,
811                barrier_to_inject,
812                None,
813                mutation,
814                notifier,
815                None,
816                false,
817            )?;
818        }
819        Ok(())
820    }
821
822    pub(crate) fn collect(&mut self, collected_barrier: CollectedBarrier<'_>) -> bool {
823        match &mut self.status {
824            BatchRefreshJobStatus::ConsumingSnapshot { snapshot, .. } => snapshot.apply_progress(
825                collected_barrier
826                    .resps
827                    .values()
828                    .flat_map(|resp| &resp.create_mview_progress),
829            ),
830            BatchRefreshJobStatus::InitializingBatchRefresh { .. }
831            | BatchRefreshJobStatus::ConsumingLogStore { .. } => {
832                // All barriers are pre-injected; no progress tracking needed.
833                false
834            }
835            _ => false,
836        }
837    }
838}
839
840// ── Completing ────────────────────────────────────────────────────────────────
841
842impl BatchRefreshJobCheckpointControl {
843    #[expect(clippy::type_complexity)]
844    pub(crate) fn start_completing(
845        &mut self,
846        partial_graph_manager: &mut PartialGraphManager,
847    ) -> Option<(
848        u64,
849        HashMap<WorkerId, BarrierCompleteResponse>,
850        PartialGraphBarrierInfo,
851        Option<TrackingJob>,
852    )> {
853        match &self.status {
854            BatchRefreshJobStatus::ConsumingSnapshot { .. }
855            | BatchRefreshJobStatus::FinishingSnapshot { .. }
856            | BatchRefreshJobStatus::ConsumingLogStore { .. } => {}
857            BatchRefreshJobStatus::Idle
858            | BatchRefreshJobStatus::InitializingBatchRefresh { .. } => {
859                return None;
860            }
861        };
862
863        partial_graph_manager
864            .start_completing(
865                self.control.info.partial_graph_id,
866                std::ops::Bound::Unbounded,
867                |_non_checkpoint_epoch, _resps, _| {
868                    // Progress already applied in `collect()`.
869                },
870            )
871            .map(|(epoch, resps, info)| {
872                // Take tracking job only when the snapshot stop barrier completes
873                // (i.e., we are in FinishingSnapshot and the epoch matches snapshot_epoch).
874                // Note: ConsumingLogStore's stop barrier also has prev_epoch == target_upstream_epoch,
875                // which may coincidentally equal snapshot_epoch if no new upstream commits occurred.
876                // We must check the status, not just the epoch, to avoid a false positive.
877                let tracking_job = match &mut self.status {
878                    BatchRefreshJobStatus::FinishingSnapshot { tracking_job, .. }
879                        if epoch == self.control.info.snapshot_epoch =>
880                    {
881                        Some(
882                            tracking_job
883                                .take()
884                                .expect("tracking job should not have been taken yet"),
885                        )
886                    }
887                    _ => None,
888                };
889                (epoch, resps, info, tracking_job)
890            })
891    }
892
893    pub(super) fn ack_completed(
894        &mut self,
895        partial_graph_manager: &mut PartialGraphManager,
896        completed_epoch: u64,
897    ) {
898        match &self.status {
899            BatchRefreshJobStatus::ConsumingSnapshot { .. } => {
900                partial_graph_manager
901                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
902                self.control.ack_completed(completed_epoch);
903            }
904            BatchRefreshJobStatus::FinishingSnapshot { tracking_job, .. }
905                if completed_epoch == self.control.info.snapshot_epoch =>
906            {
907                partial_graph_manager
908                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
909                self.control.ack_completed(completed_epoch);
910                assert!(
911                    tracking_job.is_none(),
912                    "tracking job should have been taken at start_completing"
913                );
914                info!(
915                    job_id = %self.control.info.job_id,
916                    completed_epoch,
917                    "batch refresh job: snapshot done, transitioned to idle, removing partial graph"
918                );
919                partial_graph_manager
920                    .remove_partial_graphs(vec![self.control.info.partial_graph_id]);
921                self.status = BatchRefreshJobStatus::Idle;
922            }
923            BatchRefreshJobStatus::FinishingSnapshot { .. } => {
924                partial_graph_manager
925                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
926                self.control.ack_completed(completed_epoch);
927            }
928            BatchRefreshJobStatus::ConsumingLogStore {
929                target_upstream_epoch,
930                ..
931            } if completed_epoch == *target_upstream_epoch => {
932                let target = *target_upstream_epoch;
933                partial_graph_manager
934                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
935                self.control.ack_completed(completed_epoch);
936                info!(
937                    job_id = %self.control.info.job_id,
938                    completed_epoch,
939                    target_upstream_epoch = target,
940                    "batch refresh job: logstore done, transitioned to idle, removing partial graph"
941                );
942                partial_graph_manager
943                    .remove_partial_graphs(vec![self.control.info.partial_graph_id]);
944                self.status = BatchRefreshJobStatus::Idle;
945            }
946            BatchRefreshJobStatus::ConsumingLogStore { .. } => {
947                partial_graph_manager
948                    .ack_completed(self.control.info.partial_graph_id, completed_epoch);
949                self.control.ack_completed(completed_epoch);
950            }
951            BatchRefreshJobStatus::Idle
952            | BatchRefreshJobStatus::InitializingBatchRefresh { .. } => {
953                unreachable!("batch refresh job should not be completing in this state")
954            }
955        }
956    }
957}
958
959// ── Query methods ─────────────────────────────────────────────────────────────
960
961impl BatchRefreshJobCheckpointControl {
962    pub(crate) fn gen_backfill_progress(&self) -> Option<BackfillProgress> {
963        match &self.status {
964            BatchRefreshJobStatus::ConsumingSnapshot { snapshot, .. } => {
965                let progress = if snapshot.create_mview_tracker.is_finished() {
966                    "Snapshot finished".to_owned()
967                } else {
968                    let progress = snapshot.create_mview_tracker.gen_backfill_progress();
969                    format!("BatchRefresh Snapshot [{}]", progress)
970                };
971                Some(BackfillProgress {
972                    progress,
973                    backfill_type: PbBackfillType::SnapshotBackfill,
974                })
975            }
976            BatchRefreshJobStatus::FinishingSnapshot { .. } => Some(BackfillProgress {
977                progress: "BatchRefresh Stopping".to_owned(),
978                backfill_type: PbBackfillType::SnapshotBackfill,
979            }),
980            BatchRefreshJobStatus::InitializingBatchRefresh { .. }
981            | BatchRefreshJobStatus::ConsumingLogStore { .. } => Some(BackfillProgress {
982                progress: "BatchRefresh LogStore".to_owned(),
983                backfill_type: PbBackfillType::SnapshotBackfill,
984            }),
985            BatchRefreshJobStatus::Idle => None,
986        }
987    }
988
989    pub(super) fn gen_fragment_backfill_progress(&self) -> Vec<FragmentBackfillProgress> {
990        match &self.status {
991            BatchRefreshJobStatus::ConsumingSnapshot {
992                snapshot,
993                fragment_infos,
994                ..
995            } => snapshot
996                .create_mview_tracker
997                .collect_fragment_progress(fragment_infos, true),
998            BatchRefreshJobStatus::FinishingSnapshot { fragment_infos, .. } => {
999                collect_done_fragments(self.control.info.job_id, fragment_infos)
1000            }
1001            _ => vec![],
1002        }
1003    }
1004
1005    pub(super) fn pinned_upstream_tables(&self) -> &HashSet<TableId> {
1006        match &self.status {
1007            BatchRefreshJobStatus::ConsumingSnapshot { .. }
1008            | BatchRefreshJobStatus::FinishingSnapshot { .. }
1009            | BatchRefreshJobStatus::ConsumingLogStore { .. }
1010            | BatchRefreshJobStatus::InitializingBatchRefresh { .. }
1011            | BatchRefreshJobStatus::Idle => &self.control.info.snapshot_backfill_upstream_tables,
1012        }
1013    }
1014
1015    pub(crate) fn fragment_infos(&self) -> Option<&HashMap<FragmentId, InflightFragmentInfo>> {
1016        match &self.status {
1017            BatchRefreshJobStatus::ConsumingSnapshot { fragment_infos, .. } => Some(fragment_infos),
1018            BatchRefreshJobStatus::InitializingBatchRefresh { fragment_infos, .. } => {
1019                Some(fragment_infos)
1020            }
1021            BatchRefreshJobStatus::ConsumingLogStore { fragment_infos, .. } => Some(fragment_infos),
1022            BatchRefreshJobStatus::FinishingSnapshot { .. } | BatchRefreshJobStatus::Idle => None,
1023        }
1024    }
1025
1026    pub(crate) fn pre_apply_throttle(
1027        &mut self,
1028        config: &mut ThrottleConfigMap,
1029    ) -> Option<Mutation> {
1030        let BatchRefreshJobStatus::ConsumingSnapshot {
1031            fragment_infos,
1032            snapshot,
1033            ..
1034        } = &mut self.status
1035        else {
1036            return None;
1037        };
1038        if snapshot.create_mview_tracker.is_finished() {
1039            return None;
1040        }
1041
1042        extract_throttle_config(config, |fragment_id, stream_node| {
1043            if let Some(fragment_info) = fragment_infos.get_mut(&fragment_id) {
1044                fragment_info.nodes = stream_node.clone();
1045                true
1046            } else {
1047                false
1048            }
1049        })
1050    }
1051
1052    /// Whether this idle job should start a refresh run.
1053    ///
1054    /// Returns `true` if the job is idle and the upstream committed epoch is
1055    /// far enough ahead of the job's last committed epoch (by `batch_refresh_seconds`).
1056    pub(crate) fn should_start_refresh(&self, upstream_committed_epoch: u64) -> bool {
1057        if let BatchRefreshJobStatus::Idle = &self.status {
1058            let last_committed_epoch = self
1059                .control
1060                .max_committed_epoch
1061                .expect("batch refresh job should have committed when entering Idle");
1062            let job_physical_ms = Epoch(last_committed_epoch).physical_time();
1063            let upstream_physical_ms = Epoch(upstream_committed_epoch).physical_time();
1064            let threshold_ms = self.batch_refresh_seconds * 1000;
1065            upstream_physical_ms.saturating_sub(job_physical_ms) >= threshold_ms
1066        } else {
1067            false
1068        }
1069    }
1070
1071    /// Returns the last committed epoch if the job is idle.
1072    pub(crate) fn last_committed_epoch(&self) -> Option<u64> {
1073        if let BatchRefreshJobStatus::Idle = &self.status {
1074            self.control.max_committed_epoch
1075        } else {
1076            None
1077        }
1078    }
1079}
1080
1081// ── Logstore refresh run ──────────────────────────────────────────────────────
1082
1083impl BatchRefreshJobCheckpointControl {
1084    /// Start a logstore consumption run.
1085    ///
1086    /// Preconditions: the job must be `Idle`.
1087    ///
1088    /// 1. Resolves log epochs from the hummock changelog
1089    /// 2. Re-renders actors using the cached context
1090    /// 3. Injects all barriers at once (first with `AddMutation`, last with `StopMutation`)
1091    /// 4. Transitions to `ConsumingLogStore`
1092    ///
1093    /// Returns `true` if a refresh run was started, `false` if there are no
1094    /// log epochs to consume (early return, stays idle).
1095    pub(crate) fn start_refresh_run(
1096        &mut self,
1097        context: &BatchRefreshJobTriggerContext,
1098        worker_nodes: &HashMap<WorkerId, WorkerNode>,
1099        actor_id_counter: &AtomicU32,
1100        term_id: &str,
1101        partial_graph_manager: &mut PartialGraphManager,
1102    ) -> MetaResult<bool> {
1103        let last_committed_epoch = match &self.status {
1104            BatchRefreshJobStatus::Idle => self
1105                .control
1106                .max_committed_epoch
1107                .expect("batch refresh job should have committed when entering Idle"),
1108            _ => panic!(
1109                "batch refresh job {}: start_refresh_run called in non-Idle state {:?}",
1110                self.control.info.job_id, self.status
1111            ),
1112        };
1113
1114        // Resolve log epochs into barrier infos.
1115        let target_upstream_epoch = context.target_upstream_epoch;
1116        let Some((first_epoch, pending_log_barriers)) = Self::resolve_log_epoch_barriers(
1117            &self.control.info.snapshot_backfill_upstream_tables,
1118            &context.upstream_table_log_epochs,
1119            last_committed_epoch,
1120        )?
1121        else {
1122            info!(
1123                job_id = %self.control.info.job_id,
1124                last_committed_epoch,
1125                target_upstream_epoch,
1126                "batch refresh job: no log epochs to consume, staying idle"
1127            );
1128            return Ok(false);
1129        };
1130
1131        let log_target_epoch = pending_log_barriers.last().expect("non-empty").prev_epoch();
1132        if target_upstream_epoch != log_target_epoch {
1133            info!(
1134                job_id = %self.control.info.job_id,
1135                last_committed_epoch,
1136                target_upstream_epoch,
1137                log_target_epoch,
1138                "batch refresh job: upstream target has no resolved changelog yet, staying idle"
1139            );
1140            return Ok(false);
1141        }
1142
1143        // Build logical fragments from cached context.
1144        let logical = BatchRefreshLogicalFragments::from_context(context);
1145
1146        // Re-render actors.
1147        let render_result = Self::render_actors_and_build_job_info(
1148            &logical.fragments,
1149            &logical.downstreams,
1150            &context.definition,
1151            actor_id_counter,
1152            worker_nodes,
1153            partial_graph_manager.control_stream_manager(),
1154            &context.database_resource_group,
1155            &context.streaming_job_model,
1156            self.control.info.partial_graph_id,
1157        )?;
1158
1159        // Build actors_to_create and initial mutation.
1160        let added_actors: Vec<ActorId> = render_result
1161            .fragment_infos
1162            .values()
1163            .flat_map(|fragment| fragment.actors.keys().copied())
1164            .collect();
1165
1166        let initial_mutation = Mutation::Add(AddMutation {
1167            actor_dispatchers: Default::default(),
1168            added_actors,
1169            actor_splits: Default::default(),
1170            pause: false,
1171            subscriptions_to_add: Default::default(),
1172            backfill_nodes_to_pause: Default::default(),
1173            actor_cdc_table_snapshot_splits: None,
1174            new_upstream_sinks: Default::default(),
1175            dropped_actors: Default::default(),
1176            sink_log_store_flush: Default::default(),
1177        });
1178
1179        let node_actors = &render_result.node_actors;
1180        let initial_barrier = BarrierInfo {
1181            prev_epoch: TracedEpoch::new(Epoch(last_committed_epoch)),
1182            curr_epoch: TracedEpoch::new(Epoch(first_epoch)),
1183            kind: BarrierKind::Initial,
1184        };
1185        let mut partial_graph_recoverer = partial_graph_manager.start_recover();
1186        let recover_result = partial_graph_recoverer.recover_graph(
1187            self.control.info.partial_graph_id,
1188            term_id,
1189            initial_mutation,
1190            &initial_barrier,
1191            node_actors,
1192            self.control.info.state_table_ids.iter().copied(),
1193            render_result.actors_to_create,
1194            BatchRefreshBarrierStats::new(
1195                self.control.info.job_id,
1196                self.control.info.snapshot_epoch,
1197            ),
1198        );
1199        match recover_result {
1200            Ok(()) => {
1201                let initializing_partial_graphs = partial_graph_recoverer.all_initializing();
1202                debug_assert_eq!(initializing_partial_graphs.len(), 1);
1203                debug_assert!(
1204                    initializing_partial_graphs.contains(&self.control.info.partial_graph_id)
1205                );
1206            }
1207            Err(e) => {
1208                partial_graph_recoverer.failed();
1209                return Err(e);
1210            }
1211        }
1212
1213        info!(
1214            job_id = %self.control.info.job_id,
1215            last_committed_epoch,
1216            target_upstream_epoch,
1217            num_log_barriers = pending_log_barriers.len(),
1218            "batch refresh job: initialized logstore consumption partial graph"
1219        );
1220
1221        self.status = BatchRefreshJobStatus::InitializingBatchRefresh {
1222            fragment_infos: render_result.fragment_infos,
1223            node_actors: render_result.node_actors,
1224            pending_log_barriers,
1225            target_upstream_epoch,
1226        };
1227
1228        Ok(true)
1229    }
1230
1231    pub(crate) fn on_log_store_initialized(
1232        &mut self,
1233        partial_graph_manager: &mut PartialGraphManager,
1234    ) -> MetaResult<()> {
1235        let old_status = replace(&mut self.status, BatchRefreshJobStatus::Idle);
1236        let BatchRefreshJobStatus::InitializingBatchRefresh {
1237            fragment_infos,
1238            node_actors,
1239            pending_log_barriers,
1240            target_upstream_epoch,
1241        } = old_status
1242        else {
1243            panic!(
1244                "batch refresh job {}: logstore initialized in unexpected status {:?}",
1245                self.control.info.job_id, old_status
1246            );
1247        };
1248
1249        let final_barrier_idx = pending_log_barriers.len() - 1;
1250        let mut stop_mutation = Some(Mutation::Stop(StopMutation {
1251            actors: fragment_infos
1252                .values()
1253                .flat_map(|fragment| fragment.actors.keys().copied())
1254                .collect(),
1255            dropped_sink_fragments: vec![],
1256        }));
1257        for (idx, barrier) in pending_log_barriers.into_iter().enumerate() {
1258            let is_stop_barrier = idx == final_barrier_idx;
1259            let mutation = is_stop_barrier.then(|| stop_mutation.take().expect("unused"));
1260            Self::inject_barrier(
1261                self.control.info.partial_graph_id,
1262                partial_graph_manager,
1263                &node_actors,
1264                &self.control.info.state_table_ids,
1265                barrier,
1266                None,
1267                mutation,
1268                None,
1269                None,
1270                is_stop_barrier,
1271            )?;
1272        }
1273
1274        self.status = BatchRefreshJobStatus::ConsumingLogStore {
1275            fragment_infos,
1276            target_upstream_epoch,
1277        };
1278        Ok(())
1279    }
1280
1281    /// Resolve upstream log epochs from the hummock changelog into barrier infos.
1282    ///
1283    /// Returns `(first_epoch, log_barriers)`. `first_epoch` is consumed by the
1284    /// initial barrier. `log_barriers` contains all barriers to inject after
1285    /// initialization, ending with the final checkpoint stop barrier.
1286    fn resolve_log_epoch_barriers(
1287        snapshot_backfill_upstream_tables: &HashSet<TableId>,
1288        upstream_table_log_epochs: &HashMap<TableId, Vec<(Vec<u64>, u64)>>,
1289        exclusive_start_log_epoch: u64,
1290    ) -> MetaResult<Option<(u64, Vec<BarrierInfo>)>> {
1291        let table_id = snapshot_backfill_upstream_tables
1292            .iter()
1293            .next()
1294            .expect("snapshot backfill job should have upstream");
1295        let Some(epochs) = upstream_table_log_epochs.get(table_id) else {
1296            return Ok(None);
1297        };
1298
1299        // Find the starting point: skip entries up to and including exclusive_start_log_epoch.
1300        let mut epochs_iter = epochs.iter().peekable();
1301        loop {
1302            match epochs_iter.peek() {
1303                Some((_, checkpoint_epoch)) if *checkpoint_epoch <= exclusive_start_log_epoch => {
1304                    epochs_iter.next();
1305                }
1306                _ => break,
1307            }
1308        }
1309
1310        let mut epoch_infos = vec![];
1311        for (non_checkpoint_epochs, checkpoint_epoch) in epochs_iter {
1312            epoch_infos.extend(
1313                non_checkpoint_epochs
1314                    .iter()
1315                    .copied()
1316                    .map(|epoch| (epoch, false)),
1317            );
1318            epoch_infos.push((*checkpoint_epoch, true));
1319        }
1320        if epoch_infos.is_empty() {
1321            return Ok(None);
1322        }
1323
1324        let first_epoch = epoch_infos[0].0;
1325        let mut pending_non_checkpoint_epochs = vec![];
1326        let mut replay_barriers = vec![];
1327        for window in epoch_infos.windows(2) {
1328            let (prev_epoch, is_checkpoint) = window[0];
1329            let curr_epoch = window[1].0;
1330            assert!(prev_epoch > exclusive_start_log_epoch);
1331            assert!(curr_epoch > prev_epoch);
1332            pending_non_checkpoint_epochs.push(prev_epoch);
1333            let kind = if is_checkpoint {
1334                BarrierKind::Checkpoint(take(&mut pending_non_checkpoint_epochs))
1335            } else {
1336                BarrierKind::Barrier
1337            };
1338            replay_barriers.push(BarrierInfo {
1339                prev_epoch: TracedEpoch::new(Epoch(prev_epoch)),
1340                curr_epoch: TracedEpoch::new(Epoch(curr_epoch)),
1341                kind,
1342            });
1343        }
1344
1345        let (last_epoch, _) = *epoch_infos.last().expect("non-empty");
1346        assert!(last_epoch > exclusive_start_log_epoch);
1347        pending_non_checkpoint_epochs.push(last_epoch);
1348        replay_barriers.push(BarrierInfo {
1349            prev_epoch: TracedEpoch::new(Epoch(last_epoch)),
1350            curr_epoch: TracedEpoch::new(Epoch(u64::MAX)),
1351            kind: BarrierKind::Checkpoint(pending_non_checkpoint_epochs),
1352        });
1353
1354        Ok(Some((first_epoch, replay_barriers)))
1355    }
1356}
1357
1358impl BatchRefreshLogicalFragments {
1359    /// Build logical fragments from a trigger context.
1360    pub(crate) fn from_context(ctx: &BatchRefreshJobTriggerContext) -> Self {
1361        Self {
1362            fragments: ctx.fragments.clone(),
1363            downstreams: ctx.downstreams.clone(),
1364        }
1365    }
1366}
1367
1368// ── Barrier stats ─────────────────────────────────────────────────────────────
1369
1370struct BatchRefreshBarrierStats {
1371    barrier_latency: LabelGuardedHistogram,
1372    inflight_barrier_num: LabelGuardedIntGauge,
1373}
1374
1375impl BatchRefreshBarrierStats {
1376    fn new(job_id: JobId, _snapshot_epoch: u64) -> Self {
1377        let table_id_str = format!("{}", job_id);
1378        Self {
1379            barrier_latency: GLOBAL_META_METRICS
1380                .snapshot_backfill_barrier_latency
1381                .with_guarded_label_values(&[table_id_str.as_str(), "batch_refresh_snapshot"]),
1382            inflight_barrier_num: GLOBAL_META_METRICS
1383                .snapshot_backfill_inflight_barrier_num
1384                .with_guarded_label_values(&[&table_id_str]),
1385        }
1386    }
1387}
1388
1389impl PartialGraphStat for BatchRefreshBarrierStats {
1390    fn observe_barrier_latency(&self, _epoch: EpochPair, barrier_latency_secs: f64) {
1391        self.barrier_latency.observe(barrier_latency_secs);
1392    }
1393
1394    fn observe_barrier_num(&self, inflight_barrier_num: usize, _collected_barrier_num: usize) {
1395        self.inflight_barrier_num.set(inflight_barrier_num as _);
1396    }
1397}