go-cache/cache.go
Virgil 19232c5575
Some checks failed
CI / test (push) Failing after 3s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s
feat(cache): add batch eviction support
2026-04-01 09:46:02 +00:00

348 lines
7.6 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
// Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium.
package cache
import (
"encoding/json"
"io/fs"
"time"
"dappco.re/go/core"
coreio "dappco.re/go/core/io"
)
// DefaultTTL is the default cache expiry time.
//
// Usage example:
//
// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL)
const DefaultTTL = 1 * time.Hour
// Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir.
type Cache struct {
medium coreio.Medium
baseDir string
ttl time.Duration
}
// Entry is the serialized cache record written to the backing Medium.
type Entry struct {
Data json.RawMessage `json:"data"`
CachedAt time.Time `json:"cached_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// New creates a cache and applies default Medium, base directory, and TTL values
// when callers pass zero values.
//
// c, err := cache.New(coreio.Local, "/tmp/cache", time.Hour)
func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) {
if medium == nil {
medium = coreio.Local
}
if baseDir == "" {
cwd := currentDir()
if cwd == "" || cwd == "." {
return nil, core.E("cache.New", "failed to resolve current working directory", nil)
}
baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache"))
} else {
baseDir = absolutePath(baseDir)
}
if ttl < 0 {
return nil, core.E("cache.New", "ttl must be >= 0", nil)
}
if ttl == 0 {
ttl = DefaultTTL
}
if err := medium.EnsureDir(baseDir); err != nil {
return nil, core.E("cache.New", "failed to create cache directory", err)
}
return &Cache{
medium: medium,
baseDir: baseDir,
ttl: ttl,
}, nil
}
// Path returns the storage path used for key and rejects path traversal
// attempts.
//
// path, err := c.Path("github/acme/repos")
func (c *Cache) Path(key string) (string, error) {
if err := c.ensureConfigured("cache.Path"); err != nil {
return "", err
}
baseDir := absolutePath(c.baseDir)
path := absolutePath(core.JoinPath(baseDir, key+".json"))
pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator()))
if path != baseDir && !core.HasPrefix(path, pathPrefix) {
return "", core.E("cache.Path", "invalid cache key: path traversal attempt", nil)
}
return path, nil
}
// Get unmarshals the cached item into dest if it exists and has not expired.
//
// found, err := c.Get("github/acme/repos", &repos)
func (c *Cache) Get(key string, dest any) (bool, error) {
if err := c.ensureReady("cache.Get"); err != nil {
return false, err
}
path, err := c.Path(key)
if err != nil {
return false, err
}
dataStr, err := c.medium.Read(path)
if err != nil {
if core.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, core.E("cache.Get", "failed to read cache file", err)
}
var entry Entry
entryResult := core.JSONUnmarshalString(dataStr, &entry)
if !entryResult.OK {
return false, nil
}
if time.Now().After(entry.ExpiresAt) {
return false, nil
}
if err := core.JSONUnmarshal(entry.Data, dest); !err.OK {
return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error))
}
return true, nil
}
// Set marshals data and stores it in the cache.
//
// err := c.Set("github/acme/repos", repos)
func (c *Cache) Set(key string, data any) error {
if err := c.ensureReady("cache.Set"); err != nil {
return err
}
path, err := c.Path(key)
if err != nil {
return err
}
if err := c.medium.EnsureDir(core.PathDir(path)); err != nil {
return core.E("cache.Set", "failed to create directory", err)
}
dataResult := core.JSONMarshal(data)
if !dataResult.OK {
return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error))
}
ttl := c.ttl
if ttl < 0 {
return core.E("cache.Set", "cache ttl must be >= 0", nil)
}
if ttl == 0 {
ttl = DefaultTTL
}
entry := Entry{
Data: dataResult.Value.([]byte),
CachedAt: time.Now(),
ExpiresAt: time.Now().Add(ttl),
}
entryBytes, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return core.E("cache.Set", "failed to marshal cache entry", err)
}
if err := c.medium.Write(path, string(entryBytes)); err != nil {
return core.E("cache.Set", "failed to write cache file", err)
}
return nil
}
// Delete removes the cached item for key.
//
// err := c.Delete("github/acme/repos")
func (c *Cache) Delete(key string) error {
if err := c.ensureReady("cache.Delete"); err != nil {
return err
}
path, err := c.Path(key)
if err != nil {
return err
}
err = c.medium.Delete(path)
if core.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return core.E("cache.Delete", "failed to delete cache file", err)
}
return nil
}
// DeleteMany removes several cached items in one call.
//
// err := c.DeleteMany("github/acme/repos", "github/acme/meta")
func (c *Cache) DeleteMany(keys ...string) error {
if err := c.ensureReady("cache.DeleteMany"); err != nil {
return err
}
for _, key := range keys {
path, err := c.Path(key)
if err != nil {
return err
}
err = c.medium.Delete(path)
if core.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return core.E("cache.DeleteMany", "failed to delete cache file", err)
}
}
return nil
}
// Clear removes all cached items under the cache base directory.
//
// err := c.Clear()
func (c *Cache) Clear() error {
if err := c.ensureReady("cache.Clear"); err != nil {
return err
}
if err := c.medium.DeleteAll(c.baseDir); err != nil {
return core.E("cache.Clear", "failed to clear cache", err)
}
return nil
}
// Age reports how long ago key was cached, or -1 if it is missing or unreadable.
//
// age := c.Age("github/acme/repos")
func (c *Cache) Age(key string) time.Duration {
if err := c.ensureReady("cache.Age"); err != nil {
return -1
}
path, err := c.Path(key)
if err != nil {
return -1
}
dataStr, err := c.medium.Read(path)
if err != nil {
return -1
}
var entry Entry
entryResult := core.JSONUnmarshalString(dataStr, &entry)
if !entryResult.OK {
return -1
}
return time.Since(entry.CachedAt)
}
// GitHub-specific cache keys
// GitHubReposKey returns the cache key used for an organisation's repo list.
//
// key := cache.GitHubReposKey("acme")
func GitHubReposKey(org string) string {
return core.JoinPath("github", org, "repos")
}
// GitHubRepoKey returns the cache key used for a repository metadata entry.
//
// key := cache.GitHubRepoKey("acme", "widgets")
func GitHubRepoKey(org, repo string) string {
return core.JoinPath("github", org, repo, "meta")
}
func pathSeparator() string {
if ds := core.Env("DS"); ds != "" {
return ds
}
return "/"
}
func normalizePath(path string) string {
ds := pathSeparator()
normalized := core.Replace(path, "\\", ds)
if ds != "/" {
normalized = core.Replace(normalized, "/", ds)
}
return core.CleanPath(normalized, ds)
}
func absolutePath(path string) string {
normalized := normalizePath(path)
if core.PathIsAbs(normalized) {
return normalized
}
cwd := currentDir()
if cwd == "" || cwd == "." {
return normalized
}
return normalizePath(core.JoinPath(cwd, normalized))
}
func currentDir() string {
cwd := normalizePath(core.Env("PWD"))
if cwd != "" && cwd != "." {
return cwd
}
return normalizePath(core.Env("DIR_CWD"))
}
func (c *Cache) ensureConfigured(op string) error {
if c == nil {
return core.E(op, "cache is nil", nil)
}
if c.baseDir == "" {
return core.E(op, "cache base directory is empty; construct with cache.New", nil)
}
return nil
}
func (c *Cache) ensureReady(op string) error {
if err := c.ensureConfigured(op); err != nil {
return err
}
if c.medium == nil {
return core.E(op, "cache medium is nil; construct with cache.New", nil)
}
return nil
}