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:
Copilot
2025-11-23 13:46:45 +08:00
committed by GitHub
parent b7c4c21796
commit ace6d62d76
4 changed files with 287 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import typing
import os
import sys
import httpx
import sqlalchemy
from async_lru import alru_cache
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 ..core import taskmgr
from ..entity.persistence import plugin as persistence_plugin
class PluginRuntimeConnector:
@@ -279,7 +281,61 @@ class PluginRuntimeConnector:
if not self.is_enable_plugin:
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]:
return await self.handler.get_plugin_info(author, plugin_name)

View File

@@ -0,0 +1 @@
# Plugin connector unit tests

View 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

View File

@@ -12,6 +12,7 @@
padding-left: 0.8rem;
padding-right: 0.8rem;
padding-top: 2rem;
padding-bottom: 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
gap: 2rem;