Conversation
New package: pkg/cache/ - Cache interface: Get(key) → ([]byte, bool, error), Set, Delete, Flush - GetJSON/SetJSON generic helpers for type-safe cached values - MemoryCache: LRU eviction (container/list) + TTL expiration, background cleanup goroutine, thread-safe with sync.RWMutex - SQLiteCache: persistent across restarts, uses existing app DB, creates _cache table on init, background cleanup of expired rows - HTTPCache/NoCache middleware for Cache-Control headers 18 tests covering both backends: set/get, cache miss, TTL expiration, LRU eviction, delete, flush, overwrite, concurrent access (race-safe), JSON round-trip, HTTP header verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review:
|
There was a problem hiding this comment.
Pull request overview
This PR introduces a new pkg/cache package providing a common caching interface with both in-memory and SQLite-backed implementations, plus JSON helpers and HTTP header middleware to control response caching.
Changes:
- Added
Cacheinterface withMemoryCache(LRU + TTL) andSQLiteCache(persistent table-backed) implementations. - Added
GetJSON/SetJSONhelpers for type-safe JSON value caching. - Added
HTTPCache/NoCacheHTTP middleware forCache-Controlheader management.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/cache/cache.go | Defines the cache interface plus JSON helper functions. |
| pkg/cache/memory.go | Adds an in-memory LRU+TTL cache with background expiration cleanup. |
| pkg/cache/sqlite.go | Adds a SQLite-backed persistent cache with periodic expired-row cleanup. |
| pkg/cache/http.go | Adds HTTP middleware to set caching-related headers. |
| pkg/cache/cache_test.go | Tests JSON helper round-trip and miss behavior. |
| pkg/cache/memory_test.go | Tests in-memory cache behavior (TTL, LRU, concurrency, etc.). |
| pkg/cache/sqlite_test.go | Tests SQLite cache behavior (TTL, overwrite, flush, etc.). |
| pkg/cache/http_test.go | Tests HTTP header middleware behavior. |
| err := c.db.QueryRowContext(ctx, | ||
| `SELECT value FROM _cache WHERE key = ? AND expires_at > ?`, key, time.Now(), | ||
| ).Scan(&value) | ||
| if err == sql.ErrNoRows { | ||
| return nil, false, nil | ||
| } | ||
| if err != nil { | ||
| return nil, false, err | ||
| } | ||
| return value, true, nil | ||
| } | ||
|
|
||
| func (c *SQLiteCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { | ||
| expiresAt := time.Now().Add(ttl) | ||
| _, err := c.db.ExecContext(ctx, | ||
| `INSERT OR REPLACE INTO _cache (key, value, expires_at) VALUES (?, ?, ?)`, | ||
| key, value, expiresAt, | ||
| ) |
There was a problem hiding this comment.
expires_at is written and compared using Go time.Time parameters. With SQLite, the underlying storage/compare semantics depend on driver binding/affinity and can break TTL checks if the value is stored in a non-lexicographically sortable format. Consider storing expires_at explicitly as an RFC3339 UTC string (as done elsewhere in the repo, e.g. internal/telemetry/sqlite.go) or as an INTEGER unix timestamp, and use the same representation in Get/cleanup queries.
| func (c *SQLiteCache) cleanup(interval time.Duration) { | ||
| ticker := time.NewTicker(interval) | ||
| defer ticker.Stop() | ||
| for { | ||
| select { | ||
| case <-c.stop: | ||
| return | ||
| case <-ticker.C: | ||
| c.db.Exec(`DELETE FROM _cache WHERE expires_at < ?`, time.Now()) | ||
| } |
There was a problem hiding this comment.
The cleanup loop discards errors from Exec and runs without a context. If deletes fail (e.g., DB is busy/closed), expired rows will accumulate silently. Use ExecContext (with a background/derived context) and handle/log errors so operational issues are visible.
| // Close stops the background cleanup goroutine. | ||
| func (c *SQLiteCache) Close() error { | ||
| close(c.stop) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Close() unconditionally closes c.stop, which will panic if Close() is called more than once (or concurrently). Make Close idempotent (e.g., via sync.Once or an atomic/try-close pattern) to avoid process crashes during shutdown paths.
|
|
||
| // Close stops the background cleanup goroutine. | ||
| func (c *MemoryCache) Close() error { | ||
| close(c.stop) |
There was a problem hiding this comment.
Close() unconditionally closes c.stop, which will panic if Close() is called more than once (or concurrently). Make Close idempotent (e.g., sync.Once) so callers can safely defer Close without worrying about double-close during teardown.
| close(c.stop) | |
| c.mu.Lock() | |
| defer c.mu.Unlock() | |
| // Make Close idempotent and safe for concurrent callers. | |
| select { | |
| case <-c.stop: | |
| // already closed | |
| default: | |
| close(c.stop) | |
| } |
| // HTTPCache returns middleware that sets Cache-Control headers. | ||
| // maxAge is the duration in seconds that the response may be cached. | ||
| func HTTPCache(maxAge int) func(http.Handler) http.Handler { | ||
| value := fmt.Sprintf("public, max-age=%d", maxAge) | ||
| return func(next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Set("Cache-Control", value) | ||
| next.ServeHTTP(w, r) | ||
| }) |
There was a problem hiding this comment.
HTTPCache will emit Cache-Control: public, max-age=<n> even when maxAge is negative, producing an invalid header and potentially confusing caches/clients. Validate/clamp maxAge (e.g., treat <=0 as no-store/max-age=0) or return an error/alternate middleware for invalid inputs.
| } | ||
| var result T | ||
| if err := json.Unmarshal(data, &result); err != nil { | ||
| return zero, false, err |
There was a problem hiding this comment.
On JSON unmarshal failure, GetJSON returns found=false even though the key existed, which makes it hard for callers to distinguish a true cache miss from cache corruption/format changes. Consider returning found=true with the error, and/or deleting the bad entry before returning so repeated calls don’t keep hitting the same failure.
| return zero, false, err | |
| // The key was present in the cache, but the stored JSON could not be unmarshaled. | |
| // Return found=true with the error so callers can distinguish this from a cache miss. | |
| return zero, true, err |
| func TestSQLiteCache_TTLExpiration(t *testing.T) { | ||
| c := newTestSQLiteCache(t) | ||
| ctx := context.Background() | ||
|
|
||
| c.Set(ctx, "expires", []byte("data"), 50*time.Millisecond) | ||
| time.Sleep(100 * time.Millisecond) | ||
|
|
There was a problem hiding this comment.
This test ignores the error return from Set. If the insert fails for any reason, the expiration assertion can become a false positive (testing only that the key is missing). Assert Set succeeded so the test actually validates TTL behavior.
| defer c.Close() | ||
| ctx := context.Background() | ||
|
|
||
| c.Set(ctx, "expires", []byte("data"), 50*time.Millisecond) |
There was a problem hiding this comment.
This test ignores the error return from Set. If Set fails, the expiration assertion can become a false positive. Please assert the Set error so the test reliably exercises TTL expiration.
| c.Set(ctx, "expires", []byte("data"), 50*time.Millisecond) | |
| if err := c.Set(ctx, "expires", []byte("data"), 50*time.Millisecond); err != nil { | |
| t.Fatalf("Set() error = %v", err) | |
| } |
Summary
pkg/cache/package withCacheinterface (Get/Set/Delete/Flush)MemoryCache: in-memory LRU eviction + TTL expiration, background cleanup, thread-safeSQLiteCache: persistent cache using app's existing SQLite DB, auto-creates_cachetableGetJSON/SetJSONgeneric helpers for type-safe cached valuesHTTPCache/NoCachemiddleware for Cache-Control headersTest plan
-raceflag🤖 Generated with Claude Code