mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-16 23:43:03 +00:00
Pressing Stop on a chat that has more than one running task (a multi-model response, or a follow-up sent from another tab or device while a response is still streaming) reported success but only cancelled the first task. The survivors kept streaming and kept executing tool calls until the iteration limit, which is the runaway reported in the issue. Without Redis the stop loop iterates the live task-id list of the chat. Each awaited cancellation runs that task's cleanup, which removes its id from the same list mid-iteration, so the loop runs out one element early and the last task is never cancelled. Returning a snapshot of the list to callers keeps the loop on the ids it started with. Redis deployments already got a fresh list from the set and were not affected. Verified against a mock upstream that calls a tool on every turn: with three tasks on one chat, stop left one or two alive before the change and cancels all of them after it. Fixes #29816
288 lines
9.3 KiB
Python
288 lines
9.3 KiB
Python
# tasks.py
|
|
import asyncio
|
|
import logging
|
|
from contextlib import suppress
|
|
from uuid import uuid4
|
|
|
|
from redis.asyncio import Redis
|
|
|
|
from open_webui.env import REDIS_KEY_PREFIX, REDIS_RESPONSE_STREAM_TTL
|
|
from open_webui.utils.json_codec import JSONCodec, dumps_bytes
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# A dictionary to keep track of active tasks
|
|
tasks: dict[str, asyncio.Task] = {}
|
|
item_tasks = {}
|
|
response_streams: dict[str, dict] = {}
|
|
|
|
|
|
REDIS_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks'
|
|
REDIS_ITEM_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks:item'
|
|
REDIS_RESPONSE_STREAMS_KEY = f'{REDIS_KEY_PREFIX}:tasks:response_streams'
|
|
REDIS_PUBSUB_CHANNEL = f'{REDIS_KEY_PREFIX}:tasks:commands'
|
|
REDIS_PUBSUB_RECONNECT_INTERVAL = 1.0
|
|
REDIS_PUBSUB_MAX_RECONNECT_INTERVAL = 30.0
|
|
|
|
|
|
async def redis_task_command_listener(app):
|
|
redis: Redis = app.state.redis
|
|
reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL
|
|
|
|
while True:
|
|
pubsub = None
|
|
try:
|
|
# RedisCluster can't route a pubsub subscribe until initialize() fills its slot cache.
|
|
await redis.initialize()
|
|
|
|
pubsub = redis.pubsub()
|
|
await pubsub.subscribe(REDIS_PUBSUB_CHANNEL)
|
|
reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL
|
|
|
|
async for message in pubsub.listen():
|
|
if message['type'] != 'message':
|
|
continue
|
|
try:
|
|
command = JSONCodec.loads(message['data'])
|
|
if command.get('action') != 'stop':
|
|
continue
|
|
|
|
local_task = tasks.get(command.get('task_id'))
|
|
if local_task:
|
|
local_task.cancel()
|
|
except Exception as e:
|
|
log.exception(f'Error handling distributed task command: {e}')
|
|
log.warning('Redis task command listener stopped. Retrying.')
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
log.exception(f'Redis task command listener failed. Retrying: {e}')
|
|
finally:
|
|
if pubsub:
|
|
with suppress(Exception):
|
|
await pubsub.aclose()
|
|
|
|
await asyncio.sleep(reconnect_interval)
|
|
reconnect_interval = min(reconnect_interval * 2, REDIS_PUBSUB_MAX_RECONNECT_INTERVAL)
|
|
|
|
|
|
### ------------------------------
|
|
### REDIS-ENABLED HANDLERS
|
|
### ------------------------------
|
|
|
|
|
|
async def redis_save_task(redis: Redis, task_id: str, item_id: str | None):
|
|
pipe = redis.pipeline()
|
|
pipe.hset(REDIS_TASKS_KEY, task_id, item_id or '')
|
|
if item_id:
|
|
pipe.sadd(f'{REDIS_ITEM_TASKS_KEY}:{item_id}', task_id)
|
|
await pipe.execute()
|
|
|
|
|
|
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None):
|
|
pipe = redis.pipeline()
|
|
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
|
pipe.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id)
|
|
if item_id:
|
|
pipe.srem(f'{REDIS_ITEM_TASKS_KEY}:{item_id}', task_id)
|
|
await pipe.execute()
|
|
# Remove the set key entirely if no tasks remain for this item
|
|
if await redis.scard(f'{REDIS_ITEM_TASKS_KEY}:{item_id}') == 0:
|
|
await redis.delete(f'{REDIS_ITEM_TASKS_KEY}:{item_id}')
|
|
else:
|
|
await pipe.execute()
|
|
|
|
|
|
async def redis_list_tasks(redis: Redis) -> list[str]:
|
|
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
|
|
|
|
|
async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]:
|
|
return list(await redis.smembers(f'{REDIS_ITEM_TASKS_KEY}:{item_id}'))
|
|
|
|
|
|
async def redis_send_command(redis: Redis, command: dict):
|
|
command_json = dumps_bytes(command)
|
|
# RedisCluster doesn't expose publish() directly, but the
|
|
# PUBLISH command broadcasts across all cluster nodes server-side.
|
|
if hasattr(redis, 'nodes_manager'):
|
|
await redis.execute_command('PUBLISH', REDIS_PUBSUB_CHANNEL, command_json)
|
|
else:
|
|
await redis.publish(REDIS_PUBSUB_CHANNEL, command_json)
|
|
|
|
|
|
async def cleanup_task(redis, task_id: str, id=None):
|
|
"""
|
|
Remove a completed or canceled task from the global `tasks` dictionary.
|
|
"""
|
|
if redis:
|
|
await redis_cleanup_task(redis, task_id, id)
|
|
|
|
tasks.pop(task_id, None) # Remove the task if it exists
|
|
response_streams.pop(task_id, None)
|
|
|
|
# If an ID is provided, remove the task from the item_tasks dictionary
|
|
if id and task_id in item_tasks.get(id, []):
|
|
item_tasks[id].remove(task_id)
|
|
if not item_tasks[id]: # If no tasks left for this ID, remove the entry
|
|
item_tasks.pop(id, None)
|
|
|
|
|
|
async def create_task(redis, coroutine, id=None, task_id=None):
|
|
"""
|
|
Create a new asyncio task and add it to the global task dictionary.
|
|
"""
|
|
task_id = task_id or str(uuid4()) # Generate a unique ID for the task
|
|
task = asyncio.create_task(coroutine) # Create the task
|
|
|
|
# Add a done callback for cleanup
|
|
task.add_done_callback(lambda t: asyncio.create_task(cleanup_task(redis, task_id, id)))
|
|
tasks[task_id] = task
|
|
|
|
# If an ID is provided, associate the task with that ID
|
|
if item_tasks.get(id):
|
|
item_tasks[id].append(task_id)
|
|
else:
|
|
item_tasks[id] = [task_id]
|
|
|
|
if redis:
|
|
await redis_save_task(redis, task_id, id)
|
|
|
|
return task_id, task
|
|
|
|
|
|
async def list_tasks(redis):
|
|
"""
|
|
List all currently active task IDs.
|
|
"""
|
|
if redis:
|
|
return await redis_list_tasks(redis)
|
|
return list(tasks.keys())
|
|
|
|
|
|
async def list_task_ids_by_item_id(redis, id):
|
|
"""
|
|
List all tasks associated with a specific ID.
|
|
"""
|
|
if redis:
|
|
return await redis_list_item_tasks(redis, id)
|
|
return list(item_tasks.get(id, []))
|
|
|
|
|
|
async def save_response_stream(
|
|
redis,
|
|
task_id: str | None,
|
|
chat_id: str | None,
|
|
message_id: str | None,
|
|
content: str,
|
|
output: list,
|
|
):
|
|
if not task_id or not chat_id or not message_id:
|
|
return
|
|
|
|
data = {
|
|
'chat_id': chat_id,
|
|
'message_id': message_id,
|
|
'content': content,
|
|
'output': output,
|
|
}
|
|
|
|
if redis:
|
|
await redis.hset(REDIS_RESPONSE_STREAMS_KEY, task_id, dumps_bytes(data))
|
|
if REDIS_RESPONSE_STREAM_TTL > 0:
|
|
with suppress(Exception):
|
|
await redis.hexpire(REDIS_RESPONSE_STREAMS_KEY, REDIS_RESPONSE_STREAM_TTL, task_id)
|
|
else:
|
|
response_streams[task_id] = data
|
|
|
|
|
|
async def get_response_streams_by_chat_id(redis, chat_id: str) -> list[dict]:
|
|
task_ids = await list_task_ids_by_item_id(redis, chat_id)
|
|
if not task_ids:
|
|
return []
|
|
|
|
if redis:
|
|
values = await redis.hmget(REDIS_RESPONSE_STREAMS_KEY, task_ids)
|
|
streams = []
|
|
for value in values:
|
|
if not value:
|
|
continue
|
|
try:
|
|
data = JSONCodec.loads(value)
|
|
except Exception:
|
|
continue
|
|
if data.get('chat_id') == chat_id:
|
|
streams.append(data)
|
|
return streams
|
|
|
|
return [
|
|
stream for task_id in task_ids if (stream := response_streams.get(task_id)) and stream.get('chat_id') == chat_id
|
|
]
|
|
|
|
|
|
async def clear_response_stream(redis, task_id: str | None):
|
|
if not task_id:
|
|
return
|
|
if redis:
|
|
await redis.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id)
|
|
else:
|
|
response_streams.pop(task_id, None)
|
|
|
|
|
|
async def stop_task(redis, task_id: str):
|
|
"""
|
|
Cancel a running task and remove it from the global task list.
|
|
"""
|
|
if redis:
|
|
# Look up the item_id before cleanup so we can remove the set entry too
|
|
item_id = await redis.hget(REDIS_TASKS_KEY, task_id)
|
|
# PUBSUB: All instances check if they have this task, and stop if so.
|
|
await redis_send_command(
|
|
redis,
|
|
{
|
|
'action': 'stop',
|
|
'task_id': task_id,
|
|
},
|
|
)
|
|
# Always clean Redis directly — hdel/srem are idempotent, safe even
|
|
# if the done_callback on the owning process also fires cleanup.
|
|
await redis_cleanup_task(redis, task_id, item_id or None)
|
|
return {'status': True, 'message': f'Task {task_id} stopped.'}
|
|
|
|
task = tasks.pop(task_id, None)
|
|
if not task:
|
|
return {'status': False, 'message': f'Task with ID {task_id} not found.'}
|
|
|
|
task.cancel() # Request task cancellation
|
|
try:
|
|
await task # Wait for the task to handle the cancellation
|
|
except asyncio.CancelledError:
|
|
# Task successfully canceled
|
|
return {'status': True, 'message': f'Task {task_id} successfully stopped.'}
|
|
|
|
if task.cancelled() or task.done():
|
|
return {'status': True, 'message': f'Task {task_id} successfully cancelled.'}
|
|
|
|
return {'status': True, 'message': f'Cancellation requested for {task_id}.'}
|
|
|
|
|
|
async def stop_item_tasks(redis: Redis, item_id: str):
|
|
"""
|
|
Stop all tasks associated with a specific item ID.
|
|
"""
|
|
task_ids = await list_task_ids_by_item_id(redis, item_id)
|
|
if not task_ids:
|
|
return {'status': True, 'message': f'No tasks found for item {item_id}.'}
|
|
|
|
# Cleanup mutates the local task list while cancellation is awaited.
|
|
for task_id in list(task_ids):
|
|
# A task that already finished needs no stopping; continue with the rest.
|
|
await stop_task(redis, task_id)
|
|
|
|
return {'status': True, 'message': f'All tasks for item {item_id} stopped.'}
|
|
|
|
|
|
async def has_active_tasks(redis, chat_id: str) -> bool:
|
|
"""Check if a chat has any active tasks."""
|
|
task_ids = await list_task_ids_by_item_id(redis, chat_id)
|
|
return len(task_ids) > 0
|