You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
InMemoryCacheProvider.TryGet can delete a fresh value that another thread just Set, when it sees an expired entry for the same key #47
InMemoryCacheProvider.TryGet (Essentials.CacheProviders.InMemory/InMemoryCacheProvider.cs, lines 26-35) reads an entry, sees that it has expired, and then removes whatever is currently stored under that key:
if(cache.TryGetValue(key,outCacheEntry?entry)){if(entry.Expirationisnull||entry.Expiration>DateTime.UtcNow){ ...return true;}// Entry has expired, remove it
cache.TryRemove(key,out_);}
Nothing prevents another thread from calling Set for the same key between the TryGetValue and the TryRemove, in which case the new, non-expired entry is deleted.
Thread B: Set(k, v2) (no expiration) → cache[k] = E2.
Thread A: cache.TryRemove(k, out _) → removes E2.
Thread B: TryGet(k) returns false. The value it just set, which never expires, is gone, and GetOrAdd will call the factory again.
How it was verified
Stress program: one thread calls TryGet("k") in a tight loop; a second repeats Set("k", -1, TimeSpan.FromTicks(-1)); Set("k", i); if (!TryGet("k", out _)) lost++;. Runs against HEAD on a 4-core machine reported fresh non-expiring entries lost: 3 / 200000, then 16, 2 and 0 over three more runs. Correct code must always report 0.
Related failure in the same file
Set(key, value, TimeSpan.MaxValue) throws ArgumentOutOfRangeException from DateTime.UtcNow + expiration.Value (line 50) instead of storing an entry that never expires.
Suggested fix / acceptance criteria
Remove only the entry that was observed to be expired. On .NET 5+ use cache.TryRemove(new KeyValuePair<TKey, CacheEntry>(key, entry)); on netstandard2.1 use ((ICollection<KeyValuePair<TKey, CacheEntry>>)cache).Remove(...). Both compare the value, so a concurrently set replacement is untouched.
Saturate the expiration computation: treat overflow / TimeSpan.MaxValue as no expiration.
A concurrency test like the one above reports zero lost entries.
Priority: Medium. A race can delete a freshly Set, non-expiring cache entry, so the value is lost and the factory re-runs. It is rare but real (reproduced under stress). The TimeSpan.MaxValue overflow in Set is a second, deterministic bug in the same file.
Area / suggested owner:Essentials.CacheProviders.InMemory
Duplicates / in progress: None found. No open PR.
Notes: Use the value-comparing remove (TryRemove(KeyValuePair) on .NET 5+, ICollection<KVP>.Remove on netstandard2.1). Treat expiration overflow as "never expires". Both fit in one PR.
Activity
matt-edmondson commentedon Sep 27, 2026
Triage
Set, non-expiring cache entry, so the value is lost and the factory re-runs. It is rare but real (reproduced under stress). TheTimeSpan.MaxValueoverflow inSetis a second, deterministic bug in the same file.Essentials.CacheProviders.InMemoryTryRemove(KeyValuePair)on .NET 5+,ICollection<KVP>.Removeon netstandard2.1). Treat expiration overflow as "never expires". Both fit in one PR.Generated by Claude Code