// 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) } // REQ-156 / P07 T1: busy_timeout(5000) so concurrent cache opens // (e.g. two `orca node list` invocations racing on the same shell) // wait up to 5s for the writer instead of failing immediately with // SQLITE_BUSY. SetMaxOpenConns(1) serializes the connections so the // busy_timeout is rarely needed but keeps the cache durable under // contention. db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") if err != nil { return nil, fmt.Errorf("open cache sqlite: %w", err) } db.SetMaxOpenConns(1) 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() }