perf(dashboard): 用量统计从秒级降到感知不到 — SQLite 连接单例、配置读取缓存、聚合结果缓存、快照持久化 + 后台校验
功能与数据口径零变化(用量数字、字段、IPC 签名、导出格式均与 v0.8.1 一致)。
- db.rs:新增进程级连接单例(含迁移只跑一次),卡片墙场景由 200+ 次「开连接 + 7 条 DDL」降为 1 次;
新增写路径代际计数器供各处读缓存判失效
- commands.rs:load_agent_config_command 结果缓存(db 代际 + config.toml 文件戳双重校验),
N 张供应商卡片由 N 次全量加载(config.toml 解析 + SQLite 全量读 + 逐供应商 settings 读)降为 1 次
- dashboard.rs:
* build_all_models 单遍轻量聚合,替换原先为取一张模型表而白算日/明细/供应商三份数据的整表遍历
* SUMMARY_CACHE 按记录集 Arc 身份 + 日期 + 分钟桶缓存热力图/模型总览/各范围汇总,切范围来回近乎零成本
* 归档会话列表与合成记录按代际 + Arc 身份缓存(合并后的 Arc 保持稳定,否则上层缓存永不命中)
* 解析结果持久化到 ~/.kimi-switch/scan-cache-<hash>.json:重启后首次 get_summary 直读快照
(实测 6.4 万条记录 / 1.03 GB:22~27s → 0.85s),随后后台线程全量校验,
有变化时经 dashboard://records-updated 事件推送,前端无感刷新(先快后准);
快照损坏/指纹不符静默降级为全量扫描,refresh=true 语义不变
- useDashboard.ts:stale-while-revalidate,切范围/重进页面先呈现旧数据再静默刷新,不再闪加载态;
订阅上述事件做无感更新,保留原世代号防竞态
测试:Rust 193 项(+16)、前端 130 项全绿
This commit is contained in:
1 parent
803c346854
commit
ded166c4c8
4 files changed
+1591
-140
No files matched your search
@@ -6,7 +6,7 @@ use indexmap::IndexMap;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
@@ -79,8 +79,61 @@ fn load_pi_native_config() -> Result<Config, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Cached `load_agent_config_command` results, keyed by agent. The full load
|
||||
/// parses config.toml, reads all of SQLite and runs `merge_usage_kinds` (two
|
||||
/// settings reads per provider); a usage-card wall triggers one load per
|
||||
/// provider card, so caching turns N loads into one. Entries are validated
|
||||
/// against the db generation counter (bumped by every write path in db.rs)
|
||||
/// plus the config.toml file stamp (catches edits made outside the app, e.g.
|
||||
/// the CLI's /provider command).
|
||||
static CONFIG_CACHE: Mutex<Option<HashMap<String, (u64, Option<(u64, u64)>, Config)>>> =
|
||||
Mutex::new(None);
|
||||
|
||||
/// (mtime_ms, len) of the authoritative on-disk config file for the agent, or
|
||||
/// None when the file is absent / the agent has no authoritative file (Pi is
|
||||
/// SQLite-first, so the db generation alone covers it).
|
||||
fn config_file_stamp(agent: &Agent) -> Option<(u64, u64)> {
|
||||
match agent {
|
||||
Agent::KimiCode => {
|
||||
let md = std::fs::metadata(crate::kimi_code_io::kimi_code_config_path()).ok()?;
|
||||
let mtime_ms = md
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_millis() as u64;
|
||||
Some((mtime_ms, md.len()))
|
||||
}
|
||||
Agent::Pi => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
||||
let stamp = config_file_stamp(&agent);
|
||||
let gen = db::generation();
|
||||
let key = agent.as_str().to_string();
|
||||
|
||||
{
|
||||
let guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(map) = guard.as_ref() {
|
||||
if let Some((cached_gen, cached_stamp, config)) = map.get(&key) {
|
||||
if *cached_gen == gen && *cached_stamp == stamp {
|
||||
return Ok(config.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config = load_agent_config_uncached(agent)?;
|
||||
let mut guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.get_or_insert_with(HashMap::new)
|
||||
.insert(key, (gen, stamp, config.clone()));
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn load_agent_config_uncached(agent: Agent) -> Result<Config, String> {
|
||||
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
|
||||
let db_config = db::load_config(&agent).ok();
|
||||
|
||||
|
||||
+1335
-66
File diff suppressed because it is too large.
Load diff
+125
-59
@@ -6,6 +6,8 @@
|
||||
//! a provider.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::Context;
|
||||
use indexmap::IndexMap;
|
||||
@@ -17,6 +19,51 @@ use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
||||
|
||||
pub type DbResult<T> = anyhow::Result<T>;
|
||||
|
||||
/// Process-wide cached connection, keyed by the resolved db path. Opening a
|
||||
/// SQLite connection plus running the DDL migrations costs several ms; before
|
||||
/// this cache every public helper paid that cost on every call (a usage-card
|
||||
/// wall could open 200+ connections per refresh). `Connection` is `Send` but
|
||||
/// not `Sync`, so a `Mutex` is the correct `static` wrapper — all db access is
|
||||
/// serialized, which is fine for a single-user desktop app.
|
||||
static DB_CONN: Mutex<Option<(PathBuf, Connection)>> = Mutex::new(None);
|
||||
|
||||
/// Monotonic generation counter, bumped by every write path. Read-side caches
|
||||
/// (config cache, archived-session cache) key on this to detect staleness.
|
||||
static DB_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub(crate) fn generation() -> u64 {
|
||||
DB_GEN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn bump_generation() {
|
||||
DB_GEN.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Run `f` against the cached connection for the current `db_path()`, opening
|
||||
/// (and migrating) it on first use or whenever the path changed (tests point
|
||||
/// `KIMI_SWITCH_DB_PATH` at per-test temp dirs).
|
||||
fn with_conn<T>(f: impl FnOnce(&mut Connection) -> DbResult<T>) -> DbResult<T> {
|
||||
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path = db_path();
|
||||
let needs_open = match guard.as_ref() {
|
||||
Some((cached_path, _)) => cached_path != &path,
|
||||
None => true,
|
||||
};
|
||||
if needs_open {
|
||||
*guard = Some((path, init_db()?));
|
||||
}
|
||||
let (_, conn) = guard.as_mut().expect("connection just opened");
|
||||
f(conn)
|
||||
}
|
||||
|
||||
/// Drop the cached connection so tests that switch `KIMI_SWITCH_DB_PATH`
|
||||
/// between temp dirs never observe cross-test state.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn close_cached_conn_for_tests() {
|
||||
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
pub fn kimi_switch_data_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".kimi-switch"))
|
||||
@@ -158,7 +205,10 @@ pub fn init_db() -> DbResult<Connection> {
|
||||
}
|
||||
|
||||
pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
let mut conn = init_db()?;
|
||||
with_conn(|conn| load_config_inner(conn, agent))
|
||||
}
|
||||
|
||||
fn load_config_inner(conn: &mut Connection, agent: &Agent) -> DbResult<Config> {
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
let default_model = get_setting_tx(&tx, &default_model_key(agent))?;
|
||||
@@ -249,7 +299,12 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
}
|
||||
|
||||
pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
let mut conn = init_db()?;
|
||||
with_conn(|conn| save_config_inner(conn, agent, config))?;
|
||||
bump_generation();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_config_inner(conn: &mut Connection, agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
tx.execute("DELETE FROM providers WHERE agent = ?1", params![agent.as_str()])?;
|
||||
@@ -340,29 +395,36 @@ fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResul
|
||||
|
||||
/// Public helper: read a single setting without an explicit transaction.
|
||||
pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
|
||||
let conn = init_db()?;
|
||||
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(row.get(0)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
with_conn(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(row.get(0)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Public helper: write a single setting in its own transaction.
|
||||
pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
|
||||
let mut conn = init_db()?;
|
||||
let tx = conn.transaction()?;
|
||||
set_setting_tx(&tx, key, value)?;
|
||||
tx.commit()?;
|
||||
with_conn(|conn| {
|
||||
let tx = conn.transaction()?;
|
||||
set_setting_tx(&tx, key, value)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
bump_generation();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Public helper: delete a single setting (no-op if the key does not exist).
|
||||
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
|
||||
let conn = init_db()?;
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
||||
with_conn(|conn| {
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
})?;
|
||||
bump_generation();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -413,55 +475,59 @@ pub struct ArchivedSessionSnapshot {
|
||||
/// Insert or replace one archived-session snapshot (keyed by session id, which
|
||||
/// is a UUID and never reused).
|
||||
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
|
||||
let conn = init_db()?;
|
||||
let day_stats = serde_json::to_string(&row.day_stats)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO archived_sessions
|
||||
(session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms, total_tokens, total_cost_usd, day_stats)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
row.session_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.archived_at_ms as i64,
|
||||
row.updated_at_ms.map(|v| v as i64),
|
||||
row.created_at_ms.map(|v| v as i64),
|
||||
row.total_tokens as i64,
|
||||
row.total_cost_usd,
|
||||
day_stats,
|
||||
],
|
||||
)?;
|
||||
with_conn(|conn| {
|
||||
let day_stats = serde_json::to_string(&row.day_stats)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO archived_sessions
|
||||
(session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms, total_tokens, total_cost_usd, day_stats)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
row.session_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.archived_at_ms as i64,
|
||||
row.updated_at_ms.map(|v| v as i64),
|
||||
row.created_at_ms.map(|v| v as i64),
|
||||
row.total_tokens as i64,
|
||||
row.total_cost_usd,
|
||||
day_stats,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
bump_generation();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
|
||||
/// to an empty breakdown instead of failing the whole list.
|
||||
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
|
||||
let conn = init_db()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
|
||||
total_tokens, total_cost_usd, day_stats
|
||||
FROM archived_sessions",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let day_stats_json: String = row.get(8)?;
|
||||
Ok(ArchivedSessionSnapshot {
|
||||
session_id: row.get(0)?,
|
||||
workspace_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
archived_at_ms: row.get::<_, i64>(3)? as u64,
|
||||
updated_at_ms: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
|
||||
created_at_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
|
||||
total_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cost_usd: row.get(7)?,
|
||||
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
|
||||
total_tokens, total_cost_usd, day_stats
|
||||
FROM archived_sessions",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let day_stats_json: String = row.get(8)?;
|
||||
Ok(ArchivedSessionSnapshot {
|
||||
session_id: row.get(0)?,
|
||||
workspace_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
archived_at_ms: row.get::<_, i64>(3)? as u64,
|
||||
updated_at_ms: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
|
||||
created_at_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
|
||||
total_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cost_usd: row.get(7)?,
|
||||
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||
|
||||
Reference in new issue
Block a user