mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
427 lines
17 KiB
Python
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,
|
|
)
|