package http import ( "context" "sync" "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" ) // treeCacheTTL is how long a per-session cached directory listing // stays valid. The whole point of the cache is to make re-expanding // nodes within the same wizard session snappy; 30 minutes covers a // generous wizard interaction window without holding stale data // indefinitely. const treeCacheTTL = 30 * time.Minute // treeCacheKey identifies one cached listing. session_id scopes // entries to a single browser session so two operators don't share // view state; snapshot_id + path identify the directory inside the // snapshot. type treeCacheKey struct { SessionID string HostID string SnapshotID string Path string } type treeCacheEntry struct { Result api.TreeListResultPayload ExpiresAt time.Time } // treeCache is a per-process map of synchronously fetched directory // listings. Concurrency is light (a few entries per active wizard // session) so a single mutex is fine. type treeCache struct { mu sync.Mutex entries map[treeCacheKey]treeCacheEntry } func newTreeCache() *treeCache { return &treeCache{entries: make(map[treeCacheKey]treeCacheEntry)} } // Get returns a cached entry if one exists and hasn't expired. func (c *treeCache) Get(k treeCacheKey, now time.Time) (api.TreeListResultPayload, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[k] if !ok { return api.TreeListResultPayload{}, false } if now.After(e.ExpiresAt) { delete(c.entries, k) return api.TreeListResultPayload{}, false } return e.Result, true } // Put records a fresh listing under k. Caller is responsible for // having validated the result first (Error == ""). func (c *treeCache) Put(k treeCacheKey, result api.TreeListResultPayload, now time.Time) { c.mu.Lock() c.entries[k] = treeCacheEntry{ Result: result, ExpiresAt: now.Add(treeCacheTTL), } c.mu.Unlock() } // Sweep deletes expired entries. Called opportunistically from the // wizard handler — no separate goroutine needed; cache size is small. func (c *treeCache) Sweep(now time.Time) { c.mu.Lock() for k, e := range c.entries { if now.After(e.ExpiresAt) { delete(c.entries, k) } } c.mu.Unlock() } // fetchTreeWithCache returns a directory listing — cache hit, or a // synchronous tree.list RPC against the agent on miss. On agent error // (not transport error), the result is returned as-is with Error set // rather than cached, so a transient failure doesn't poison subsequent // requests for the same path. // //nolint:unused // wired in by the wizard handler in the next slice func (s *Server) fetchTreeWithCache(ctx context.Context, sessionID, hostID, snapshotID, path string) (api.TreeListResultPayload, error) { now := time.Now() k := treeCacheKey{SessionID: sessionID, HostID: hostID, SnapshotID: snapshotID, Path: path} if cached, ok := s.treeCache.Get(k, now); ok { return cached, nil } reply, err := s.deps.Hub.SendRPC(ctx, hostID, api.MsgTreeList, api.TreeListRequestPayload{SnapshotID: snapshotID, Path: path}, 30*time.Second) if err != nil { return api.TreeListResultPayload{}, err } var result api.TreeListResultPayload if perr := reply.UnmarshalPayload(&result); perr != nil { return api.TreeListResultPayload{}, perr } if result.Error == "" { s.treeCache.Put(k, result, now) } return result, nil }