Skip to content

Commit 604392d

Browse files
authored
Refactor: stat service deprecated stats (#9658)
* refactor(dashboard): migrate get_stat off deprecated stats methods Rewrite StatService.get_stat to query PlatformStat directly via db_helper.get_db(), following the existing get_provider_token_stats pattern in the same file, instead of the deprecated get_base_stats/get_grouped_base_stats/get_total_message_count. - Windowed rows are fetched once with an explicit ORDER BY timestamp (the old get_base_stats relied on insertion order) - Per-platform sums and hourly time-series buckets are aggregated in Python; total message count uses func.coalesce(func.sum(...), 0) - Response shape is unchanged: platform entries keep the {name, count, timestamp} keys, now built as plain dicts so no deprecated po.Platform/Stats classes are instantiated - Verified semantically identical against the old methods with an A/B comparison over a seeded database (time series, per-platform sums, total count, and empty-window case all match) * test(dashboard): cover StatService.get_stat aggregation semantics The existing test_get_stat route test only asserts the HTTP status and the presence of the platform key, so an aggregation regression would pass unnoticed. Add focused unit tests that seed PlatformStat rows and assert the windowed per-platform sums, the global message total, the hourly time-series bucket shape, the response key set, and the empty-window behavior.
1 parent 19c029e commit 604392d

2 files changed

Lines changed: 133 additions & 32 deletions

File tree

astrbot/dashboard/services/stat_service.py

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import aiohttp
1515
import psutil
16-
from sqlmodel import col, select
16+
from sqlmodel import col, func, select
1717

1818
from astrbot.core import DEMO_MODE, logger
1919
from astrbot.core.config import VERSION
@@ -23,7 +23,7 @@
2323
get_dashboard_version,
2424
)
2525
from astrbot.core.db import BaseDatabase
26-
from astrbot.core.db.po import ProviderStat
26+
from astrbot.core.db.po import PlatformStat, ProviderStat
2727
from astrbot.core.desktop_runtime import (
2828
DESKTOP_MANAGED_RESTART_MESSAGE,
2929
is_desktop_managed_backend,
@@ -210,23 +210,49 @@ async def cleanup_storage(self, target: str) -> dict:
210210

211211
async def get_stat(self, offset_sec: int) -> dict:
212212
try:
213-
stat = self.db_helper.get_base_stats(offset_sec)
214213
now = int(time.time())
215214
start_time = now - offset_sec
216-
message_time_based_stats = []
217215

216+
async with self.db_helper.get_db() as session:
217+
window_start = datetime.now() - timedelta(seconds=offset_sec)
218+
result = await session.execute(
219+
select(PlatformStat)
220+
.where(PlatformStat.timestamp >= window_start)
221+
.order_by(col(PlatformStat.timestamp)),
222+
)
223+
# Convert to (epoch_seconds, count, platform_id) tuples once.
224+
rows = [
225+
(int(r.timestamp.timestamp()), r.count, r.platform_id)
226+
for r in result.scalars().all()
227+
]
228+
total_messages = (
229+
await session.execute(
230+
select(func.coalesce(func.sum(PlatformStat.count), 0)),
231+
)
232+
).scalar_one()
233+
234+
# Bucket message counts into hourly slots for the time series chart.
235+
message_time_based_stats = []
218236
idx = 0
219237
for bucket_end in range(start_time, now, 3600):
220238
cnt = 0
221-
while (
222-
idx < len(stat.platform)
223-
and stat.platform[idx].timestamp < bucket_end
224-
):
225-
cnt += stat.platform[idx].count
239+
while idx < len(rows) and rows[idx][0] < bucket_end:
240+
cnt += rows[idx][1]
226241
idx += 1
227242
message_time_based_stats.append([bucket_end, cnt])
228243

229-
stat_dict = stat.__dict__
244+
# Aggregate per-platform message counts within the window.
245+
per_platform: dict[str, int] = defaultdict(int)
246+
for _, count, platform_id in rows:
247+
per_platform[platform_id] += count
248+
platform_stats = [
249+
{
250+
"name": platform_id,
251+
"count": count,
252+
"timestamp": int(window_start.timestamp()),
253+
}
254+
for platform_id, count in per_platform.items()
255+
]
230256

231257
process_cpu = await asyncio.to_thread(psutil.Process().cpu_percent, 0.5)
232258
cpu_percent = process_cpu / (psutil.cpu_count() or 1)
@@ -246,29 +272,24 @@ async def get_stat(self, offset_sec: int) -> dict:
246272
int(time.time()) - self.core_lifecycle.start_time,
247273
)
248274

249-
stat_dict.update(
250-
{
251-
"platform": self.db_helper.get_grouped_base_stats(
252-
offset_sec,
253-
).platform,
254-
"message_count": self.db_helper.get_total_message_count() or 0,
255-
"platform_count": len(
256-
self.core_lifecycle.platform_manager.get_insts(),
257-
),
258-
"plugin_count": len(plugins),
259-
"plugins": plugin_info,
260-
"message_time_series": message_time_based_stats,
261-
"running": running_time,
262-
"memory": {
263-
"process": psutil.Process().memory_info().rss >> 20,
264-
"system": psutil.virtual_memory().total >> 20,
265-
},
266-
"cpu_percent": round(cpu_percent, 1),
267-
"thread_count": thread_count,
268-
"start_time": self.core_lifecycle.start_time,
275+
return {
276+
"platform": platform_stats,
277+
"message_count": total_messages,
278+
"platform_count": len(
279+
self.core_lifecycle.platform_manager.get_insts(),
280+
),
281+
"plugin_count": len(plugins),
282+
"plugins": plugin_info,
283+
"message_time_series": message_time_based_stats,
284+
"running": running_time,
285+
"memory": {
286+
"process": psutil.Process().memory_info().rss >> 20,
287+
"system": psutil.virtual_memory().total >> 20,
269288
},
270-
)
271-
return stat_dict
289+
"cpu_percent": round(cpu_percent, 1),
290+
"thread_count": thread_count,
291+
"start_time": self.core_lifecycle.start_time,
292+
}
272293
except Exception as exc:
273294
logger.error(traceback.format_exc())
274295
raise StatServiceError(str(exc)) from exc

tests/unit/test_stat_service.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import time
2+
from datetime import datetime, timedelta
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
7+
from astrbot.dashboard.services.stat_service import StatService
8+
9+
10+
def _make_service(db) -> StatService:
11+
"""Build a StatService with a real DB and a mocked core lifecycle."""
12+
core_lifecycle = MagicMock()
13+
core_lifecycle.star_context.get_all_stars.return_value = []
14+
core_lifecycle.platform_manager.get_insts.return_value = []
15+
core_lifecycle.start_time = int(time.time()) - 100
16+
return StatService(db_helper=db, core_lifecycle=core_lifecycle, config={})
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_get_stat_aggregates_platform_stats(temp_db):
21+
"""Seeded rows must aggregate into windowed platform sums and a global total."""
22+
now = datetime.now()
23+
seed = [
24+
("aiocqhttp", 3, now - timedelta(hours=1)),
25+
("aiocqhttp", 5, now - timedelta(hours=1, minutes=30)),
26+
("qqofficial", 2, now - timedelta(hours=2)),
27+
("webchat", 7, now - timedelta(minutes=10)),
28+
# Outside the 24h window: counted in the total but not in window stats.
29+
("aiocqhttp", 4, now - timedelta(hours=26)),
30+
]
31+
for platform_id, count, ts in seed:
32+
await temp_db.insert_platform_stats(platform_id, platform_id, count, ts)
33+
34+
result = await _make_service(temp_db).get_stat(86400)
35+
36+
# Global total counts every row, including the one outside the window.
37+
assert result["message_count"] == 21
38+
39+
# Windowed per-platform sums, serialized with the legacy response keys.
40+
platform = {entry["name"]: entry["count"] for entry in result["platform"]}
41+
assert platform == {"aiocqhttp": 8, "qqofficial": 2, "webchat": 7}
42+
for entry in result["platform"]:
43+
assert set(entry) == {"name", "count", "timestamp"}
44+
45+
# Hourly buckets cover [now - offset, now) in ascending order.
46+
series = result["message_time_series"]
47+
assert len(series) == 24
48+
bucket_ends = [bucket_end for bucket_end, _ in series]
49+
assert bucket_ends == sorted(bucket_ends)
50+
assert all(count >= 0 for _, count in series)
51+
# Rows within the current partial hour are not bucketed yet, so the
52+
# series sum never exceeds the windowed total of 17.
53+
assert sum(count for _, count in series) <= 17
54+
55+
assert set(result) == {
56+
"platform",
57+
"message_count",
58+
"platform_count",
59+
"plugin_count",
60+
"plugins",
61+
"message_time_series",
62+
"running",
63+
"memory",
64+
"cpu_percent",
65+
"thread_count",
66+
"start_time",
67+
}
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_get_stat_empty_window(temp_db):
72+
"""A window with no rows yields empty platform stats but keeps the total."""
73+
old_ts = datetime.now() - timedelta(hours=2)
74+
await temp_db.insert_platform_stats("aiocqhttp", "aiocqhttp", 4, old_ts)
75+
76+
result = await _make_service(temp_db).get_stat(1)
77+
78+
assert result["platform"] == []
79+
assert result["message_count"] == 4
80+
assert all(count == 0 for _, count in result["message_time_series"])

0 commit comments

Comments
 (0)