Files
dify/api/services/app_task_service.py

427 lines
17 KiB
Python

"""Service for managing application task operations.
This service provides centralized logic for task control operations
like stopping tasks, handling both legacy Redis flag mechanism and
new Engine command channel mechanism.
"""
import json
import logging
from enum import StrEnum
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.execution_coordinator import send_abort_command, set_app_task_stop_flag
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom
from core.app.entities.task_entities import WorkflowFinishStreamResponse
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from core.repositories.factory import DifyCoreRepositoryFactory
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
from extensions.ext_database import db
from extensions.ext_redis import RedisClientWrapper, redis_client
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus, WorkflowType
from libs.datetime_utils import to_utc_timestamp
from models import Account, EndUser, WorkflowRun
from models.enums import CreatorUserRole
from models.model import AppMode
from models.workflow import WorkflowNodeExecutionModel
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.entities.workflow_execution import WorkflowExecution
from repositories.entities.workflow_pause import WorkflowPauseEntity
from repositories.sqlalchemy_api_workflow_run_repository import (
DifyAPISQLAlchemyWorkflowRunRepository,
WorkflowTaskOwnerMismatchError,
)
logger = logging.getLogger(__name__)
class WorkflowStopOutcome(StrEnum):
STOPPED = "stopped"
NOT_FOUND = "not_found"
NOT_STOPPABLE = "not_stoppable"
class AppTaskControlService:
"""Service for managing application task operations."""
def __init__(self, *, redis_client: RedisClientWrapper) -> None:
self._redis_client: RedisClientWrapper = redis_client
def stop_task(
self,
task_id: str,
invoke_from: InvokeFrom,
user_id: str,
) -> bool:
"""Stop a running non-workflow task through the Redis stop flag.
Workflow-based apps stop through ``stop_workflow_task``, which also
sends the Engine abort command.
Args:
task_id: The task ID to stop
invoke_from: The source of the invoke (e.g., DEBUGGER, WEB_APP, SERVICE_API)
user_id: The user ID requesting the stop
"""
return AppQueueManager.set_stop_flag(task_id, invoke_from, user_id, redis=self._redis_client)
def stop_message_task(self, *, task_id: str, caller_role: CreatorUserRole, caller_id: str) -> bool:
"""Stop a chat, agent-chat or completion task, restricting end users to their own tasks."""
if caller_role == CreatorUserRole.END_USER:
return self.stop_task(task_id, InvokeFrom.OPENAPI, caller_id)
set_app_task_stop_flag(task_id, redis=self._redis_client)
return True
def stop_workflow_run(
self,
*,
tenant_id: str,
app_id: str,
run_id: str,
app_mode: AppMode,
caller_role: CreatorUserRole,
caller_id: str,
) -> WorkflowStopOutcome:
"""Stop a workflow run by its run ID, restricting end users to their own runs."""
owner = (caller_role, caller_id) if caller_role == CreatorUserRole.END_USER else None
return AppTaskService.stop_workflow_run(
tenant_id=tenant_id,
app_id=app_id,
run_id=run_id,
app_mode=app_mode,
owner=owner,
redis=self._redis_client,
)
def stop_workflow_task_no_user_check(self, *, task_id: str) -> None:
"""Stop a workflow after app admission, without consulting the user ownership cache.
Keep the legacy stop flag before the Engine command, including when
the latter fails. Callers must authorize access to the workflow app first.
"""
set_app_task_stop_flag(task_id, redis=self._redis_client)
send_abort_command(task_id, redis=self._redis_client)
def stop_workflow_task(
self,
*,
tenant_id: str,
app_id: str,
task_id: str,
app_mode: AppMode,
owner: tuple[CreatorUserRole, str] | None,
) -> bool:
"""Apply durable workflow admission while retaining this service's Redis transport."""
return AppTaskService.stop_workflow_task(
tenant_id=tenant_id,
app_id=app_id,
task_id=task_id,
app_mode=app_mode,
owner=owner,
redis=self._redis_client,
)
class AppTaskService:
"""Compatibility entry point for callers outside ApplicationServices."""
@staticmethod
def index_workflow_pause_task(
*,
workflow_run: WorkflowRun,
pause_entity: WorkflowPauseEntity,
resumption_context: WorkflowResumptionContext,
workflow_run_repo: APIWorkflowRunRepository,
) -> bool:
"""Recover a missing task ID only from the same run's saved invocation."""
# TODO: Remove legacy task recovery 30 days after the last legacy writer is retired.
if workflow_run.task_id is not None:
return False
generate_entity = resumption_context.get_generate_entity()
try:
task_id = str(UUID(generate_entity.task_id))
except ValueError:
return False
run_id = (
generate_entity.workflow_run_id
if isinstance(generate_entity, AdvancedChatAppGenerateEntity)
else generate_entity.workflow_execution_id
)
if (
run_id != workflow_run.id
or generate_entity.app_config is None
or generate_entity.app_config.tenant_id != workflow_run.tenant_id
or generate_entity.app_config.app_id != workflow_run.app_id
or generate_entity.user_id != workflow_run.created_by
):
return False
return workflow_run_repo.backfill_workflow_pause_task_id(
tenant_id=workflow_run.tenant_id,
app_id=workflow_run.app_id,
workflow_run_id=workflow_run.id,
pause_id=pause_entity.id,
task_id=task_id,
)
@staticmethod
def _recover_legacy_workflow_task_id(
*,
tenant_id: str,
app_id: str,
task_id: str,
owner: tuple[CreatorUserRole, str] | None,
repository: DifyAPISQLAlchemyWorkflowRunRepository,
) -> None:
# TODO: Remove legacy task recovery 30 days after the last legacy writer is retired.
after_id: str | None = None
while pauses := repository.list_legacy_workflow_pauses(
tenant_id=tenant_id, app_id=app_id, owner=owner, after_id=after_id, limit=100
):
for run, pause in pauses:
try:
context = WorkflowResumptionContext.loads(pause.get_state().decode())
saved_task_id = str(UUID(context.get_generate_entity().task_id))
except Exception:
logger.exception("Failed to recover legacy workflow task, workflow_run_id=%s", run.id)
continue
if saved_task_id == task_id and AppTaskService.index_workflow_pause_task(
workflow_run=run,
pause_entity=pause,
resumption_context=context,
workflow_run_repo=repository,
):
return
after_id = pauses[-1][1].id
@staticmethod
def stop_workflow_run(
*,
tenant_id: str,
app_id: str,
run_id: str,
app_mode: AppMode,
owner: tuple[CreatorUserRole, str] | None,
redis: RedisClientWrapper,
) -> WorkflowStopOutcome:
repository = DifyAPISQLAlchemyWorkflowRunRepository(sessionmaker(bind=db.engine))
run = repository.get_workflow_run_by_id_and_tenant_id(tenant_id, run_id)
if run is None:
return WorkflowStopOutcome.NOT_FOUND
if run.app_id != app_id:
return WorkflowStopOutcome.NOT_FOUND
if owner is not None and (run.created_by_role, run.created_by) != owner:
return WorkflowStopOutcome.NOT_FOUND
if run.task_id is None:
return WorkflowStopOutcome.NOT_STOPPABLE
task_id = AppTaskService._parse_task_id(run.task_id)
try:
paused_run = repository.stop_paused_workflow_task(
tenant_id=tenant_id, app_id=app_id, task_id=task_id, owner=owner
)
except WorkflowTaskOwnerMismatchError:
return WorkflowStopOutcome.NOT_STOPPABLE
stopped = AppTaskService._finish_workflow_stop(
run=paused_run, tenant_id=tenant_id, app_id=app_id, task_id=task_id, app_mode=app_mode, redis=redis
)
if not stopped:
return WorkflowStopOutcome.NOT_STOPPABLE
return WorkflowStopOutcome.STOPPED
@staticmethod
def _parse_task_id(task_id: str) -> str:
try:
return str(UUID(task_id))
except ValueError as exc:
raise ValueError("Invalid task ID: expected a UUID") from exc
@staticmethod
def stop_workflow_task(
*,
tenant_id: str,
app_id: str,
task_id: str,
app_mode: AppMode,
owner: tuple[CreatorUserRole, str] | None,
redis: RedisClientWrapper | None = None,
) -> bool:
"""Stop a persisted pause, or signal its currently executing attempt.
A paused task has no engine consuming Redis commands. Its durable
transition and form invalidation must complete before acknowledging stop.
Omitted Redis uses the compatibility transport; injected callers supply theirs.
"""
task_id = AppTaskService._parse_task_id(task_id)
repository = DifyAPISQLAlchemyWorkflowRunRepository(sessionmaker(bind=db.engine))
try:
run = repository.stop_paused_workflow_task(tenant_id=tenant_id, app_id=app_id, task_id=task_id, owner=owner)
if run is None:
AppTaskService._recover_legacy_workflow_task_id(
tenant_id=tenant_id, app_id=app_id, task_id=task_id, owner=owner, repository=repository
)
run = repository.stop_paused_workflow_task(
tenant_id=tenant_id, app_id=app_id, task_id=task_id, owner=owner
)
except WorkflowTaskOwnerMismatchError:
return False
return AppTaskService._finish_workflow_stop(
run=run, tenant_id=tenant_id, app_id=app_id, task_id=task_id, app_mode=app_mode, redis=redis
)
@staticmethod
def _finish_workflow_stop(
*,
run: WorkflowRun | None,
tenant_id: str,
app_id: str,
task_id: str,
app_mode: AppMode,
redis: RedisClientWrapper | None,
) -> bool:
if run is not None and run.status == WorkflowExecutionStatus.RUNNING:
# Resume has claimed the pause, but may not have opened its queue.
# The repository has already checked the complete app/user owner.
AppTaskControlService(
redis_client=redis if redis is not None else redis_client
).stop_workflow_task_no_user_check(task_id=task_id)
return True
if run is None:
return False
AppTaskService._persist_stopped_history(run)
# The paused engine cannot execute its terminal Workspace layer. Keep
# run-owned Agent resources on the same retirement path as a live abort.
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
try:
workspace_ids = WorkflowAgentWorkspaceStore().retire_workflow_run(
tenant_id=tenant_id, app_id=app_id, workflow_run_id=run.id
)
enqueue_agent_resource_collection(tenant_id=tenant_id, workspace_ids=workspace_ids)
except Exception:
logger.exception("Failed to retire stopped Workflow Agent Workspaces, workflow_run_id=%s", run.id)
event = WorkflowFinishStreamResponse(
task_id=task_id,
workflow_run_id=run.id,
data=WorkflowFinishStreamResponse.Data(
id=run.id,
workflow_id=run.workflow_id,
status=run.status,
outputs=run.outputs_dict,
error=run.error,
elapsed_time=run.elapsed_time,
total_tokens=run.total_tokens,
total_steps=run.total_steps,
created_by={},
created_at=int(to_utc_timestamp(run.created_at)),
finished_at=int(to_utc_timestamp(run.finished_at)) if run.finished_at is not None else None,
exceptions_count=run.exceptions_count,
files=[],
),
)
topic = MessageBasedAppGenerator.get_response_topic(app_mode, run.id)
topic.publish(json.dumps(event.model_dump(mode="json"), ensure_ascii=False).encode())
return True
@staticmethod
def _persist_stopped_history(run: WorkflowRun) -> None:
# The SQL stop transaction is already committed. Persist the terminal
# history through the configured backend without holding its run lock.
user = Account(name="", email="") if run.created_by_role == CreatorUserRole.ACCOUNT else EndUser()
user.id = run.created_by
repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
session_factory=sessionmaker(bind=db.engine),
tenant_id=run.tenant_id,
user=user,
app_id=run.app_id,
triggered_from=run.triggered_from,
)
repository.save_synchronously(
WorkflowExecution(
id_=run.id,
workflow_id=run.workflow_id,
workflow_type=WorkflowType(run.type),
workflow_version=run.version,
graph=run.graph_dict,
inputs=run.inputs_dict,
outputs=run.outputs_dict,
status=WorkflowExecutionStatus.STOPPED,
error_message=run.error or "User requested stop",
total_tokens=run.total_tokens,
total_steps=run.total_steps,
exceptions_count=run.exceptions_count,
started_at=run.created_at,
accumulated_elapsed_time=run.elapsed_time,
finished_at=run.finished_at,
)
)
factory = sessionmaker(bind=db.engine)
with factory() as session:
scopes = session.execute(
select(WorkflowNodeExecutionModel.app_id, WorkflowNodeExecutionModel.triggered_from)
.where(
WorkflowNodeExecutionModel.tenant_id == run.tenant_id,
WorkflowNodeExecutionModel.workflow_run_id == run.id,
WorkflowNodeExecutionModel.status == WorkflowNodeExecutionStatus.FAILED,
WorkflowNodeExecutionModel.finished_at == run.finished_at,
WorkflowNodeExecutionModel.error == run.error,
)
.distinct()
).all()
for app_id, triggered_from in scopes:
source = SQLAlchemyWorkflowNodeExecutionRepository(
session_factory=factory,
tenant_id=run.tenant_id,
user=user,
app_id=app_id,
triggered_from=triggered_from,
)
stopped_nodes = [
execution
for execution in source.get_by_workflow_execution(run.id, include_paused=True)
if execution.status == WorkflowNodeExecutionStatus.FAILED
and execution.finished_at == run.finished_at
and execution.error == run.error
]
target = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=factory,
tenant_id=run.tenant_id,
user=user,
app_id=app_id,
triggered_from=triggered_from,
)
target.save_many(stopped_nodes)
@staticmethod
def stop_task(
task_id: str,
invoke_from: InvokeFrom,
user_id: str,
app_mode: AppMode,
*,
tenant_id: str,
app_id: str,
) -> None:
if app_mode in (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW):
role = CreatorUserRole.ACCOUNT if invoke_from.runs_as_account() else CreatorUserRole.END_USER
AppTaskService.stop_workflow_task(
tenant_id=tenant_id,
app_id=app_id,
task_id=task_id,
app_mode=app_mode,
owner=(role, user_id),
)
return
AppTaskControlService(redis_client=redis_client).stop_task(
task_id=task_id,
invoke_from=invoke_from,
user_id=user_id,
)