8.2 FrameLockProvider
Keyed per-frame SemaphoreSlim(1,1) over a ConcurrentDictionary with a disposable releaser, so the next increment can serialize the upload pipeline per FrameName. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- 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.
|
- 401 on mismatch. `/i/{mac}.png` and `/health` unaffected.
|
||||||
|
|
||||||
### [ ] 8.2 `FrameLockProvider`
|
### [x] 8.2 `FrameLockProvider`
|
||||||
- `ConcurrentDictionary<FrameName, SemaphoreSlim>` (each `SemaphoreSlim(1, 1)`).
|
- `ConcurrentDictionary<FrameName, SemaphoreSlim>` (each `SemaphoreSlim(1, 1)`).
|
||||||
- `Task<IDisposable> AcquireAsync(FrameName, CancellationToken)` returning a disposable that releases on dispose.
|
- `Task<IDisposable> AcquireAsync(FrameName, CancellationToken)` returning a disposable that releases on dispose.
|
||||||
|
|
||||||
|
|||||||
33
src/FrameProcessor/Concurrency/FrameLockProvider.cs
Normal file
33
src/FrameProcessor/Concurrency/FrameLockProvider.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using FrameProcessor.Domain;
|
||||||
|
|
||||||
|
namespace FrameProcessor.Concurrency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands out a per-frame <see cref="SemaphoreSlim"/> so concurrent uploads to the same
|
||||||
|
/// frame are serialized while different frames remain independent
|
||||||
|
/// (see SPEC.md §3.4, CLAUDE.md "Per-frame serialization").
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FrameLockProvider
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<FrameName, SemaphoreSlim> _locks = new();
|
||||||
|
|
||||||
|
public async Task<IDisposable> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user