Skip to content

Commit c134795

Browse files
committed
Fixes for mem cache performance
1 parent 2e8531c commit c134795

2 files changed

Lines changed: 131 additions & 48 deletions

File tree

Shared/CIPPSharp/CIPPTestDataCache.cs

Lines changed: 131 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.Collections.Generic;
55
using System.Linq;
66
using System.Reflection;
7-
using System.Text.Json;
87
using System.Threading;
98
using 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>

Shared/CIPPSharp/bin/CIPPSharp.dll

1.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)