diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index d25ee36..069777b 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -198,7 +198,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - Matches request path `/api/*`; reads `X-Api-Key` header; constant-time compare against `ApiKeyOptions` only if `ApiKeyOptions`is set to non-empty. - 401 on mismatch. `/i/{mac}.png` and `/health` unaffected. -### [ ] 8.2 `FrameLockProvider` +### [x] 8.2 `FrameLockProvider` - `ConcurrentDictionary` (each `SemaphoreSlim(1, 1)`). - `Task AcquireAsync(FrameName, CancellationToken)` returning a disposable that releases on dispose. diff --git a/src/FrameProcessor/Concurrency/FrameLockProvider.cs b/src/FrameProcessor/Concurrency/FrameLockProvider.cs new file mode 100644 index 0000000..5d36308 --- /dev/null +++ b/src/FrameProcessor/Concurrency/FrameLockProvider.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; +using FrameProcessor.Domain; + +namespace FrameProcessor.Concurrency; + +/// +/// Hands out a per-frame so concurrent uploads to the same +/// frame are serialized while different frames remain independent +/// (see SPEC.md ยง3.4, CLAUDE.md "Per-frame serialization"). +/// +public sealed class FrameLockProvider +{ + private readonly ConcurrentDictionary _locks = new(); + + public async Task AcquireAsync(FrameName frame, CancellationToken cancellationToken) + { + var semaphore = _locks.GetOrAdd(frame, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new Releaser(semaphore); + } + + private sealed class Releaser : IDisposable + { + private SemaphoreSlim? _semaphore; + + public Releaser(SemaphoreSlim semaphore) => _semaphore = semaphore; + + public void Dispose() + { + Interlocked.Exchange(ref _semaphore, null)?.Release(); + } + } +}