返回 DeepSeek-Reasonix
stats.go
根目录 / internal / plugin / stats.go
1 // Per-plugin startup latency tracking for MCP servers. Reasonix uses these
2 // samples to decide whether a chronically slow plugin should be demoted from
3 // "eager" to background loading for the rest of a session — see Recommend.
4 //
5 // Storage is one tiny JSON file per plugin under <cacheDir>/mcp/, written
6 // atomically (tmpfile + Rename) so a crash mid-write can't corrupt history.
7 // All errors are best-effort: missing/unreadable files yield "no demote",
8 // write failures get logged via slog and dropped — startup must not fail
9 // because telemetry can't persist.
10 package plugin
11
12 import (
13 "encoding/json"
14 "fmt"
15 "hash/fnv"
16 "log/slog"
17 "os"
18 "path/filepath"
19 "slices"
20 "time"
21
22 "reasonix/internal/config"
23 fileencoding "reasonix/internal/fileutil/encoding"
24 )
25
26 const (
27 // statsVersion is the on-disk format version. Bump when StartupStats changes
28 // shape incompatibly; older files are then ignored on load (treated as
29 // missing) so a new build doesn't crash on a stale schema.
30 statsVersion = 1
31 // maxSamples bounds the rolling window. 20 is enough to absorb a few flukes
32 // while still letting recent regressions dominate p99 / consecutive-fail
33 // checks. Older samples drop off the front.
34 maxSamples = 20
35 // defaultDemoteAfter is the consecutive-over-budget threshold used when the
36 // caller passes <= 0. Three in a row is "user has felt it three startups",
37 // which matches what the plan calls out as the demote signal.
38 defaultDemoteAfter = 3
39 )
40
41 // StartupStats is the on-disk record of recent startup durations for one
42 // plugin. SamplesMs is oldest→newest (newest appended at the tail); LastSeen
43 // is the wall-clock time the most recent sample was recorded so a future
44 // "stale data" pruning step can act on it without re-parsing each sample.
45 type StartupStats struct {
46 Version int `json:"version"`
47 SamplesMs []int64 `json:"samples_ms"`
48 LastSeen time.Time `json:"last_seen"`
49 // Name is the raw (pre-slug) server name that owns these samples. The
50 // filename slug is lossy — "foo.bar" and "foo-bar" share one file — so
51 // readers verify ownership before trusting samples, or a slow server's
52 // history could demote an unrelated healthy one. Empty on files written
53 // by older versions; those are adopted by the first writer (additive
54 // field, no version bump, so downgraded builds still read the file).
55 Name string `json:"name,omitempty"`
56 }
57
58 // Recommendation is the result of inspecting a plugin's recent startup
59 // history. Demote is the actionable bit boot.go consumes (true → switch this
60 // plugin to background startup for this session). P99 and Reason are
61 // descriptive only — Reason is meant to be surfaced to the user as a Notice so a
62 // sudden demotion isn't silent.
63 type Recommendation struct {
64 Demote bool
65 P99 time.Duration
66 Reason string
67 }
68
69 // RecordStartup appends one sample (the wall-clock duration of a plugin's
70 // blocking handshake phase) to that plugin's stats file. The file is created
71 // on first call; existing samples are kept in a rolling window of maxSamples,
72 // dropping the oldest when full.
73 //
74 // Best-effort: any I/O or marshal failure is logged with slog.Warn and
75 // returned, but callers are expected to ignore the error — telemetry must
76 // never block real work. Writes go through a tmpfile + Rename so a partial
77 // write can't leave the stats file truncated or unparseable.
78 func RecordStartup(name string, dur time.Duration) error {
79 path := statsPath(name)
80 if path == "" {
81 // No cache dir resolvable on this host — silently skip. This is the
82 // same fallback every other persistence helper in the project takes
83 // (ArchiveDir/SessionDir return "" and writers no-op).
84 return nil
85 }
86
87 stats := loadStatsForOwner(name) // missing/corrupt/foreign → fresh zero value
88 if stats.Version != statsVersion {
89 // Version mismatch: start over rather than try to migrate. The window
90 // is small enough that "lose 20 samples" is cheap, and it keeps the
91 // migration path trivial as the format evolves.
92 stats = StartupStats{Version: statsVersion}
93 }
94 stats.Name = name
95
96 ms := max(dur.Milliseconds(), 0)
97 stats.SamplesMs = append(stats.SamplesMs, ms)
98 if len(stats.SamplesMs) > maxSamples {
99 // Trim from the front: oldest samples leave first.
100 stats.SamplesMs = stats.SamplesMs[len(stats.SamplesMs)-maxSamples:]
101 }
102 stats.LastSeen = time.Now()
103
104 if err := writeStatsAtomic(path, stats); err != nil {
105 slog.Warn("plugin: record startup stats failed", "server", name, "err", err)
106 return err
107 }
108 return nil
109 }
110
111 // Recommend inspects the recent samples for name and decides whether the
112 // plugin should be demoted to "lazy" this session. The rule is simple: demote
113 // when the last demoteAfter samples all hit or exceed the blocking startup
114 // budget. Missing/empty stats → no demote (a fresh plugin gets the benefit of
115 // the doubt and one normal startup attempt).
116 //
117 // budget == 0 disables the check (returns no-demote). demoteAfter <= 0 falls
118 // back to defaultDemoteAfter so callers can pass the config value verbatim
119 // without sanitising it.
120 func Recommend(name string, budget time.Duration, demoteAfter int) Recommendation {
121 if budget <= 0 {
122 return Recommendation{}
123 }
124 if demoteAfter <= 0 {
125 demoteAfter = defaultDemoteAfter
126 }
127
128 path := statsPath(name)
129 if path == "" {
130 return Recommendation{}
131 }
132 stats := loadStatsForOwner(name)
133 if stats.Version != statsVersion || len(stats.SamplesMs) == 0 {
134 // Either no history yet or a format we can't read — give the plugin a
135 // chance. The cost of one slow start is small compared to wrongly
136 // demoting a healthy plugin off stale data.
137 return Recommendation{}
138 }
139
140 rec := Recommendation{P99: p99(stats.SamplesMs)}
141 if len(stats.SamplesMs) < demoteAfter {
142 return rec
143 }
144
145 threshold := budget.Milliseconds()
146 tail := stats.SamplesMs[len(stats.SamplesMs)-demoteAfter:]
147 for _, ms := range tail {
148 if ms < threshold {
149 return rec
150 }
151 }
152 rec.Demote = true
153 rec.Reason = fmt.Sprintf(
154 "plugin %q has been slow %d startups in a row (last %dms, budget %dms); demoting to background startup this session",
155 name, demoteAfter, tail[len(tail)-1], budget.Milliseconds(),
156 )
157 return rec
158 }
159
160 // statsPath returns the canonical path for one plugin's stats file:
161 // <config.CacheDir()>/mcp/<slug>-<hash>.stats.json. The slug alone is lossy —
162 // "foo.bar" and "foo-bar" collapse to one slug — and two colliding servers
163 // sharing a file would alternately reset each other's window, so neither
164 // could ever accumulate enough consecutive samples to demote. Hashing the raw
165 // name into the filename gives every server its own history. The slug portion
166 // is bounded so the whole component stays under the 255-byte filesystem limit
167 // even for very long server names — the hash carries the uniqueness, so
168 // truncating the slug is safe. Returns "" when no cache dir is resolvable,
169 // which all callers treat as "skip telemetry".
170 func statsPath(name string) string {
171 base := config.CacheDir()
172 if base == "" {
173 return ""
174 }
175 h := fnv.New32a()
176 _, _ = h.Write([]byte(name))
177 // 255-byte component budget: slug + "-" + 8 hex digits + ".stats.json".
178 stem := slug(name)
179 if maxStem := 255 - len("-00000000.stats.json"); len(stem) > maxStem {
180 stem = stem[:maxStem]
181 }
182 return filepath.Join(base, "mcp", fmt.Sprintf("%s-%08x.stats.json", stem, h.Sum32()))
183 }
184
185 // legacyStatsPath is the pre-hash location, shared by every server whose name
186 // collapses to the same slug. Read-only for migration: a named legacy file is
187 // adopted by its owner; a nameless one (pre-Name builds) has no provable owner
188 // and is never trusted for demotion or adopted into a new window.
189 func legacyStatsPath(name string) string {
190 base := config.CacheDir()
191 if base == "" {
192 return ""
193 }
194 return filepath.Join(base, "mcp", slug(name)+".stats.json")
195 }
196
197 // loadStats reads and decodes a stats file. Any failure (missing file,
198 // permission denied, malformed JSON) returns a zero StartupStats so callers
199 // can treat absence and corruption identically. The slog.Warn fires only on
200 // non-NotExist read errors and on JSON errors — those are surprising enough
201 // to be worth a trace, but they still must not stop the caller.
202 func loadStats(path string) StartupStats {
203 var s StartupStats
204 b, err := fileencoding.ReadFileUTF8(path)
205 if err != nil {
206 if !os.IsNotExist(err) {
207 slog.Warn("plugin: read startup stats failed", "path", path, "err", err)
208 }
209 return s
210 }
211 if err := json.Unmarshal(b, &s); err != nil {
212 slog.Warn("plugin: parse startup stats failed", "path", path, "err", err)
213 return StartupStats{}
214 }
215 return s
216 }
217
218 // loadStatsForOwner reads name's history from its hashed path, falling back to
219 // the shared legacy (pre-hash) file for a one-time migration. Legacy files are
220 // trusted only when their recorded Name matches: a nameless legacy file
221 // (written before ownership tracking) is shared by every server whose name
222 // collapses to the same slug, so its samples cannot be attributed and must not
223 // seed anyone's window or demote anyone.
224 func loadStatsForOwner(name string) StartupStats {
225 if s := loadStats(statsPath(name)); s.Version != 0 || len(s.SamplesMs) > 0 {
226 if s.Name == "" || s.Name == name {
227 return s
228 }
229 return StartupStats{}
230 }
231 legacy := legacyStatsPath(name)
232 if legacy == "" {
233 return StartupStats{}
234 }
235 if s := loadStats(legacy); s.Name == name {
236 return s
237 }
238 return StartupStats{}
239 }
240
241 // writeStatsAtomic serialises s and writes it via tmpfile + os.Rename so that
242 // concurrent readers see either the old content or the new one, never a
243 // half-written file. Mirrors desktop/sessions.go:40-64.
244 func writeStatsAtomic(path string, s StartupStats) error {
245 dir := filepath.Dir(path)
246 if err := os.MkdirAll(dir, 0o755); err != nil {
247 return err
248 }
249 b, err := json.Marshal(s)
250 if err != nil {
251 return err
252 }
253 tmp, err := os.CreateTemp(dir, ".stats.*.tmp")
254 if err != nil {
255 return err
256 }
257 tmpPath := tmp.Name()
258 if _, err := tmp.Write(b); err != nil {
259 tmp.Close()
260 os.Remove(tmpPath)
261 return err
262 }
263 if err := tmp.Close(); err != nil {
264 os.Remove(tmpPath)
265 return err
266 }
267 return os.Rename(tmpPath, path)
268 }
269
270 // p99 returns the 99th-percentile sample as a Duration. With small windows
271 // (n ≤ 20) "p99" collapses to "the slowest sample we have"; the value is
272 // purely informational, surfaced in Recommendation for UI/notice text.
273 func p99(samples []int64) time.Duration {
274 if len(samples) == 0 {
275 return 0
276 }
277 sorted := append([]int64(nil), samples...)
278 slices.Sort(sorted)
279 // Use ceil(0.99 * n) - 1 so that for n=1..100 we always pick the last
280 // element; for larger n it's the index at the 99% boundary.
281 idx := max(int(float64(len(sorted))*0.99+0.9999999)-1, 0)
282 if idx >= len(sorted) {
283 idx = len(sorted) - 1
284 }
285 return time.Duration(sorted[idx]) * time.Millisecond
286 }
287
287 lines GO