package main // 本文件把 **LLM 用量落库与保留期清理**接进组合根(切片 U1,主人裁决「2.4 落」): // 构造异步写入器、把它注入模型栈,并在启动期清一次过期样本、此后按间隔定时清。 // // 三条取舍写在这里(写入器实现见 `internal/runtime/model/usage_store.go`): // // 1. **写入绝不阻塞模型调用**:模型栈拿到的是一个有界队列 + 单写者的 sink, // `RecordUsage` 不做任何等待(队列满就丢弃并限流 WARN)。因此库慢、库锁、 // 库挂了,主人的每次对话都不受影响。 // 2. **失败不退出进程**:整批落库失败只记一条带来源的 WARN;保留期清理失败 // 同样是 WARN 后等下一轮 —— 用量是观测数据,不该决定进程生死。 // 3. **关闭 = 显式配 ≤0**:`LLM_USAGE_RETENTION=0`(或负值)不构造清理器, // 由下面的一条 INFO 写明「清理已关闭」(静默的「配了却不生效」不存在)。 // // 清理间隔刻意不做成 env 键:保留期才是部署者的旋钮,清扫频率没有可调价值。 import ( "context" "database/sql" "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // usagePurgeInterval 是用量保留期清理的定时间隔(启动期另有一次立即清扫)。 const usagePurgeInterval = time.Hour // usageRetentionGC 是常驻的用量保留期清理任务:一个连接 + 一个可停的循环。 type usageRetentionGC struct { db *sql.DB retention time.Duration log *slog.Logger cancel context.CancelFunc done chan struct{} } // newUsageSink 按当前配置构造用量写入器;库未接时返回 nil(= 只记日志, // 与这一版之前逐字相同)。 func (a *app) newUsageSink() *model.UsageWriter { return model.NewUsageWriter(a.db, a.log) } // newUsageRetentionGC 按当前配置构造保留期清理器;库未接或保留期 ≤0(关闭清理) // 时返回 nil。关闭原因由 startUsage 记一条 INFO。 func (a *app) newUsageRetentionGC() *usageRetentionGC { if a.db == nil || a.cfg.Server.LLMUsageRetention <= 0 { return nil } return &usageRetentionGC{ db: a.db, retention: a.cfg.Server.LLMUsageRetention, log: logger.WithSource(a.log, "cmd/cyrene"), } } // startUsage 拉起用量落库的单写者与保留期清理(各自关闭时是空操作)。 // 它不等清理跑完就去拉网关(与文档 GC 同一取舍:慢盘上的一次 DELETE 不该把 // 「昔涟能不能起来」绑在磁盘上)。 func (a *app) startUsage(ctx context.Context) { if a.usage != nil { a.usage.Start(ctx) } gc := a.newUsageRetentionGC() if gc == nil { a.log.Info("llm usage retention disabled", "env", "LLM_USAGE_RETENTION", "value", a.cfg.Server.LLMUsageRetention.String()) return } runCtx, cancel := context.WithCancel(ctx) gc.cancel = cancel gc.done = make(chan struct{}) a.usageGC = gc go gc.loop(runCtx) } // stopUsage 停掉用量落库与保留期清理(先让单写者把队列里剩下的样本写完,再停 // 清理)。两者都要用连接池,因此必须在关库**之前**调用;未启动时是空操作。 func (a *app) stopUsage() { if a.usage != nil { a.usage.Stop() } a.usageGC.stop() } // loop 是清理循环:启动期清扫一次 → 定时清扫。 func (g *usageRetentionGC) loop(ctx context.Context) { defer close(g.done) g.sweep(ctx) ticker := time.NewTicker(usagePurgeInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: g.sweep(ctx) } } } // sweep 跑一轮清理。删除条数**不是**每次都要记(正常运转时每轮都是 0,记满日志 // 只是噪声);真的删了才记一条 INFO,失败一律 WARN 并让循环继续。 func (g *usageRetentionGC) sweep(ctx context.Context) { before := time.Now().Add(-g.retention) removed, err := model.PurgeUsage(ctx, g.db, before) if err != nil { g.log.Warn("llm usage retention sweep failed", "err", err) return } if removed > 0 { g.log.Info("llm usage retention sweep removed rows", "rows", removed, "retention", g.retention.String()) } } // stop 停掉清理循环并等它退出;未启动时是空操作。 func (g *usageRetentionGC) stop() { if g == nil || g.cancel == nil { return } g.cancel() <-g.done }