Skip to main content

risingwave_meta/barrier/context/
recovery.rs

1// Copyright 2024 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
15use std::cmp::{Ordering, max, min};
16use std::collections::hash_map::Entry;
17use std::collections::{HashMap, HashSet};
18use std::sync::atomic::AtomicU32;
19
20use anyhow::{Context, anyhow};
21use futures::StreamExt;
22use futures::stream::FuturesUnordered;
23use itertools::Itertools;
24use risingwave_common::bail;
25use risingwave_common::catalog::{DatabaseId, TableId};
26use risingwave_common::id::JobId;
27use risingwave_common::util::stream_graph_visitor::visit_stream_node_cont;
28use risingwave_connector::source::SplitImpl;
29use risingwave_hummock_sdk::change_log::TableChangeLogs;
30use risingwave_hummock_sdk::version::HummockVersion;
31use risingwave_meta_model::SinkId;
32use risingwave_pb::stream_plan::stream_node::PbNodeBody;
33use sea_orm::TransactionTrait;
34use thiserror_ext::AsReport;
35use tracing::{info, warn};
36
37use super::BarrierWorkerRuntimeInfoSnapshot;
38use crate::MetaResult;
39use crate::barrier::DatabaseRuntimeInfoSnapshot;
40use crate::barrier::checkpoint::{
41    BatchRefreshJobCheckpointControl, BatchRefreshLogicalFragments, BatchRefreshRenderResult,
42    IcebergV3JobCheckpointControl, IcebergV3RenderResult, is_iceberg_v3_fragment_nodes,
43};
44use crate::barrier::context::{GlobalBarrierWorkerContext, GlobalBarrierWorkerContextImpl};
45use crate::barrier::progress::TrackingJob;
46use crate::barrier::rpc::{ControlStreamManager, to_partial_graph_id};
47use crate::controller::fragment::{InflightActorInfo, InflightFragmentInfo};
48use crate::controller::scale::{
49    FragmentRenderMap, LoadedFragment, LoadedFragmentContext, RenderedGraph,
50    render_actor_assignments,
51};
52use crate::controller::utils::StreamingJobExtraInfo;
53use crate::manager::ActiveStreamingWorkerNodes;
54use crate::model::{ActorId, FragmentDownstreamRelation, FragmentId, StreamActor};
55use crate::rpc::ddl_controller::refill_upstream_sink_union_in_table;
56use crate::stream::cdc::reload_cdc_table_snapshot_splits;
57use crate::stream::{
58    SourceChange, StreamFragmentGraph, UpstreamSinkInfo, cleanup_dropped_streaming_jobs,
59};
60
61#[derive(Debug)]
62pub(crate) struct UpstreamSinkRecoveryInfo {
63    target_fragment_id: FragmentId,
64    upstream_infos: Vec<UpstreamSinkInfo>,
65}
66
67#[derive(Debug)]
68pub struct LoadedRecoveryContext {
69    pub fragment_context: LoadedFragmentContext,
70    pub job_extra_info: HashMap<JobId, StreamingJobExtraInfo>,
71    pub upstream_sink_recovery: HashMap<JobId, UpstreamSinkRecoveryInfo>,
72    pub fragment_relations: FragmentDownstreamRelation,
73}
74
75impl LoadedRecoveryContext {
76    fn empty(fragment_context: LoadedFragmentContext) -> Self {
77        Self {
78            fragment_context,
79            job_extra_info: HashMap::new(),
80            upstream_sink_recovery: HashMap::new(),
81            fragment_relations: FragmentDownstreamRelation::default(),
82        }
83    }
84}
85
86pub struct RenderedDatabaseRuntimeInfo {
87    pub job_infos: HashMap<JobId, HashMap<FragmentId, InflightFragmentInfo>>,
88    pub stream_actors: HashMap<ActorId, StreamActor>,
89    pub source_splits: HashMap<ActorId, Vec<SplitImpl>>,
90    /// Batch refresh jobs rendered during `render_runtime_info`.
91    pub batch_refresh: HashMap<JobId, BatchRefreshRenderResult>,
92    /// Iceberg V3 jobs rendered independently from the database graph.
93    pub iceberg_v3: HashMap<JobId, IcebergV3RenderResult>,
94}
95
96pub(in crate::barrier) fn render_runtime_info(
97    actor_id_generator: &AtomicU32,
98    worker_nodes: &ActiveStreamingWorkerNodes,
99    control_stream_manager: &ControlStreamManager,
100    recovery_context: &LoadedRecoveryContext,
101    database_id: DatabaseId,
102) -> MetaResult<Option<RenderedDatabaseRuntimeInfo>> {
103    let Some(mut per_database_context) =
104        recovery_context.fragment_context.for_database(database_id)
105    else {
106        return Ok(None);
107    };
108
109    assert!(!per_database_context.is_empty());
110
111    // Closed independent jobs are rendered separately from the database graph. This keeps their
112    // placement independent from upstream actors and renders inactive Iceberg fragments up front.
113    let batch_refresh_job_ids: HashSet<JobId> = per_database_context
114        .job_map
115        .iter()
116        .filter(|(_, model)| model.refresh_interval_sec.is_some())
117        .map(|(job_id, _)| *job_id)
118        .collect();
119    let iceberg_v3_job_ids: HashSet<JobId> = per_database_context
120        .job_fragments
121        .iter()
122        .filter(|(_, fragments)| {
123            is_iceberg_v3_fragment_nodes(fragments.values().map(|fragment| &fragment.nodes))
124        })
125        .map(|(job_id, _)| *job_id)
126        .collect();
127    assert!(
128        batch_refresh_job_ids.is_disjoint(&iceberg_v3_job_ids),
129        "Iceberg V3 jobs cannot be batch refresh jobs"
130    );
131    let independent_job_ids: HashSet<_> = batch_refresh_job_ids
132        .union(&iceberg_v3_job_ids)
133        .copied()
134        .collect();
135
136    let mut batch_refresh_logical = HashMap::new();
137    let mut iceberg_v3_logical = HashMap::new();
138    if !independent_job_ids.is_empty() {
139        let independent_fragment_ids: HashSet<FragmentId> = independent_job_ids
140            .iter()
141            .flat_map(|job_id| {
142                per_database_context
143                    .job_fragments
144                    .get(job_id)
145                    .unwrap()
146                    .keys()
147                    .copied()
148            })
149            .collect();
150
151        for &job_id in &independent_job_ids {
152            let fragments = per_database_context.job_fragments.remove(&job_id).unwrap();
153            let downstreams = fragments
154                .keys()
155                .filter_map(|fid| {
156                    recovery_context
157                        .fragment_relations
158                        .get(fid)
159                        .map(|r| (*fid, r.clone()))
160                })
161                .collect();
162            if batch_refresh_job_ids.contains(&job_id) {
163                batch_refresh_logical.insert(
164                    job_id,
165                    BatchRefreshLogicalFragments {
166                        fragments,
167                        downstreams,
168                    },
169                );
170            } else {
171                iceberg_v3_logical.insert(job_id, (fragments, downstreams));
172            }
173        }
174
175        // Closed independent jobs cannot share a no-shuffle ensemble with another job.
176        per_database_context.ensembles.retain(|ensemble| {
177            let mut has_independent_fragment = false;
178            let mut has_database_fragment = false;
179            for fragment_id in ensemble.component_fragments() {
180                if independent_fragment_ids.contains(&fragment_id) {
181                    has_independent_fragment = true;
182                } else {
183                    has_database_fragment = true;
184                }
185            }
186            assert!(
187                !(has_independent_fragment && has_database_fragment),
188                "closed independent job cannot share a no-shuffle ensemble"
189            );
190            has_database_fragment
191        });
192    }
193
194    // Render actors for each batch refresh job.
195    let mut batch_refresh = HashMap::new();
196    for (job_id, logical) in batch_refresh_logical {
197        let extra = recovery_context
198            .job_extra_info
199            .get(&job_id)
200            .expect("should have extra info");
201        let streaming_job_model = per_database_context
202            .job_map
203            .get(&job_id)
204            .expect("should have streaming job model");
205        let database_model = &per_database_context.database_map[&database_id];
206        let partial_graph_id = to_partial_graph_id(database_id, Some(job_id));
207
208        let render_result = BatchRefreshJobCheckpointControl::render_actors_and_build_job_info(
209            &logical.fragments,
210            &logical.downstreams,
211            &extra.job_definition,
212            actor_id_generator,
213            worker_nodes.current(),
214            control_stream_manager,
215            &database_model.resource_group,
216            streaming_job_model,
217            partial_graph_id,
218        )?;
219
220        batch_refresh.insert(job_id, render_result);
221    }
222
223    let mut iceberg_v3 = HashMap::new();
224    for (job_id, (fragments, downstreams)) in iceberg_v3_logical {
225        let extra = recovery_context
226            .job_extra_info
227            .get(&job_id)
228            .expect("should have extra info");
229        let streaming_job_model = per_database_context
230            .job_map
231            .get(&job_id)
232            .expect("should have streaming job model");
233        let database_model = &per_database_context.database_map[&database_id];
234        let render_result = IcebergV3JobCheckpointControl::render_actors_and_build_job_info(
235            &fragments,
236            &downstreams,
237            &extra.job_definition,
238            actor_id_generator,
239            worker_nodes.current(),
240            control_stream_manager,
241            &database_model.resource_group,
242            streaming_job_model,
243            to_partial_graph_id(database_id, Some(job_id)),
244        )?;
245        iceberg_v3.insert(job_id, render_result);
246    }
247
248    let (job_infos, stream_actors) = if per_database_context.ensembles.is_empty() {
249        (HashMap::new(), HashMap::new())
250    } else {
251        let RenderedGraph { mut fragments, .. } = render_actor_assignments(
252            actor_id_generator,
253            worker_nodes.current(),
254            &per_database_context,
255        )?;
256        let single_database = fragments
257            .remove(&database_id)
258            .expect("non-empty database render must produce its database entry");
259        let mut database_map = HashMap::from([(database_id, single_database)]);
260        recovery_table_with_upstream_sinks(
261            &mut database_map,
262            &recovery_context.upstream_sink_recovery,
263        )?;
264        let stream_actors = build_stream_actors(&database_map, &recovery_context.job_extra_info)?;
265        let job_infos = database_map
266            .remove(&database_id)
267            .expect("database entry must exist");
268        (job_infos, stream_actors)
269    };
270
271    let mut source_splits = HashMap::new();
272    for fragment_infos in job_infos.values() {
273        for fragment in fragment_infos.values() {
274            for (actor_id, info) in &fragment.actors {
275                source_splits.insert(*actor_id, info.splits.clone());
276            }
277        }
278    }
279
280    Ok(Some(RenderedDatabaseRuntimeInfo {
281        job_infos,
282        stream_actors,
283        source_splits,
284        batch_refresh,
285        iceberg_v3,
286    }))
287}
288
289/// For normal DDL operations, the `UpstreamSinkUnion` operator is modified dynamically, and does not persist the
290/// newly added or deleted upstreams in meta-store. Therefore, when restoring jobs, we need to restore the
291/// information required by the operator based on the current state of the upstream (sink) and downstream (table) of
292/// the operator. All necessary metadata must be preloaded before rendering.
293fn recovery_table_with_upstream_sinks(
294    inflight_jobs: &mut FragmentRenderMap,
295    upstream_sink_recovery: &HashMap<JobId, UpstreamSinkRecoveryInfo>,
296) -> MetaResult<()> {
297    if upstream_sink_recovery.is_empty() {
298        return Ok(());
299    }
300
301    let mut seen_jobs = HashSet::new();
302
303    for jobs in inflight_jobs.values_mut() {
304        for (job_id, fragments) in jobs {
305            if !seen_jobs.insert(*job_id) {
306                return Err(anyhow::anyhow!("Duplicate job id found: {}", job_id).into());
307            }
308
309            if let Some(recovery) = upstream_sink_recovery.get(job_id) {
310                if let Some(target_fragment) = fragments.get_mut(&recovery.target_fragment_id) {
311                    refill_upstream_sink_union_in_table(
312                        &mut target_fragment.nodes,
313                        &recovery.upstream_infos,
314                    );
315                } else {
316                    return Err(anyhow::anyhow!(
317                        "target fragment {} not found for upstream sink recovery of job {}",
318                        recovery.target_fragment_id,
319                        job_id
320                    )
321                    .into());
322                }
323            }
324        }
325    }
326
327    Ok(())
328}
329
330/// Assembles `StreamActor` instances from rendered fragment info and job context.
331///
332/// This function combines the actor assignments from `FragmentRenderMap` with
333/// runtime context (timezone, config, definition) from `StreamingJobExtraInfo`
334/// to produce the final `StreamActor` structures needed for recovery.
335fn build_stream_actors(
336    all_info: &FragmentRenderMap,
337    job_extra_info: &HashMap<JobId, StreamingJobExtraInfo>,
338) -> MetaResult<HashMap<ActorId, StreamActor>> {
339    let mut stream_actors = HashMap::new();
340
341    for (job_id, streaming_info) in all_info.values().flatten() {
342        let extra_info = job_extra_info
343            .get(job_id)
344            .cloned()
345            .ok_or_else(|| anyhow!("no streaming job info for {}", job_id))?;
346        let expr_context = extra_info.stream_context().to_expr_context();
347        let job_definition = extra_info.job_definition;
348        let config_override = extra_info.config_override;
349
350        for (fragment_id, fragment_infos) in streaming_info {
351            for (actor_id, InflightActorInfo { vnode_bitmap, .. }) in &fragment_infos.actors {
352                stream_actors.insert(
353                    *actor_id,
354                    StreamActor {
355                        actor_id: *actor_id,
356                        fragment_id: *fragment_id,
357                        vnode_bitmap: vnode_bitmap.clone(),
358                        mview_definition: job_definition.clone(),
359                        expr_context: Some(expr_context.clone()),
360                        config_override: config_override.clone(),
361                    },
362                );
363            }
364        }
365    }
366    Ok(stream_actors)
367}
368
369impl GlobalBarrierWorkerContextImpl {
370    fn resolve_job_committed_epoch(
371        job_id: JobId,
372        fragments: &HashMap<FragmentId, LoadedFragment>,
373        state_table_committed_epochs: &HashMap<TableId, u64>,
374    ) -> MetaResult<u64> {
375        let mut table_id_iter = fragments
376            .values()
377            .flat_map(|fragment| fragment.state_table_ids.iter().copied());
378        let Some(first_table_id) = table_id_iter.next() else {
379            bail!("job {} has no state table", job_id);
380        };
381        let committed_epoch = *state_table_committed_epochs
382            .get(&first_table_id)
383            .ok_or_else(|| anyhow!("cannot get committed epoch on table {}.", first_table_id))?;
384        for table_id in table_id_iter {
385            let table_committed_epoch = *state_table_committed_epochs
386                .get(&table_id)
387                .ok_or_else(|| anyhow!("cannot get committed epoch on table {}.", table_id))?;
388            if committed_epoch != table_committed_epoch {
389                bail!(
390                    "table {} has committed epoch {} different to other table {} with committed epoch {} in job {}",
391                    first_table_id,
392                    committed_epoch,
393                    table_id,
394                    table_committed_epoch,
395                    job_id
396                );
397            }
398        }
399
400        Ok(committed_epoch)
401    }
402
403    async fn finish_completed_independent_snapshot_jobs(
404        &self,
405        recovery_context: &LoadedRecoveryContext,
406        state_table_committed_epochs: &HashMap<TableId, u64>,
407        creating_jobs: &mut HashSet<JobId>,
408    ) -> MetaResult<()> {
409        let creating_job_ids = creating_jobs.iter().copied().collect_vec();
410        for job_id in creating_job_ids {
411            let Some(job) = recovery_context.fragment_context.job_map.get(&job_id) else {
412                continue;
413            };
414            let Some(fragments) = recovery_context.fragment_context.job_fragments.get(&job_id)
415            else {
416                continue;
417            };
418            let is_batch_refresh = job.refresh_interval_sec.is_some();
419            let is_iceberg_v3 =
420                is_iceberg_v3_fragment_nodes(fragments.values().map(|fragment| &fragment.nodes));
421            let job_type = if is_batch_refresh {
422                "batch refresh"
423            } else if is_iceberg_v3 {
424                "Iceberg V3"
425            } else {
426                continue;
427            };
428
429            let snapshot_backfill_info = StreamFragmentGraph::collect_snapshot_backfill_info_impl(
430                fragments
431                    .values()
432                    .map(|fragment| (&fragment.nodes, fragment.fragment_type_mask)),
433            )?
434            .0
435            .ok_or_else(|| anyhow!("independent job {} has no snapshot backfill info", job_id))?;
436            let committed_epoch =
437                Self::resolve_job_committed_epoch(job_id, fragments, state_table_committed_epochs)?;
438            let snapshot_epoch = snapshot_backfill_info
439                .upstream_mv_table_id_to_backfill_epoch
440                .values()
441                .find_map(|epoch| *epoch)
442                .unwrap_or(committed_epoch);
443            if committed_epoch < snapshot_epoch {
444                continue;
445            }
446
447            info!(
448                %job_id,
449                committed_epoch,
450                snapshot_epoch,
451                job_type,
452                "finish completed independent snapshot job during recovery"
453            );
454            self.finish_creating_job(TrackingJob::recovered_from_fragment_nodes(
455                job_id,
456                fragments
457                    .iter()
458                    .map(|(fragment_id, fragment)| (*fragment_id, &fragment.nodes)),
459            ))
460            .await?;
461            creating_jobs.remove(&job_id);
462        }
463
464        Ok(())
465    }
466
467    async fn apply_pre_applied_drop_cancel(
468        &self,
469        database_id: Option<DatabaseId>,
470    ) -> MetaResult<bool> {
471        let drop_cancel = self.scheduled_barriers.pre_apply_drop_cancel(database_id);
472        let has_drop_streaming_jobs = !drop_cancel.streaming_job_ids.is_empty();
473        cleanup_dropped_streaming_jobs(
474            &self.refresh_manager,
475            &self.hummock_manager,
476            &self.metadata_manager,
477            &self.sink_manager,
478            &self.iceberg_pk_index_sink_manager,
479            drop_cancel.streaming_job_ids,
480            drop_cancel.dropped_state_table_ids,
481            "drop_streaming_jobs",
482        )
483        .await?;
484        Ok(has_drop_streaming_jobs)
485    }
486
487    /// Clean catalogs for jobs in the initial state and creating jobs whose progress cannot be
488    /// recovered.
489    async fn clean_dirty_streaming_jobs(&self, database_id: Option<DatabaseId>) -> MetaResult<()> {
490        self.metadata_manager
491            .catalog_controller
492            .clean_dirty_subscription(database_id)
493            .await?;
494
495        let cleaned_dirty_jobs = self
496            .metadata_manager
497            .catalog_controller
498            .clean_dirty_creating_jobs(database_id)
499            .await?;
500        self.iceberg_pk_index_sink_manager
501            .retire_sinks(cleaned_dirty_jobs.sink_ids.iter().copied());
502        if database_id.is_some() {
503            // Per-database recovery does not run the global Hummock purge below. Unregister the
504            // dirty jobs cleaned in this database through the normal dropped-table cleanup path.
505            cleanup_dropped_streaming_jobs(
506                &self.refresh_manager,
507                &self.hummock_manager,
508                &self.metadata_manager,
509                &self.sink_manager,
510                &self.iceberg_pk_index_sink_manager,
511                cleaned_dirty_jobs.streaming_job_ids,
512                cleaned_dirty_jobs.dropped_table_ids,
513                "clean_dirty_creating_jobs",
514            )
515            .await?;
516        }
517        // unregister cleaned sources.
518        self.source_manager
519            .apply_source_change(SourceChange::DropSource {
520                dropped_source_ids: cleaned_dirty_jobs.source_ids,
521            })
522            .await;
523
524        Ok(())
525    }
526
527    /// Re-register iceberg pk-index sink commit coordinators after recovery wipes them.
528    async fn reregister_iceberg_pk_index_sinks(
529        &self,
530        database_id: Option<DatabaseId>,
531    ) -> MetaResult<()> {
532        let pb_sinks = self
533            .metadata_manager
534            .catalog_controller
535            .list_sinks()
536            .await?;
537        let mut futs = FuturesUnordered::new();
538        for pb_sink in pb_sinks {
539            if database_id.is_some_and(|db_id| pb_sink.database_id != db_id)
540                || !crate::manager::iceberg_pk_index_sink::is_iceberg_pk_index_sink(
541                    &pb_sink.properties,
542                )
543            {
544                continue;
545            }
546            let state_table_id = self
547                .metadata_manager
548                .catalog_controller
549                .get_sink_state_table_ids(pb_sink.id)
550                .await?
551                .into_iter()
552                .next()
553                .ok_or_else(|| {
554                    anyhow!(
555                        "no state table found while re-registering iceberg v3 sink {}",
556                        pb_sink.id
557                    )
558                })?;
559            let recovered_epoch = self
560                .hummock_manager
561                .on_current_version(|version| version.table_committed_epoch(state_table_id))
562                .await
563                .ok_or_else(|| {
564                    anyhow!(
565                        "cannot get committed epoch for iceberg v3 sink {} state table {}",
566                        pb_sink.id,
567                        state_table_id
568                    )
569                })?;
570            let manager = &self.iceberg_pk_index_sink_manager;
571            futs.push(async move {
572                let partial_graph_id =
573                    to_partial_graph_id(pb_sink.database_id, Some(pb_sink.id.as_job_id()));
574                let result = async {
575                    manager.register_sink(pb_sink.id, partial_graph_id).await?;
576                    manager.advance_committed_epochs([(partial_graph_id, recovered_epoch)]);
577                    Ok::<_, anyhow::Error>(())
578                }
579                .await;
580                (pb_sink.id, result)
581            });
582        }
583
584        while let Some((id, res)) = futs.next().await {
585            if let Err(e) = res {
586                let msg = format!("register iceberg v3 sink {} during recovery", id);
587                return Err(e.context(msg).into());
588            }
589        }
590        Ok(())
591    }
592
593    async fn abort_dirty_pending_sink_state(
594        &self,
595        database_id: Option<DatabaseId>,
596    ) -> MetaResult<()> {
597        let pending_sinks: HashSet<SinkId> = self
598            .metadata_manager
599            .catalog_controller
600            .list_all_pending_sinks(database_id)
601            .await?;
602
603        if pending_sinks.is_empty() {
604            return Ok(());
605        }
606
607        let sink_with_state_tables: HashMap<SinkId, Vec<TableId>> = self
608            .metadata_manager
609            .catalog_controller
610            .fetch_sink_with_state_table_ids(pending_sinks)
611            .await?;
612
613        let mut sink_committed_epoch: HashMap<SinkId, u64> = HashMap::new();
614
615        for (sink_id, table_ids) in sink_with_state_tables {
616            let Some(table_id) = table_ids.first() else {
617                return Err(anyhow!("no state table id in sink: {}", sink_id).into());
618            };
619
620            self.hummock_manager
621                .on_current_version(|version| -> MetaResult<()> {
622                    if let Some(committed_epoch) = version.table_committed_epoch(*table_id) {
623                        assert!(
624                            sink_committed_epoch
625                                .insert(sink_id, committed_epoch)
626                                .is_none()
627                        );
628                        Ok(())
629                    } else {
630                        Err(anyhow!("cannot get committed epoch on table {}.", table_id).into())
631                    }
632                })
633                .await?;
634        }
635
636        self.metadata_manager
637            .catalog_controller
638            .abort_pending_sink_epochs(sink_committed_epoch)
639            .await?;
640
641        Ok(())
642    }
643
644    async fn purge_state_table_from_hummock(
645        &self,
646        all_state_table_ids: &HashSet<TableId>,
647    ) -> MetaResult<()> {
648        self.hummock_manager.purge(all_state_table_ids).await?;
649        Ok(())
650    }
651
652    async fn list_creating_jobs(
653        &self,
654        database_id: Option<DatabaseId>,
655    ) -> MetaResult<HashSet<JobId>> {
656        let mgr = &self.metadata_manager;
657        Ok(mgr
658            .catalog_controller
659            .list_creating_jobs(false, database_id)
660            .await?
661            .into_iter()
662            .map(|(job_id, _, _, _, _)| job_id)
663            .collect())
664    }
665
666    async fn load_recovery_context(
667        &self,
668        database_id: Option<DatabaseId>,
669    ) -> MetaResult<LoadedRecoveryContext> {
670        let inner = self
671            .metadata_manager
672            .catalog_controller
673            .get_inner_read_guard()
674            .await;
675        let txn = inner.db.begin().await?;
676
677        let fragment_context = self
678            .metadata_manager
679            .catalog_controller
680            .load_fragment_context_in_txn(&txn, database_id)
681            .await
682            .inspect_err(|err| {
683                warn!(error = %err.as_report(), "load fragment context failed");
684            })?;
685
686        if fragment_context.is_empty() {
687            return Ok(LoadedRecoveryContext::empty(fragment_context));
688        }
689
690        let job_ids = fragment_context.job_map.keys().copied().collect_vec();
691        let job_extra_info = self
692            .metadata_manager
693            .catalog_controller
694            .get_streaming_job_extra_info_in_txn(&txn, job_ids)
695            .await?;
696
697        let mut upstream_targets = HashMap::new();
698        for fragment in fragment_context
699            .job_fragments
700            .values()
701            .flat_map(|fragments| fragments.values())
702        {
703            let mut has_upstream_union = false;
704            visit_stream_node_cont(&fragment.nodes, |node| {
705                if let Some(PbNodeBody::UpstreamSinkUnion(_)) = node.node_body {
706                    has_upstream_union = true;
707                    false
708                } else {
709                    true
710                }
711            });
712
713            if has_upstream_union
714                && let Some(previous) =
715                    upstream_targets.insert(fragment.job_id, fragment.fragment_id)
716            {
717                bail!(
718                    "multiple upstream sink union fragments found for job {}, fragment {}, kept {}",
719                    fragment.job_id,
720                    fragment.fragment_id,
721                    previous
722                );
723            }
724        }
725
726        let mut upstream_sink_recovery = HashMap::new();
727        if !upstream_targets.is_empty() {
728            let tables = self
729                .metadata_manager
730                .catalog_controller
731                .get_user_created_table_by_ids_in_txn(&txn, upstream_targets.keys().copied())
732                .await?;
733
734            for table in tables {
735                let job_id = table.id.as_job_id();
736                let Some(target_fragment_id) = upstream_targets.get(&job_id) else {
737                    // This should not happen unless catalog changes or legacy metadata are involved.
738                    tracing::debug!(
739                        job_id = %job_id,
740                        "upstream sink union target fragment not found for table"
741                    );
742                    continue;
743                };
744
745                let upstream_infos = self
746                    .metadata_manager
747                    .catalog_controller
748                    .get_all_upstream_sink_infos_in_txn(&txn, &table, *target_fragment_id as _)
749                    .await?;
750
751                upstream_sink_recovery.insert(
752                    job_id,
753                    UpstreamSinkRecoveryInfo {
754                        target_fragment_id: *target_fragment_id,
755                        upstream_infos,
756                    },
757                );
758            }
759        }
760
761        let fragment_relations = self
762            .metadata_manager
763            .catalog_controller
764            .get_fragment_downstream_relations_in_txn(
765                &txn,
766                fragment_context
767                    .job_fragments
768                    .values()
769                    .flat_map(|fragments| fragments.keys().copied())
770                    .collect_vec(),
771            )
772            .await?;
773
774        Ok(LoadedRecoveryContext {
775            fragment_context,
776            job_extra_info,
777            upstream_sink_recovery,
778            fragment_relations,
779        })
780    }
781
782    #[expect(clippy::type_complexity)]
783    fn resolve_hummock_version_epochs(
784        jobs: impl Iterator<Item = (JobId, &HashMap<FragmentId, LoadedFragment>)>,
785        version: &HummockVersion,
786        table_change_log: &TableChangeLogs,
787    ) -> MetaResult<(
788        HashMap<TableId, u64>,
789        HashMap<TableId, Vec<(Vec<u64>, u64)>>,
790    )> {
791        let table_committed_epoch: HashMap<_, _> = version
792            .state_table_info
793            .info()
794            .iter()
795            .map(|(table_id, info)| (*table_id, info.committed_epoch))
796            .collect();
797        let get_table_committed_epoch = |table_id| -> anyhow::Result<u64> {
798            Ok(*table_committed_epoch
799                .get(&table_id)
800                .ok_or_else(|| anyhow!("cannot get committed epoch on table {}.", table_id))?)
801        };
802        let mut min_downstream_committed_epochs = HashMap::new();
803        for (job_id, fragments) in jobs {
804            let iceberg_v3 =
805                is_iceberg_v3_fragment_nodes(fragments.values().map(|fragment| &fragment.nodes));
806            let job_committed_epoch =
807                Self::resolve_job_committed_epoch(job_id, fragments, &table_committed_epoch)?;
808            if let (Some(snapshot_backfill_info), _) =
809                StreamFragmentGraph::collect_snapshot_backfill_info_impl(
810                    fragments
811                        .values()
812                        .map(|fragment| (&fragment.nodes, fragment.fragment_type_mask)),
813                )?
814            {
815                for (upstream_table, snapshot_epoch) in
816                    snapshot_backfill_info.upstream_mv_table_id_to_backfill_epoch
817                {
818                    let snapshot_epoch = snapshot_epoch.ok_or_else(|| {
819                        anyhow!(
820                            "recovered snapshot backfill job {} has not filled snapshot epoch to upstream {}",
821                            job_id, upstream_table
822                        )
823                    })?;
824                    let mut pinned_epoch = max(snapshot_epoch, job_committed_epoch);
825                    if iceberg_v3
826                        && let Some(log) = table_change_log.get(&upstream_table)
827                        && let Err(index) = log.binary_search_by_checkpoint_epoch(pinned_epoch)
828                        && let Some(previous) = index.checked_sub(1).and_then(|i| log.get(i))
829                    {
830                        // Compaction may commit between two upstream checkpoints.
831                        // Keep the preceding real checkpoint as the replay anchor.
832                        pinned_epoch = previous.checkpoint_epoch;
833                    }
834                    match min_downstream_committed_epochs.entry(upstream_table) {
835                        Entry::Occupied(entry) => {
836                            let prev_min_epoch = entry.into_mut();
837                            *prev_min_epoch = min(*prev_min_epoch, pinned_epoch);
838                        }
839                        Entry::Vacant(entry) => {
840                            entry.insert(pinned_epoch);
841                        }
842                    }
843                }
844            }
845        }
846        let mut log_epochs = HashMap::new();
847        for (upstream_table_id, downstream_committed_epoch) in min_downstream_committed_epochs {
848            let upstream_committed_epoch = get_table_committed_epoch(upstream_table_id)?;
849            match upstream_committed_epoch.cmp(&downstream_committed_epoch) {
850                Ordering::Less => {
851                    bail!(
852                        "downstream epoch {} later than upstream epoch {} of table {}",
853                        downstream_committed_epoch,
854                        upstream_committed_epoch,
855                        upstream_table_id
856                    );
857                }
858                Ordering::Equal => {
859                    continue;
860                }
861                Ordering::Greater => {
862                    if let Some(table_change_log) = table_change_log.get(&upstream_table_id) {
863                        // The downstream committed epoch may be synthetic and therefore fall
864                        // between two upstream checkpoints. Preserve the latest checkpoint group
865                        // at or before it so the job-specific recovery logic can decide whether
866                        // exact checkpoint alignment is required.
867                        //
868                        // For checkpoint epochs [100, 102], `binary_search` returns:
869                        // - `Err(0)` for 99: no preceding checkpoint exists;
870                        // - `Ok(0)` for 100: start from checkpoint 100;
871                        // - `Err(1)` for 101: start from the preceding checkpoint at index 0;
872                        // - `Ok(1)` for 102: start from checkpoint 102;
873                        // - `Err(2)` for 103: start from the preceding checkpoint at index 1.
874                        let first_checkpoint_index = match table_change_log
875                            .binary_search_by_checkpoint_epoch(downstream_committed_epoch)
876                        {
877                            Ok(index) => index,
878                            Err(0) => {
879                                bail!(
880                                    "no upstream checkpoint epoch at or before downstream committed epoch {} on table {}",
881                                    downstream_committed_epoch,
882                                    upstream_table_id
883                                );
884                            }
885                            Err(index) => index - 1,
886                        };
887                        let epochs = table_change_log
888                            .range(first_checkpoint_index..)
889                            .map(|epoch_log| {
890                                (
891                                    epoch_log.non_checkpoint_epochs.clone(),
892                                    epoch_log.checkpoint_epoch,
893                                )
894                            })
895                            .collect_vec();
896                        log_epochs
897                            .try_insert(upstream_table_id, epochs)
898                            .expect("non-duplicated");
899                    } else {
900                        bail!(
901                            "upstream table {} on epoch {} has lagged downstream on epoch {} but no table change log",
902                            upstream_table_id,
903                            upstream_committed_epoch,
904                            downstream_committed_epoch
905                        );
906                    }
907                }
908            }
909        }
910        Ok((table_committed_epoch, log_epochs))
911    }
912
913    pub(super) async fn reload_runtime_info_impl(
914        &self,
915    ) -> MetaResult<BarrierWorkerRuntimeInfoSnapshot> {
916        {
917            {
918                {
919                    self.clean_dirty_streaming_jobs(None)
920                        .await
921                        .context("clean dirty streaming jobs")?;
922
923                    self.abort_dirty_pending_sink_state(None)
924                        .await
925                        .context("abort dirty pending sink state")?;
926
927                    // We must abort dirty pending sink state before registering iceberg pk-index sinks,
928                    // otherwise recover_pending will take speculative (epoch > committed epoch) pending sink state
929                    // as valid and cause duplicated iceberg commit.
930                    self.reregister_iceberg_pk_index_sinks(None)
931                        .await
932                        .context("re-register iceberg v3 sinks after recovery")?;
933
934                    // Creating job progress needs to be recovered.
935                    tracing::info!("recovering creating job progress");
936                    let mut initial_creating_jobs = self
937                        .list_creating_jobs(None)
938                        .await
939                        .context("recover creating job progress should not fail")?;
940
941                    tracing::info!("recovered creating job progress");
942
943                    // This is a quick path to accelerate the process of dropping and canceling streaming jobs.
944                    let _ = self.apply_pre_applied_drop_cancel(None).await?;
945                    self.metadata_manager
946                        .catalog_controller
947                        .cleanup_dropped_tables()
948                        .await;
949                    self.refresh_manager.abandon_cycles(None).await?;
950
951                    let active_streaming_nodes =
952                        ActiveStreamingWorkerNodes::new_snapshot(self.metadata_manager.clone())
953                            .await?;
954
955                    let creating_streaming_jobs =
956                        initial_creating_jobs.iter().cloned().collect_vec();
957
958                    tracing::info!(
959                        "creating streaming jobs: {:?} total {}",
960                        creating_streaming_jobs,
961                        creating_streaming_jobs.len()
962                    );
963
964                    let unreschedulable_jobs = {
965                        let mut unreschedulable_jobs = HashSet::new();
966
967                        for job_id in creating_streaming_jobs {
968                            let scan_types = self
969                                .metadata_manager
970                                .get_job_backfill_scan_types(job_id)
971                                .await?;
972
973                            if scan_types
974                                .values()
975                                .any(|scan_type| !scan_type.is_reschedulable(false))
976                            {
977                                unreschedulable_jobs.insert(job_id);
978                            }
979                        }
980
981                        unreschedulable_jobs
982                    };
983
984                    if !unreschedulable_jobs.is_empty() {
985                        info!("unreschedulable creating jobs: {:?}", unreschedulable_jobs);
986                    }
987
988                    // Resolve actor info for recovery. If there's no actor to recover, most of the
989                    // following steps will be no-op, while the compute nodes will still be reset.
990                    // TODO(error-handling): attach context to the errors and log them together, instead of inspecting everywhere.
991                    if !unreschedulable_jobs.is_empty() {
992                        bail!(
993                            "Recovery for unreschedulable creating jobs is not yet implemented. \
994                             This path is triggered when the following jobs have at least one scan type that is not reschedulable: {:?}.",
995                            unreschedulable_jobs
996                        );
997                    }
998
999                    let mut recovery_context = self.load_recovery_context(None).await?;
1000                    if self.apply_pre_applied_drop_cancel(None).await? {
1001                        recovery_context = self.load_recovery_context(None).await?;
1002                    }
1003
1004                    self.purge_state_table_from_hummock(
1005                        &recovery_context
1006                            .fragment_context
1007                            .job_fragments
1008                            .values()
1009                            .flat_map(|fragments| fragments.values())
1010                            .flat_map(|fragment| fragment.state_table_ids.iter().copied())
1011                            .collect(),
1012                    )
1013                    .await
1014                    .context("purge state table from hummock")?;
1015
1016                    let (state_table_committed_epochs, state_table_log_epochs) = self
1017                        .hummock_manager
1018                        .on_current_version_and_table_change_log(|version, table_change_log| {
1019                            Self::resolve_hummock_version_epochs(
1020                                recovery_context
1021                                    .fragment_context
1022                                    .job_fragments
1023                                    .iter()
1024                                    .filter_map(|(job_id, job)| {
1025                                        (initial_creating_jobs.contains(job_id)
1026                                            || is_iceberg_v3_fragment_nodes(
1027                                                job.values().map(|fragment| &fragment.nodes),
1028                                            ))
1029                                        .then_some((*job_id, job))
1030                                    }),
1031                                version,
1032                                table_change_log,
1033                            )
1034                        })
1035                        .await?;
1036
1037                    self.finish_completed_independent_snapshot_jobs(
1038                        &recovery_context,
1039                        &state_table_committed_epochs,
1040                        &mut initial_creating_jobs,
1041                    )
1042                    .await?;
1043
1044                    let mv_depended_subscriptions = self
1045                        .metadata_manager
1046                        .get_mv_depended_subscriptions(None)
1047                        .await?;
1048
1049                    // Refresh creating job progress for the final snapshot to reflect any catalog changes.
1050                    let creating_jobs = {
1051                        let mut refreshed_creating_jobs = self
1052                            .list_creating_jobs(None)
1053                            .await
1054                            .context("recover creating job progress should not fail")?;
1055                        recovery_context
1056                            .fragment_context
1057                            .job_map
1058                            .keys()
1059                            .filter_map(|job_id| {
1060                                refreshed_creating_jobs.remove(job_id).then_some(*job_id)
1061                            })
1062                            .collect()
1063                    };
1064
1065                    let database_infos = self
1066                        .metadata_manager
1067                        .catalog_controller
1068                        .list_databases()
1069                        .await?;
1070
1071                    let cdc_table_snapshot_splits =
1072                        reload_cdc_table_snapshot_splits(&self.env.meta_store_ref().conn, None)
1073                            .await?;
1074
1075                    Ok(BarrierWorkerRuntimeInfoSnapshot {
1076                        active_streaming_nodes,
1077                        recovery_context,
1078                        state_table_committed_epochs,
1079                        state_table_log_epochs,
1080                        mv_depended_subscriptions,
1081                        creating_jobs,
1082                        hummock_version_stats: self.hummock_manager.get_version_stats().await,
1083                        database_infos,
1084                        cdc_table_snapshot_splits,
1085                    })
1086                }
1087            }
1088        }
1089    }
1090
1091    pub(super) async fn reload_database_runtime_info_impl(
1092        &self,
1093        database_id: DatabaseId,
1094    ) -> MetaResult<DatabaseRuntimeInfoSnapshot> {
1095        self.clean_dirty_streaming_jobs(Some(database_id))
1096            .await
1097            .context("clean dirty streaming jobs")?;
1098
1099        self.abort_dirty_pending_sink_state(Some(database_id))
1100            .await
1101            .context("abort dirty pending sink state")?;
1102        self.reregister_iceberg_pk_index_sinks(Some(database_id))
1103            .await
1104            .context("re-register iceberg v3 sinks after recovery")?;
1105
1106        // Creating job progress needs to be recovered.
1107        tracing::info!(?database_id, "recovering creating job progress of database");
1108
1109        let mut creating_jobs = self
1110            .list_creating_jobs(Some(database_id))
1111            .await
1112            .context("recover creating job progress of database should not fail")?;
1113        tracing::info!(?database_id, "recovered creating job progress");
1114
1115        // This is a quick path to accelerate the process of dropping and canceling streaming jobs.
1116        let _ = self
1117            .apply_pre_applied_drop_cancel(Some(database_id))
1118            .await?;
1119
1120        let recovery_context = self.load_recovery_context(Some(database_id)).await?;
1121
1122        let missing_creating_jobs = creating_jobs
1123            .iter()
1124            .filter(|job_id| {
1125                !recovery_context
1126                    .fragment_context
1127                    .job_map
1128                    .contains_key(*job_id)
1129            })
1130            .copied()
1131            .collect_vec();
1132        if !missing_creating_jobs.is_empty() {
1133            warn!(
1134                database_id = %database_id,
1135                missing_job_ids = ?missing_creating_jobs,
1136                "creating jobs missing in rendered info"
1137            );
1138        }
1139
1140        let (state_table_committed_epochs, state_table_log_epochs) = self
1141            .hummock_manager
1142            .on_current_version_and_table_change_log(|version, table_change_log| {
1143                Self::resolve_hummock_version_epochs(
1144                    recovery_context
1145                        .fragment_context
1146                        .job_fragments
1147                        .iter()
1148                        .filter_map(|(job_id, job)| {
1149                            (creating_jobs.contains(job_id)
1150                                || is_iceberg_v3_fragment_nodes(
1151                                    job.values().map(|fragment| &fragment.nodes),
1152                                ))
1153                            .then_some((*job_id, job))
1154                        }),
1155                    version,
1156                    table_change_log,
1157                )
1158            })
1159            .await?;
1160
1161        self.finish_completed_independent_snapshot_jobs(
1162            &recovery_context,
1163            &state_table_committed_epochs,
1164            &mut creating_jobs,
1165        )
1166        .await?;
1167
1168        let mv_depended_subscriptions = self
1169            .metadata_manager
1170            .get_mv_depended_subscriptions(Some(database_id))
1171            .await?;
1172
1173        let cdc_table_snapshot_splits =
1174            reload_cdc_table_snapshot_splits(&self.env.meta_store_ref().conn, Some(database_id))
1175                .await?;
1176
1177        self.refresh_manager
1178            .abandon_cycles(Some(database_id))
1179            .await?;
1180
1181        Ok(DatabaseRuntimeInfoSnapshot {
1182            recovery_context,
1183            state_table_committed_epochs,
1184            state_table_log_epochs,
1185            mv_depended_subscriptions,
1186            creating_jobs,
1187            cdc_table_snapshot_splits,
1188        })
1189    }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use std::collections::HashMap;
1195
1196    use risingwave_common::catalog::{FragmentTypeFlag, FragmentTypeMask};
1197    use risingwave_common::id::WorkerId;
1198    use risingwave_hummock_sdk::change_log::{EpochNewChangeLog, TableChangeLog};
1199    use risingwave_hummock_sdk::version::HummockVersionStateTableInfo;
1200    use risingwave_meta_model::DispatcherType;
1201    use risingwave_meta_model::fragment::DistributionType;
1202    use risingwave_pb::hummock::StateTableInfo;
1203    use risingwave_pb::stream_plan::stream_node::PbNodeBody;
1204    use risingwave_pb::stream_plan::{
1205        PbDispatchOutputMapping, PbStreamNode, StreamScanNode, StreamScanType,
1206        UpstreamSinkUnionNode as PbUpstreamSinkUnionNode,
1207    };
1208
1209    use super::*;
1210    use crate::controller::fragment::InflightActorInfo;
1211    use crate::model::DownstreamFragmentRelation;
1212    use crate::stream::UpstreamSinkInfo;
1213
1214    #[test]
1215    fn test_resolve_hummock_version_epochs_keeps_checkpoint_before_synthetic_epoch() {
1216        let job_id = JobId::new(10);
1217        let fragment_id = FragmentId::new(100);
1218        let upstream_table_id = TableId::new(200);
1219        let state_table_id = TableId::new(300);
1220        let mut fragments = HashMap::from([(
1221            fragment_id,
1222            LoadedFragment {
1223                fragment_id,
1224                job_id,
1225                fragment_type_mask: FragmentTypeMask::from(
1226                    FragmentTypeFlag::SnapshotBackfillStreamScan as i32,
1227                ),
1228                distribution_type: DistributionType::Hash,
1229                vnode_count: 1,
1230                nodes: PbStreamNode {
1231                    node_body: Some(PbNodeBody::StreamScan(Box::new(StreamScanNode {
1232                        table_id: upstream_table_id,
1233                        stream_scan_type: StreamScanType::SnapshotBackfill as i32,
1234                        snapshot_backfill_epoch: Some(100),
1235                        ..Default::default()
1236                    }))),
1237                    ..Default::default()
1238                },
1239                state_table_ids: HashSet::from([state_table_id]),
1240                parallelism: None,
1241            },
1242        )]);
1243
1244        let mut version = HummockVersion::default();
1245        version.state_table_info =
1246            HummockVersionStateTableInfo::from_protobuf_owned(HashMap::from([
1247                (
1248                    state_table_id,
1249                    StateTableInfo {
1250                        committed_epoch: 101,
1251                        compaction_group_id: 1.into(),
1252                    },
1253                ),
1254                (
1255                    upstream_table_id,
1256                    StateTableInfo {
1257                        committed_epoch: 102,
1258                        compaction_group_id: 1.into(),
1259                    },
1260                ),
1261            ]));
1262        let table_change_log = HashMap::from([(
1263            upstream_table_id,
1264            TableChangeLog::new([
1265                EpochNewChangeLog {
1266                    new_value: vec![],
1267                    old_value: vec![],
1268                    non_checkpoint_epochs: vec![],
1269                    checkpoint_epoch: 100,
1270                },
1271                EpochNewChangeLog {
1272                    new_value: vec![],
1273                    old_value: vec![],
1274                    non_checkpoint_epochs: vec![],
1275                    checkpoint_epoch: 102,
1276                },
1277            ]),
1278        )]);
1279
1280        let (_, log_epochs) = GlobalBarrierWorkerContextImpl::resolve_hummock_version_epochs(
1281            std::iter::once((job_id, &fragments)),
1282            &version,
1283            &table_change_log,
1284        )
1285        .unwrap();
1286
1287        assert_eq!(
1288            log_epochs.get(&upstream_table_id).unwrap(),
1289            &vec![(vec![], 100), (vec![], 102)]
1290        );
1291
1292        // A completed compaction can be just beyond the upstream's durable head.
1293        let fragment = fragments.get_mut(&fragment_id).unwrap();
1294        fragment.nodes = PbStreamNode {
1295            node_body: Some(PbNodeBody::IcebergWithPkIndexWriter(Default::default())),
1296            input: vec![std::mem::take(&mut fragment.nodes)],
1297            ..Default::default()
1298        };
1299        version.state_table_info =
1300            HummockVersionStateTableInfo::from_protobuf_owned(HashMap::from([
1301                (
1302                    state_table_id,
1303                    StateTableInfo {
1304                        committed_epoch: 101,
1305                        compaction_group_id: 1.into(),
1306                    },
1307                ),
1308                (
1309                    upstream_table_id,
1310                    StateTableInfo {
1311                        committed_epoch: 100,
1312                        compaction_group_id: 1.into(),
1313                    },
1314                ),
1315            ]));
1316        let (_, log_epochs) = GlobalBarrierWorkerContextImpl::resolve_hummock_version_epochs(
1317            std::iter::once((job_id, &fragments)),
1318            &version,
1319            &table_change_log,
1320        )
1321        .unwrap();
1322        assert!(!log_epochs.contains_key(&upstream_table_id));
1323
1324        // A missing real checkpoint is not permission to guess a synthetic replay anchor.
1325        for missing_logs in [
1326            HashMap::new(),
1327            HashMap::from([(upstream_table_id, TableChangeLog::new([]))]),
1328        ] {
1329            let error = GlobalBarrierWorkerContextImpl::resolve_hummock_version_epochs(
1330                std::iter::once((job_id, &fragments)),
1331                &version,
1332                &missing_logs,
1333            )
1334            .unwrap_err();
1335            assert!(
1336                error
1337                    .to_string()
1338                    .contains("downstream epoch 101 later than upstream epoch 100")
1339            );
1340        }
1341    }
1342
1343    #[test]
1344    fn test_recovery_table_with_upstream_sinks_updates_union_node() {
1345        let database_id = DatabaseId::new(1);
1346        let job_id = JobId::new(10);
1347        let fragment_id = FragmentId::new(100);
1348        let sink_fragment_id = FragmentId::new(200);
1349
1350        let mut inflight_jobs: FragmentRenderMap = HashMap::new();
1351        let fragment = InflightFragmentInfo {
1352            fragment_id,
1353            distribution_type: DistributionType::Hash,
1354            fragment_type_mask: FragmentTypeMask::empty(),
1355            vnode_count: 1,
1356            nodes: PbStreamNode {
1357                node_body: Some(PbNodeBody::UpstreamSinkUnion(Box::new(
1358                    PbUpstreamSinkUnionNode {
1359                        init_upstreams: vec![],
1360                    },
1361                ))),
1362                ..Default::default()
1363            },
1364            actors: HashMap::new(),
1365            state_table_ids: HashSet::new(),
1366        };
1367
1368        inflight_jobs
1369            .entry(database_id)
1370            .or_default()
1371            .entry(job_id)
1372            .or_default()
1373            .insert(fragment_id, fragment);
1374
1375        let upstream_sink_recovery = HashMap::from([(
1376            job_id,
1377            UpstreamSinkRecoveryInfo {
1378                target_fragment_id: fragment_id,
1379                upstream_infos: vec![UpstreamSinkInfo {
1380                    sink_id: SinkId::new(1),
1381                    sink_fragment_id,
1382                    sink_output_fields: vec![],
1383                    sink_original_target_columns: vec![],
1384                    project_exprs: vec![],
1385                    new_sink_downstream: DownstreamFragmentRelation {
1386                        downstream_fragment_id: FragmentId::new(300),
1387                        dispatcher_type: DispatcherType::Hash,
1388                        dist_key_indices: vec![],
1389                        output_mapping: PbDispatchOutputMapping::default(),
1390                    },
1391                }],
1392            },
1393        )]);
1394
1395        recovery_table_with_upstream_sinks(&mut inflight_jobs, &upstream_sink_recovery).unwrap();
1396
1397        let updated = inflight_jobs
1398            .get(&database_id)
1399            .unwrap()
1400            .get(&job_id)
1401            .unwrap()
1402            .get(&fragment_id)
1403            .unwrap();
1404
1405        let PbNodeBody::UpstreamSinkUnion(updated_union) =
1406            updated.nodes.node_body.as_ref().unwrap()
1407        else {
1408            panic!("expected upstream sink union node");
1409        };
1410
1411        assert_eq!(updated_union.init_upstreams.len(), 1);
1412        assert_eq!(
1413            updated_union.init_upstreams[0].upstream_fragment_id,
1414            sink_fragment_id.as_raw_id()
1415        );
1416    }
1417
1418    #[test]
1419    fn test_build_stream_actors_uses_preloaded_extra_info() {
1420        let database_id = DatabaseId::new(2);
1421        let job_id = JobId::new(20);
1422        let fragment_id = FragmentId::new(120);
1423        let actor_id = ActorId::new(500);
1424
1425        let mut inflight_jobs: FragmentRenderMap = HashMap::new();
1426        inflight_jobs
1427            .entry(database_id)
1428            .or_default()
1429            .entry(job_id)
1430            .or_default()
1431            .insert(
1432                fragment_id,
1433                InflightFragmentInfo {
1434                    fragment_id,
1435                    distribution_type: DistributionType::Hash,
1436                    fragment_type_mask: FragmentTypeMask::empty(),
1437                    vnode_count: 1,
1438                    nodes: PbStreamNode::default(),
1439                    actors: HashMap::from([(
1440                        actor_id,
1441                        InflightActorInfo {
1442                            worker_id: WorkerId::new(1),
1443                            vnode_bitmap: None,
1444                            splits: vec![],
1445                        },
1446                    )]),
1447                    state_table_ids: HashSet::new(),
1448                },
1449            );
1450
1451        let job_extra_info = HashMap::from([(
1452            job_id,
1453            StreamingJobExtraInfo {
1454                timezone: Some("UTC".to_owned()),
1455                config_override: "cfg".into(),
1456                job_definition: "definition".to_owned(),
1457                backfill_orders: None,
1458                refresh_interval_sec: None,
1459            },
1460        )]);
1461
1462        let stream_actors = build_stream_actors(&inflight_jobs, &job_extra_info).unwrap();
1463
1464        let actor = stream_actors.get(&actor_id).unwrap();
1465        assert_eq!(actor.actor_id, actor_id);
1466        assert_eq!(actor.fragment_id, fragment_id);
1467        assert_eq!(actor.mview_definition, "definition");
1468        assert_eq!(&*actor.config_override, "cfg");
1469        let expr_ctx = actor.expr_context.as_ref().unwrap();
1470        assert_eq!(expr_ctx.time_zone, "UTC");
1471    }
1472}