Files
orca/internal/cache/cache.go
T
Jon Chery b6d4db1a96 feat(P00): CLI cache layer (R-008) — orca_cache SQLite + cache CLI
internal/cache/ package with per-class TTLs (Get/Set/Invalidate);
wired into node/job/ns list read paths; orca cache show/invalidate CLI.
Tests: hit/miss/invalidate/TTL-expiry + bench <1ms hit.

---ci---
project: orca
phase: 00
milestone: v0.11
status: execute
---/ci---
2026-08-07 04:17:25 +00:00

212 lines
6.6 KiB
Go

// Package cache implements a CLI-side SQLite-backed key/value cache with
// per-class TTLs (R-008). It is the on-disk cache layer used by read-only
// `orca` subcommands (node/job/ns list) to avoid hitting the source DB
// or filesystem on every invocation.
//
// The cache is intentionally optional: callers that fail to open the
// cache DB must fall back to the uncached read path silently. Writes
// bypass the cache entirely (cache invalidation is per-class or
// whole-DB only — there is no write-through path).
//
// Schema (orca_cache):
//
// CREATE TABLE cache_entries (
// class TEXT,
// key TEXT,
// value BLOB,
// inserted_at INTEGER, -- unix nanoseconds
// ttl_seconds INTEGER, -- TTL in nanoseconds; 0 = never expires
// PRIMARY KEY (class, key)
// );
//
// The schema columns match the v0.11 plan (R-008); the integer columns
// are stored at nanosecond resolution so sub-second TTLs (used in tests
// and short-lived caches like the 10s job-list cache) work correctly.
package cache
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
// ErrCacheMiss is returned (wrapped) by Get when an entry is absent or
// expired. Callers that want a silent miss should treat any error
// satisfying errors.Is(err, ErrCacheMiss) as "not in cache".
var ErrCacheMiss = errors.New("cache miss")
// Cache wraps a SQLite-backed key/value cache with per-class TTLs.
type Cache struct {
db *sql.DB
}
// Open opens (or creates) the SQLite cache DB at path. If path is empty
// it defaults to paths.CacheDB(). The DB is created with WAL journal
// mode (matching internal/store). The schema is idempotent
// (CREATE TABLE IF NOT EXISTS).
func Open(path string) (*Cache, error) {
if path == "" {
path = paths.CacheDB()
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create cache db dir: %w", err)
}
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
if err != nil {
return nil, fmt.Errorf("open cache sqlite: %w", err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping cache sqlite: %w", err)
}
const schema = `CREATE TABLE IF NOT EXISTS cache_entries (
class TEXT NOT NULL,
key TEXT NOT NULL,
value BLOB NOT NULL,
inserted_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
PRIMARY KEY (class, key)
)`
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("create cache schema: %w", err)
}
return &Cache{db: db}, nil
}
// Get returns the cached value and insertion time for (class, key).
// On a miss or expired entry Get returns (nil, zero, ErrCacheMiss).
func (c *Cache) Get(class, key string) ([]byte, time.Time, error) {
const q = `SELECT value, inserted_at, ttl_seconds FROM cache_entries WHERE class = ? AND key = ?`
var (
val []byte
inserted int64
ttlNanos int64
)
err := c.db.QueryRow(q, class, key).Scan(&val, &inserted, &ttlNanos)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, time.Time{}, ErrCacheMiss
}
return nil, time.Time{}, fmt.Errorf("cache get %s/%s: %w", class, key, err)
}
if ttlNanos > 0 {
expiresAt := time.Unix(0, inserted).Add(time.Duration(ttlNanos))
if time.Now().After(expiresAt) {
_, _ = c.db.Exec(`DELETE FROM cache_entries WHERE class = ? AND key = ?`, class, key)
return nil, time.Time{}, ErrCacheMiss
}
}
return val, time.Unix(0, inserted).UTC(), nil
}
// Set stores val for (class, key) with the given ttl. A ttl of 0 means
// the entry never expires. An existing entry for (class, key) is
// replaced (UPSERT).
func (c *Cache) Set(class, key string, val []byte, ttl time.Duration) error {
inserted := time.Now().UTC().UnixNano()
ttlNanos := int64(ttl)
const q = `INSERT INTO cache_entries (class, key, value, inserted_at, ttl_seconds)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(class, key) DO UPDATE SET
value = excluded.value,
inserted_at = excluded.inserted_at,
ttl_seconds = excluded.ttl_seconds`
if _, err := c.db.Exec(q, class, key, val, inserted, ttlNanos); err != nil {
return fmt.Errorf("cache set %s/%s: %w", class, key, err)
}
return nil
}
// Invalidate removes all entries for class.
func (c *Cache) Invalidate(class string) error {
if _, err := c.db.Exec(`DELETE FROM cache_entries WHERE class = ?`, class); err != nil {
return fmt.Errorf("cache invalidate %s: %w", class, err)
}
return nil
}
// InvalidateKey removes a single (class, key) entry.
func (c *Cache) InvalidateKey(class, key string) error {
if _, err := c.db.Exec(`DELETE FROM cache_entries WHERE class = ? AND key = ?`, class, key); err != nil {
return fmt.Errorf("cache invalidate %s/%s: %w", class, key, err)
}
return nil
}
// Close releases the underlying DB handle.
func (c *Cache) Close() error {
if c == nil || c.db == nil {
return nil
}
return c.db.Close()
}
// ClassStats describes one cache class for `orca cache show`.
type ClassStats struct {
Class string `json:"class"`
Count int `json:"count"`
Bytes int64 `json:"bytes"`
OldestAt int64 `json:"oldest_at"`
}
// Stats returns per-class entry counts, total bytes, and oldest
// insertion time. Used by `orca cache show`.
func (c *Cache) Stats() ([]ClassStats, error) {
const q = `SELECT class,
COUNT(*) AS count,
COALESCE(SUM(LENGTH(value)), 0) AS bytes,
COALESCE(MIN(inserted_at), 0) AS oldest
FROM cache_entries GROUP BY class ORDER BY class`
rows, err := c.db.Query(q)
if err != nil {
return nil, fmt.Errorf("cache stats: %w", err)
}
defer rows.Close()
var out []ClassStats
for rows.Next() {
var s ClassStats
if err := rows.Scan(&s.Class, &s.Count, &s.Bytes, &s.OldestAt); err != nil {
return nil, fmt.Errorf("cache stats scan: %w", err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("cache stats rows: %w", err)
}
return out, nil
}
// InvalidateAll clears every entry in the cache.
func (c *Cache) InvalidateAll() error {
if _, err := c.db.Exec(`DELETE FROM cache_entries`); err != nil {
return fmt.Errorf("cache invalidate-all: %w", err)
}
return nil
}
// Classes returns the distinct class names in the cache.
func (c *Cache) Classes() ([]string, error) {
rows, err := c.db.Query(`SELECT DISTINCT class FROM cache_entries ORDER BY class`)
if err != nil {
return nil, fmt.Errorf("cache classes: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("cache classes scan: %w", err)
}
out = append(out, name)
}
return out, rows.Err()
}