Skip to content

feat: add caching layer with memory and SQLite backends (Roadmap 3.1)#280

Merged
adnaan merged 1 commit intomainfrom
caching
Mar 23, 2026
Merged

feat: add caching layer with memory and SQLite backends (Roadmap 3.1)#280
adnaan merged 1 commit intomainfrom
caching

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Mar 23, 2026

Summary

  • New pkg/cache/ package with Cache interface (Get/Set/Delete/Flush)
  • MemoryCache: in-memory LRU eviction + TTL expiration, background cleanup, thread-safe
  • SQLiteCache: persistent cache using app's existing SQLite DB, auto-creates _cache table
  • GetJSON/SetJSON generic helpers for type-safe cached values
  • HTTPCache/NoCache middleware for Cache-Control headers

Test plan

  • MemoryCache: set/get, cache miss, TTL expiration, LRU eviction at capacity, delete, flush, overwrite
  • MemoryCache: concurrent access with 100 goroutines (race detector clean)
  • SQLiteCache: set/get, cache miss, TTL expiration, delete, flush, overwrite
  • JSON helpers: SetJSON/GetJSON round-trip with struct types
  • HTTP middleware: Cache-Control and no-cache header verification
  • All 18 tests pass with -race flag
  • Full test suite passes (zero regressions)

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings March 23, 2026 07:56
@claude
Copy link
Copy Markdown

claude bot commented Mar 23, 2026

Code Review: pkg/cache — Caching Layer (Roadmap 3.1)

Overall this is clean, well-structured code with good test coverage. The interface design is simple and the LRU+TTL memory implementation is solid. A few issues worth addressing before merging:


Bugs

1. MemoryCache.Get — potential LRU inconsistency after concurrent delete (memory.go:56-66)

Between releasing the RLock and re-acquiring the write lock for MoveToFront, another goroutine can delete the element. Go's container/list.MoveToFront is a no-op if the element has been removed (e.list == nil), so there's no panic — but a concurrent Set of the same key could result in the new element never being promoted. Minor LRU inaccuracy, but worth a note.

The simplest fix: check whether the element is still in c.items after acquiring the write lock before calling MoveToFront.

c.mu.Lock()
if _, ok := c.items[key]; ok {
    c.order.MoveToFront(elem)
}
c.mu.Unlock()

2. Close() panics on second call (memory.go:121, sqlite.go:73)

close(c.stop) panics if called twice. In test code with defer c.Close() this is safe, but any caller who accidentally calls Close twice will get a hard panic. A simple sync.Once guard would prevent this:

func (c *MemoryCache) Close() error {
    c.closeOnce.Do(func() { close(c.stop) })
    return nil
}

3. SQLite time comparison fragility (sqlite.go:48)

`SELECT value FROM _cache WHERE key = ? AND expires_at > ?`, key, time.Now(),

The behavior of comparing DATETIME columns with a Go time.Time depends on how the driver serializes it. With modernc.org/sqlite, this works, but it's implicit. Using time.Now().UTC().Format(time.RFC3339) makes the format explicit and portable across drivers.

4. Unhandled error in cleanup goroutine (sqlite.go:84)

c.db.Exec(`DELETE FROM _cache WHERE expires_at < ?`, time.Now())

The error is silently discarded. Even if it's a background task, logging the error (or using a passed-in logger) would help with observability in production use.


Design / Convention Issues

5. Closeable should be named Closer (cache.go:22)

Go convention (from io.Closer) uses the -er suffix for single-method interfaces: Closer, not Closeable. The interface should either be named Closer or simply embed io.Closer:

type Closer interface {
    Close() error
}

6. Context parameter ordering in GetJSON/SetJSON (cache.go:26, 40)

Go convention is ctx context.Context as the first parameter. The current signatures put the cache before context, which is non-idiomatic and will likely trigger staticcheck SA1012 or similar linters:

// Current (non-idiomatic)
func GetJSON[T any](c Cache, ctx context.Context, key string) (T, bool, error)

// Idiomatic
func GetJSON[T any](ctx context.Context, c Cache, key string) (T, bool, error)

7. Missing index on expires_at in SQLite schema (sqlite.go:24-30)

The cleanup query DELETE FROM _cache WHERE expires_at < ? and the Get query both filter on expires_at. Without an index, these do a full table scan. For any non-trivial cache size this will be slow:

CREATE INDEX IF NOT EXISTS _cache_expires_at ON _cache (expires_at)

8. HTTPCache applies to all HTTP methods (http.go:10)

Setting Cache-Control: public on POST/PUT/DELETE responses is semantically wrong and could confuse CDNs or proxies. The middleware should restrict to safe methods:

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodGet || r.Method == http.MethodHead {
        w.Header().Set("Cache-Control", value)
    }
    next.ServeHTTP(w, r)
})

Test Coverage Gaps

9. TTL tests use time.Sleep — flaky in CI (memory_test.go:66, sqlite_test.go:69)

time.Sleep(100 * time.Millisecond) after a 50ms TTL is a common source of CI flakiness under load. Consider a slightly larger margin (e.g., 200ms sleep, 50ms TTL) or use testing.Short() to skip in short mode.

10. No concurrent access test for SQLiteCache

MemoryCache has a good concurrency test, but SQLiteCache has none. SQLite's default WAL mode handles concurrent reads fine, but concurrent writes can produce SQLITE_BUSY errors without proper retry logic. A basic concurrent test (similar to TestMemoryCache_ConcurrentAccess) would catch driver-level issues.

11. No test for double-Close()

Given the panic on double-close described above, a test that calls Close() twice would pin the expected behavior.


Minor

  • errors.Is(err, sql.ErrNoRows) is preferred over err == sql.ErrNoRows in sqlite.go:48 for wrapped error compatibility.
  • NewSQLiteCache starts the cleanup goroutine before returning the cache — if NewSQLiteCache is called with a cleanupInterval of 0, the default kicks in but a zero-value cleanupInterval from the caller could also mean "no cleanup". Worth documenting the semantics clearly.

Summary: Fix the Close() double-call panic and the context parameter ordering (both are breaking correctness or API convention issues). The index and method-filtering for HTTPCache are the most impactful improvements for production use. Everything else is polish.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Cache interface with MemoryCache (LRU + TTL) and SQLiteCache (persistent table-backed) implementations.
  • Added GetJSON/SetJSON helpers for type-safe JSON value caching.
  • Added HTTPCache/NoCache HTTP middleware for Cache-Control header 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.

Comment thread pkg/cache/sqlite.go
Comment on lines +40 to +57
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,
)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pkg/cache/sqlite.go
Comment on lines +77 to +86
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())
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pkg/cache/sqlite.go
Comment on lines +71 to +75
// Close stops the background cleanup goroutine.
func (c *SQLiteCache) Close() error {
close(c.stop)
return nil
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pkg/cache/memory.go

// Close stops the background cleanup goroutine.
func (c *MemoryCache) Close() error {
close(c.stop)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
Comment thread pkg/cache/http.go
Comment on lines +8 to +16
// 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)
})
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pkg/cache/cache.go
}
var result T
if err := json.Unmarshal(data, &result); err != nil {
return zero, false, err
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread pkg/cache/sqlite_test.go
Comment on lines +60 to +66
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)

Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pkg/cache/memory_test.go
defer c.Close()
ctx := context.Background()

c.Set(ctx, "expires", []byte("data"), 50*time.Millisecond)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
@adnaan adnaan merged commit 4ffd25d into main Mar 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants