Add event-driven state sync with ETag optimization

This commit is contained in:
2026-02-18 19:58:57 +01:00
parent 5b921063ec
commit 3c7f3d2114
17 changed files with 493 additions and 30 deletions

View File

@@ -1,4 +1,5 @@
using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Authentication;
namespace GameList.Infrastructure;
@@ -10,12 +11,22 @@ public class EnsurePlayerExistsMiddleware(RequestDelegate next)
if (context.User.Identity?.IsAuthenticated == true)
{
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null)
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId))
{
await context.SignOutAsync();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var player = await db.Players.FindAsync(playerId);
if (player is null)
{
await context.SignOutAsync();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
context.Items[nameof(Player)] = player;
}
await next(context);

View File

@@ -0,0 +1,31 @@
namespace GameList.Infrastructure;
public sealed class StateChangeNotificationMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, StateChangeNotifier notifier)
{
await next(context);
if (ShouldNotify(context))
notifier.NotifyChange();
}
private static bool ShouldNotify(HttpContext context)
{
if (context.Response.StatusCode >= StatusCodes.Status400BadRequest)
return false;
if (!HttpMethods.IsPost(context.Request.Method)
&& !HttpMethods.IsPut(context.Request.Method)
&& !HttpMethods.IsDelete(context.Request.Method))
return false;
var path = context.Request.Path;
return path.StartsWithSegments("/api/suggestions", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/api/votes", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/api/me/phase", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/api/auth/register", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.Extensions.Primitives;
namespace GameList.Infrastructure;
public sealed class StateChangeNotifier
{
private readonly string _instanceId = Guid.NewGuid().ToString("N");
private long _version = 1;
private TaskCompletionSource<long> _nextChange = CreateWaiter();
public long CurrentVersion => Interlocked.Read(ref _version);
public string CurrentEtag => $"\"{_instanceId}:{CurrentVersion}\"";
public long NotifyChange()
{
var newVersion = Interlocked.Increment(ref _version);
while (true)
{
var waiter = Volatile.Read(ref _nextChange);
var replacement = CreateWaiter();
if (Interlocked.CompareExchange(ref _nextChange, replacement, waiter) != waiter)
continue;
waiter.TrySetResult(newVersion);
return newVersion;
}
}
public bool MatchesCurrentEtag(StringValues ifNoneMatchValues)
{
if (StringValues.IsNullOrEmpty(ifNoneMatchValues))
return false;
var current = CurrentEtag;
foreach (var raw in ifNoneMatchValues)
{
if (string.IsNullOrWhiteSpace(raw))
continue;
var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
if (part == "*" || string.Equals(part, current, StringComparison.Ordinal))
return true;
}
}
return false;
}
public async Task<long> WaitForChangeAsync(long observedVersion, CancellationToken cancellationToken)
{
while (true)
{
var current = CurrentVersion;
if (current > observedVersion)
return current;
var waiter = Volatile.Read(ref _nextChange);
var signaled = await waiter.Task.WaitAsync(cancellationToken);
if (signaled > observedVersion)
return signaled;
}
}
private static TaskCompletionSource<long> CreateWaiter() => new(TaskCreationOptions.RunContinuationsAsynchronously);
}