66using System . Reflection ;
77using System . Text . Json ;
88using System . Threading ;
9+ using System . Threading . Tasks ;
910
1011namespace CIPP
1112{
1213 /// <summary>
13- /// Process-scoped, thread-safe LRU cache for test data lookups.
14- /// Shared across all PowerShell runspaces. Bounded by both a byte-size
15- /// cap (default 50 MB) and a short TTL (default 1 minute) so that test
16- /// suites running against a single tenant get fast cache hits without
17- /// accumulating stale Gen2 roots that cause GC thrashing.
14+ /// Host-scoped, thread-safe LRU cache for test data lookups. The DLL is
15+ /// loaded once per Azure Functions host, so every PowerShell worker
16+ /// process on that host shares this exact instance. Bounded by both a
17+ /// byte-size cap (default 100 MB) and a short TTL (default 5 minutes)
18+ /// so that test suites running against a single tenant get fast cache
19+ /// hits without accumulating stale Gen2 roots that cause GC thrashing.
1820 /// </summary>
1921 public static class TestDataCache
2022 {
@@ -28,10 +30,12 @@ public static class TestDataCache
2830 private static readonly Dictionary < string , LinkedListNode < string > > _lruIndex = new ( ) ; // key → node
2931 private static readonly object _lruLock = new ( ) ;
3032 private static long _currentBytes ;
31- private static int _accessCount ;
33+ private static long _accessCount ;
3234 private static long _hits ;
3335 private static long _misses ;
3436 private static long _evictions ;
37+ private static long _oversized ;
38+ private static int _sweepInFlight ; // 0 = idle, 1 = a background ClearExpired is running
3539
3640 private sealed class CacheEntry
3741 {
@@ -60,7 +64,11 @@ public static void Configure(long maxBytes = 100L * 1024 * 1024, int ttlSeconds
6064
6165 public static bool TryGet ( string key , out object ? value )
6266 {
63- Interlocked . Increment ( ref _accessCount ) ;
67+ var count = Interlocked . Increment ( ref _accessCount ) ;
68+ // Every ~1000 accesses, kick off a background sweep so TTL-expired
69+ // entries that nobody re-reads still get evicted. CAS-guarded so
70+ // overlapping triggers collapse to a single sweep.
71+ if ( ( count % 1000 ) == 0 ) TryFireBackgroundSweep ( ) ;
6472
6573 if ( _cache . TryGetValue ( key , out var entry ) && ! entry . IsExpired )
6674 {
@@ -95,9 +103,14 @@ public static void Set(string key, object? value)
95103 int itemCount = value is ICollection col ? col . Count : 0 ;
96104 long sizeBytes = EstimateValueSize ( value , itemCount ) ;
97105
98- // If a single entry exceeds the cap, don't cache it at all
106+ // If a single entry exceeds the cap, don't cache it at all — bump
107+ // _oversized so GetDiagnostics surfaces these silent drops instead
108+ // of leaving callers to chase phantom misses.
99109 if ( sizeBytes > _maxBytes )
110+ {
111+ Interlocked . Increment ( ref _oversized ) ;
100112 return ;
113+ }
101114
102115 // Remove existing entry for this key first
103116 if ( _cache . ContainsKey ( key ) )
@@ -170,6 +183,7 @@ public static void Clear()
170183 Interlocked . Exchange ( ref _hits , 0 ) ;
171184 Interlocked . Exchange ( ref _misses , 0 ) ;
172185 Interlocked . Exchange ( ref _evictions , 0 ) ;
186+ Interlocked . Exchange ( ref _oversized , 0 ) ;
173187 }
174188
175189 /// <summary>
@@ -191,6 +205,40 @@ public static int ClearTenant(string tenantFilter)
191205 return removed ;
192206 }
193207
208+ /// <summary>
209+ /// Remove every entry whose TTL has elapsed. Pair to the lazy per-key
210+ /// eviction in TryGet — handles keys that nobody reads again. Safe to
211+ /// call from anywhere; the background sweep triggered by TryGet uses
212+ /// this method.
213+ /// </summary>
214+ public static int ClearExpired ( )
215+ {
216+ // Snapshot first; RemoveEntry mutates _cache and _lruIndex under _lruLock.
217+ var expiredKeys = new List < string > ( ) ;
218+ foreach ( var kvp in _cache )
219+ {
220+ if ( kvp . Value . IsExpired ) expiredKeys . Add ( kvp . Key ) ;
221+ }
222+ foreach ( var key in expiredKeys ) RemoveEntry ( key ) ;
223+ return expiredKeys . Count ;
224+ }
225+
226+ /// <summary>
227+ /// Fire-and-forget a single background ClearExpired sweep. The CAS guard
228+ /// collapses overlapping triggers so we never have more than one sweep
229+ /// running at a time, regardless of read pressure.
230+ /// </summary>
231+ private static void TryFireBackgroundSweep ( )
232+ {
233+ if ( Interlocked . CompareExchange ( ref _sweepInFlight , 1 , 0 ) != 0 ) return ;
234+ Task . Run ( ( ) =>
235+ {
236+ try { ClearExpired ( ) ; }
237+ catch { /* swallow — sweep is best-effort */ }
238+ finally { Interlocked . Exchange ( ref _sweepInFlight , 0 ) ; }
239+ } ) ;
240+ }
241+
194242 public static int Count => _cache . Count ;
195243 public static long CurrentBytes => Interlocked . Read ( ref _currentBytes ) ;
196244 public static double CurrentMB => Math . Round ( Interlocked . Read ( ref _currentBytes ) / ( 1024.0 * 1024.0 ) , 2 ) ;
@@ -199,6 +247,7 @@ public static int ClearTenant(string tenantFilter)
199247 public static long Hits => Interlocked . Read ( ref _hits ) ;
200248 public static long Misses => Interlocked . Read ( ref _misses ) ;
201249 public static long Evictions => Interlocked . Read ( ref _evictions ) ;
250+ public static long Oversized => Interlocked . Read ( ref _oversized ) ;
202251 public static double HitRate => ( _hits + _misses ) > 0
203252 ? Math . Round ( _hits * 100.0 / ( _hits + _misses ) , 1 ) : 0 ;
204253
@@ -326,12 +375,16 @@ private static long EstimateValueSize(object? value, int itemCount)
326375 /// </summary>
327376 public static CacheDiagnostics GetDiagnostics ( )
328377 {
329- var now = DateTime . UtcNow ;
330378 var entries = _cache . ToArray ( ) ; // snapshot
331379
332380 long totalBytes = 0 ;
333381 var byType = new Dictionary < string , TypeBucket > ( ) ;
382+ int active = 0 , expired = 0 ;
383+ DateTime ? earliestExpiry = null , latestExpiry = null ;
334384
385+ // Single pass — use the SizeBytes stored at insert instead of
386+ // re-running EstimateValueSize (which would JSON-serialize every
387+ // PSObject tree on every diagnostic poll and thrash the LOH).
335388 foreach ( var kvp in entries )
336389 {
337390 var parts = kvp . Key . Split ( '|' , 2 ) ;
@@ -341,7 +394,7 @@ public static CacheDiagnostics GetDiagnostics()
341394 if ( kvp . Value . Value is ICollection col )
342395 itemCount = col . Count ;
343396
344- long entryBytes = EstimateValueSize ( kvp . Value . Value , itemCount ) ;
397+ long entryBytes = kvp . Value . SizeBytes ;
345398 totalBytes += entryBytes ;
346399
347400 if ( ! byType . TryGetValue ( dataType , out var bucket ) )
@@ -352,12 +405,7 @@ public static CacheDiagnostics GetDiagnostics()
352405 bucket . EntryCount ++ ;
353406 bucket . TotalBytes += entryBytes ;
354407 bucket . TotalItems += itemCount ;
355- }
356408
357- int active = 0 , expired = 0 ;
358- DateTime ? earliestExpiry = null , latestExpiry = null ;
359- foreach ( var kvp in entries )
360- {
361409 if ( kvp . Value . IsExpired ) { expired ++ ; } else { active ++ ; }
362410 var exp = kvp . Value . ExpiresUtc ;
363411 if ( earliestExpiry == null || exp < earliestExpiry ) earliestExpiry = exp ;
@@ -377,9 +425,10 @@ public static CacheDiagnostics GetDiagnostics()
377425 Misses = Interlocked . Read ( ref _misses ) ,
378426 HitRate = HitRate ,
379427 Evictions = Interlocked . Read ( ref _evictions ) ,
428+ Oversized = Interlocked . Read ( ref _oversized ) ,
380429 EarliestExpiryUtc = earliestExpiry ,
381430 LatestExpiryUtc = latestExpiry ,
382- AccessCount = _accessCount ,
431+ AccessCount = Interlocked . Read ( ref _accessCount ) ,
383432 TypeBreakdown = byType . Values
384433 . OrderByDescending ( b => b . TotalBytes )
385434 . ToList ( ) ,
@@ -401,9 +450,10 @@ public class CacheDiagnostics
401450 public long Misses { get ; set ; }
402451 public double HitRate { get ; set ; }
403452 public long Evictions { get ; set ; }
453+ public long Oversized { get ; set ; }
404454 public DateTime ? EarliestExpiryUtc { get ; set ; }
405455 public DateTime ? LatestExpiryUtc { get ; set ; }
406- public int AccessCount { get ; set ; }
456+ public long AccessCount { get ; set ; }
407457 public List < TypeBucket > TypeBreakdown { get ; set ; } = new ( ) ;
408458 }
409459
0 commit comments