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); } } }