// Package cache implements a Drive-modifiedTime-gated JSON file cache, // mirroring scripts/cache_utils.py. package cache import ( "context" "encoding/json" "fmt" "fuj-management/go/internal/io/drive" "os" "path/filepath" "sync" "time" ) // DriveClient is the subset of drive.Client used by FileCache. type DriveClient interface { ModifiedTime(ctx context.Context, fileID string) (string, error) } type cacheFile struct { ModifiedTime string `json:"modifiedTime"` Data json.RawMessage `json:"data"` CachedAt string `json:"cachedAt"` } // FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime. // // Two TTL knobs mirror scripts/cache_utils.py: // - ttl: if the cache file on disk is younger than this, skip the Drive check entirely. // - apiCheckTTL: debounces in-memory Drive API calls per sheet ID. // // Atomic writes: data is marshaled to a .tmp file then os.Rename'd. // Cache files are compatible with Python's format: // // {"modifiedTime":"…","data":…,"cachedAt":"…"} type FileCache struct { drive DriveClient dir string sheetMap map[string]string // cache key → Drive file ID ttl time.Duration apiCheckTTL time.Duration mu sync.Mutex lastChecked map[string]time.Time } // New creates a FileCache. // sheetMap maps cache keys to Google Sheets/Drive file IDs (mirrors CACHE_SHEET_MAP in config). func New(d DriveClient, dir string, sheetMap map[string]string, ttl, apiCheckTTL time.Duration) *FileCache { return &FileCache{ drive: d, dir: dir, sheetMap: sheetMap, ttl: ttl, apiCheckTTL: apiCheckTTL, lastChecked: make(map[string]time.Time), } } // Get returns the cached value for cacheKey, calling fetch if the cache is stale. // T must be JSON-marshalable. func Get[T any](ctx context.Context, fc *FileCache, cacheKey string, fetch func(context.Context) (T, error)) (T, error) { sheetID := fc.sheetMap[cacheKey] if sheetID == "" { sheetID = cacheKey } cacheFilePath := filepath.Join(fc.dir, cacheKey+"_cache.json") currentModTime, err := fc.currentModifiedTime(ctx, sheetID, cacheFilePath) if err != nil { return *new(T), fmt.Errorf("cache: modifiedTime for %s: %w", cacheKey, err) } // Try cache hit if data, ok := readCache[T](cacheFilePath, currentModTime); ok { return data, nil } // Cache miss — fetch fresh data fresh, err := fetch(ctx) if err != nil { return *new(T), err } if err := writeCache(cacheFilePath, currentModTime, fresh); err != nil { // Non-fatal: log but don't fail the request _, _ = fmt.Fprintf(os.Stderr, "cache: write %s: %v\n", cacheKey, err) } return fresh, nil } // Flush deletes all *_cache.json files in the cache dir and resets in-memory state. func (fc *FileCache) Flush() (int, error) { fc.mu.Lock() fc.lastChecked = make(map[string]time.Time) fc.mu.Unlock() pattern := filepath.Join(fc.dir, "*_cache.json") matches, err := filepath.Glob(pattern) if err != nil { return 0, err } for _, f := range matches { _ = os.Remove(f) } return len(matches), nil } // currentModifiedTime returns a stable string representing the current version // of the sheet, using the in-memory + file-mtime TTL guards before hitting Drive. // On Drive failure, falls back to a 5-minute bucket string (matching Python). func (fc *FileCache) currentModifiedTime(ctx context.Context, sheetID, cacheFilePath string) (string, error) { now := time.Now() fc.mu.Lock() lastCheck := fc.lastChecked[sheetID] fc.mu.Unlock() // Guard 1: in-memory debounce — skip Drive if checked recently if fc.apiCheckTTL > 0 && now.Sub(lastCheck) < fc.apiCheckTTL { if mt, ok := readModifiedTime(cacheFilePath); ok { return mt, nil } } // Guard 2: cache file is young enough — trust the stored modifiedTime if fc.ttl > 0 { if info, err := os.Stat(cacheFilePath); err == nil { if now.Sub(info.ModTime()) < fc.ttl { if mt, ok := readModifiedTime(cacheFilePath); ok { fc.mu.Lock() fc.lastChecked[sheetID] = now fc.mu.Unlock() return mt, nil } } } } // Hit Drive API mt, err := fc.drive.ModifiedTime(ctx, sheetID) if err != nil { // Fallback: 5-minute bucket string, matches Python _fallback_ttl() bucket := time.Now().Unix() / 300 return fmt.Sprintf("ttl-5m-%d", bucket), nil } fc.mu.Lock() fc.lastChecked[sheetID] = now fc.mu.Unlock() return mt, nil } func readModifiedTime(path string) (string, bool) { cf, ok := readCacheFile(path) if !ok { return "", false } return cf.ModifiedTime, cf.ModifiedTime != "" } func readCacheFile(path string) (cacheFile, bool) { b, err := os.ReadFile(path) if err != nil { return cacheFile{}, false } var cf cacheFile if err := json.Unmarshal(b, &cf); err != nil { return cacheFile{}, false } return cf, true } func readCache[T any](path, currentModTime string) (T, bool) { cf, ok := readCacheFile(path) if !ok || cf.ModifiedTime != currentModTime { return *new(T), false } var v T if err := json.Unmarshal(cf.Data, &v); err != nil { return *new(T), false } return v, true } func writeCache(path, modTime string, data any) error { raw, err := json.Marshal(data) if err != nil { return err } cf := cacheFile{ ModifiedTime: modTime, Data: json.RawMessage(raw), CachedAt: time.Now().Format(time.RFC3339), } b, err := json.Marshal(cf) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, path) } // Ensure *drive.Client satisfies DriveClient at compile time. var _ DriveClient = (*drive.Client)(nil)