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