返回 DeepSeek-Reasonix
usage_catalog.go
根目录 / internal / stats / usage_catalog.go
1 package stats
2
3 import (
4 "context"
5 "path/filepath"
6 "strings"
7 "sync"
8 "sync/atomic"
9
10 "reasonix/internal/config"
11 "reasonix/internal/usagecatalog"
12 )
13
14 type usageManager struct {
15 catalog atomic.Pointer[usagecatalog.Catalog]
16 mu sync.Mutex
17 generation uint64
18 opening bool
19 openDone chan struct{}
20 openCancel context.CancelFunc
21 open func(context.Context, string) (*usagecatalog.Catalog, error)
22 }
23
24 var usageManagers = struct {
25 sync.Mutex
26 byDir map[string]*usageManager
27 }{byDir: map[string]*usageManager{}}
28
29 func managerForUsage(dir string) *usageManager {
30 dir = strings.TrimSpace(dir)
31 if dir == "" || !sameUsageDirectory(dir, config.StatsDir()) {
32 return nil
33 }
34 usageManagers.Lock()
35 manager := usageManagers.byDir[dir]
36 if manager == nil {
37 manager = &usageManager{}
38 usageManagers.byDir[dir] = manager
39 }
40 usageManagers.Unlock()
41 manager.start(dir)
42 return manager
43 }
44
45 func (m *usageManager) start(dir string) {
46 m.mu.Lock()
47 if m.catalog.Load() != nil || m.opening {
48 m.mu.Unlock()
49 return
50 }
51 m.generation++
52 generation := m.generation
53 ctx, cancel := context.WithCancel(context.Background())
54 done := make(chan struct{})
55 m.opening, m.openDone, m.openCancel = true, done, cancel
56 openCatalog := m.open
57 if openCatalog == nil {
58 openCatalog = usagecatalog.Open
59 }
60 m.mu.Unlock()
61 go m.openGeneration(ctx, generation, done, dir, openCatalog)
62 }
63
64 func (m *usageManager) openGeneration(ctx context.Context, generation uint64, done chan struct{}, dir string, openCatalog func(context.Context, string) (*usagecatalog.Catalog, error)) {
65 catalog, err := openCatalog(ctx, "")
66 if err == nil {
67 _ = catalog.ReconcileDir(ctx, dir)
68 }
69 m.mu.Lock()
70 stale := generation != m.generation || ctx.Err() != nil
71 if err == nil && !stale {
72 m.catalog.Store(catalog)
73 }
74 m.mu.Unlock()
75 if catalog != nil && (err != nil || stale) {
76 _ = catalog.Close(context.Background())
77 }
78 m.mu.Lock()
79 if m.openDone == done {
80 m.opening = false
81 m.openDone = nil
82 m.openCancel = nil
83 }
84 close(done)
85 m.mu.Unlock()
86 }
87
88 func (m *usageManager) close(ctx context.Context) error {
89 m.mu.Lock()
90 m.generation++
91 if m.openCancel != nil {
92 m.openCancel()
93 }
94 done := m.openDone
95 catalog := m.catalog.Swap(nil)
96 m.mu.Unlock()
97 var closeErr error
98 if catalog != nil {
99 closeErr = catalog.Close(ctx)
100 }
101 if done != nil {
102 select {
103 case <-done:
104 case <-ctx.Done():
105 if closeErr == nil {
106 closeErr = ctx.Err()
107 }
108 }
109 }
110 return closeErr
111 }
112
113 // The single usage catalog projects the single authoritative Reasonix stats
114 // directory. Test/custom writers retain the exact JSONL implementation rather
115 // than accidentally sharing rollups with the production cache database.
116 func sameUsageDirectory(left, right string) bool {
117 leftAbs, leftErr := filepath.Abs(filepath.Clean(left))
118 rightAbs, rightErr := filepath.Abs(filepath.Clean(right))
119 return leftErr == nil && rightErr == nil && leftAbs == rightAbs
120 }
121
122 // existingUsageManager returns an already-started projection without creating
123 // background work. Read-only commands, Query and Flush use this path so merely
124 // inspecting authoritative JSONL cannot make the process outlive the command.
125 func existingUsageManager(dir string) *usageManager {
126 dir = strings.TrimSpace(dir)
127 if dir == "" {
128 return nil
129 }
130 usageManagers.Lock()
131 defer usageManagers.Unlock()
132 return usageManagers.byDir[dir]
133 }
134
135 // CloseUsageCatalogs closes every process-local usage projection. Desktop
136 // shutdown and test isolation call this so Windows can delete TempDir cache
137 // files that would otherwise stay locked by open SQLite handles.
138 func CloseUsageCatalogs(ctx context.Context) error {
139 usageManagers.Lock()
140 managers := make([]*usageManager, 0, len(usageManagers.byDir))
141 for dir, manager := range usageManagers.byDir {
142 managers = append(managers, manager)
143 delete(usageManagers.byDir, dir)
144 }
145 usageManagers.Unlock()
146 var first error
147 for _, manager := range managers {
148 if err := manager.close(ctx); err != nil && first == nil {
149 first = err
150 }
151 }
152 return first
153 }
154
155 func usageEntry(day string, r record) usagecatalog.Entry {
156 turns := 0
157 if r.Turn {
158 turns = 1
159 }
160 return usagecatalog.Entry{Day: day, Source: r.Source, ModelRef: r.ModelRef, Provider: providerOf(r.ModelRef),
161 Prompt: r.Prompt, Completion: r.Completion, Reasoning: r.Reasoning, CacheHit: r.CacheHit,
162 CacheMiss: r.CacheMiss, Total: r.Total, Requests: r.Requests, Turns: turns}
163 }
164
164 lines GO