mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 11:29:39 +08:00
perf: Sort installed plugins: debug plugins first, then by installation time (#1798)
* Initial plan * Implement plugin list sorting: debug plugins first, then by installation time Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Apply ruff formatting * Add unit tests for plugin list sorting functionality Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Optimize database query to avoid N+1 problem and update tests Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Remove redundant assertion in test Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * perf: plugin list sorting --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import typing
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import httpx
|
import httpx
|
||||||
|
import sqlalchemy
|
||||||
from async_lru import alru_cache
|
from async_lru import alru_cache
|
||||||
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ from langbot_plugin.api.entities.builtin.command import (
|
|||||||
)
|
)
|
||||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||||
from ..core import taskmgr
|
from ..core import taskmgr
|
||||||
|
from ..entity.persistence import plugin as persistence_plugin
|
||||||
|
|
||||||
|
|
||||||
class PluginRuntimeConnector:
|
class PluginRuntimeConnector:
|
||||||
@@ -279,7 +281,61 @@ class PluginRuntimeConnector:
|
|||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return await self.handler.list_plugins()
|
plugins = await self.handler.list_plugins()
|
||||||
|
|
||||||
|
# Sort plugins: debug plugins first, then by installation time (newest first)
|
||||||
|
# Get installation timestamps from database in a single query
|
||||||
|
plugin_timestamps = {}
|
||||||
|
|
||||||
|
if plugins:
|
||||||
|
# Build list of (author, name) tuples for all plugins
|
||||||
|
plugin_ids = []
|
||||||
|
for plugin in plugins:
|
||||||
|
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
||||||
|
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
||||||
|
if author and name:
|
||||||
|
plugin_ids.append((author, name))
|
||||||
|
|
||||||
|
# Fetch all timestamps in a single query using OR conditions
|
||||||
|
if plugin_ids:
|
||||||
|
conditions = [
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_plugin.PluginSetting.plugin_author == author,
|
||||||
|
persistence_plugin.PluginSetting.plugin_name == name,
|
||||||
|
)
|
||||||
|
for author, name in plugin_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(
|
||||||
|
persistence_plugin.PluginSetting.plugin_author,
|
||||||
|
persistence_plugin.PluginSetting.plugin_name,
|
||||||
|
persistence_plugin.PluginSetting.created_at,
|
||||||
|
).where(sqlalchemy.or_(*conditions))
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in result:
|
||||||
|
plugin_id = f'{row.plugin_author}/{row.plugin_name}'
|
||||||
|
plugin_timestamps[plugin_id] = row.created_at
|
||||||
|
|
||||||
|
# Sort: debug plugins first (descending), then by created_at (descending)
|
||||||
|
def sort_key(plugin):
|
||||||
|
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
||||||
|
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
||||||
|
plugin_id = f'{author}/{name}'
|
||||||
|
|
||||||
|
is_debug = plugin.get('debug', False)
|
||||||
|
created_at = plugin_timestamps.get(plugin_id)
|
||||||
|
|
||||||
|
# Return tuple: (not is_debug, -timestamp)
|
||||||
|
# not is_debug: False (0) for debug plugins, True (1) for non-debug
|
||||||
|
# -timestamp: to sort newest first (will be None for plugins without timestamp)
|
||||||
|
timestamp_value = -created_at.timestamp() if created_at else 0
|
||||||
|
return (not is_debug, timestamp_value)
|
||||||
|
|
||||||
|
plugins.sort(key=sort_key)
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
||||||
return await self.handler.get_plugin_info(author, plugin_name)
|
return await self.handler.get_plugin_info(author, plugin_name)
|
||||||
|
|||||||
1
tests/unit_tests/plugin/__init__.py
Normal file
1
tests/unit_tests/plugin/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Plugin connector unit tests
|
||||||
228
tests/unit_tests/plugin/test_plugin_list_sorting.py
Normal file
228
tests/unit_tests/plugin/test_plugin_list_sorting.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""Test plugin list sorting functionality."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plugin_list_sorting_debug_first():
|
||||||
|
"""Test that debug plugins appear before non-debug plugins."""
|
||||||
|
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||||
|
|
||||||
|
# Mock the application
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||||
|
mock_app.logger = MagicMock()
|
||||||
|
|
||||||
|
# Create connector
|
||||||
|
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||||
|
connector.handler = MagicMock()
|
||||||
|
|
||||||
|
# Mock plugin data with different debug states and timestamps
|
||||||
|
now = datetime.now()
|
||||||
|
mock_plugins = [
|
||||||
|
{
|
||||||
|
'debug': False,
|
||||||
|
'manifest': {
|
||||||
|
'manifest': {
|
||||||
|
'metadata': {
|
||||||
|
'author': 'author1',
|
||||||
|
'name': 'plugin1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'debug': True,
|
||||||
|
'manifest': {
|
||||||
|
'manifest': {
|
||||||
|
'metadata': {
|
||||||
|
'author': 'author2',
|
||||||
|
'name': 'plugin2',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'debug': False,
|
||||||
|
'manifest': {
|
||||||
|
'manifest': {
|
||||||
|
'metadata': {
|
||||||
|
'author': 'author3',
|
||||||
|
'name': 'plugin3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||||
|
|
||||||
|
# Mock database query to return all timestamps in a single batch
|
||||||
|
async def mock_execute_async(query):
|
||||||
|
mock_result = MagicMock()
|
||||||
|
|
||||||
|
# Create mock rows for all plugins with timestamps
|
||||||
|
mock_rows = []
|
||||||
|
|
||||||
|
# plugin1: oldest, plugin2: middle, plugin3: newest
|
||||||
|
mock_row1 = MagicMock()
|
||||||
|
mock_row1.plugin_author = 'author1'
|
||||||
|
mock_row1.plugin_name = 'plugin1'
|
||||||
|
mock_row1.created_at = now - timedelta(days=2)
|
||||||
|
mock_rows.append(mock_row1)
|
||||||
|
|
||||||
|
mock_row2 = MagicMock()
|
||||||
|
mock_row2.plugin_author = 'author2'
|
||||||
|
mock_row2.plugin_name = 'plugin2'
|
||||||
|
mock_row2.created_at = now - timedelta(days=1)
|
||||||
|
mock_rows.append(mock_row2)
|
||||||
|
|
||||||
|
mock_row3 = MagicMock()
|
||||||
|
mock_row3.plugin_author = 'author3'
|
||||||
|
mock_row3.plugin_name = 'plugin3'
|
||||||
|
mock_row3.created_at = now
|
||||||
|
mock_rows.append(mock_row3)
|
||||||
|
|
||||||
|
# Make the result iterable
|
||||||
|
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||||
|
|
||||||
|
return mock_result
|
||||||
|
|
||||||
|
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||||
|
|
||||||
|
# Call list_plugins
|
||||||
|
result = await connector.list_plugins()
|
||||||
|
|
||||||
|
# Verify sorting: debug plugin should be first
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0]['debug'] is True # plugin2 (debug)
|
||||||
|
assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin2'
|
||||||
|
|
||||||
|
# Remaining should be sorted by created_at (newest first)
|
||||||
|
assert result[1]['debug'] is False
|
||||||
|
assert result[1]['manifest']['manifest']['metadata']['name'] == 'plugin3' # newest non-debug
|
||||||
|
assert result[2]['debug'] is False
|
||||||
|
assert result[2]['manifest']['manifest']['metadata']['name'] == 'plugin1' # oldest non-debug
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plugin_list_sorting_by_installation_time():
|
||||||
|
"""Test that non-debug plugins are sorted by installation time (newest first)."""
|
||||||
|
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||||
|
|
||||||
|
# Mock the application
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||||
|
mock_app.logger = MagicMock()
|
||||||
|
|
||||||
|
# Create connector
|
||||||
|
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||||
|
connector.handler = MagicMock()
|
||||||
|
|
||||||
|
# Mock plugin data - all non-debug with different installation times
|
||||||
|
now = datetime.now()
|
||||||
|
mock_plugins = [
|
||||||
|
{
|
||||||
|
'debug': False,
|
||||||
|
'manifest': {
|
||||||
|
'manifest': {
|
||||||
|
'metadata': {
|
||||||
|
'author': 'author1',
|
||||||
|
'name': 'oldest_plugin',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'debug': False,
|
||||||
|
'manifest': {
|
||||||
|
'manifest': {
|
||||||
|
'metadata': {
|
||||||
|
'author': 'author2',
|
||||||
|
'name': 'middle_plugin',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'debug': False,
|
||||||
|
'manifest': {
|
||||||
|
'manifest': {
|
||||||
|
'metadata': {
|
||||||
|
'author': 'author3',
|
||||||
|
'name': 'newest_plugin',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||||
|
|
||||||
|
# Mock database query to return all timestamps in a single batch
|
||||||
|
async def mock_execute_async(query):
|
||||||
|
mock_result = MagicMock()
|
||||||
|
|
||||||
|
# Create mock rows for all plugins with timestamps
|
||||||
|
mock_rows = []
|
||||||
|
|
||||||
|
# oldest_plugin: oldest, middle_plugin: middle, newest_plugin: newest
|
||||||
|
mock_row1 = MagicMock()
|
||||||
|
mock_row1.plugin_author = 'author1'
|
||||||
|
mock_row1.plugin_name = 'oldest_plugin'
|
||||||
|
mock_row1.created_at = now - timedelta(days=10)
|
||||||
|
mock_rows.append(mock_row1)
|
||||||
|
|
||||||
|
mock_row2 = MagicMock()
|
||||||
|
mock_row2.plugin_author = 'author2'
|
||||||
|
mock_row2.plugin_name = 'middle_plugin'
|
||||||
|
mock_row2.created_at = now - timedelta(days=5)
|
||||||
|
mock_rows.append(mock_row2)
|
||||||
|
|
||||||
|
mock_row3 = MagicMock()
|
||||||
|
mock_row3.plugin_author = 'author3'
|
||||||
|
mock_row3.plugin_name = 'newest_plugin'
|
||||||
|
mock_row3.created_at = now
|
||||||
|
mock_rows.append(mock_row3)
|
||||||
|
|
||||||
|
# Make the result iterable
|
||||||
|
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||||
|
|
||||||
|
return mock_result
|
||||||
|
|
||||||
|
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||||
|
|
||||||
|
# Call list_plugins
|
||||||
|
result = await connector.list_plugins()
|
||||||
|
|
||||||
|
# Verify sorting: newest first
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0]['manifest']['manifest']['metadata']['name'] == 'newest_plugin'
|
||||||
|
assert result[1]['manifest']['manifest']['metadata']['name'] == 'middle_plugin'
|
||||||
|
assert result[2]['manifest']['manifest']['metadata']['name'] == 'oldest_plugin'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plugin_list_empty():
|
||||||
|
"""Test that empty plugin list is handled correctly."""
|
||||||
|
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||||
|
|
||||||
|
# Mock the application
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||||
|
mock_app.logger = MagicMock()
|
||||||
|
|
||||||
|
# Create connector
|
||||||
|
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||||
|
connector.handler = MagicMock()
|
||||||
|
|
||||||
|
# Mock empty plugin list
|
||||||
|
connector.handler.list_plugins = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
# Call list_plugins
|
||||||
|
result = await connector.list_plugins()
|
||||||
|
|
||||||
|
# Verify empty list
|
||||||
|
assert len(result) == 0
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
padding-left: 0.8rem;
|
padding-left: 0.8rem;
|
||||||
padding-right: 0.8rem;
|
padding-right: 0.8rem;
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user