// MIT License - Copyright (c) 2026 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Utils
{
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using System.Threading;
using UnityEngine;
using UnityEngine.SceneManagement;
///
/// Global and type-specific configuration registry for intelligent pool purging.
///
///
///
/// This class provides a hierarchical configuration system for pool purging behavior:
///
/// - Specific type configuration (e.g., List<int>)
/// - Generic type pattern configuration (e.g., List<> for any List<T>)
/// - Global defaults
///
///
///
/// By default, intelligent purging is disabled. Enable it by setting
/// to true or by configuring pool settings in the Unity Editor
/// (Edit > Project Settings > Unity Helpers > Pool Purging).
///
///
/// The retention model uses two settings:
///
/// - (default 0) - Absolute floor. Pools never purge below this.
/// - (default 2) - For active pools (accessed within IdleTimeoutSeconds), keep this many warm to avoid cold-start allocations.
///
/// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0)
///
///
/// >(options => {
/// options.IdleTimeoutSeconds = 600f; // 10 minutes
/// options.MinRetainCount = 10;
/// options.WarmRetainCount = 5;
/// });
///
/// // Configure all List types
/// PoolPurgeSettings.ConfigureGeneric(typeof(List<>), options => {
/// options.IdleTimeoutSeconds = 300f;
/// });
///
/// // Disable for expensive objects
/// PoolPurgeSettings.Disable();
/// ]]>
///
///
public static class PoolPurgeSettings
{
///
/// Default idle timeout in seconds when intelligent purging is enabled.
/// Set to 5 minutes by default to be conservative and avoid GC churn.
///
public const float DefaultIdleTimeoutSeconds = 300f;
///
/// Default minimum retain count during purge operations.
/// This is the absolute floor - pools never purge below this, ever.
///
public const int DefaultMinRetainCount = 0;
///
/// Default warm retain count for active pools.
/// Active pools (accessed within IdleTimeoutSeconds) keep this many items warm
/// to avoid cold-start allocations.
///
public const int DefaultWarmRetainCount = 2;
///
/// Default buffer multiplier for comfortable pool size calculation.
/// The comfortable size is calculated as max(effectiveMinRetain, rollingHighWaterMark * BufferMultiplier).
///
public const float DefaultBufferMultiplier = 2.0f;
///
/// Default rolling window duration in seconds for high water mark tracking.
///
public const float DefaultRollingWindowSeconds = 300f;
///
/// Default hysteresis duration in seconds.
/// After a usage spike, purging is suppressed for this duration to prevent purge-allocate cycles.
///
public const float DefaultHysteresisSeconds = 120f;
///
/// Default spike threshold multiplier.
/// A spike is detected when concurrent rentals exceed the rolling average by this factor.
///
public const float DefaultSpikeThresholdMultiplier = 2.5f;
///
/// Default maximum number of items to purge per operation.
/// Limits GC pressure by spreading large purge operations across multiple calls.
/// A value of 0 means unlimited (purge all eligible items in one operation).
///
public const int DefaultMaxPurgesPerOperation = 10;
///
/// Default maximum pool size (0 = unbounded).
/// Pools exceeding this limit will have items purged.
///
public const int DefaultMaxPoolSize = 0;
///
/// Default Large Object Heap (LOH) threshold in bytes.
/// Objects of this size or larger are allocated on the LOH and receive stricter purge policies.
/// The .NET runtime uses 85,000 bytes as the LOH threshold.
///
public const int DefaultLargeObjectThresholdBytes = 85000;
///
/// Default buffer multiplier for large objects.
/// Large objects use less buffer than regular objects to minimize LOH memory pressure.
/// Default is 1.0 (no buffer) compared to 2.0 for regular objects.
///
public const float DefaultLargeObjectBufferMultiplier = 1.0f;
///
/// Default idle timeout multiplier for large objects.
/// Large objects have shorter effective idle timeouts to be purged faster.
/// Default is 0.5 (50% of normal timeout).
///
public const float DefaultLargeObjectIdleTimeoutMultiplier = 0.5f;
///
/// Default warm retain count for large objects.
/// Large objects keep fewer warm items to minimize LOH memory usage.
/// Default is 1 compared to 2 for regular objects.
///
public const int DefaultLargeObjectWarmRetainCount = 1;
private static int _globalEnabled = 0;
private static int _purgeOnLowMemory = 1;
private static int _purgeOnAppBackground = 1;
private static int _purgeOnSceneUnload = 1;
private static int _lifecycleHooksRegistered;
private static float _defaultIdleTimeoutSeconds = DefaultIdleTimeoutSeconds;
private static int _defaultMinRetainCount = DefaultMinRetainCount;
private static int _defaultWarmRetainCount = DefaultWarmRetainCount;
private static float _defaultBufferMultiplier = DefaultBufferMultiplier;
private static float _defaultRollingWindowSeconds = DefaultRollingWindowSeconds;
private static float _defaultHysteresisSeconds = DefaultHysteresisSeconds;
private static float _defaultSpikeThresholdMultiplier = DefaultSpikeThresholdMultiplier;
private static int _defaultMaxPurgesPerOperation = DefaultMaxPurgesPerOperation;
private static int _defaultMaxPoolSize = DefaultMaxPoolSize;
private static int _largeObjectThresholdBytes = DefaultLargeObjectThresholdBytes;
private static float _largeObjectBufferMultiplier = DefaultLargeObjectBufferMultiplier;
private static float _largeObjectIdleTimeoutMultiplier =
DefaultLargeObjectIdleTimeoutMultiplier;
private static int _largeObjectWarmRetainCount = DefaultLargeObjectWarmRetainCount;
private static int _sizeAwarePoliciesEnabled = 1;
private static readonly object ConfigLock = new object();
private static readonly Dictionary TypeConfigurations =
new Dictionary();
private static readonly Dictionary GenericTypeConfigurations =
new Dictionary();
private static readonly HashSet DisabledTypes = new HashSet();
// Settings-based per-type configurations (lower priority than programmatic API)
private static readonly Dictionary SettingsTypeConfigurations =
new Dictionary();
private static readonly Dictionary<
Type,
PoolPurgeTypeOptions
> SettingsGenericTypeConfigurations = new Dictionary();
private static readonly HashSet SettingsDisabledTypes = new HashSet();
// Built-in type-aware defaults (lowest priority - applied before user configuration)
private static readonly Dictionary BuiltInTypeConfigurations =
new Dictionary();
private static readonly Dictionary<
Type,
PoolPurgeTypeOptions
> BuiltInGenericTypeConfigurations = new Dictionary();
private static int _builtInDefaultsInitialized;
// Cache for PoolPurgePolicyAttribute reflection results to avoid repeated reflection on the same type
private static readonly Dictionary<
Type,
(bool HasAttribute, bool Enabled, PoolPurgeTypeOptions Options)
> AttributeCache =
new Dictionary();
///
/// Gets or sets whether intelligent pool purging is globally enabled.
/// Default is false (disabled). Enable via Unity Editor settings or by setting this property.
///
public static bool GlobalEnabled
{
get => Volatile.Read(ref _globalEnabled) != 0;
set => Volatile.Write(ref _globalEnabled, value ? 1 : 0);
}
///
/// Gets or sets the default idle timeout in seconds for pools without type-specific configuration.
/// Items idle longer than this are eligible for purging when intelligent purging is enabled.
///
public static float DefaultGlobalIdleTimeoutSeconds
{
get => Volatile.Read(ref _defaultIdleTimeoutSeconds);
set => Volatile.Write(ref _defaultIdleTimeoutSeconds, value);
}
///
/// Gets or sets the default minimum retain count for pools without type-specific configuration.
/// This is the absolute floor - purge operations will never reduce the pool below this count.
///
public static int DefaultGlobalMinRetainCount
{
get => Volatile.Read(ref _defaultMinRetainCount);
set => Volatile.Write(ref _defaultMinRetainCount, value);
}
///
/// Gets or sets the default warm retain count for active pools.
/// Active pools (accessed within ) keep this many items
/// warm to avoid cold-start allocations. Idle pools purge to .
/// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0).
///
public static int DefaultGlobalWarmRetainCount
{
get => Volatile.Read(ref _defaultWarmRetainCount);
set => Volatile.Write(ref _defaultWarmRetainCount, value);
}
///
/// Gets or sets the default buffer multiplier for comfortable pool size calculation.
///
public static float DefaultGlobalBufferMultiplier
{
get => Volatile.Read(ref _defaultBufferMultiplier);
set => Volatile.Write(ref _defaultBufferMultiplier, value);
}
///
/// Gets or sets the default rolling window duration in seconds for high water mark tracking.
///
public static float DefaultGlobalRollingWindowSeconds
{
get => Volatile.Read(ref _defaultRollingWindowSeconds);
set => Volatile.Write(ref _defaultRollingWindowSeconds, value);
}
///
/// Gets or sets the default hysteresis duration in seconds.
///
public static float DefaultGlobalHysteresisSeconds
{
get => Volatile.Read(ref _defaultHysteresisSeconds);
set => Volatile.Write(ref _defaultHysteresisSeconds, value);
}
///
/// Gets or sets the default spike threshold multiplier.
///
public static float DefaultGlobalSpikeThresholdMultiplier
{
get => Volatile.Read(ref _defaultSpikeThresholdMultiplier);
set => Volatile.Write(ref _defaultSpikeThresholdMultiplier, value);
}
///
/// Gets or sets the default maximum number of items to purge per operation.
/// Limits GC pressure by spreading large purge operations across multiple calls.
/// A value of 0 means unlimited (purge all eligible items in one operation).
/// Negative values are normalized to 0 (unlimited).
///
///
///
/// When set to a positive value, purge operations will process at most this many items
/// before returning, setting a "pending purges" flag to continue on subsequent operations.
/// This prevents GC spikes from bulk deallocation.
///
///
/// The following operations bypass this limit and purge all eligible items immediately:
///
/// - Emergency purges via (triggered by )
/// - Explicit Purge(reason) calls with a specified reason
/// - ForceFullPurge() method calls
///
///
///
public static int DefaultGlobalMaxPurgesPerOperation
{
get => Volatile.Read(ref _defaultMaxPurgesPerOperation);
set => Volatile.Write(ref _defaultMaxPurgesPerOperation, Math.Max(0, value));
}
///
/// Gets or sets the default maximum pool size.
/// Pools exceeding this limit will have items purged.
/// A value of 0 means unbounded (no size limit).
/// Negative values are normalized to 0 (unbounded).
///
public static int DefaultGlobalMaxPoolSize
{
get => Volatile.Read(ref _defaultMaxPoolSize);
set => Volatile.Write(ref _defaultMaxPoolSize, Math.Max(0, value));
}
///
/// Gets or sets whether size-aware purge policies are enabled.
/// When enabled, pools containing large objects (above )
/// automatically receive stricter purge policies.
/// Default is true.
///
///
///
/// Size-aware policies help manage Large Object Heap (LOH) memory pressure by:
///
/// - Using a smaller buffer multiplier for large objects (default 1.0x vs 2.0x)
/// - Reducing idle timeout for large objects (default 50% of normal)
/// - Keeping fewer warm items for large objects (default 1 vs 2)
///
///
///
/// Set this to false if you want to manage large object pools manually or if
/// automatic size estimation causes issues.
///
///
public static bool SizeAwarePoliciesEnabled
{
get => Volatile.Read(ref _sizeAwarePoliciesEnabled) != 0;
set => Volatile.Write(ref _sizeAwarePoliciesEnabled, value ? 1 : 0);
}
///
/// Gets or sets the threshold in bytes above which objects are considered "large objects"
/// and receive stricter purge policies.
/// Default is 85,000 bytes (the .NET Large Object Heap threshold).
///
///
///
/// Objects of this size or larger are allocated on the .NET Large Object Heap (LOH).
/// The LOH has different garbage collection characteristics:
///
/// - Only collected during Gen2 collections (expensive full GC)
/// - Not compacted by default (causes fragmentation)
/// - Retaining large pooled objects wastes significant memory
///
///
///
/// For pools containing large objects, the purge system automatically applies:
///
/// - instead of
/// - Idle timeout multiplied by
/// - instead of
///
///
///
public static int LargeObjectThresholdBytes
{
get => Volatile.Read(ref _largeObjectThresholdBytes);
set => Volatile.Write(ref _largeObjectThresholdBytes, Math.Max(0, value));
}
///
/// Gets or sets the buffer multiplier used for large objects.
/// Large objects use a smaller buffer to minimize LOH memory pressure.
/// Default is 1.0 (no buffer above peak usage).
///
///
///
/// This value replaces for pools containing
/// objects that exceed . A smaller buffer means
/// pools are kept closer to their actual usage patterns, freeing LOH memory faster.
///
///
public static float LargeObjectBufferMultiplier
{
get => Volatile.Read(ref _largeObjectBufferMultiplier);
set => Volatile.Write(ref _largeObjectBufferMultiplier, Math.Max(0f, value));
}
///
/// Gets or sets the multiplier applied to idle timeout for large objects.
/// Large objects have shorter effective idle timeouts to be purged faster.
/// Default is 0.5 (50% of normal timeout).
///
///
///
/// This multiplier is applied to (or the
/// type-specific idle timeout) for pools containing objects that exceed
/// . A smaller multiplier means large objects
/// are purged sooner after becoming idle.
///
///
/// Example: If is 300 seconds and
/// is 0.5, large objects become eligible
/// for purging after 150 seconds of idle time.
///
///
public static float LargeObjectIdleTimeoutMultiplier
{
get => Volatile.Read(ref _largeObjectIdleTimeoutMultiplier);
set =>
Volatile.Write(
ref _largeObjectIdleTimeoutMultiplier,
Math.Max(0f, Math.Min(1f, value))
);
}
///
/// Gets or sets the warm retain count for large object pools.
/// Large object pools keep fewer warm items to minimize LOH memory usage.
/// Default is 1 (compared to 2 for regular objects).
///
///
///
/// This value replaces for pools containing
/// objects that exceed . Keeping fewer warm items
/// reduces LOH memory usage at the cost of potentially more allocations for bursty workloads.
///
///
public static int LargeObjectWarmRetainCount
{
get => Volatile.Read(ref _largeObjectWarmRetainCount);
set => Volatile.Write(ref _largeObjectWarmRetainCount, Math.Max(0, value));
}
///
/// Gets or sets whether pools should be purged when is triggered.
/// When enabled, an emergency purge is performed that ignores hysteresis and purges to .
/// Default is true.
///
public static bool PurgeOnLowMemory
{
get => Volatile.Read(ref _purgeOnLowMemory) != 0;
set => Volatile.Write(ref _purgeOnLowMemory, value ? 1 : 0);
}
///
/// Gets or sets whether pools should be purged when the application loses focus (backgrounds).
/// This is particularly useful on mobile platforms where backgrounded apps may be killed.
/// When enabled, a normal purge is performed that respects hysteresis settings.
/// Default is true.
///
public static bool PurgeOnAppBackground
{
get => Volatile.Read(ref _purgeOnAppBackground) != 0;
set => Volatile.Write(ref _purgeOnAppBackground, value ? 1 : 0);
}
///
/// Gets or sets whether pools should be purged when a scene is unloaded.
/// When enabled, a purge check is triggered on all pools via .
/// The purge respects hysteresis settings to avoid purge-allocate cycles during rapid scene transitions.
/// Default is true.
///
public static bool PurgeOnSceneUnload
{
get => Volatile.Read(ref _purgeOnSceneUnload) != 0;
set => Volatile.Write(ref _purgeOnSceneUnload, value ? 1 : 0);
}
///
/// Configures intelligent purging options for a specific type.
///
/// The type to configure.
/// Action to configure the options.
/// Thrown when configure is null.
public static void Configure(Action configure)
{
if (configure == null)
{
throw new ArgumentNullException(nameof(configure));
}
Type type = typeof(T);
PoolPurgeTypeOptions options = new PoolPurgeTypeOptions();
configure(options);
lock (ConfigLock)
{
TypeConfigurations[type] = options;
DisabledTypes.Remove(type);
}
}
///
/// Configures intelligent purging options for all types matching a generic type definition.
///
/// The generic type definition (e.g., typeof(List<>)).
/// Action to configure the options.
/// Thrown when genericTypeDefinition or configure is null.
/// Thrown when genericTypeDefinition is not a generic type definition.
public static void ConfigureGeneric(
Type genericTypeDefinition,
Action configure
)
{
if (genericTypeDefinition == null)
{
throw new ArgumentNullException(nameof(genericTypeDefinition));
}
if (configure == null)
{
throw new ArgumentNullException(nameof(configure));
}
if (!genericTypeDefinition.IsGenericTypeDefinition)
{
throw new ArgumentException(
"Type must be a generic type definition (e.g., typeof(List<>)).",
nameof(genericTypeDefinition)
);
}
PoolPurgeTypeOptions options = new PoolPurgeTypeOptions();
configure(options);
lock (ConfigLock)
{
GenericTypeConfigurations[genericTypeDefinition] = options;
}
}
///
/// Disables intelligent purging for a specific type.
///
/// The type to disable purging for.
public static void Disable()
{
Type type = typeof(T);
lock (ConfigLock)
{
DisabledTypes.Add(type);
TypeConfigurations.Remove(type);
}
}
///
/// Disables intelligent purging for a specific type.
///
/// The type to disable purging for.
/// Thrown when type is null.
public static void Disable(Type type)
{
if (type == null)
{
throw new ArgumentNullException(nameof(type));
}
lock (ConfigLock)
{
DisabledTypes.Add(type);
TypeConfigurations.Remove(type);
}
}
///
/// Enables intelligent purging for a specific type that was previously disabled.
///
/// The type to enable purging for.
public static void Enable()
{
Type type = typeof(T);
lock (ConfigLock)
{
DisabledTypes.Remove(type);
}
}
///
/// Enables intelligent purging for a specific type that was previously disabled.
///
/// The type to enable purging for.
/// Thrown when type is null.
public static void Enable(Type type)
{
if (type == null)
{
throw new ArgumentNullException(nameof(type));
}
lock (ConfigLock)
{
DisabledTypes.Remove(type);
}
}
///
/// Removes all type-specific and generic type configurations.
/// Does not affect global settings.
///
public static void ClearTypeConfigurations()
{
lock (ConfigLock)
{
TypeConfigurations.Clear();
GenericTypeConfigurations.Clear();
DisabledTypes.Clear();
AttributeCache.Clear();
}
}
///
/// Clears all settings-based type configurations.
/// This is typically called before reloading configurations from UnityHelpersSettings.
///
public static void ClearSettingsTypeConfigurations()
{
lock (ConfigLock)
{
SettingsTypeConfigurations.Clear();
SettingsGenericTypeConfigurations.Clear();
SettingsDisabledTypes.Clear();
}
}
///
/// Clears all built-in type-aware default configurations and resets the initialization flag.
/// This is primarily used for testing.
///
internal static void ClearBuiltInTypeConfigurations()
{
lock (ConfigLock)
{
BuiltInTypeConfigurations.Clear();
BuiltInGenericTypeConfigurations.Clear();
}
Volatile.Write(ref _builtInDefaultsInitialized, 0);
}
///
/// Ensures the built-in type-aware defaults are initialized.
/// This method is called automatically when getting effective options.
///
///
///
/// Built-in defaults provide sensible out-of-box behavior for common types:
///
/// - Arrays: Purge more aggressively (1.5x buffer, 3 minute idle timeout)
/// - StringBuilder: Short-lived temporaries (2 minute idle timeout, 1 min retain)
/// - List<>: Common collections kept warm (2x buffer, 2 min retain)
/// - Dictionary<,>: Common collections kept warm (2x buffer, 2 min retain)
/// - HashSet<>: Common collections kept warm (2x buffer, 2 min retain)
/// - Queue<>, Stack<>: Common collections kept warm
///
///
///
/// These defaults have the lowest priority and are overridden by any user configuration.
///
///
private static void EnsureBuiltInDefaultsInitialized()
{
if (Volatile.Read(ref _builtInDefaultsInitialized) != 0)
{
return;
}
lock (ConfigLock)
{
// Double-check inside lock
if (Volatile.Read(ref _builtInDefaultsInitialized) != 0)
{
return;
}
InitializeBuiltInDefaults();
Volatile.Write(ref _builtInDefaultsInitialized, 1);
}
}
///
/// Initializes the built-in type-aware defaults for common types.
/// This method is called once during lazy initialization.
///
private static void InitializeBuiltInDefaults()
{
// Arrays - generally larger memory footprint, purge more aggressively
// Using typeof(Array) which will be checked in GetEffectiveOptions for all array types
BuiltInTypeConfigurations[typeof(Array)] = new PoolPurgeTypeOptions
{
BufferMultiplier = 1.5f,
IdleTimeoutSeconds = 180f, // 3 minutes
};
// StringBuilder - often temporary, used for string building operations
BuiltInTypeConfigurations[typeof(StringBuilder)] = new PoolPurgeTypeOptions
{
IdleTimeoutSeconds = 120f, // 2 minutes
MinRetainCount = 1,
};
// List<> - very common, keep warm for performance
BuiltInGenericTypeConfigurations[typeof(List<>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 2,
BufferMultiplier = 2.0f,
};
// Dictionary<,> - common, keep warm for performance
BuiltInGenericTypeConfigurations[typeof(Dictionary<,>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 2,
BufferMultiplier = 2.0f,
};
// HashSet<> - common, keep warm for performance
BuiltInGenericTypeConfigurations[typeof(HashSet<>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 2,
BufferMultiplier = 2.0f,
};
// Queue<> - keep a few warm
BuiltInGenericTypeConfigurations[typeof(Queue<>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 1,
BufferMultiplier = 1.5f,
};
// Stack<> - keep a few warm
BuiltInGenericTypeConfigurations[typeof(Stack<>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 1,
BufferMultiplier = 1.5f,
};
// LinkedList<> - less common, can purge more aggressively
BuiltInGenericTypeConfigurations[typeof(LinkedList<>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 1,
BufferMultiplier = 1.5f,
IdleTimeoutSeconds = 180f, // 3 minutes
};
// SortedDictionary<,> - less common, can purge more aggressively
BuiltInGenericTypeConfigurations[typeof(SortedDictionary<,>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 1,
BufferMultiplier = 1.5f,
};
// SortedSet<> - less common, can purge more aggressively
BuiltInGenericTypeConfigurations[typeof(SortedSet<>)] = new PoolPurgeTypeOptions
{
MinRetainCount = 1,
BufferMultiplier = 1.5f,
};
}
///
/// Forces re-initialization of built-in type-aware defaults.
/// This is primarily used for testing.
///
internal static void ReinitializeBuiltInDefaults()
{
ClearBuiltInTypeConfigurations();
EnsureBuiltInDefaultsInitialized();
}
///
/// Gets whether built-in type-aware defaults have been initialized.
///
internal static bool BuiltInDefaultsInitialized =>
Volatile.Read(ref _builtInDefaultsInitialized) != 0;
///
/// Configures settings-based per-type options.
/// These have lower priority than programmatic API configurations.
///
/// The type to configure.
/// The configuration options.
/// Thrown when type or options is null.
public static void ConfigureFromSettings(Type type, PoolPurgeTypeOptions options)
{
if (type == null)
{
throw new ArgumentNullException(nameof(type));
}
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
lock (ConfigLock)
{
SettingsTypeConfigurations[type] = options;
SettingsDisabledTypes.Remove(type);
}
}
///
/// Configures settings-based per-type options for a generic type definition.
/// These have lower priority than programmatic API configurations.
///
/// The generic type definition (e.g., typeof(List<>)).
/// The configuration options.
/// Thrown when genericTypeDefinition or options is null.
/// Thrown when genericTypeDefinition is not a generic type definition.
public static void ConfigureGenericFromSettings(
Type genericTypeDefinition,
PoolPurgeTypeOptions options
)
{
if (genericTypeDefinition == null)
{
throw new ArgumentNullException(nameof(genericTypeDefinition));
}
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
if (!genericTypeDefinition.IsGenericTypeDefinition)
{
throw new ArgumentException(
"Type must be a generic type definition (e.g., typeof(List<>)).",
nameof(genericTypeDefinition)
);
}
lock (ConfigLock)
{
SettingsGenericTypeConfigurations[genericTypeDefinition] = options;
}
}
///
/// Disables a type from settings-based configuration.
///
/// The type to disable.
/// Thrown when type is null.
public static void DisableFromSettings(Type type)
{
if (type == null)
{
throw new ArgumentNullException(nameof(type));
}
lock (ConfigLock)
{
SettingsDisabledTypes.Add(type);
SettingsTypeConfigurations.Remove(type);
}
}
///
/// Resets all settings to their default values.
///
public static void ResetToDefaults()
{
GlobalEnabled = false;
DefaultGlobalIdleTimeoutSeconds = DefaultIdleTimeoutSeconds;
DefaultGlobalMinRetainCount = DefaultMinRetainCount;
DefaultGlobalWarmRetainCount = DefaultWarmRetainCount;
DefaultGlobalMaxPoolSize = DefaultMaxPoolSize;
DefaultGlobalBufferMultiplier = DefaultBufferMultiplier;
DefaultGlobalRollingWindowSeconds = DefaultRollingWindowSeconds;
DefaultGlobalHysteresisSeconds = DefaultHysteresisSeconds;
DefaultGlobalSpikeThresholdMultiplier = DefaultSpikeThresholdMultiplier;
DefaultGlobalMaxPurgesPerOperation = DefaultMaxPurgesPerOperation;
SizeAwarePoliciesEnabled = true;
LargeObjectThresholdBytes = DefaultLargeObjectThresholdBytes;
LargeObjectBufferMultiplier = DefaultLargeObjectBufferMultiplier;
LargeObjectIdleTimeoutMultiplier = DefaultLargeObjectIdleTimeoutMultiplier;
LargeObjectWarmRetainCount = DefaultLargeObjectWarmRetainCount;
PurgeOnLowMemory = true;
PurgeOnAppBackground = true;
PurgeOnSceneUnload = true;
ClearTypeConfigurations();
ClearSettingsTypeConfigurations();
}
///
/// Disables intelligent pool purging globally. This is a convenience method for easy opt-out.
/// Equivalent to setting GlobalEnabled = false.
///
///
/// Call this method early in application initialization if you prefer to manage pool memory manually
/// or if the automatic purging behavior is undesirable for your use case.
///
///
///
/// // In your initialization code (e.g., RuntimeInitializeOnLoadMethod)
/// PoolPurgeSettings.DisableGlobally();
///
///
public static void DisableGlobally()
{
GlobalEnabled = false;
}
///
/// Gets the effective configuration for a specific type, considering the hierarchy:
/// specific type > generic type pattern > global defaults.
///
/// The type to get configuration for.
/// The effective purge configuration for the type.
public static PoolPurgeEffectiveOptions GetEffectiveOptions()
{
return GetEffectiveOptions(typeof(T));
}
///
/// Gets the effective configuration for a specific type, considering the hierarchy:
///
/// - Programmatic API (Configure, Disable) - highest priority
/// - UnityHelpersSettings per-type configuration
/// - PoolPurgePolicyAttribute on the type
/// - Generic type patterns by specificity (exact > inner open > outer open)
/// - Built-in type-aware defaults (for arrays, StringBuilder, common collections)
/// - Hardcoded global defaults - lowest priority
///
///
/// The type to get configuration for.
/// The effective purge configuration for the type. Returns global defaults if type is null.
///
///
/// If is null, this method returns global default options rather than throwing.
/// This follows the defensive programming principle of handling all inputs gracefully.
///
///
/// For generic types like List<List<int>>, patterns are matched in order of specificity:
///
/// - List<List<int>> - exact match
/// - List<List<>> - inner generic open
/// - List<> - outer generic open
///
///
///
/// Built-in type-aware defaults provide sensible out-of-box behavior for common types:
///
/// - Arrays: Purge more aggressively (1.5x buffer, 3 minute idle timeout)
/// - StringBuilder: Short-lived temporaries (2 minute idle timeout, 1 min retain)
/// - List<>, Dictionary<,>, HashSet<>: Common collections kept warm (2x buffer, 2 min retain)
///
///
///
public static PoolPurgeEffectiveOptions GetEffectiveOptions(Type type)
{
if (type == null)
{
// Defensive: return global defaults rather than throwing for null input
return new PoolPurgeEffectiveOptions(
enabled: GlobalEnabled,
idleTimeoutSeconds: DefaultGlobalIdleTimeoutSeconds,
minRetainCount: DefaultGlobalMinRetainCount,
warmRetainCount: DefaultGlobalWarmRetainCount,
bufferMultiplier: DefaultGlobalBufferMultiplier,
rollingWindowSeconds: DefaultGlobalRollingWindowSeconds,
hysteresisSeconds: DefaultGlobalHysteresisSeconds,
spikeThresholdMultiplier: DefaultGlobalSpikeThresholdMultiplier,
maxPurgesPerOperation: DefaultGlobalMaxPurgesPerOperation,
maxPoolSize: DefaultGlobalMaxPoolSize,
source: PoolPurgeConfigurationSource.GlobalDefaults
);
}
// Ensure built-in defaults are initialized
EnsureBuiltInDefaultsInitialized();
bool globalEnabled = GlobalEnabled;
bool typeDisabled;
bool settingsTypeDisabled;
PoolPurgeTypeOptions typeOptions = null;
PoolPurgeTypeOptions settingsTypeOptions = null;
PoolPurgeTypeOptions builtInTypeOptions = null;
PoolPurgeTypeOptions bestProgrammaticGenericOptions = null;
PoolPurgeTypeOptions bestSettingsGenericOptions = null;
PoolPurgeTypeOptions bestBuiltInGenericOptions = null;
int bestProgrammaticGenericPriority = int.MaxValue;
int bestSettingsGenericPriority = int.MaxValue;
int bestBuiltInGenericPriority = int.MaxValue;
lock (ConfigLock)
{
// Check programmatic disabled first (highest priority)
typeDisabled = DisabledTypes.Contains(type);
settingsTypeDisabled = SettingsDisabledTypes.Contains(type);
if (!typeDisabled)
{
// Programmatic type-specific configuration
TypeConfigurations.TryGetValue(type, out typeOptions);
// Settings-based type-specific configuration (lower priority)
SettingsTypeConfigurations.TryGetValue(type, out settingsTypeOptions);
// Built-in type-specific configuration (lowest priority)
BuiltInTypeConfigurations.TryGetValue(type, out builtInTypeOptions);
// For generic types, find the best matching pattern by specificity
if (type.IsGenericType)
{
// Get all possible patterns for this type in order of specificity
foreach (Type pattern in PoolTypeResolver.GetAllMatchingPatterns(type))
{
// Skip the exact type (already handled above)
if (pattern == type)
{
continue;
}
// Check programmatic generic configurations
if (
GenericTypeConfigurations.TryGetValue(
pattern,
out PoolPurgeTypeOptions programmaticOptions
)
)
{
int priority = PoolTypeResolver.GetMatchPriority(type, pattern);
if (priority < bestProgrammaticGenericPriority)
{
bestProgrammaticGenericPriority = priority;
bestProgrammaticGenericOptions = programmaticOptions;
}
}
// Check settings-based generic configurations
if (
SettingsGenericTypeConfigurations.TryGetValue(
pattern,
out PoolPurgeTypeOptions settingsOptions
)
)
{
int priority = PoolTypeResolver.GetMatchPriority(type, pattern);
if (priority < bestSettingsGenericPriority)
{
bestSettingsGenericPriority = priority;
bestSettingsGenericOptions = settingsOptions;
}
}
// Check built-in generic configurations (lowest priority)
if (
BuiltInGenericTypeConfigurations.TryGetValue(
pattern,
out PoolPurgeTypeOptions builtInOptions
)
)
{
int priority = PoolTypeResolver.GetMatchPriority(type, pattern);
if (priority < bestBuiltInGenericPriority)
{
bestBuiltInGenericPriority = priority;
bestBuiltInGenericOptions = builtInOptions;
}
}
}
}
// For arrays, check if we have a built-in configuration for Array
if (type.IsArray && builtInTypeOptions == null)
{
BuiltInTypeConfigurations.TryGetValue(
typeof(Array),
out builtInTypeOptions
);
}
}
}
// 1. Programmatic disabled (highest priority for disabling)
if (typeDisabled)
{
return new PoolPurgeEffectiveOptions(
enabled: false,
idleTimeoutSeconds: 0f,
minRetainCount: 0,
warmRetainCount: 0,
bufferMultiplier: DefaultBufferMultiplier,
rollingWindowSeconds: DefaultRollingWindowSeconds,
hysteresisSeconds: DefaultHysteresisSeconds,
spikeThresholdMultiplier: DefaultSpikeThresholdMultiplier,
maxPurgesPerOperation: DefaultMaxPurgesPerOperation,
maxPoolSize: DefaultMaxPoolSize,
source: PoolPurgeConfigurationSource.TypeDisabled
);
}
// 2. Programmatic type-specific configuration
if (typeOptions != null)
{
return BuildEffectiveOptions(
typeOptions,
PoolPurgeConfigurationSource.TypeSpecific
);
}
// 3. Settings-based per-type configuration
if (settingsTypeOptions != null)
{
return BuildEffectiveOptions(
settingsTypeOptions,
PoolPurgeConfigurationSource.UnityHelpersSettingsPerType
);
}
// 4. Settings-based disabled
if (settingsTypeDisabled)
{
return new PoolPurgeEffectiveOptions(
enabled: false,
idleTimeoutSeconds: 0f,
minRetainCount: 0,
warmRetainCount: 0,
bufferMultiplier: DefaultBufferMultiplier,
rollingWindowSeconds: DefaultRollingWindowSeconds,
hysteresisSeconds: DefaultHysteresisSeconds,
spikeThresholdMultiplier: DefaultSpikeThresholdMultiplier,
maxPurgesPerOperation: DefaultMaxPurgesPerOperation,
maxPoolSize: DefaultMaxPoolSize,
source: PoolPurgeConfigurationSource.UnityHelpersSettingsPerType
);
}
// 5. PoolPurgePolicyAttribute on the type
bool hasTypeAttribute = HasPoolPurgePolicyAttribute(
type,
out bool attributeEnabled,
out PoolPurgeTypeOptions attributeOptions
);
if (hasTypeAttribute)
{
if (!attributeEnabled)
{
return new PoolPurgeEffectiveOptions(
enabled: false,
idleTimeoutSeconds: 0f,
minRetainCount: 0,
warmRetainCount: 0,
bufferMultiplier: DefaultBufferMultiplier,
rollingWindowSeconds: DefaultRollingWindowSeconds,
hysteresisSeconds: DefaultHysteresisSeconds,
spikeThresholdMultiplier: DefaultSpikeThresholdMultiplier,
maxPurgesPerOperation: DefaultMaxPurgesPerOperation,
maxPoolSize: DefaultMaxPoolSize,
source: PoolPurgeConfigurationSource.Attribute
);
}
return BuildEffectiveOptions(
attributeOptions,
PoolPurgeConfigurationSource.Attribute
);
}
// 6. Programmatic generic pattern (best match by specificity)
if (bestProgrammaticGenericOptions != null)
{
return BuildEffectiveOptions(
bestProgrammaticGenericOptions,
PoolPurgeConfigurationSource.GenericPattern
);
}
// 7. Settings-based generic pattern (best match by specificity)
if (bestSettingsGenericOptions != null)
{
return BuildEffectiveOptions(
bestSettingsGenericOptions,
PoolPurgeConfigurationSource.UnityHelpersSettingsPerType
);
}
// 8. Built-in type-specific defaults (lowest priority tier)
if (builtInTypeOptions != null)
{
return BuildEffectiveOptions(
builtInTypeOptions,
PoolPurgeConfigurationSource.BuiltInDefaults
);
}
// 9. Built-in generic pattern defaults (lowest priority tier)
if (bestBuiltInGenericOptions != null)
{
return BuildEffectiveOptions(
bestBuiltInGenericOptions,
PoolPurgeConfigurationSource.BuiltInDefaults
);
}
// 10. Global defaults
return GetGlobalDefaultEffectiveOptions();
}
///
/// Gets the global default effective options with no type-specific configuration.
///
/// The global default purge configuration.
private static PoolPurgeEffectiveOptions GetGlobalDefaultEffectiveOptions()
{
return new PoolPurgeEffectiveOptions(
enabled: GlobalEnabled,
idleTimeoutSeconds: DefaultGlobalIdleTimeoutSeconds,
minRetainCount: DefaultGlobalMinRetainCount,
warmRetainCount: DefaultGlobalWarmRetainCount,
bufferMultiplier: DefaultGlobalBufferMultiplier,
rollingWindowSeconds: DefaultGlobalRollingWindowSeconds,
hysteresisSeconds: DefaultGlobalHysteresisSeconds,
spikeThresholdMultiplier: DefaultGlobalSpikeThresholdMultiplier,
maxPurgesPerOperation: DefaultGlobalMaxPurgesPerOperation,
maxPoolSize: DefaultGlobalMaxPoolSize,
source: PoolPurgeConfigurationSource.GlobalDefaults
);
}
///
/// Checks if intelligent purging is enabled for a specific type.
///
/// The type to check.
/// true if intelligent purging is enabled; otherwise, false.
public static bool IsEnabled()
{
return GetEffectiveOptions().Enabled;
}
///
/// Checks if intelligent purging is enabled for a specific type.
///
/// The type to check.
/// true if intelligent purging is enabled; otherwise, false.
/// Thrown when type is null.
public static bool IsEnabled(Type type)
{
return GetEffectiveOptions(type).Enabled;
}
///
/// Gets the effective configuration for a specific type, with size-aware policy adjustments.
/// Large objects (above ) receive stricter policies.
///
/// The type to get configuration for.
/// The effective purge configuration for the type, adjusted for size.
///
///
/// When is true and the type is estimated to
/// exceed , the returned options are adjusted:
///
/// - is reduced to
/// - is multiplied by
/// - is reduced to
///
///
///
public static PoolPurgeEffectiveOptions GetSizeAwareEffectiveOptions()
{
return GetSizeAwareEffectiveOptions(typeof(T));
}
///
/// Gets the effective configuration for a specific type, with size-aware policy adjustments.
/// Large objects (above ) receive stricter policies.
///
/// The type to get configuration for.
/// The effective purge configuration for the type, adjusted for size.
///
///
/// If is null, returns the result of
/// with a null type (which uses global defaults).
///
///
/// When is true and the type is estimated to
/// exceed , the returned options are adjusted:
///
/// - is reduced to
/// - is multiplied by
/// - is reduced to
///
///
///
public static PoolPurgeEffectiveOptions GetSizeAwareEffectiveOptions(Type type)
{
if (type == null)
{
return GetGlobalDefaultEffectiveOptions();
}
PoolPurgeEffectiveOptions baseOptions = GetEffectiveOptions(type);
// If size-aware policies are disabled, return base options
if (!SizeAwarePoliciesEnabled)
{
return baseOptions;
}
// Check if this type is a large object
int estimatedSize = PoolSizeEstimator.EstimateItemSizeBytes(type);
int threshold = LargeObjectThresholdBytes;
if (estimatedSize < threshold)
{
return baseOptions;
}
// Apply large object adjustments
float adjustedIdleTimeout =
baseOptions.IdleTimeoutSeconds * LargeObjectIdleTimeoutMultiplier;
float adjustedBufferMultiplier = LargeObjectBufferMultiplier;
int adjustedWarmRetainCount = LargeObjectWarmRetainCount;
// Use the smaller of the configured and large-object values
if (baseOptions.BufferMultiplier < adjustedBufferMultiplier)
{
adjustedBufferMultiplier = baseOptions.BufferMultiplier;
}
if (baseOptions.WarmRetainCount < adjustedWarmRetainCount)
{
adjustedWarmRetainCount = baseOptions.WarmRetainCount;
}
return new PoolPurgeEffectiveOptions(
enabled: baseOptions.Enabled,
idleTimeoutSeconds: adjustedIdleTimeout,
minRetainCount: baseOptions.MinRetainCount,
warmRetainCount: adjustedWarmRetainCount,
bufferMultiplier: adjustedBufferMultiplier,
rollingWindowSeconds: baseOptions.RollingWindowSeconds,
hysteresisSeconds: baseOptions.HysteresisSeconds,
spikeThresholdMultiplier: baseOptions.SpikeThresholdMultiplier,
maxPurgesPerOperation: baseOptions.MaxPurgesPerOperation,
maxPoolSize: baseOptions.MaxPoolSize,
source: baseOptions.Source
);
}
///
/// Checks if a type is considered a large object based on its estimated size.
///
/// The type to check.
/// true if the type's estimated size exceeds ; otherwise, false.
public static bool IsLargeObject()
{
return IsLargeObject(typeof(T));
}
///
/// Checks if a type is considered a large object based on its estimated size.
///
/// The type to check.
/// true if the type's estimated size exceeds ; otherwise, false.
///
/// Returns false if is null.
///
public static bool IsLargeObject(Type type)
{
if (type == null)
{
return false;
}
int estimatedSize = PoolSizeEstimator.EstimateItemSizeBytes(type);
return estimatedSize >= LargeObjectThresholdBytes;
}
private static PoolPurgeEffectiveOptions BuildEffectiveOptions(
PoolPurgeTypeOptions options,
PoolPurgeConfigurationSource source
)
{
bool enabled = options.Enabled ?? GlobalEnabled;
float idleTimeout = options.IdleTimeoutSeconds ?? DefaultGlobalIdleTimeoutSeconds;
int minRetain = options.MinRetainCount ?? DefaultGlobalMinRetainCount;
int warmRetain = options.WarmRetainCount ?? DefaultGlobalWarmRetainCount;
float buffer = options.BufferMultiplier ?? DefaultGlobalBufferMultiplier;
float window = options.RollingWindowSeconds ?? DefaultGlobalRollingWindowSeconds;
float hysteresis = options.HysteresisSeconds ?? DefaultGlobalHysteresisSeconds;
float spike = options.SpikeThresholdMultiplier ?? DefaultGlobalSpikeThresholdMultiplier;
int maxPurges = options.MaxPurgesPerOperation ?? DefaultGlobalMaxPurgesPerOperation;
int maxPoolSize = options.MaxPoolSize ?? DefaultGlobalMaxPoolSize;
return new PoolPurgeEffectiveOptions(
enabled,
idleTimeout,
minRetain,
warmRetain,
buffer,
window,
hysteresis,
spike,
maxPurges,
maxPoolSize,
source
);
}
private static bool HasPoolPurgePolicyAttribute(
Type type,
out bool enabled,
out PoolPurgeTypeOptions attributeOptions
)
{
// Check cache first to avoid repeated reflection on the same type
lock (ConfigLock)
{
if (
AttributeCache.TryGetValue(
type,
out (bool HasAttribute, bool Enabled, PoolPurgeTypeOptions Options) cached
)
)
{
enabled = cached.Enabled;
attributeOptions = cached.Options;
return cached.HasAttribute;
}
}
// Perform reflection (outside lock to minimize contention)
PoolPurgePolicyAttribute attribute =
type.GetCustomAttribute();
bool hasAttribute;
bool attributeEnabled;
PoolPurgeTypeOptions options;
if (attribute == null)
{
hasAttribute = false;
attributeEnabled = true;
options = null;
}
else
{
hasAttribute = true;
attributeEnabled = attribute.Enabled;
options = new PoolPurgeTypeOptions
{
Enabled = attribute.Enabled,
IdleTimeoutSeconds = attribute.IdleTimeoutSeconds,
MinRetainCount = attribute.MinRetainCount,
WarmRetainCount = attribute.WarmRetainCount,
};
}
// Cache the result
lock (ConfigLock)
{
AttributeCache[type] = (hasAttribute, attributeEnabled, options);
}
enabled = attributeEnabled;
attributeOptions = options;
return hasAttribute;
}
///
/// Registers application lifecycle hooks for automatic pool purging.
/// This method is automatically called during runtime initialization via .
///
///
///
/// This method registers handlers for the following Unity events:
///
/// - - Triggers emergency purge when the system is low on memory
/// - - Triggers purge when the app loses focus (backgrounds on mobile)
/// - - Triggers purge when a scene is unloaded
///
///
///
/// The method is idempotent - calling it multiple times has no additional effect.
///
///
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
internal static void RegisterLifecycleHooks()
{
if (Interlocked.Exchange(ref _lifecycleHooksRegistered, 1) != 0)
{
return;
}
Application.lowMemory += OnLowMemory;
Application.focusChanged += OnFocusChanged;
SceneManager.sceneUnloaded += OnSceneUnloaded;
}
///
/// Unregisters application lifecycle hooks. Primarily used for testing.
///
internal static void UnregisterLifecycleHooks()
{
if (Interlocked.Exchange(ref _lifecycleHooksRegistered, 0) == 0)
{
return;
}
Application.lowMemory -= OnLowMemory;
Application.focusChanged -= OnFocusChanged;
SceneManager.sceneUnloaded -= OnSceneUnloaded;
}
private static void OnLowMemory()
{
if (!PurgeOnLowMemory)
{
return;
}
// Use ForceFullPurgeAll to bypass MaxPurgesPerOperation limits during memory pressure.
// This ensures all eligible items are purged immediately when the system is low on memory.
GlobalPoolRegistry.ForceFullPurgeAll(
respectHysteresis: false,
reason: PurgeReason.MemoryPressure
);
}
private static void OnFocusChanged(bool hasFocus)
{
if (hasFocus || !PurgeOnAppBackground)
{
return;
}
PurgeAllPools(respectHysteresis: true, reason: PurgeReason.AppBackgrounded);
}
///
/// Handles scene unloaded events by triggering a purge on all pools.
///
///
/// The scene that was unloaded. This parameter is unused because purge operations
/// are global (affecting all pools) rather than scene-specific. The parameter is
/// required by the delegate signature.
///
///
///
/// When a scene is unloaded, pooled objects that were primarily used in that scene
/// may no longer be needed. This handler triggers a purge check on all pools to
/// reclaim memory from items that are no longer actively used.
///
///
/// The purge respects hysteresis settings to avoid purge-allocate cycles during
/// rapid scene transitions. Set to false
/// to disable this behavior.
///
///
private static void OnSceneUnloaded(Scene scene)
{
// Scene parameter unused: purge is global across all pools, not scene-specific.
// The parameter is required by the SceneManager.sceneUnloaded delegate signature.
_ = scene;
if (!PurgeOnSceneUnload)
{
return;
}
PurgeAllPools(respectHysteresis: true, reason: PurgeReason.SceneUnloaded);
}
///
/// Purges all registered pools.
///
///
/// If true, pools in their hysteresis period (after a usage spike) will skip purging.
/// If false, all pools are purged regardless of hysteresis state (emergency purge).
///
/// The reason for purging (used in callbacks and statistics).
/// The total number of items purged across all pools.
public static int PurgeAllPools(bool respectHysteresis, PurgeReason reason)
{
return GlobalPoolRegistry.PurgeAll(respectHysteresis, reason);
}
///
/// Purges all registered pools with the reason.
///
/// The total number of items purged across all pools.
public static int PurgeAllPools()
{
return PurgeAllPools(respectHysteresis: true, reason: PurgeReason.Explicit);
}
}
///
/// Global registry for all pool instances, enabling cross-pool operations like purge-all
/// and global memory budget enforcement.
///
///
///
/// Pools automatically register themselves when created and unregister when disposed.
/// The registry uses weak references to avoid preventing pool garbage collection.
///
///
/// The global memory budget feature prevents aggregate memory bloat by tracking the total
/// number of pooled items across all pools and purging from least-recently-used pools when
/// the budget is exceeded. Use to configure the maximum
/// allowed items and to trigger purging.
///
///
public static class GlobalPoolRegistry
{
///
/// Default maximum number of pooled items across all pools (50,000).
///
public const long DefaultGlobalMaxPooledItems = 50000;
///
/// Default interval in seconds between automatic budget enforcement checks.
///
public const float DefaultBudgetEnforcementIntervalSeconds = 30f;
///
/// Interface for pools that can be purged via the global registry.
///
public interface IPurgeable
{
///
/// Purges items from the pool with the specified reason.
///
/// The reason for purging.
///
/// If true, bypasses the hysteresis check and purges immediately.
/// Used for emergency purges (e.g., low memory situations).
///
/// The number of items purged.
///
/// This method purges all eligible items without respecting MaxPurgesPerOperation limits.
/// It is treated as an explicit cleanup operation similar to .
///
int Purge(PurgeReason reason, bool ignoreHysteresis = false);
///
/// Forces a full purge with a specified reason, bypassing MaxPurgesPerOperation limits.
///
/// The reason for purging (used in callbacks).
///
/// If true, bypasses the hysteresis check and purges immediately.
/// Used for emergency purges (e.g., low memory situations).
///
/// The number of items purged.
int ForceFullPurge(PurgeReason reason, bool ignoreHysteresis = false);
}
///
/// Interface for pools that provide statistics for global budget tracking.
/// Extends with the ability to report current size and last access time.
///
public interface IPoolStatistics : IPurgeable
{
///
/// Gets the current number of items in the pool.
///
int CurrentPooledCount { get; }
///
/// Gets the time (in seconds since pool creation or epoch) when the pool was last accessed.
/// Used for LRU-based purging when the global budget is exceeded.
///
float LastAccessTime { get; }
///
/// Purges a specific number of items from the pool for budget enforcement.
///
/// The maximum number of items to purge.
/// The actual number of items purged (may be less than requested if pool has fewer items).
///
/// This method is called by to reduce pool size.
/// It respects and will not purge below that threshold.
///
int PurgeForBudget(int count);
}
private static readonly object RegistryLock = new object();
private static readonly List> RegisteredPools =
new List>();
private static readonly List BudgetEnforcementPools =
new List();
private static long _globalMaxPooledItems = DefaultGlobalMaxPooledItems;
private static int _budgetEnforcementEnabled = 1;
private static float _budgetEnforcementIntervalSeconds =
DefaultBudgetEnforcementIntervalSeconds;
private static float _lastBudgetEnforcementTime;
private static readonly System.Diagnostics.Stopwatch RegistryStopwatch =
System.Diagnostics.Stopwatch.StartNew();
///
/// Gets or sets the global maximum number of pooled items across all pools.
/// When the total exceeds this limit, purges from least-recently-used pools.
/// A value of 0 or less disables budget enforcement.
/// Default is 50,000.
///
public static long GlobalMaxPooledItems
{
get => Volatile.Read(ref _globalMaxPooledItems);
set => Volatile.Write(ref _globalMaxPooledItems, value);
}
///
/// Gets or sets whether automatic budget enforcement is enabled.
/// When enabled, is called periodically during pool operations.
/// Default is true.
///
public static bool BudgetEnforcementEnabled
{
get => Volatile.Read(ref _budgetEnforcementEnabled) != 0;
set => Volatile.Write(ref _budgetEnforcementEnabled, value ? 1 : 0);
}
///
/// Gets or sets the interval in seconds between automatic budget enforcement checks.
/// Default is 30 seconds.
///
public static float BudgetEnforcementIntervalSeconds
{
get => Volatile.Read(ref _budgetEnforcementIntervalSeconds);
set =>
Volatile.Write(
ref _budgetEnforcementIntervalSeconds,
value > 0f ? value : DefaultBudgetEnforcementIntervalSeconds
);
}
///
/// Gets the current total number of pooled items across all registered pools.
///
///
/// This property iterates through all registered pools to calculate the total.
/// For pools that don't implement , their items are not counted.
/// Dead pool references are cleaned up during iteration.
///
public static long CurrentTotalPooledItems
{
get
{
long total = 0;
lock (RegistryLock)
{
for (int i = RegisteredPools.Count - 1; i >= 0; i--)
{
if (!RegisteredPools[i].TryGetTarget(out IPurgeable pool))
{
RegisteredPools.RemoveAt(i);
continue;
}
if (pool is IPoolStatistics stats)
{
total += stats.CurrentPooledCount;
}
}
}
return total;
}
}
///
/// Gets the current number of registered pools (including potentially collected ones).
/// This count may be higher than the actual number of live pools.
///
public static int RegisteredCount
{
get
{
lock (RegistryLock)
{
return RegisteredPools.Count;
}
}
}
///
/// Registers a pool with the global registry.
///
/// The pool to register.
public static void Register(IPurgeable pool)
{
if (pool == null)
{
return;
}
lock (RegistryLock)
{
RegisteredPools.Add(new WeakReference(pool));
}
}
///
/// Unregisters a pool from the global registry.
///
/// The pool to unregister.
public static void Unregister(IPurgeable pool)
{
if (pool == null)
{
return;
}
lock (RegistryLock)
{
for (int i = RegisteredPools.Count - 1; i >= 0; i--)
{
if (
!RegisteredPools[i].TryGetTarget(out IPurgeable target)
|| ReferenceEquals(target, pool)
)
{
RegisteredPools.RemoveAt(i);
}
}
}
}
///
/// Purges all registered pools.
///
///
/// If true, pools in their hysteresis period will skip purging.
/// If false, all pools are purged regardless of hysteresis state (emergency purge).
///
/// The reason for purging.
/// The total number of items purged across all pools.
public static int PurgeAll(bool respectHysteresis, PurgeReason reason)
{
int totalPurged = 0;
bool ignoreHysteresis = !respectHysteresis;
// Copy pool references while holding lock, then purge outside lock
// to avoid holding lock during potentially slow purge operations
using PooledResource> pooled = Buffers.List.Get(
out List poolsToPurge
);
lock (RegistryLock)
{
for (int i = RegisteredPools.Count - 1; i >= 0; i--)
{
if (RegisteredPools[i].TryGetTarget(out IPurgeable pool))
{
poolsToPurge.Add(pool);
}
else
{
// Clean up dead references
RegisteredPools.RemoveAt(i);
}
}
}
for (int i = 0; i < poolsToPurge.Count; i++)
{
try
{
totalPurged += poolsToPurge[i].Purge(reason, ignoreHysteresis);
}
catch (Exception e)
{
// Swallow exceptions from individual pools to ensure all pools are attempted
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning($"[PoolPurgeSettings] Failed to purge pool: {e.Message}");
#endif
_ = e;
}
}
return totalPurged;
}
///
/// Forces a full purge on all registered pools, bypassing MaxPurgesPerOperation limits.
///
///
/// If true, pools in their hysteresis period will skip purging.
/// If false, all pools are purged regardless of hysteresis state (emergency purge).
///
/// The reason for purging.
/// The total number of items purged across all pools.
///
/// This method is used for emergency purges (e.g., )
/// where immediate memory reclamation is critical and gradual purging limits should be bypassed.
///
public static int ForceFullPurgeAll(bool respectHysteresis, PurgeReason reason)
{
int totalPurged = 0;
bool ignoreHysteresis = !respectHysteresis;
// Copy pool references while holding lock, then purge outside lock
// to avoid holding lock during potentially slow purge operations
using PooledResource> pooled = Buffers.List.Get(
out List poolsToPurge
);
lock (RegistryLock)
{
for (int i = RegisteredPools.Count - 1; i >= 0; i--)
{
if (RegisteredPools[i].TryGetTarget(out IPurgeable pool))
{
poolsToPurge.Add(pool);
}
else
{
// Clean up dead references
RegisteredPools.RemoveAt(i);
}
}
}
for (int i = 0; i < poolsToPurge.Count; i++)
{
try
{
totalPurged += poolsToPurge[i].ForceFullPurge(reason, ignoreHysteresis);
}
catch (Exception e)
{
// Swallow exceptions from individual pools to ensure all pools are attempted
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning(
$"[PoolPurgeSettings] Failed to force-purge pool: {e.Message}"
);
#endif
_ = e;
}
}
return totalPurged;
}
///
/// Enforces the global pool budget by purging from least-recently-used pools.
///
/// The total number of items purged across all pools.
///
///
/// This method calculates the total pooled items across all registered pools and,
/// if the total exceeds , purges items from the
/// least-recently-used pools until the budget is satisfied.
///
///
/// Pools are sorted by (ascending)
/// and items are purged starting from the oldest pools. Each pool's
/// is respected.
///
///
public static int EnforceBudget()
{
long maxItems = GlobalMaxPooledItems;
if (maxItems <= 0)
{
return 0;
}
long currentTotal = 0;
int totalPurged = 0;
lock (RegistryLock)
{
BudgetEnforcementPools.Clear();
for (int i = RegisteredPools.Count - 1; i >= 0; i--)
{
if (!RegisteredPools[i].TryGetTarget(out IPurgeable pool))
{
RegisteredPools.RemoveAt(i);
continue;
}
if (pool is IPoolStatistics stats)
{
BudgetEnforcementPools.Add(stats);
currentTotal += stats.CurrentPooledCount;
}
}
if (currentTotal <= maxItems)
{
BudgetEnforcementPools.Clear();
return 0;
}
long excess = currentTotal - maxItems;
SortPoolsByLastAccessTime(BudgetEnforcementPools);
long remaining = excess;
for (int i = 0; i < BudgetEnforcementPools.Count && remaining > 0; i++)
{
IPoolStatistics pool = BudgetEnforcementPools[i];
int poolCount = pool.CurrentPooledCount;
if (poolCount <= 0)
{
continue;
}
int toPurge = (int)System.Math.Min(remaining, poolCount);
if (toPurge <= 0)
{
continue;
}
try
{
int purged = pool.PurgeForBudget(toPurge);
totalPurged += purged;
remaining -= purged;
}
catch (Exception e)
{
// Swallow exceptions to continue with other pools
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning(
$"[PoolPurgeSettings] Failed to purge pool for budget: {e.Message}"
);
#endif
_ = e;
}
}
BudgetEnforcementPools.Clear();
}
return totalPurged;
}
///
/// Tries to enforce the budget if the enforcement interval has elapsed.
/// Called automatically during pool operations when is true.
///
/// The number of items purged, or 0 if enforcement was not needed or not due.
public static int TryEnforceBudgetIfNeeded()
{
if (!BudgetEnforcementEnabled)
{
return 0;
}
long maxItems = GlobalMaxPooledItems;
if (maxItems <= 0)
{
return 0;
}
float currentTime = (float)RegistryStopwatch.Elapsed.TotalSeconds;
float interval = BudgetEnforcementIntervalSeconds;
float lastEnforcement = Volatile.Read(ref _lastBudgetEnforcementTime);
if (currentTime - lastEnforcement < interval)
{
return 0;
}
float original = Interlocked.CompareExchange(
ref _lastBudgetEnforcementTime,
currentTime,
lastEnforcement
);
if (original != lastEnforcement)
{
return 0;
}
return EnforceBudget();
}
///
/// Gets a snapshot of global pool statistics.
///
/// A snapshot of current global pool metrics.
public static GlobalPoolStatistics GetStatistics()
{
int livePoolCount = 0;
int statsPoolCount = 0;
long totalItems = 0;
float oldestAccessTime = float.MaxValue;
float newestAccessTime = float.MinValue;
lock (RegistryLock)
{
for (int i = RegisteredPools.Count - 1; i >= 0; i--)
{
if (!RegisteredPools[i].TryGetTarget(out IPurgeable pool))
{
RegisteredPools.RemoveAt(i);
continue;
}
livePoolCount++;
if (pool is IPoolStatistics stats)
{
statsPoolCount++;
totalItems += stats.CurrentPooledCount;
float accessTime = stats.LastAccessTime;
if (accessTime < oldestAccessTime)
{
oldestAccessTime = accessTime;
}
if (accessTime > newestAccessTime)
{
newestAccessTime = accessTime;
}
}
}
}
if (statsPoolCount == 0)
{
oldestAccessTime = 0f;
newestAccessTime = 0f;
}
return new GlobalPoolStatistics(
livePoolCount,
statsPoolCount,
totalItems,
GlobalMaxPooledItems,
oldestAccessTime,
newestAccessTime
);
}
///
/// Resets all budget-related settings to their default values.
///
public static void ResetBudgetSettings()
{
GlobalMaxPooledItems = DefaultGlobalMaxPooledItems;
BudgetEnforcementEnabled = true;
BudgetEnforcementIntervalSeconds = DefaultBudgetEnforcementIntervalSeconds;
Volatile.Write(ref _lastBudgetEnforcementTime, 0f);
}
///
/// Clears all registered pools from the registry.
/// This does not dispose or purge the pools, only removes their registrations.
/// Primarily used for testing.
///
internal static void Clear()
{
lock (RegistryLock)
{
RegisteredPools.Clear();
}
Volatile.Write(ref _lastBudgetEnforcementTime, 0f);
}
private static void SortPoolsByLastAccessTime(List pools)
{
int count = pools.Count;
for (int i = 1; i < count; i++)
{
IPoolStatistics key = pools[i];
float keyTime = key.LastAccessTime;
int j = i - 1;
while (j >= 0 && pools[j].LastAccessTime > keyTime)
{
pools[j + 1] = pools[j];
j--;
}
pools[j + 1] = key;
}
}
}
///
/// Immutable snapshot of global pool registry statistics.
///
public readonly struct GlobalPoolStatistics
{
///
/// Gets the number of live (non-collected) registered pools.
///
public int LivePoolCount { get; }
///
/// Gets the number of pools that implement .
///
public int StatisticsPoolCount { get; }
///
/// Gets the total number of pooled items across all statistics-enabled pools.
///
public long TotalPooledItems { get; }
///
/// Gets the configured global maximum pooled items budget.
///
public long GlobalMaxPooledItems { get; }
///
/// Gets the last access time of the oldest pool (earliest ).
///
public float OldestPoolAccessTime { get; }
///
/// Gets the last access time of the newest pool (latest ).
///
public float NewestPoolAccessTime { get; }
///
/// Gets the ratio of current pooled items to the budget.
/// A value greater than 1.0 indicates the budget is exceeded.
///
public float BudgetUtilization =>
GlobalMaxPooledItems > 0 ? (float)TotalPooledItems / GlobalMaxPooledItems : 0f;
///
/// Gets whether the current total exceeds the configured budget.
///
public bool IsBudgetExceeded =>
GlobalMaxPooledItems > 0 && TotalPooledItems > GlobalMaxPooledItems;
///
/// Creates a new global pool statistics snapshot.
///
public GlobalPoolStatistics(
int livePoolCount,
int statisticsPoolCount,
long totalPooledItems,
long globalMaxPooledItems,
float oldestPoolAccessTime,
float newestPoolAccessTime
)
{
LivePoolCount = livePoolCount;
StatisticsPoolCount = statisticsPoolCount;
TotalPooledItems = totalPooledItems;
GlobalMaxPooledItems = globalMaxPooledItems;
OldestPoolAccessTime = oldestPoolAccessTime;
NewestPoolAccessTime = newestPoolAccessTime;
}
///
public override string ToString()
{
return $"GlobalPoolStatistics(LivePools={LivePoolCount}, StatsPools={StatisticsPoolCount}, "
+ $"TotalItems={TotalPooledItems}, MaxItems={GlobalMaxPooledItems}, "
+ $"Utilization={BudgetUtilization:P1}, Exceeded={IsBudgetExceeded})";
}
}
///
/// Mutable options for configuring intelligent pool purging for a specific type.
///
public sealed class PoolPurgeTypeOptions
{
///
/// Gets or sets whether intelligent purging is enabled for this type.
/// If null, uses the global setting.
///
public bool? Enabled { get; set; }
///
/// Gets or sets the idle timeout in seconds.
/// Items idle longer than this are eligible for purging.
/// If null, uses the global default.
///
public float? IdleTimeoutSeconds { get; set; }
///
/// Gets or sets the minimum number of items to always retain during purge operations.
/// This is the absolute floor - pools never purge below this, ever.
/// If null, uses the global default.
///
public int? MinRetainCount { get; set; }
///
/// Gets or sets the warm retain count for active pools.
/// Active pools (accessed within ) keep this many items warm
/// to avoid cold-start allocations. Idle pools purge to .
/// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0).
/// If null, uses the global default.
///
public int? WarmRetainCount { get; set; }
///
/// Gets or sets the buffer multiplier for comfortable pool size calculation.
/// If null, uses the global default.
///
public float? BufferMultiplier { get; set; }
///
/// Gets or sets the rolling window duration in seconds for high water mark tracking.
/// If null, uses the global default.
///
public float? RollingWindowSeconds { get; set; }
///
/// Gets or sets the hysteresis duration in seconds.
/// After a usage spike, purging is suppressed for this duration.
/// If null, uses the global default.
///
public float? HysteresisSeconds { get; set; }
///
/// Gets or sets the spike threshold multiplier.
/// A spike is detected when concurrent rentals exceed the rolling average by this factor.
/// If null, uses the global default.
///
public float? SpikeThresholdMultiplier { get; set; }
///
/// Gets or sets the maximum number of items to purge per operation.
/// Limits GC pressure by spreading large purge operations across multiple calls.
/// A value of 0 means unlimited (purge all eligible items in one operation).
/// If null, uses the global default.
///
public int? MaxPurgesPerOperation { get; set; }
///
/// Gets or sets the maximum pool size.
/// Pools exceeding this limit will have items purged.
/// A value of 0 means unbounded (no size limit).
/// If null, uses the global default.
///
public int? MaxPoolSize { get; set; }
}
///
/// Immutable effective configuration for intelligent pool purging.
///
public readonly struct PoolPurgeEffectiveOptions
{
///
/// Gets whether intelligent purging is enabled.
///
public bool Enabled { get; }
///
/// Gets the idle timeout in seconds.
///
public float IdleTimeoutSeconds { get; }
///
/// Gets the minimum number of items to always retain.
/// This is the absolute floor - pools never purge below this, ever.
///
public int MinRetainCount { get; }
///
/// Gets the warm retain count for active pools.
/// Active pools keep this many items warm to avoid cold-start allocations.
///
public int WarmRetainCount { get; }
///
/// Gets the buffer multiplier for comfortable pool size calculation.
///
public float BufferMultiplier { get; }
///
/// Gets the rolling window duration in seconds for high water mark tracking.
///
public float RollingWindowSeconds { get; }
///
/// Gets the hysteresis duration in seconds.
///
public float HysteresisSeconds { get; }
///
/// Gets the spike threshold multiplier.
///
public float SpikeThresholdMultiplier { get; }
///
/// Gets the maximum number of items to purge per operation.
/// A value of 0 means unlimited (purge all eligible items in one operation).
///
public int MaxPurgesPerOperation { get; }
///
/// Gets the maximum pool size.
/// A value of 0 means unbounded (no size limit).
///
public int MaxPoolSize { get; }
///
/// Gets the source of this configuration.
///
public PoolPurgeConfigurationSource Source { get; }
///
/// Creates a new effective options instance.
///
public PoolPurgeEffectiveOptions(
bool enabled,
float idleTimeoutSeconds,
int minRetainCount,
int warmRetainCount,
float bufferMultiplier,
float rollingWindowSeconds,
float hysteresisSeconds,
float spikeThresholdMultiplier,
int maxPurgesPerOperation,
int maxPoolSize,
PoolPurgeConfigurationSource source
)
{
Enabled = enabled;
IdleTimeoutSeconds = idleTimeoutSeconds;
MinRetainCount = minRetainCount;
WarmRetainCount = warmRetainCount;
BufferMultiplier = bufferMultiplier;
RollingWindowSeconds = rollingWindowSeconds;
HysteresisSeconds = hysteresisSeconds;
SpikeThresholdMultiplier = spikeThresholdMultiplier;
MaxPurgesPerOperation = maxPurgesPerOperation;
MaxPoolSize = maxPoolSize;
Source = source;
}
///
public override string ToString()
{
return $"PoolPurgeEffectiveOptions(Enabled={Enabled}, IdleTimeout={IdleTimeoutSeconds}s, "
+ $"MinRetain={MinRetainCount}, WarmRetain={WarmRetainCount}, Buffer={BufferMultiplier}, "
+ $"Window={RollingWindowSeconds}s, Hysteresis={HysteresisSeconds}s, "
+ $"SpikeThreshold={SpikeThresholdMultiplier}, MaxPurgesPerOp={MaxPurgesPerOperation}, "
+ $"MaxPoolSize={MaxPoolSize}, Source={Source})";
}
}
///
/// Indicates the source of a pool purge configuration.
///
public enum PoolPurgeConfigurationSource
{
///
/// Configuration comes from global defaults.
///
GlobalDefaults = 0,
///
/// Configuration comes from a type-specific setting.
///
TypeSpecific = 1,
///
/// Configuration comes from a generic type pattern.
///
GenericPattern = 2,
///
/// Configuration comes from a on the type.
///
Attribute = 3,
///
/// The type is explicitly disabled.
///
TypeDisabled = 4,
///
/// Configuration comes from UnityHelpersSettings per-type configuration.
///
UnityHelpersSettingsPerType = 5,
///
/// Configuration comes from built-in type-aware defaults.
/// These are sensible defaults for common types (arrays, collections, StringBuilder)
/// that are applied at the lowest priority level before global defaults.
///
BuiltInDefaults = 6,
}
}