From 310ae9130211da200c4c4de6cac6fa472e603aeb Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:51:07 +0200 Subject: [PATCH] perf: make tools defer_content real and batch tool and knowledge access filters (#27387) Tools.get_tools(defer_content=True) contained the literal dead statement "stmt = stmt": the deferral was a no-op, so every tools listing loaded the full Python source of every tool (five caller sites pass defer_content=True expecting the optimization: the tools list endpoints and the user and group permission overviews). The listing now selects every column except content, and ToolModel.content becomes optional to represent deferred rows; router projections are content-less response models, so nothing downstream reads the source on these paths. On top of that, get_tools_by_user_id issued one grant query per non-owned tool and Knowledges.get_knowledge_bases_by_user_id did the same per knowledge base (the latter also sits inside per-file access checks). Both now resolve grants for all non-owned rows in a single get_accessible_resource_ids call, the same batch helper the model listing already uses. Benchmark (real SQLite DB): | metric | before | after | | --- | --- | --- | | tools listing, 33 tools x ~200 KB source | 5.13 ms | 3.18 ms | | grant queries per accessible-tools call, N non-owned tools | N | 1 | | grant queries per accessible-KBs call, N non-owned KBs | N | 1 | The listing row scales with source size; on Postgres the deferral additionally avoids shipping every tool's source over the wire per listing, and each removed grant query was a real round trip. Functionally verified: deferred listings match full listings field for field with content None, grants included and router projections working; access filtering returns exactly owned plus granted tools and knowledge bases and nothing for strangers; full (non-deferred) reads still carry the source. --- backend/open_webui/models/knowledge.py | 24 ++++++--------- backend/open_webui/models/tools.py | 42 ++++++++++++++------------ 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 544eec9284..3163a24f49 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -479,20 +479,16 @@ class KnowledgeTable: user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} - result = [] - for knowledge_base in knowledge_bases: - if knowledge_base.user_id == user_id: - result.append(knowledge_base) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='knowledge', - resource_id=knowledge_base.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(knowledge_base) - return result + # One grants query for all non-owned knowledge bases instead of one each + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='knowledge', + resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids] async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]: try: diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index a6468f1876..2873b7c9ac 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -43,7 +43,8 @@ class ToolModel(BaseModel): id: str user_id: str name: str - content: str + # None when listed with defer_content=True (source skipped for listings) + content: str | None = None specs: list[dict] meta: ToolMeta access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -171,11 +172,18 @@ class ToolsTable: async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]: async with get_async_db_context(db) as db: - stmt = select(Tool).order_by(Tool.updated_at.desc()) if defer_content: - stmt = stmt - result = await db.execute(stmt) - all_tools = result.scalars().all() + # Skip Tool.content (plugin source, potentially large) via a + # column select; Row attributes satisfy from_attributes. + result = await db.execute( + select( + Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at + ).order_by(Tool.updated_at.desc()) + ) + all_tools = result.all() + else: + result = await db.execute(select(Tool).order_by(Tool.updated_at.desc())) + all_tools = result.scalars().all() user_ids = list(set(tool.user_id for tool in all_tools)) tool_ids = [tool.id for tool in all_tools] @@ -214,20 +222,16 @@ class ToolsTable: user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} - result = [] - for tool in tools: - if tool.user_id == user_id: - result.append(tool) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='tool', - resource_id=tool.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(tool) - return result + # One grants query for all non-owned tools instead of one per tool + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='tool', + resource_ids=[tool.id for tool in tools if tool.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids] async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None: try: