mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 02:18:06 +00:00
perf(webui)+feat(config): 聊天记录写盘节流 + 配置库空闲页回收
两条都是我上一封里点出、你说继续的问题。
① 聊天记录:每条消息都整段重写 → 节流合并写
原来 persistChatLocked 每次变更就整段重写记录文件,而一轮对话会触发多次
(用户消息、每个工具事件、收尾消息)。200 条上限下文件可达数 MB,单轮就能
放大出几十 MB 写。文件里还留着一个 chatSaveThrottle=3s 常量——声明了但从未
被使用(疑似上次 revert 的遗留),等于节流从来没生效。
现在:persistChatLocked 只置脏 + 唤醒写盘协程;chatPersistLoop 去抖
chatSaveThrottle(3s)、并以 chatSaveMaxDelay(10s) 兜底(持续输出也不会无限拖延);
写盘前把快照拷出来,**不持 chatMu 做文件 IO**;写失败重新标脏下轮重试。
插件 Stop 里调 Handler.Close():停协程 + 强制落最后一次(幂等),否则丢最后一轮。
实测(临时实例,连发 3 条消息):3s 窗口内记录文件**尚未创建**(节流生效);
SIGTERM 后文件出现且 6 条(3 用户 + 3 助手,无 LLM key 故为错误回复)全在
——关停落盘没丢。
② config.db:SQLite 的 DELETE 不缩文件 → 空闲页够多时 VACUUM
新增 ConfigRegistry.MaybeCompact(minFreeBytes, minRatio):空闲页 >= 1MB 且
占页数 >= 25% 才做一次 VACUUM,避免每次启动都重写整库。库里是 WAL 模式,
VACUUM 之后必须再 wal_checkpoint(TRUNCATE),否则主库文件看着没变小。
调用点放在插件加载**之后**(大值的搬走/删除发生在插件 Start 里,之前调没意义)。
实测(一个刚被搬走 5MB 聊天记录的实例):
freelist 1288 页 × 4096B;启动日志「配置库已压缩: 5394432 -> 118784 字节」
config.db 5,394,432 → 118,784 字节;记录文件 5,279,491 字节完好未动。
测试:TestChatPersistenceIsThrottled(节流窗口内不写盘 + Close 必落盘 + Close 幂等)、
TestMaybeCompactReclaimsFreePages(删大值后文件确实变小 + 数据完好 + 阈值不达标时不白做功)。
This commit is contained in:
@ -494,6 +494,49 @@ func (r *ConfigRegistry) Close() error {
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
// MaybeCompact 在「空闲页占比高且绝对量够大」时做一次 VACUUM,把文件真正缩回去。
|
||||
//
|
||||
// 为什么需要:SQLite 的 DELETE 只把页标成空闲(进 freelist),文件体积不变。
|
||||
// 典型场景是聊天记录那类大值被搬走/删除后,config.db 仍占着几 MB。
|
||||
// 只在空闲页超过 minFreeBytes 且占比 >= minRatio 时动手,避免每次启动都重写整个库。
|
||||
// 返回是否真的执行了 VACUUM。
|
||||
func (r *ConfigRegistry) MaybeCompact(minFreeBytes int64, minRatio float64) (bool, error) {
|
||||
if r.dbPath == "" || r.dbPath == ":memory:" {
|
||||
return false, nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var pageCount, freeCount, pageSize int64
|
||||
if err := r.db.QueryRow("PRAGMA page_count").Scan(&pageCount); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.db.QueryRow("PRAGMA freelist_count").Scan(&freeCount); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.db.QueryRow("PRAGMA page_size").Scan(&pageSize); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if pageCount == 0 {
|
||||
return false, nil
|
||||
}
|
||||
freeBytes := freeCount * pageSize
|
||||
if freeBytes < minFreeBytes || float64(freeCount)/float64(pageCount) < minRatio {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := r.db.Exec("VACUUM"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 库是 WAL 模式:VACUUM 的结果先落进 -wal,必须再 checkpoint(TRUNCATE)
|
||||
// 才会真正回写主库文件并缩小它(否则文件大小看着没变)。
|
||||
if _, err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DBPath 返回配置库路径(空/内存库时为空串)。
|
||||
func (r *ConfigRegistry) DBPath() string { return r.dbPath }
|
||||
|
||||
var defaultSources = map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -452,3 +453,51 @@ func keysOf(defs []*ConfigDef) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMaybeCompactReclaimsFreePages 钉住「删除大值后文件要真的缩回去」。
|
||||
// SQLite 的 DELETE 只把页标空闲,文件体积不变;v1.3.12 之前那条 5MB 的
|
||||
// plugin.webui.chathistory 就是这样让 config.db 一直占着几 MB。
|
||||
func TestMaybeCompactReclaimsFreePages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.db")
|
||||
r := NewConfigRegistry(path)
|
||||
r.RegisterDef(ConfigDef{Key: "plugin.webui.chathistory", Default: ""})
|
||||
|
||||
big := strings.Repeat("x", 512*1024)
|
||||
r.Set("plugin.webui.chathistory", big)
|
||||
if err := r.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
before, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 阈值远比这次写入小 → 搬走大值后应触发压缩
|
||||
if err := r.Delete("plugin.webui.chathistory"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
done, err := r.MaybeCompact(1, 0.1)
|
||||
if err != nil {
|
||||
t.Fatalf("MaybeCompact: %v", err)
|
||||
}
|
||||
if !done {
|
||||
t.Fatal("空闲页远大于阈值时应执行 VACUUM")
|
||||
}
|
||||
after, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Size() >= before.Size() {
|
||||
t.Fatalf("压缩后文件应变小:%d -> %d", before.Size(), after.Size())
|
||||
}
|
||||
// 数据仍在(压缩不能破坏内容)
|
||||
r.Set("core.llm.model", "m1")
|
||||
if v, err := r.Get("core.llm.model"); err != nil || v != "m1" {
|
||||
t.Fatalf("压缩后读写异常: %v %v", v, err)
|
||||
}
|
||||
// 没有空闲页时不应白做功
|
||||
if done2, err := r.MaybeCompact(1<<30, 0.9); err != nil || done2 {
|
||||
t.Fatalf("阈值很高时不应压缩,得到 done=%v err=%v", done2, err)
|
||||
}
|
||||
r.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user