Add event-driven state sync with ETag optimization
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using GameList.Data;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -8,14 +9,70 @@ public static class StateEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api").RequireAuthorization();
|
||||
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service, StateChangeNotifier notifier) =>
|
||||
{
|
||||
ctx.Response.Headers.CacheControl = "private, no-cache";
|
||||
if (notifier.MatchesCurrentEtag(ctx.Request.Headers.IfNoneMatch))
|
||||
{
|
||||
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var result = await service.GetStateAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
return result.ToHttpResult(payload =>
|
||||
{
|
||||
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||
return Results.Ok(payload);
|
||||
});
|
||||
});
|
||||
|
||||
group.MapGet("/events/state", async (HttpContext ctx, AppDbContext db, StateChangeNotifier notifier) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
ctx.Response.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
var observedVersion = notifier.CurrentVersion;
|
||||
await WriteStateEventAsync(ctx, "ready", observedVersion, ctx.RequestAborted);
|
||||
|
||||
while (!ctx.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var changeTask = notifier.WaitForChangeAsync(observedVersion, ctx.RequestAborted);
|
||||
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), ctx.RequestAborted);
|
||||
var completed = await Task.WhenAny(changeTask, heartbeatTask);
|
||||
|
||||
if (completed == changeTask)
|
||||
{
|
||||
observedVersion = await changeTask;
|
||||
await WriteStateEventAsync(ctx, "state", observedVersion, ctx.RequestAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ctx.Response.WriteAsync(": ping\n\n", ctx.RequestAborted);
|
||||
await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
@@ -49,4 +106,11 @@ public static class StateEndpoints
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private static async Task WriteStateEventAsync(HttpContext ctx, string eventName, long version, CancellationToken cancellationToken)
|
||||
{
|
||||
await ctx.Response.WriteAsync($"event: {eventName}\n", cancellationToken);
|
||||
await ctx.Response.WriteAsync($"data: {version}\n\n", cancellationToken);
|
||||
await ctx.Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,34 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
|
||||
{
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
var state = await db.AppState
|
||||
.AsNoTracking()
|
||||
.Select(s => new
|
||||
{
|
||||
s.ResultsOpen,
|
||||
s.UpdatedAt,
|
||||
Players = db.Players.Count(),
|
||||
Suggestions = db.Suggestions.Count(),
|
||||
Votes = db.Votes.Count()
|
||||
})
|
||||
.SingleAsync();
|
||||
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync());
|
||||
var summary = new StateSummaryResponse(
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin,
|
||||
player.IsOwner,
|
||||
phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt,
|
||||
state.Players,
|
||||
state.Suggestions,
|
||||
state.Votes
|
||||
);
|
||||
return ServiceResult<StateSummaryResponse>.Success(summary);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user