feat: extract cache package from core/go pkg/cache
File-based cache with TTL, JSON serialisation, go-io Medium backend. Moved from forge.lthn.ai/core/go/pkg/cache to standalone module. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
commit
9f8cb6cd0e
4 changed files with 294 additions and 0 deletions
171
cache.go
Normal file
171
cache.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Package cache provides a file-based cache for GitHub API responses.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// DefaultTTL is the default cache expiry time.
|
||||
const DefaultTTL = 1 * time.Hour
|
||||
|
||||
// Cache represents a file-based cache.
|
||||
type Cache struct {
|
||||
medium io.Medium
|
||||
baseDir string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// Entry represents a cached item with metadata.
|
||||
type Entry struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// New creates a new cache instance.
|
||||
// If medium is nil, uses io.Local (filesystem).
|
||||
// If baseDir is empty, uses .core/cache in current directory.
|
||||
func New(medium io.Medium, baseDir string, ttl time.Duration) (*Cache, error) {
|
||||
if medium == nil {
|
||||
medium = io.Local
|
||||
}
|
||||
|
||||
if baseDir == "" {
|
||||
// Use .core/cache in current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDir = filepath.Join(cwd, ".core", "cache")
|
||||
}
|
||||
|
||||
if ttl == 0 {
|
||||
ttl = DefaultTTL
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := medium.EnsureDir(baseDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Cache{
|
||||
medium: medium,
|
||||
baseDir: baseDir,
|
||||
ttl: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Path returns the full path for a cache key.
|
||||
func (c *Cache) Path(key string) string {
|
||||
return filepath.Join(c.baseDir, key+".json")
|
||||
}
|
||||
|
||||
// Get retrieves a cached item if it exists and hasn't expired.
|
||||
func (c *Cache) Get(key string, dest any) (bool, error) {
|
||||
path := c.Path(key)
|
||||
|
||||
dataStr, err := c.medium.Read(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
var entry Entry
|
||||
if err := json.Unmarshal([]byte(dataStr), &entry); err != nil {
|
||||
// Invalid cache file, treat as miss
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Unmarshal the actual data
|
||||
if err := json.Unmarshal(entry.Data, dest); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Set stores an item in the cache.
|
||||
func (c *Cache) Set(key string, data any) error {
|
||||
path := c.Path(key)
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := c.medium.EnsureDir(filepath.Dir(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Marshal the data
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Data: dataBytes,
|
||||
CachedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(c.ttl),
|
||||
}
|
||||
|
||||
entryBytes, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.medium.Write(path, string(entryBytes))
|
||||
}
|
||||
|
||||
// Delete removes an item from the cache.
|
||||
func (c *Cache) Delete(key string) error {
|
||||
path := c.Path(key)
|
||||
err := c.medium.Delete(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear removes all cached items.
|
||||
func (c *Cache) Clear() error {
|
||||
return c.medium.DeleteAll(c.baseDir)
|
||||
}
|
||||
|
||||
// Age returns how old a cached item is, or -1 if not cached.
|
||||
func (c *Cache) Age(key string) time.Duration {
|
||||
path := c.Path(key)
|
||||
|
||||
dataStr, err := c.medium.Read(path)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
var entry Entry
|
||||
if err := json.Unmarshal([]byte(dataStr), &entry); err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
return time.Since(entry.CachedAt)
|
||||
}
|
||||
|
||||
// GitHub-specific cache keys
|
||||
|
||||
// GitHubReposKey returns the cache key for an org's repo list.
|
||||
func GitHubReposKey(org string) string {
|
||||
return filepath.Join("github", org, "repos")
|
||||
}
|
||||
|
||||
// GitHubRepoKey returns the cache key for a specific repo's metadata.
|
||||
func GitHubRepoKey(org, repo string) string {
|
||||
return filepath.Join("github", org, repo, "meta")
|
||||
}
|
||||
104
cache_test.go
Normal file
104
cache_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package cache_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-cache"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
// Use a path that MockMedium will understand
|
||||
baseDir := "/tmp/cache"
|
||||
c, err := cache.New(m, baseDir, 1*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
}
|
||||
|
||||
key := "test-key"
|
||||
data := map[string]string{"foo": "bar"}
|
||||
|
||||
// Test Set
|
||||
if err := c.Set(key, data); err != nil {
|
||||
t.Errorf("Set failed: %v", err)
|
||||
}
|
||||
|
||||
// Test Get
|
||||
var retrieved map[string]string
|
||||
found, err := c.Get(key, &retrieved)
|
||||
if err != nil {
|
||||
t.Errorf("Get failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected to find cached item")
|
||||
}
|
||||
if retrieved["foo"] != "bar" {
|
||||
t.Errorf("expected foo=bar, got %v", retrieved["foo"])
|
||||
}
|
||||
|
||||
// Test Age
|
||||
age := c.Age(key)
|
||||
if age < 0 {
|
||||
t.Error("expected age >= 0")
|
||||
}
|
||||
|
||||
// Test Delete
|
||||
if err := c.Delete(key); err != nil {
|
||||
t.Errorf("Delete failed: %v", err)
|
||||
}
|
||||
found, err = c.Get(key, &retrieved)
|
||||
if err != nil {
|
||||
t.Errorf("Get after delete returned an unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected item to be deleted")
|
||||
}
|
||||
|
||||
// Test Expiry
|
||||
cshort, err := cache.New(m, "/tmp/cache-short", 10*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create short-lived cache: %v", err)
|
||||
}
|
||||
if err := cshort.Set(key, data); err != nil {
|
||||
t.Fatalf("Set for expiry test failed: %v", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
found, err = cshort.Get(key, &retrieved)
|
||||
if err != nil {
|
||||
t.Errorf("Get for expired item returned an unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected item to be expired")
|
||||
}
|
||||
|
||||
// Test Clear
|
||||
if err := c.Set("key1", data); err != nil {
|
||||
t.Fatalf("Set for clear test failed for key1: %v", err)
|
||||
}
|
||||
if err := c.Set("key2", data); err != nil {
|
||||
t.Fatalf("Set for clear test failed for key2: %v", err)
|
||||
}
|
||||
if err := c.Clear(); err != nil {
|
||||
t.Errorf("Clear failed: %v", err)
|
||||
}
|
||||
found, err = c.Get("key1", &retrieved)
|
||||
if err != nil {
|
||||
t.Errorf("Get after clear returned an unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected key1 to be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheDefaults(t *testing.T) {
|
||||
// Test default Medium (io.Local) and default TTL
|
||||
c, err := cache.New(nil, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache with defaults: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("expected cache instance")
|
||||
}
|
||||
}
|
||||
7
go.mod
Normal file
7
go.mod
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module forge.lthn.ai/core/go-cache
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require forge.lthn.ai/core/go-io v0.0.3
|
||||
|
||||
require forge.lthn.ai/core/go-log v0.0.1 // indirect
|
||||
12
go.sum
Normal file
12
go.sum
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI=
|
||||
forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0=
|
||||
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
|
||||
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
Loading…
Add table
Reference in a new issue