44using System . Collections . Generic ;
55using System . Linq ;
66using System . Reflection ;
7- using System . Text . Json ;
87using System . Threading ;
98using System . Threading . Tasks ;
109
@@ -14,16 +13,32 @@ namespace CIPP
1413 /// Host-scoped, thread-safe LRU cache for test data lookups. The DLL is
1514 /// loaded once per Azure Functions host, so every PowerShell worker
1615 /// 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)
16+ /// byte-size cap (default 300 MB) and a short TTL (default 5 minutes)
1817 /// so that test suites running against a single tenant get fast cache
1918 /// hits without accumulating stale Gen2 roots that cause GC thrashing.
2019 /// </summary>
2120 public static class TestDataCache
2221 {
2322 // ── Configuration ──
24- private static long _maxBytes = 100L * 1024 * 1024 ; // 100 MB default
23+ // Defaults are 300 MB / 5 min. Both are overridable per deployment via
24+ // Azure Functions app settings (environment variables) resolved once at
25+ // load, or programmatically via Configure() for tests. The cap now maps to
26+ // real managed-heap bytes (see EstimateValueSize), so 300 MB means ~300 MB.
27+ private static long _maxBytes = 300L * 1024 * 1024 ; // 300 MB default
2528 private static TimeSpan _ttl = TimeSpan . FromMinutes ( 5 ) ;
2629
30+ static TestDataCache ( )
31+ {
32+ try
33+ {
34+ if ( long . TryParse ( Environment . GetEnvironmentVariable ( "CIPPTestCacheMaxMB" ) , out var maxMB ) && maxMB > 0 )
35+ _maxBytes = maxMB * 1024L * 1024L ;
36+ if ( int . TryParse ( Environment . GetEnvironmentVariable ( "CIPPTestCacheTtlSeconds" ) , out var ttlSec ) && ttlSec > 0 )
37+ _ttl = TimeSpan . FromSeconds ( ttlSec ) ;
38+ }
39+ catch { /* keep defaults if app settings are unreadable */ }
40+ }
41+
2742 // ── State ──
2843 private static readonly ConcurrentDictionary < string , CacheEntry > _cache = new ( ) ;
2944 private static readonly LinkedList < string > _lruOrder = new ( ) ; // head = oldest
@@ -56,7 +71,7 @@ public CacheEntry(object? value, long sizeBytes, DateTime expiresUtc)
5671 }
5772
5873 /// <summary>Configure the cache limits. Call before first use or between test runs.</summary>
59- public static void Configure ( long maxBytes = 100L * 1024 * 1024 , int ttlSeconds = 300 )
74+ public static void Configure ( long maxBytes = 300L * 1024 * 1024 , int ttlSeconds = 300 )
6075 {
6176 _maxBytes = maxBytes ;
6277 _ttl = TimeSpan . FromSeconds ( ttlSeconds ) ;
@@ -258,8 +273,31 @@ private static void TryFireBackgroundSweep()
258273 private static PropertyInfo ? s_psPropsProp ; // PSObject.Properties
259274 private static PropertyInfo ? s_psPropName ; // PSPropertyInfo.Name
260275 private static PropertyInfo ? s_psPropValue ; // PSPropertyInfo.Value
261- private const int SampleSize = 5 ;
262- private const int MaxDepth = 4 ;
276+ // ── Managed-heap size model (x64 CoreCLR) ──
277+ // The cache stores *parsed PSObject graphs*, whose live heap footprint is
278+ // many times their JSON text size (measured ≈8× on real Graph data). Sizing
279+ // by JSON serialization therefore under-counts by ~8× and lets the byte cap
280+ // balloon far past its nominal limit. Instead we walk the object graph and
281+ // sum approximate per-object heap costs. Constants below are calibrated
282+ // against real GC heap growth (see tools/measure-testcache.ps1).
283+ private const int ObjectHeader = 16 ; // sync block + method table ptr
284+ private const int RefSlot = 8 ; // one reference (array/field slot)
285+ private const int StringBaseOverhead = 22 ; // header(16)+length(4)+terminator(2)
286+ private const int BoxedPrimitive = 24 ; // header(16)+<=8 payload, aligned
287+ private const int BoxedLarge = 32 ; // header(16)+16 payload (decimal/Guid)
288+ private const int PSObjectBase = 192 ; // PSObject wrapper + base + member collections
289+ private const int PSNotePropertyOverhead = 132 ; // PSNoteProperty + name-lookup entry (excl. name string + value)
290+ private const int DictBase = 80 ; // buckets + entries baseline
291+ private const int DictEntryOverhead = 48 ; // per-entry slot incl. capacity slack (excl. key + value)
292+ private const int CollectionBase = 56 ; // list/array object + backing array baseline
293+
294+ // Bound the walk on pathological payloads: full-walk small collections for
295+ // accuracy, but stride-sample very large ones so a 100k-item array can't
296+ // turn a single Set() into a multi-second reflection storm.
297+ private const int LargeCollectionThreshold = 512 ;
298+ private const int LargeCollectionSamples = 256 ;
299+ private const int MaxDepth = 32 ;
300+ private const long NodeBudget = 2_000_000 ; // hard ceiling on nodes visited per Set()
263301
264302 private static void EnsurePSResolved ( )
265303 {
@@ -289,82 +327,127 @@ private static void EnsurePSResolved()
289327 }
290328 }
291329
330+ private static long Align8 ( long bytes ) => ( bytes + 7 ) & ~ 7L ;
331+
292332 /// <summary>
293- /// Estimate the serialized size of a cached value. For large collections,
294- /// samples a few items and extrapolates. Handles PSObject by unwrapping
295- /// NoteProperties into dictionaries via reflection.
333+ /// Estimate the live managed-heap footprint of a cached value by walking the
334+ /// object graph and summing approximate per-object costs. This tracks real
335+ /// memory (parsed PSObjects, boxed primitives, strings, dictionaries and
336+ /// collections) rather than JSON text size, so the byte cap corresponds to
337+ /// actual process memory. <paramref name="itemCount"/> is accepted for call
338+ /// compatibility but the walk recomputes counts itself.
296339 /// </summary>
297340 private static long EstimateValueSize ( object ? value , int itemCount )
298341 {
299342 if ( value == null ) return 0 ;
300343 EnsurePSResolved ( ) ;
301-
302344 try
303345 {
304- // Large collection: sample a few items, extrapolate
305- if ( itemCount > SampleSize && value is IEnumerable enumerable )
306- {
307- var sample = new List < object ? > ( ) ;
308- foreach ( var item in enumerable )
309- {
310- sample . Add ( Unwrap ( item , 0 ) ) ;
311- if ( sample . Count >= SampleSize ) break ;
312- }
313- if ( sample . Count == 0 ) return 0 ;
314- var sampleBytes = JsonSerializer . SerializeToUtf8Bytes ( sample ) . LongLength ;
315- return sampleBytes * itemCount / sample . Count ;
316- }
317-
318- // Small collection or single value: unwrap everything
319- var unwrapped = Unwrap ( value , 0 ) ;
320- return JsonSerializer . SerializeToUtf8Bytes ( unwrapped ) . LongLength ;
346+ long budget = NodeBudget ;
347+ return SizeOf ( value , 0 , ref budget ) ;
321348 }
322349 catch { return 0 ; }
323350 }
324351
325352 /// <summary>
326- /// Recursively unwrap PSObject → Dictionary and IEnumerable → List
327- /// so System.Text.Json can serialize them.
353+ /// Recursively sum the approximate managed-heap bytes of <paramref name="value"/>
354+ /// and everything it owns. Returns the object's own heap size; the reference
355+ /// slot that points at it is accounted for by the owning object/collection.
328356 /// </summary>
329- private static object ? Unwrap ( object ? value , int depth )
357+ private static long SizeOf ( object ? value , int depth , ref long budget )
330358 {
331- if ( value == null || depth > MaxDepth ) return value ;
359+ if ( value == null ) return 0 ;
360+ if ( -- budget <= 0 || depth > MaxDepth ) return 0 ;
332361
333- // PSObject → extract NoteProperties as Dictionary
362+ switch ( value )
363+ {
364+ case string s :
365+ return Align8 ( StringBaseOverhead + 2L * s . Length ) ;
366+ case bool : case byte : case sbyte : case char :
367+ case short : case ushort : case int : case uint :
368+ case long : case ulong : case float : case double :
369+ case DateTime : case DateTimeOffset : case TimeSpan : case Enum :
370+ return BoxedPrimitive ;
371+ case decimal : case Guid :
372+ return BoxedLarge ;
373+ case byte [ ] bytes :
374+ return Align8 ( ObjectHeader + RefSlot + bytes . LongLength ) ;
375+ }
376+
377+ // PSObject → wrapper cost + each NoteProperty (name string + value + overhead)
334378 if ( s_psObjectType != null && s_psObjectType . IsInstanceOfType ( value ) )
335- return UnwrapPSObject ( value , depth ) ;
379+ return SizeOfPSObject ( value , depth , ref budget ) ;
380+
381+ // Dictionary / Hashtable → base + per-entry overhead + keys + values
382+ if ( value is IDictionary dict )
383+ {
384+ long total = ObjectHeader + DictBase ;
385+ foreach ( DictionaryEntry de in dict )
386+ {
387+ total += DictEntryOverhead ;
388+ total += SizeOf ( de . Key , depth + 1 , ref budget ) ;
389+ total += SizeOf ( de . Value , depth + 1 , ref budget ) ;
390+ if ( budget <= 0 ) break ;
391+ }
392+ return total ;
393+ }
336394
337- // Collection → unwrap each element
338- if ( value is IEnumerable enumerable && value is not string && value is not byte [ ] )
395+ // Other collections (object[], List<object>, …). Stride-sample very large
396+ // ones to bound the walk; full-walk the rest for accuracy.
397+ if ( value is IEnumerable enumerable && value is not string )
339398 {
340- var list = new List < object ? > ( ) ;
399+ int count = value is ICollection col ? col . Count : - 1 ;
400+
401+ if ( count > LargeCollectionThreshold && value is IList list )
402+ {
403+ long step = count / ( long ) LargeCollectionSamples ;
404+ if ( step < 1 ) step = 1 ;
405+ long sampled = 0 , sampledBytes = 0 ;
406+ for ( long i = 0 ; i < count && sampled < LargeCollectionSamples ; i += step )
407+ {
408+ sampledBytes += SizeOf ( list [ ( int ) i ] , depth + 1 , ref budget ) ;
409+ sampled ++ ;
410+ if ( budget <= 0 ) break ;
411+ }
412+ long avgItem = sampled > 0 ? sampledBytes / sampled : 0 ;
413+ // backing array slots + extrapolated element payloads
414+ return ObjectHeader + CollectionBase + count * ( RefSlot + avgItem ) ;
415+ }
416+
417+ long total = ObjectHeader + CollectionBase ;
341418 foreach ( var item in enumerable )
342- list . Add ( Unwrap ( item , depth + 1 ) ) ;
343- return list ;
419+ {
420+ total += RefSlot ;
421+ total += SizeOf ( item , depth + 1 , ref budget ) ;
422+ if ( budget <= 0 ) break ;
423+ }
424+ return total ;
344425 }
345426
346- return value ;
427+ // Unknown reference type: header plus a small nominal for its fields.
428+ return ObjectHeader + 32 ;
347429 }
348430
349- private static Dictionary < string , object ? > UnwrapPSObject ( object psObj , int depth )
431+ private static long SizeOfPSObject ( object psObj , int depth , ref long budget )
350432 {
351- var dict = new Dictionary < string , object ? > ( ) ;
433+ long total = PSObjectBase ;
352434 if ( s_psPropsProp == null || s_psPropName == null || s_psPropValue == null )
353- return dict ;
354-
355- if ( s_psPropsProp . GetValue ( psObj ) is not IEnumerable props ) return dict ;
435+ return total ;
436+ if ( s_psPropsProp . GetValue ( psObj ) is not IEnumerable props ) return total ;
356437
357438 foreach ( var prop in props )
358439 {
440+ if ( -- budget <= 0 ) break ;
441+ total += PSNotePropertyOverhead ;
359442 try
360443 {
361- var name = s_psPropName . GetValue ( prop ) ? . ToString ( ) ;
362- if ( name != null )
363- dict [ name ] = Unwrap ( s_psPropValue . GetValue ( prop ) , depth + 1 ) ;
444+ if ( s_psPropName . GetValue ( prop ) is string name )
445+ total += Align8 ( StringBaseOverhead + 2L * name . Length ) ;
446+ total += SizeOf ( s_psPropValue . GetValue ( prop ) , depth + 1 , ref budget ) ;
364447 }
365448 catch { /* skip properties that throw on access */ }
366449 }
367- return dict ;
450+ return total ;
368451 }
369452
370453 /// <summary>
0 commit comments