Skip to main content

risingwave_meta/barrier/context/
context_impl.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::collections::{HashMap, HashSet};
16use std::sync::Arc;
17
18use futures::StreamExt;
19use futures::stream::FuturesUnordered;
20use risingwave_common::catalog::{DatabaseId, FragmentTypeFlag, TableId};
21use risingwave_common::id::{JobId, PartialGraphId, SinkId};
22use risingwave_hummock_sdk::change_log::TableChangeLogs;
23use risingwave_meta_model::ActorId;
24use risingwave_meta_model::streaming_job::BackfillOrders;
25use risingwave_pb::common::WorkerNode;
26use risingwave_pb::hummock::HummockVersionStats;
27use risingwave_pb::meta::PbTableRefillRuntimeConfig;
28use risingwave_pb::meta::subscribe_response::{Info, Operation};
29use risingwave_pb::stream_service::barrier_complete_response::{
30    PbListFinishedSource, PbLoadFinishedSource, PbRefreshFinishedActor,
31};
32use risingwave_rpc_client::StreamingControlHandle;
33use thiserror_ext::AsReport;
34
35use crate::barrier::cdc_progress::CdcTableBackfillTracker;
36use crate::barrier::checkpoint::independent_job::BatchRefreshJobTriggerContext;
37use crate::barrier::command::{
38    PostCollectCommand, ResumeBackfillTarget, SinceTimestampResolvedEpoch,
39};
40use crate::barrier::context::{GlobalBarrierWorkerContext, GlobalBarrierWorkerContextImpl};
41use crate::barrier::progress::TrackingJob;
42use crate::barrier::schedule::MarkReadyOptions;
43use crate::barrier::{
44    BarrierManagerStatus, BarrierWorkerRuntimeInfoSnapshot, CreateStreamingJobCommandInfo,
45    CreateStreamingJobType, DatabaseRuntimeInfoSnapshot, RecoveryReason, ReplaceStreamJobPlan,
46    Scheduled,
47};
48use crate::hummock::CommitEpochInfo;
49use crate::manager::iceberg_pk_index_sink::{
50    IcebergPkIndexPreCommitMetadata, build_iceberg_config, group_pre_commit_metadata,
51    is_iceberg_pk_index_sink,
52};
53use crate::manager::sink_coordination::{RecoveryStart, RecoverySucceeded};
54use crate::manager::{LocalNotification, StreamingJob};
55use crate::model::FragmentDownstreamRelation;
56use crate::serving::{fetch_serving_infos, sync_serving_table_vnode_mappings_to_hummock};
57use crate::stream::{RefreshStage, SourceChange, cleanup_dropped_streaming_jobs};
58use crate::{MetaError, MetaResult};
59
60fn resolve_since_timestamp_log_store_epoch(
61    table_id: TableId,
62    since_epoch: u64,
63    upstream_committed_epoch: u64,
64    table_change_log: &TableChangeLogs,
65) -> MetaResult<SinceTimestampResolvedEpoch> {
66    let change_log = table_change_log.get(&table_id).ok_or_else(|| {
67        anyhow::anyhow!(
68            "no table changelog found for upstream table {} when resolving since_timestamp",
69            table_id
70        )
71    })?;
72    let Some(first_log) = change_log.first() else {
73        return Err(anyhow::anyhow!(
74            "empty table changelog found for upstream table {} when resolving since_timestamp",
75            table_id
76        )
77        .into());
78    };
79    let first_checkpoint_epoch = first_log.checkpoint_epoch;
80    if since_epoch < first_checkpoint_epoch {
81        return Err(anyhow::anyhow!(
82            "since_timestamp is earlier than the retained changelog of upstream table {}: requested epoch {}, first retained checkpoint epoch {}",
83            table_id,
84            since_epoch,
85            first_checkpoint_epoch,
86        )
87        .into());
88    }
89    if since_epoch >= upstream_committed_epoch {
90        return Err(anyhow::anyhow!(
91            "since_timestamp is not before the committed epoch of upstream table {}: requested epoch {}, committed epoch {}",
92            table_id,
93            since_epoch,
94            upstream_committed_epoch,
95        )
96        .into());
97    }
98    let latest_log = change_log.last().expect("checked non-empty");
99    if upstream_committed_epoch != latest_log.checkpoint_epoch {
100        return Err(anyhow::anyhow!(
101            "upstream committed epoch {} does not match latest changelog epoch {} for upstream table {}",
102            upstream_committed_epoch,
103            latest_log.checkpoint_epoch,
104            table_id,
105        )
106        .into());
107    }
108
109    // `binary_search_by_checkpoint_epoch` searches only by the checkpoint epoch
110    // of each changelog entry. An entry may still cover earlier non-checkpoint
111    // epochs, e.g. `{ non_checkpoint_epochs: [30, 35], checkpoint_epoch: 40 }`
112    // covers `(20, 40]` if the previous checkpoint is 20. For `since_epoch =
113    // 35`, the search returns `Err(index_of_40)`, and 40 is the snapshot epoch.
114    // For `since_epoch = 40`, it returns `Ok(index_of_40)`. In both cases, the
115    // resolved snapshot epoch is the least checkpoint epoch >= `since_epoch`.
116    let snapshot_epoch_index = match change_log.binary_search_by_checkpoint_epoch(since_epoch) {
117        Ok(index) => index,
118        Err(index) => index,
119    };
120    let snapshot_epoch = change_log
121        .get(snapshot_epoch_index)
122        .ok_or_else(|| {
123            anyhow::anyhow!(
124                "since_timestamp is later than the latest changelog of upstream table {}: requested epoch {}, latest changelog epoch {}",
125                table_id,
126                since_epoch,
127                upstream_committed_epoch,
128            )
129        })?
130        .checkpoint_epoch;
131    // A request inside the latest changelog entry may be smaller than
132    // `upstream_committed_epoch`, but still resolve to the latest checkpoint.
133    // That end-of-log case is not a usable historical snapshot epoch.
134    if snapshot_epoch >= upstream_committed_epoch {
135        return Err(anyhow::anyhow!(
136            "since_timestamp is too new for upstream table {}: requested epoch {}, resolved snapshot epoch {}, latest changelog epoch {}",
137            table_id,
138            since_epoch,
139            snapshot_epoch,
140            upstream_committed_epoch,
141        )
142        .into());
143    }
144    let epochs = change_log
145        .range((snapshot_epoch_index + 1)..)
146        .map(|epoch_log| {
147            (
148                epoch_log.non_checkpoint_epochs.clone(),
149                epoch_log.checkpoint_epoch,
150            )
151        })
152        .collect::<Vec<_>>();
153
154    Ok((snapshot_epoch, epochs))
155}
156
157impl GlobalBarrierWorkerContext for GlobalBarrierWorkerContextImpl {
158    #[await_tree::instrument]
159    async fn commit_epoch(&self, commit_info: CommitEpochInfo) -> MetaResult<HummockVersionStats> {
160        self.hummock_manager.commit_epoch(commit_info).await?;
161        Ok(self.hummock_manager.get_version_stats().await)
162    }
163
164    #[await_tree::instrument("next_scheduled_barrier")]
165    async fn next_scheduled(&self) -> Scheduled {
166        self.scheduled_barriers.next_scheduled().await
167    }
168
169    async fn abort_and_mark_blocked(
170        &self,
171        recovery: RecoveryStart,
172        recovery_reason: &RecoveryReason,
173    ) -> MetaResult<()> {
174        let (database_id, database_job_ids) = match &recovery {
175            RecoveryStart::Global => (None, None),
176            RecoveryStart::Database {
177                database_id,
178                job_ids,
179            } => (Some(*database_id), Some(job_ids.clone())),
180        };
181        if database_id.is_none() {
182            self.set_status(BarrierManagerStatus::Recovering(recovery_reason.into()));
183        }
184
185        // Mark blocked and abort buffered schedules, they might be dirty already.
186        self.scheduled_barriers
187            .abort_and_mark_blocked(database_id, "cluster is under recovering");
188        self.iceberg_pk_index_sink_manager
189            .start_recovery(database_job_ids.as_ref());
190        self.sink_manager.start_recovery(recovery).await?;
191        Ok(())
192    }
193
194    async fn mark_ready(
195        &self,
196        options: MarkReadyOptions,
197        running_job_ids: HashSet<JobId>,
198    ) -> MetaResult<()> {
199        let recovery = match &options {
200            MarkReadyOptions::Database(database_id) => RecoverySucceeded::Database(*database_id),
201            MarkReadyOptions::Global { failed_databases } => RecoverySucceeded::Global {
202                failed_databases: failed_databases.clone(),
203            },
204        };
205        self.sink_manager.recovery_succeeded(recovery).await?;
206        let is_global = matches!(&options, MarkReadyOptions::Global { .. });
207        self.scheduled_barriers.mark_ready(options);
208        // Compaction reads settled coordinator state only after the queues are ready.
209        self.iceberg_pk_index_sink_manager
210            .on_jobs_ready(&running_job_ids)
211            .await;
212        if is_global {
213            self.set_status(BarrierManagerStatus::Running);
214        }
215        // The scheduler re-runs the cycles abandoned by the recovery.
216        self.refresh_manager.notify_scheduler();
217        Ok(())
218    }
219
220    async fn resolve_log_store_epoch<'a>(
221        &'a self,
222        upstream_table_ids: impl Iterator<Item = TableId> + Send + 'a,
223        since_epoch: u64,
224    ) -> MetaResult<SinceTimestampResolvedEpoch> {
225        let upstream_table_ids = upstream_table_ids.collect::<Vec<_>>();
226        if upstream_table_ids.is_empty() {
227            return Err(
228                anyhow::anyhow!("since_timestamp requires at least one upstream table").into(),
229            );
230        }
231
232        self.hummock_manager
233            .on_current_version_and_table_change_log(|version, table_change_log| {
234                let mut unified_log_epochs = None;
235                for &upstream_table_id in &upstream_table_ids {
236                    let upstream_committed_epoch = version
237                        .state_table_info
238                        .info()
239                        .get(&upstream_table_id)
240                        .map(|info| info.committed_epoch)
241                        .ok_or_else(|| {
242                            anyhow::anyhow!(
243                                "cannot get committed epoch for upstream table {}",
244                                upstream_table_id
245                            )
246                        })?;
247                    let table_log_epochs = resolve_since_timestamp_log_store_epoch(
248                        upstream_table_id,
249                        since_epoch,
250                        upstream_committed_epoch,
251                        table_change_log,
252                    )?;
253                    if let Some(unified_log_epochs) = &unified_log_epochs {
254                        if unified_log_epochs != &table_log_epochs {
255                            return Err(anyhow::anyhow!(
256                                "resolved since_timestamp log epochs for upstream table {} do not match previous upstream table log epochs: {:?} vs {:?}",
257                                upstream_table_id,
258                                table_log_epochs,
259                                unified_log_epochs,
260                            )
261                            .into());
262                        }
263                    } else {
264                        unified_log_epochs = Some(table_log_epochs);
265                    }
266                }
267                unified_log_epochs.ok_or_else(|| {
268                    anyhow::anyhow!("since_timestamp requires at least one upstream table").into()
269                })
270            })
271            .await
272    }
273
274    async fn refresh_table_refill_runtime_state_after_recovery(&self) -> MetaResult<()> {
275        let policies = self
276            .metadata_manager
277            .catalog_controller
278            .table_cache_refill_policies_snapshot()
279            .await?;
280        let (serving_workers, fragment_serving_infos) =
281            fetch_serving_infos(&self.metadata_manager).await?;
282
283        self.env.notification_manager().notify_hummock(
284            Operation::Update,
285            Info::TableRefillRuntimeConfig(PbTableRefillRuntimeConfig {
286                table_cache_refill_policies: Some(policies),
287                ..Default::default()
288            }),
289        );
290        sync_serving_table_vnode_mappings_to_hummock(
291            &self.env.notification_manager_ref(),
292            &self.serving_vnode_mapping,
293            &serving_workers,
294            &fragment_serving_infos,
295        )
296        .await;
297        Ok(())
298    }
299
300    #[await_tree::instrument("post_collect_command({command})")]
301    async fn post_collect_command(&self, command: PostCollectCommand) -> MetaResult<()> {
302        Box::pin(command.post_collect(self)).await
303    }
304
305    async fn notify_creating_job_failed(&self, database_id: Option<DatabaseId>, err: String) {
306        self.metadata_manager
307            .notify_finish_failed(database_id, err)
308            .await
309    }
310
311    #[await_tree::instrument("finish_creating_job({job})")]
312    async fn finish_creating_job(&self, job: TrackingJob) -> MetaResult<()> {
313        let job_id = job.job_id();
314        job.finish(&self.metadata_manager, &self.source_manager)
315            .await?;
316        self.env
317            .notification_manager()
318            .notify_local_subscribers(LocalNotification::StreamingJobBackfillFinished(job_id));
319        Ok(())
320    }
321
322    #[await_tree::instrument("finish_cdc_table_backfill({job})")]
323    async fn finish_cdc_table_backfill(&self, job: JobId) -> MetaResult<()> {
324        CdcTableBackfillTracker::mark_complete_job(&self.env.meta_store().conn, job).await
325    }
326
327    #[await_tree::instrument("new_control_stream({})", node.id)]
328    async fn new_control_stream(&self, node: &WorkerNode) -> MetaResult<StreamingControlHandle> {
329        self.new_control_stream_impl(node).await
330    }
331
332    async fn reload_runtime_info(&self) -> MetaResult<BarrierWorkerRuntimeInfoSnapshot> {
333        self.reload_runtime_info_impl().await
334    }
335
336    async fn reload_database_runtime_info(
337        &self,
338        database_id: DatabaseId,
339    ) -> MetaResult<DatabaseRuntimeInfoSnapshot> {
340        self.reload_database_runtime_info_impl(database_id).await
341    }
342
343    async fn handle_list_finished_source_ids(
344        &self,
345        list_finished: Vec<PbListFinishedSource>,
346    ) -> MetaResult<()> {
347        for (table_id, actors) in group_reporters(
348            list_finished
349                .into_iter()
350                .map(|report| (report.table_id, report.reporter_actor_id)),
351        ) {
352            self.refresh_manager
353                .report_stage(table_id, RefreshStage::List, actors)?;
354        }
355        Ok(())
356    }
357
358    async fn handle_load_finished_source_ids(
359        &self,
360        load_finished: Vec<PbLoadFinishedSource>,
361    ) -> MetaResult<()> {
362        for (table_id, actors) in group_reporters(
363            load_finished
364                .into_iter()
365                .map(|report| (report.table_id, report.reporter_actor_id)),
366        ) {
367            self.refresh_manager
368                .report_stage(table_id, RefreshStage::Fetch, actors)?;
369        }
370        Ok(())
371    }
372
373    async fn handle_refresh_finished_actors(
374        &self,
375        refresh_finished: Vec<PbRefreshFinishedActor>,
376    ) -> MetaResult<()> {
377        for (table_id, actors) in group_reporters(
378            refresh_finished
379                .into_iter()
380                .map(|report| (report.table_id, report.reporter_actor_id)),
381        ) {
382            self.refresh_manager
383                .report_stage(table_id, RefreshStage::Mview, actors)?;
384        }
385        Ok(())
386    }
387
388    async fn load_batch_refresh_trigger_context(
389        &self,
390        job_id: JobId,
391        database_id: DatabaseId,
392        last_committed_epoch: u64,
393    ) -> MetaResult<BatchRefreshJobTriggerContext> {
394        self.load_batch_refresh_trigger_context_impl(job_id, database_id, last_committed_epoch)
395            .await
396    }
397
398    #[await_tree::instrument]
399    async fn pre_commit_iceberg_pk_index_sink_metadata(
400        &self,
401        metadata: Vec<IcebergPkIndexPreCommitMetadata>,
402    ) -> MetaResult<Vec<SinkId>> {
403        let inputs = group_pre_commit_metadata(metadata)?;
404        let futs = FuturesUnordered::new();
405        for input in inputs {
406            let manager = &self.iceberg_pk_index_sink_manager;
407            futs.push(async move {
408                let sink_id = input.sink_id;
409                (sink_id, manager.pre_commit(input).await)
410            });
411        }
412
413        // Drain all futures regardless of individual failures, so that no coordinator is left with
414        // state inconsistent vs. the caller's view.
415        let results: Vec<(SinkId, anyhow::Result<()>)> = futs.collect().await;
416        let has_err = results.iter().any(|(_, result)| result.is_err());
417        if !has_err {
418            let success_ids = results.into_iter().map(|(id, _)| id).collect();
419            Ok(success_ids)
420        } else {
421            let errs = results
422                .into_iter()
423                .filter_map(|(id, result)| result.err().map(|error| (id, error)))
424                .collect();
425            Err(aggregate_sink_errors("pre-commit", errs).into())
426        }
427    }
428
429    #[await_tree::instrument]
430    async fn commit_iceberg_pk_index_sink_metadata(&self, sink_ids: Vec<SinkId>) -> MetaResult<()> {
431        let futs = FuturesUnordered::new();
432        for sink_id in sink_ids {
433            let manager = &self.iceberg_pk_index_sink_manager;
434            futs.push(async move { (sink_id, manager.commit_epoch(sink_id).await) });
435        }
436
437        let results: Vec<(SinkId, anyhow::Result<()>)> = futs.collect().await;
438        let errs: Vec<(SinkId, anyhow::Error)> = results
439            .into_iter()
440            .filter_map(|(id, r)| r.err().map(|e| (id, e)))
441            .collect();
442        if !errs.is_empty() {
443            return Err(aggregate_sink_errors("commit", errs).into());
444        }
445        Ok(())
446    }
447
448    fn advance_iceberg_pk_index_sink_committed_epochs(
449        &self,
450        epochs: impl IntoIterator<Item = (PartialGraphId, u64)>,
451    ) {
452        self.iceberg_pk_index_sink_manager
453            .advance_committed_epochs(epochs);
454    }
455}
456
457/// Combine per-sink errors from a fan-out into a single `anyhow::Error`. The first failing
458/// sink's error is used as the source so the original chain is preserved; the message lists
459/// every failing `sink_id` and its error stringified.
460fn aggregate_sink_errors(
461    phase: &'static str,
462    mut errs: Vec<(SinkId, anyhow::Error)>,
463) -> anyhow::Error {
464    debug_assert!(!errs.is_empty());
465    let sink_ids: Vec<String> = errs.iter().map(|(id, _)| id.to_string()).collect();
466    let details: Vec<String> = errs
467        .iter()
468        .map(|(id, e)| format!("sink {}: {}", id, e.as_report()))
469        .collect();
470    // Preserve the first error's chain as the cause.
471    let (_, first_err) = errs.remove(0);
472    first_err.context(format!(
473        "iceberg v3 sink {} failed for sink_id(s) [{}]: {}",
474        phase,
475        sink_ids.join(", "),
476        details.join("; ")
477    ))
478}
479
480impl GlobalBarrierWorkerContextImpl {
481    fn set_status(&self, new_status: BarrierManagerStatus) {
482        self.status.store(Arc::new(new_status));
483    }
484
485    /// Load the context metadata and resolve upstream log epochs for a batch refresh trigger.
486    async fn load_batch_refresh_trigger_context_impl(
487        &self,
488        job_id: JobId,
489        database_id: DatabaseId,
490        last_committed_epoch: u64,
491    ) -> MetaResult<BatchRefreshJobTriggerContext> {
492        use itertools::Itertools;
493        use sea_orm::TransactionTrait;
494
495        use crate::controller::scale::load_fragment_context_for_jobs;
496
497        // Load metadata from the catalog under a single transaction.
498        let inner = self
499            .metadata_manager
500            .catalog_controller
501            .get_inner_read_guard()
502            .await;
503        let txn = inner.db.begin().await?;
504
505        // 1. Load fragment context (job model, database model).
506        let fragment_context =
507            load_fragment_context_for_jobs(&txn, HashSet::from([job_id])).await?;
508
509        let streaming_job_model = fragment_context
510            .job_map
511            .get(&job_id)
512            .ok_or_else(|| anyhow::anyhow!("streaming job model not found for job {}", job_id))?
513            .clone();
514
515        let database_model = fragment_context
516            .database_map
517            .get(&database_id)
518            .ok_or_else(|| {
519                anyhow::anyhow!("database model not found for database {}", database_id)
520            })?;
521        let database_resource_group = database_model.resource_group.clone();
522
523        // 2. Load job definition.
524        let mut job_extra_info = self
525            .metadata_manager
526            .catalog_controller
527            .get_streaming_job_extra_info_in_txn(&txn, vec![job_id])
528            .await?;
529        let definition = job_extra_info
530            .remove(&job_id)
531            .ok_or_else(|| anyhow::anyhow!("extra info not found for job {}", job_id))?
532            .job_definition;
533
534        // 3. Get fragments from fragment_context and load downstream relations.
535        let fragments = fragment_context
536            .job_fragments
537            .get(&job_id)
538            .ok_or_else(|| anyhow::anyhow!("fragments not found for job {}", job_id))?
539            .clone();
540
541        // Derive upstream table IDs from the snapshot backfill scan nodes in the fragments.
542        let upstream_table_ids: HashSet<TableId> = {
543            use crate::stream::StreamFragmentGraph;
544            let snapshot_backfill_info = StreamFragmentGraph::collect_snapshot_backfill_info_impl(
545                fragments.values().map(|f| (&f.nodes, f.fragment_type_mask)),
546            )?
547            .0
548            .ok_or_else(|| {
549                anyhow::anyhow!("batch refresh job {} has no snapshot backfill info", job_id)
550            })?;
551            snapshot_backfill_info
552                .upstream_mv_table_id_to_backfill_epoch
553                .into_keys()
554                .collect()
555        };
556
557        let fragment_ids: Vec<_> = fragments.keys().copied().collect();
558        let downstreams = self
559            .metadata_manager
560            .catalog_controller
561            .get_fragment_downstream_relations_in_txn(&txn, fragment_ids)
562            .await?;
563
564        txn.commit().await?;
565        drop(inner);
566
567        // Resolve upstream log epochs from the hummock changelog.
568        let (upstream_table_log_epochs, target_upstream_epoch) = self
569            .hummock_manager
570            .on_current_version_and_table_change_log(|version, table_change_log| {
571                let mut target_upstream_epoch = last_committed_epoch;
572                let mut log_epochs: HashMap<TableId, Vec<(Vec<u64>, u64)>> = HashMap::new();
573
574                for &upstream_table_id in &upstream_table_ids {
575                    let upstream_committed_epoch = version
576                        .state_table_info
577                        .info()
578                        .get(&upstream_table_id)
579                        .map(|info| info.committed_epoch)
580                        .ok_or_else(|| {
581                            anyhow::anyhow!(
582                                "cannot get committed epoch for upstream table {}",
583                                upstream_table_id
584                            )
585                        })?;
586
587                    target_upstream_epoch =
588                        std::cmp::max(target_upstream_epoch, upstream_committed_epoch);
589
590                    if upstream_committed_epoch <= last_committed_epoch {
591                        continue;
592                    }
593
594                    if let Some(change_log) = table_change_log.get(&upstream_table_id) {
595                        let epochs = change_log
596                            .filter_epoch((last_committed_epoch, upstream_committed_epoch))
597                            .map(|epoch_log| {
598                                (
599                                    epoch_log.non_checkpoint_epochs.clone(),
600                                    epoch_log.checkpoint_epoch,
601                                )
602                            })
603                            .collect_vec();
604                        if !epochs.is_empty() {
605                            log_epochs.insert(upstream_table_id, epochs);
606                        }
607                    } else {
608                        anyhow::bail!(
609                            "upstream table {} has lagged downstream on epoch {} but no table change log (upstream committed: {})",
610                            upstream_table_id,
611                            last_committed_epoch,
612                            upstream_committed_epoch,
613                        );
614                    }
615                }
616
617                Ok((log_epochs, target_upstream_epoch))
618            })
619            .await?;
620
621        Ok(BatchRefreshJobTriggerContext {
622            fragments,
623            downstreams,
624            streaming_job_model,
625            definition,
626            database_resource_group,
627            upstream_table_log_epochs,
628            target_upstream_epoch,
629        })
630    }
631}
632
633impl PostCollectCommand {
634    /// Do some stuffs after barriers are collected and the new storage version is committed, for
635    /// the given command.
636    pub async fn post_collect(
637        self,
638        barrier_manager_context: &GlobalBarrierWorkerContextImpl,
639    ) -> MetaResult<()> {
640        match self {
641            PostCollectCommand::Command(_) => {}
642            PostCollectCommand::SourceChangeSplit {
643                split_assignment: assignment,
644            } => {
645                barrier_manager_context
646                    .metadata_manager
647                    .update_fragment_splits(&assignment)
648                    .await?;
649            }
650
651            PostCollectCommand::DropStreamingJobs => {}
652            PostCollectCommand::RefreshStarted {
653                table_id,
654                database_id,
655                associated_source_id,
656                staging_table_id,
657                trigger_time,
658                actors,
659            } => {
660                barrier_manager_context
661                    .refresh_manager
662                    .cycle_started(
663                        table_id,
664                        database_id,
665                        associated_source_id,
666                        staging_table_id,
667                        trigger_time,
668                        actors,
669                    )
670                    .await?;
671            }
672            PostCollectCommand::FinishRefresh {
673                table_id,
674                trigger_time,
675                ..
676            } => {
677                barrier_manager_context
678                    .refresh_manager
679                    .complete_refresh(table_id, trigger_time)
680                    .await?;
681            }
682            PostCollectCommand::ConnectorPropsChange(obj_id_map_props) => {
683                // PK-index maintenance runs in Meta, not in the ordinary sink executor.
684                // Refresh it explicitly so an idle sink also observes ALTER immediately.
685                for object_id in obj_id_map_props.keys() {
686                    if let Some(sink) = barrier_manager_context
687                        .metadata_manager
688                        .catalog_controller
689                        .get_sink_by_id(object_id.as_sink_id())
690                        .await?
691                        && is_iceberg_pk_index_sink(&sink.properties)
692                    {
693                        let config = build_iceberg_config(&sink)?;
694                        barrier_manager_context
695                            .iceberg_pk_index_sink_manager
696                            .on_sink_config_changed(sink.id, config);
697                    }
698                }
699                // todo: we dont know the type of the object id, it can be a source or a sink. Should carry more info in the barrier command.
700                barrier_manager_context
701                    .source_manager
702                    .apply_source_change(SourceChange::UpdateSourceProps {
703                        // Only sources are managed in source manager. Convert object IDs to source IDs and let
704                        // source manager ignore unknown/unregistered sources.
705                        source_id_map_new_props: obj_id_map_props
706                            .iter()
707                            .map(|(object_id, props)| (object_id.as_source_id(), props.clone()))
708                            .collect(),
709                    })
710                    .await;
711            }
712            PostCollectCommand::ResumeBackfill { target } => match target {
713                ResumeBackfillTarget::Job(job_id) => {
714                    barrier_manager_context
715                        .metadata_manager
716                        .catalog_controller
717                        .update_backfill_orders_by_job_id(job_id, None)
718                        .await?;
719                }
720                ResumeBackfillTarget::Fragment(fragment_id) => {
721                    let mut job_ids = barrier_manager_context
722                        .metadata_manager
723                        .catalog_controller
724                        .get_fragment_job_id(vec![fragment_id])
725                        .await?;
726                    let job_id = job_ids.pop().ok_or_else(|| {
727                        MetaError::invalid_parameter("fragment not found".to_owned())
728                    })?;
729                    let job_id = JobId::new(job_id.as_raw_id());
730
731                    let extra_info = barrier_manager_context
732                        .metadata_manager
733                        .catalog_controller
734                        .get_streaming_job_extra_info(vec![job_id])
735                        .await?;
736                    let mut backfill_orders: BackfillOrders = extra_info
737                        .get(&job_id)
738                        .cloned()
739                        .ok_or_else(|| MetaError::invalid_parameter("job not found".to_owned()))?
740                        .backfill_orders
741                        .unwrap_or_default();
742
743                    let resumed_fragment_id = fragment_id.as_raw_id();
744                    for children in backfill_orders.0.values_mut() {
745                        children.retain(|child| *child != resumed_fragment_id);
746                    }
747                    backfill_orders.0.retain(|_, children| !children.is_empty());
748
749                    barrier_manager_context
750                        .metadata_manager
751                        .catalog_controller
752                        .update_backfill_orders_by_job_id(job_id, Some(backfill_orders))
753                        .await?;
754                }
755            },
756            PostCollectCommand::CreateStreamingJob {
757                info,
758                job_type,
759                cross_db_snapshot_backfill_info,
760                resolved_split_assignment,
761            } => {
762                match &job_type {
763                    CreateStreamingJobType::SinkIntoTable(_) | CreateStreamingJobType::Normal => {
764                        barrier_manager_context
765                            .metadata_manager
766                            .catalog_controller
767                            .fill_snapshot_backfill_epoch(
768                                info.stream_job_fragments.fragments.iter().filter_map(
769                                    |(fragment_id, fragment)| {
770                                        if fragment.fragment_type_mask.contains(
771                                            FragmentTypeFlag::CrossDbSnapshotBackfillStreamScan,
772                                        ) {
773                                            Some(*fragment_id as _)
774                                        } else {
775                                            None
776                                        }
777                                    },
778                                ),
779                                None,
780                                &cross_db_snapshot_backfill_info,
781                            )
782                            .await?
783                    }
784                    CreateStreamingJobType::Independent {
785                        snapshot_backfill_info,
786                        ..
787                    } => {
788                        barrier_manager_context
789                            .metadata_manager
790                            .catalog_controller
791                            .fill_snapshot_backfill_epoch(
792                                info.stream_job_fragments.fragments.iter().filter_map(
793                                    |(fragment_id, fragment)| {
794                                        if fragment.fragment_type_mask.contains_any([
795                                            FragmentTypeFlag::SnapshotBackfillStreamScan,
796                                            FragmentTypeFlag::CrossDbSnapshotBackfillStreamScan,
797                                        ]) {
798                                            Some(*fragment_id as _)
799                                        } else {
800                                            None
801                                        }
802                                    },
803                                ),
804                                Some(snapshot_backfill_info),
805                                &cross_db_snapshot_backfill_info,
806                            )
807                            .await?
808                    }
809                }
810
811                // Do `post_collect_job_fragments` of the original streaming job in the end, so that in any previous failure,
812                // we won't mark the job as `Creating`, and then the job will be later clean by the recovery triggered by the returned error.
813                let CreateStreamingJobCommandInfo {
814                    stream_job_fragments,
815                    upstream_fragment_downstreams,
816                    replace_sink,
817                    streaming_job,
818                    ..
819                } = info;
820                let new_job_id = stream_job_fragments.stream_job_id();
821                let new_sink_downstream =
822                    if let CreateStreamingJobType::SinkIntoTable(ctx) = job_type {
823                        let new_downstreams = ctx.new_sink_downstream.clone();
824                        let new_downstreams = FragmentDownstreamRelation::from([(
825                            ctx.sink_fragment_id,
826                            vec![new_downstreams],
827                        )]);
828                        Some(new_downstreams)
829                    } else {
830                        None
831                    };
832
833                let old_state_table_ids = barrier_manager_context
834                    .metadata_manager
835                    .catalog_controller
836                    .post_collect_job_fragments(
837                        new_job_id,
838                        &upstream_fragment_downstreams,
839                        new_sink_downstream,
840                        Some(&resolved_split_assignment),
841                        replace_sink.as_ref(),
842                        replace_sink.is_none(),
843                    )
844                    .await?;
845
846                let added_source_fragments = stream_job_fragments.stream_source_fragments();
847                let added_backfill_fragments = stream_job_fragments.source_backfill_fragments();
848                // Skip the source-manager notification (and its `core` lock) when the job has no source/backfill fragments.
849                if !added_source_fragments.is_empty() || !added_backfill_fragments.is_empty() {
850                    barrier_manager_context
851                        .source_manager
852                        .apply_source_change(SourceChange::CreateJob {
853                            added_source_fragments,
854                            added_backfill_fragments,
855                        })
856                        .await;
857                }
858
859                // The coordinator is registered before CREATE is submitted, but its
860                // compaction results cannot be applied until the independent job is running.
861                if let StreamingJob::Sink(sink, _) = &streaming_job
862                    && is_iceberg_pk_index_sink(&sink.properties)
863                {
864                    barrier_manager_context
865                        .iceberg_pk_index_sink_manager
866                        .on_jobs_ready(&HashSet::from([sink.id.as_job_id()]))
867                        .await;
868                }
869
870                if let Some(old_sink_id) = replace_sink {
871                    cleanup_dropped_streaming_jobs(
872                        &barrier_manager_context.refresh_manager,
873                        &barrier_manager_context.hummock_manager,
874                        &barrier_manager_context.metadata_manager,
875                        &barrier_manager_context.sink_manager,
876                        &barrier_manager_context.iceberg_pk_index_sink_manager,
877                        [old_sink_id.as_job_id()],
878                        old_state_table_ids.expect("replace sink should return old state tables"),
879                        "replace_sink",
880                    )
881                    .await?;
882                }
883            }
884            PostCollectCommand::Reschedule { reschedules, .. } => {
885                let fragment_splits = reschedules
886                    .iter()
887                    .map(|(fragment_id, reschedule)| {
888                        (*fragment_id, reschedule.actor_splits.clone())
889                    })
890                    .collect();
891
892                barrier_manager_context
893                    .metadata_manager
894                    .update_fragment_splits(&fragment_splits)
895                    .await?;
896            }
897
898            PostCollectCommand::ReplaceStreamJob {
899                plan: replace_plan,
900                resolved_split_assignment,
901            } => {
902                let ReplaceStreamJobPlan {
903                    old_fragments,
904                    new_fragments,
905                    upstream_fragment_downstreams,
906                    to_drop_state_table_ids,
907                    auto_refresh_schema_sinks,
908                    ..
909                } = &replace_plan;
910                // Update actors and actor_dispatchers for new table fragments.
911                barrier_manager_context
912                    .metadata_manager
913                    .catalog_controller
914                    .post_collect_job_fragments(
915                        new_fragments.stream_job_id,
916                        upstream_fragment_downstreams,
917                        None,
918                        Some(&resolved_split_assignment),
919                        None,
920                        false,
921                    )
922                    .await?;
923
924                if let Some(sinks) = auto_refresh_schema_sinks {
925                    for sink in sinks {
926                        barrier_manager_context
927                            .metadata_manager
928                            .catalog_controller
929                            .post_collect_job_fragments(
930                                sink.tmp_sink_id.as_job_id(),
931                                &Default::default(), // upstream_fragment_downstreams is already inserted in the job of upstream table
932                                None, // no replace plan
933                                None, // no init split assignment
934                                None,
935                                false,
936                            )
937                            .await?;
938                    }
939                }
940
941                // Apply the split changes in source manager.
942                barrier_manager_context
943                    .source_manager
944                    .handle_replace_job(
945                        old_fragments,
946                        new_fragments.stream_source_fragments(),
947                        &replace_plan,
948                    )
949                    .await;
950                cleanup_dropped_streaming_jobs(
951                    &barrier_manager_context.refresh_manager,
952                    &barrier_manager_context.hummock_manager,
953                    &barrier_manager_context.metadata_manager,
954                    &barrier_manager_context.sink_manager,
955                    &barrier_manager_context.iceberg_pk_index_sink_manager,
956                    [],
957                    to_drop_state_table_ids.clone(),
958                    "replace_streaming_job",
959                )
960                .await?;
961            }
962
963            PostCollectCommand::CreateSubscription { subscription_id } => {
964                barrier_manager_context
965                    .metadata_manager
966                    .catalog_controller
967                    .finish_create_subscription_catalog(subscription_id)
968                    .await?
969            }
970        }
971
972        Ok(())
973    }
974}
975
976fn group_reporters(
977    reports: impl Iterator<Item = (TableId, ActorId)>,
978) -> HashMap<TableId, HashSet<ActorId>> {
979    let mut grouped: HashMap<TableId, HashSet<ActorId>> = HashMap::new();
980    for (table_id, actor_id) in reports {
981        grouped.entry(table_id).or_default().insert(actor_id);
982    }
983    grouped
984}
985
986#[cfg(test)]
987mod tests {
988    use risingwave_hummock_sdk::change_log::{EpochNewChangeLog, TableChangeLog};
989
990    use super::*;
991
992    fn test_change_logs(table_id: TableId) -> TableChangeLogs {
993        TableChangeLogs::from_iter([(
994            table_id,
995            TableChangeLog::new([
996                EpochNewChangeLog {
997                    new_value: vec![],
998                    old_value: vec![],
999                    non_checkpoint_epochs: vec![10],
1000                    checkpoint_epoch: 20,
1001                },
1002                EpochNewChangeLog {
1003                    new_value: vec![],
1004                    old_value: vec![],
1005                    non_checkpoint_epochs: vec![30],
1006                    checkpoint_epoch: 40,
1007                },
1008                EpochNewChangeLog {
1009                    new_value: vec![],
1010                    old_value: vec![],
1011                    non_checkpoint_epochs: vec![50],
1012                    checkpoint_epoch: 60,
1013                },
1014                EpochNewChangeLog {
1015                    new_value: vec![],
1016                    old_value: vec![],
1017                    non_checkpoint_epochs: vec![],
1018                    checkpoint_epoch: 80,
1019                },
1020            ]),
1021        )])
1022    }
1023
1024    #[test]
1025    fn test_resolve_since_timestamp_log_store_epoch() {
1026        let table_id = TableId::new(233);
1027        let change_logs = test_change_logs(table_id);
1028
1029        let resolved =
1030            resolve_since_timestamp_log_store_epoch(table_id, 45, 80, &change_logs).unwrap();
1031
1032        assert_eq!(resolved, (60, vec![(vec![], 80)]));
1033    }
1034
1035    #[test]
1036    fn test_resolve_since_timestamp_log_store_epoch_rejects_invalid_range() {
1037        let table_id = TableId::new(233);
1038        let change_logs = test_change_logs(table_id);
1039
1040        let err = resolve_since_timestamp_log_store_epoch(table_id, 9, 80, &change_logs)
1041            .unwrap_err()
1042            .to_string();
1043        assert!(err.contains("earlier than the retained changelog"));
1044
1045        let err = resolve_since_timestamp_log_store_epoch(table_id, 80, 80, &change_logs)
1046            .unwrap_err()
1047            .to_string();
1048        assert!(err.contains("is not before the committed epoch"));
1049
1050        let err = resolve_since_timestamp_log_store_epoch(table_id, 45, 60, &change_logs)
1051            .unwrap_err()
1052            .to_string();
1053        assert!(err.contains("does not match latest changelog epoch"));
1054    }
1055
1056    #[test]
1057    fn test_resolve_since_timestamp_log_store_epoch_rejects_empty_or_missing_log() {
1058        let table_id = TableId::new(233);
1059        let empty_change_logs = TableChangeLogs::from_iter([(table_id, TableChangeLog::new([]))]);
1060
1061        let err = resolve_since_timestamp_log_store_epoch(table_id, 30, 60, &empty_change_logs)
1062            .unwrap_err()
1063            .to_string();
1064        assert!(err.contains("empty table changelog"));
1065
1066        let err = resolve_since_timestamp_log_store_epoch(table_id, 30, 60, &Default::default())
1067            .unwrap_err()
1068            .to_string();
1069        assert!(err.contains("no table changelog"));
1070    }
1071
1072    #[test]
1073    fn test_resolve_since_timestamp_log_store_epoch_keeps_latest_non_checkpoint_epochs() {
1074        let table_id = TableId::new(233);
1075        let change_logs = TableChangeLogs::from_iter([(
1076            table_id,
1077            TableChangeLog::new([
1078                EpochNewChangeLog {
1079                    new_value: vec![],
1080                    old_value: vec![],
1081                    non_checkpoint_epochs: vec![],
1082                    checkpoint_epoch: 20,
1083                },
1084                EpochNewChangeLog {
1085                    new_value: vec![],
1086                    old_value: vec![],
1087                    non_checkpoint_epochs: vec![30],
1088                    checkpoint_epoch: 40,
1089                },
1090            ]),
1091        )]);
1092
1093        let resolved =
1094            resolve_since_timestamp_log_store_epoch(table_id, 20, 40, &change_logs).unwrap();
1095        assert_eq!(resolved, (20, vec![(vec![30], 40)]));
1096
1097        let err = resolve_since_timestamp_log_store_epoch(table_id, 30, 40, &change_logs)
1098            .unwrap_err()
1099            .to_string();
1100        assert!(err.contains("since_timestamp is too new"));
1101    }
1102
1103    #[test]
1104    fn test_skip_refresh_finish_when_associated_source_missing() {
1105        let err = MetaError::catalog_id_not_found("object", 42);
1106        assert!(err.is_catalog_id_not_found("object"));
1107    }
1108
1109    #[test]
1110    fn test_do_not_skip_refresh_finish_for_other_not_found_types() {
1111        let err = MetaError::catalog_id_not_found("table", 42);
1112        assert!(!err.is_catalog_id_not_found("object"));
1113    }
1114}