package netbox import ( "context" "sync" "sync/atomic" "time" "git.unkin.net/unkin/bootapi/internal/model" ) // Cache wraps a Resolver with a short-TTL in-memory cache. PXE boots come in // bursts (iPXE fetches the boot script, then the kickstart, then package repos // hit repeatedly), so even a 30s TTL collapses many NetBox lookups per host // while keeping the data fresh enough that a re-provisioned host picks up // changes on its next boot. type Cache struct { inner Resolver ttl time.Duration now func() time.Time // injectable for tests mu sync.Mutex entries map[string]cacheEntry hits atomic.Int64 misses atomic.Int64 } // Hits returns the cumulative cache-hit count (published as a metric). func (c *Cache) Hits() int64 { return c.hits.Load() } // Misses returns the cumulative cache-miss count (published as a metric). func (c *Cache) Misses() int64 { return c.misses.Load() } type cacheEntry struct { host *model.Host exp time.Time } // NewCache wraps inner with a TTL cache. A non-positive ttl disables caching. func NewCache(inner Resolver, ttl time.Duration) *Cache { return &Cache{ inner: inner, ttl: ttl, now: time.Now, entries: map[string]cacheEntry{}, } } // HostByMAC returns a cached host or resolves and caches one. func (c *Cache) HostByMAC(ctx context.Context, mac string) (*model.Host, error) { return c.lookup(ctx, "mac:"+normalizeMAC(mac), func() (*model.Host, error) { return c.inner.HostByMAC(ctx, mac) }) } // HostByName returns a cached host or resolves and caches one. func (c *Cache) HostByName(ctx context.Context, name string) (*model.Host, error) { return c.lookup(ctx, "name:"+name, func() (*model.Host, error) { return c.inner.HostByName(ctx, name) }) } func (c *Cache) lookup(_ context.Context, key string, resolve func() (*model.Host, error)) (*model.Host, error) { if c.ttl <= 0 { return resolve() } now := c.now() c.mu.Lock() if e, ok := c.entries[key]; ok && now.Before(e.exp) { c.mu.Unlock() c.hits.Add(1) return e.host, nil } c.mu.Unlock() c.misses.Add(1) // Resolve outside the lock so a slow NetBox call doesn't block cache hits. host, err := resolve() if err != nil { return nil, err } c.mu.Lock() c.entries[key] = cacheEntry{host: host, exp: now.Add(c.ttl)} c.mu.Unlock() return host, nil }