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