Skip to main content

Multi-instance list-cache: IListCache (Redis-backed by default)

Confirmed 2026-08-26 that at least one client deployment runs multiple ADO Environment VMs behind the same client's Api instance — meaning IMemoryCache, used per the Reference CRUD pattern's caching step, has a real staleness bug: a write on instance A invalidates only A's cache; instances B/C keep serving the pre-write list for up to the full TTL.

:::note Scope was wider than expected Only City/Country/Choice inherit MasListRepositoryBase; 32 more Mas* repositories independently hand-rolled the identical IMemoryCache.GetOrCreateAsync/Cache.Remove pattern inline — 35 files total, not ~20. Full rollout completed the same day as the pilot. :::

Building blocks

  • IListCache (Application/Common/Interfaces/) — the abstraction every Mas* repository injects instead of IMemoryCache: GetOrCreateAsync<T>(key, factory, ttl, ct) / RemoveAsync(key, ct), generic over IReadOnlyList<T>.
  • RedisListCache (Infrastructure/Caching/) — the distributed implementation, same lazy-connect-on-first-use singleton shape as RedisPendingSsoLoginStore. Reuses the Redis connection settings already on MAS_SYSCONFIG rather than a second config mechanism; values are JSON-serialized into Redis strings keyed {prefix}ListCache:{key} with Redis's own TTL replacing AbsoluteExpirationRelativeToNow. No distributed lock around a cache-miss factory run — a brief thundering-herd re-run of the same idempotent DB query on concurrent misses is the same behavior IMemoryCache's own GetOrCreateAsync already had per-process.
  • MemoryListCache (Infrastructure/Caching/) — thin IMemoryCache wrapper, the single-instance fallback for local dev with no Redis running.
  • Caching:ListCache config toggle, same convention as Sso:PendingLoginStore/Auth:RefreshTokenStore: "Memory" forces the fallback, "Redis" forces Redis, anything else (including unset) defaults to Redis outside Development, Memory inside it.
  • MasListRepositoryBase<TEntity, TDto> takes IListCache instead of IMemoryCache; derived repositories' write paths call await Cache.RemoveAsync(key, ct) (was the synchronous Cache.Remove(key)).

A handful of methods cache a single value rather than a list (AppSettingRepository.GetStringValueAsync, LabelTranslationRepository.ResolveAsync, a few others) — IListCache has no scalar overload, so each wraps its value in a 1-element list and unwraps it after the call.

:::caution The scaffold tool hasn't been updated backend/tools/Logiswift.Eos.Scaffold/Generators/RepositoryGenerator.cs still generates raw IMemoryCache usage — not yet updated to emit IListCache. A newly scaffolded entity needs this swap done by hand. :::

:::danger Not verified No live test against an actual multi-instance deployment or a real Redis connection exists yet — same standing "shipped, unexecuted against real infrastructure" caveat as several other items in this codebase. :::

Reference files

IListCache.cs, RedisListCache.cs, MemoryListCache.cs, MasListRepositoryBase.cs, CityRepository.cs/CountryRepository.cs/ ChoiceRepository.cs (the pilot), RedisPendingSsoLoginStore.cs (the connection-pattern precedent), DependencyInjection.cs (ShouldUseRedisListCache).