b6d4db1a96
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---
219 lines
6.4 KiB
Go
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)
|
|
}
|