返回 DeepSeek-Reasonix
catalog.go
根目录 / internal / usagecatalog / catalog.go
1 // Package usagecatalog maintains a disposable aggregate projection of the
2 // authoritative daily statistics JSONL files.
3 package usagecatalog
4
5 import (
6 "bufio"
7 "context"
8 "crypto/sha256"
9 "database/sql"
10 "encoding/hex"
11 "encoding/json"
12 "errors"
13 "io"
14 "os"
15 "path/filepath"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "time"
20
21 "reasonix/internal/config"
22 "reasonix/internal/projectiondb"
23 )
24
25 const SchemaVersion = 1
26
27 type AppendReceipt struct {
28 Path string
29 Day string
30 Offset int64
31 Length int
32 LineHash string
33 }
34
35 type Entry struct {
36 Day string
37 Source string
38 ModelRef string
39 Provider string
40 Prompt int
41 Completion int
42 Reasoning int
43 CacheHit int
44 CacheMiss int
45 Total int
46 Requests int
47 Turns int
48 }
49
50 type Rollup struct {
51 Day string
52 Source string
53 ModelRef string
54 Provider string
55 Prompt int64
56 Completion int64
57 Reasoning int64
58 CacheHit int64
59 CacheMiss int64
60 Total int64
61 Requests int64
62 Turns int64
63 }
64
65 type Status struct {
66 State string `json:"state"`
67 Mode projectiondb.Mode `json:"mode"`
68 Path string `json:"path,omitempty"`
69 Revision uint64 `json:"revision"`
70 IndexedFiles int64 `json:"indexedFiles"`
71 LagBytes int64 `json:"lagBytes"`
72 CorruptLines int64 `json:"corruptLines"`
73 Fallbacks uint64 `json:"fallbacks"`
74 LastError string `json:"lastError,omitempty"`
75 }
76
77 type Catalog struct {
78 db *sql.DB
79 statusMu sync.RWMutex
80 status Status
81 revision atomic.Uint64
82 fallback atomic.Uint64
83 queue chan receiptEntry
84 dirtyFiles sync.Map
85 dirtyDirs sync.Map
86 dirtyWake chan struct{}
87 ctx context.Context
88 cancel context.CancelFunc
89 wg sync.WaitGroup
90 closeOnce sync.Once
91 closeDone chan struct{}
92 closeErr error
93 }
94
95 type receiptEntry struct {
96 receipt AppendReceipt
97 entry Entry
98 flush chan struct{}
99 }
100
101 // DefaultPath returns the disposable usage rollup path under CacheDir.
102 // Empty when cache is unavailable so Open falls back to an in-memory projection.
103 func DefaultPath() string {
104 cache := strings.TrimSpace(config.CacheDir())
105 if cache == "" {
106 return ""
107 }
108 return filepath.Join(cache, "usage-catalog", "v1.sqlite")
109 }
110
111 const schema = `
112 CREATE TABLE usage_state(id INTEGER PRIMARY KEY CHECK(id=1),revision INTEGER NOT NULL DEFAULT 0);
113 INSERT INTO usage_state(id,revision) VALUES(1,0);
114 CREATE TABLE usage_files(
115 path TEXT PRIMARY KEY,day TEXT NOT NULL,size INTEGER NOT NULL DEFAULT 0,mtime_ns INTEGER NOT NULL DEFAULT 0,
116 indexed_offset INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL DEFAULT 'pending',error TEXT NOT NULL DEFAULT '',
117 corrupt_lines INTEGER NOT NULL DEFAULT 0,completed_at INTEGER NOT NULL DEFAULT 0
118 );
119 CREATE TABLE usage_records(
120 file_path TEXT NOT NULL,byte_offset INTEGER NOT NULL,byte_length INTEGER NOT NULL,line_hash TEXT NOT NULL,
121 day TEXT NOT NULL,source TEXT NOT NULL,model_ref TEXT NOT NULL,provider TEXT NOT NULL,
122 prompt INTEGER NOT NULL,completion INTEGER NOT NULL,reasoning INTEGER NOT NULL,cache_hit INTEGER NOT NULL,
123 cache_miss INTEGER NOT NULL,total INTEGER NOT NULL,requests INTEGER NOT NULL,turns INTEGER NOT NULL,
124 PRIMARY KEY(file_path,byte_offset)
125 );
126 CREATE TABLE usage_rollups(
127 day TEXT NOT NULL,source TEXT NOT NULL,model_ref TEXT NOT NULL,provider TEXT NOT NULL,
128 prompt INTEGER NOT NULL,completion INTEGER NOT NULL,reasoning INTEGER NOT NULL,cache_hit INTEGER NOT NULL,
129 cache_miss INTEGER NOT NULL,total INTEGER NOT NULL,requests INTEGER NOT NULL,turns INTEGER NOT NULL,
130 PRIMARY KEY(day,source,model_ref)
131 );
132 CREATE INDEX idx_usage_rollups_range ON usage_rollups(day,source,model_ref);
133 `
134
135 func migrations() []projectiondb.Migration {
136 return []projectiondb.Migration{{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error {
137 _, err := tx.ExecContext(ctx, schema)
138 return err
139 }}}
140 }
141
142 func Open(ctx context.Context, path string) (*Catalog, error) {
143 if path == "" {
144 path = DefaultPath()
145 }
146 inMemory := strings.TrimSpace(path) == ""
147 if inMemory {
148 path = ""
149 }
150 handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{
151 Path: path, MemoryName: "usage-catalog", Migrations: migrations(), InMemory: inMemory, MaxOpenConns: 4,
152 })
153 if err != nil {
154 return nil, err
155 }
156 workerCtx, cancel := context.WithCancel(context.Background())
157 c := &Catalog{db: handle.DB, queue: make(chan receiptEntry, 1024), dirtyWake: make(chan struct{}, 1), ctx: workerCtx, cancel: cancel,
158 closeDone: make(chan struct{}),
159 status: Status{State: string(handle.Status.State), Mode: handle.Status.Mode, Path: handle.Status.Path, LastError: handle.Status.LastError}}
160 var revision uint64
161 _ = c.db.QueryRowContext(ctx, `SELECT revision FROM usage_state WHERE id=1`).Scan(&revision)
162 c.revision.Store(revision)
163 c.refresh(ctx)
164 c.wg.Add(1)
165 go c.worker()
166 return c, nil
167 }
168
169 func (c *Catalog) Enqueue(receipt AppendReceipt, entry Entry) bool {
170 if c == nil {
171 return false
172 }
173 select {
174 case c.queue <- receiptEntry{receipt: receipt, entry: entry}:
175 return true
176 default:
177 c.dirtyFiles.Store(receipt.Path, receipt.Day)
178 c.wakeDirty()
179 return false
180 }
181 }
182
183 func (c *Catalog) worker() {
184 defer c.wg.Done()
185 for {
186 if path, day, ok := c.takeDirtyFile(); ok {
187 _ = c.ReconcileFile(c.ctx, path, day)
188 continue
189 }
190 if dir, ok := c.takeDirtyDir(); ok {
191 _ = c.ReconcileDir(c.ctx, dir)
192 continue
193 }
194 select {
195 case <-c.ctx.Done():
196 return
197 case <-c.dirtyWake:
198 case item := <-c.queue:
199 if item.flush != nil {
200 close(item.flush)
201 continue
202 }
203 _ = c.applyReceipt(c.ctx, item.receipt, item.entry)
204 }
205 }
206 }
207
208 func (c *Catalog) takeDirtyFile() (string, string, bool) {
209 var path, day string
210 c.dirtyFiles.Range(func(key, value any) bool {
211 path, _ = key.(string)
212 day, _ = value.(string)
213 c.dirtyFiles.Delete(key)
214 return false
215 })
216 return path, day, path != ""
217 }
218
219 func (c *Catalog) takeDirtyDir() (string, bool) {
220 var dir string
221 c.dirtyDirs.Range(func(key, _ any) bool {
222 dir, _ = key.(string)
223 c.dirtyDirs.Delete(key)
224 return false
225 })
226 return dir, dir != ""
227 }
228
229 func (c *Catalog) RequestReconcileDir(dir string) {
230 if c != nil && strings.TrimSpace(dir) != "" {
231 c.dirtyDirs.Store(filepath.Clean(dir), true)
232 c.wakeDirty()
233 }
234 }
235
236 func (c *Catalog) wakeDirty() {
237 select {
238 case c.dirtyWake <- struct{}{}:
239 default:
240 }
241 }
242
243 func (c *Catalog) Flush(ctx context.Context) error {
244 if c == nil {
245 return nil
246 }
247 done := make(chan struct{})
248 select {
249 case c.queue <- receiptEntry{flush: done}:
250 case <-ctx.Done():
251 return ctx.Err()
252 }
253 select {
254 case <-done:
255 return nil
256 case <-ctx.Done():
257 return ctx.Err()
258 }
259 }
260
261 func (c *Catalog) applyReceipt(ctx context.Context, receipt AppendReceipt, entry Entry) error {
262 tx, err := c.db.BeginTx(ctx, nil)
263 if err != nil {
264 return err
265 }
266 var existingHash string
267 err = tx.QueryRowContext(ctx, `SELECT line_hash FROM usage_records WHERE file_path=? AND byte_offset=?`, receipt.Path, receipt.Offset).Scan(&existingHash)
268 if err == nil {
269 _ = tx.Rollback()
270 if existingHash != receipt.LineHash {
271 return c.ReconcileFile(ctx, receipt.Path, receipt.Day)
272 }
273 return nil
274 }
275 if !errors.Is(err, sql.ErrNoRows) {
276 _ = tx.Rollback()
277 return err
278 }
279 if err := insertRecord(ctx, tx, receipt, entry); err != nil {
280 _ = tx.Rollback()
281 return err
282 }
283 end := receipt.Offset + int64(receipt.Length)
284 mtime := int64(0)
285 if info, statErr := os.Stat(receipt.Path); statErr == nil {
286 mtime = info.ModTime().UnixNano()
287 }
288 _, err = tx.ExecContext(ctx, `INSERT INTO usage_files(path,day,size,mtime_ns,indexed_offset,state,completed_at) VALUES(?,?,?,?,?,'ready',?)
289 ON CONFLICT(path) DO UPDATE SET day=excluded.day,size=MAX(usage_files.size,excluded.size),
290 mtime_ns=CASE WHEN usage_files.indexed_offset=? THEN excluded.mtime_ns ELSE usage_files.mtime_ns END,
291 indexed_offset=CASE WHEN usage_files.indexed_offset=? THEN excluded.indexed_offset ELSE usage_files.indexed_offset END,
292 state=CASE WHEN usage_files.indexed_offset=? THEN 'ready' ELSE 'pending' END,completed_at=excluded.completed_at`,
293 receipt.Path, receipt.Day, end, mtime, end, time.Now().UnixMilli(), receipt.Offset, receipt.Offset, receipt.Offset)
294 if err != nil {
295 _ = tx.Rollback()
296 return err
297 }
298 revision, err := bump(ctx, tx)
299 if err != nil {
300 _ = tx.Rollback()
301 return err
302 }
303 if err := tx.Commit(); err != nil {
304 return err
305 }
306 c.revision.Store(revision)
307 c.refresh(context.Background())
308 return nil
309 }
310
311 func insertRecord(ctx context.Context, tx *sql.Tx, receipt AppendReceipt, entry Entry) error {
312 if entry.Total > 0 && entry.Requests <= 0 {
313 entry.Requests = 1
314 }
315 result, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO usage_records(file_path,byte_offset,byte_length,line_hash,day,source,
316 model_ref,provider,prompt,completion,reasoning,cache_hit,cache_miss,total,requests,turns) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
317 receipt.Path, receipt.Offset, receipt.Length, receipt.LineHash, entry.Day, entry.Source, entry.ModelRef, entry.Provider,
318 entry.Prompt, entry.Completion, entry.Reasoning, entry.CacheHit, entry.CacheMiss, entry.Total, entry.Requests, entry.Turns)
319 if err != nil {
320 return err
321 }
322 inserted, _ := result.RowsAffected()
323 if inserted == 0 {
324 return nil
325 }
326 _, err = tx.ExecContext(ctx, `INSERT INTO usage_rollups(day,source,model_ref,provider,prompt,completion,reasoning,cache_hit,
327 cache_miss,total,requests,turns) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(day,source,model_ref) DO UPDATE SET
328 prompt=prompt+excluded.prompt,completion=completion+excluded.completion,reasoning=reasoning+excluded.reasoning,
329 cache_hit=cache_hit+excluded.cache_hit,cache_miss=cache_miss+excluded.cache_miss,total=total+excluded.total,
330 requests=requests+excluded.requests,turns=turns+excluded.turns`, entry.Day, entry.Source, entry.ModelRef, entry.Provider,
331 entry.Prompt, entry.Completion, entry.Reasoning, entry.CacheHit, entry.CacheMiss, entry.Total, entry.Requests, entry.Turns)
332 return err
333 }
334
335 type rawRecord struct {
336 Timestamp time.Time `json:"ts"`
337 ModelRef string `json:"model"`
338 Source string `json:"source"`
339 Prompt int `json:"prompt"`
340 Completion int `json:"completion"`
341 Reasoning int `json:"reasoning"`
342 CacheHit int `json:"cache_hit"`
343 CacheMiss int `json:"cache_miss"`
344 Total int `json:"total"`
345 Requests int `json:"requests"`
346 Turn bool `json:"turn"`
347 }
348
349 func providerOf(model string) string {
350 if i := strings.IndexByte(model, '/'); i > 0 {
351 return model[:i]
352 }
353 return "default"
354 }
355
356 func entryFromRaw(day string, raw rawRecord) Entry {
357 turns := 0
358 if raw.Turn {
359 turns = 1
360 }
361 return Entry{Day: day, Source: raw.Source, ModelRef: raw.ModelRef, Provider: providerOf(raw.ModelRef), Prompt: raw.Prompt,
362 Completion: raw.Completion, Reasoning: raw.Reasoning, CacheHit: raw.CacheHit, CacheMiss: raw.CacheMiss,
363 Total: raw.Total, Requests: raw.Requests, Turns: turns}
364 }
365
366 func (c *Catalog) ReconcileFile(ctx context.Context, path, day string) error {
367 f, err := os.Open(path)
368 if errors.Is(err, os.ErrNotExist) {
369 return nil
370 }
371 if err != nil {
372 return err
373 }
374 defer f.Close()
375 tx, err := c.db.BeginTx(ctx, nil)
376 if err != nil {
377 return err
378 }
379 if _, err := tx.ExecContext(ctx, `DELETE FROM usage_rollups WHERE day IN (SELECT DISTINCT day FROM usage_records WHERE file_path=?)`, path); err != nil {
380 _ = tx.Rollback()
381 return err
382 }
383 if _, err := tx.ExecContext(ctx, `DELETE FROM usage_records WHERE file_path=?`, path); err != nil {
384 _ = tx.Rollback()
385 return err
386 }
387 reader := bufio.NewReader(f)
388 offset := int64(0)
389 corrupt := int64(0)
390 for {
391 line, readErr := reader.ReadBytes('\n')
392 if len(line) > 0 {
393 trimmed := strings.TrimSpace(string(line))
394 if trimmed != "" {
395 var raw rawRecord
396 if json.Unmarshal([]byte(trimmed), &raw) != nil {
397 corrupt++
398 } else {
399 hash := sha256.Sum256([]byte(trimmed))
400 receipt := AppendReceipt{Path: path, Day: day, Offset: offset, Length: len(line), LineHash: hex.EncodeToString(hash[:])}
401 if err := insertRecord(ctx, tx, receipt, entryFromRaw(day, raw)); err != nil {
402 _ = tx.Rollback()
403 return err
404 }
405 }
406 }
407 offset += int64(len(line))
408 }
409 if errors.Is(readErr, io.EOF) {
410 break
411 }
412 if readErr != nil {
413 _ = tx.Rollback()
414 return readErr
415 }
416 }
417 info, _ := os.Stat(path)
418 mtime := int64(0)
419 if info != nil {
420 mtime = info.ModTime().UnixNano()
421 }
422 _, err = tx.ExecContext(ctx, `INSERT INTO usage_files(path,day,size,mtime_ns,indexed_offset,state,error,corrupt_lines,completed_at)
423 VALUES(?,?,?,?,?,'ready','',?,?) ON CONFLICT(path) DO UPDATE SET day=excluded.day,size=excluded.size,mtime_ns=excluded.mtime_ns,
424 indexed_offset=excluded.indexed_offset,state='ready',error='',corrupt_lines=excluded.corrupt_lines,completed_at=excluded.completed_at`,
425 path, day, offset, mtime, offset, corrupt, time.Now().UnixMilli())
426 if err != nil {
427 _ = tx.Rollback()
428 return err
429 }
430 revision, err := bump(ctx, tx)
431 if err != nil {
432 _ = tx.Rollback()
433 return err
434 }
435 if err := tx.Commit(); err != nil {
436 return err
437 }
438 c.revision.Store(revision)
439 c.refresh(context.Background())
440 return nil
441 }
442
443 func (c *Catalog) ReconcileDir(ctx context.Context, dir string) error {
444 entries, err := os.ReadDir(dir)
445 if errors.Is(err, os.ErrNotExist) {
446 return nil
447 }
448 if err != nil {
449 return err
450 }
451 for _, entry := range entries {
452 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
453 continue
454 }
455 day := strings.TrimSuffix(entry.Name(), ".jsonl")
456 if err := c.ReconcileFile(ctx, filepath.Join(dir, entry.Name()), day); err != nil {
457 return err
458 }
459 }
460 return nil
461 }
462
463 func (c *Catalog) Ready(ctx context.Context, dir string, days []string) bool {
464 for _, day := range days {
465 path := filepath.Join(dir, day+".jsonl")
466 info, err := os.Stat(path)
467 if errors.Is(err, os.ErrNotExist) {
468 continue
469 }
470 if err != nil {
471 return false
472 }
473 var size, offset, mtime int64
474 var state string
475 if err := c.db.QueryRowContext(ctx, `SELECT size,indexed_offset,state,mtime_ns FROM usage_files WHERE path=?`, path).Scan(&size, &offset, &state, &mtime); err != nil {
476 return false
477 }
478 // Same-size in-place rewrites must invalidate Ready so callers fall back
479 // to authoritative JSONL until the catalog rescan catches up.
480 if state != "ready" || size != info.Size() || offset != info.Size() || mtime != info.ModTime().UnixNano() {
481 return false
482 }
483 }
484 return true
485 }
486
487 func (c *Catalog) Query(ctx context.Context, fromDay, toDay, source string) ([]Rollup, error) {
488 args := []any{fromDay, toDay}
489 where := `day>=? AND day<=?`
490 if source != "" && source != "all" {
491 where += ` AND source=?`
492 args = append(args, source)
493 }
494 rows, err := c.db.QueryContext(ctx, `SELECT day,source,model_ref,provider,prompt,completion,reasoning,cache_hit,cache_miss,total,requests,turns
495 FROM usage_rollups WHERE `+where+` ORDER BY day,source,model_ref`, args...)
496 if err != nil {
497 return nil, err
498 }
499 defer rows.Close()
500 out := []Rollup{}
501 for rows.Next() {
502 var row Rollup
503 if err := rows.Scan(&row.Day, &row.Source, &row.ModelRef, &row.Provider, &row.Prompt, &row.Completion, &row.Reasoning,
504 &row.CacheHit, &row.CacheMiss, &row.Total, &row.Requests, &row.Turns); err != nil {
505 return nil, err
506 }
507 out = append(out, row)
508 }
509 return out, rows.Err()
510 }
511
512 func bump(ctx context.Context, tx *sql.Tx) (uint64, error) {
513 if _, err := tx.ExecContext(ctx, `UPDATE usage_state SET revision=revision+1 WHERE id=1`); err != nil {
514 return 0, err
515 }
516 var revision uint64
517 err := tx.QueryRowContext(ctx, `SELECT revision FROM usage_state WHERE id=1`).Scan(&revision)
518 return revision, err
519 }
520
521 func (c *Catalog) NoteFallback() { c.fallback.Add(1) }
522
523 func (c *Catalog) refresh(ctx context.Context) {
524 var files, lag, corrupt int64
525 _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*),COALESCE(SUM(MAX(size-indexed_offset,0)),0),COALESCE(SUM(corrupt_lines),0) FROM usage_files`).Scan(&files, &lag, &corrupt)
526 c.statusMu.Lock()
527 c.status.Revision, c.status.IndexedFiles, c.status.LagBytes, c.status.CorruptLines = c.revision.Load(), files, lag, corrupt
528 c.status.Fallbacks = c.fallback.Load()
529 c.statusMu.Unlock()
530 }
531
532 func (c *Catalog) Status() Status {
533 c.statusMu.RLock()
534 defer c.statusMu.RUnlock()
535 status := c.status
536 status.Fallbacks = c.fallback.Load()
537 return status
538 }
539
540 func (c *Catalog) Close(ctx context.Context) error {
541 if c == nil {
542 return nil
543 }
544 c.closeOnce.Do(func() {
545 c.cancel()
546 go func() {
547 c.wg.Wait()
548 c.closeErr = c.db.Close()
549 close(c.closeDone)
550 }()
551 })
552 select {
553 case <-c.closeDone:
554 return c.closeErr
555 case <-ctx.Done():
556 return ctx.Err()
557 }
558 }
559
559 lines GO