refactor(backend): extract to pkg/cap

This commit is contained in:
ryan
2026-06-15 16:19:18 +08:00
parent 84ae4ec27e
commit b3ed94342c
63 changed files with 770 additions and 706 deletions
+350
View File
@@ -0,0 +1,350 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package diskcache implements a platform-level disk-backed cache with size limit, TTL, and LRU eviction.
package diskcache
import (
"container/list"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/peterbourgon/diskv/v3"
)
// ErrCacheMiss represents a cache miss.
var ErrCacheMiss = errors.New("cache miss")
// Constants for disk cache configuration and sizing
const (
headerSize = 8 // 8 bytes metadata prefix for expiration UnixNano timestamp
defaultMaxSizeMB = 100
defaultTTLMinutes = 60
cacheDirPerm = 0750
// DefaultExpiration applies the cache-wide default TTL.
DefaultExpiration time.Duration = 0
// NoExpiration stores the item without a TTL. Size limits and LRU eviction still apply.
NoExpiration time.Duration = -1
)
// Status represents the runtime cache statistics.
type Status struct {
TotalSize int64 `json:"total_size"`
KeysCount int `json:"keys_count"`
MaxSizeMB int64 `json:"max_size_mb"`
TTLMinutes int64 `json:"ttl_minutes"`
LRUEnabled bool `json:"lru_enabled"`
BasePath string `json:"base_path"`
}
// DiskCache implements the disk-backed cache with size limits, TTL, and LRU eviction.
type DiskCache struct {
mu sync.RWMutex
d *diskv.Diskv
basePath string
maxSize int64 // in bytes
defaultTTL time.Duration
lruEnabled bool
// LRU and Size tracking
currentSize int64
items map[string]*list.Element
evictList *list.List
}
type cacheItem struct {
key string
size int64
expiredAt time.Time
}
// New creates a new DiskCache instance.
func New(basePath string) *DiskCache {
d := diskv.New(diskv.Options{
BasePath: basePath,
Transform: func(_ string) []string { return []string{} }, // flat structure for easy walk
CacheSizeMax: 1024 * 1024, // 1MB in-memory cache size for diskv itself
})
c := &DiskCache{
d: d,
basePath: basePath,
maxSize: defaultMaxSizeMB * 1024 * 1024, // 100MB default
defaultTTL: defaultTTLMinutes * time.Minute, // 60 minutes default
lruEnabled: true,
items: make(map[string]*list.Element),
evictList: list.New(),
}
// Scan directory on startup to rebuild LRU and size tracking
_ = c.loadTracker()
return c
}
// Set stores a key-value pair in the cache.
// Use DefaultExpiration for the configured default TTL, NoExpiration for no
// TTL, or a positive duration for a business-specific TTL.
func (c *DiskCache) Set(key string, value []byte, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
if ttl == DefaultExpiration {
ttl = c.defaultTTL
}
var expiredAt time.Time
if ttl > 0 {
expiredAt = time.Now().Add(ttl)
}
// Prepare data layout: 8 bytes expiration timestamp + raw payload
buf := make([]byte, headerSize+len(value))
var expNano int64
if !expiredAt.IsZero() {
expNano = expiredAt.UnixNano()
}
binary.BigEndian.PutUint64(buf[0:headerSize], uint64(expNano))
copy(buf[headerSize:], value)
// Write to diskv
if err := c.d.Write(key, buf); err != nil {
return fmt.Errorf("failed to write key to disk: %w", err)
}
// Get file size on disk (approximate)
size := int64(len(buf))
// Update memory tracker
if elem, ok := c.items[key]; ok {
item := elem.Value.(*cacheItem)
c.currentSize += size - item.size
item.size = size
item.expiredAt = expiredAt
c.evictList.MoveToFront(elem)
} else {
item := &cacheItem{
key: key,
size: size,
expiredAt: expiredAt,
}
elem := c.evictList.PushFront(item)
c.items[key] = elem
c.currentSize += size
}
// Evict items if size limit exceeded and LRU is enabled
c.evict()
return nil
}
// Get retrieves a key's value from the cache.
func (c *DiskCache) Get(key string) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
elem, ok := c.items[key]
if !ok {
return nil, ErrCacheMiss
}
item := elem.Value.(*cacheItem)
// Check expiration
if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) {
// Lazily delete expired item
_ = c.deleteUnlocked(key)
return nil, ErrCacheMiss
}
// Read from diskv
data, err := c.d.Read(key)
if err != nil {
// Key exists in memory but not on disk, sync state
_ = c.deleteUnlocked(key)
return nil, ErrCacheMiss
}
if len(data) < headerSize {
_ = c.deleteUnlocked(key)
return nil, ErrCacheMiss
}
// Update LRU access order
c.evictList.MoveToFront(elem)
// Slice off the metadata header
return data[headerSize:], nil
}
// Delete removes a key-value pair from the cache.
func (c *DiskCache) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.deleteUnlocked(key)
}
func (c *DiskCache) deleteUnlocked(key string) error {
if elem, ok := c.items[key]; ok {
item := elem.Value.(*cacheItem)
c.currentSize -= item.size
c.evictList.Remove(elem)
delete(c.items, key)
}
return c.d.Erase(key)
}
// Clear flushes all cached elements.
func (c *DiskCache) Clear() error {
c.mu.Lock()
defer c.mu.Unlock()
c.currentSize = 0
c.items = make(map[string]*list.Element)
c.evictList.Init()
return c.d.EraseAll()
}
// Status returns the cache status.
func (c *DiskCache) Status() Status {
c.mu.RLock()
defer c.mu.RUnlock()
return Status{
TotalSize: c.currentSize,
KeysCount: len(c.items),
MaxSizeMB: c.maxSize / (1024 * 1024),
TTLMinutes: int64(c.defaultTTL.Minutes()),
LRUEnabled: c.lruEnabled,
BasePath: c.basePath,
}
}
// UpdatePolicy dynamically updates policies.
func (c *DiskCache) UpdatePolicy(maxSizeMB int64, ttlMinutes int64, lruEnabled bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.maxSize = maxSizeMB * 1024 * 1024
c.defaultTTL = time.Duration(ttlMinutes) * time.Minute
c.lruEnabled = lruEnabled
c.evict()
}
// evict evicts oldest items if current size exceeds maxSize and LRU is enabled.
func (c *DiskCache) evict() {
if !c.lruEnabled {
return
}
for c.currentSize > c.maxSize && c.evictList.Len() > 0 {
elem := c.evictList.Back()
item := elem.Value.(*cacheItem)
c.currentSize -= item.size
c.evictList.Remove(elem)
delete(c.items, item.key)
_ = c.d.Erase(item.key)
}
}
// loadTracker scans the cache directory on startup to rebuild memory state.
func (c *DiskCache) loadTracker() error {
c.mu.Lock()
defer c.mu.Unlock()
// Ensure directory exists
if err := os.MkdirAll(c.basePath, cacheDirPerm); err != nil {
return err
}
type loadedItem struct {
key string
size int64
expiredAt time.Time
modTime time.Time
}
var loadedItems []loadedItem
// Walk keys through diskv
keysChan := c.d.Keys(nil)
for key := range keysChan {
// Read raw bytes to parse expiration prefix
data, err := c.d.Read(key)
if err != nil || len(data) < headerSize {
_ = c.d.Erase(key) // corrupted file, wipe
continue
}
expNano := int64(binary.BigEndian.Uint64(data[0:headerSize])) //nolint:gosec // false positive: UnixNano fits within int64
var expiredAt time.Time
if expNano > 0 {
expiredAt = time.Unix(0, expNano)
}
// Check mod time for ordering
path := filepath.Join(c.basePath, key)
info, err := os.Stat(path)
if err != nil {
continue
}
loadedItems = append(loadedItems, loadedItem{
key: key,
size: int64(len(data)),
expiredAt: expiredAt,
modTime: info.ModTime(),
})
}
// Sort by ModTime ascending (oldest first) so we rebuild LRU correctly
sort.Slice(loadedItems, func(i, j int) bool {
return loadedItems[i].modTime.Before(loadedItems[j].modTime)
})
// Populate LRU (PushFront so that newest items are at the front, oldest at the back)
for _, item := range loadedItems {
entry := &cacheItem{
key: item.key,
size: item.size,
expiredAt: item.expiredAt,
}
element := c.evictList.PushFront(entry)
c.items[item.key] = element
c.currentSize += item.size
}
return nil
}
// StartCleanupWorker periodically cleans up expired cache items.
func (c *DiskCache) StartCleanupWorker(interval time.Duration) {
ticker := time.NewTicker(interval)
for range ticker.C {
c.cleanExpired()
}
}
// cleanExpired scans memory for expired items and removes them.
func (c *DiskCache) cleanExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, elem := range c.items {
item := elem.Value.(*cacheItem)
if !item.expiredAt.IsZero() && now.After(item.expiredAt) {
c.currentSize -= item.size
c.evictList.Remove(elem)
delete(c.items, key)
_ = c.d.Erase(key)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package diskcache
import (
"bytes"
"os"
"testing"
"time"
)
func TestDiskCacheBasic(t *testing.T) {
testDir := "uploads/test_diskcache_basic"
defer func() { _ = os.RemoveAll(testDir) }()
_ = os.RemoveAll(testDir)
c := New(testDir)
defer func() { _ = c.Clear() }()
key := "key1"
val := []byte("value1")
// Get non-existent
_, err := c.Get(key)
if err != ErrCacheMiss {
t.Fatalf("expected ErrCacheMiss, got %v", err)
}
// Set & Get
err = c.Set(key, val, 10*time.Second)
if err != nil {
t.Fatalf("failed to set cache: %v", err)
}
got, err := c.Get(key)
if err != nil {
t.Fatalf("failed to get cache: %v", err)
}
if !bytes.Equal(got, val) {
t.Errorf("expected %s, got %s", val, got)
}
// Delete
err = c.Delete(key)
if err != nil {
t.Fatalf("failed to delete: %v", err)
}
_, err = c.Get(key)
if err != ErrCacheMiss {
t.Errorf("expected ErrCacheMiss after delete, got %v", err)
}
}
func TestDiskCacheTTL(t *testing.T) {
testDir := "uploads/test_diskcache_ttl"
defer func() { _ = os.RemoveAll(testDir) }()
_ = os.RemoveAll(testDir)
c := New(testDir)
defer func() { _ = c.Clear() }()
key := "ttlkey"
val := []byte("ttlval")
// Set with 200ms TTL
err := c.Set(key, val, 200*time.Millisecond)
if err != nil {
t.Fatalf("failed to set: %v", err)
}
// Immediate Get should succeed
got, err := c.Get(key)
if err != nil {
t.Fatalf("failed to get: %v", err)
}
if !bytes.Equal(got, val) {
t.Errorf("expected %s, got %s", val, got)
}
// Sleep 250ms to expire
time.Sleep(250 * time.Millisecond)
// Get should fail with cache miss
_, err = c.Get(key)
if err != ErrCacheMiss {
t.Errorf("expected ErrCacheMiss after TTL expiration, got %v", err)
}
}
func TestDiskCacheExpirationPolicies(t *testing.T) {
testDir := "uploads/test_diskcache_expiration_policies"
defer func() { _ = os.RemoveAll(testDir) }()
_ = os.RemoveAll(testDir)
c := New(testDir)
defer func() { _ = c.Clear() }()
c.defaultTTL = 50 * time.Millisecond
if err := c.Set("default", []byte("default"), DefaultExpiration); err != nil {
t.Fatalf("Set(default, DefaultExpiration) returned error: %v", err)
}
if err := c.Set("custom", []byte("custom"), 100*time.Millisecond); err != nil {
t.Fatalf("Set(custom, 100ms) returned error: %v", err)
}
if err := c.Set("permanent", []byte("permanent"), NoExpiration); err != nil {
t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err)
}
time.Sleep(75 * time.Millisecond)
if _, err := c.Get("default"); err != ErrCacheMiss {
t.Errorf("Get(default) error = %v, want ErrCacheMiss", err)
}
if _, err := c.Get("custom"); err != nil {
t.Errorf("Get(custom) returned error before custom TTL elapsed: %v", err)
}
if _, err := c.Get("permanent"); err != nil {
t.Errorf("Get(permanent) returned error: %v", err)
}
time.Sleep(50 * time.Millisecond)
if _, err := c.Get("custom"); err != ErrCacheMiss {
t.Errorf("Get(custom) error = %v, want ErrCacheMiss", err)
}
if _, err := c.Get("permanent"); err != nil {
t.Errorf("Get(permanent) returned error after other entries expired: %v", err)
}
}
func TestDiskCacheNoExpirationSurvivesReload(t *testing.T) {
testDir := "uploads/test_diskcache_no_expiration_reload"
defer func() { _ = os.RemoveAll(testDir) }()
_ = os.RemoveAll(testDir)
c := New(testDir)
if err := c.Set("permanent", []byte("value"), NoExpiration); err != nil {
t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err)
}
reloaded := New(testDir)
defer func() { _ = reloaded.Clear() }()
got, err := reloaded.Get("permanent")
if err != nil {
t.Fatalf("reloaded Get(permanent) returned error: %v", err)
}
if !bytes.Equal(got, []byte("value")) {
t.Errorf("reloaded Get(permanent) = %q, want %q", got, "value")
}
}
func TestDiskCacheLRUEviction(t *testing.T) {
testDir := "uploads/test_diskcache_lru"
defer func() { _ = os.RemoveAll(testDir) }()
_ = os.RemoveAll(testDir)
c := New(testDir)
defer func() { _ = c.Clear() }()
// Force a very small max size of 20 bytes for testing (8 bytes header + payload)
// So 2 items of 2 bytes payload = 2 * (8 + 2) = 20 bytes max.
c.maxSize = 20
c.lruEnabled = true
// Write item 1: 8 + 2 = 10 bytes
err := c.Set("k1", []byte("v1"), DefaultExpiration)
if err != nil {
t.Fatalf("failed to set k1: %v", err)
}
// Write item 2: 8 + 2 = 10 bytes
err = c.Set("k2", []byte("v2"), DefaultExpiration)
if err != nil {
t.Fatalf("failed to set k2: %v", err)
}
// Both should exist
if _, err := c.Get("k1"); err != nil {
t.Errorf("k1 should exist: %v", err)
}
if _, err := c.Get("k2"); err != nil {
t.Errorf("k2 should exist: %v", err)
}
// Write item 3: 8 + 2 = 10 bytes -> total size would be 30, exceeding 20.
// This should evict the oldest item. Since k1 was accessed, but then k2 was accessed,
// wait, let's access k1 again to make it the most recently used, so k2 becomes oldest!
_, _ = c.Get("k1") // k1 is now MRU, k2 is LRU
err = c.Set("k3", []byte("v3"), DefaultExpiration)
if err != nil {
t.Fatalf("failed to set k3: %v", err)
}
// k2 should be evicted, k1 and k3 should exist
_, err = c.Get("k2")
if err != ErrCacheMiss {
t.Errorf("expected k2 to be evicted, got error %v", err)
}
if _, err := c.Get("k1"); err != nil {
t.Errorf("k1 should still exist: %v", err)
}
if _, err := c.Get("k3"); err != nil {
t.Errorf("k3 should exist: %v", err)
}
}