Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 90 additions & 29 deletions internal/grep/cache.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
package grep

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
)

const EnvCacheDir = "CCSESSION_GREP_CACHE_DIR"

const (
EnvCacheDir = "CCSESSION_GREP_CACHE_DIR"
cacheVersion = 2
cacheIndexName = "index.json"
cacheTextSep = "\x00"
defaultCachePerm = 0o600
defaultCacheDir = 0o700
)

type cacheRecord struct {
Path string `json:"path"`
Size int64 `json:"size"`
ModTimeUnixNano int64 `json:"mod_time_unix_nano"`
Texts []string `json:"texts"`
Version int `json:"v"`
Path string `json:"path"`
Size int64 `json:"size"`
ModTimeUnixNano int64 `json:"mod_time_unix_nano"`
Text string `json:"text"`
Fragments int `json:"fragments"`
}

type cacheIndex struct {
path string
records map[string]cacheRecord
}

var (
indexMu sync.Mutex
indexByDir = map[string]*cacheIndex{}
)

// CachedFileTexts returns extracted searchable text for path, reusing a
// metadata-validated on-disk cache when possible.
func CachedFileTexts(path string, read func(string) ([]string, error)) ([]string, error) {
Expand All @@ -32,20 +49,25 @@ func CachedFileTexts(path string, read func(string) ([]string, error)) ([]string
return read(path)
}

cachePath := filepath.Join(dir, cacheFileName(path))
if rec, ok := readCache(cachePath, path, fi); ok {
return rec.Texts, nil
idx := loadIndex(dir)
if idx == nil {
return read(path)
}
if rec, ok := idx.record(path, fi); ok {
return splitCacheText(rec), nil
}

texts, err := read(path)
if err != nil {
return nil, err
}
_ = writeCache(cachePath, cacheRecord{
idx.set(path, cacheRecord{
Version: cacheVersion,
Path: path,
Size: fi.Size(),
ModTimeUnixNano: fi.ModTime().UnixNano(),
Texts: texts,
Text: strings.Join(texts, cacheTextSep),
Fragments: len(texts),
})
return texts, nil
}
Expand Down Expand Up @@ -81,30 +103,69 @@ func cacheDir() (string, error) {
return filepath.Join(base, "ccsession", "grep"), nil
}

func cacheFileName(path string) string {
sum := sha256.Sum256([]byte(path))
return hex.EncodeToString(sum[:]) + ".json"
func loadIndex(dir string) *cacheIndex {
indexMu.Lock()
defer indexMu.Unlock()
if idx := indexByDir[dir]; idx != nil {
return idx
}
if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
return nil
}
idx := &cacheIndex{
path: filepath.Join(dir, cacheIndexName),
records: map[string]cacheRecord{},
}
b, err := os.ReadFile(idx.path)
if err == nil {
_ = json.Unmarshal(b, &idx.records)
if idx.records == nil {
idx.records = map[string]cacheRecord{}
}
}
indexByDir[dir] = idx
return idx
}

func readCache(path, transcriptPath string, fi os.FileInfo) (cacheRecord, bool) {
b, err := os.ReadFile(path)
if err != nil {
return cacheRecord{}, false
}
var rec cacheRecord
if err := json.Unmarshal(b, &rec); err != nil {
return cacheRecord{}, false
}
if rec.Path != transcriptPath ||
func (idx *cacheIndex) record(path string, fi os.FileInfo) (cacheRecord, bool) {
indexMu.Lock()
defer indexMu.Unlock()
rec, ok := idx.records[path]
if !ok ||
rec.Version != cacheVersion ||
rec.Path != path ||
rec.Size != fi.Size() ||
rec.ModTimeUnixNano != fi.ModTime().UnixNano() {
return cacheRecord{}, false
}
return rec, true
}

func writeCache(path string, rec cacheRecord) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
func (idx *cacheIndex) set(path string, rec cacheRecord) {
indexMu.Lock()
idx.records[path] = rec
snapshot := make(map[string]cacheRecord, len(idx.records))
for k, v := range idx.records {
snapshot[k] = v
}
indexMu.Unlock()

_ = writeIndex(idx.path, snapshot)
}

func splitCacheText(rec cacheRecord) []string {
switch rec.Fragments {
case 0:
return nil
case 1:
return []string{rec.Text}
default:
return strings.Split(rec.Text, cacheTextSep)
}
}

func writeIndex(path string, records map[string]cacheRecord) error {
if err := os.MkdirAll(filepath.Dir(path), defaultCacheDir); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-")
Expand All @@ -114,11 +175,11 @@ func writeCache(path string, rec cacheRecord) error {
tmpName := tmp.Name()
defer os.Remove(tmpName)

if err := json.NewEncoder(tmp).Encode(rec); err != nil {
if err := json.NewEncoder(tmp).Encode(records); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(0o600); err != nil {
if err := tmp.Chmod(defaultCachePerm); err != nil {
_ = tmp.Close()
return err
}
Expand Down
90 changes: 90 additions & 0 deletions internal/grep/cache_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package grep

import (
"encoding/json"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -93,3 +94,92 @@ func TestCachedFileTextsCacheUnavailableFallsBackToReader(t *testing.T) {
t.Fatalf("read calls = %d, want 2", calls)
}
}

func TestCachedFileTextsV1RecordIsMiss(t *testing.T) {
cacheDir := t.TempDir()
t.Setenv(EnvCacheDir, cacheDir)
path := filepath.Join(t.TempDir(), "session.jsonl")
writeFile(t, path, "body")
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
v1 := map[string]cacheRecord{
path: {
Path: path,
Size: fi.Size(),
ModTimeUnixNano: fi.ModTime().UnixNano(),
Text: "stale",
Fragments: 1,
},
}
writeIndexJSON(t, filepath.Join(cacheDir, cacheIndexName), v1)

calls := 0
texts, err := CachedFileTexts(path, func(string) ([]string, error) {
calls++
return []string{"fresh"}, nil
})
if err != nil {
t.Fatalf("CachedFileTexts: %v", err)
}
if calls != 1 || len(texts) != 1 || texts[0] != "fresh" {
t.Fatalf("calls/texts = %d/%#v, want fresh miss", calls, texts)
}
}

func TestCachedFileTextsCorruptIndexFallsBack(t *testing.T) {
cacheDir := t.TempDir()
t.Setenv(EnvCacheDir, cacheDir)
if err := os.WriteFile(filepath.Join(cacheDir, cacheIndexName), []byte("{"), 0o600); err != nil {
t.Fatalf("write corrupt index: %v", err)
}
path := filepath.Join(t.TempDir(), "session.jsonl")
writeFile(t, path, "body")

texts, err := CachedFileTexts(path, func(string) ([]string, error) {
return []string{"fresh"}, nil
})
if err != nil {
t.Fatalf("CachedFileTexts: %v", err)
}
if len(texts) != 1 || texts[0] != "fresh" {
t.Fatalf("texts = %#v, want fresh fallback", texts)
}
}

func TestCachedFileTextsPreservesRegexFragmentBoundaries(t *testing.T) {
t.Setenv(EnvCacheDir, t.TempDir())
path := filepath.Join(t.TempDir(), "session.jsonl")
writeFile(t, path, "body")
read := func(string) ([]string, error) {
return []string{"foo", "bar"}, nil
}
if _, err := CachedFileTexts(path, read); err != nil {
t.Fatalf("warm cache: %v", err)
}

ok, err := FileContains(path, func(s string) bool {
return s == "foo"+cacheTextSep+"bar" || s == "foobar"
}, read)
if err != nil {
t.Fatalf("FileContains: %v", err)
}
if ok {
t.Fatal("cache should not match across fragment boundaries")
}
}

func writeIndexJSON(t *testing.T, path string, records map[string]cacheRecord) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
b, err := json.Marshal(records)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/grep/grep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func BenchmarkFilterRepeatedQuery(b *testing.B) {
func writeBenchmarkProjects(b *testing.B) {
b.Helper()
projects := makeProjects(b)
for i := range 64 {
for i := range 512 {
body := strings.Repeat("ordinary transcript text\n", 128)
if i%8 == 0 {
body += "needle\n"
Expand Down
Loading