namespace ZymonicServices;
///
/// Shares disposable resources, creates them on one background worker at a time,
/// and retains unused resources within count and optional weight budgets in LRU order.
/// Active leases keep their resource alive even after the cache is disposed.
///
public sealed class ZymonicResourceCache : IDisposable
where TKey : notnull
where TResource : class, IDisposable
{
private readonly object _gate = new object();
private readonly Dictionary _entries = new();
private readonly LinkedList _unused = new();
private readonly SemaphoreSlim _creationGate = new(1, 1);
private readonly Func? _create;
private readonly Func _getWeight;
private readonly long _maxUnusedWeight;
private long _unusedWeight;
private readonly int _unusedCapacity;
private bool _disposed;
private sealed class Entry(TKey key)
{
public TKey Key { get; } = key;
public Func? Factory;
public long Weight;
public int References;
public bool Removed;
public TResource? Resource;
public LinkedListNode? UnusedNode;
public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
public sealed class Lease : IDisposable
{
private Action? _release;
public TResource Resource { get; }
internal Lease(TResource resource, Action release)
{
Resource = resource;
_release = release;
}
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
public ZymonicResourceCache(Func create, int unusedCapacity = 32,
Func? getWeight = null, long maxUnusedWeight = long.MaxValue)
: this(unusedCapacity, getWeight, maxUnusedWeight)
{
ArgumentNullException.ThrowIfNull(create);
_create = create;
}
public ZymonicResourceCache(int unusedCapacity = 32,
Func? getWeight = null, long maxUnusedWeight = long.MaxValue)
{
ArgumentOutOfRangeException.ThrowIfNegative(unusedCapacity);
ArgumentOutOfRangeException.ThrowIfNegative(maxUnusedWeight);
_unusedCapacity = unusedCapacity;
_getWeight = getWeight ?? (_ => 0);
_maxUnusedWeight = maxUnusedWeight;
}
public Task AcquireAsync(TKey key, CancellationToken cancellationToken = default)
{
if (_create is null) throw new InvalidOperationException("A creation factory must be supplied.");
return AcquireAsync(key, () => _create(key), cancellationToken);
}
// The first factory for a key wins. Captured source data is released after
// creation or abandonment, independently of the cached resource's lifetime.
public async Task AcquireAsync(TKey key, Func create, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(create);
cancellationToken.ThrowIfCancellationRequested();
Entry entry;
bool startCreation = false;
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_entries.TryGetValue(key, out var existing))
{
entry = existing;
RemoveUnusedNode(entry);
}
else
{
entry = new Entry(key) { Factory = create };
_entries.Add(key, entry);
startCreation = true;
}
entry.References++;
}
create = null!; // Do not retain an unused factory in an awaiting state machine.
if (startCreation)
{
_ = Task.Run(() => CreateAsync(entry));
}
try
{
var resource = await entry.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
return new Lease(resource, () => Release(entry));
}
catch
{
Release(entry);
throw;
}
}
private async Task CreateAsync(Entry entry)
{
await _creationGate.WaitAsync().ConfigureAwait(false);
try
{
Func? factory;
lock (_gate)
{
factory = !_disposed && !entry.Removed && entry.References > 0 ? entry.Factory : null;
entry.Factory = null;
}
if (factory is not null)
{
var resource = factory();
factory = null;
long weight;
try
{
weight = _getWeight(resource);
ArgumentOutOfRangeException.ThrowIfNegative(weight);
}
catch { resource.Dispose(); throw; }
List evicted;
lock (_gate)
{
entry.Resource = resource;
entry.Weight = weight;
evicted = RetainOrEvict(entry);
entry.Completion.TrySetResult(resource);
}
DisposeResources(evicted);
}
else
{
entry.Completion.TrySetCanceled();
}
}
catch (Exception error)
{
lock (_gate)
{
RemoveEntry(entry);
}
entry.Completion.TrySetException(error);
// All waiters may already have cancelled. Observe the failure in that case too.
_ = entry.Completion.Task.Exception;
}
finally
{
_creationGate.Release();
}
}
private void Release(Entry entry)
{
List evicted;
lock (_gate)
{
entry.References--;
if (entry.References == 0 && entry.Resource is null)
{
// Queued work with no remaining consumers should not decode anything.
RemoveEntry(entry);
}
evicted = RetainOrEvict(entry);
}
DisposeResources(evicted);
}
// Called under _gate; disposal itself happens outside the lock.
private List RetainOrEvict(Entry entry)
{
List evicted = new();
if (entry.References == 0 && entry.Resource is not null)
{
if (!_disposed && !entry.Removed && _unusedCapacity > 0 && entry.Weight <= _maxUnusedWeight)
{
// Evict before adding to avoid overflow in weight accounting.
while (_unused.Count > 0 && (_unused.Count >= _unusedCapacity
|| entry.Weight > _maxUnusedWeight - _unusedWeight))
{
var oldest = _unused.First!.Value;
RemoveEntry(oldest);
evicted.Add(oldest.Resource!);
oldest.Resource = null;
}
entry.UnusedNode = _unused.AddLast(entry);
_unusedWeight += entry.Weight;
}
else
{
RemoveEntry(entry);
evicted.Add(entry.Resource);
entry.Resource = null;
}
}
return evicted;
}
private void RemoveUnusedNode(Entry entry)
{
if (entry.UnusedNode is not null)
{
_unusedWeight -= entry.Weight;
_unused.Remove(entry.UnusedNode);
entry.UnusedNode = null;
}
}
private void RemoveEntry(Entry entry)
{
if (!entry.Removed)
{
entry.Factory = null;
_entries.Remove(entry.Key);
RemoveUnusedNode(entry);
entry.Removed = true;
}
}
private static void DisposeResources(IEnumerable resources)
{
foreach (var resource in resources)
{
resource.Dispose();
}
}
public void Dispose()
{
List evicted = new();
lock (_gate)
{
if (_disposed) return;
_disposed = true;
foreach (var entry in _entries.Values)
{
entry.Factory = null;
entry.Removed = true;
entry.UnusedNode = null;
if (entry.References == 0 && entry.Resource is not null)
{
evicted.Add(entry.Resource);
entry.Resource = null;
}
}
_entries.Clear();
_unused.Clear();
_unusedWeight = 0;
}
DisposeResources(evicted);
// Pending workers still use _creationGate; leave its managed lifetime to the GC.
}
}