1use 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 pub batch_refresh: HashMap<JobId, BatchRefreshRenderResult>,
92 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 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 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 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
289fn 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
330fn 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 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 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 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 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 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 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 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 self.reregister_iceberg_pk_index_sinks(None)
931 .await
932 .context("re-register iceberg v3 sinks after recovery")?;
933
934 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 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 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 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 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 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 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 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}