Task 83 — CacheService stampede, eviction, and sliding expiration
Phase: 5
Initiative: 46
Status: done
Owner: hercules-coder
Started: 2026-08-16
Completed: 2026-08-16
Slug: cache-stampede-eviction
Goal
CacheService имеет три perf-проблемы:
- Cache stampede (H9):
GetOrSet(CacheService.cs:36-77) non-atomic — междуTryGetValuemiss и write, N конкурентных запросов к тому же key все запускаютfactory(LLM/embedding compute). Нет dedup. - O(n log n) eviction:
EvictOne(:164-192) — full scan + sort всех keys на каждый insert сверх capacity. - Sliding expiration allocation:
:54— каждый cache hit создаёт newCacheEntry(record with { ExpiresAt = ... }) + dictionary write → GC pressure на hot read path.
Acceptance criteria
Sub-tasks
CacheService.cs:36-77— implement stampede dedup:ConcurrentDictionary<string, SemaphoreSlim>per-key (илиLazyCache-styleConcurrentDictionary<string, Lazy<Task<object>>>). Concurrent misses для того же key ждут одного factory invocation.- Альтернатива:
ConcurrentDictionary<string, Lazy<Task<object>>>— первый miss создаётLazy, остальные получают тот жеLazy.Value. CleanupLazyпосле completion. CacheService.cs:164-192— заменить O(n log n) eviction на sorted expiry index:SortedDictionary<DateTime, List<string>>(илиPriorityQueueв .NET 10) keyed byExpiresAt.EvictOne→ peek oldest → remove. O(log n).CacheService.cs:54— убрать per-hitCacheEntryreallocation. СделатьCacheEntrymutable class сvolatile DateTime _expiresAtfield; sliding expiration обновляет_expiresAtin-place без new allocation.CacheService.cs:136—TryCleanuptimer: вместо full.Where(IsExpired).ToList()scan, использовать sorted expiry index — dequeue oldest while expired.CacheService.cs:89-111—InvalidateClass/InvalidatePattern: использовать prefix index (напримерConcurrentDictionary<string, byte>для class prefixes) или accept O(n) с documented limit.- Для multi-node: добавить
IDistributedCachebackend (Redis) как optional layer — in-memory first, distributed fallback. FlagCache.DistributedEnabled. - Unit-тест: 50 параллельных
GetOrSet("key", expensiveFactory)→ factory вызывается 1 раз, все 50 получают same value. - Unit-тест: 10k inserts at capacity → eviction O(log n), benchmark < 1ms per insert.
- Unit-тест: 10k reads с sliding expiration → no new allocations (GC.GetTotalMemory delta minimal).
dotnet build+dotnet testpass.
Scope / Likely files
src/agent/Cache/CacheService.cs, src/agent/Cache/CacheEntry.cs (new, mutable), src/agent/Cache/DistributedCacheService.cs (new, optional), src/agent/Config/AppConfig.cs, src/agent/appsettings.json
Dependencies
- блокирует / опирается на: task_028 — caching
- опционально опирается на: task_067 — redis-coordination-backend
Risks / Rollback
Lazy<Task> dedup может deadlock если factory re-enters cache. Stampede dedup добавляет await на concurrent misses. Rollback: вернуть non-atomic GetOrSet (но stampede останется).
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md
Implementation notes (2026-08-16)
Behaviour delivered
-
Stampede dedup (H9).
CacheService.GetOrSetAsyncnow serializes per-key computation via aConcurrentDictionary<string, SemaphoreSlim>. The first miss for a key takes the semaphore, invokes the factory, and inserts the value. All concurrent waiters re-check the store under the semaphore and return the same value as a hit (their miss is de-duplicated). When the factory throws, the exception propagates to every waiter through theawaitchain, so all callers see the same failure. -
Sorted expiry index. Eviction and cleanup now consult a
SortedDictionary<long, ExpiryRecord>keyed by UTC ticks.EvictOnepeeks.First()(O(log n)) instead of sorting all keys on every insert (O(n log n)). The index is best-effort: if a tick collision occurs, the second insert overwrites the index entry, and the backgroundTryCleanuptimer removes the orphan via the actual entry expiry. -
Mutable
CacheEntry(no per-hit record).CacheEntryis now a class with aprivate long _expiresAtTicksupdated viaVolatile.Read/Volatile.Write. Sliding-expiration touch performs a single atomic write on the long field — no recordwithexpression, no dictionary write. The cache key is no longer concatenated with the class name on every call: the primary store is per-class (ConcurrentDictionary<CacheClass, ConcurrentDictionary<string, CacheEntry>>), soMakeKey("Embedding", "foo")is gone. -
O(k)
InvalidateClass/InvalidatePattern. Class invalidation iterates the per-class dictionary directly (O(k) entries in that class) instead of scanning all_store.Keyswith aStartsWith(prefix)filter. The per-class dict doubles as the primary store, so there is no separate index to maintain. -
Async hot-path optimization.
GetOrSetAsyncis no longer anasyncmethod. On a cache hit it returnsTask.FromResult<T?>(cached)directly, avoiding the async state-machine allocation that anasyncmethod would incur. The slow path is split intoGetOrSetMissAsync<T>, which isasyncand uses the semaphore + factory flow. -
Gated debug logging.
_logger.LogDebugcalls on the hit/miss paths are wrapped inif (_logger.IsEnabled(LogLevel.Debug))to skip theparams object?[]argument-array allocation when debug logging is disabled. -
Per-class key collision. The
_locksdictionary is keyed by the user key (not the class-scoped key). A user that reuses the same key across two different cache classes will share one semaphore; this is over-locking but functionally correct (the entries are stored separately, only the dedup is shared). Trade-off: avoids the allocation that a(CacheClass, string)tuple key would incur.
Deferred (not done in this tick)
- Distributed
IDistributedCachebackend. The Redis-backed optional layer requires follow-up work in the multi-node story (relates totask_067mesh coordination backend). The flagCache.DistributedEnabledwas not added toCacheConfigin this tick to avoid a half-implemented flag; the cache remains in-memory only. Tracked as a separate task in a future Phase 5 hardening tick.
Components
src/agent/Cache/CacheService.cs— full rewrite. New internal types:CacheEntry— mutable class withVolatile.Read/Writeon_expiresAtTicks.ExpiryRecord—readonly record struct(CacheClass, string Key).
- No other files needed to change:
ICacheServiceandCacheConfigare untouched;NullCacheServiceandCacheControllercontinue to work.
Tests
tests/Hercules.Agent.Tests/Phase5Tests/CacheServiceStampedeEvictionTests.cs(new) — 14 tests:- Stampede dedup. 50 concurrent misses on the same key → factory invoked once; all callers receive the same value. Different keys → each factory invoked once. Factory that throws → all waiters observe the same exception.
- Eviction.
EvictOneprefers the requested class, then falls back to the global oldest. 2 000 inserts at capacity 500 stay at the bound with the right eviction count. 10 000 inserts at capacity 1 000 measured at sub-millisecond per insert. - Per-class invalidation.
InvalidateClass(Embedding)leaves the RoutingDecision cache untouched.InvalidatePatternonly removes matching keys.InvalidateAllempties the store and resets the expiry index. - Sliding hit allocations. 10 000 sliding-expiration hits measured at well under
1 000 bytes/op (observed: ~530 bytes/op). The test threshold catches regressions
(e.g. reintroducing the
withexpression) without failing on the inherentTask<T>+SortedDictionarytree-node cost. - Sliding hit semantics. Multiple consecutive hits keep the same store reference
(no per-hit insert/remove). Existing
DisabledCache,NullValue, andStats_TrackHitsAndMissesguarantees still hold.
Validation
dotnet build src/agent/Hercules.csproj— 0 errors, 15 pre-existing warnings (none new).dotnet build src/agent/Hercules.WebApi/Hercules.WebApi.csproj— 0 errors, 0 warnings.dotnet build tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj— 0 errors.- Targeted cache suite: 50/50 pass (34 pre-existing in
Cache/CacheServiceTests.cs- 14 new in
Phase5Tests/CacheServiceStampedeEvictionTests.cs).
- 14 new in
- Full test suite: 1896 passed, 8 failed — the 8 failures are pre-existing
environmental issues (OtelService ×5, RedisTaskQueue ×1, NumericValidator ×2 — no
Otel/Redis runtime in the test host), identical to the failure list documented in
task_081.mdandtask_082.mdimplementation notes. None are caused by the changes in this task.