0358efe95b
- SQLite busy_timeout(5000) + SetMaxOpenConns(1) on all 4 DSNs - secrets file flock (concurrent set on same ns no longer loses data) - upgrade lock file (refuse concurrent orca upgrade) - backup lock file (refuse concurrent backup) - cache invalidation by writes (read-after-write consistency) - Executor.Run mutex scope fix (hold only for DB inserts) - ns create/inherit/set-constraint atomic writeNSMdAtomic - writeCurrentLead + rotateSSHKeys atomic - consolidate 3 writeAtomic impls onto security.WriteAtomic - WebAuthn session stores guarded with sync.Mutex Tests: concurrent secrets set, upgrade lock rejection, cache read-after-write, WebAuthn session thread-safety (pass under -race). ---ci--- project: orca phase: 7 milestone: v0.13 status: complete requirements: covered: [156] ---/ci---
219 lines
7.0 KiB
Go
219 lines
7.0 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)
|
|
}
|
|
// 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()
|
|
}
|