perf: assorted small backend fixes

Six contained fixes across the backend:

- Tool-call arguments were parsed with ast.literal_eval first, which cannot parse JSON true/false/null, so the standard case paid a full AST parse plus an exception before json.loads ran; the order is flipped, with literal_eval kept as fallback for Python-repr payloads. Both parsers agree on every input they both accept.
- The skill-mention strip regex was rebuilt inside the function on every chat request while its sibling pattern was already module-level; hoisted.
- Plugin module loaders extracted the source frontmatter before installing requirements and then again after exec on the identical content; the first result is reused.
- Event dispatch recomputed inspect.signature per handler per dispatched event; signatures are now cached in a WeakKeyDictionary keyed on the underlying function, so reloads evict naturally.
- Users.get_valid_user_ids loaded full user rows (settings, oauth and avatar payloads included) to return a list of ids; it selects only the id column now.
- The sidebar folder listing re-queried every folder's parent to validate integrity although the user's complete folder set was loaded one line above; parents are validated against that set, removing one query per folder with a parent.

Benchmark:

| metric | before | after |
| --- | --- | --- |
| handler signature per dispatched event | 5.46 us | 0.15 us |
| tool argument parse (JSON with true/null) | 9.03 us | 0.89 us |
| frontmatter extractions per module load | 2 | 1 |
| user-id validation query | full rows | id column only |
| sidebar parent checks, N folders with parents | N queries | 0 |

Functionally verified: skill mentions are stripped to their labels; old and new argument parse orders agree on JSON-only, repr-only, overlap and garbage inputs; a module load extracts frontmatter exactly once and still returns the filter instance and frontmatter; id validation returns exactly the existing ids; the signature cache returns one shared signature across bound-method lookups.
This commit is contained in:
Classic298 2026-07-24 01:05:23 +02:00
parent e62e4eb9fe
commit 43f7666700
5 changed files with 41 additions and 12 deletions

View file

@ -5,6 +5,7 @@ import inspect
import logging
import time
import uuid
import weakref
from types import SimpleNamespace
from typing import Any
@ -1067,6 +1068,23 @@ class NotificationEventSink:
schedule_notification_dispatch(app, event)
# Keyed on the underlying function: reflection is expensive and runs per
# dispatched event per handler; reloads create new functions and evict.
_signature_cache = weakref.WeakKeyDictionary()
def _handler_signature(handler):
func = getattr(handler, '__func__', handler)
try:
signature = _signature_cache.get(func)
if signature is None:
signature = inspect.signature(handler)
_signature_cache[func] = signature
return signature
except TypeError:
return inspect.signature(handler)
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
if not ENABLE_PLUGINS:
return
@ -1094,7 +1112,7 @@ async def dispatch_event_functions(app: Any, event: Event, request: Any | None =
valves = await Functions.get_function_valves_by_id(function.id)
function_module.valves = function_module.Valves(**(valves if valves else {}))
sig = inspect.signature(handler)
sig = _handler_signature(handler)
accepts_kwargs = any(param.kind == inspect.Parameter.VAR_KEYWORD for param in sig.parameters.values())
extra_params = {
'event': event_payload,

View file

@ -728,8 +728,8 @@ class UsersTable:
async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]:
async with get_async_db_context(db) as session:
result = await session.execute(select(User).where(User.id.in_(user_ids)))
return [u.id for u in result.scalars().all()]
result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
return list(result.scalars().all())
async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None:
async with get_async_db_context(db) as session:

View file

@ -76,11 +76,13 @@ async def get_folders(
await check_folders_permission(request, user, db=db)
folders = await Folders.get_folders_by_user_id(user.id, db=db)
# The full folder set is already loaded; parents can be validated against it
folder_ids = {folder.id for folder in folders}
# Verify folder data integrity
folder_list = []
for folder in folders:
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
if folder.parent_id and folder.parent_id not in folder_ids:
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
if folder.data and 'files' in folder.data:

View file

@ -2157,9 +2157,12 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
return ids
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
def strip_skill_mentions(messages: list[dict]) -> None:
"""Replace <$skillId|label> and </skillId|label> mention tags with the label in-place."""
strip_re = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
strip_re = SKILL_MENTION_STRIP_RE
def label(match):
return match.group(1) or match.group(2) or ''
@ -4788,13 +4791,15 @@ async def streaming_chat_response_handler(response, ctx):
tool_args = tool_call.get('function', {}).get('arguments', '{}')
params = {}
if tool_args and tool_args.strip():
# Arguments are almost always JSON; literal_eval is
# kept as fallback for Python-repr-style payloads
try:
params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
params = json.loads(tool_args)
except Exception:
try:
params = json.loads(tool_args)
except Exception:
params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
return None
tool_call.setdefault('function', {})['arguments'] = json.dumps(params)
return params

View file

@ -207,6 +207,7 @@ async def load_tool_module_by_id(tool_id, content=None):
if not ENABLE_PLUGINS:
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
frontmatter = None
if content is None:
tool = await Tools.get_tool_by_id(tool_id)
if not tool:
@ -238,7 +239,8 @@ async def load_tool_module_by_id(tool_id, content=None):
# Executing the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
if frontmatter is None:
frontmatter = extract_frontmatter(content)
log.info(f'Loaded module: {module.__name__}')
# Create and return the object if the class 'Tools' is found in the module
@ -258,6 +260,7 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
if not ENABLE_PLUGINS:
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
frontmatter = None
if content is None:
function = await Functions.get_function_by_id(function_id)
if not function:
@ -286,7 +289,8 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
# Execute the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
if frontmatter is None:
frontmatter = extract_frontmatter(content)
log.info(f'Loaded module: {module.__name__}')
# Create appropriate object based on available class type in the module