Skip to main content

risingwave_meta/controller/
streaming_job.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::{BTreeMap, HashMap, HashSet};
16use std::num::NonZeroUsize;
17
18use anyhow::anyhow;
19use indexmap::IndexMap;
20use itertools::Itertools;
21use risingwave_common::catalog::{
22    ColumnCatalog, FragmentTypeFlag, FragmentTypeMask, ICEBERG_SINK_PREFIX, ICEBERG_SOURCE_PREFIX,
23    RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME, max_column_id,
24};
25use risingwave_common::config::DefaultParallelism;
26use risingwave_common::hash::VnodeCountCompat;
27use risingwave_common::id::JobId;
28use risingwave_common::secret::LocalSecretManager;
29use risingwave_common::system_param::AdaptiveParallelismStrategy;
30use risingwave_common::system_param::adaptive_parallelism_strategy::parse_strategy;
31use risingwave_common::util::iter_util::ZipEqDebug;
32use risingwave_common::util::stream_graph_visitor::{
33    visit_stream_node_body, visit_stream_node_mut, visit_stream_node_stream_scan,
34};
35use risingwave_common::{bail, current_cluster_version};
36use risingwave_connector::allow_alter_on_fly_fields::check_sink_allow_alter_on_fly_fields;
37use risingwave_connector::connector_common::validate_connection;
38use risingwave_connector::error::ConnectorError;
39use risingwave_connector::sink::file_sink::fs::FsSink;
40use risingwave_connector::sink::{CONNECTOR_TYPE_KEY, SinkError};
41use risingwave_connector::source::cdc::{CdcSourceDdlValidator, CdcSourceType};
42use risingwave_connector::source::{
43    ConnectorProperties, UPSTREAM_SOURCE_KEY, pb_connection_type_to_connection_type,
44};
45use risingwave_connector::{
46    WithOptionsSecResolved, WithPropertiesExt, dispatch_cdc_source_type, match_sink_name_str,
47};
48use risingwave_meta_model::object::ObjectType;
49use risingwave_meta_model::prelude::{StreamingJob as StreamingJobModel, *};
50use risingwave_meta_model::refresh_job::RefreshState;
51use risingwave_meta_model::streaming_job::BackfillOrders;
52use risingwave_meta_model::user_privilege::Action;
53use risingwave_meta_model::*;
54use risingwave_pb::catalog::table::PbEngine;
55use risingwave_pb::catalog::{PbConnection, PbCreateType, PbTable};
56use risingwave_pb::common::ThrottleType;
57use risingwave_pb::ddl_service::streaming_job_resource_type;
58use risingwave_pb::meta::alter_connector_props_request::AlterIcebergTableIds;
59use risingwave_pb::meta::list_rate_limits_response::RateLimitInfo;
60use risingwave_pb::meta::object::PbObjectInfo;
61use risingwave_pb::meta::subscribe_response::{
62    Info as NotificationInfo, Operation as NotificationOperation, Operation,
63};
64use risingwave_pb::meta::{ObjectDependency as PbObjectDependency, PbObject, PbObjectGroup};
65use risingwave_pb::plan_common::PbColumnCatalog;
66use risingwave_pb::plan_common::source_refresh_mode::{
67    RefreshMode, SourceRefreshModeFullReload, SourceRefreshModeStreaming,
68};
69use risingwave_pb::secret::PbSecretRef;
70use risingwave_pb::stream_plan::stream_fragment_graph::Parallelism;
71use risingwave_pb::stream_plan::stream_node::PbNodeBody;
72use risingwave_pb::stream_plan::{PbSinkLogStoreType, PbStreamNode, StreamScanType};
73use risingwave_pb::user::PbUserInfo;
74use risingwave_sqlparser::ast::{Engine, SqlOption, Statement};
75use risingwave_sqlparser::parser::{Parser, ParserError};
76use sea_orm::ActiveValue::Set;
77use sea_orm::sea_query::{Expr, Query, SimpleExpr};
78use sea_orm::{
79    ActiveModelTrait, ColumnTrait, Condition, DatabaseTransaction, EntityTrait, IntoActiveModel,
80    JoinType, NotSet, PaginatorTrait, QueryFilter, QuerySelect, RelationTrait, TransactionTrait,
81};
82use thiserror_ext::AsReport;
83
84use super::rename::IndexItemRewriter;
85use crate::barrier::Command;
86use crate::controller::ObjectModel;
87use crate::controller::catalog::{
88    CatalogController, DropTableConnectorContext, load_object_models,
89};
90use crate::controller::fragment::FragmentTypeMaskExt;
91use crate::controller::utils::{
92    PartialObject, build_object_group_for_delete, check_if_belongs_to_iceberg_table,
93    check_relation_name_duplicate, check_sink_into_table_cycle, ensure_job_not_canceled,
94    ensure_object_id, ensure_user_id, fetch_target_fragments, format_with_option_secret_resolved,
95    get_belong_objects, get_belong_objects_by_ids, get_referring_objects, get_table_columns,
96    grant_default_privileges_automatically, insert_fragment_relations,
97    list_object_dependencies_by_object_id, list_user_info_by_ids, upsert_user_privileges,
98};
99use crate::error::MetaErrorInner;
100use crate::manager::{NotificationVersion, StreamingJob, StreamingJobType};
101use crate::model::{
102    FragmentDownstreamRelation, FragmentReplaceUpstream, StreamContext, StreamJobFragments,
103    StreamJobFragmentsToCreate,
104};
105use crate::stream::SplitAssignment;
106use crate::{MetaError, MetaResult};
107
108/// Result of [`CatalogController::try_abort_creating_streaming_job`].
109pub struct AbortCreatingJobResult {
110    /// The job was aborted by this call or already gone. `false` means the job was left untouched.
111    pub aborted: bool,
112    /// The database of the job, if the job was found.
113    pub database_id: Option<DatabaseId>,
114    /// The aborted sinks; the caller should retire their maintenance state and stop their
115    /// coordinators, using only sinks actually removed.
116    pub aborted_sink_ids: Vec<SinkId>,
117    /// Runtime and state cleanup information captured before deleting the catalog rows.
118    /// This is returned only when handling an explicit cancellation.
119    pub cancel_info: Option<CancelStreamingJobInfo>,
120}
121
122/// Runtime and state cleanup information for canceling a creating streaming job.
123pub struct CancelStreamingJobInfo {
124    /// Barrier command that stops every affected streaming job.
125    pub command: Command,
126    /// Streaming jobs included in `command`.
127    pub streaming_job_ids: Vec<JobId>,
128    /// State tables to unregister after the barrier is collected.
129    pub state_table_ids: Vec<TableId>,
130}
131
132#[derive(Debug)]
133pub struct CommittedEpochTruncateJob {
134    pub job_id: JobId,
135    pub state_table_ids: HashSet<TableId>,
136    pub upstream_table_snapshot_epochs: HashMap<TableId, Option<u64>>,
137}
138
139#[derive(Debug)]
140pub struct TableChangeLogTruncateInfo {
141    pub subscription_retention_seconds: HashMap<TableId, u64>,
142    pub progress_table_subscribers: Vec<ProgressTableChangeLogSubscriber>,
143    pub committed_epoch_truncate_jobs: Vec<CommittedEpochTruncateJob>,
144}
145
146#[derive(Debug)]
147pub struct ProgressTableChangeLogSubscriber {
148    pub subscriber_job_id: JobId,
149    pub upstream_table_id: TableId,
150    pub progress_table_id: TableId,
151}
152
153fn serverless_backfill_resource_group_placeholder(job_id: JobId) -> String {
154    format!("SERVERLESS_BACKFILL_RESOURCE_GROUP_TBD_FOR_{job_id}")
155}
156
157fn job_initial_catalog_resource_group(
158    resource_type: &streaming_job_resource_type::ResourceType,
159    job_id: JobId,
160) -> Option<String> {
161    match resource_type {
162        streaming_job_resource_type::ResourceType::Regular(_)
163        | streaming_job_resource_type::ResourceType::ServerlessBackfill(false) => None,
164        streaming_job_resource_type::ResourceType::SpecificResourceGroup(group) => {
165            Some(group.clone())
166        }
167        streaming_job_resource_type::ResourceType::ServerlessBackfill(true) => {
168            Some(serverless_backfill_resource_group_placeholder(job_id))
169        }
170    }
171}
172
173/// A planned fragment update for dependent sources when a connection's properties change.
174///
175/// We keep it as a named struct (instead of a tuple) for readability and to avoid clippy
176/// `type_complexity` warnings.
177#[derive(Debug)]
178struct DependentSourceFragmentUpdate {
179    job_ids: Vec<JobId>,
180    with_properties: BTreeMap<String, String>,
181    secret_refs: BTreeMap<String, PbSecretRef>,
182    is_shared_source: bool,
183}
184
185#[derive(Debug)]
186struct ReplaceOriginalJobInfo {
187    max_parallelism: i32,
188    timezone: Option<String>,
189    config_override: Option<String>,
190    adaptive_parallelism_strategy: Option<String>,
191    parallelism: StreamingParallelism,
192    specific_resource_group: Option<String>,
193}
194
195impl ReplaceOriginalJobInfo {
196    fn resolved_parallelism(
197        &self,
198        specified_parallelism: Option<&NonZeroUsize>,
199    ) -> StreamingParallelism {
200        specified_parallelism
201            .map(|n| StreamingParallelism::Fixed(n.get() as _))
202            .unwrap_or_else(|| self.parallelism.clone())
203    }
204
205    fn stream_context(&self, ctx_override: Option<&StreamContext>) -> StreamContext {
206        StreamContext {
207            timezone: ctx_override
208                .and_then(|ctx| ctx.timezone.clone())
209                .or_else(|| self.timezone.clone()),
210            // We don't expect replacing a job with a different config override.
211            // Thus always use the original config override.
212            config_override: self.config_override.clone().unwrap_or_default().into(),
213        }
214    }
215
216    fn resource_type(&self) -> streaming_job_resource_type::ResourceType {
217        match &self.specific_resource_group {
218            Some(group) => {
219                streaming_job_resource_type::ResourceType::SpecificResourceGroup(group.clone())
220            }
221            None => streaming_job_resource_type::ResourceType::Regular(true),
222        }
223    }
224}
225
226impl From<streaming_job::Model> for ReplaceOriginalJobInfo {
227    fn from(model: streaming_job::Model) -> Self {
228        Self {
229            max_parallelism: model.max_parallelism,
230            timezone: model.timezone,
231            config_override: model.config_override,
232            adaptive_parallelism_strategy: model.adaptive_parallelism_strategy,
233            parallelism: model.parallelism,
234            specific_resource_group: model.specific_resource_group,
235        }
236    }
237}
238
239fn update_source_node_rate_limit(
240    node: &mut PbNodeBody,
241    rate_limit: Option<u32>,
242) -> MetaResult<bool> {
243    let (properties, current_limit) = match node {
244        PbNodeBody::Source(node) => match &mut node.source_inner {
245            Some(inner) => (&inner.with_properties, &mut inner.rate_limit),
246            None => return Ok(false),
247        },
248        PbNodeBody::StreamFsFetch(node) => match &mut node.node_inner {
249            Some(inner) => (&inner.with_properties, &mut inner.rate_limit),
250            None => return Ok(false),
251        },
252        _ => return Ok(false),
253    };
254    if properties.is_iceberg_update_source() {
255        return Err(MetaError::invalid_parameter(
256            "Iceberg streaming_updates does not support rate limiting",
257        ));
258    }
259    *current_limit = rate_limit;
260    Ok(true)
261}
262
263fn update_sink_node_rate_limit(node: &mut PbNodeBody, rate_limit: Option<u32>) -> MetaResult<bool> {
264    let PbNodeBody::Sink(node) = node else {
265        return Ok(false);
266    };
267    if node.log_store_type != PbSinkLogStoreType::KvLogStore as i32 {
268        return Err(MetaError::invalid_parameter(
269            "sink rate limit is only supported for kv log store, please SET sink_decouple = TRUE before CREATE SINK",
270        ));
271    }
272    node.rate_limit = rate_limit;
273    Ok(true)
274}
275
276impl CatalogController {
277    pub async fn get_table_change_log_truncate_info(
278        &self,
279    ) -> MetaResult<TableChangeLogTruncateInfo> {
280        let inner = self.inner.read().await;
281
282        let subscriptions: Vec<(TableId, i64)> = Subscription::find()
283            .select_only()
284            .columns([
285                subscription::Column::DependentTableId,
286                subscription::Column::RetentionSeconds,
287            ])
288            .into_tuple()
289            .all(&inner.db)
290            .await?;
291        let mut subscription_retention_seconds = HashMap::new();
292        for (table_id, retention_seconds) in subscriptions {
293            let retention_seconds = u64::try_from(retention_seconds).map_err(|_| {
294                anyhow!(
295                    "subscription on table {} has invalid retention seconds {}",
296                    table_id,
297                    retention_seconds
298                )
299            })?;
300            subscription_retention_seconds
301                .entry(table_id)
302                .and_modify(|retention: &mut u64| *retention = (*retention).max(retention_seconds))
303                .or_insert(retention_seconds);
304        }
305
306        let committed_epoch_truncate_job_ids: HashSet<JobId> = StreamingJobModel::find()
307            .select_only()
308            .column(streaming_job::Column::JobId)
309            .filter(
310                Condition::any()
311                    .add(streaming_job::Column::JobStatus.eq(JobStatus::Creating))
312                    .add(streaming_job::Column::RefreshIntervalSec.is_not_null()),
313            )
314            .into_tuple::<JobId>()
315            .all(&inner.db)
316            .await?
317            .into_iter()
318            .collect();
319        let fragments: Vec<(FragmentId, JobId, i32, StreamNode, TableIdArray)> = Fragment::find()
320            .select_only()
321            .columns([
322                fragment::Column::FragmentId,
323                fragment::Column::JobId,
324                fragment::Column::FragmentTypeMask,
325                fragment::Column::StreamNode,
326                fragment::Column::StateTableIds,
327            ])
328            .filter(FragmentTypeMask::intersects_any([
329                FragmentTypeFlag::IcebergWithPkIndexWriter,
330                FragmentTypeFlag::SnapshotBackfillStreamScan,
331            ]))
332            .into_tuple()
333            .all(&inner.db)
334            .await?;
335        let iceberg_job_ids: HashSet<_> = fragments
336            .iter()
337            .filter(|(_, _, fragment_type_mask, _, _)| {
338                FragmentTypeMask::from(*fragment_type_mask)
339                    .contains(FragmentTypeFlag::IcebergWithPkIndexWriter)
340            })
341            .map(|(_, job_id, _, _, _)| *job_id)
342            .collect();
343        let mut committed_epoch_truncate_job_info: HashMap<_, _> = committed_epoch_truncate_job_ids
344            .into_iter()
345            .filter(|job_id| !iceberg_job_ids.contains(job_id))
346            .map(|job_id| {
347                (
348                    job_id,
349                    CommittedEpochTruncateJob {
350                        job_id,
351                        state_table_ids: HashSet::new(),
352                        upstream_table_snapshot_epochs: HashMap::new(),
353                    },
354                )
355            })
356            .collect();
357        let mut progress_table_subscribers = Vec::new();
358        for (fragment_id, job_id, fragment_type_mask, stream_node, state_table_ids) in fragments {
359            let fragment_type_mask = FragmentTypeMask::from(fragment_type_mask);
360            if iceberg_job_ids.contains(&job_id) {
361                if !fragment_type_mask.contains(FragmentTypeFlag::SnapshotBackfillStreamScan) {
362                    continue;
363                }
364                let mut collection_error = None;
365                visit_stream_node_stream_scan(&stream_node.to_protobuf(), |stream_scan| {
366                    let scan_type = match StreamScanType::try_from(stream_scan.stream_scan_type) {
367                        Ok(scan_type) => scan_type,
368                        Err(err) => {
369                            collection_error = Some(anyhow::Error::new(err).context(format!(
370                                "invalid persisted stream scan type {} in job {} fragment {}",
371                                stream_scan.stream_scan_type, job_id, fragment_id
372                            )));
373                            return;
374                        }
375                    };
376                    if scan_type != StreamScanType::SnapshotBackfill {
377                        return;
378                    }
379                    let Some(progress_table) = stream_scan.state_table.as_ref() else {
380                        collection_error = Some(anyhow!(
381                            "snapshot backfill in Iceberg V3 job {} has no progress table",
382                            job_id
383                        ));
384                        return;
385                    };
386                    progress_table_subscribers.push(ProgressTableChangeLogSubscriber {
387                        subscriber_job_id: job_id,
388                        upstream_table_id: stream_scan.table_id,
389                        progress_table_id: progress_table.id,
390                    });
391                });
392                if let Some(err) = collection_error {
393                    return Err(err.into());
394                }
395            } else if let Some(info) = committed_epoch_truncate_job_info.get_mut(&job_id) {
396                info.state_table_ids
397                    .extend(state_table_ids.inner_ref().iter().copied());
398                let mut collection_error = None;
399                visit_stream_node_stream_scan(&stream_node.to_protobuf(), |stream_scan| {
400                    let scan_type = match StreamScanType::try_from(stream_scan.stream_scan_type) {
401                        Ok(scan_type) => scan_type,
402                        Err(err) => {
403                            collection_error = Some(anyhow::Error::new(err).context(format!(
404                                "invalid persisted stream scan type {} in job {} fragment {}",
405                                stream_scan.stream_scan_type, job_id, fragment_id
406                            )));
407                            return;
408                        }
409                    };
410                    if scan_type != StreamScanType::SnapshotBackfill {
411                        return;
412                    }
413                    match info
414                        .upstream_table_snapshot_epochs
415                        .entry(stream_scan.table_id)
416                    {
417                        std::collections::hash_map::Entry::Occupied(entry) => {
418                            if entry.get() != &stream_scan.snapshot_backfill_epoch {
419                                collection_error = Some(anyhow!(
420                                    "job {} has inconsistent snapshot epochs for upstream table {}",
421                                    job_id,
422                                    stream_scan.table_id
423                                ));
424                            }
425                        }
426                        std::collections::hash_map::Entry::Vacant(entry) => {
427                            entry.insert(stream_scan.snapshot_backfill_epoch);
428                        }
429                    }
430                });
431                if let Some(err) = collection_error {
432                    return Err(err.into());
433                }
434            }
435        }
436        let committed_epoch_truncate_jobs = committed_epoch_truncate_job_info
437            .into_values()
438            .filter(|info| !info.upstream_table_snapshot_epochs.is_empty())
439            .collect();
440        Ok(TableChangeLogTruncateInfo {
441            subscription_retention_seconds,
442            progress_table_subscribers,
443            committed_epoch_truncate_jobs,
444        })
445    }
446
447    pub async fn get_pinned_snapshot_epochs(&self) -> MetaResult<HashMap<TableId, HashSet<u64>>> {
448        // Hold the catalog read lock across both queries so a job cannot transition out of
449        // `Creating` while its fragments are being inspected.
450        let inner = self.inner.read().await;
451        let creating_job_ids = StreamingJobModel::find()
452            .select_only()
453            .column(streaming_job::Column::JobId)
454            .filter(streaming_job::Column::JobStatus.eq(JobStatus::Creating))
455            .into_tuple::<JobId>()
456            .all(&inner.db)
457            .await?;
458        if creating_job_ids.is_empty() {
459            return Ok(HashMap::new());
460        }
461        let fragments = Fragment::find()
462            .filter(fragment::Column::JobId.is_in(creating_job_ids))
463            .all(&inner.db)
464            .await?;
465        let mut pinned_snapshot_epochs: HashMap<TableId, HashSet<u64>> = HashMap::new();
466        for fragment in fragments {
467            visit_stream_node_stream_scan(&fragment.stream_node.to_protobuf(), |stream_scan| {
468                let scan_type = match StreamScanType::try_from(stream_scan.stream_scan_type) {
469                    Ok(scan_type) => scan_type,
470                    Err(err) => {
471                        tracing::warn!(
472                            job_id = %fragment.job_id,
473                            fragment_id = %fragment.fragment_id,
474                            stream_scan_type = stream_scan.stream_scan_type,
475                            error = %err.as_report(),
476                            "invalid persisted stream scan type, skip collecting snapshot pin"
477                        );
478                        return;
479                    }
480                };
481                if !matches!(
482                    scan_type,
483                    StreamScanType::SnapshotBackfill | StreamScanType::CrossDbSnapshotBackfill
484                ) {
485                    return;
486                }
487                let Some(epoch) = stream_scan.snapshot_backfill_epoch else {
488                    tracing::warn!(
489                        job_id = %fragment.job_id,
490                        fragment_id = %fragment.fragment_id,
491                        table_id = %stream_scan.table_id,
492                        "persisted snapshot backfill epoch is not set, skip collecting snapshot pin"
493                    );
494                    return;
495                };
496                pinned_snapshot_epochs
497                    .entry(stream_scan.table_id)
498                    .or_default()
499                    .insert(epoch);
500            });
501        }
502        Ok(pinned_snapshot_epochs)
503    }
504
505    #[expect(clippy::too_many_arguments)]
506    pub async fn create_streaming_job_obj(
507        txn: &DatabaseTransaction,
508        obj_type: ObjectType,
509        owner_id: UserId,
510        belong_to_oid: Option<ObjectId>,
511        create_type: PbCreateType,
512        ctx: StreamContext,
513        adaptive_parallelism_strategy: Option<AdaptiveParallelismStrategy>,
514        streaming_parallelism: StreamingParallelism,
515        max_parallelism: usize,
516        resource_type: streaming_job_resource_type::ResourceType,
517        backfill_parallelism: Option<StreamingParallelism>,
518        backfill_adaptive_parallelism_strategy: Option<AdaptiveParallelismStrategy>,
519        refresh_interval_sec: Option<u64>,
520    ) -> MetaResult<streaming_job::Model> {
521        let obj = Self::create_object(txn, obj_type, owner_id, belong_to_oid).await?;
522        let job_id = obj.oid.as_job_id();
523        let is_serverless_backfill = matches!(
524            &resource_type,
525            streaming_job_resource_type::ResourceType::ServerlessBackfill(true)
526        );
527        let model = streaming_job::Model {
528            job_id,
529            job_status: JobStatus::Initial,
530            create_type: create_type.into(),
531            timezone: ctx.timezone,
532            config_override: Some(ctx.config_override.to_string()),
533            adaptive_parallelism_strategy: adaptive_parallelism_strategy
534                .as_ref()
535                .map(ToString::to_string),
536            parallelism: streaming_parallelism,
537            backfill_parallelism,
538            backfill_adaptive_parallelism_strategy: backfill_adaptive_parallelism_strategy
539                .as_ref()
540                .map(ToString::to_string),
541            backfill_orders: None,
542            max_parallelism: max_parallelism as _,
543            specific_resource_group: job_initial_catalog_resource_group(&resource_type, job_id),
544            is_serverless_backfill,
545            refresh_interval_sec: refresh_interval_sec.map(|s| s as i64),
546        };
547        let job = model.clone().into_active_model();
548        StreamingJobModel::insert(job).exec(txn).await?;
549
550        Ok(model)
551    }
552
553    /// Create the initial catalogs for the streaming job.
554    ///
555    /// Some of the fields in the given streaming job are placeholders, which will
556    /// be updated later in `prepare_streaming_job`. The catalogs become visible to frontend after
557    /// the first barrier is collected in `post_collect_job_fragments`.
558    #[expect(clippy::too_many_arguments)]
559    #[await_tree::instrument]
560    pub async fn create_job_catalog(
561        &self,
562        streaming_job: &mut StreamingJob,
563        ctx: &StreamContext,
564        parallelism: &Option<Parallelism>,
565        max_parallelism: usize,
566        mut dependencies: HashSet<ObjectId>,
567        resource_type: streaming_job_resource_type::ResourceType,
568        backfill_parallelism: &Option<Parallelism>,
569        adaptive_parallelism_strategy: Option<AdaptiveParallelismStrategy>,
570        backfill_adaptive_parallelism_strategy: Option<AdaptiveParallelismStrategy>,
571        replace_sink: Option<&SinkId>,
572        refresh_interval_sec: Option<u64>,
573    ) -> MetaResult<streaming_job::Model> {
574        let inner = self.inner.write().await;
575        let txn = inner.db.begin().await?;
576        let create_type = streaming_job.create_type();
577
578        let streaming_parallelism = match (parallelism, self.env.opts.default_parallelism) {
579            (None, DefaultParallelism::Full) => StreamingParallelism::Adaptive,
580            (None, DefaultParallelism::Default(n)) => StreamingParallelism::Fixed(n.get()),
581            (Some(n), _) => StreamingParallelism::Fixed(n.parallelism as _),
582        };
583        let backfill_parallelism = backfill_parallelism
584            .as_ref()
585            .map(|p| StreamingParallelism::Fixed(p.parallelism as _))
586            .or_else(|| {
587                backfill_adaptive_parallelism_strategy
588                    .as_ref()
589                    .map(|_| StreamingParallelism::Adaptive)
590            });
591
592        ensure_user_id(streaming_job.owner() as _, &txn).await?;
593        ensure_object_id(ObjectType::Database, streaming_job.database_id(), &txn).await?;
594        ensure_object_id(ObjectType::Schema, streaming_job.schema_id(), &txn).await?;
595        if let Some(old_sink_id) = replace_sink {
596            let StreamingJob::Sink(sink, _) = streaming_job else {
597                bail!("replacement sink catalog requires a sink job")
598            };
599            let (old_sink, old_object) = Sink::find_by_id(*old_sink_id)
600                .find_also_related(Object)
601                .one(&txn)
602                .await?
603                .and_then(|(sink, object)| object.map(|object| (sink, object)))
604                .ok_or_else(|| MetaError::catalog_id_not_found("sink", *old_sink_id))?;
605            let old_streaming_job = StreamingJobModel::find_by_id(old_sink_id.as_job_id())
606                .one(&txn)
607                .await?
608                .ok_or_else(|| MetaError::catalog_id_not_found("sink", *old_sink_id))?;
609            if old_object.obj_type != ObjectType::Sink
610                || old_object.database_id != Some(sink.database_id)
611                || old_object.schema_id != Some(sink.schema_id)
612                || old_sink.name != sink.name
613            {
614                bail!(
615                    "old sink {} does not match replacement sink {}",
616                    old_sink_id,
617                    sink.name
618                );
619            }
620            if old_sink.target_table.is_some() || sink.target_table.is_some() {
621                bail!("replace sink into table is not supported");
622            }
623            if old_streaming_job.job_status != JobStatus::Created {
624                bail!("sink {} is not ready to be replaced", old_sink_id);
625            }
626        } else {
627            check_relation_name_duplicate(
628                &streaming_job.name(),
629                streaming_job.database_id(),
630                streaming_job.schema_id(),
631                &txn,
632            )
633            .await?;
634        }
635
636        // check if any dependency is in altering status.
637        if !dependencies.is_empty() {
638            let altering_cnt = ObjectDependency::find()
639                .join(
640                    JoinType::InnerJoin,
641                    object_dependency::Relation::Object1.def(),
642                )
643                .join(JoinType::InnerJoin, object::Relation::StreamingJob.def())
644                .filter(
645                    object_dependency::Column::Oid
646                        .is_in(dependencies.clone())
647                        .and(object::Column::ObjType.eq(ObjectType::Table))
648                        .and(streaming_job::Column::JobStatus.ne(JobStatus::Created))
649                        .and(
650                            // It means the referring table is just dummy for altering.
651                            object::Column::Oid.not_in_subquery(
652                                Query::select()
653                                    .column(table::Column::TableId)
654                                    .from(Table)
655                                    .to_owned(),
656                            ),
657                        ),
658                )
659                .count(&txn)
660                .await?;
661            if altering_cnt != 0 {
662                return Err(MetaError::permission_denied(
663                    "some dependent relations are being altered",
664                ));
665            }
666
667            // Check if any dependency is a batch refresh job.
668            // Streaming on batch refresh materialized views is not supported.
669            let batch_refresh_cnt = StreamingJobModel::find()
670                .filter(
671                    streaming_job::Column::JobId
672                        .is_in(dependencies.iter().map(|id| JobId::new(id.as_raw_id())))
673                        .and(streaming_job::Column::RefreshIntervalSec.is_not_null()),
674                )
675                .count(&txn)
676                .await?;
677            if batch_refresh_cnt != 0 {
678                return Err(MetaError::permission_denied(
679                    "creating streaming jobs on batch refresh materialized views is not supported",
680                ));
681            }
682        }
683
684        let streaming_job_model = match streaming_job {
685            StreamingJob::MaterializedView(table) => {
686                let streaming_job_model = Self::create_streaming_job_obj(
687                    &txn,
688                    ObjectType::Table,
689                    table.owner as _,
690                    Some(table.schema_id.as_object_id()),
691                    create_type,
692                    ctx.clone(),
693                    adaptive_parallelism_strategy,
694                    streaming_parallelism,
695                    max_parallelism,
696                    resource_type.clone(),
697                    backfill_parallelism.clone(),
698                    backfill_adaptive_parallelism_strategy,
699                    refresh_interval_sec,
700                )
701                .await?;
702                table.id = streaming_job_model.job_id.as_mv_table_id();
703                let table_model: table::ActiveModel = table.clone().into();
704                Table::insert(table_model).exec(&txn).await?;
705                streaming_job_model
706            }
707            StreamingJob::Sink(sink, belong_to_table_id) => {
708                if let Some(target_table_id) = sink.target_table
709                    && check_sink_into_table_cycle(
710                        target_table_id.into(),
711                        dependencies.iter().cloned().collect(),
712                        &txn,
713                    )
714                    .await?
715                {
716                    bail!("Creating such a sink will result in circular dependency.");
717                }
718
719                let streaming_job_model = Self::create_streaming_job_obj(
720                    &txn,
721                    ObjectType::Sink,
722                    sink.owner as _,
723                    Some(
724                        belong_to_table_id
725                            .map(|table_id| table_id.as_object_id())
726                            .unwrap_or(sink.schema_id.as_object_id()),
727                    ),
728                    create_type,
729                    ctx.clone(),
730                    adaptive_parallelism_strategy,
731                    streaming_parallelism,
732                    max_parallelism,
733                    resource_type.clone(),
734                    backfill_parallelism.clone(),
735                    backfill_adaptive_parallelism_strategy,
736                    None, // refresh_interval_sec: only for MV
737                )
738                .await?;
739                sink.id = streaming_job_model.job_id.as_sink_id();
740                if let Some(old_sink_id) = replace_sink {
741                    let final_sink_name = sink.name.clone();
742                    // The replacement sink cannot reuse the old catalog name until the
743                    // cutover transaction deletes the old sink. Use a deterministic temporary
744                    // name and rename it back in `post_collect_job_fragments`.
745                    sink.name = format!("__rw_replacing_sink_{}_{}", old_sink_id, sink.id);
746                    tracing::debug!(
747                        old_sink_id = %old_sink_id,
748                        new_sink_id = %sink.id,
749                        final_name = %final_sink_name,
750                        temp_name = %sink.name,
751                        "created replacement sink catalog with temporary name"
752                    );
753                }
754                let sink_model: sink::ActiveModel = sink.clone().into();
755                Sink::insert(sink_model).exec(&txn).await?;
756                streaming_job_model
757            }
758            StreamingJob::Table(src, table, _) => {
759                let streaming_job_model = Self::create_streaming_job_obj(
760                    &txn,
761                    ObjectType::Table,
762                    table.owner as _,
763                    Some(table.schema_id.as_object_id()),
764                    create_type,
765                    ctx.clone(),
766                    adaptive_parallelism_strategy,
767                    streaming_parallelism,
768                    max_parallelism,
769                    resource_type.clone(),
770                    backfill_parallelism.clone(),
771                    backfill_adaptive_parallelism_strategy,
772                    None, // refresh_interval_sec: only for MV
773                )
774                .await?;
775                let job_id = streaming_job_model.job_id;
776                table.id = job_id.as_mv_table_id();
777                if let Some(src) = src {
778                    let src_obj = Self::create_object(
779                        &txn,
780                        ObjectType::Source,
781                        src.owner as _,
782                        Some(job_id.as_object_id()),
783                    )
784                    .await?;
785                    src.id = src_obj.oid.as_source_id();
786                    src.optional_associated_table_id = Some(job_id.as_mv_table_id().into());
787                    table.optional_associated_source_id = Some(src_obj.oid.as_source_id().into());
788                    let source: source::ActiveModel = src.clone().into();
789                    Source::insert(source).exec(&txn).await?;
790                }
791                let table_model: table::ActiveModel = table.clone().into();
792                Table::insert(table_model).exec(&txn).await?;
793
794                if table.refreshable {
795                    let trigger_interval_secs = src
796                        .as_ref()
797                        .and_then(|source_catalog| source_catalog.refresh_mode)
798                        .and_then(
799                            |source_refresh_mode| match source_refresh_mode.refresh_mode {
800                                Some(RefreshMode::FullReload(SourceRefreshModeFullReload {
801                                    refresh_interval_sec,
802                                })) => refresh_interval_sec,
803                                Some(RefreshMode::Streaming(SourceRefreshModeStreaming {})) => None,
804                                None => None,
805                            },
806                        );
807
808                    RefreshJob::insert(refresh_job::ActiveModel {
809                        table_id: Set(table.id),
810                        last_trigger_time: Set(None),
811                        trigger_interval_secs: Set(trigger_interval_secs),
812                        current_status: Set(RefreshState::Idle),
813                        last_success_time: Set(None),
814                    })
815                    .exec(&txn)
816                    .await?;
817                }
818                streaming_job_model
819            }
820            StreamingJob::Index(index, table) => {
821                ensure_object_id(ObjectType::Table, index.primary_table_id, &txn).await?;
822                let streaming_job_model = Self::create_streaming_job_obj(
823                    &txn,
824                    ObjectType::Index,
825                    index.owner as _,
826                    Some(index.schema_id.as_object_id()),
827                    create_type,
828                    ctx.clone(),
829                    adaptive_parallelism_strategy,
830                    streaming_parallelism,
831                    max_parallelism,
832                    resource_type.clone(),
833                    backfill_parallelism.clone(),
834                    backfill_adaptive_parallelism_strategy,
835                    None, // refresh_interval_sec: only for MV
836                )
837                .await?;
838                // to be compatible with old implementation.
839                let job_id = streaming_job_model.job_id;
840                index.id = job_id.as_index_id();
841                index.index_table_id = job_id.as_mv_table_id();
842                table.id = job_id.as_mv_table_id();
843
844                ObjectDependency::insert(object_dependency::ActiveModel {
845                    oid: Set(index.primary_table_id.into()),
846                    used_by: Set(table.id.into()),
847                    ..Default::default()
848                })
849                .exec(&txn)
850                .await?;
851
852                let table_model: table::ActiveModel = table.clone().into();
853                Table::insert(table_model).exec(&txn).await?;
854                let index_model: index::ActiveModel = index.clone().into();
855                Index::insert(index_model).exec(&txn).await?;
856                streaming_job_model
857            }
858            StreamingJob::Source(src) => {
859                let streaming_job_model = Self::create_streaming_job_obj(
860                    &txn,
861                    ObjectType::Source,
862                    src.owner as _,
863                    Some(src.schema_id.as_object_id()),
864                    create_type,
865                    ctx.clone(),
866                    adaptive_parallelism_strategy,
867                    streaming_parallelism,
868                    max_parallelism,
869                    resource_type.clone(),
870                    backfill_parallelism.clone(),
871                    backfill_adaptive_parallelism_strategy,
872                    None, // refresh_interval_sec: only for MV
873                )
874                .await?;
875                src.id = streaming_job_model.job_id.as_shared_source_id();
876                let source_model: source::ActiveModel = src.clone().into();
877                Source::insert(source_model).exec(&txn).await?;
878                streaming_job_model
879            }
880        };
881
882        // collect dependent secrets.
883        dependencies.extend(
884            streaming_job
885                .dependent_secret_ids()?
886                .into_iter()
887                .map(|id| id.as_object_id()),
888        );
889        // collect dependent connection
890        dependencies.extend(
891            streaming_job
892                .dependent_connection_ids()?
893                .into_iter()
894                .map(|id| id.as_object_id()),
895        );
896
897        // record object dependency.
898        if !dependencies.is_empty() {
899            ObjectDependency::insert_many(dependencies.into_iter().map(|oid| {
900                object_dependency::ActiveModel {
901                    oid: Set(oid),
902                    used_by: Set(streaming_job.id().as_object_id()),
903                    ..Default::default()
904                }
905            }))
906            .exec(&txn)
907            .await?;
908        }
909
910        txn.commit().await?;
911
912        Ok(streaming_job_model)
913    }
914
915    /// Create the initial catalogs for internal tables.
916    ///
917    /// Some of the fields in the given "incomplete" internal tables are placeholders, which will
918    /// be updated later in `prepare_streaming_job`. The catalogs become visible to frontend after
919    /// the first barrier is collected in `post_collect_job_fragments`.
920    ///
921    /// Returns a mapping from the temporary table id to the actual global table id.
922    pub async fn create_internal_table_catalog(
923        &self,
924        job: &StreamingJob,
925        mut incomplete_internal_tables: Vec<PbTable>,
926    ) -> MetaResult<HashMap<TableId, TableId>> {
927        let job_id = job.id();
928        let inner = self.inner.write().await;
929        let txn = inner.db.begin().await?;
930
931        // Ensure the job exists.
932        ensure_job_not_canceled(job_id, &txn).await?;
933
934        let mut table_id_map = HashMap::new();
935        for table in &mut incomplete_internal_tables {
936            let table_id = Self::create_object(
937                &txn,
938                ObjectType::Table,
939                table.owner as _,
940                Some(job_id.as_object_id()),
941            )
942            .await?
943            .oid
944            .as_table_id();
945            table_id_map.insert(table.id, table_id);
946            table.id = table_id;
947            table.job_id = Some(job_id);
948
949            let table_model = table::ActiveModel {
950                table_id: Set(table_id),
951                belongs_to_job_id: Set(Some(job_id)),
952                fragment_id: NotSet,
953                ..table.clone().into()
954            };
955            Table::insert(table_model).exec(&txn).await?;
956        }
957        txn.commit().await?;
958
959        Ok(table_id_map)
960    }
961
962    pub async fn update_streaming_job_resource_group(
963        &self,
964        job_id: JobId,
965        resource_group: String,
966    ) -> MetaResult<()> {
967        let inner = self.inner.write().await;
968        let txn = inner.db.begin().await?;
969
970        ensure_job_not_canceled(job_id, &txn).await?;
971
972        let job = streaming_job::ActiveModel {
973            job_id: Set(job_id),
974            specific_resource_group: Set(Some(resource_group)),
975            ..Default::default()
976        };
977        StreamingJobModel::update(job).exec(&txn).await?;
978
979        txn.commit().await?;
980
981        Ok(())
982    }
983
984    pub async fn prepare_stream_job_fragments(
985        &self,
986        stream_job_fragments: &StreamJobFragmentsToCreate,
987        streaming_job: &StreamingJob,
988        for_replace: bool,
989        backfill_orders: Option<BackfillOrders>,
990    ) -> MetaResult<()> {
991        self.prepare_streaming_job(
992            stream_job_fragments.stream_job_id(),
993            || stream_job_fragments.fragments.values(),
994            &stream_job_fragments.downstreams,
995            for_replace,
996            Some(streaming_job),
997            backfill_orders,
998        )
999        .await
1000    }
1001
1002    // TODO: In this function, we also update the `Table` model in the meta store.
1003    // Given that we've ensured the tables inside `TableFragments` are complete, shall we consider
1004    // making them the source of truth and performing a full replacement for those in the meta store?
1005    /// Insert fragments and actors into the meta store. Used both for creating new jobs and
1006    /// replacing jobs. This does not make a new job visible to frontend.
1007    #[await_tree::instrument("prepare_streaming_job_for_{}", if for_replace { "replace" } else { "create" }
1008    )]
1009    pub async fn prepare_streaming_job<'a, I: Iterator<Item = &'a crate::model::Fragment> + 'a>(
1010        &self,
1011        job_id: JobId,
1012        get_fragments: impl Fn() -> I + 'a,
1013        downstreams: &FragmentDownstreamRelation,
1014        for_replace: bool,
1015        creating_streaming_job: Option<&'a StreamingJob>,
1016        backfill_orders: Option<BackfillOrders>,
1017    ) -> MetaResult<()> {
1018        let fragments = Self::prepare_fragment_models_from_fragments(job_id, get_fragments())?;
1019
1020        let inner = self.inner.write().await;
1021
1022        let txn = inner.db.begin().await?;
1023
1024        // Ensure the job exists.
1025        ensure_job_not_canceled(job_id, &txn).await?;
1026
1027        if let Some(backfill_orders) = backfill_orders {
1028            let job = streaming_job::ActiveModel {
1029                job_id: Set(job_id),
1030                backfill_orders: Set(Some(backfill_orders)),
1031                ..Default::default()
1032            };
1033            StreamingJobModel::update(job).exec(&txn).await?;
1034        }
1035
1036        let state_table_ids = fragments
1037            .iter()
1038            .flat_map(|fragment| fragment.state_table_ids.inner_ref().clone())
1039            .collect_vec();
1040
1041        // Collect fragment IDs before consuming `fragments` for serving mapping notification.
1042        let inserted_fragment_ids: Vec<crate::model::FragmentId> = fragments
1043            .iter()
1044            .map(|f| f.fragment_id as crate::model::FragmentId)
1045            .collect();
1046
1047        if !fragments.is_empty() {
1048            let fragment_models = fragments
1049                .into_iter()
1050                .map(|fragment| fragment.into_active_model())
1051                .collect_vec();
1052            Fragment::insert_many(fragment_models).exec(&txn).await?;
1053        }
1054
1055        // Fields including `fragment_id` and `vnode_count` were placeholder values before.
1056        // After table fragments are created, update them for all tables.
1057        if !for_replace {
1058            let all_tables = StreamJobFragments::collect_tables(get_fragments());
1059            for state_table_id in state_table_ids {
1060                // Table's vnode count is not always the fragment's vnode count, so we have to
1061                // look up the table from `TableFragments`.
1062                // See `ActorGraphBuilder::new`.
1063                let table = all_tables
1064                    .get(&state_table_id)
1065                    .unwrap_or_else(|| panic!("table {} not found", state_table_id));
1066                assert_eq!(table.id, state_table_id);
1067                let vnode_count = table.vnode_count();
1068
1069                Table::update(table::ActiveModel {
1070                    table_id: Set(state_table_id as _),
1071                    fragment_id: Set(Some(table.fragment_id)),
1072                    vnode_count: Set(vnode_count as _),
1073                    ..Default::default()
1074                })
1075                .exec(&txn)
1076                .await?;
1077            }
1078        }
1079
1080        insert_fragment_relations(&txn, downstreams).await?;
1081
1082        if !for_replace {
1083            // Update dml fragment id.
1084            if let Some(StreamingJob::Table(_, table, _)) = creating_streaming_job {
1085                Table::update(table::ActiveModel {
1086                    table_id: Set(table.id),
1087                    dml_fragment_id: Set(table.dml_fragment_id),
1088                    ..Default::default()
1089                })
1090                .exec(&txn)
1091                .await?;
1092            }
1093        }
1094
1095        txn.commit().await?;
1096
1097        // Notify serving module about newly inserted fragments so it can establish
1098        // serving vnode mappings. This is driven by the fragment model insertion,
1099        // decoupled from the barrier-driven streaming mapping notifications.
1100        self.env
1101            .notification_manager()
1102            .notify_serving_fragment_mapping_update(inserted_fragment_ids);
1103
1104        self.notify_hummock_table_cache_refill_policy_if_explicit(&inner, job_id)
1105            .await?;
1106
1107        Ok(())
1108    }
1109
1110    /// Tries to abort a creating streaming job.
1111    ///
1112    /// An explicit cancellation always aborts the job. An ordinary creation failure only aborts a
1113    /// job in the initial status; a job that has reached the creating status is left to recovery.
1114    #[await_tree::instrument]
1115    pub async fn try_abort_creating_streaming_job(
1116        &self,
1117        job_id: JobId,
1118        is_cancelled: bool,
1119    ) -> MetaResult<AbortCreatingJobResult> {
1120        let mut inner = self.inner.write().await;
1121        let txn = inner.db.begin().await?;
1122
1123        let obj = Object::find_by_id(job_id).one(&txn).await?;
1124        let Some(obj) = obj else {
1125            tracing::warn!(
1126                id = %job_id,
1127                "streaming job not found when aborting creating, might be cancelled already or cleaned by recovery"
1128            );
1129            return Ok(AbortCreatingJobResult {
1130                aborted: true,
1131                database_id: None,
1132                aborted_sink_ids: vec![],
1133                cancel_info: None,
1134            });
1135        };
1136        let database_id = obj
1137            .database_id
1138            .ok_or_else(|| anyhow!("obj has no database id: {:?}", obj))?;
1139        let streaming_job = streaming_job::Entity::find_by_id(job_id).one(&txn).await?;
1140
1141        if let Some(streaming_job) = &streaming_job {
1142            if streaming_job.job_status == JobStatus::Created {
1143                tracing::warn!(%job_id, "streaming job is already created, ignore abort request");
1144                return Ok(AbortCreatingJobResult {
1145                    aborted: false,
1146                    database_id: Some(database_id),
1147                    aborted_sink_ids: vec![],
1148                    cancel_info: None,
1149                });
1150            }
1151
1152            if !is_cancelled && streaming_job.job_status == JobStatus::Creating {
1153                if (obj.obj_type == ObjectType::Table || obj.obj_type == ObjectType::Sink)
1154                    && check_if_belongs_to_iceberg_table(&txn, job_id).await?
1155                {
1156                    // If the job belongs to an Iceberg table, we still need to clean it.
1157                } else {
1158                    // If the job is created in background and still in creating status, we should
1159                    // not abort it and let recovery handle it.
1160                    tracing::warn!(
1161                        id = %job_id,
1162                        "streaming job is still in creating status"
1163                    );
1164                    return Ok(AbortCreatingJobResult {
1165                        aborted: false,
1166                        database_id: Some(database_id),
1167                        aborted_sink_ids: vec![],
1168                        cancel_info: None,
1169                    });
1170                }
1171            }
1172        }
1173
1174        let mut objects_to_abort = vec![obj];
1175        objects_to_abort.extend(get_belong_objects(&txn, job_id.as_object_id()).await?);
1176        let object_ids_to_abort = objects_to_abort
1177            .iter()
1178            .map(|object| object.oid)
1179            .collect_vec();
1180        let streaming_job_ids_to_abort: Vec<JobId> = StreamingJobModel::find()
1181            .select_only()
1182            .column(streaming_job::Column::JobId)
1183            .filter(streaming_job::Column::JobId.is_in(object_ids_to_abort.iter().copied()))
1184            .into_tuple()
1185            .all(&txn)
1186            .await?;
1187        let object_models = load_object_models(&txn, &objects_to_abort).await?;
1188
1189        let fragments: Vec<(FragmentId, i32, TableIdArray)> = Fragment::find()
1190            .select_only()
1191            .columns([
1192                fragment::Column::FragmentId,
1193                fragment::Column::FragmentTypeMask,
1194                fragment::Column::StateTableIds,
1195            ])
1196            .filter(fragment::Column::JobId.is_in(streaming_job_ids_to_abort.iter().copied()))
1197            .into_tuple()
1198            .all(&txn)
1199            .await?;
1200        let cancel_info = if is_cancelled {
1201            let state_table_ids: HashSet<_> = fragments
1202                .iter()
1203                .flat_map(|(_, _, state_table_ids)| state_table_ids.inner_ref().iter().copied())
1204                .collect();
1205            let sink_fragment_ids = fragments
1206                .iter()
1207                .filter_map(|(fragment_id, fragment_type_mask, _)| {
1208                    FragmentTypeMask::from(*fragment_type_mask)
1209                        .contains(FragmentTypeFlag::Sink)
1210                        .then_some(*fragment_id)
1211                })
1212                .collect_vec();
1213            let sink_target_fragments = fetch_target_fragments(&txn, sink_fragment_ids).await?;
1214            let mut dropped_sink_fragment_by_targets = HashMap::new();
1215            for (sink_fragment, target_fragments) in sink_target_fragments {
1216                if let Some(target_fragment) = target_fragments.first() {
1217                    dropped_sink_fragment_by_targets
1218                        .entry(*target_fragment)
1219                        .or_insert_with(Vec::new)
1220                        .push(sink_fragment);
1221                }
1222            }
1223
1224            Some(CancelStreamingJobInfo {
1225                command: Command::DropStreamingJobs {
1226                    streaming_job_ids: streaming_job_ids_to_abort.iter().copied().collect(),
1227                    unregistered_state_table_ids: state_table_ids.clone(),
1228                    dropped_sink_fragment_by_targets,
1229                },
1230                streaming_job_ids: streaming_job_ids_to_abort.clone(),
1231                state_table_ids: state_table_ids.into_iter().collect(),
1232            })
1233        } else {
1234            None
1235        };
1236
1237        // A job becomes visible to frontend only after it enters the creating status.
1238        let need_notify = streaming_job
1239            .as_ref()
1240            .is_some_and(|job| job.job_status != JobStatus::Initial);
1241
1242        if is_cancelled {
1243            let dropped_tables = object_models.iter().filter_map(|object_info| {
1244                if let PbObjectInfo::Table(table) = object_info {
1245                    Some((table.id, table.clone()))
1246                } else {
1247                    None
1248                }
1249            });
1250            inner.dropped_tables.extend(dropped_tables);
1251        }
1252
1253        let objs = if need_notify {
1254            objects_to_abort
1255                .iter()
1256                .map(|object| PartialObject {
1257                    oid: object.oid,
1258                    obj_type: object.obj_type,
1259                    schema_id: object.schema_id,
1260                    database_id: object.database_id,
1261                })
1262                .collect_vec()
1263        } else {
1264            vec![]
1265        };
1266
1267        // Query fragment IDs before cascade-deleting them, for serving mapping cleanup.
1268        let abort_fragment_ids = fragments
1269            .iter()
1270            .map(|(fragment_id, _, _)| *fragment_id)
1271            .collect_vec();
1272
1273        // Do not walk from an implicit Iceberg sink to its parent table here. The Iceberg table
1274        // creation error path drops the table explicitly, which then cascades to all objects that
1275        // belong to it.
1276        Object::delete_by_id(job_id).exec(&txn).await?;
1277
1278        let err = if is_cancelled {
1279            MetaError::cancelled(format!("streaming job {job_id} is cancelled"))
1280        } else {
1281            MetaError::catalog_id_not_found("stream job", format!("streaming job {job_id} failed"))
1282        };
1283        let abort_reason = format!("streaming job aborted {}", err.as_report());
1284        for aborted_job_id in streaming_job_ids_to_abort {
1285            for tx in inner
1286                .creating_table_finish_notifier
1287                .get_mut(&database_id)
1288                .map(|creating_tables| creating_tables.remove(&aborted_job_id).into_iter())
1289                .into_iter()
1290                .flatten()
1291                .flatten()
1292            {
1293                let _ = tx.send(Err(abort_reason.clone()));
1294            }
1295        }
1296        txn.commit().await?;
1297
1298        // Notify serving module about deleted fragments from the aborted job.
1299        self.env
1300            .notification_manager()
1301            .notify_serving_fragment_mapping_delete(
1302                abort_fragment_ids.iter().map(|id| *id as _).collect(),
1303            );
1304
1305        if !objs.is_empty() {
1306            // We also have notified the frontend about these objects,
1307            // so we need to notify the frontend to delete them here.
1308            let _version = self
1309                .notify_frontend(Operation::Delete, build_object_group_for_delete(objs))
1310                .await;
1311        }
1312        let aborted_sink_ids = objects_to_abort
1313            .iter()
1314            .filter(|object| object.obj_type == ObjectType::Sink)
1315            .map(|object| object.oid.as_sink_id())
1316            .collect();
1317        Ok(AbortCreatingJobResult {
1318            aborted: true,
1319            database_id: Some(database_id),
1320            aborted_sink_ids,
1321            cancel_info,
1322        })
1323    }
1324
1325    async fn build_creating_streaming_job_objects(
1326        txn: &DatabaseTransaction,
1327        job_id: JobId,
1328    ) -> MetaResult<Vec<PbObject>> {
1329        let job_type = Object::find_by_id(job_id)
1330            .select_only()
1331            .column(object::Column::ObjType)
1332            .into_tuple()
1333            .one(txn)
1334            .await?
1335            .ok_or_else(|| MetaError::catalog_id_not_found("streaming job", job_id))?;
1336        let streaming_job = StreamingJobModel::find_by_id(job_id)
1337            .one(txn)
1338            .await?
1339            .ok_or_else(|| MetaError::catalog_id_not_found("streaming job", job_id))?;
1340
1341        let table_objs = Table::find()
1342            .find_also_related(Object)
1343            .filter(
1344                table::Column::BelongsToJobId
1345                    .eq(job_id)
1346                    .or(table::Column::TableId.eq(job_id.as_mv_table_id())),
1347            )
1348            .all(txn)
1349            .await?;
1350        let associated_source_id = table_objs
1351            .iter()
1352            .find(|(table, _)| table.table_id == job_id.as_mv_table_id())
1353            .and_then(|(table, _)| table.optional_associated_source_id);
1354        let mut objects = table_objs
1355            .into_iter()
1356            .map(|(table, obj)| PbObject {
1357                object_info: Some(PbObjectInfo::Table(
1358                    ObjectModel(table, obj.unwrap(), Some(streaming_job.clone())).into(),
1359                )),
1360            })
1361            .collect_vec();
1362
1363        match job_type {
1364            ObjectType::Table => {
1365                if let Some(source_id) = associated_source_id {
1366                    let (source, obj) = Source::find_by_id(source_id)
1367                        .find_also_related(Object)
1368                        .one(txn)
1369                        .await?
1370                        .ok_or_else(|| MetaError::catalog_id_not_found("source", source_id))?;
1371                    objects.push(PbObject {
1372                        object_info: Some(PbObjectInfo::Source(
1373                            ObjectModel(source, obj.unwrap(), None).into(),
1374                        )),
1375                    });
1376                }
1377            }
1378            ObjectType::Sink => {
1379                let (sink, obj) = Sink::find_by_id(job_id.as_sink_id())
1380                    .find_also_related(Object)
1381                    .one(txn)
1382                    .await?
1383                    .ok_or_else(|| MetaError::catalog_id_not_found("sink", job_id))?;
1384                objects.push(PbObject {
1385                    object_info: Some(PbObjectInfo::Sink(
1386                        ObjectModel(sink, obj.unwrap(), Some(streaming_job)).into(),
1387                    )),
1388                });
1389            }
1390            ObjectType::Index => {
1391                let (index, obj) = Index::find_by_id(job_id.as_index_id())
1392                    .find_also_related(Object)
1393                    .one(txn)
1394                    .await?
1395                    .ok_or_else(|| MetaError::catalog_id_not_found("index", job_id))?;
1396                objects.push(PbObject {
1397                    object_info: Some(PbObjectInfo::Index(
1398                        ObjectModel(index, obj.unwrap(), Some(streaming_job)).into(),
1399                    )),
1400                });
1401            }
1402            ObjectType::Source => {
1403                let (source, obj) = Source::find_by_id(job_id.as_shared_source_id())
1404                    .find_also_related(Object)
1405                    .one(txn)
1406                    .await?
1407                    .ok_or_else(|| MetaError::catalog_id_not_found("source", job_id))?;
1408                objects.push(PbObject {
1409                    object_info: Some(PbObjectInfo::Source(
1410                        ObjectModel(source, obj.unwrap(), None).into(),
1411                    )),
1412                });
1413            }
1414            _ => unreachable!("invalid streaming job type: {job_type:?}"),
1415        }
1416
1417        Ok(objects)
1418    }
1419
1420    /// Mark a job as creating after its first barrier is collected and notify frontend to add its
1421    /// creating catalogs.
1422    #[await_tree::instrument]
1423    pub async fn post_collect_job_fragments(
1424        &self,
1425        job_id: JobId,
1426        upstream_fragment_new_downstreams: &FragmentDownstreamRelation,
1427        new_sink_downstream: Option<FragmentDownstreamRelation>,
1428        split_assignment: Option<&SplitAssignment>,
1429        replace_sink: Option<&SinkId>,
1430        notify_creating: bool,
1431    ) -> MetaResult<Option<Vec<TableId>>> {
1432        let mut inner = self.inner.write().await;
1433        let txn = inner.db.begin().await?;
1434        let mut replace_sink_post_collect = None;
1435
1436        insert_fragment_relations(&txn, upstream_fragment_new_downstreams).await?;
1437
1438        if let Some(new_downstream) = new_sink_downstream {
1439            insert_fragment_relations(&txn, &new_downstream).await?;
1440        }
1441
1442        // Mark job as CREATING.
1443        let mut streaming_job_model = streaming_job::ActiveModel {
1444            job_id: Set(job_id),
1445            job_status: Set(JobStatus::Creating),
1446            ..Default::default()
1447        };
1448        if replace_sink.is_some() {
1449            // The old sink is dropped in this transaction before the replacement job may be
1450            // marked Created by a later barrier. Preserve it across recovery even when the legacy
1451            // option to clean all foreground creating jobs is enabled.
1452            streaming_job_model.create_type = Set(CreateType::Background);
1453        }
1454        StreamingJobModel::update(streaming_job_model)
1455            .exec(&txn)
1456            .await?;
1457
1458        if let Some(split_assignment) = split_assignment {
1459            let fragment_splits = split_assignment
1460                .iter()
1461                .map(|(fragment_id, splits)| {
1462                    (
1463                        *fragment_id as _,
1464                        splits.values().flatten().cloned().collect_vec(),
1465                    )
1466                })
1467                .collect();
1468
1469            self.update_fragment_splits(&txn, &fragment_splits).await?;
1470        }
1471
1472        if let Some(old_sink_id) = replace_sink {
1473            let old_sink_id = *old_sink_id;
1474
1475            let (old_sink, old_sink_object) = Sink::find_by_id(old_sink_id)
1476                .find_also_related(Object)
1477                .one(&txn)
1478                .await?
1479                .and_then(|(sink, object)| object.map(|object| (sink, object)))
1480                .ok_or_else(|| MetaError::catalog_id_not_found("sink", old_sink_id))?;
1481            if old_sink_object.obj_type != ObjectType::Sink {
1482                bail!("object {} is not a sink", old_sink_id);
1483            }
1484            let final_sink_name = old_sink.name.clone();
1485
1486            let mut old_objects_to_delete = vec![old_sink_object];
1487            old_objects_to_delete
1488                .extend(get_belong_objects(&txn, old_sink_id.as_object_id()).await?);
1489            let old_object_models = load_object_models(&txn, &old_objects_to_delete).await?;
1490            let old_state_table_ids = old_object_models
1491                .iter()
1492                .filter_map(|object_info| match object_info {
1493                    PbObjectInfo::Table(table) => Some(table.id),
1494                    _ => None,
1495                })
1496                .collect_vec();
1497            let old_fragment_ids: Vec<FragmentId> = Fragment::find()
1498                .select_only()
1499                .column(fragment::Column::FragmentId)
1500                .filter(
1501                    fragment::Column::JobId.is_in(
1502                        old_objects_to_delete
1503                            .iter()
1504                            .map(|object| object.oid.as_job_id()),
1505                    ),
1506                )
1507                .into_tuple()
1508                .all(&txn)
1509                .await?;
1510            let dropped_tables = old_object_models
1511                .iter()
1512                .filter_map(|object_info| match object_info {
1513                    PbObjectInfo::Table(table) => Some(table.clone()),
1514                    _ => None,
1515                })
1516                .collect_vec();
1517
1518            Sink::update(sink::ActiveModel {
1519                sink_id: Set(job_id.as_sink_id()),
1520                name: Set(final_sink_name),
1521                ..Default::default()
1522            })
1523            .exec(&txn)
1524            .await?;
1525
1526            let old_objects = old_objects_to_delete
1527                .into_iter()
1528                .map(PartialObject::from)
1529                .collect_vec();
1530            let old_object_ids = old_objects.iter().map(|obj| obj.oid).collect_vec();
1531            let updated_user_ids: Vec<UserId> = UserPrivilege::find()
1532                .select_only()
1533                .distinct()
1534                .column(user_privilege::Column::UserId)
1535                .filter(user_privilege::Column::Oid.is_in(old_object_ids.clone()))
1536                .into_tuple()
1537                .all(&txn)
1538                .await?;
1539
1540            // Internal objects and their catalog rows follow the old sink's belong-to cascade.
1541            let res = Object::delete_by_id(old_sink_id).exec(&txn).await?;
1542            if res.rows_affected == 0 {
1543                return Err(MetaError::catalog_id_not_found("sink", old_sink_id));
1544            }
1545
1546            let new_root_object = Object::find_by_id(job_id)
1547                .one(&txn)
1548                .await?
1549                .ok_or_else(|| MetaError::catalog_id_not_found("sink", job_id))?;
1550            let mut new_objects_to_notify = vec![new_root_object];
1551            new_objects_to_notify.extend(get_belong_objects(&txn, job_id.as_object_id()).await?);
1552            let new_objects = load_object_models(&txn, &new_objects_to_notify)
1553                .await?
1554                .into_iter()
1555                .map(|object_info| PbObject {
1556                    object_info: Some(object_info),
1557                })
1558                .collect_vec();
1559            let dependencies =
1560                list_object_dependencies_by_object_id(&txn, job_id.as_object_id()).await?;
1561            let updated_user_info = list_user_info_by_ids(updated_user_ids, &txn).await?;
1562
1563            replace_sink_post_collect = Some((
1564                old_state_table_ids,
1565                old_fragment_ids,
1566                old_objects,
1567                dropped_tables,
1568                new_objects,
1569                dependencies,
1570                updated_user_info,
1571            ));
1572        }
1573
1574        let creating_objects = if notify_creating {
1575            Some(Self::build_creating_streaming_job_objects(&txn, job_id).await?)
1576        } else {
1577            None
1578        };
1579
1580        txn.commit().await?;
1581
1582        let mut notifications = self.frontend_notification_batch();
1583        if let Some(objects) = creating_objects {
1584            notifications.add(
1585                NotificationOperation::Add,
1586                NotificationInfo::ObjectGroup(PbObjectGroup {
1587                    objects,
1588                    dependencies: vec![],
1589                }),
1590            );
1591        }
1592
1593        let Some((
1594            old_state_table_ids,
1595            old_fragment_ids,
1596            old_objects,
1597            dropped_tables,
1598            new_objects,
1599            dependencies,
1600            updated_user_info,
1601        )) = replace_sink_post_collect
1602        else {
1603            if let Some(version) = notifications.publish().await {
1604                tracing::debug!(job_id = %job_id, version, "notified frontend of creating streaming job");
1605            }
1606            return Ok(None);
1607        };
1608
1609        inner
1610            .dropped_tables
1611            .extend(dropped_tables.into_iter().map(|table| (table.id, table)));
1612        drop(inner);
1613
1614        self.env
1615            .notification_manager()
1616            .notify_serving_fragment_mapping_delete(
1617                old_fragment_ids.iter().map(|id| *id as _).collect(),
1618            );
1619
1620        notifications.add(
1621            NotificationOperation::Delete,
1622            build_object_group_for_delete(old_objects),
1623        );
1624
1625        // The replacement sink is not pre-notified to frontend while creating, so the cutover
1626        // should add the new finalized sink after deleting the old one.
1627        notifications.add(
1628            NotificationOperation::Add,
1629            NotificationInfo::ObjectGroup(PbObjectGroup {
1630                objects: new_objects,
1631                dependencies,
1632            }),
1633        );
1634
1635        Self::add_users_update_notifications(&mut notifications, updated_user_info);
1636        let version = notifications
1637            .publish()
1638            .await
1639            .expect("the replacement notification batch contains the replacement sink");
1640        tracing::debug!(job_id = %job_id, version, "notified frontend of replacement streaming job");
1641
1642        Ok(Some(old_state_table_ids))
1643    }
1644
1645    pub async fn create_job_catalog_for_replace(
1646        &self,
1647        streaming_job: &StreamingJob,
1648        ctx: Option<&StreamContext>,
1649        specified_parallelism: Option<&NonZeroUsize>,
1650        expected_original_max_parallelism: Option<usize>,
1651    ) -> MetaResult<streaming_job::Model> {
1652        let id = streaming_job.id();
1653        let inner = self.inner.write().await;
1654        let txn = inner.db.begin().await?;
1655
1656        // 1. check version.
1657        streaming_job.verify_version_for_replace(&txn).await?;
1658        // 2. check concurrent replace.
1659        let referring_objects =
1660            get_referring_objects(id.as_object_id(), streaming_job.object_type(), &txn).await?;
1661        let referring_job_ids = referring_objects
1662            .iter()
1663            .map(|object| object.oid.as_job_id())
1664            .collect_vec();
1665        let non_created_referring_job_count = StreamingJobModel::find()
1666            .filter(
1667                streaming_job::Column::JobId
1668                    .is_in(referring_job_ids)
1669                    .and(streaming_job::Column::JobStatus.ne(JobStatus::Created)),
1670            )
1671            .count(&txn)
1672            .await?;
1673        if non_created_referring_job_count != 0 {
1674            return Err(MetaError::permission_denied(
1675                "job is being altered or referenced by some creating jobs",
1676            ));
1677        }
1678
1679        // Check if any dependent job is a batch refresh job.
1680        // Replace table is not supported when a batch refresh MV depends on it.
1681        let batch_refresh_dep_cnt = ObjectDependency::find()
1682            .join(
1683                JoinType::InnerJoin,
1684                object_dependency::Relation::Object1.def(),
1685            )
1686            .join(JoinType::InnerJoin, object::Relation::StreamingJob.def())
1687            .filter(
1688                object_dependency::Column::Oid
1689                    .eq(id)
1690                    .and(streaming_job::Column::RefreshIntervalSec.is_not_null()),
1691            )
1692            .count(&txn)
1693            .await?;
1694        if batch_refresh_dep_cnt != 0 {
1695            return Err(MetaError::permission_denied(
1696                "replacing a table with dependent batch refresh materialized views is not supported",
1697            ));
1698        }
1699
1700        // 3. check parallelism.
1701        let original_job = StreamingJobModel::find_by_id(id)
1702            .one(&txn)
1703            .await?
1704            .map(ReplaceOriginalJobInfo::from)
1705            .ok_or_else(|| MetaError::catalog_id_not_found(streaming_job.job_type_str(), id))?;
1706
1707        if let Some(max_parallelism) = expected_original_max_parallelism
1708            && original_job.max_parallelism != max_parallelism as i32
1709        {
1710            // We already override the max parallelism in `StreamFragmentGraph` before entering this function.
1711            // This should not happen in normal cases.
1712            bail!(
1713                "cannot use a different max parallelism \
1714                 when replacing streaming job, \
1715                 original: {}, new: {}",
1716                original_job.max_parallelism,
1717                max_parallelism
1718            );
1719        }
1720
1721        let parallelism = original_job.resolved_parallelism(specified_parallelism);
1722        let ctx = original_job.stream_context(ctx);
1723        let adaptive_parallelism_strategy = original_job
1724            .adaptive_parallelism_strategy
1725            .as_deref()
1726            .map(|s| parse_strategy(s).expect("strategy should be validated before persisting"));
1727        let resource_type = original_job.resource_type();
1728        let belong_to_oid = Object::find_by_id(id)
1729            .select_only()
1730            .column(object::Column::BelongToOid)
1731            .into_tuple::<Option<ObjectId>>()
1732            .one(&txn)
1733            .await?
1734            .ok_or_else(|| MetaError::catalog_id_not_found(streaming_job.job_type_str(), id))?;
1735
1736        // 4. create streaming object for new replace table.
1737        let tmp_model = Self::create_streaming_job_obj(
1738            &txn,
1739            streaming_job.object_type(),
1740            streaming_job.owner() as _,
1741            belong_to_oid,
1742            streaming_job.create_type(),
1743            ctx,
1744            adaptive_parallelism_strategy,
1745            parallelism,
1746            original_job.max_parallelism as _,
1747            resource_type,
1748            // `backfill_parallelism` is intentionally NOT inherited from the original job.
1749            // Replace has no "backfill finish -> restore parallelism" phase, so inheriting it
1750            // would render actors at the backfill parallelism and never recover to steady-state.
1751            None,
1752            None,
1753            None, // refresh_interval_sec: not applicable for replace jobs
1754        )
1755        .await?;
1756
1757        // 5. record dependency for new replace table.
1758        ObjectDependency::insert(object_dependency::ActiveModel {
1759            oid: Set(id.as_object_id()),
1760            used_by: Set(tmp_model.job_id.as_object_id()),
1761            ..Default::default()
1762        })
1763        .exec(&txn)
1764        .await?;
1765
1766        txn.commit().await?;
1767
1768        Ok(tmp_model)
1769    }
1770
1771    /// `finish_streaming_job` marks job related objects as `Created` and notify frontend.
1772    pub async fn finish_streaming_job(&self, job_id: JobId) -> MetaResult<()> {
1773        let mut inner = self.inner.write().await;
1774        let txn = inner.db.begin().await?;
1775
1776        // Check if the job belongs to iceberg table.
1777        if check_if_belongs_to_iceberg_table(&txn, job_id).await? {
1778            tracing::info!(
1779                "streaming job {} is for iceberg table, wait for manual finish operation",
1780                job_id
1781            );
1782            return Ok(());
1783        }
1784
1785        let (notification_op, objects, updated_user_info, dependencies) =
1786            self.finish_streaming_job_inner(&txn, job_id).await?;
1787
1788        txn.commit().await?;
1789
1790        let mut notifications = self.frontend_notification_batch();
1791        notifications.add(
1792            notification_op,
1793            NotificationInfo::ObjectGroup(PbObjectGroup {
1794                objects,
1795                dependencies,
1796            }),
1797        );
1798
1799        // notify users about the default privileges
1800        Self::add_users_update_notifications(&mut notifications, updated_user_info);
1801        let version = notifications
1802            .publish()
1803            .await
1804            .expect("the streaming job notification batch contains the streaming job");
1805
1806        inner
1807            .creating_table_finish_notifier
1808            .values_mut()
1809            .for_each(|creating_tables| {
1810                if let Some(txs) = creating_tables.remove(&job_id) {
1811                    for tx in txs {
1812                        let _ = tx.send(Ok(version));
1813                    }
1814                }
1815            });
1816
1817        Ok(())
1818    }
1819
1820    /// `finish_streaming_job` marks job related objects as `Created` and notify frontend.
1821    pub async fn finish_streaming_job_inner(
1822        &self,
1823        txn: &DatabaseTransaction,
1824        job_id: JobId,
1825    ) -> MetaResult<(
1826        Operation,
1827        Vec<risingwave_pb::meta::Object>,
1828        Vec<PbUserInfo>,
1829        Vec<PbObjectDependency>,
1830    )> {
1831        let job_type = Object::find_by_id(job_id)
1832            .select_only()
1833            .column(object::Column::ObjType)
1834            .into_tuple()
1835            .one(txn)
1836            .await?
1837            .ok_or_else(|| MetaError::catalog_id_not_found("streaming job", job_id))?;
1838
1839        // update `created_at` as now() and `created_at_cluster_version` as current cluster version.
1840        let res = Object::update_many()
1841            .col_expr(object::Column::CreatedAt, Expr::current_timestamp().into())
1842            .col_expr(
1843                object::Column::CreatedAtClusterVersion,
1844                current_cluster_version().into(),
1845            )
1846            .filter(object::Column::Oid.eq(job_id))
1847            .exec(txn)
1848            .await?;
1849        if res.rows_affected == 0 {
1850            return Err(MetaError::catalog_id_not_found("streaming job", job_id));
1851        }
1852
1853        // mark the target stream job as `Created`.
1854        let job = streaming_job::ActiveModel {
1855            job_id: Set(job_id),
1856            job_status: Set(JobStatus::Created),
1857            ..Default::default()
1858        };
1859        let streaming_job = Some(job.update(txn).await?);
1860
1861        // notify frontend: job, internal tables.
1862        let internal_table_objs = Table::find()
1863            .find_also_related(Object)
1864            .filter(table::Column::BelongsToJobId.eq(job_id))
1865            .all(txn)
1866            .await?;
1867        let mut objects = internal_table_objs
1868            .iter()
1869            .map(|(table, obj)| PbObject {
1870                object_info: Some(PbObjectInfo::Table(
1871                    ObjectModel(table.clone(), obj.clone().unwrap(), streaming_job.clone()).into(),
1872                )),
1873            })
1874            .collect_vec();
1875        let notification_op = NotificationOperation::Update;
1876        let mut updated_user_info = vec![];
1877        let mut need_grant_default_privileges = true;
1878
1879        match job_type {
1880            ObjectType::Table => {
1881                let (table, obj) = Table::find_by_id(job_id.as_mv_table_id())
1882                    .find_also_related(Object)
1883                    .one(txn)
1884                    .await?
1885                    .ok_or_else(|| MetaError::catalog_id_not_found("table", job_id))?;
1886                if let Some(source_id) = table.optional_associated_source_id {
1887                    let (src, obj) = Source::find_by_id(source_id)
1888                        .find_also_related(Object)
1889                        .one(txn)
1890                        .await?
1891                        .ok_or_else(|| MetaError::catalog_id_not_found("source", source_id))?;
1892                    objects.push(PbObject {
1893                        object_info: Some(PbObjectInfo::Source(
1894                            ObjectModel(src, obj.unwrap(), None).into(),
1895                        )),
1896                    });
1897                }
1898                objects.push(PbObject {
1899                    object_info: Some(PbObjectInfo::Table(
1900                        ObjectModel(table, obj.unwrap(), streaming_job).into(),
1901                    )),
1902                });
1903            }
1904            ObjectType::Sink => {
1905                let (sink, obj) = Sink::find_by_id(job_id.as_sink_id())
1906                    .find_also_related(Object)
1907                    .one(txn)
1908                    .await?
1909                    .ok_or_else(|| MetaError::catalog_id_not_found("sink", job_id))?;
1910                if sink.name.starts_with(ICEBERG_SINK_PREFIX) {
1911                    need_grant_default_privileges = false;
1912                }
1913                objects.push(PbObject {
1914                    object_info: Some(PbObjectInfo::Sink(
1915                        ObjectModel(sink, obj.unwrap(), streaming_job).into(),
1916                    )),
1917                });
1918            }
1919            ObjectType::Index => {
1920                need_grant_default_privileges = false;
1921                let (index, obj) = Index::find_by_id(job_id.as_index_id())
1922                    .find_also_related(Object)
1923                    .one(txn)
1924                    .await?
1925                    .ok_or_else(|| MetaError::catalog_id_not_found("index", job_id))?;
1926                {
1927                    let (table, obj) = Table::find_by_id(index.index_table_id)
1928                        .find_also_related(Object)
1929                        .one(txn)
1930                        .await?
1931                        .ok_or_else(|| {
1932                            MetaError::catalog_id_not_found("table", index.index_table_id)
1933                        })?;
1934                    objects.push(PbObject {
1935                        object_info: Some(PbObjectInfo::Table(
1936                            ObjectModel(table, obj.unwrap(), streaming_job.clone()).into(),
1937                        )),
1938                    });
1939                }
1940
1941                // If the index is created on a table with privileges, we should also
1942                // grant the privileges for the index and its state tables.
1943                let primary_table_privileges = UserPrivilege::find()
1944                    .filter(
1945                        user_privilege::Column::Oid
1946                            .eq(index.primary_table_id)
1947                            .and(user_privilege::Column::Action.eq(Action::Select)),
1948                    )
1949                    .all(txn)
1950                    .await?;
1951                if !primary_table_privileges.is_empty() {
1952                    let index_state_table_ids: Vec<TableId> = Table::find()
1953                        .select_only()
1954                        .column(table::Column::TableId)
1955                        .filter(
1956                            table::Column::BelongsToJobId
1957                                .eq(job_id)
1958                                .or(table::Column::TableId.eq(index.index_table_id)),
1959                        )
1960                        .into_tuple()
1961                        .all(txn)
1962                        .await?;
1963                    let mut new_privileges = vec![];
1964                    for privilege in &primary_table_privileges {
1965                        for state_table_id in &index_state_table_ids {
1966                            new_privileges.push(user_privilege::ActiveModel {
1967                                id: Default::default(),
1968                                oid: Set(state_table_id.as_object_id()),
1969                                user_id: Set(privilege.user_id),
1970                                action: Set(Action::Select),
1971                                dependent_id: Set(privilege.dependent_id),
1972                                granted_by: Set(privilege.granted_by),
1973                                with_grant_option: Set(privilege.with_grant_option),
1974                            });
1975                        }
1976                    }
1977                    upsert_user_privileges(txn, new_privileges).await?;
1978
1979                    updated_user_info = list_user_info_by_ids(
1980                        primary_table_privileges.into_iter().map(|p| p.user_id),
1981                        txn,
1982                    )
1983                    .await?;
1984                }
1985
1986                objects.push(PbObject {
1987                    object_info: Some(PbObjectInfo::Index(
1988                        ObjectModel(index, obj.unwrap(), streaming_job).into(),
1989                    )),
1990                });
1991            }
1992            ObjectType::Source => {
1993                let (source, obj) = Source::find_by_id(job_id.as_shared_source_id())
1994                    .find_also_related(Object)
1995                    .one(txn)
1996                    .await?
1997                    .ok_or_else(|| MetaError::catalog_id_not_found("source", job_id))?;
1998                objects.push(PbObject {
1999                    object_info: Some(PbObjectInfo::Source(
2000                        ObjectModel(source, obj.unwrap(), None).into(),
2001                    )),
2002                });
2003            }
2004            _ => unreachable!("invalid job type: {:?}", job_type),
2005        }
2006
2007        if need_grant_default_privileges {
2008            updated_user_info = grant_default_privileges_automatically(txn, job_id).await?;
2009        }
2010
2011        let dependencies =
2012            list_object_dependencies_by_object_id(txn, job_id.as_object_id()).await?;
2013
2014        Ok((notification_op, objects, updated_user_info, dependencies))
2015    }
2016
2017    pub async fn finish_replace_streaming_job(
2018        &self,
2019        tmp_id: JobId,
2020        streaming_job: StreamingJob,
2021        replace_upstream: FragmentReplaceUpstream,
2022        sink_into_table_context: SinkIntoTableContext,
2023        drop_table_connector_ctx: Option<&DropTableConnectorContext>,
2024        auto_refresh_schema_sinks: Option<Vec<FinishAutoRefreshSchemaSinkContext>>,
2025    ) -> MetaResult<NotificationVersion> {
2026        let inner = self.inner.write().await;
2027        let txn = inner.db.begin().await?;
2028
2029        let (objects, delete_notification_objs, old_fragment_ids, new_fragment_ids) =
2030            Self::finish_replace_streaming_job_inner(
2031                tmp_id,
2032                replace_upstream,
2033                sink_into_table_context,
2034                &txn,
2035                streaming_job,
2036                drop_table_connector_ctx,
2037                auto_refresh_schema_sinks,
2038            )
2039            .await?;
2040
2041        txn.commit().await?;
2042
2043        // Notify serving module: delete old fragment mappings, upsert new ones.
2044        let notification_manager = self.env.notification_manager();
2045        notification_manager.notify_serving_fragment_mapping_delete(
2046            old_fragment_ids.iter().map(|id| *id as _).collect(),
2047        );
2048        notification_manager.notify_serving_fragment_mapping_update(
2049            new_fragment_ids.iter().map(|id| *id as _).collect(),
2050        );
2051
2052        let mut notifications = self.frontend_notification_batch();
2053        notifications.add(
2054            NotificationOperation::Update,
2055            NotificationInfo::ObjectGroup(PbObjectGroup {
2056                objects,
2057                dependencies: vec![],
2058            }),
2059        );
2060
2061        if let Some((user_infos, to_drop_objects)) = delete_notification_objs {
2062            Self::add_users_update_notifications(&mut notifications, user_infos);
2063            notifications.add(
2064                NotificationOperation::Delete,
2065                build_object_group_for_delete(to_drop_objects),
2066            );
2067        }
2068        let version = notifications
2069            .publish()
2070            .await
2071            .expect("the replacement notification batch contains the updated object");
2072
2073        Ok(version)
2074    }
2075
2076    fn update_iceberg_source_columns(
2077        original_source_columns: &[ColumnCatalog],
2078        original_row_id_index: Option<usize>,
2079        new_table_columns: &[ColumnCatalog],
2080    ) -> (Vec<ColumnCatalog>, Option<usize>) {
2081        let row_id_column_name = original_row_id_index
2082            .and_then(|idx| original_source_columns.get(idx))
2083            .map(|col| col.name().to_owned());
2084        let mut next_column_id = max_column_id(original_source_columns).next();
2085        let existing_columns: HashMap<String, ColumnCatalog> = original_source_columns
2086            .iter()
2087            .cloned()
2088            .map(|col| (col.name().to_owned(), col))
2089            .collect();
2090
2091        let mut new_columns = Vec::new();
2092        for table_col in new_table_columns
2093            .iter()
2094            .filter(|col| !col.is_rw_sys_column())
2095        {
2096            let mut source_col_name = table_col.name().to_owned();
2097            if source_col_name == ROW_ID_COLUMN_NAME {
2098                source_col_name = RISINGWAVE_ICEBERG_ROW_ID.to_owned();
2099            }
2100
2101            if let Some(existing) = existing_columns.get(&source_col_name) {
2102                new_columns.push(existing.clone());
2103            } else {
2104                let mut new_col = table_col.clone();
2105                new_col.column_desc.name = source_col_name;
2106                new_col.column_desc.column_id = next_column_id;
2107                next_column_id = next_column_id.next();
2108                new_columns.push(new_col);
2109            }
2110        }
2111
2112        let mut seen_names: HashSet<String> = new_columns
2113            .iter()
2114            .map(|col| col.name().to_owned())
2115            .collect();
2116        for col in original_source_columns
2117            .iter()
2118            .filter(|col| col.is_iceberg_hidden_column())
2119        {
2120            if seen_names.insert(col.name().to_owned()) {
2121                new_columns.push(col.clone());
2122            }
2123        }
2124
2125        let new_row_id_index = row_id_column_name
2126            .as_ref()
2127            .and_then(|name| new_columns.iter().position(|col| col.name() == name));
2128
2129        (new_columns, new_row_id_index)
2130    }
2131
2132    pub async fn finish_replace_streaming_job_inner(
2133        tmp_id: JobId,
2134        replace_upstream: FragmentReplaceUpstream,
2135        SinkIntoTableContext {
2136            updated_sink_catalogs,
2137        }: SinkIntoTableContext,
2138        txn: &DatabaseTransaction,
2139        streaming_job: StreamingJob,
2140        drop_table_connector_ctx: Option<&DropTableConnectorContext>,
2141        auto_refresh_schema_sinks: Option<Vec<FinishAutoRefreshSchemaSinkContext>>,
2142    ) -> MetaResult<(
2143        Vec<PbObject>,
2144        Option<(Vec<PbUserInfo>, Vec<PartialObject>)>,
2145        Vec<FragmentId>,
2146        Vec<FragmentId>,
2147    )> {
2148        let original_job_id = streaming_job.id();
2149        let job_type = streaming_job.job_type();
2150
2151        // Query old fragment IDs (will be deleted) and new fragment IDs (will be reassigned).
2152        let old_fragment_ids: Vec<FragmentId> = Fragment::find()
2153            .select_only()
2154            .column(fragment::Column::FragmentId)
2155            .filter(fragment::Column::JobId.eq(original_job_id))
2156            .into_tuple()
2157            .all(txn)
2158            .await?;
2159        let new_fragment_ids: Vec<FragmentId> = Fragment::find()
2160            .select_only()
2161            .column(fragment::Column::FragmentId)
2162            .filter(fragment::Column::JobId.eq(tmp_id))
2163            .into_tuple()
2164            .all(txn)
2165            .await?;
2166
2167        let mut index_item_rewriter = None;
2168        let mut updated_iceberg_source_id: Option<SourceId> = None;
2169
2170        // Update catalog
2171        match streaming_job {
2172            StreamingJob::Table(_, table, _table_job_type) => {
2173                let original_column_catalogs =
2174                    get_table_columns(txn, original_job_id.as_mv_table_id()).await?;
2175                let schema_changed = original_column_catalogs.to_protobuf() != table.columns;
2176                let is_iceberg = table
2177                    .engine
2178                    .and_then(|engine| PbEngine::try_from(engine).ok())
2179                    == Some(PbEngine::Iceberg);
2180                if is_iceberg && schema_changed {
2181                    let iceberg_source_name = format!("{}{}", ICEBERG_SOURCE_PREFIX, table.name);
2182                    let source = Source::find()
2183                        .inner_join(Object)
2184                        .filter(
2185                            object::Column::DatabaseId
2186                                .eq(table.database_id)
2187                                .and(object::Column::SchemaId.eq(table.schema_id))
2188                                .and(source::Column::Name.eq(&iceberg_source_name)),
2189                        )
2190                        .one(txn)
2191                        .await?
2192                        .ok_or_else(|| {
2193                            MetaError::catalog_id_not_found("source", iceberg_source_name)
2194                        })?;
2195
2196                    let source_id = source.source_id;
2197                    let source_version = source.version;
2198                    let source_columns: Vec<ColumnCatalog> = source
2199                        .columns
2200                        .to_protobuf()
2201                        .into_iter()
2202                        .map(ColumnCatalog::from)
2203                        .collect();
2204                    let source_row_id_index = source
2205                        .row_id_index
2206                        .and_then(|idx| usize::try_from(idx).ok());
2207                    let table_columns: Vec<ColumnCatalog> = table
2208                        .columns
2209                        .iter()
2210                        .cloned()
2211                        .map(ColumnCatalog::from)
2212                        .collect();
2213                    let (updated_columns, updated_row_id_index) =
2214                        Self::update_iceberg_source_columns(
2215                            &source_columns,
2216                            source_row_id_index,
2217                            &table_columns,
2218                        );
2219                    let updated_columns: Vec<PbColumnCatalog> = updated_columns
2220                        .into_iter()
2221                        .map(|col| col.to_protobuf())
2222                        .collect();
2223
2224                    let mut source_active = source.into_active_model();
2225                    source_active.columns = Set(ColumnCatalogArray::from(updated_columns));
2226                    source_active.row_id_index = Set(updated_row_id_index.map(|idx| idx as i32));
2227                    source_active.version = Set(source_version + 1);
2228                    source_active.update(txn).await?;
2229                    updated_iceberg_source_id = Some(source_id);
2230                }
2231
2232                index_item_rewriter = Some({
2233                    let original_columns = original_column_catalogs
2234                        .to_protobuf()
2235                        .into_iter()
2236                        .map(|c| c.column_desc.unwrap())
2237                        .collect_vec();
2238                    let new_columns = table
2239                        .columns
2240                        .iter()
2241                        .map(|c| c.column_desc.clone().unwrap())
2242                        .collect_vec();
2243
2244                    IndexItemRewriter {
2245                        original_columns,
2246                        new_columns,
2247                    }
2248                });
2249
2250                // For sinks created in earlier versions, we need to set the original_target_columns.
2251                for sink_id in updated_sink_catalogs {
2252                    Sink::update(sink::ActiveModel {
2253                        sink_id: Set(sink_id as _),
2254                        original_target_columns: Set(Some(original_column_catalogs.clone())),
2255                        ..Default::default()
2256                    })
2257                    .exec(txn)
2258                    .await?;
2259                }
2260                // Update the table catalog with the new one. (column catalog is also updated here)
2261                let mut table = table::ActiveModel::from(table);
2262                if let Some(drop_table_connector_ctx) = drop_table_connector_ctx
2263                    && drop_table_connector_ctx.to_change_streaming_job_id == original_job_id
2264                {
2265                    // drop table connector, the rest logic is in `drop_table_associated_source`
2266                    table.optional_associated_source_id = Set(None);
2267                }
2268
2269                Table::update(table).exec(txn).await?;
2270            }
2271            StreamingJob::Source(source) => {
2272                // Update the source catalog with the new one.
2273                let source = source::ActiveModel::from(source);
2274                Source::update(source).exec(txn).await?;
2275            }
2276            StreamingJob::MaterializedView(table) => {
2277                // Update the table catalog with the new one.
2278                let table = table::ActiveModel::from(table);
2279                Table::update(table).exec(txn).await?;
2280            }
2281            _ => unreachable!(
2282                "invalid streaming job type: {:?}",
2283                streaming_job.job_type_str()
2284            ),
2285        }
2286
2287        async fn finish_fragments(
2288            txn: &DatabaseTransaction,
2289            tmp_id: JobId,
2290            original_job_id: JobId,
2291            replace_upstream: FragmentReplaceUpstream,
2292        ) -> MetaResult<()> {
2293            // 0. update internal tables
2294            // Fields including `fragment_id` were placeholder values before.
2295            // After table fragments are created, update them for all internal tables.
2296            let fragment_info: Vec<(FragmentId, I32Array)> = Fragment::find()
2297                .select_only()
2298                .columns([
2299                    fragment::Column::FragmentId,
2300                    fragment::Column::StateTableIds,
2301                ])
2302                .filter(fragment::Column::JobId.eq(tmp_id))
2303                .into_tuple()
2304                .all(txn)
2305                .await?;
2306            for (fragment_id, state_table_ids) in fragment_info {
2307                for state_table_id in state_table_ids.into_inner() {
2308                    let state_table_id = TableId::new(state_table_id as _);
2309                    Table::update(table::ActiveModel {
2310                        table_id: Set(state_table_id),
2311                        fragment_id: Set(Some(fragment_id)),
2312                        // No need to update `vnode_count` because it must remain the same.
2313                        ..Default::default()
2314                    })
2315                    .exec(txn)
2316                    .await?;
2317                }
2318            }
2319
2320            // 1. replace old fragments/actors with new ones.
2321            Fragment::delete_many()
2322                .filter(fragment::Column::JobId.eq(original_job_id))
2323                .exec(txn)
2324                .await?;
2325            Fragment::update_many()
2326                .col_expr(fragment::Column::JobId, SimpleExpr::from(original_job_id))
2327                .filter(fragment::Column::JobId.eq(tmp_id))
2328                .exec(txn)
2329                .await?;
2330
2331            // 2. update merges.
2332            // update downstream fragment's Merge node, and upstream_fragment_id
2333            for (fragment_id, fragment_replace_map) in replace_upstream {
2334                let (fragment_id, mut stream_node) =
2335                    Fragment::find_by_id(fragment_id as FragmentId)
2336                        .select_only()
2337                        .columns([fragment::Column::FragmentId, fragment::Column::StreamNode])
2338                        .into_tuple::<(FragmentId, StreamNode)>()
2339                        .one(txn)
2340                        .await?
2341                        .map(|(id, node)| (id, node.to_protobuf()))
2342                        .ok_or_else(|| MetaError::catalog_id_not_found("fragment", fragment_id))?;
2343
2344                visit_stream_node_mut(&mut stream_node, |body| {
2345                    if let PbNodeBody::Merge(m) = body
2346                        && let Some(new_fragment_id) =
2347                            fragment_replace_map.get(&m.upstream_fragment_id)
2348                    {
2349                        m.upstream_fragment_id = *new_fragment_id;
2350                    }
2351                });
2352                Fragment::update(fragment::ActiveModel {
2353                    fragment_id: Set(fragment_id),
2354                    stream_node: Set(StreamNode::from(&stream_node)),
2355                    ..Default::default()
2356                })
2357                .exec(txn)
2358                .await?;
2359            }
2360
2361            // 3. remove dummy object.
2362            Object::delete_by_id(tmp_id).exec(txn).await?;
2363
2364            Ok(())
2365        }
2366
2367        finish_fragments(txn, tmp_id, original_job_id, replace_upstream).await?;
2368
2369        // 4. update catalogs and notify.
2370        let mut objects = vec![];
2371        match job_type {
2372            StreamingJobType::Table(_) | StreamingJobType::MaterializedView => {
2373                let (table, table_obj) = Table::find_by_id(original_job_id.as_mv_table_id())
2374                    .find_also_related(Object)
2375                    .one(txn)
2376                    .await?
2377                    .ok_or_else(|| MetaError::catalog_id_not_found("object", original_job_id))?;
2378                let streaming_job = streaming_job::Entity::find_by_id(table.job_id())
2379                    .one(txn)
2380                    .await?;
2381                objects.push(PbObject {
2382                    object_info: Some(PbObjectInfo::Table(
2383                        ObjectModel(table, table_obj.unwrap(), streaming_job).into(),
2384                    )),
2385                })
2386            }
2387            StreamingJobType::Source => {
2388                let (source, source_obj) =
2389                    Source::find_by_id(original_job_id.as_shared_source_id())
2390                        .find_also_related(Object)
2391                        .one(txn)
2392                        .await?
2393                        .ok_or_else(|| {
2394                            MetaError::catalog_id_not_found("object", original_job_id)
2395                        })?;
2396                objects.push(PbObject {
2397                    object_info: Some(PbObjectInfo::Source(
2398                        ObjectModel(source, source_obj.unwrap(), None).into(),
2399                    )),
2400                })
2401            }
2402            _ => unreachable!("invalid streaming job type for replace: {:?}", job_type),
2403        }
2404
2405        if let Some(source_id) = updated_iceberg_source_id {
2406            let (source, source_obj) = Source::find_by_id(source_id)
2407                .find_also_related(Object)
2408                .one(txn)
2409                .await?
2410                .ok_or_else(|| MetaError::catalog_id_not_found("source", source_id))?;
2411            objects.push(PbObject {
2412                object_info: Some(PbObjectInfo::Source(
2413                    ObjectModel(source, source_obj.unwrap(), None).into(),
2414                )),
2415            });
2416        }
2417
2418        if let Some(expr_rewriter) = index_item_rewriter {
2419            let index_items: Vec<(IndexId, ExprNodeArray)> = Index::find()
2420                .select_only()
2421                .columns([index::Column::IndexId, index::Column::IndexItems])
2422                .filter(index::Column::PrimaryTableId.eq(original_job_id))
2423                .into_tuple()
2424                .all(txn)
2425                .await?;
2426            for (index_id, nodes) in index_items {
2427                let mut pb_nodes = nodes.to_protobuf();
2428                pb_nodes
2429                    .iter_mut()
2430                    .for_each(|x| expr_rewriter.rewrite_expr(x));
2431                let index = index::ActiveModel {
2432                    index_id: Set(index_id),
2433                    index_items: Set(pb_nodes.into()),
2434                    ..Default::default()
2435                }
2436                .update(txn)
2437                .await?;
2438                let (index_obj, streaming_job) = Object::find_by_id(index.index_id)
2439                    .find_also_related(streaming_job::Entity)
2440                    .one(txn)
2441                    .await?
2442                    .ok_or_else(|| MetaError::catalog_id_not_found("object", index.index_id))?;
2443                objects.push(PbObject {
2444                    object_info: Some(PbObjectInfo::Index(
2445                        ObjectModel(index, index_obj, streaming_job).into(),
2446                    )),
2447                });
2448            }
2449        }
2450
2451        if let Some(sinks) = auto_refresh_schema_sinks {
2452            for finish_sink_context in sinks {
2453                finish_fragments(
2454                    txn,
2455                    finish_sink_context.tmp_sink_id.as_job_id(),
2456                    finish_sink_context.original_sink_id.as_job_id(),
2457                    Default::default(),
2458                )
2459                .await?;
2460                let (mut sink, sink_obj) = Sink::find_by_id(finish_sink_context.original_sink_id)
2461                    .find_also_related(Object)
2462                    .one(txn)
2463                    .await?
2464                    .ok_or_else(|| MetaError::catalog_id_not_found("sink", original_job_id))?;
2465                let sink_streaming_job =
2466                    streaming_job::Entity::find_by_id(sink.sink_id.as_job_id())
2467                        .one(txn)
2468                        .await?;
2469                let columns = ColumnCatalogArray::from(finish_sink_context.columns);
2470                Sink::update(sink::ActiveModel {
2471                    sink_id: Set(finish_sink_context.original_sink_id),
2472                    columns: Set(columns.clone()),
2473                    ..Default::default()
2474                })
2475                .exec(txn)
2476                .await?;
2477                sink.columns = columns;
2478                objects.push(PbObject {
2479                    object_info: Some(PbObjectInfo::Sink(
2480                        ObjectModel(sink, sink_obj.unwrap(), sink_streaming_job.clone()).into(),
2481                    )),
2482                });
2483                if let Some(new_log_store_table) = finish_sink_context.new_log_store_table {
2484                    let log_store_table_id = new_log_store_table.id;
2485                    let new_log_store_table_columns: ColumnCatalogArray =
2486                        new_log_store_table.columns.clone().into();
2487                    let new_log_store_table_value_indices =
2488                        new_log_store_table.value_indices.clone();
2489                    let (mut table, table_obj) = Table::find_by_id(log_store_table_id)
2490                        .find_also_related(Object)
2491                        .one(txn)
2492                        .await?
2493                        .ok_or_else(|| MetaError::catalog_id_not_found("table", original_job_id))?;
2494                    Table::update(table::ActiveModel {
2495                        table_id: Set(log_store_table_id),
2496                        columns: Set(new_log_store_table_columns.clone()),
2497                        value_indices: Set(new_log_store_table_value_indices.clone().into()),
2498                        ..Default::default()
2499                    })
2500                    .exec(txn)
2501                    .await?;
2502                    table.columns = new_log_store_table_columns;
2503                    table.value_indices = new_log_store_table_value_indices.into();
2504                    objects.push(PbObject {
2505                        object_info: Some(PbObjectInfo::Table(
2506                            ObjectModel(table, table_obj.unwrap(), sink_streaming_job.clone())
2507                                .into(),
2508                        )),
2509                    });
2510                }
2511            }
2512        }
2513
2514        let mut notification_objs: Option<(Vec<PbUserInfo>, Vec<PartialObject>)> = None;
2515        if let Some(drop_table_connector_ctx) = drop_table_connector_ctx {
2516            notification_objs =
2517                Some(Self::drop_table_associated_source(txn, drop_table_connector_ctx).await?);
2518        }
2519
2520        Ok((
2521            objects,
2522            notification_objs,
2523            old_fragment_ids,
2524            new_fragment_ids,
2525        ))
2526    }
2527
2528    /// Abort the replacing streaming job by deleting the temporary job object.
2529    pub async fn try_abort_replacing_streaming_job(
2530        &self,
2531        tmp_job_id: JobId,
2532        tmp_sink_ids: Option<Vec<ObjectId>>,
2533    ) -> MetaResult<()> {
2534        let inner = self.inner.write().await;
2535        let txn = inner.db.begin().await?;
2536
2537        let mut root_ids: Vec<ObjectId> = vec![tmp_job_id.into()];
2538        if let Some(sink_ids) = &tmp_sink_ids {
2539            root_ids.extend(sink_ids.iter().copied());
2540        }
2541        let mut temporary_objects = Object::find()
2542            .filter(object::Column::Oid.is_in(root_ids.iter().copied()))
2543            .all(&txn)
2544            .await?;
2545        temporary_objects.extend(get_belong_objects_by_ids(&txn, root_ids.iter().copied()).await?);
2546
2547        // Query fragment IDs before cascade-deleting the temporary jobs and objects that belong to
2548        // them.
2549        let abort_fragment_ids: Vec<FragmentId> = Fragment::find()
2550            .select_only()
2551            .column(fragment::Column::FragmentId)
2552            .filter(
2553                fragment::Column::JobId.is_in(
2554                    temporary_objects
2555                        .iter()
2556                        .map(|object| object.oid.as_job_id()),
2557                ),
2558            )
2559            .into_tuple()
2560            .all(&txn)
2561            .await?;
2562
2563        Object::delete_many()
2564            .filter(object::Column::Oid.is_in(root_ids))
2565            .exec(&txn)
2566            .await?;
2567        txn.commit().await?;
2568
2569        // Notify serving module about deleted fragments from the aborted replace job.
2570        self.env
2571            .notification_manager()
2572            .notify_serving_fragment_mapping_delete(
2573                abort_fragment_ids.iter().map(|id| *id as _).collect(),
2574            );
2575
2576        Ok(())
2577    }
2578
2579    // edit the `rate_limit` of the `Source` node in given `source_id`'s fragments
2580    // return the actor_ids to be applied
2581    pub async fn update_source_rate_limit_by_source_id(
2582        &self,
2583        source_id: SourceId,
2584        rate_limit: Option<u32>,
2585    ) -> MetaResult<HashMap<FragmentId, PbStreamNode>> {
2586        let inner = self.inner.read().await;
2587        let txn = inner.db.begin().await?;
2588
2589        {
2590            let active_source = source::ActiveModel {
2591                source_id: Set(source_id),
2592                rate_limit: Set(rate_limit.map(|v| v as i32)),
2593                ..Default::default()
2594            };
2595            Source::update(active_source).exec(&txn).await?;
2596        }
2597
2598        let (source, obj) = Source::find_by_id(source_id)
2599            .find_also_related(Object)
2600            .one(&txn)
2601            .await?
2602            .ok_or_else(|| {
2603                MetaError::catalog_id_not_found(ObjectType::Source.as_str(), source_id)
2604            })?;
2605
2606        if source
2607            .with_properties
2608            .inner_ref()
2609            .is_iceberg_update_source()
2610        {
2611            return Err(MetaError::invalid_parameter(
2612                "Iceberg streaming_updates does not support rate limiting",
2613            ));
2614        }
2615        let streaming_job_ids: Vec<JobId> =
2616            if let Some(table_id) = source.optional_associated_table_id {
2617                vec![table_id.as_job_id()]
2618            } else if let Some(source_info) = &source.source_info
2619                && source_info.to_protobuf().is_shared()
2620            {
2621                vec![source_id.as_share_source_job_id()]
2622            } else {
2623                ObjectDependency::find()
2624                    .select_only()
2625                    .column(object_dependency::Column::UsedBy)
2626                    .filter(object_dependency::Column::Oid.eq(source_id))
2627                    .into_tuple()
2628                    .all(&txn)
2629                    .await?
2630            };
2631
2632        if streaming_job_ids.is_empty() {
2633            return Err(MetaError::invalid_parameter(format!(
2634                "source id {source_id} not used by any streaming job"
2635            )));
2636        }
2637
2638        let fragments: Vec<(FragmentId, JobId, i32, StreamNode)> = Fragment::find()
2639            .select_only()
2640            .columns([
2641                fragment::Column::FragmentId,
2642                fragment::Column::JobId,
2643                fragment::Column::FragmentTypeMask,
2644                fragment::Column::StreamNode,
2645            ])
2646            .filter(fragment::Column::JobId.is_in(streaming_job_ids))
2647            .into_tuple()
2648            .all(&txn)
2649            .await?;
2650        let mut fragments = fragments
2651            .into_iter()
2652            .map(|(id, job_id, mask, stream_node)| {
2653                (
2654                    id,
2655                    job_id,
2656                    FragmentTypeMask::from(mask as u32),
2657                    stream_node.to_protobuf(),
2658                )
2659            })
2660            .collect_vec();
2661
2662        fragments.retain_mut(|(_, _, fragment_type_mask, stream_node)| {
2663            let mut found = false;
2664            if fragment_type_mask.contains(FragmentTypeFlag::Source) {
2665                visit_stream_node_mut(stream_node, |node| {
2666                    if let PbNodeBody::Source(node) = node
2667                        && let Some(node_inner) = &mut node.source_inner
2668                        && node_inner.source_id == source_id
2669                    {
2670                        node_inner.rate_limit = rate_limit;
2671                        found = true;
2672                    }
2673                });
2674            }
2675            // Fragments from older versions carry no `FsFetch` flag, so scan every fragment
2676            // for the node and backfill the flag.
2677            visit_stream_node_mut(stream_node, |node| {
2678                if let PbNodeBody::StreamFsFetch(node) = node {
2679                    fragment_type_mask.add(FragmentTypeFlag::FsFetch);
2680                    if let Some(node_inner) = &mut node.node_inner
2681                        && node_inner.source_id == source_id
2682                    {
2683                        node_inner.rate_limit = rate_limit;
2684                        found = true;
2685                    }
2686                }
2687            });
2688            found
2689        });
2690
2691        assert!(
2692            !fragments.is_empty(),
2693            "source id should be used by at least one fragment"
2694        );
2695
2696        let fragment_nodes = fragments
2697            .iter()
2698            .map(|(fragment_id, _, _, stream_node)| (*fragment_id, stream_node.clone()))
2699            .collect();
2700
2701        for (fragment_id, _, fragment_type_mask, stream_node) in fragments {
2702            Fragment::update(fragment::ActiveModel {
2703                fragment_id: Set(fragment_id),
2704                fragment_type_mask: Set(fragment_type_mask.into()),
2705                stream_node: Set(StreamNode::from(&stream_node)),
2706                ..Default::default()
2707            })
2708            .exec(&txn)
2709            .await?;
2710        }
2711
2712        txn.commit().await?;
2713
2714        let relation_info = PbObjectInfo::Source(ObjectModel(source, obj.unwrap(), None).into());
2715        let _version = self
2716            .notify_frontend(
2717                NotificationOperation::Update,
2718                NotificationInfo::ObjectGroup(PbObjectGroup {
2719                    objects: vec![PbObject {
2720                        object_info: Some(relation_info),
2721                    }],
2722                    dependencies: vec![],
2723                }),
2724            )
2725            .await;
2726
2727        Ok(fragment_nodes)
2728    }
2729
2730    // edit the content of fragments in given `table_id`
2731    // return the updated stream nodes to be applied
2732    pub async fn mutate_fragments_by_job_id(
2733        &self,
2734        job_id: JobId,
2735        // returns true if the mutation is applied
2736        mut fragments_mutation_fn: impl FnMut(FragmentTypeMask, &mut PbStreamNode) -> MetaResult<bool>,
2737        // error message when no relevant fragments is found
2738        err_msg: &'static str,
2739    ) -> MetaResult<HashMap<FragmentId, PbStreamNode>> {
2740        let inner = self.inner.read().await;
2741        let txn = inner.db.begin().await?;
2742
2743        let fragments: Vec<(FragmentId, i32, StreamNode)> = Fragment::find()
2744            .select_only()
2745            .columns([
2746                fragment::Column::FragmentId,
2747                fragment::Column::FragmentTypeMask,
2748                fragment::Column::StreamNode,
2749            ])
2750            .filter(fragment::Column::JobId.eq(job_id))
2751            .into_tuple()
2752            .all(&txn)
2753            .await?;
2754        let mut fragments = fragments
2755            .into_iter()
2756            .map(|(id, mask, stream_node)| {
2757                (id, FragmentTypeMask::from(mask), stream_node.to_protobuf())
2758            })
2759            .collect_vec();
2760
2761        let fragments = fragments
2762            .iter_mut()
2763            .map(|(_, fragment_type_mask, stream_node)| {
2764                fragments_mutation_fn(*fragment_type_mask, stream_node)
2765            })
2766            .collect::<MetaResult<Vec<bool>>>()?
2767            .into_iter()
2768            .zip_eq_debug(std::mem::take(&mut fragments))
2769            .filter_map(|(keep, fragment)| if keep { Some(fragment) } else { None })
2770            .collect::<Vec<_>>();
2771
2772        if fragments.is_empty() {
2773            return Err(MetaError::invalid_parameter(format!(
2774                "job id {job_id}: {}",
2775                err_msg
2776            )));
2777        }
2778
2779        let fragment_nodes = fragments
2780            .iter()
2781            .map(|(id, _, stream_node)| (*id, stream_node.clone()))
2782            .collect();
2783        for (id, _, stream_node) in fragments {
2784            Fragment::update(fragment::ActiveModel {
2785                fragment_id: Set(id),
2786                stream_node: Set(StreamNode::from(&stream_node)),
2787                ..Default::default()
2788            })
2789            .exec(&txn)
2790            .await?;
2791        }
2792
2793        txn.commit().await?;
2794
2795        Ok(fragment_nodes)
2796    }
2797
2798    async fn mutate_fragment_by_fragment_id(
2799        &self,
2800        fragment_id: FragmentId,
2801        mut fragment_mutation_fn: impl FnMut(FragmentTypeMask, &mut PbStreamNode) -> MetaResult<bool>,
2802        err_msg: &'static str,
2803    ) -> MetaResult<PbStreamNode> {
2804        let inner = self.inner.read().await;
2805        let txn = inner.db.begin().await?;
2806
2807        let (fragment_type_mask, stream_node): (i32, StreamNode) =
2808            Fragment::find_by_id(fragment_id)
2809                .select_only()
2810                .columns([
2811                    fragment::Column::FragmentTypeMask,
2812                    fragment::Column::StreamNode,
2813                ])
2814                .into_tuple()
2815                .one(&txn)
2816                .await?
2817                .ok_or_else(|| MetaError::catalog_id_not_found("fragment", fragment_id))?;
2818        let mut pb_stream_node = stream_node.to_protobuf();
2819        let fragment_type_mask = FragmentTypeMask::from(fragment_type_mask);
2820
2821        if !fragment_mutation_fn(fragment_type_mask, &mut pb_stream_node)? {
2822            return Err(MetaError::invalid_parameter(format!(
2823                "fragment id {fragment_id}: {}",
2824                err_msg
2825            )));
2826        }
2827
2828        Fragment::update(fragment::ActiveModel {
2829            fragment_id: Set(fragment_id),
2830            stream_node: Set(StreamNode::from(&pb_stream_node)),
2831            ..Default::default()
2832        })
2833        .exec(&txn)
2834        .await?;
2835
2836        txn.commit().await?;
2837
2838        Ok(pb_stream_node)
2839    }
2840
2841    pub async fn update_backfill_orders_by_job_id(
2842        &self,
2843        job_id: JobId,
2844        backfill_orders: Option<BackfillOrders>,
2845    ) -> MetaResult<()> {
2846        let inner = self.inner.write().await;
2847        let txn = inner.db.begin().await?;
2848
2849        ensure_job_not_canceled(job_id, &txn).await?;
2850
2851        streaming_job::ActiveModel {
2852            job_id: Set(job_id),
2853            backfill_orders: Set(backfill_orders),
2854            ..Default::default()
2855        }
2856        .update(&txn)
2857        .await?;
2858
2859        txn.commit().await?;
2860
2861        Ok(())
2862    }
2863
2864    // edit the `rate_limit` of the `Chain` node in given `table_id`'s fragments
2865    // return the actor_ids to be applied
2866    pub async fn update_backfill_rate_limit_by_job_id(
2867        &self,
2868        job_id: JobId,
2869        rate_limit: Option<u32>,
2870    ) -> MetaResult<HashMap<FragmentId, PbStreamNode>> {
2871        let update_backfill_rate_limit =
2872            |fragment_type_mask: FragmentTypeMask, stream_node: &mut PbStreamNode| {
2873                let mut found = false;
2874                if fragment_type_mask
2875                    .contains_any(FragmentTypeFlag::backfill_rate_limit_fragments())
2876                {
2877                    visit_stream_node_mut(stream_node, |node| match node {
2878                        PbNodeBody::StreamCdcScan(node) => {
2879                            node.rate_limit = rate_limit;
2880                            found = true;
2881                        }
2882                        PbNodeBody::StreamScan(node) => {
2883                            node.rate_limit = rate_limit;
2884                            found = true;
2885                        }
2886                        PbNodeBody::SourceBackfill(node) => {
2887                            node.rate_limit = rate_limit;
2888                            found = true;
2889                        }
2890                        PbNodeBody::LocalityProvider(node) => {
2891                            node.rate_limit = rate_limit;
2892                            found = true;
2893                        }
2894                        _ => {}
2895                    });
2896                }
2897                Ok(found)
2898            };
2899
2900        self.mutate_fragments_by_job_id(
2901            job_id,
2902            update_backfill_rate_limit,
2903            "stream scan node or source node not found",
2904        )
2905        .await
2906    }
2907
2908    // edit the `rate_limit` of the `Sink` node in given `table_id`'s fragments
2909    // return the actor_ids to be applied
2910    pub async fn update_sink_rate_limit_by_job_id(
2911        &self,
2912        sink_id: SinkId,
2913        rate_limit: Option<u32>,
2914    ) -> MetaResult<HashMap<FragmentId, PbStreamNode>> {
2915        let update_sink_rate_limit =
2916            |fragment_type_mask: FragmentTypeMask, stream_node: &mut PbStreamNode| {
2917                let mut found = Ok(false);
2918                if fragment_type_mask.contains_any(FragmentTypeFlag::sink_rate_limit_fragments()) {
2919                    visit_stream_node_mut(stream_node, |node| {
2920                        if found.is_err() {
2921                            return;
2922                        }
2923                        match update_sink_node_rate_limit(node, rate_limit) {
2924                            Ok(true) => found = Ok(true),
2925                            Ok(false) => {}
2926                            Err(err) => found = Err(err),
2927                        }
2928                    });
2929                }
2930                found
2931            };
2932
2933        self.mutate_fragments_by_job_id(
2934            sink_id.as_job_id(),
2935            update_sink_rate_limit,
2936            "sink node not found",
2937        )
2938        .await
2939    }
2940
2941    pub async fn update_dml_rate_limit_by_job_id(
2942        &self,
2943        job_id: JobId,
2944        rate_limit: Option<u32>,
2945    ) -> MetaResult<HashMap<FragmentId, PbStreamNode>> {
2946        let update_dml_rate_limit =
2947            |fragment_type_mask: FragmentTypeMask, stream_node: &mut PbStreamNode| {
2948                let mut found = false;
2949                if fragment_type_mask.contains_any(FragmentTypeFlag::dml_rate_limit_fragments()) {
2950                    visit_stream_node_mut(stream_node, |node| {
2951                        if let PbNodeBody::Dml(node) = node {
2952                            node.rate_limit = rate_limit;
2953                            found = true;
2954                        }
2955                    });
2956                }
2957                Ok(found)
2958            };
2959
2960        self.mutate_fragments_by_job_id(job_id, update_dml_rate_limit, "dml node not found")
2961            .await
2962    }
2963
2964    pub async fn update_source_props_by_source_id(
2965        &self,
2966        source_id: SourceId,
2967        alter_props: BTreeMap<String, String>,
2968        alter_secret_refs: BTreeMap<String, PbSecretRef>,
2969        skip_alter_on_fly_check: bool,
2970    ) -> MetaResult<WithOptionsSecResolved> {
2971        let inner = self.inner.read().await;
2972        let txn = inner.db.begin().await?;
2973
2974        let (source, _obj) = Source::find_by_id(source_id)
2975            .find_also_related(Object)
2976            .one(&txn)
2977            .await?
2978            .ok_or_else(|| {
2979                MetaError::catalog_id_not_found(ObjectType::Source.as_str(), source_id)
2980            })?;
2981        ensure_source_props_not_set_by_connection(&txn, &source, &alter_props, &alter_secret_refs)
2982            .await?;
2983        let connector = source.with_properties.0.get_connector().unwrap();
2984        let is_shared_source = source.is_shared();
2985
2986        let mut dep_source_job_ids: Vec<JobId> = Vec::new();
2987        if !is_shared_source {
2988            // mv using non-shared source holds a copy of source in their fragments
2989            dep_source_job_ids = ObjectDependency::find()
2990                .select_only()
2991                .column(object_dependency::Column::UsedBy)
2992                .filter(object_dependency::Column::Oid.eq(source_id))
2993                .into_tuple()
2994                .all(&txn)
2995                .await?;
2996        }
2997
2998        // Validate that connector type is not being changed
2999        if let Some(new_connector) = alter_props.get(UPSTREAM_SOURCE_KEY)
3000            && new_connector != &connector
3001        {
3002            return Err(MetaError::invalid_parameter(format!(
3003                "Cannot change connector type from '{}' to '{}'. Drop and recreate the source instead.",
3004                connector, new_connector
3005            )));
3006        }
3007
3008        // Only check alter-on-fly restrictions for SQL ALTER SOURCE, not for admin risectl operations
3009        if !skip_alter_on_fly_check {
3010            dispatch_cdc_source_type!(
3011                CdcSourceType::from(connector.as_str()),
3012                T,
3013                T::check_alter_with_options(&alter_props, &alter_secret_refs)?,
3014                {
3015                    let prop_keys: Vec<String> = alter_props
3016                        .keys()
3017                        .chain(alter_secret_refs.keys())
3018                        .cloned()
3019                        .collect();
3020                    risingwave_connector::allow_alter_on_fly_fields::check_source_allow_alter_on_fly_fields(
3021                        &connector, &prop_keys,
3022                    )?
3023                }
3024            );
3025        }
3026
3027        let mut options_with_secret = WithOptionsSecResolved::new(
3028            source.with_properties.0.clone(),
3029            source
3030                .secret_ref
3031                .map(|secret_ref| secret_ref.to_protobuf())
3032                .unwrap_or_default(),
3033        );
3034        let altered_options_with_secret =
3035            WithOptionsSecResolved::new(alter_props.clone(), alter_secret_refs.clone());
3036        let (to_add_secret_dep, to_remove_secret_dep) =
3037            options_with_secret.handle_update(alter_props, alter_secret_refs)?;
3038
3039        tracing::info!(
3040            "applying new properties to source: source_id={}, options_with_secret={:?}",
3041            source_id,
3042            options_with_secret
3043        );
3044        // check if the alter-ed props are valid for each Connector
3045        let _ = ConnectorProperties::extract(options_with_secret.clone(), true)?;
3046        // todo: validate via source manager
3047
3048        let mut associate_table_id = None;
3049
3050        // can be source_id or table_id
3051        // if updating an associated source, the preferred_id is the table_id
3052        // otherwise, it is the source_id
3053        let mut preferred_id = source_id.as_object_id();
3054        let rewrite_sql = {
3055            let definition = source.definition.clone();
3056
3057            let [mut stmt]: [_; 1] = Parser::parse_sql(&definition)
3058                .map_err(|e| {
3059                    MetaError::from(MetaErrorInner::Connector(ConnectorError::from(
3060                        anyhow!(e).context("Failed to parse source definition SQL"),
3061                    )))
3062                })?
3063                .try_into()
3064                .unwrap();
3065
3066            match &mut stmt {
3067                Statement::CreateSource { stmt } => {
3068                    let altered_sql_options =
3069                        format_with_option_secret_resolved(&txn, &altered_options_with_secret)
3070                            .await?;
3071                    merge_with_options(&mut stmt.with_properties.0, altered_sql_options);
3072                }
3073                Statement::CreateTable { with_options, .. } => {
3074                    let altered_sql_options =
3075                        format_with_option_secret_resolved(&txn, &altered_options_with_secret)
3076                            .await?;
3077                    merge_with_options(with_options, altered_sql_options);
3078                    associate_table_id = source.optional_associated_table_id;
3079                    preferred_id = associate_table_id.unwrap().as_object_id();
3080                }
3081                _ => unreachable!(),
3082            }
3083
3084            stmt.to_string()
3085        };
3086
3087        {
3088            // Update secret dependencies atomically within the transaction.
3089            // Add new dependencies for secrets that are newly referenced.
3090            if !to_add_secret_dep.is_empty() {
3091                ObjectDependency::insert_many(to_add_secret_dep.into_iter().map(|secret_id| {
3092                    object_dependency::ActiveModel {
3093                        oid: Set(secret_id.into()),
3094                        used_by: Set(preferred_id),
3095                        ..Default::default()
3096                    }
3097                }))
3098                .exec(&txn)
3099                .await?;
3100            }
3101            // Remove dependencies for secrets that are no longer referenced.
3102            // This allows the secrets to be deleted after this source no longer uses them.
3103            if !to_remove_secret_dep.is_empty() {
3104                let _ = ObjectDependency::delete_many()
3105                    .filter(
3106                        object_dependency::Column::Oid
3107                            .is_in(to_remove_secret_dep)
3108                            .and(object_dependency::Column::UsedBy.eq(preferred_id)),
3109                    )
3110                    .exec(&txn)
3111                    .await?;
3112            }
3113        }
3114
3115        let active_source_model = source::ActiveModel {
3116            source_id: Set(source_id),
3117            definition: Set(rewrite_sql.clone()),
3118            with_properties: Set(options_with_secret.as_plaintext().clone().into()),
3119            secret_ref: Set((!options_with_secret.as_secret().is_empty())
3120                .then(|| SecretRef::from(options_with_secret.as_secret().clone()))),
3121            ..Default::default()
3122        };
3123        Source::update(active_source_model).exec(&txn).await?;
3124
3125        if let Some(associate_table_id) = associate_table_id {
3126            // update the associated table statement accordly
3127            let active_table_model = table::ActiveModel {
3128                table_id: Set(associate_table_id),
3129                definition: Set(rewrite_sql),
3130                ..Default::default()
3131            };
3132            Table::update(active_table_model).exec(&txn).await?;
3133        }
3134
3135        let to_check_job_ids = vec![if let Some(associate_table_id) = associate_table_id {
3136            // if updating table with connector, the fragment_id is table id
3137            associate_table_id.as_job_id()
3138        } else {
3139            source_id.as_share_source_job_id()
3140        }]
3141        .into_iter()
3142        .chain(dep_source_job_ids)
3143        .collect_vec();
3144
3145        // update fragments
3146        update_connector_props_fragments(
3147            &txn,
3148            to_check_job_ids,
3149            FragmentTypeFlag::Source,
3150            |node, found| {
3151                if let PbNodeBody::Source(node) = node
3152                    && let Some(source_inner) = &mut node.source_inner
3153                {
3154                    source_inner.with_properties = options_with_secret.as_plaintext().clone();
3155                    source_inner.secret_refs = options_with_secret.as_secret().clone();
3156                    *found = true;
3157                }
3158            },
3159            is_shared_source,
3160        )
3161        .await?;
3162
3163        let mut to_update_objs = Vec::with_capacity(2);
3164        let (source, obj) = Source::find_by_id(source_id)
3165            .find_also_related(Object)
3166            .one(&txn)
3167            .await?
3168            .ok_or_else(|| {
3169                MetaError::catalog_id_not_found(ObjectType::Source.as_str(), source_id)
3170            })?;
3171        to_update_objs.push(PbObject {
3172            object_info: Some(PbObjectInfo::Source(
3173                ObjectModel(source, obj.unwrap(), None).into(),
3174            )),
3175        });
3176
3177        if let Some(associate_table_id) = associate_table_id {
3178            let (table, obj) = Table::find_by_id(associate_table_id)
3179                .find_also_related(Object)
3180                .one(&txn)
3181                .await?
3182                .ok_or_else(|| MetaError::catalog_id_not_found("table", associate_table_id))?;
3183            let streaming_job = streaming_job::Entity::find_by_id(table.job_id())
3184                .one(&txn)
3185                .await?;
3186            to_update_objs.push(PbObject {
3187                object_info: Some(PbObjectInfo::Table(
3188                    ObjectModel(table, obj.unwrap(), streaming_job).into(),
3189                )),
3190            });
3191        }
3192
3193        txn.commit().await?;
3194
3195        let _version = self
3196            .notify_frontend(
3197                NotificationOperation::Update,
3198                NotificationInfo::ObjectGroup(PbObjectGroup {
3199                    objects: to_update_objs,
3200                    dependencies: vec![],
3201                }),
3202            )
3203            .await;
3204
3205        Ok(options_with_secret)
3206    }
3207
3208    pub async fn update_sink_props_by_sink_id(
3209        &self,
3210        sink_id: SinkId,
3211        props: BTreeMap<String, String>,
3212    ) -> MetaResult<HashMap<String, String>> {
3213        let inner = self.inner.read().await;
3214        let txn = inner.db.begin().await?;
3215
3216        let (sink, _obj) = Sink::find_by_id(sink_id)
3217            .find_also_related(Object)
3218            .one(&txn)
3219            .await?
3220            .ok_or_else(|| MetaError::catalog_id_not_found(ObjectType::Sink.as_str(), sink_id))?;
3221        validate_sink_props(&sink, &props, &props.keys().cloned().collect_vec())?;
3222        let definition = sink.definition.clone();
3223        let [mut stmt]: [_; 1] = Parser::parse_sql(&definition)
3224            .map_err(|e| SinkError::Config(anyhow!(e)))?
3225            .try_into()
3226            .unwrap();
3227        if let Statement::CreateSink { stmt } = &mut stmt {
3228            update_stmt_with_props(&mut stmt.with_properties.0, &props)?;
3229        } else {
3230            return Err(MetaError::invalid_parameter(
3231                "cannot alter an internal Iceberg sink; use ALTER TABLE ... CONNECTOR WITH (...) instead",
3232            ));
3233        }
3234        let mut new_config = sink.properties.clone().into_inner();
3235        new_config.extend(props.clone());
3236
3237        let definition = stmt.to_string();
3238        let active_sink = sink::ActiveModel {
3239            sink_id: Set(sink_id),
3240            properties: Set(risingwave_meta_model::Property(new_config.clone())),
3241            definition: Set(definition),
3242            ..Default::default()
3243        };
3244        Sink::update(active_sink).exec(&txn).await?;
3245
3246        update_sink_fragment_props(&txn, sink_id, new_config).await?;
3247        let (sink, obj) = Sink::find_by_id(sink_id)
3248            .find_also_related(Object)
3249            .one(&txn)
3250            .await?
3251            .ok_or_else(|| MetaError::catalog_id_not_found(ObjectType::Sink.as_str(), sink_id))?;
3252        let streaming_job = streaming_job::Entity::find_by_id(sink.sink_id.as_job_id())
3253            .one(&txn)
3254            .await?;
3255        txn.commit().await?;
3256        let relation_infos = vec![PbObject {
3257            object_info: Some(PbObjectInfo::Sink(
3258                ObjectModel(sink, obj.unwrap(), streaming_job).into(),
3259            )),
3260        }];
3261
3262        let _version = self
3263            .notify_frontend(
3264                NotificationOperation::Update,
3265                NotificationInfo::ObjectGroup(PbObjectGroup {
3266                    objects: relation_infos,
3267                    dependencies: vec![],
3268                }),
3269            )
3270            .await;
3271
3272        Ok(props.into_iter().collect())
3273    }
3274
3275    pub async fn update_iceberg_table_props_by_table_id(
3276        &self,
3277        table_id: TableId,
3278        props: BTreeMap<String, String>,
3279        alter_iceberg_table_props: Option<
3280            risingwave_pb::meta::alter_connector_props_request::PbExtraOptions,
3281        >,
3282    ) -> MetaResult<(HashMap<String, String>, SinkId)> {
3283        let risingwave_pb::meta::alter_connector_props_request::PbExtraOptions::AlterIcebergTableIds(AlterIcebergTableIds { sink_id, source_id }) = alter_iceberg_table_props.
3284            ok_or_else(|| MetaError::invalid_parameter("alter_iceberg_table_props is required"))?;
3285        let inner = self.inner.read().await;
3286        let txn = inner.db.begin().await?;
3287
3288        let (sink, _obj) = Sink::find_by_id(sink_id)
3289            .find_also_related(Object)
3290            .one(&txn)
3291            .await?
3292            .ok_or_else(|| MetaError::catalog_id_not_found(ObjectType::Sink.as_str(), sink_id))?;
3293        validate_sink_props(&sink, &props, &props.keys().cloned().collect_vec())?;
3294
3295        let definition = sink.definition.clone();
3296        let [mut stmt]: [_; 1] = Parser::parse_sql(&definition)
3297            .map_err(|e| SinkError::Config(anyhow!(e)))?
3298            .try_into()
3299            .unwrap();
3300        if let Statement::CreateTable {
3301            with_options,
3302            engine,
3303            ..
3304        } = &mut stmt
3305        {
3306            if !matches!(engine, Engine::Iceberg) {
3307                return Err(SinkError::Config(anyhow!(
3308                    "only iceberg table can be altered as sink"
3309                ))
3310                .into());
3311            }
3312            update_stmt_with_props(with_options, &props)?;
3313        } else {
3314            return Err(MetaError::invalid_parameter(
3315                "definition is not a create iceberg table statement",
3316            ));
3317        }
3318        let mut new_config = sink.properties.clone().into_inner();
3319        new_config.extend(props.clone());
3320
3321        let definition = stmt.to_string();
3322        let active_sink = sink::ActiveModel {
3323            sink_id: Set(sink_id),
3324            properties: Set(risingwave_meta_model::Property(new_config.clone())),
3325            definition: Set(definition.clone()),
3326            ..Default::default()
3327        };
3328        let active_source = source::ActiveModel {
3329            source_id: Set(source_id),
3330            definition: Set(definition.clone()),
3331            ..Default::default()
3332        };
3333        let active_table = table::ActiveModel {
3334            table_id: Set(table_id),
3335            definition: Set(definition),
3336            ..Default::default()
3337        };
3338        Sink::update(active_sink).exec(&txn).await?;
3339        Source::update(active_source).exec(&txn).await?;
3340        Table::update(active_table).exec(&txn).await?;
3341
3342        update_sink_fragment_props(&txn, sink_id, new_config).await?;
3343
3344        let (sink, sink_obj) = Sink::find_by_id(sink_id)
3345            .find_also_related(Object)
3346            .one(&txn)
3347            .await?
3348            .ok_or_else(|| MetaError::catalog_id_not_found(ObjectType::Sink.as_str(), sink_id))?;
3349        let sink_streaming_job = streaming_job::Entity::find_by_id(sink.sink_id.as_job_id())
3350            .one(&txn)
3351            .await?;
3352        let (source, source_obj) = Source::find_by_id(source_id)
3353            .find_also_related(Object)
3354            .one(&txn)
3355            .await?
3356            .ok_or_else(|| {
3357                MetaError::catalog_id_not_found(ObjectType::Source.as_str(), source_id)
3358            })?;
3359        let (table, table_obj) = Table::find_by_id(table_id)
3360            .find_also_related(Object)
3361            .one(&txn)
3362            .await?
3363            .ok_or_else(|| MetaError::catalog_id_not_found(ObjectType::Table.as_str(), table_id))?;
3364        let table_streaming_job = streaming_job::Entity::find_by_id(table.job_id())
3365            .one(&txn)
3366            .await?;
3367        txn.commit().await?;
3368        let relation_infos = vec![
3369            PbObject {
3370                object_info: Some(PbObjectInfo::Sink(
3371                    ObjectModel(sink, sink_obj.unwrap(), sink_streaming_job).into(),
3372                )),
3373            },
3374            PbObject {
3375                object_info: Some(PbObjectInfo::Source(
3376                    ObjectModel(source, source_obj.unwrap(), None).into(),
3377                )),
3378            },
3379            PbObject {
3380                object_info: Some(PbObjectInfo::Table(
3381                    ObjectModel(table, table_obj.unwrap(), table_streaming_job).into(),
3382                )),
3383            },
3384        ];
3385        let _version = self
3386            .notify_frontend(
3387                NotificationOperation::Update,
3388                NotificationInfo::ObjectGroup(PbObjectGroup {
3389                    objects: relation_infos,
3390                    dependencies: vec![],
3391                }),
3392            )
3393            .await;
3394
3395        Ok((props.into_iter().collect(), sink_id))
3396    }
3397
3398    /// Update connection properties and all dependent sources/sinks in a single transaction
3399    pub async fn update_connection_and_dependent_objects_props(
3400        &self,
3401        connection_id: ConnectionId,
3402        alter_props: BTreeMap<String, String>,
3403        alter_secret_refs: BTreeMap<String, PbSecretRef>,
3404    ) -> MetaResult<(
3405        WithOptionsSecResolved,                   // Connection's new properties
3406        Vec<(SourceId, HashMap<String, String>)>, // Source ID and their complete properties
3407        Vec<(SinkId, HashMap<String, String>)>,   // Sink ID and their complete properties
3408    )> {
3409        let inner = self.inner.read().await;
3410        let txn = inner.db.begin().await?;
3411
3412        // Find all dependent sources and sinks first
3413        let dependent_sources: Vec<SourceId> = Source::find()
3414            .select_only()
3415            .column(source::Column::SourceId)
3416            .filter(source::Column::ConnectionId.eq(connection_id))
3417            .into_tuple()
3418            .all(&txn)
3419            .await?;
3420
3421        let dependent_sinks: Vec<SinkId> = Sink::find()
3422            .select_only()
3423            .column(sink::Column::SinkId)
3424            .filter(sink::Column::ConnectionId.eq(connection_id))
3425            .into_tuple()
3426            .all(&txn)
3427            .await?;
3428
3429        let (connection_catalog, _obj) = Connection::find_by_id(connection_id)
3430            .find_also_related(Object)
3431            .one(&txn)
3432            .await?
3433            .ok_or_else(|| {
3434                MetaError::catalog_id_not_found(ObjectType::Connection.as_str(), connection_id)
3435            })?;
3436
3437        // Validate that props can be altered
3438        let prop_keys: Vec<String> = alter_props
3439            .keys()
3440            .chain(alter_secret_refs.keys())
3441            .cloned()
3442            .collect();
3443
3444        // Map the connection type enum to the string name expected by the validation function
3445        let connection_type_str = pb_connection_type_to_connection_type(
3446            &connection_catalog.params.to_protobuf().connection_type(),
3447        )
3448        .ok_or_else(|| MetaError::invalid_parameter("Unspecified connection type"))?;
3449
3450        risingwave_connector::allow_alter_on_fly_fields::check_connection_allow_alter_on_fly_fields(
3451            connection_type_str, &prop_keys,
3452        )?;
3453
3454        let connection_pb = connection_catalog.params.to_protobuf();
3455        let mut connection_options_with_secret = WithOptionsSecResolved::new(
3456            connection_pb.properties.into_iter().collect(),
3457            connection_pb.secret_refs.into_iter().collect(),
3458        );
3459
3460        let (to_add_secret_dep, to_remove_secret_dep) = connection_options_with_secret
3461            .handle_update(alter_props.clone(), alter_secret_refs.clone())?;
3462
3463        tracing::debug!(
3464            "applying new properties to connection and dependents: connection_id={}, sources={:?}, sinks={:?}",
3465            connection_id,
3466            dependent_sources,
3467            dependent_sinks
3468        );
3469
3470        // Validate connection
3471        {
3472            let conn_params_pb = risingwave_pb::catalog::ConnectionParams {
3473                connection_type: connection_pb.connection_type,
3474                properties: connection_options_with_secret
3475                    .as_plaintext()
3476                    .clone()
3477                    .into_iter()
3478                    .collect(),
3479                secret_refs: connection_options_with_secret
3480                    .as_secret()
3481                    .clone()
3482                    .into_iter()
3483                    .collect(),
3484            };
3485            let connection = PbConnection {
3486                id: connection_id as _,
3487                info: Some(risingwave_pb::catalog::connection::Info::ConnectionParams(
3488                    conn_params_pb,
3489                )),
3490                ..Default::default()
3491            };
3492            validate_connection(&connection).await?;
3493        }
3494
3495        // Update connection secret dependencies
3496        if !to_add_secret_dep.is_empty() {
3497            ObjectDependency::insert_many(to_add_secret_dep.into_iter().map(|secret_id| {
3498                object_dependency::ActiveModel {
3499                    oid: Set(secret_id.into()),
3500                    used_by: Set(connection_id.as_object_id()),
3501                    ..Default::default()
3502                }
3503            }))
3504            .exec(&txn)
3505            .await?;
3506        }
3507        if !to_remove_secret_dep.is_empty() {
3508            let _ = ObjectDependency::delete_many()
3509                .filter(
3510                    object_dependency::Column::Oid
3511                        .is_in(to_remove_secret_dep)
3512                        .and(object_dependency::Column::UsedBy.eq(connection_id.as_object_id())),
3513                )
3514                .exec(&txn)
3515                .await?;
3516        }
3517
3518        // Update the connection with new properties
3519        let updated_connection_params = risingwave_pb::catalog::ConnectionParams {
3520            connection_type: connection_pb.connection_type,
3521            properties: connection_options_with_secret
3522                .as_plaintext()
3523                .clone()
3524                .into_iter()
3525                .collect(),
3526            secret_refs: connection_options_with_secret
3527                .as_secret()
3528                .clone()
3529                .into_iter()
3530                .collect(),
3531        };
3532        let active_connection_model = connection::ActiveModel {
3533            connection_id: Set(connection_id),
3534            params: Set(ConnectionParams::from(&updated_connection_params)),
3535            ..Default::default()
3536        };
3537        Connection::update(active_connection_model)
3538            .exec(&txn)
3539            .await?;
3540
3541        // Batch update dependent sources and collect their complete properties
3542        let mut updated_sources_with_props: Vec<(SourceId, HashMap<String, String>)> = Vec::new();
3543
3544        if !dependent_sources.is_empty() {
3545            // Batch fetch all dependent sources
3546            let sources_with_objs = Source::find()
3547                .find_also_related(Object)
3548                .filter(source::Column::SourceId.is_in(dependent_sources.iter().cloned()))
3549                .all(&txn)
3550                .await?;
3551
3552            // Prepare batch updates
3553            let mut source_updates = Vec::new();
3554            let mut fragment_updates: Vec<DependentSourceFragmentUpdate> = Vec::new();
3555
3556            for (source, _obj) in sources_with_objs {
3557                let source_id = source.source_id;
3558
3559                let mut source_options_with_secret = WithOptionsSecResolved::new(
3560                    source.with_properties.0.clone(),
3561                    source
3562                        .secret_ref
3563                        .clone()
3564                        .map(|secret_ref| secret_ref.to_protobuf())
3565                        .unwrap_or_default(),
3566                );
3567                let (source_to_add_secret_dep, source_to_remove_secret_dep) =
3568                    source_options_with_secret
3569                        .handle_update(alter_props.clone(), alter_secret_refs.clone())?;
3570
3571                // Validate the updated source properties
3572                let _ = ConnectorProperties::extract(source_options_with_secret.clone(), true)?;
3573
3574                // Keep source-level secret dependencies in sync with the source properties that
3575                // are rewritten from the altered connection.
3576                let source_used_by_id = source
3577                    .optional_associated_table_id
3578                    .map(|table_id| table_id.as_object_id())
3579                    .unwrap_or_else(|| source_id.as_object_id());
3580                if !source_to_add_secret_dep.is_empty() {
3581                    ObjectDependency::insert_many(source_to_add_secret_dep.into_iter().map(
3582                        |secret_id| object_dependency::ActiveModel {
3583                            oid: Set(secret_id.into()),
3584                            used_by: Set(source_used_by_id),
3585                            ..Default::default()
3586                        },
3587                    ))
3588                    .exec(&txn)
3589                    .await?;
3590                }
3591                if !source_to_remove_secret_dep.is_empty() {
3592                    let _ = ObjectDependency::delete_many()
3593                        .filter(
3594                            object_dependency::Column::Oid
3595                                .is_in(source_to_remove_secret_dep)
3596                                .and(object_dependency::Column::UsedBy.eq(source_used_by_id)),
3597                        )
3598                        .exec(&txn)
3599                        .await?;
3600                }
3601
3602                // Prepare source update
3603                let active_source = source::ActiveModel {
3604                    source_id: Set(source_id),
3605                    with_properties: Set(Property(
3606                        source_options_with_secret.as_plaintext().clone(),
3607                    )),
3608                    secret_ref: Set((!source_options_with_secret.as_secret().is_empty()).then(
3609                        || {
3610                            risingwave_meta_model::SecretRef::from(
3611                                source_options_with_secret.as_secret().clone(),
3612                            )
3613                        },
3614                    )),
3615                    ..Default::default()
3616                };
3617                source_updates.push(active_source);
3618
3619                // Prepare fragment update:
3620                // - If the source is a table-associated source, update fragments for the table job.
3621                // - Otherwise update the shared source job.
3622                // - For non-shared sources, also update any dependent streaming jobs that embed a copy.
3623                let is_shared_source = source.is_shared();
3624                let mut dep_source_job_ids: Vec<JobId> = Vec::new();
3625                if !is_shared_source {
3626                    dep_source_job_ids = ObjectDependency::find()
3627                        .select_only()
3628                        .column(object_dependency::Column::UsedBy)
3629                        .filter(object_dependency::Column::Oid.eq(source_id))
3630                        .into_tuple()
3631                        .all(&txn)
3632                        .await?;
3633                }
3634
3635                let base_job_id =
3636                    if let Some(associate_table_id) = source.optional_associated_table_id {
3637                        associate_table_id.as_job_id()
3638                    } else {
3639                        source_id.as_share_source_job_id()
3640                    };
3641                let job_ids = vec![base_job_id]
3642                    .into_iter()
3643                    .chain(dep_source_job_ids)
3644                    .collect_vec();
3645
3646                fragment_updates.push(DependentSourceFragmentUpdate {
3647                    job_ids,
3648                    with_properties: source_options_with_secret.as_plaintext().clone(),
3649                    secret_refs: source_options_with_secret.as_secret().clone(),
3650                    is_shared_source,
3651                });
3652
3653                // Collect the complete properties for runtime broadcast
3654                let complete_source_props = LocalSecretManager::global()
3655                    .fill_secrets(
3656                        source_options_with_secret.as_plaintext().clone(),
3657                        source_options_with_secret.as_secret().clone(),
3658                    )
3659                    .map_err(MetaError::from)?
3660                    .into_iter()
3661                    .collect::<HashMap<String, String>>();
3662                updated_sources_with_props.push((source_id, complete_source_props));
3663            }
3664
3665            for source_update in source_updates {
3666                Source::update(source_update).exec(&txn).await?;
3667            }
3668
3669            // Batch execute fragment updates
3670            for DependentSourceFragmentUpdate {
3671                job_ids,
3672                with_properties,
3673                secret_refs,
3674                is_shared_source,
3675            } in fragment_updates
3676            {
3677                update_connector_props_fragments(
3678                    &txn,
3679                    job_ids,
3680                    FragmentTypeFlag::Source,
3681                    |node, found| {
3682                        if let PbNodeBody::Source(node) = node
3683                            && let Some(source_inner) = &mut node.source_inner
3684                        {
3685                            source_inner.with_properties = with_properties.clone();
3686                            source_inner.secret_refs = secret_refs.clone();
3687                            *found = true;
3688                        }
3689                    },
3690                    is_shared_source,
3691                )
3692                .await?;
3693            }
3694        }
3695
3696        // Batch update dependent sinks and collect their complete properties
3697        let mut updated_sinks_with_props: Vec<(SinkId, HashMap<String, String>)> = Vec::new();
3698
3699        if !dependent_sinks.is_empty() {
3700            // Batch fetch all dependent sinks
3701            let sinks_with_objs = Sink::find()
3702                .find_also_related(Object)
3703                .filter(sink::Column::SinkId.is_in(dependent_sinks.iter().cloned()))
3704                .all(&txn)
3705                .await?;
3706
3707            // Prepare batch updates
3708            let mut sink_updates = Vec::new();
3709            let mut sink_fragment_updates = Vec::new();
3710
3711            for (sink, _obj) in sinks_with_objs {
3712                let sink_id = sink.sink_id;
3713
3714                validate_sink_props(&sink, &alter_props, &prop_keys)?;
3715
3716                let mut new_sink_props = sink.properties.0.clone();
3717                new_sink_props.extend(alter_props.clone());
3718
3719                // Prepare sink update
3720                let active_sink = sink::ActiveModel {
3721                    sink_id: Set(sink_id),
3722                    properties: Set(risingwave_meta_model::Property(new_sink_props.clone())),
3723                    ..Default::default()
3724                };
3725                sink_updates.push(active_sink);
3726
3727                // Prepare fragment updates for this sink
3728                sink_fragment_updates.push((sink_id, new_sink_props.clone()));
3729
3730                // Collect the complete properties for runtime broadcast
3731                let complete_sink_props: HashMap<String, String> =
3732                    new_sink_props.into_iter().collect();
3733                updated_sinks_with_props.push((sink_id, complete_sink_props));
3734            }
3735
3736            // Batch execute sink updates
3737            for sink_update in sink_updates {
3738                Sink::update(sink_update).exec(&txn).await?;
3739            }
3740
3741            // Batch execute sink fragment updates using the reusable function
3742            for (sink_id, new_sink_props) in sink_fragment_updates {
3743                update_connector_props_fragments(
3744                    &txn,
3745                    vec![sink_id.as_job_id()],
3746                    FragmentTypeFlag::Sink,
3747                    |node, found| {
3748                        if let PbNodeBody::Sink(node) = node
3749                            && let Some(sink_desc) = &mut node.sink_desc
3750                            && sink_desc.id == sink_id.as_raw_id()
3751                        {
3752                            sink_desc.properties = new_sink_props.clone();
3753                            *found = true;
3754                        }
3755                    },
3756                    true,
3757                )
3758                .await?;
3759            }
3760        }
3761
3762        // Collect all updated objects for frontend notification
3763        let mut updated_objects = Vec::new();
3764
3765        // Add connection
3766        let (connection, obj) = Connection::find_by_id(connection_id)
3767            .find_also_related(Object)
3768            .one(&txn)
3769            .await?
3770            .ok_or_else(|| {
3771                MetaError::catalog_id_not_found(ObjectType::Connection.as_str(), connection_id)
3772            })?;
3773        updated_objects.push(PbObject {
3774            object_info: Some(PbObjectInfo::Connection(
3775                ObjectModel(connection, obj.unwrap(), None).into(),
3776            )),
3777        });
3778
3779        // Add sources
3780        for source_id in &dependent_sources {
3781            let (source, obj) = Source::find_by_id(*source_id)
3782                .find_also_related(Object)
3783                .one(&txn)
3784                .await?
3785                .ok_or_else(|| {
3786                    MetaError::catalog_id_not_found(ObjectType::Source.as_str(), *source_id)
3787                })?;
3788            updated_objects.push(PbObject {
3789                object_info: Some(PbObjectInfo::Source(
3790                    ObjectModel(source, obj.unwrap(), None).into(),
3791                )),
3792            });
3793        }
3794
3795        // Add sinks
3796        for sink_id in &dependent_sinks {
3797            let (sink, obj) = Sink::find_by_id(*sink_id)
3798                .find_also_related(Object)
3799                .one(&txn)
3800                .await?
3801                .ok_or_else(|| {
3802                    MetaError::catalog_id_not_found(ObjectType::Sink.as_str(), *sink_id)
3803                })?;
3804            let streaming_job = streaming_job::Entity::find_by_id(sink.sink_id.as_job_id())
3805                .one(&txn)
3806                .await?;
3807            updated_objects.push(PbObject {
3808                object_info: Some(PbObjectInfo::Sink(
3809                    ObjectModel(sink, obj.unwrap(), streaming_job).into(),
3810                )),
3811            });
3812        }
3813
3814        // Commit the transaction
3815        txn.commit().await?;
3816
3817        // Notify frontend about all updated objects
3818        if !updated_objects.is_empty() {
3819            let _version = self
3820                .notify_frontend(
3821                    NotificationOperation::Update,
3822                    NotificationInfo::ObjectGroup(PbObjectGroup {
3823                        objects: updated_objects,
3824                        dependencies: vec![],
3825                    }),
3826                )
3827                .await;
3828        }
3829
3830        Ok((
3831            connection_options_with_secret,
3832            updated_sources_with_props,
3833            updated_sinks_with_props,
3834        ))
3835    }
3836
3837    pub async fn update_fragment_rate_limit_by_fragment_id(
3838        &self,
3839        fragment_id: FragmentId,
3840        throttle_type: ThrottleType,
3841        rate_limit: Option<u32>,
3842    ) -> MetaResult<PbStreamNode> {
3843        let update_rate_limit = |fragment_type_mask: FragmentTypeMask,
3844                                 stream_node: &mut PbStreamNode| {
3845            let mut found = Ok(false);
3846            match throttle_type {
3847                ThrottleType::Source => {
3848                    visit_stream_node_mut(stream_node, |node| {
3849                        if found.is_err() {
3850                            return;
3851                        }
3852                        match update_source_node_rate_limit(node, rate_limit) {
3853                            Ok(true) => found = Ok(true),
3854                            Ok(false) => {}
3855                            Err(err) => found = Err(err),
3856                        }
3857                    });
3858                }
3859                ThrottleType::Backfill => {
3860                    if fragment_type_mask
3861                        .contains_any(FragmentTypeFlag::backfill_rate_limit_fragments())
3862                    {
3863                        visit_stream_node_mut(stream_node, |node| match node {
3864                            PbNodeBody::StreamCdcScan(node) => {
3865                                node.rate_limit = rate_limit;
3866                                found = Ok(true);
3867                            }
3868                            PbNodeBody::StreamScan(node) => {
3869                                node.rate_limit = rate_limit;
3870                                found = Ok(true);
3871                            }
3872                            PbNodeBody::SourceBackfill(node) => {
3873                                node.rate_limit = rate_limit;
3874                                found = Ok(true);
3875                            }
3876                            PbNodeBody::LocalityProvider(node) => {
3877                                node.rate_limit = rate_limit;
3878                                found = Ok(true);
3879                            }
3880                            _ => {}
3881                        });
3882                    }
3883                }
3884                ThrottleType::Sink => {
3885                    if fragment_type_mask
3886                        .contains_any(FragmentTypeFlag::sink_rate_limit_fragments())
3887                    {
3888                        visit_stream_node_mut(stream_node, |node| {
3889                            if found.is_err() {
3890                                return;
3891                            }
3892                            match update_sink_node_rate_limit(node, rate_limit) {
3893                                Ok(true) => found = Ok(true),
3894                                Ok(false) => {}
3895                                Err(err) => found = Err(err),
3896                            }
3897                        });
3898                    }
3899                }
3900                ThrottleType::Dml => {
3901                    if fragment_type_mask.contains_any(FragmentTypeFlag::dml_rate_limit_fragments())
3902                    {
3903                        visit_stream_node_mut(stream_node, |node| {
3904                            if let PbNodeBody::Dml(node) = node {
3905                                node.rate_limit = rate_limit;
3906                                found = Ok(true);
3907                            }
3908                        });
3909                    }
3910                }
3911                ThrottleType::Unspecified => {}
3912            }
3913            found
3914        };
3915        self.mutate_fragment_by_fragment_id(
3916            fragment_id,
3917            update_rate_limit,
3918            "rate limit node not found",
3919        )
3920        .await
3921    }
3922
3923    /// Note: `FsFetch` created in old versions are not included.
3924    /// Since this is only used for debugging, it should be fine.
3925    pub async fn list_rate_limits(&self) -> MetaResult<Vec<RateLimitInfo>> {
3926        let inner = self.inner.read().await;
3927        let txn = inner.db.begin().await?;
3928
3929        let fragments: Vec<(FragmentId, JobId, i32, StreamNode)> = Fragment::find()
3930            .select_only()
3931            .columns([
3932                fragment::Column::FragmentId,
3933                fragment::Column::JobId,
3934                fragment::Column::FragmentTypeMask,
3935                fragment::Column::StreamNode,
3936            ])
3937            .filter(FragmentTypeMask::intersects_any(
3938                FragmentTypeFlag::rate_limit_fragments(),
3939            ))
3940            .into_tuple()
3941            .all(&txn)
3942            .await?;
3943
3944        let mut rate_limits = Vec::new();
3945        for (fragment_id, job_id, fragment_type_mask, stream_node) in fragments {
3946            let stream_node = stream_node.to_protobuf();
3947            visit_stream_node_body(&stream_node, |node| {
3948                let mut rate_limit = None;
3949                let mut node_name = None;
3950
3951                match node {
3952                    // source rate limit
3953                    PbNodeBody::Source(node) => {
3954                        if let Some(node_inner) = &node.source_inner {
3955                            rate_limit = node_inner.rate_limit;
3956                            node_name = Some("SOURCE");
3957                        }
3958                    }
3959                    PbNodeBody::StreamFsFetch(node) => {
3960                        if let Some(node_inner) = &node.node_inner {
3961                            rate_limit = node_inner.rate_limit;
3962                            node_name = Some("FS_FETCH");
3963                        }
3964                    }
3965                    // backfill rate limit
3966                    PbNodeBody::SourceBackfill(node) => {
3967                        rate_limit = node.rate_limit;
3968                        node_name = Some("SOURCE_BACKFILL");
3969                    }
3970                    PbNodeBody::StreamScan(node) => {
3971                        rate_limit = node.rate_limit;
3972                        node_name = Some("STREAM_SCAN");
3973                    }
3974                    PbNodeBody::StreamCdcScan(node) => {
3975                        rate_limit = node.rate_limit;
3976                        node_name = Some("STREAM_CDC_SCAN");
3977                    }
3978                    PbNodeBody::LocalityProvider(node) => {
3979                        rate_limit = node.rate_limit;
3980                        node_name = Some("LOCALITY_PROVIDER");
3981                    }
3982                    PbNodeBody::Sink(node) => {
3983                        rate_limit = node.rate_limit;
3984                        node_name = Some("SINK");
3985                    }
3986                    PbNodeBody::Dml(node) => {
3987                        rate_limit = node.rate_limit;
3988                        node_name = Some("DML");
3989                    }
3990                    _ => {}
3991                }
3992
3993                if let Some(rate_limit) = rate_limit {
3994                    rate_limits.push(RateLimitInfo {
3995                        fragment_id,
3996                        job_id,
3997                        fragment_type_mask: fragment_type_mask as u32,
3998                        rate_limit,
3999                        node_name: node_name.unwrap().to_owned(),
4000                    });
4001                }
4002            });
4003        }
4004
4005        Ok(rate_limits)
4006    }
4007}
4008
4009/// Validates altering `alter_props` of a sink. `altered_field_names` are all the keys being
4010/// altered, including secret-backed ones that are not part of `alter_props`.
4011fn validate_sink_props(
4012    sink: &sink::Model,
4013    alter_props: &BTreeMap<String, String>,
4014    altered_field_names: &[String],
4015) -> MetaResult<()> {
4016    let Some(connector) = sink.properties.inner_ref().get(CONNECTOR_TYPE_KEY) else {
4017        return Err(SinkError::Config(anyhow!("connector not specified when alter sink")).into());
4018    };
4019    let connector_type = connector.to_lowercase();
4020    check_sink_allow_alter_on_fly_fields(&connector_type, altered_field_names)
4021        .map_err(|e| SinkError::Config(anyhow!(e)))?;
4022
4023    // Validate against the config the sink actually runs with: properties stored as secrets
4024    // live in `secret_ref` rather than `properties`, and some of them are required fields.
4025    let mut new_props = LocalSecretManager::global()
4026        .fill_secrets(
4027            sink.properties.0.clone(),
4028            sink.secret_ref
4029                .as_ref()
4030                .map(|secret_ref| secret_ref.to_protobuf())
4031                .unwrap_or_default(),
4032        )
4033        .map_err(MetaError::from)?;
4034    new_props.extend(alter_props.clone());
4035
4036    match_sink_name_str!(
4037        connector_type.as_str(),
4038        SinkType,
4039        SinkType::validate_alter_config_change(&new_props, alter_props),
4040        |sink: &str| Err(SinkError::Config(anyhow!("unsupported sink type {}", sink)))
4041    )?;
4042    Ok(())
4043}
4044
4045fn update_stmt_with_props(
4046    with_properties: &mut Vec<SqlOption>,
4047    props: &BTreeMap<String, String>,
4048) -> MetaResult<()> {
4049    let mut new_sql_options = with_properties
4050        .iter()
4051        .map(|sql_option| (&sql_option.name, sql_option))
4052        .collect::<IndexMap<_, _>>();
4053    let add_sql_options = props
4054        .iter()
4055        .map(|(k, v)| SqlOption::try_from((k, v)))
4056        .collect::<Result<Vec<SqlOption>, ParserError>>()
4057        .map_err(|e| SinkError::Config(anyhow!(e)))?;
4058    new_sql_options.extend(
4059        add_sql_options
4060            .iter()
4061            .map(|sql_option| (&sql_option.name, sql_option)),
4062    );
4063    *with_properties = new_sql_options.into_values().cloned().collect();
4064    Ok(())
4065}
4066
4067fn merge_with_options(with_properties: &mut Vec<SqlOption>, altered_options: Vec<SqlOption>) {
4068    for altered_option in altered_options {
4069        if let Some(existing_option) = with_properties
4070            .iter_mut()
4071            .find(|option| option.name.real_value() == altered_option.name.real_value())
4072        {
4073            *existing_option = altered_option;
4074        } else {
4075            with_properties.push(altered_option);
4076        }
4077    }
4078}
4079
4080async fn ensure_source_props_not_set_by_connection(
4081    txn: &DatabaseTransaction,
4082    source: &source::Model,
4083    alter_props: &BTreeMap<String, String>,
4084    alter_secret_refs: &BTreeMap<String, PbSecretRef>,
4085) -> MetaResult<()> {
4086    let Some(connection_id) = source.connection_id else {
4087        return Ok(());
4088    };
4089
4090    let connection = Connection::find_by_id(connection_id)
4091        .one(txn)
4092        .await?
4093        .ok_or_else(|| {
4094            MetaError::catalog_id_not_found(ObjectType::Connection.as_str(), connection_id)
4095        })?;
4096    let connection_params = connection.params.to_protobuf();
4097
4098    if let Some(key) = alter_props
4099        .keys()
4100        .chain(alter_secret_refs.keys())
4101        .find(|key| {
4102            connection_params.properties.contains_key(*key)
4103                || connection_params.secret_refs.contains_key(*key)
4104        })
4105    {
4106        return Err(MetaError::invalid_parameter(format!(
4107            "Cannot alter source connector property `{key}` because it is set by CONNECTION `{}`. Use ALTER CONNECTION instead.",
4108            connection.name
4109        )));
4110    }
4111
4112    Ok(())
4113}
4114
4115fn update_sink_node_props(
4116    node: &mut PbNodeBody,
4117    sink_id: SinkId,
4118    props: &BTreeMap<String, String>,
4119) -> bool {
4120    let sink_desc = match node {
4121        PbNodeBody::Sink(node) => &mut node.sink_desc,
4122        PbNodeBody::IcebergWithPkIndexWriter(node) => &mut node.sink_desc,
4123        PbNodeBody::IcebergWithPkIndexPositionDeleteMerger(node) => &mut node.sink_desc,
4124        PbNodeBody::CompactionResolver(node) if node.sink_id == sink_id => {
4125            node.properties.extend(props.clone());
4126            return true;
4127        }
4128        _ => return false,
4129    };
4130    if let Some(sink_desc) = sink_desc
4131        && sink_desc.id == sink_id
4132    {
4133        sink_desc.properties.extend(props.clone());
4134        true
4135    } else {
4136        false
4137    }
4138}
4139
4140async fn update_sink_fragment_props(
4141    txn: &DatabaseTransaction,
4142    sink_id: SinkId,
4143    props: BTreeMap<String, String>,
4144) -> MetaResult<()> {
4145    let fragments: Vec<(FragmentId, StreamNode)> = Fragment::find()
4146        .select_only()
4147        .columns([fragment::Column::FragmentId, fragment::Column::StreamNode])
4148        .filter(fragment::Column::JobId.eq(sink_id))
4149        .into_tuple()
4150        .all(txn)
4151        .await?;
4152    let fragments = fragments
4153        .into_iter()
4154        .filter_map(|(id, stream_node)| {
4155            let mut stream_node = stream_node.to_protobuf();
4156            let mut found = false;
4157            visit_stream_node_mut(&mut stream_node, |node| {
4158                found |= update_sink_node_props(node, sink_id, &props);
4159            });
4160            if found { Some((id, stream_node)) } else { None }
4161        })
4162        .collect_vec();
4163    if fragments.is_empty() {
4164        return Err(MetaError::invalid_parameter(format!(
4165            "sink {sink_id} is not used by any fragment"
4166        )));
4167    }
4168    for (id, stream_node) in fragments {
4169        Fragment::update(fragment::ActiveModel {
4170            fragment_id: Set(id),
4171            stream_node: Set(StreamNode::from(&stream_node)),
4172            ..Default::default()
4173        })
4174        .exec(txn)
4175        .await?;
4176    }
4177    Ok(())
4178}
4179
4180pub struct SinkIntoTableContext {
4181    /// For alter table (e.g., add column), this is the list of existing sink ids
4182    /// otherwise empty.
4183    pub updated_sink_catalogs: Vec<SinkId>,
4184}
4185
4186pub struct FinishAutoRefreshSchemaSinkContext {
4187    pub tmp_sink_id: SinkId,
4188    pub original_sink_id: SinkId,
4189    pub columns: Vec<PbColumnCatalog>,
4190    pub new_log_store_table: Option<Box<PbTable>>,
4191}
4192
4193async fn update_connector_props_fragments<F>(
4194    txn: &DatabaseTransaction,
4195    job_ids: Vec<JobId>,
4196    expect_flag: FragmentTypeFlag,
4197    mut alter_stream_node_fn: F,
4198    is_shared_source: bool,
4199) -> MetaResult<()>
4200where
4201    F: FnMut(&mut PbNodeBody, &mut bool),
4202{
4203    let fragments: Vec<(FragmentId, StreamNode)> = Fragment::find()
4204        .select_only()
4205        .columns([fragment::Column::FragmentId, fragment::Column::StreamNode])
4206        .filter(
4207            fragment::Column::JobId
4208                .is_in(job_ids.clone())
4209                .and(FragmentTypeMask::intersects(expect_flag)),
4210        )
4211        .into_tuple()
4212        .all(txn)
4213        .await?;
4214    let fragments = fragments
4215        .into_iter()
4216        .filter_map(|(id, stream_node)| {
4217            let mut stream_node = stream_node.to_protobuf();
4218            let mut found = false;
4219            visit_stream_node_mut(&mut stream_node, |node| {
4220                alter_stream_node_fn(node, &mut found);
4221            });
4222            if found { Some((id, stream_node)) } else { None }
4223        })
4224        .collect_vec();
4225    if is_shared_source || job_ids.len() > 1 {
4226        // the first element is the source_id or associated table_id
4227        // if the source is non-shared, there is no updated fragments
4228        // job_ids.len() > 1 means the source is used by other streaming jobs, so there should be at least one fragment updated
4229        assert!(
4230            !fragments.is_empty(),
4231            "job ids {:?} (type: {:?}) should be used by at least one fragment",
4232            job_ids,
4233            expect_flag
4234        );
4235    }
4236
4237    for (id, stream_node) in fragments {
4238        Fragment::update(fragment::ActiveModel {
4239            fragment_id: Set(id),
4240            stream_node: Set(StreamNode::from(&stream_node)),
4241            ..Default::default()
4242        })
4243        .exec(txn)
4244        .await?;
4245    }
4246
4247    Ok(())
4248}
4249
4250#[cfg(test)]
4251mod tests {
4252    use std::collections::BTreeMap;
4253
4254    use risingwave_pb::stream_plan::{
4255        CompactionResolverNode, IcebergWithPkIndexPositionDeleteMergerNode,
4256        IcebergWithPkIndexWriterNode, SinkDesc, SinkNode,
4257    };
4258    use risingwave_sqlparser::ast::{SqlOption, Statement};
4259
4260    use super::{Parser, PbNodeBody, SinkId, merge_with_options, update_sink_node_props};
4261
4262    #[test]
4263    fn test_update_sink_props_in_all_sink_nodes() {
4264        let sink_id = SinkId::new(42);
4265        let props = BTreeMap::from([("enable_compaction".to_owned(), "true".to_owned())]);
4266        let sink_desc = SinkDesc {
4267            id: sink_id,
4268            properties: BTreeMap::from([
4269                ("connector".to_owned(), "iceberg".to_owned()),
4270                ("enable_compaction".to_owned(), "false".to_owned()),
4271            ]),
4272            ..Default::default()
4273        };
4274        let nodes = [
4275            PbNodeBody::Sink(Box::new(SinkNode {
4276                sink_desc: Some(sink_desc.clone()),
4277                ..Default::default()
4278            })),
4279            PbNodeBody::IcebergWithPkIndexWriter(Box::new(IcebergWithPkIndexWriterNode {
4280                sink_desc: Some(sink_desc.clone()),
4281                ..Default::default()
4282            })),
4283            PbNodeBody::IcebergWithPkIndexPositionDeleteMerger(Box::new(
4284                IcebergWithPkIndexPositionDeleteMergerNode {
4285                    sink_desc: Some(sink_desc.clone()),
4286                },
4287            )),
4288            PbNodeBody::CompactionResolver(Box::new(CompactionResolverNode {
4289                sink_id,
4290                properties: sink_desc.properties,
4291                ..Default::default()
4292            })),
4293        ];
4294        for mut node in nodes {
4295            assert!(!update_sink_node_props(&mut node, SinkId::new(43), &props));
4296            assert!(update_sink_node_props(&mut node, sink_id, &props));
4297            let properties = match &node {
4298                PbNodeBody::Sink(node) => &node.sink_desc.as_ref().unwrap().properties,
4299                PbNodeBody::IcebergWithPkIndexWriter(node) => {
4300                    &node.sink_desc.as_ref().unwrap().properties
4301                }
4302                PbNodeBody::IcebergWithPkIndexPositionDeleteMerger(node) => {
4303                    &node.sink_desc.as_ref().unwrap().properties
4304                }
4305                PbNodeBody::CompactionResolver(node) => &node.properties,
4306                _ => unreachable!(),
4307            };
4308            assert_eq!(properties["enable_compaction"], "true");
4309            assert_eq!(properties["connector"], "iceberg");
4310        }
4311    }
4312
4313    #[test]
4314    fn test_merge_with_options_normalizes_altered_option_name() {
4315        let mut statements = Parser::parse_sql(
4316            "CREATE SOURCE s WITH (properties.receive.message.max.bytes = 'old', \
4317             connection = kafka_conn) FORMAT PLAIN ENCODE JSON",
4318        )
4319        .unwrap();
4320        let Statement::CreateSource { stmt } = statements.remove(0) else {
4321            unreachable!()
4322        };
4323        let mut with_properties = stmt.with_properties.0;
4324        let altered_name = "properties.receive.message.max.bytes".to_owned();
4325        let altered_value = "new".to_owned();
4326
4327        merge_with_options(
4328            &mut with_properties,
4329            vec![SqlOption::try_from((&altered_name, &altered_value)).unwrap()],
4330        );
4331
4332        assert_eq!(with_properties.len(), 2);
4333        assert_eq!(
4334            with_properties[0].to_string(),
4335            "properties.receive.\"message\".\"max\".bytes = 'new'"
4336        );
4337        assert_eq!(with_properties[1].to_string(), "connection = kafka_conn");
4338    }
4339}