Files
fuj-management/go/internal/io/cache/filecache.go
T
kacerrandClaude Opus 5.5 022facc957
Deploy to K8s / deploy (push) Successful in 20s
refactor: move Python implementation to legacy/, make Go primary
Go (go/) becomes the primary implementation. The original Python
version moves to legacy/ and is kept runnable; new features are still
built in both (see the Dual implementation rule in CLAUDE.md).

- git mv app.py, scripts/, templates/, tests/, pyproject.toml, uv.lock
  and build/ into legacy/
- Makefile: Python targets point at legacy/, venv in legacy/.venv,
  CREDENTIALS_PATH exported for the root .secret/ file
- CI and make image build the Python image with legacy/ as context;
  the Makefile is no longer copied into the image
- legacy/.dockerignore moved to the context root so it takes effect
- CLAUDE.md, docs/README.md, Go comments, fixture README, capture
  script and VS Code launch config updated to the new layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:57 +02:00

210 lines
5.6 KiB
Go

// Package cache implements a Drive-modifiedTime-gated JSON file cache,
// mirroring legacy/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 legacy/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)