mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-16 23:43:03 +00:00
Bug: request.app.state.TOOLS / FUNCTIONS are per-worker Python dicts.
Save on worker A does not touch worker B's cache, so worker B keeps
serving the stale compiled module until the process restarts.
utils/tools.py's invocation path was also completely cache-blind —
a bare dict.get with no content-hash fallback, so even same-worker
edits skipped the TOOL_CONTENTS check and served the first-seen
module forever.
Symptom: editing a tool in the Workspace UI, triggering a chat, and
watching the OLD code execute until backend restart. Confirmed on a
multi-worker deployment (UVICORN_WORKERS > 1) with Redis.
Fix — three parts:
1. backend/open_webui/utils/plugin_cache.py (new)
* publish_invalidation(app, kind, id)
- drops the local TOOLS/FUNCTIONS + _CONTENTS entry
- publishes {kind, id} on REDIS_PLUGIN_CACHE_CHANNEL
* plugin_cache_listener(app)
- subscribes on startup, drops caches on every inbound message
- mirrors the existing redis_task_command_listener pattern
* falls back to pure local invalidation when Redis is unconfigured
2. utils/tools.py
* invocation path swapped from `TOOLS.get(tool_id)` to
`await get_tool_module_from_cache(request, tool_id)`, which does
content-hash invalidation from DB. Covers edits that predate or
race the Redis pub/sub message.
3. routers/tools.py + routers/functions.py
* create / update / delete / toggle / toggle_global all call
publish_invalidation after the DB mutation commits.
* toggle_function_by_id + toggle_global_by_id gained a `request`
parameter they were missing.
main.py lifespan starts the listener alongside the task listener and
cancels it on shutdown. No new env vars — reuses REDIS_URL and
REDIS_KEY_PREFIX.
Single-worker deployments still work: publish_invalidation
unconditionally drops the local cache before trying to publish.
110 lines
4 KiB
Python
110 lines
4 KiB
Python
# plugin_cache.py
|
|
#
|
|
# Cross-worker invalidation for tool + function module caches.
|
|
#
|
|
# Tool / function source code lives in the DB (shared across workers),
|
|
# but the compiled Python module is cached per-worker in
|
|
# ``request.app.state.TOOLS`` / ``FUNCTIONS``. Saving a tool on
|
|
# worker A does not touch worker B's module dict, so worker B keeps
|
|
# serving the stale module until the process restarts.
|
|
#
|
|
# We fix that by publishing an invalidation message on Redis whenever
|
|
# a plugin is created / updated / deleted / toggled. Every worker
|
|
# subscribes on startup and drops the matching entries from its local
|
|
# caches, forcing the next invocation to reload fresh from the DB.
|
|
#
|
|
# When Redis is not configured we fall back to in-process invalidation
|
|
# only — the single-worker case "just works" because save already
|
|
# updates that worker's cache directly.
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Literal
|
|
|
|
from redis.asyncio import Redis
|
|
|
|
from open_webui.env import REDIS_KEY_PREFIX
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
REDIS_PLUGIN_CACHE_CHANNEL = f"{REDIS_KEY_PREFIX}:plugins:invalidate"
|
|
|
|
PluginKind = Literal["tool", "function"]
|
|
|
|
|
|
def _local_invalidate(app, kind: PluginKind, plugin_id: str) -> None:
|
|
"""Drop the per-worker cache entries for a plugin id.
|
|
|
|
Safe to call when the caches don't exist yet (first invocation on
|
|
this worker, startup race with the listener, etc.).
|
|
"""
|
|
try:
|
|
if kind == "tool":
|
|
store = getattr(app.state, "TOOLS", None)
|
|
content_store = getattr(app.state, "TOOL_CONTENTS", None)
|
|
else:
|
|
store = getattr(app.state, "FUNCTIONS", None)
|
|
content_store = getattr(app.state, "FUNCTION_CONTENTS", None)
|
|
if store is not None:
|
|
store.pop(plugin_id, None)
|
|
if content_store is not None:
|
|
content_store.pop(plugin_id, None)
|
|
except Exception as e:
|
|
log.exception(f"plugin-cache: local invalidate failed ({kind}/{plugin_id}): {e}")
|
|
|
|
|
|
async def publish_invalidation(app, kind: PluginKind, plugin_id: str) -> None:
|
|
"""Invalidate locally + publish to Redis for the other workers.
|
|
|
|
Called by the save / update / delete / toggle handlers. The local
|
|
invalidation covers the single-worker case and the worker that
|
|
received the write request; the Redis publish covers every other
|
|
worker in a multi-process deployment.
|
|
"""
|
|
_local_invalidate(app, kind, plugin_id)
|
|
redis: Redis | None = getattr(app.state, "redis", None)
|
|
if redis is None:
|
|
return
|
|
try:
|
|
payload = json.dumps({"kind": kind, "id": plugin_id})
|
|
await redis.publish(REDIS_PLUGIN_CACHE_CHANNEL, payload)
|
|
except Exception as e:
|
|
log.exception(f"plugin-cache: redis publish failed ({kind}/{plugin_id}): {e}")
|
|
|
|
|
|
async def plugin_cache_listener(app) -> None:
|
|
"""Subscribe to invalidations and drop local caches on receipt.
|
|
|
|
Mirrors the existing ``redis_task_command_listener`` pattern.
|
|
Started from the lifespan hook in main.py. Silently exits if
|
|
Redis is unavailable.
|
|
"""
|
|
redis: Redis | None = getattr(app.state, "redis", None)
|
|
if redis is None:
|
|
return
|
|
pubsub = redis.pubsub()
|
|
try:
|
|
await pubsub.subscribe(REDIS_PLUGIN_CACHE_CHANNEL)
|
|
except Exception as e:
|
|
log.exception(f"plugin-cache: redis subscribe failed: {e}")
|
|
return
|
|
|
|
async for message in pubsub.listen():
|
|
if message.get("type") != "message":
|
|
continue
|
|
try:
|
|
data = message.get("data")
|
|
if isinstance(data, (bytes, bytearray)):
|
|
data = data.decode("utf-8", "replace")
|
|
payload = json.loads(data)
|
|
kind = payload.get("kind")
|
|
plugin_id = payload.get("id")
|
|
if kind not in ("tool", "function") or not plugin_id:
|
|
continue
|
|
_local_invalidate(app, kind, plugin_id)
|
|
log.info(f"plugin-cache: invalidated {kind}/{plugin_id}")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
log.exception(f"plugin-cache: listener handler failed: {e}")
|