92 lines
2.9 KiB
C#
92 lines
2.9 KiB
C#
namespace ItWorx.KnowledgeHub.Application.Attachments;
|
|
|
|
/// <summary>
|
|
/// One process-wide, per-content-hash mutual-exclusion lock, shared by every writer of
|
|
/// the content-addressed blob store — <c>UploadAttachmentUseCase</c> (commit a new blob
|
|
/// or discover an existing one) and <c>OrphanedBlobGarbageCollector</c> (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).
|
|
/// </summary>
|
|
public sealed class AttachmentContentLock
|
|
{
|
|
private readonly object gate = new();
|
|
private readonly Dictionary<string, Entry> locks = [];
|
|
|
|
public async Task<IDisposable> 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);
|
|
}
|
|
}
|
|
}
|