Files
orca/internal/cli/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

219 lines
6.4 KiB
Go

// Package cli: cache.go implements the `orca cache` subcommand family
// (P00-T3, R-008) and the shared cache helpers used by the read-only
// list commands (node/job/ns list).
//
// The cache is optional: if the cache DB cannot be opened (missing dir,
// permissions, corrupt file) the list commands fall back to the
// uncached read path silently with a slog.Warn.
package cli
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/cache"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
// cacheHit reports whether the cache returned a fresh entry for
// (class, key). On any cache-open or read error it returns false (miss)
// and logs a warning — the caller proceeds to the uncached path. The
// cache never *creates* ORCA_HOME: if the parent directory is missing
// the cache is skipped silently so that source-read errors (e.g. `orca
// ns list` against a nonexistent ORCA_HOME) still surface.
func cacheHit(class, key string) ([]byte, bool) {
if !cacheAvailable() {
return nil, false
}
c, err := cache.Open(paths.CacheDB())
if err != nil {
slog.Warn("cache: open failed, falling back to uncached path", "class", class, "err", err)
return nil, false
}
defer c.Close()
val, _, err := c.Get(class, key)
if err != nil {
if !errors.Is(err, cache.ErrCacheMiss) {
slog.Warn("cache: get failed, falling back to uncached path", "class", class, "err", err)
}
return nil, false
}
return val, true
}
// cachePopulate stores val for (class, key) with the given ttl. Errors
// are logged but never returned — a failed populate must not break
// the list command.
func cachePopulate(class, key string, val []byte, ttl time.Duration) {
if !cacheAvailable() {
return
}
c, err := cache.Open(paths.CacheDB())
if err != nil {
slog.Warn("cache: open failed during populate", "class", class, "err", err)
return
}
defer c.Close()
if err := c.Set(class, key, val, ttl); err != nil {
slog.Warn("cache: populate failed", "class", class, "err", err)
}
}
// cacheAvailable reports whether the cache DB parent dir (ORCA_HOME)
// exists. The cache layer must never create ORCA_HOME; doing so would
// mask source-read errors like `orca ns list` against a missing home.
func cacheAvailable() bool {
info, err := os.Stat(paths.Root())
if err != nil || !info.IsDir() {
return false
}
return true
}
// cacheGetList returns the cached JSON list for (class, key), or nil
// if miss/any error. It is the read-side helper for list commands.
func cacheGetList(class, key string, out any) bool {
val, ok := cacheHit(class, key)
if !ok {
return false
}
if err := json.Unmarshal(val, out); err != nil {
slog.Warn("cache: unmarshal failed, falling back to uncached path", "class", class, "err", err)
return false
}
return true
}
// cachePutList stores list as JSON under (class, key) with ttl. Used
// by list commands after fetching from source.
func cachePutList(class, key string, list any, ttl time.Duration) {
val, err := json.Marshal(list)
if err != nil {
slog.Warn("cache: marshal failed during populate", "class", class, "err", err)
return
}
cachePopulate(class, key, val, ttl)
}
// Per-class TTLs (P00-T2).
const (
cacheNodeTTL = 30 * time.Second
cacheJobTTL = 10 * time.Second
cacheNamespaceTTL = 60 * time.Second
cacheNodeClass = "nodes"
cacheJobClass = "jobs"
cacheNamespaceClass = "namespaces"
cacheListKey = "list"
)
// --- `orca cache` CLI (P00-T3) ---
var cacheCmd = &cobra.Command{
Use: "cache",
Short: "Inspect or invalidate the orca CLI cache",
Long: `Manage the CLI-side SQLite cache (R-008) at
` + "`" + `ORCA_HOME/orca_cache.db` + "`" + `.
Subcommands:
show — print per-class entry counts, total size, oldest entry
invalidate <c> — drop all entries for a class (e.g. "nodes", "jobs")
invalidate-all — drop every entry in the cache
Read-only list commands (node/job/ns list) populate the cache; writes
bypass it. The --watch flag bypasses the cache entirely (streaming).`,
}
var cacheShowCmd = &cobra.Command{
Use: "show",
Short: "Print cache stats (per-class counts, sizes, oldest entry)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := cache.Open(paths.CacheDB())
if err != nil {
return fmt.Errorf("open cache: %w", err)
}
defer c.Close()
stats, err := c.Stats()
if err != nil {
return fmt.Errorf("cache stats: %w", err)
}
if jsonOutput {
return printJSON(stats)
}
out := cmd.OutOrStdout()
if len(stats) == 0 {
fmt.Fprintln(out, "Cache is empty.")
return nil
}
fmt.Fprintf(out, "%-20s %-8s %-12s %s\n", "CLASS", "COUNT", "BYTES", "OLDEST")
var totalCount, totalBytes int64
for _, s := range stats {
oldest := time.Unix(0, s.OldestAt).UTC().Format(time.RFC3339)
if s.OldestAt == 0 {
oldest = "-"
}
fmt.Fprintf(out, "%-20s %-8d %-12d %s\n", s.Class, s.Count, s.Bytes, oldest)
totalCount += int64(s.Count)
totalBytes += s.Bytes
}
fmt.Fprintf(out, "%-20s %-8d %-12d\n", "TOTAL", totalCount, totalBytes)
return nil
},
}
var cacheInvalidateCmd = &cobra.Command{
Use: "invalidate <class>",
Short: "Drop all entries for a cache class",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
class := args[0]
c, err := cache.Open(paths.CacheDB())
if err != nil {
return fmt.Errorf("open cache: %w", err)
}
defer c.Close()
if err := c.Invalidate(class); err != nil {
return fmt.Errorf("invalidate %s: %w", class, err)
}
if jsonOutput {
return printJSON(map[string]string{"class": class, "status": "invalidated"})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Cache invalidated: %s\n", class)
return nil
},
}
var cacheInvalidateAllCmd = &cobra.Command{
Use: "invalidate-all",
Short: "Drop every entry in the cache",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := cache.Open(paths.CacheDB())
if err != nil {
return fmt.Errorf("open cache: %w", err)
}
defer c.Close()
if err := c.InvalidateAll(); err != nil {
return fmt.Errorf("invalidate-all: %w", err)
}
if jsonOutput {
return printJSON(map[string]string{"status": "invalidated"})
}
fmt.Fprintln(cmd.OutOrStdout(), "✓ Cache cleared.")
return nil
},
}
func init() {
cacheCmd.AddCommand(cacheShowCmd)
cacheCmd.AddCommand(cacheInvalidateCmd)
cacheCmd.AddCommand(cacheInvalidateAllCmd)
rootCmd.AddCommand(cacheCmd)
}