import logging import time from collections.abc import Callable from typing import Any, cast import click import sqlalchemy as sa from agenton.compositor import CompositorSessionSnapshot from celery import shared_task from dify_agent.protocol import RuntimeLayerSpec from pydantic import JsonValue, TypeAdapter from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import sessionmaker from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload from configs import dify_config from core.db.session_factory import session_factory from extensions.ext_database import db from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage from libs.datetime_utils import naive_utc_now from models import ( AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus, ApiToken, AppAnnotationHitHistory, AppAnnotationSetting, AppDatasetJoin, AppMCPServer, AppModelConfig, AppStar, AppTrigger, Conversation, EndUser, InstalledApp, Message, MessageAgentThought, MessageAnnotation, MessageChain, MessageFeedback, MessageFile, RecommendedApp, Site, TagBinding, TraceAppConfig, WorkflowSchedulePlan, ) from models.tools import WorkflowToolProvider from models.trigger import WorkflowPluginTrigger, WorkflowTriggerLog, WorkflowWebhookTrigger from models.web import PinnedConversation, SavedMessage from models.workflow import ( ConversationVariable, Workflow, WorkflowAppLog, WorkflowArchiveLog, ) from repositories.factory import DifyAPIRepositoryFactory from services.api_token_service import ApiTokenCache from tasks.agent_backend_session_cleanup_task import ( cleanup_conversation_agent_runtime_session, cleanup_workflow_agent_runtime_session, ) logger = logging.getLogger(__name__) _RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) @shared_task(queue="app_deletion", bind=True, max_retries=3) def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): logger.info(click.style(f"Start deleting app and related data: {tenant_id}:{app_id}", fg="green")) start_at = time.perf_counter() try: _cleanup_active_agent_runtime_sessions_for_app(tenant_id, app_id) # Delete related data _delete_app_model_configs(tenant_id, app_id) _delete_app_site(tenant_id, app_id) _delete_app_mcp_servers(tenant_id, app_id) _delete_app_api_tokens(tenant_id, app_id) _delete_installed_apps(tenant_id, app_id) _delete_app_stars(tenant_id, app_id) _delete_recommended_apps(tenant_id, app_id) _delete_app_annotation_data(tenant_id, app_id) _delete_app_dataset_joins(tenant_id, app_id) _delete_app_workflows(tenant_id, app_id) _delete_app_workflow_runs(tenant_id, app_id) _delete_app_workflow_node_executions(tenant_id, app_id) _delete_app_workflow_app_logs(tenant_id, app_id) if dify_config.BILLING_ENABLED and dify_config.ARCHIVE_STORAGE_ENABLED: _delete_app_workflow_archive_logs(tenant_id, app_id) _delete_archived_workflow_run_files(tenant_id, app_id) _delete_app_conversations(tenant_id, app_id) _delete_app_messages(tenant_id, app_id) _delete_workflow_tool_providers(tenant_id, app_id) _delete_app_tag_bindings(tenant_id, app_id) _delete_end_users(tenant_id, app_id) _delete_trace_app_configs(tenant_id, app_id) _delete_conversation_variables(app_id=app_id) _delete_draft_variables(app_id) _delete_app_triggers(tenant_id, app_id) _delete_workflow_plugin_triggers(tenant_id, app_id) _delete_workflow_webhook_triggers(tenant_id, app_id) _delete_workflow_schedule_plans(tenant_id, app_id) _delete_workflow_trigger_logs(tenant_id, app_id) end_at = time.perf_counter() logger.info(click.style(f"App and related data deleted: {app_id} latency: {end_at - start_at}", fg="green")) except SQLAlchemyError as e: logger.exception(click.style(f"Database error occurred while deleting app {app_id} and related data", fg="red")) raise self.retry(exc=e, countdown=60) # Retry after 60 seconds except Exception as e: logger.exception(click.style(f"Error occurred while deleting app {app_id} and related data", fg="red")) raise self.retry(exc=e, countdown=60) # Retry after 60 seconds def _cleanup_active_agent_runtime_sessions_for_app(tenant_id: str, app_id: str, *, batch_size: int = 100) -> None: """Best-effort fan-out for ACTIVE Agent runtime sessions during app deletion. App deletion must not block on synchronous Agent backend lifecycle work, so this helper scans ACTIVE ``agent_runtime_sessions`` rows in batches, dispatches owner-specific cleanup tasks only when enough persisted data exists to replay a lifecycle-only run, and then marks each visited row ``CLEANED`` locally regardless of enqueue outcome. The local retirement is the contract that lets the rest of app deletion continue even when backend cleanup dispatch is skipped or fails. """ if batch_size <= 0: raise ValueError("batch_size must be positive") while True: with session_factory.create_session() as session: row_ids = session.scalars( select(AgentRuntimeSession.id) .where( AgentRuntimeSession.tenant_id == tenant_id, AgentRuntimeSession.app_id == app_id, AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE, ) .order_by(AgentRuntimeSession.updated_at.asc()) .limit(batch_size) ).all() if not row_ids: return retired_count = 0 for row_id in row_ids: with session_factory.create_session() as session: row = session.get(AgentRuntimeSession, row_id) if row is None or row.status != AgentRuntimeSessionStatus.ACTIVE: retired_count += 1 continue try: payload = _build_agent_runtime_session_cleanup_payload(row) if payload is not None: _enqueue_agent_runtime_session_cleanup(row=row, payload=payload) except Exception: logger.warning( "Failed to enqueue Agent backend cleanup during app deletion: " "tenant_id=%s app_id=%s owner_type=%s conversation_id=%s workflow_run_id=%s " "node_id=%s agent_id=%s backend_run_id=%s", row.tenant_id, row.app_id, row.owner_type, row.conversation_id, row.workflow_run_id, row.node_id, row.agent_id, row.backend_run_id, exc_info=True, ) finally: try: row.status = AgentRuntimeSessionStatus.CLEANED row.cleaned_at = naive_utc_now() session.commit() retired_count += 1 except Exception: session.rollback() logger.warning( "Failed to retire Agent runtime session during app deletion: " "tenant_id=%s app_id=%s owner_type=%s conversation_id=%s workflow_run_id=%s " "node_id=%s agent_id=%s backend_run_id=%s", row.tenant_id, row.app_id, row.owner_type, row.conversation_id, row.workflow_run_id, row.node_id, row.agent_id, row.backend_run_id, exc_info=True, ) if retired_count == 0: logger.warning( "Failed to retire any active Agent runtime sessions during app deletion: tenant_id=%s app_id=%s", tenant_id, app_id, ) return def _build_agent_runtime_session_cleanup_payload( row: AgentRuntimeSession, ) -> AgentBackendSessionCleanupPayload | None: runtime_layer_specs = _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(row.composition_layer_specs or "[]") if not runtime_layer_specs: return None metadata: dict[str, JsonValue] = { "tenant_id": row.tenant_id, "app_id": row.app_id, "agent_id": row.agent_id, "agent_config_snapshot_id": row.agent_config_snapshot_id, "previous_agent_backend_run_id": row.backend_run_id, } if row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION: metadata["conversation_id"] = row.conversation_id idempotency_key = ( f"{row.tenant_id}:{row.app_id}:{row.conversation_id}:" f"{row.agent_id}:app-delete-cleanup:{row.id or row.backend_run_id or 'no-session-id'}" ) else: metadata["workflow_run_id"] = row.workflow_run_id metadata["node_id"] = row.node_id idempotency_key = ( f"{row.tenant_id}:{row.app_id}:{row.workflow_run_id}:{row.node_id}:" f"{row.agent_id}:app-delete-cleanup:{row.id or row.backend_run_id or 'no-session-id'}" ) return AgentBackendSessionCleanupPayload( session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), runtime_layer_specs=runtime_layer_specs, idempotency_key=idempotency_key, metadata=metadata, ) def _enqueue_agent_runtime_session_cleanup( *, row: AgentRuntimeSession, payload: AgentBackendSessionCleanupPayload, ) -> None: payload_dict = payload.model_dump(mode="json") if row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION: cleanup_conversation_agent_runtime_session.delay(payload_dict) return cleanup_workflow_agent_runtime_session.delay(payload_dict) def _delete_app_model_configs(tenant_id: str, app_id: str): def del_model_config(session, model_config_id: str): session.execute( delete(AppModelConfig) .where(AppModelConfig.id == model_config_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from app_model_configs where app_id=:app_id limit 1000""", {"app_id": app_id}, del_model_config, "app model config", ) def _delete_app_site(tenant_id: str, app_id: str): def del_site(session, site_id: str): session.execute(delete(Site).where(Site.id == site_id).execution_options(synchronize_session=False)) _delete_records( """select id from sites where app_id=:app_id limit 1000""", {"app_id": app_id}, del_site, "site", ) def _delete_app_mcp_servers(tenant_id: str, app_id: str): def del_mcp_server(session, mcp_server_id: str): session.execute( delete(AppMCPServer).where(AppMCPServer.id == mcp_server_id).execution_options(synchronize_session=False) ) _delete_records( """select id from app_mcp_servers where app_id=:app_id limit 1000""", {"app_id": app_id}, del_mcp_server, "app mcp server", ) def _delete_app_api_tokens(tenant_id: str, app_id: str): def del_api_token(session, api_token_id: str): # Fetch token details for cache invalidation token_obj = session.scalar(select(ApiToken).where(ApiToken.id == api_token_id).limit(1)) if token_obj: # Invalidate cache before deletion ApiTokenCache.delete(token_obj.token, token_obj.type) session.execute( delete(ApiToken).where(ApiToken.id == api_token_id).execution_options(synchronize_session=False) ) _delete_records( """select id from api_tokens where app_id=:app_id limit 1000""", {"app_id": app_id}, del_api_token, "api token", ) def _delete_installed_apps(tenant_id: str, app_id: str): def del_installed_app(session, installed_app_id: str): session.execute( delete(InstalledApp).where(InstalledApp.id == installed_app_id).execution_options(synchronize_session=False) ) _delete_records( """select id from installed_apps where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_installed_app, "installed app", ) def _delete_app_stars(tenant_id: str, app_id: str): def del_app_star(session, app_star_id: str): session.execute(delete(AppStar).where(AppStar.id == app_star_id).execution_options(synchronize_session=False)) _delete_records( """select id from app_stars where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_app_star, "app star", ) def _delete_recommended_apps(tenant_id: str, app_id: str): def del_recommended_app(session, recommended_app_id: str): session.execute( delete(RecommendedApp) .where(RecommendedApp.id == recommended_app_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from recommended_apps where app_id=:app_id limit 1000""", {"app_id": app_id}, del_recommended_app, "recommended app", ) def _delete_app_annotation_data(tenant_id: str, app_id: str): def del_annotation_hit_history(session, annotation_hit_history_id: str): session.execute( delete(AppAnnotationHitHistory) .where(AppAnnotationHitHistory.id == annotation_hit_history_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from app_annotation_hit_histories where app_id=:app_id limit 1000""", {"app_id": app_id}, del_annotation_hit_history, "annotation hit history", ) def del_annotation_setting(session, annotation_setting_id: str): session.execute( delete(AppAnnotationSetting) .where(AppAnnotationSetting.id == annotation_setting_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from app_annotation_settings where app_id=:app_id limit 1000""", {"app_id": app_id}, del_annotation_setting, "annotation setting", ) def _delete_app_dataset_joins(tenant_id: str, app_id: str): def del_dataset_join(session, dataset_join_id: str): session.execute( delete(AppDatasetJoin) .where(AppDatasetJoin.id == dataset_join_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from app_dataset_joins where app_id=:app_id limit 1000""", {"app_id": app_id}, del_dataset_join, "dataset join", ) def _delete_app_workflows(tenant_id: str, app_id: str): def del_workflow(session, workflow_id: str): session.execute(delete(Workflow).where(Workflow.id == workflow_id).execution_options(synchronize_session=False)) _delete_records( """select id from workflows where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_workflow, "workflow", ) def _delete_app_workflow_runs(tenant_id: str, app_id: str): """Delete all workflow runs for an app using the service repository.""" session_maker = sessionmaker(bind=db.engine) workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) deleted_count = workflow_run_repo.delete_runs_by_app( tenant_id=tenant_id, app_id=app_id, batch_size=1000, ) logger.info("Deleted %s workflow runs for app %s", deleted_count, app_id) def _delete_app_workflow_node_executions(tenant_id: str, app_id: str): """Delete all workflow node executions for an app using the service repository.""" session_maker = sessionmaker(bind=db.engine) node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(session_maker) deleted_count = node_execution_repo.delete_executions_by_app( tenant_id=tenant_id, app_id=app_id, batch_size=1000, ) logger.info("Deleted %s workflow node executions for app %s", deleted_count, app_id) def _delete_app_workflow_app_logs(tenant_id: str, app_id: str): def del_workflow_app_log(session, workflow_app_log_id: str): session.execute( delete(WorkflowAppLog) .where(WorkflowAppLog.id == workflow_app_log_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from workflow_app_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_workflow_app_log, "workflow app log", ) def _delete_app_workflow_archive_logs(tenant_id: str, app_id: str): def del_workflow_archive_log(session, workflow_archive_log_id: str): session.execute( delete(WorkflowArchiveLog) .where(WorkflowArchiveLog.id == workflow_archive_log_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from workflow_archive_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_workflow_archive_log, "workflow archive log", ) def _delete_archived_workflow_run_files(tenant_id: str, app_id: str): prefix = f"{tenant_id}/app_id={app_id}/" try: archive_storage = get_archive_storage() except ArchiveStorageNotConfiguredError as e: logger.info("Archive storage not configured, skipping archive file cleanup: %s", e) return try: keys = archive_storage.list_objects(prefix) except Exception: logger.exception("Failed to list archive files for app %s", app_id) return deleted = 0 for key in keys: try: archive_storage.delete_object(key) deleted += 1 except Exception: logger.exception("Failed to delete archive object %s", key) logger.info("Deleted %s archive objects for app %s", deleted, app_id) def _delete_app_conversations(tenant_id: str, app_id: str): def del_conversation(session, conversation_id: str): session.execute( delete(PinnedConversation) .where(PinnedConversation.conversation_id == conversation_id) .execution_options(synchronize_session=False) ) session.execute( delete(Conversation).where(Conversation.id == conversation_id).execution_options(synchronize_session=False) ) _delete_records( """select id from conversations where app_id=:app_id limit 1000""", {"app_id": app_id}, del_conversation, "conversation", ) def _delete_conversation_variables(*, app_id: str): with session_factory.create_session() as session: stmt = delete(ConversationVariable).where(ConversationVariable.app_id == app_id) session.execute(stmt) session.commit() logger.info(click.style(f"Deleted conversation variables for app {app_id}", fg="green")) def _delete_app_messages(tenant_id: str, app_id: str): def del_message(session, message_id: str): session.execute( delete(MessageFeedback) .where(MessageFeedback.message_id == message_id) .execution_options(synchronize_session=False) ) session.execute( delete(MessageAnnotation) .where(MessageAnnotation.message_id == message_id) .execution_options(synchronize_session=False) ) session.execute( delete(MessageChain) .where(MessageChain.message_id == message_id) .execution_options(synchronize_session=False) ) session.execute( delete(MessageAgentThought) .where(MessageAgentThought.message_id == message_id) .execution_options(synchronize_session=False) ) session.execute( delete(MessageFile).where(MessageFile.message_id == message_id).execution_options(synchronize_session=False) ) session.execute( delete(SavedMessage) .where(SavedMessage.message_id == message_id) .execution_options(synchronize_session=False) ) session.execute(delete(Message).where(Message.id == message_id).execution_options(synchronize_session=False)) _delete_records( """select id from messages where app_id=:app_id limit 1000""", {"app_id": app_id}, del_message, "message", ) def _delete_workflow_tool_providers(tenant_id: str, app_id: str): def del_tool_provider(session, tool_provider_id: str): session.execute( delete(WorkflowToolProvider) .where(WorkflowToolProvider.id == tool_provider_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from tool_workflow_providers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_tool_provider, "tool workflow provider", ) def _delete_app_tag_bindings(tenant_id: str, app_id: str): def del_tag_binding(session, tag_binding_id: str): session.execute( delete(TagBinding).where(TagBinding.id == tag_binding_id).execution_options(synchronize_session=False) ) _delete_records( """select id from tag_bindings where tenant_id=:tenant_id and target_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_tag_binding, "tag binding", ) def _delete_end_users(tenant_id: str, app_id: str): def del_end_user(session, end_user_id: str): session.execute(delete(EndUser).where(EndUser.id == end_user_id).execution_options(synchronize_session=False)) _delete_records( """select id from end_users where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_end_user, "end user", ) def _delete_trace_app_configs(tenant_id: str, app_id: str): def del_trace_app_config(session, trace_app_config_id: str): session.execute( delete(TraceAppConfig) .where(TraceAppConfig.id == trace_app_config_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from trace_app_config where app_id=:app_id limit 1000""", {"app_id": app_id}, del_trace_app_config, "trace app config", ) def _delete_draft_variables(app_id: str): """Delete all workflow draft variables for an app in batches.""" return delete_draft_variables_batch(app_id, batch_size=1000) def delete_draft_variables_batch(app_id: str, batch_size: int = 1000) -> int: """ Delete draft variables for an app in batches. This function now handles cleanup of associated Offload data including: - WorkflowDraftVariableFile records - UploadFile records - Object storage files Args: app_id: The ID of the app whose draft variables should be deleted batch_size: Number of records to delete per batch Returns: Total number of records deleted """ if batch_size <= 0: raise ValueError("batch_size must be positive") total_deleted = 0 total_files_deleted = 0 while True: with session_factory.create_session() as session, session.begin(): # Get a batch of draft variable IDs along with their file_ids query_sql = """ SELECT id, file_id FROM workflow_draft_variables WHERE app_id = :app_id LIMIT :batch_size """ result = session.execute(sa.text(query_sql), {"app_id": app_id, "batch_size": batch_size}) rows = list(result) if not rows: break draft_var_ids = [row[0] for row in rows] file_ids = [row[1] for row in rows if row[1] is not None] # Clean up associated Offload data first if file_ids: files_deleted = _delete_draft_variable_offload_data(session, file_ids) total_files_deleted += files_deleted # Delete the draft variables delete_sql = """ DELETE FROM workflow_draft_variables WHERE id IN :ids """ deleted_result = cast( CursorResult[Any], session.execute(sa.text(delete_sql), {"ids": tuple(draft_var_ids)}), ) batch_deleted: int = int(getattr(deleted_result, "rowcount", 0) or 0) total_deleted += batch_deleted logger.info(click.style(f"Deleted {batch_deleted} draft variables (batch) for app {app_id}", fg="green")) logger.info( click.style( f"Deleted {total_deleted} total draft variables for app {app_id}. " f"Cleaned up {total_files_deleted} total associated files.", fg="green", ) ) return total_deleted def _delete_draft_variable_offload_data(session, file_ids: list[str]) -> int: """ Delete Offload data associated with WorkflowDraftVariable file_ids. This function: 1. Finds WorkflowDraftVariableFile records by file_ids 2. Deletes associated files from object storage 3. Deletes UploadFile records 4. Deletes WorkflowDraftVariableFile records Args: session: Database connection file_ids: List of WorkflowDraftVariableFile IDs Returns: Number of files cleaned up """ from extensions.ext_storage import storage if not file_ids: return 0 files_deleted = 0 try: # Get WorkflowDraftVariableFile records and their associated UploadFile keys query_sql = """ SELECT wdvf.id, uf.key, uf.id as upload_file_id FROM workflow_draft_variable_files wdvf JOIN upload_files uf ON wdvf.upload_file_id = uf.id WHERE wdvf.id IN :file_ids \ """ result = session.execute(sa.text(query_sql), {"file_ids": tuple(file_ids)}) file_records = list(result) # Delete from object storage and collect upload file IDs upload_file_ids = [] for _, storage_key, upload_file_id in file_records: try: storage.delete(storage_key) upload_file_ids.append(upload_file_id) files_deleted += 1 except Exception: logging.exception("Failed to delete storage object %s", storage_key) # Continue with database cleanup even if storage deletion fails upload_file_ids.append(upload_file_id) # Delete UploadFile records if upload_file_ids: delete_upload_files_sql = """ DELETE \ FROM upload_files WHERE id IN :upload_file_ids \ """ session.execute(sa.text(delete_upload_files_sql), {"upload_file_ids": tuple(upload_file_ids)}) # Delete WorkflowDraftVariableFile records delete_variable_files_sql = """ DELETE \ FROM workflow_draft_variable_files WHERE id IN :file_ids \ """ session.execute(sa.text(delete_variable_files_sql), {"file_ids": tuple(file_ids)}) except Exception: logging.exception("Error deleting draft variable offload data:") # Don't raise, as we want to continue with the main deletion process return files_deleted def _delete_app_triggers(tenant_id: str, app_id: str): def del_app_trigger(session, trigger_id: str): session.execute( delete(AppTrigger).where(AppTrigger.id == trigger_id).execution_options(synchronize_session=False) ) _delete_records( """select id from app_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_app_trigger, "app trigger", ) def _delete_workflow_plugin_triggers(tenant_id: str, app_id: str): def del_plugin_trigger(session, trigger_id: str): session.execute( delete(WorkflowPluginTrigger) .where(WorkflowPluginTrigger.id == trigger_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from workflow_plugin_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_plugin_trigger, "workflow plugin trigger", ) def _delete_workflow_webhook_triggers(tenant_id: str, app_id: str): def del_webhook_trigger(session, trigger_id: str): session.execute( delete(WorkflowWebhookTrigger) .where(WorkflowWebhookTrigger.id == trigger_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from workflow_webhook_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_webhook_trigger, "workflow webhook trigger", ) def _delete_workflow_schedule_plans(tenant_id: str, app_id: str): def del_schedule_plan(session, plan_id: str): session.execute( delete(WorkflowSchedulePlan) .where(WorkflowSchedulePlan.id == plan_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from workflow_schedule_plans where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_schedule_plan, "workflow schedule plan", ) def _delete_workflow_trigger_logs(tenant_id: str, app_id: str): def del_trigger_log(session, log_id: str): session.execute( delete(WorkflowTriggerLog) .where(WorkflowTriggerLog.id == log_id) .execution_options(synchronize_session=False) ) _delete_records( """select id from workflow_trigger_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""", {"tenant_id": tenant_id, "app_id": app_id}, del_trigger_log, "workflow trigger log", ) def _delete_records(query_sql: str, params: dict[str, Any], delete_func: Callable, name: str) -> None: while True: with session_factory.create_session() as session: rs = session.execute(sa.text(query_sql), params) rows = rs.fetchall() if not rows: break success_count = 0 for i in rows: record_id = str(i.id) try: delete_func(session, record_id) logger.info(click.style(f"Deleted {name} {record_id}", fg="green")) session.commit() success_count += 1 except Exception: logger.exception("Error occurred while deleting %s %s", name, record_id) # continue with next record even if one deletion fails session.rollback() continue rs.close() # If we couldn't delete ANY records in this batch, we must break out of the while loop # to prevent an infinite loop where we keep fetching the same failing records. if success_count == 0: logger.warning( click.style( f"Failed to delete any {name} in the current batch. Stopping to prevent infinite loop.", fg="yellow", ) ) break