Skip to content

Commit ba72b84

Browse files
committed
perf: add cache size limits and dictionary reflection caching
Performance and memory optimization: - Add MaxCacheSize (1000) to prevent unbounded cache growth - Add TrimCacheIfNeeded() for automatic cache eviction - Add DictionaryTypeCache for IDictionary<string,T> interface info - Add ClearCaches() public method for testing/memory management - Cache reflection results for generic dictionary operations This prevents potential memory leaks in long-running applications that process many different object types.
1 parent 6064bb0 commit ba72b84

2 files changed

Lines changed: 102 additions & 31 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- **Smarter JSON number handling**: JSON integers return `int`/`long`, floats return `double` (previously all returned `decimal`)
1414
- **XML documentation**: Added comprehensive XML docs to `ToExpando()` method
1515
- **New tests**: 13 additional test cases for bug fixes and improvements (108 total)
16+
- **Cache management**: `ClearCaches()` public method for testing and memory management
17+
- **Cache size limits**: Automatic cache trimming when exceeding 1000 entries to prevent memory leaks
18+
- **Dictionary reflection caching**: Cached `IDictionary<string, T>` interface info for improved performance
1619

1720
### Fixed
1821

src/ObjectPath/ObjectPath.cs

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ public static class ObjectPath
1010
{
1111
private static readonly char[] Separator = new[] { '.', '[', ']' };
1212

13+
/// <summary>
14+
/// Maximum number of entries in each cache. When exceeded, oldest entries are removed.
15+
/// </summary>
16+
private const int MaxCacheSize = 1000;
17+
1318
private static readonly ConcurrentDictionary<(Type, string, bool), PropertyInfo?> PropertyCache = new();
1419
private static readonly ConcurrentDictionary<(Type, string, bool), FieldInfo?> FieldCache = new();
20+
private static readonly ConcurrentDictionary<Type, DictionaryTypeInfo?> DictionaryTypeCache = new();
1521

1622
public static object? GetValue(object? obj, string path, bool ignoreCase = true)
1723
{
@@ -261,45 +267,34 @@ public static bool TryGetValue<T>(object? obj, string path, out T? value, bool i
261267
throw new InvalidObjectPathException($"Property '{currentSegment}' not found in path '{fullPath}'.");
262268
}
263269

264-
// Handle generic IDictionary<string, T> via reflection
270+
// Handle generic IDictionary<string, T> via cached reflection
265271
var objType = obj.GetType();
266-
var dictionaryInterface = objType.GetInterfaces()
267-
.FirstOrDefault(i => i.IsGenericType &&
268-
i.GetGenericTypeDefinition() == typeof(IDictionary<,>) &&
269-
i.GetGenericArguments()[0] == typeof(string));
270-
271-
if (dictionaryInterface != null)
272+
var dictTypeInfo = GetCachedDictionaryTypeInfo(objType);
273+
274+
if (dictTypeInfo != null)
272275
{
273-
// Use reflection to access the dictionary
274-
var tryGetValueMethod = dictionaryInterface.GetMethod("TryGetValue");
275-
var keysProperty = dictionaryInterface.GetProperty("Keys");
276-
var indexer = dictionaryInterface.GetProperty("Item");
277-
278-
if (tryGetValueMethod != null && indexer != null)
276+
// Try exact key match
277+
var parameters = new object?[] { currentSegment, null };
278+
var found = (bool)dictTypeInfo.TryGetValueMethod.Invoke(obj, parameters)!;
279+
if (found)
279280
{
280-
// Try exact key match
281-
var parameters = new object?[] { currentSegment, null };
282-
var found = (bool)tryGetValueMethod.Invoke(obj, parameters)!;
283-
if (found)
284-
{
285-
return parameters[1];
286-
}
287-
288-
// Try case-insensitive match if enabled
289-
if (ignoreCase && keysProperty != null)
281+
return parameters[1];
282+
}
283+
284+
// Try case-insensitive match if enabled
285+
if (ignoreCase && dictTypeInfo.KeysProperty != null)
286+
{
287+
var keys = (System.Collections.IEnumerable)dictTypeInfo.KeysProperty.GetValue(obj)!;
288+
foreach (string key in keys)
290289
{
291-
var keys = (System.Collections.IEnumerable)keysProperty.GetValue(obj)!;
292-
foreach (string key in keys)
290+
if (string.Equals(key, currentSegment, StringComparison.OrdinalIgnoreCase))
293291
{
294-
if (string.Equals(key, currentSegment, StringComparison.OrdinalIgnoreCase))
295-
{
296-
return indexer.GetValue(obj, new object[] { key });
297-
}
292+
return dictTypeInfo.IndexerProperty.GetValue(obj, new object[] { key });
298293
}
299294
}
300-
301-
throw new InvalidObjectPathException($"Property '{currentSegment}' not found in path '{fullPath}'.");
302295
}
296+
297+
throw new InvalidObjectPathException($"Property '{currentSegment}' not found in path '{fullPath}'.");
303298
}
304299

305300
// Handle regular objects (properties and fields)
@@ -323,6 +318,7 @@ public static bool TryGetValue<T>(object? obj, string path, out T? value, bool i
323318
var key = (type, propertyName, ignoreCase);
324319
if (!PropertyCache.TryGetValue(key, out var propertyInfo))
325320
{
321+
TrimCacheIfNeeded(PropertyCache);
326322
var flags = ignoreCase
327323
? BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase
328324
: BindingFlags.Public | BindingFlags.Instance;
@@ -337,6 +333,7 @@ public static bool TryGetValue<T>(object? obj, string path, out T? value, bool i
337333
var key = (type, fieldName, ignoreCase);
338334
if (!FieldCache.TryGetValue(key, out var fieldInfo))
339335
{
336+
TrimCacheIfNeeded(FieldCache);
340337
var flags = ignoreCase
341338
? BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase
342339
: BindingFlags.Public | BindingFlags.Instance;
@@ -346,6 +343,34 @@ public static bool TryGetValue<T>(object? obj, string path, out T? value, bool i
346343
return fieldInfo;
347344
}
348345

346+
private static DictionaryTypeInfo? GetCachedDictionaryTypeInfo(Type type)
347+
{
348+
if (!DictionaryTypeCache.TryGetValue(type, out var typeInfo))
349+
{
350+
TrimCacheIfNeeded(DictionaryTypeCache);
351+
352+
var dictionaryInterface = type.GetInterfaces()
353+
.FirstOrDefault(i => i.IsGenericType &&
354+
i.GetGenericTypeDefinition() == typeof(IDictionary<,>) &&
355+
i.GetGenericArguments()[0] == typeof(string));
356+
357+
if (dictionaryInterface != null)
358+
{
359+
var tryGetValueMethod = dictionaryInterface.GetMethod("TryGetValue");
360+
var keysProperty = dictionaryInterface.GetProperty("Keys");
361+
var indexer = dictionaryInterface.GetProperty("Item");
362+
363+
if (tryGetValueMethod != null && indexer != null)
364+
{
365+
typeInfo = new DictionaryTypeInfo(tryGetValueMethod, keysProperty, indexer);
366+
}
367+
}
368+
369+
DictionaryTypeCache[type] = typeInfo;
370+
}
371+
return typeInfo;
372+
}
373+
349374
private static bool TryGetPropertyIgnoreCase(JsonElement jsonElement, string propertyName, out JsonElement jsonProperty)
350375
{
351376
foreach (var property in jsonElement.EnumerateObject())
@@ -411,5 +436,48 @@ private static object GetJsonNumber(JsonElement jsonElement)
411436
// Last resort: decimal for very large/precise numbers
412437
return jsonElement.GetDecimal();
413438
}
439+
440+
/// <summary>
441+
/// Trims a cache if it exceeds the maximum size by removing approximately half of the entries.
442+
/// </summary>
443+
private static void TrimCacheIfNeeded<TKey, TValue>(ConcurrentDictionary<TKey, TValue> cache) where TKey : notnull
444+
{
445+
if (cache.Count > MaxCacheSize)
446+
{
447+
// Remove approximately half of the entries to avoid frequent trimming
448+
var keysToRemove = cache.Keys.Take(cache.Count / 2).ToList();
449+
foreach (var key in keysToRemove)
450+
{
451+
cache.TryRemove(key, out _);
452+
}
453+
}
454+
}
455+
456+
/// <summary>
457+
/// Clears all internal caches. Useful for testing or when memory pressure is detected.
458+
/// </summary>
459+
public static void ClearCaches()
460+
{
461+
PropertyCache.Clear();
462+
FieldCache.Clear();
463+
DictionaryTypeCache.Clear();
464+
}
465+
}
466+
467+
/// <summary>
468+
/// Cached reflection information for dictionary types.
469+
/// </summary>
470+
internal sealed class DictionaryTypeInfo
471+
{
472+
public MethodInfo TryGetValueMethod { get; }
473+
public PropertyInfo? KeysProperty { get; }
474+
public PropertyInfo IndexerProperty { get; }
475+
476+
public DictionaryTypeInfo(MethodInfo tryGetValueMethod, PropertyInfo? keysProperty, PropertyInfo indexerProperty)
477+
{
478+
TryGetValueMethod = tryGetValueMethod;
479+
KeysProperty = keysProperty;
480+
IndexerProperty = indexerProperty;
481+
}
414482
}
415483
}

0 commit comments

Comments
 (0)