Skip to content

Add custom ObjectPoolProvider support for pooled registrations#15

Merged
tillig merged 8 commits into
developfrom
feature/issue-6-custom-pool
Jun 17, 2026
Merged

Add custom ObjectPoolProvider support for pooled registrations#15
tillig merged 8 commits into
developfrom
feature/issue-6-custom-pool

Conversation

@tillig

@tillig tillig commented Jun 17, 2026

Copy link
Copy Markdown
Member

Implements #6 — lets a pooled registration supply a custom Microsoft.Extensions.ObjectPool.ObjectPoolProvider so the caller controls where pooled instances are stored and when they are evicted, while Autofac keeps owning construction, the pooling callbacks, and disposal of the pool object.

Feature

New overloads on RegistrationExtensions:

  • PooledInstancePerLifetimeScope(Func<IComponentContext, ObjectPoolProvider> providerFactory)
  • PooledInstancePerLifetimeScope(Func<IComponentContext, IPooledRegistrationPolicy<TLimit>> policyFactory, Func<IComponentContext, ObjectPoolProvider> providerFactory)
  • PooledInstancePerMatchingLifetimeScope(Func<IComponentContext, ObjectPoolProvider> providerFactory, params object[] lifetimeScopeTags)
  • PooledInstancePerMatchingLifetimeScope(Func<IComponentContext, IPooledRegistrationPolicy<TLimit>> policyFactory, Func<IComponentContext, ObjectPoolProvider> providerFactory, params object[] lifetimeScopeTags)

The provider factory is invoked once when the pool is built, resolved from the pool-owning (root) scope, so the provider and its dependencies can come from the container. Autofac still resolves the pooled instances through the container (DI + IPooledComponent / IPooledRegistrationPolicy callbacks all work as normal); the provider only controls storage and eviction. With a custom provider, MaximumRetained does not size the pool — the provider owns sizing/eviction — and the documented disposal contract is: the container disposes the pool object if it is IDisposable, and the custom pool is responsible for disposing instances it declines on return or evicts asynchronously.

Also included

  • Disposal-leak fix (folded in from Add policy factory overloads for pooled instance registration #14): PooledInstanceContext<T> is now IDisposable and cascades disposal to the pool, so a retained disposable pool is no longer leaked at container shutdown. All PoolActivator paths now produce the wrapper uniformly, and PoolActivator / RegisterPooled were unified (one constructor, one normalized branch, one private RegisterPooled).
  • Compiled, tested example: a cache-backed ObjectPoolProvider that draws its IMemoryCache from Autofac, under test/Autofac.Pooling.Test/Examples/Caching/, plus a "custom pool provider" section in the README.
  • Test coverage: 100% line coverage of the non-generated source (added null-guard, policy, PoolService, and PoolActivator tests; InternalsVisibleTo for the strong-named assembly).
  • Housekeeping: dependency bumps (Autofac 9.2.0, Microsoft.Extensions.ObjectPool 10.0.9), csproj/build-metadata alignment with core Autofac, .snupkg symbol packages with the packaged README, and test-naming/standardization cleanup.

Closes #6.

@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.62%. Comparing base (5a2b871) to head (bae97d6).

Additional details and impacted files
@@             Coverage Diff              @@
##           develop      #15       +/-   ##
============================================
+ Coverage    86.18%   99.62%   +13.43%     
============================================
  Files            9        9               
  Lines          333      264       -69     
  Branches        32       36        +4     
============================================
- Hits           287      263       -24     
+ Misses          26        0       -26     
+ Partials        20        1       -19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tillig

tillig commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

@zms9110750 — this is the implementation of the custom pool provider support we designed together in #6. Since you were the original requester and had a clear view of what the usage should feel like, I'd really value your take on whether the result matches what you had in mind. No pressure on timing.

First, an apology: this PR got noisy. It grew beyond the core feature to include some build/metadata standardization, dependency bumps, and test cleanup, so the diff is larger and more distracting than the actual change warrants. To save you wading through it, here are the two places that actually show how the feature is used:

  • README.md → "Custom Pool Providers" — the end-to-end usage: registering a custom ObjectPoolProvider, how it composes with a policy, and the disposal contract (the pool owns what it stores/evicts/declines).
  • test/Autofac.Pooling.Test/Examples/Caching/ — the "real example in tests" I promised: a cache-backed provider/pool (CacheObjectPoolProvider, CacheObjectPool) that draws its IMemoryCache from Autofac, plus integration tests showing a consumer receiving a pooled instance and that instance being reused across scopes.

Those two together are the whole story; everything else in the diff is plumbing.

I also want to be upfront about what we intentionally left out, since it's exactly the territory you were careful about:

  • No fluent Build() API. As you laid out, pooling's surface is two stable axes (policy = how to get/return, provider = where to store/evict), so overloads fit the Autofac idiom and don't balloon the API. Build() stays additive and in our back pocket if the surface ever earns it.
  • No extra base classes / helper types for custom pools. The disposal contract is documented rather than enforced by a shipped helper, to avoid growing the surface with machinery before there's weight on it.
  • No raw "resolve a pool and call Get()" feature. The provider seam is the outlet for bespoke cases, and genuine outliers beyond its reach are the honest place to fork.

None of these are closed doors — if real demand shows up, any of them can be added later without breaking existing registrations. The bias here was just to keep the first cut small and idiomatic rather than pre-build for every edge case.

Does the shape — and especially the README usage and the caching example — match what you were hoping for?

@zms9110750

Copy link
Copy Markdown
Contributor

I've tested the API with my actual CacheObjectPool<T> implementation. The core requirements are verified:

  1. Different registrations can use different pool providers — per-registration providerFactory works as expected, and closed-generic overrides an open-generic registration normally.
  2. Cross-type cache eviction — multiple pooled types sharing one ObjectPoolProvider (and therefore one IMemoryCache) compete for the same eviction budget. I verified this with 7 types and a size-limited cache.
  3. Construction through Autofac + reuseIPooledComponent.OnGetFromPool / OnReturnToPool fire correctly on the custom-pool path, and instances are reused across scopes.

The shape matches what I was hoping for. Thanks for the implementation.

While I don't want to contribute my implementation as part of the package itself, I'd still like to see it appear in the documentation or as an example.
/// <summary>
/// An <see cref="ObjectPool{T}"/> backed by a shared <see cref="IMemoryCache"/>,
/// capable of holding multiple instances of the same type simultaneously.
/// </summary>
/// <typeparam name="T">The type of objects being pooled.</typeparam>
/// <remarks>
/// <para>
/// Instances are stored under composite keys of <c>(_poolId, index)</c> where
/// <c>_poolId</c> is a unique identifier per pool instance and <c>index</c> is a
/// monotonically increasing stack pointer. This avoids key collisions when
/// multiple pools share the same <see cref="IMemoryCache"/>.
/// </para>
/// <para>
/// <see cref="Get"/> walks the stack from the top down using
/// <see cref="Interlocked.Decrement"/> and returns the first entry still present
/// in the cache (last-in-first-out). Because cache eviction always discards
/// older entries first, any gap near the top implies all entries below have also
/// been evicted, so the walk terminates early. On a complete miss the pool
/// falls back to <see cref="IPooledObjectPolicy{T}.Create"/>.
/// </para>
/// <para>
/// <see cref="Return"/> evaluates <see cref="IPooledObjectPolicy{T}.Return"/>
/// and <see cref="IResettable.TryReset"/> before storing; if either rejects the
/// instance it is disposed immediately. Accepted instances are pushed onto the
/// stack via <see cref="Interlocked.Increment"/> and stored in the cache with a
/// post-eviction callback that disposes the instance when the cache drops it.
/// </para>
/// <para>
/// This pool does not own the <see cref="IMemoryCache"/> — the cache is
/// provided by and disposed by the container. The pool only owns the pooled
/// instances themselves.
/// </para>
/// </remarks>
public sealed class CacheObjectPool<T> : ObjectPool<T>, IDisposable
    where T : class
{
    private readonly IMemoryCache _cache;
    private readonly IPooledObjectPolicy<T> _policy;
    private readonly MemoryCacheEntryOptions _options;
    private readonly string _poolId;
    private int _top;
    private bool _disposed;

    /// <summary>
    /// Initializes a new instance of the <see cref="CacheObjectPool{T}"/> class.
    /// </summary>
    /// <param name="cache">
    /// The shared memory cache. All pools that share this cache also share its
    /// eviction budget.
    /// </param>
    /// <param name="policy">
    /// The policy Autofac supplies. Its <see cref="IPooledObjectPolicy{T}.Create"/>
    /// resolves a fully-injected instance through the container; its
    /// <see cref="IPooledObjectPolicy{T}.Return"/> fires the pooling callbacks.
    /// </param>
    /// <param name="options">
    /// Optional cache entry options. When <see langword="null"/> a default with
    /// a five-minute sliding expiration and a post-eviction disposal callback
    /// is used.
    /// </param>
    /// <exception cref="ArgumentNullException">
    /// Thrown when <paramref name="cache"/> or <paramref name="policy"/> is
    /// <see langword="null"/>.
    /// </exception>
    public CacheObjectPool(
        IMemoryCache cache,
        IPooledObjectPolicy<T> policy,
        MemoryCacheEntryOptions? options = null)
    {
        ArgumentNullException.ThrowIfNull(cache);
        ArgumentNullException.ThrowIfNull(policy);

        _cache = cache;
        _policy = policy;
        _options = WithEvictionCallback(
            options ?? new MemoryCacheEntryOptions
            {
                SlidingExpiration = TimeSpan.FromMinutes(5)
            });
        _poolId = Guid.NewGuid().ToString("N");
        _top = -1;
    }

    /// <summary>
    /// Retrieves an instance from the pool.
    /// </summary>
    /// <returns>
    /// A pooled instance if one is available in the cache, otherwise a new
    /// instance created through <see cref="IPooledObjectPolicy{T}.Create"/>
    /// (and therefore through the Autofac container).
    /// </returns>
    /// <remarks>
    /// The pool walks its LIFO stack from the top down via
    /// <see cref="Interlocked.Decrement"/>, returning the first cache hit.
    /// Because <see cref="IMemoryCache"/> evicts older entries first, a miss
    /// near the top implies all entries below have also been evicted, so the
    /// walk terminates without scanning the full range.
    /// </remarks>
    public override T Get()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        for (var i = Volatile.Read(ref _top); i >= 0; i = Interlocked.Decrement(ref _top))
        {
            var key = (_poolId, i);
            if (_cache.TryGetValue(key, out T? item))
            {
                _cache.Remove(key);
                return item!;
            }
        }

        return _policy.Create();
    }

    /// <summary>
    /// Returns an instance to the pool.
    /// </summary>
    /// <param name="obj">The instance being returned.</param>
    /// <remarks>
    /// The instance is stored only when
    /// <see cref="IPooledObjectPolicy{T}.Return"/> accepts it and, when the
    /// instance implements <see cref="IResettable"/>, its
    /// <see cref="IResettable.TryReset"/> succeeds. Rejected instances are
    /// disposed immediately. Accepted instances are pushed onto the stack via
    /// <see cref="Interlocked.Increment"/> and stored in the cache with a
    /// post-eviction callback that disposes the instance when the cache drops
    /// it.
    /// </remarks>
    public override void Return(T obj)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        if (obj is null)
            return;

        if (!_policy.Return(obj) ||
            (obj is IResettable r && !r.TryReset()))
        {
            (obj as IDisposable)?.Dispose();
            return;
        }

        var i = Interlocked.Increment(ref _top);
        _cache.Set((_poolId, i), obj, _options);
    }

    /// <summary>
    /// Removes all cached instances from the pool without disposing the pool
    /// itself.
    /// </summary>
    /// <remarks>
    /// Atomically resets the stack pointer and removes every cache entry in the
    /// range that was occupied. Useful for a full flush between game levels or
    /// similar reset boundaries.
    /// </remarks>
    public void Clear()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        var last = Interlocked.Exchange(ref _top, -1);
        for (var i = 0; i <= last; i++)
            _cache.Remove((_poolId, i));
    }

    /// <summary>
    /// Gets the approximate number of instances currently cached in the pool.
    /// </summary>
    /// <remarks>
    /// This is an estimate based on the stack pointer. The actual count may be
    /// lower because <see cref="IMemoryCache"/> may have evicted entries
    /// without updating the pointer.
    /// </remarks>
    public int Count
    {
        get
        {
            var c = Volatile.Read(ref _top) + 1;
            return c >= 0 ? c : 0;
        }
    }

    /// <summary>
    /// Disposes the pool, clearing all cached instances.
    /// </summary>
    /// <remarks>
    /// The pool does not own the <see cref="IMemoryCache"/> (it is owned by the
    /// container), so only the pooled instances themselves are released. Each
    /// evicted entry triggers the post-eviction disposal callback registered in
    /// the constructor.
    /// </remarks>
    public void Dispose()
    {
        if (_disposed)
            return;
        Clear();
        _disposed = true;
    }

    private static MemoryCacheEntryOptions WithEvictionCallback(MemoryCacheEntryOptions opts)
    {
        opts.RegisterPostEvictionCallback(static (_, value, _, _) =>
            (value as IDisposable)?.Dispose());
        return opts;
    }
}

@tillig

tillig commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

Thank you for putting it through its paces against your real implementation — confirming the per-registration providers, the shared eviction budget across 7 types, and that construction/reuse and the OnGetFromPool/OnReturnToPool callbacks all fire on the custom-pool path is exactly the validation I was hoping for, and more thorough than I could do alone.

On bundling your CacheObjectPool<T> into the docs or as a shipped example — I'd like to keep the minimal example we have, and here's my honest reasoning. The shipped example exists to teach the seam: how a custom provider plugs in, delegates construction back to the policy, and where disposal lands. I want it small enough that the pattern is obvious with nothing to mentally subtract. Yours is genuinely nice work, but it's also a real design — LIFO stack over composite keys, IResettable, multi-instance holding, Clear()/Count — and the moment it lives in our repo we own its edge cases and support. That's exactly the bespoke territory the provider seam is meant to keep in your hands, not ours — the same boundary that kept us from pre-building the Build() API and helper base classes.

That said, I'd genuinely encourage you to share it more widely — a gist, a blog post, or your own small NuGet package would all be great ways to get it in front of people with similar needs, and you'd keep full control over how it evolves.

Either way, thank you again — from the original idea through this review, you've made this feature meaningfully better.

@tillig tillig merged commit 3b924a5 into develop Jun 17, 2026
12 checks passed
@tillig tillig deleted the feature/issue-6-custom-pool branch June 17, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to register a custom object pool

2 participants