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>
210 lines
5.6 KiB
Go
210 lines
5.6 KiB
Go
// 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)
|