From 069966a60913539b45cc6c3fa93152375d263470 Mon Sep 17 00:00:00 2001 From: fujitani sora Date: Fri, 3 Jul 2026 14:49:07 +0900 Subject: [PATCH] perf: cache session scan metadata --- internal/list/list_test.go | 3 + internal/session/cache.go | 118 +++++++++++++++++ internal/session/cache_test.go | 233 +++++++++++++++++++++++++++++++++ internal/session/scan.go | 2 +- internal/session/scan_test.go | 7 +- internal/source/source_test.go | 1 + 6 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 internal/session/cache.go create mode 100644 internal/session/cache_test.go diff --git a/internal/list/list_test.go b/internal/list/list_test.go index 6db656f..326bd73 100644 --- a/internal/list/list_test.go +++ b/internal/list/list_test.go @@ -199,6 +199,7 @@ func TestRun_JSONLimit(t *testing.T) { home := t.TempDir() cwd := t.TempDir() t.Setenv("HOME", home) + t.Setenv(session.EnvCacheDir, filepath.Join(t.TempDir(), "scan-cache")) t.Setenv(source.EnvVar, "") writeListSession(t, home, cwd, "11111111-1111-1111-1111-111111111111", "2026-05-26T10:00:00Z", "older") writeListSession(t, home, cwd, "22222222-2222-2222-2222-222222222222", "2026-05-26T11:00:00Z", "newer") @@ -249,6 +250,7 @@ func BenchmarkRunJSONThousandSessions(b *testing.B) { home := b.TempDir() cwd := b.TempDir() b.Setenv("HOME", home) + b.Setenv(session.EnvCacheDir, filepath.Join(b.TempDir(), "scan-cache")) b.Setenv(source.EnvVar, "") for i := range 1000 { id := fmt.Sprintf("11111111-1111-1111-1111-%012d", i) @@ -269,6 +271,7 @@ func BenchmarkRunGrepJSONHitAndMiss(b *testing.B) { cwd := b.TempDir() cache := b.TempDir() b.Setenv("HOME", home) + b.Setenv(session.EnvCacheDir, filepath.Join(b.TempDir(), "scan-cache")) b.Setenv(source.EnvVar, "") b.Setenv("CCSESSION_GREP_CACHE_DIR", cache) for i := range 512 { diff --git a/internal/session/cache.go b/internal/session/cache.go new file mode 100644 index 0000000..b7a60ba --- /dev/null +++ b/internal/session/cache.go @@ -0,0 +1,118 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +const EnvCacheDir = "CCSESSION_SCAN_CACHE_DIR" + +type scanCacheRecord struct { + Path string `json:"path"` + Size int64 `json:"size"` + ModTimeUnixNano int64 `json:"mod_time_unix_nano"` + Empty bool `json:"empty,omitempty"` + Session Session `json:"session,omitempty"` +} + +func cachedParseSessionTail(path string) (*Session, error) { + fi, err := os.Stat(path) + if err != nil { + return nil, err + } + dir, err := scanCacheDir() + if err != nil { + return ParseSessionTail(path, TailReadBytes) + } + cachePath := filepath.Join(dir, scanCacheFileName(path)) + if rec, ok := readScanCache(cachePath, path, fi); ok { + if rec.Empty { + return nil, ErrSessionEmpty + } + s := rec.Session + return &s, nil + } + + s, err := ParseSessionTail(path, TailReadBytes) + if err != nil { + if errors.Is(err, ErrSessionEmpty) { + _ = writeScanCache(cachePath, scanCacheRecord{ + Path: path, + Size: fi.Size(), + ModTimeUnixNano: fi.ModTime().UnixNano(), + Empty: true, + }) + } + return nil, err + } + _ = writeScanCache(cachePath, scanCacheRecord{ + Path: path, + Size: fi.Size(), + ModTimeUnixNano: fi.ModTime().UnixNano(), + Session: *s, + }) + return s, nil +} + +func scanCacheDir() (string, error) { + if dir := strings.TrimSpace(os.Getenv(EnvCacheDir)); dir != "" { + return dir, nil + } + base, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(base, "ccsession", "scan"), nil +} + +func scanCacheFileName(path string) string { + sum := sha256.Sum256([]byte(path)) + return hex.EncodeToString(sum[:]) + ".json" +} + +func readScanCache(path, transcriptPath string, fi os.FileInfo) (scanCacheRecord, bool) { + b, err := os.ReadFile(path) + if err != nil { + return scanCacheRecord{}, false + } + var rec scanCacheRecord + if err := json.Unmarshal(b, &rec); err != nil { + return scanCacheRecord{}, false + } + if rec.Path != transcriptPath || + rec.Size != fi.Size() || + rec.ModTimeUnixNano != fi.ModTime().UnixNano() { + return scanCacheRecord{}, false + } + return rec, true +} + +func writeScanCache(path string, rec scanCacheRecord) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if err := json.NewEncoder(tmp).Encode(rec); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/internal/session/cache_test.go b/internal/session/cache_test.go new file mode 100644 index 0000000..52cee23 --- /dev/null +++ b/internal/session/cache_test.go @@ -0,0 +1,233 @@ +package session + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestScanCacheHitSkipsReparse(t *testing.T) { + home, projects := makeFakeHome(t) + t.Setenv("HOME", home) + + proj := filepath.Join(projects, "-tmp-a") + path := writeSessionFile(t, proj, "11111111-1111-1111-1111-111111111111.jsonl", + "2024-05-26T10:00:00Z", "cached") + got, err := Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 1 || got[0].Label != "cached" { + t.Fatalf("first scan = %+v", got) + } + + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if err := os.WriteFile(path, []byte(strings.Repeat("x", int(fi.Size()))), 0o644); err != nil { + t.Fatalf("corrupt source: %v", err) + } + if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil { + t.Fatalf("chtimes: %v", err) + } + + got, err = Scan() + if err != nil { + t.Fatalf("Scan cached: %v", err) + } + if len(got) != 1 || got[0].Label != "cached" { + t.Fatalf("cached scan = %+v, want cached session", got) + } +} + +func TestScanCacheInvalidatesOnSizeOrMTimeChange(t *testing.T) { + home, projects := makeFakeHome(t) + t.Setenv("HOME", home) + + proj := filepath.Join(projects, "-tmp-a") + path := writeSessionFile(t, proj, "11111111-1111-1111-1111-111111111111.jsonl", + "2024-05-26T10:00:00Z", "old") + if _, err := Scan(); err != nil { + t.Fatalf("Scan: %v", err) + } + + writeSessionFile(t, proj, filepath.Base(path), "2024-05-26T11:00:00Z", "new label") + got, err := Scan() + if err != nil { + t.Fatalf("Scan after change: %v", err) + } + if len(got) != 1 || got[0].Label != "new label" { + t.Fatalf("scan after change = %+v, want new label", got) + } +} + +func TestScanCacheCorruptJSONFallsBackToParse(t *testing.T) { + home, projects := makeFakeHome(t) + t.Setenv("HOME", home) + + proj := filepath.Join(projects, "-tmp-a") + path := writeSessionFile(t, proj, "11111111-1111-1111-1111-111111111111.jsonl", + "2024-05-26T10:00:00Z", "parsed") + cacheDir := os.Getenv(EnvCacheDir) + cachePath := filepath.Join(cacheDir, scanCacheFileName(path)) + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + if err := os.WriteFile(cachePath, []byte("{"), 0o600); err != nil { + t.Fatalf("write corrupt cache: %v", err) + } + + got, err := Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 1 || got[0].Label != "parsed" { + t.Fatalf("scan = %+v, want parsed session", got) + } +} + +func TestScanCacheCachesEmptySessions(t *testing.T) { + home, projects := makeFakeHome(t) + t.Setenv("HOME", home) + + proj := filepath.Join(projects, "-tmp-a") + if err := os.MkdirAll(proj, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(proj, "11111111-1111-1111-1111-111111111111.jsonl") + body := `{"type":"assistant","timestamp":"2024-05-26T10:00:00Z"}` + "\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + got, err := Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 0 { + t.Fatalf("got %d sessions, want 0", len(got)) + } + + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + valid := `{"type":"user","timestamp":"2024-05-26T10:00:00Z","message":{"role":"user","content":"x"}}` + "\n" + + `{"type":"ai-title","aiTitle":"cached empty"}` + if len(valid) < int(fi.Size()) { + valid += strings.Repeat(" ", int(fi.Size())-len(valid)) + } else { + valid = strings.Repeat("x", int(fi.Size())) + } + if err := os.WriteFile(path, []byte(valid), 0o644); err != nil { + t.Fatalf("rewrite: %v", err) + } + if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil { + t.Fatalf("chtimes: %v", err) + } + + got, err = Scan() + if err != nil { + t.Fatalf("Scan cached empty: %v", err) + } + if len(got) != 0 { + t.Fatalf("cached empty returned %d sessions, want 0", len(got)) + } +} + +func BenchmarkScanManySessions(b *testing.B) { + home := b.TempDir() + projects := filepath.Join(home, ".claude", "projects") + proj := filepath.Join(projects, "-tmp-bench") + if err := os.MkdirAll(proj, 0o755); err != nil { + b.Fatalf("mkdir: %v", err) + } + for i := range 1000 { + body := `{"type":"user","timestamp":"2024-05-26T10:00:00Z","cwd":"` + proj + `","message":{"role":"user","content":"hi"}}` + "\n" + + `{"type":"ai-title","aiTitle":"session ` + strconv.Itoa(i) + `"}` + "\n" + path := filepath.Join(proj, "session-"+strconv.Itoa(i)+".jsonl") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + b.Fatalf("write: %v", err) + } + } + b.Setenv("HOME", home) + b.Setenv(EnvCacheDir, filepath.Join(b.TempDir(), "scan-cache")) + + b.Run("cold-cache", func(b *testing.B) { + for range b.N { + if err := os.RemoveAll(os.Getenv(EnvCacheDir)); err != nil { + b.Fatalf("remove cache: %v", err) + } + if got, err := Scan(); err != nil || len(got) != 1000 { + b.Fatalf("Scan = %d, %v", len(got), err) + } + } + }) + if _, err := Scan(); err != nil { + b.Fatalf("warmup Scan: %v", err) + } + b.Run("warm-cache", func(b *testing.B) { + for range b.N { + if got, err := Scan(); err != nil || len(got) != 1000 { + b.Fatalf("Scan = %d, %v", len(got), err) + } + } + }) +} + +func TestScanCacheDirUsesUserCacheDir(t *testing.T) { + t.Setenv(EnvCacheDir, "") + home := t.TempDir() + t.Setenv("HOME", home) + dir, err := scanCacheDir() + if err != nil { + t.Fatalf("scanCacheDir: %v", err) + } + if !strings.HasSuffix(dir, filepath.Join("ccsession", "scan")) { + t.Fatalf("cache dir = %q", dir) + } +} + +func TestScanCacheDirUsesEnv(t *testing.T) { + want := filepath.Join(t.TempDir(), "cache") + t.Setenv(EnvCacheDir, want) + got, err := scanCacheDir() + if err != nil { + t.Fatalf("scanCacheDir: %v", err) + } + if got != want { + t.Fatalf("scanCacheDir = %q, want %q", got, want) + } +} + +func TestScanCacheRecordValidatesMetadata(t *testing.T) { + now := time.Now() + fi := fakeFileInfo{size: 1, mod: now} + rec := scanCacheRecord{Path: "a", Size: 1, ModTimeUnixNano: now.UnixNano()} + path := filepath.Join(t.TempDir(), "cache.json") + if err := writeScanCache(path, rec); err != nil { + t.Fatalf("writeScanCache: %v", err) + } + if _, ok := readScanCache(path, "a", fi); !ok { + t.Fatal("cache should be valid") + } + if _, ok := readScanCache(path, "b", fi); ok { + t.Fatal("cache should reject path mismatch") + } +} + +type fakeFileInfo struct { + size int64 + mod time.Time +} + +func (f fakeFileInfo) Name() string { return "" } +func (f fakeFileInfo) Size() int64 { return f.size } +func (f fakeFileInfo) Mode() os.FileMode { return 0 } +func (f fakeFileInfo) ModTime() time.Time { return f.mod } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } diff --git a/internal/session/scan.go b/internal/session/scan.go index ada1d4e..85b7b1d 100644 --- a/internal/session/scan.go +++ b/internal/session/scan.go @@ -112,7 +112,7 @@ func scanPaths(paths []string) []*Session { go func() { defer wg.Done() defer func() { <-sem }() - s, err := ParseSessionTail(p, TailReadBytes) + s, err := cachedParseSessionTail(p) if err != nil || s == nil { return } diff --git a/internal/session/scan_test.go b/internal/session/scan_test.go index 58e8fef..6f115d6 100644 --- a/internal/session/scan_test.go +++ b/internal/session/scan_test.go @@ -16,19 +16,22 @@ func makeFakeHome(t *testing.T) (string, string) { if err := os.MkdirAll(projects, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } + t.Setenv(EnvCacheDir, filepath.Join(t.TempDir(), "scan-cache")) return home, projects } -func writeSessionFile(t *testing.T, dir, name, ts, label string) { +func writeSessionFile(t *testing.T, dir, name, ts, label string) string { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } body := `{"type":"user","timestamp":"` + ts + `","cwd":"` + dir + `","message":{"role":"user","content":"hi"}}` + "\n" + `{"type":"ai-title","aiTitle":"` + label + `"}` + "\n" - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatalf("write: %v", err) } + return path } func TestProjectsDir_UsesHomeEnv(t *testing.T) { diff --git a/internal/source/source_test.go b/internal/source/source_test.go index 7dc4441..bcf3b21 100644 --- a/internal/source/source_test.go +++ b/internal/source/source_test.go @@ -130,6 +130,7 @@ func fixtureHome(t *testing.T, content string) string { t.Fatalf("write: %v", err) } t.Setenv("HOME", home) + t.Setenv(session.EnvCacheDir, filepath.Join(t.TempDir(), "scan-cache")) return id }