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