namespace ItWorx.KnowledgeHub.Application.Attachments;
///
/// One process-wide, per-content-hash mutual-exclusion lock, shared by every writer of
/// the content-addressed blob store — UploadAttachmentUseCase (commit a new blob
/// or discover an existing one) and OrphanedBlobGarbageCollector (delete a blob
/// no reference points to any more). Registered as a singleton so both use cases,
/// resolved from different per-request scopes, serialize against the very same lock
/// table.
///
/// This is what makes blob garbage collection safe against a concurrent upload of
/// byte-identical content: without it, a GC pass could delete a blob's database row and
/// physical file in between an upload's own "does this hash already exist?" check and
/// its own write, corrupting the upload's result. With both critical sections holding
/// the same per-hash lock, one of two outcomes is always consistent — either GC
/// completes first (the upload then behaves exactly like any fresh upload of new
/// content), or the upload completes first (GC's own re-check-under-lock then correctly
/// finds a live reference and skips deletion).
///
public sealed class AttachmentContentLock
{
private readonly object gate = new();
private readonly Dictionary locks = [];
public async Task AcquireAsync(string sha256, CancellationToken cancellationToken)
{
Entry entry;
lock (gate)
{
if (!locks.TryGetValue(sha256, out entry!))
{
entry = new Entry();
locks.Add(sha256, entry);
}
entry.ReferenceCount += 1;
}
try
{
await entry.Semaphore.WaitAsync(cancellationToken);
return new Lease(sha256, entry, this);
}
catch
{
Release(sha256, entry, acquired: false);
throw;
}
}
private void Release(string sha256, Entry entry, bool acquired)
{
if (acquired)
{
entry.Semaphore.Release();
}
lock (gate)
{
entry.ReferenceCount -= 1;
if (entry.ReferenceCount == 0 && locks.TryGetValue(sha256, out var current) && ReferenceEquals(current, entry))
{
locks.Remove(sha256);
entry.Semaphore.Dispose();
}
}
}
private sealed class Entry
{
public SemaphoreSlim Semaphore { get; } = new(1, 1);
public int ReferenceCount { get; set; }
}
private sealed class Lease(string sha256, Entry entry, AttachmentContentLock owner) : IDisposable
{
private bool disposed;
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
owner.Release(sha256, entry, acquired: true);
}
}
}