Fix host resolution and concurrent cache updates

This commit is contained in:
Sebastian Unterschütz committed 2026-10-27 22:23:59 +01:00
1 parent 92b1c40cb8
commit fa7446e5f7
11 files changed
+648 -113

No files matched your search

+104 -15
View File
@@ -2,13 +2,17 @@ package cache
import (
"encoding/json"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
type Entry struct {
@@ -20,8 +24,18 @@ type Entry struct {
LastUsed time.Time `json:"last_used,omitempty"`
}
type changeKind uint8
const (
upsertChange changeKind = iota
deleteChange
usageChange
)
type Cache struct {
mu sync.RWMutex
dirty map[string]changeKind
cleared bool
entries map[string]Entry
path string
ttl time.Duration
@@ -39,6 +53,7 @@ func New(path string, ttlSeconds int) *Cache {
}
return &Cache{
entries: make(map[string]Entry),
dirty: make(map[string]changeKind),
path: path,
ttl: time.Duration(ttlSeconds) * time.Second,
refreshStamp: stamp,
@@ -94,6 +109,8 @@ func (c *Cache) Load() error {
return err
}
c.dirty = make(map[string]changeKind)
c.cleared = false
c.entries = make(map[string]Entry, len(df.Entries))
for _, e := range df.Entries {
c.entries[e.Name] = e
@@ -101,23 +118,80 @@ func (c *Cache) Load() error {
return nil
}
// Save merges only local changes under a process-wide file lock. Atomic rename
// alone prevents torn JSON but cannot prevent lost updates from another process.
func (c *Cache) Save() error {
c.mu.RLock()
df := diskFormat{Entries: make([]Entry, 0, len(c.entries))}
for _, e := range c.entries {
df.Entries = append(df.Entries, e)
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
return err
}
lock, err := os.OpenFile(c.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer lock.Close()
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
return err
}
defer unix.Flock(int(lock.Fd()), unix.LOCK_UN)
merged := make(map[string]Entry)
if !c.cleared {
data, err := os.ReadFile(c.path)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
var df diskFormat
if err := json.Unmarshal(data, &df); err != nil {
return err
}
for _, e := range df.Entries {
merged[e.Name] = e
}
}
}
for name, change := range c.dirty {
if change == deleteChange {
delete(merged, name)
continue
}
e := c.entries[name]
old, exists := merged[name]
if change == usageChange && !exists {
// A connection finishing after a refresh or clear must not
// restore an entry that the other process removed.
continue
}
if exists {
lastUsed := e.LastUsed
if change == usageChange || old.CachedAt.After(e.CachedAt) {
e = old
}
if old.LastUsed.After(lastUsed) {
lastUsed = old.LastUsed
}
e.LastUsed = lastUsed
}
merged[name] = e
}
df := diskFormat{Entries: make([]Entry, 0, len(merged))}
for _, e := range merged {
df.Entries = append(df.Entries, e)
}
sort.Slice(df.Entries, func(i, j int) bool { return df.Entries[i].Name < df.Entries[j].Name })
data, err := json.MarshalIndent(df, "", " ")
if err != nil {
return err
}
return storage.WriteFile(c.path, data, 0o600)
if err := storage.WriteFile(c.path, data, 0o600); err != nil {
return err
}
c.entries = merged
c.dirty = make(map[string]changeKind)
c.cleared = false
return nil
}
func (c *Cache) Upsert(e Entry) {
@@ -127,9 +201,18 @@ func (c *Cache) Upsert(e Entry) {
e.LastUsed = old.LastUsed
}
c.entries[e.Name] = e
c.dirty[e.Name] = upsertChange
c.mu.Unlock()
}
// Delete removes an entry when a refresh can no longer resolve it safely.
func (c *Cache) Delete(name string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, name)
c.dirty[name] = deleteChange
}
// MarkUsed records the current time as LastUsed for the named entry.
// It is a no-op if the entry does not exist.
func (c *Cache) MarkUsed(name string) {
@@ -138,6 +221,9 @@ func (c *Cache) MarkUsed(name string) {
if e, ok := c.entries[name]; ok {
e.LastUsed = time.Now()
c.entries[name] = e
if _, changed := c.dirty[name]; !changed {
c.dirty[name] = usageChange
}
}
}
@@ -179,19 +265,20 @@ func (c *Cache) Search(prefix string) []Entry {
return out
}
// GetByShortcut scans all entries and returns the first whose normalized name matches
// the normalized shortcut. Returns (entry, found, fresh).
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool) {
// GetByShortcut rejects collisions instead of choosing a random map entry.
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool, err error) {
c.mu.RLock()
defer c.mu.RUnlock()
norm := normalize(shortcut)
for _, e := range c.entries {
if normalize(e.Name) == norm {
isFresh := c.ttl > 0 && time.Since(e.CachedAt) < c.ttl
return e, true, isFresh
if found {
return Entry{}, false, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", shortcut)
}
entry, found = e, true
}
}
return Entry{}, false, false
return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil
}
// Get returns an entry and reports whether it is still within the TTL.
@@ -212,6 +299,8 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) {
func (c *Cache) Clear() {
c.mu.Lock()
c.entries = make(map[string]Entry)
c.dirty = make(map[string]changeKind)
c.cleared = true
c.mu.Unlock()
}