Skip to content

fix(agfs-fuse): bound metadata caches and stop cleanup goroutine cleanly - #24

Merged
c4pt0r merged 1 commit into
masterfrom
fix/agfs-fuse-bounded-cache
May 11, 2026
Merged

fix(agfs-fuse): bound metadata caches and stop cleanup goroutine cleanly#24
c4pt0r merged 1 commit into
masterfrom
fix/agfs-fuse-bounded-cache

Conversation

@c4pt0r

@c4pt0r c4pt0r commented May 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • add optional LRU entry bounds to the FUSE cache package
  • add deterministic Stop() / Done() shutdown for cache cleanup goroutines
  • wire bounded metadata and directory cache defaults into AGFSFS
  • stop cache cleanup goroutines during filesystem close

Verification

Notes

Cache caps are configurable through fusefs.Config; CLI flags for runtime tuning are a non-blocking follow-up if operators need them.

`agfs-fuse/pkg/cache.Cache` was TTL-only with no upper bound and no
shutdown signal for its background cleanup goroutine:

- Long-lived mounts could grow memory without bound between TTL ticks
  whenever the access pattern produced distinct keys faster than they
  expired (e.g. crawling a large tree).
- `Cache.cleanup` ran on `for range ticker.C` with no exit path, so the
  goroutine outlived the cache for the rest of the process lifetime.

This change adds optional LRU bounding and a deterministic shutdown
signal, and opts the FUSE filesystem into both with conservative
defaults.

Production changes:

- `pkg/cache/cache.go`:
  * `Cache` is now backed by `container/list` + a map of
    `key -> *list.Element` so eviction is O(1) and `Get` promotes the
    entry to the front of the LRU list on hit. The contained `entry`
    embeds its key so eviction can locate its map slot without scanning.
  * Functional-options pattern: `NewCache(ttl, opts ...Option)`. The
    only option today is `WithMaxEntries(n)`; <= 0 keeps the legacy
    unbounded behaviour for direct callers that haven't opted in.
  * `Set` evicts the LRU entry when over capacity. Updating an existing
    key is treated as an in-place update — it does not count as a new
    insertion for capacity purposes and does not evict a still-present
    sibling.
  * `Get` on an expired entry returns `(nil, false)` and prunes it
    immediately rather than waiting for the next sweep, so callers
    between ticker fires still see correct expiration semantics.
  * `Stop()` (sync.Once-guarded) closes a `stop` channel; the cleanup
    goroutine ranges on a `select` that exits when `stop` is closed.
    As the last thing on its way out, `cleanup` closes a `done`
    channel exposed via `Cache.Done()` so callers can wait
    deterministically — useful for tests and strict shutdown ordering.
  * Added a public `Len()` helper that returns the current entry count
    (mainly for tests, but also useful for telemetry).
  * `MetadataCache` and `DirectoryCache` now thread `Option`s through to
    the underlying `Cache` and expose their own `Stop()` for the
    containing filesystem to call on shutdown.

- `pkg/fusefs/fs.go`:
  * `AGFSFS.Close()` now calls `metaCache.Stop()` / `dirCache.Stop()`
    in addition to clearing them, so the FUSE filesystem shuts down
    without leaking the cache cleanup goroutines.
  * `Config` exposes `MetaCacheMaxEntries` and `DirCacheMaxEntries`,
    both configurable. Defaults are conservative: 50_000 metadata
    entries and 5_000 directory listings. Rationale:
    - 50_000 metadata × ~200 B/entry (rough `FileInfo` size, name +
      a few ints) ≈ 10 MB worst-case for the metadata path;
    - directory listings carry slices of `FileInfo`, so the cap is an
      order of magnitude tighter to keep the total bounded;
    - both leave comfortable headroom for interactive workloads while
      preventing the unbounded-growth failure mode.
    Override via `Config` if a deployment needs more or less.

Tests (additive — all existing cache tests still pass unchanged):

- `TestCacheLRUEviction` — Set a→1, b→2, Get(a), Set c=3. b is now LRU
  (since a was just touched) and gets evicted. Asserts a/c survive,
  Len() == 2.
- `TestCacheLRUDoesNotEvictWithoutLimit` — backwards-compat pin: the
  legacy `NewCache(ttl)` path still admits unlimited entries.
- `TestCacheUpdateInPlaceDoesNotEvict` — Set a/b, Set a again. a is
  still 99 and b is still present (eviction would have been wrong).
- `TestCacheStopExitsCleanupGoroutine` — calls Stop, asserts
  `<-Done()` returns within 2s. Deterministic shutdown signal, no
  `runtime.NumGoroutine` polling, no flakes.
- `TestCacheStopIdempotent` — 8 goroutines call Stop concurrently;
  asserts no panic and Done() still closes. Pins the sync.Once guard.
- `TestCacheExpiredEntryReturnsNotFound` — TTL 10ms; sleep 50ms; Get
  on the expired key returns (nil, false) and Len() == 0 even though
  the sweeper may not have run yet.
- `TestMetadataCacheStop` / `TestDirectoryCacheStop` — confirm the
  wrapper types forward Stop() to the underlying Cache.

Verification:

  go test ./pkg/cache -v -timeout 30s             # 14 PASS
  go test ./... -timeout 60s                      # all PASS
  go test -race ./pkg/cache -timeout 30s          # PASS

Closes the FUSE half of the diagnostics P0 ("bound FUSE cache and stop
cleanup goroutine cleanly").
@c4pt0r
c4pt0r merged commit ea7f288 into master May 11, 2026
@c4pt0r
c4pt0r deleted the fix/agfs-fuse-bounded-cache branch May 11, 2026 15:18
@c4pt0r c4pt0r mentioned this pull request May 11, 2026
c4pt0r added a commit that referenced this pull request May 11, 2026
Merged task #24 after cross-review PASS from @Dev-1 and Cindy final verification. Tests: scripts/e2e/run-core-e2e.sh; bash -n scripts/e2e/run-core-e2e.sh; git diff --check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant