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