Deploy to K8s / deploy (push) Successful in 20s
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>
210 lines
5.6 KiB
Go
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)
|