feat(go): IO layer behind interfaces (M4)
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
- io/attendance: CSV-over-public-URL client + Fake for adult/junior tabs - io/drive: Drive v3 modifiedTime client + Fake - io/sheets: Sheets v4 client (GetValues/AppendValues/BatchUpdateValues/ WriteHeader/SortByDateColumn) + Fake with call-capture - io/cache: Drive-modifiedTime-gated FileCache; two TTL knobs; atomic writes; generic Get[T]; Python-compatible JSON format; Flush() - io/fio: Client interface backed by Fio REST API (apiClient) and HTML scraper (transparentClient); Fake; testdata fixtures - membership/sources: NewSources wires attendance CSV + Sheets + cache into LoadAdults/LoadJuniors/LoadTransactions/LoadExceptions; Czech month parsing + merged-month maps - banksync: SyncToSheets (SHA-256 dedup, optional sort) and InferPayments ([?] review prefix, dry-run) — tested with fakes - cmd/fuj: sync and infer subcommands wired; fees and reconcile use real NewSources; go.mod gains google.golang.org/api + x/net - gofumpt extra-rules applied across all packages; lint clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
209
go/internal/io/cache/filecache.go
vendored
Normal file
209
go/internal/io/cache/filecache.go
vendored
Normal file
@@ -0,0 +1,209 @@
|
||||
// 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)
|
||||
Reference in New Issue
Block a user