234 lines
8.1 KiB
C#
234 lines
8.1 KiB
C#
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Nexus.Api.Data;
|
|
using Nexus.Api.Models;
|
|
using Nexus.Api.Observability;
|
|
using Nexus.Api.Services;
|
|
|
|
namespace Nexus.Api.Controllers;
|
|
|
|
[Authorize]
|
|
[ApiController]
|
|
[Route("api/v1/events")]
|
|
public sealed class DomainEventsController(
|
|
NexusDbContext db,
|
|
IDomainEventStreamService eventStream,
|
|
ILogger<DomainEventsController> logger) : ControllerBase
|
|
{
|
|
private const int MaximumReplay = 512;
|
|
|
|
[HttpGet]
|
|
public async Task Get(
|
|
[FromQuery] string? channels,
|
|
[FromQuery] long? afterSequence,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Response.Headers.ContentType = "text/event-stream";
|
|
Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
|
|
Response.Headers.Connection = "keep-alive";
|
|
Response.Headers["X-Accel-Buffering"] = "no";
|
|
|
|
var requestedChannels = ParseChannels(channels);
|
|
var cursor = ResolveCursor(afterSequence);
|
|
await using var subscription = eventStream.Subscribe(requestedChannels);
|
|
var lastSent = cursor ?? subscription.StartingSequence;
|
|
|
|
if (cursor.HasValue)
|
|
{
|
|
var earliest = await db.OutboxEvents
|
|
.AsNoTracking()
|
|
.Where(item => item.PublishedAt != null)
|
|
.OrderBy(item => item.Sequence)
|
|
.Select(item => (long?)item.Sequence)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (earliest.HasValue && cursor.Value < earliest.Value - 1)
|
|
{
|
|
await WriteResyncRequiredAsync(
|
|
cursor.Value,
|
|
"retention_gap",
|
|
cancellationToken);
|
|
return;
|
|
}
|
|
|
|
var replay = await db.OutboxEvents
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.PublishedAt != null &&
|
|
item.Sequence > cursor.Value &&
|
|
item.Sequence <= subscription.StartingSequence)
|
|
.OrderBy(item => item.Sequence)
|
|
.Take(MaximumReplay + 1)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
if (replay.Count > MaximumReplay)
|
|
{
|
|
await WriteResyncRequiredAsync(
|
|
cursor.Value,
|
|
"replay_limit",
|
|
cancellationToken);
|
|
return;
|
|
}
|
|
|
|
foreach (var item in replay)
|
|
{
|
|
var domainEvent = DomainEventStreamService.Map(item);
|
|
if (!MatchesChannel(domainEvent, requestedChannels))
|
|
continue;
|
|
await WriteEventAsync("domain", domainEvent.Sequence, domainEvent, cancellationToken);
|
|
lastSent = domainEvent.Sequence;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
using var iteration = CancellationTokenSource.CreateLinkedTokenSource(
|
|
cancellationToken);
|
|
var readTask = subscription.Reader
|
|
.WaitToReadAsync(iteration.Token)
|
|
.AsTask();
|
|
var heartbeatTask = Task.Delay(
|
|
TimeSpan.FromSeconds(20),
|
|
iteration.Token);
|
|
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
|
if (completed == heartbeatTask)
|
|
{
|
|
iteration.Cancel();
|
|
await WriteEventAsync(
|
|
"heartbeat",
|
|
lastSent,
|
|
new
|
|
{
|
|
sequence = eventStream.CurrentSequence,
|
|
timestamp = DateTimeOffset.UtcNow
|
|
},
|
|
cancellationToken);
|
|
continue;
|
|
}
|
|
|
|
iteration.Cancel();
|
|
if (!await readTask)
|
|
break;
|
|
while (subscription.Reader.TryRead(out var domainEvent))
|
|
{
|
|
if (domainEvent.Sequence <= lastSent)
|
|
continue;
|
|
await WriteEventAsync(
|
|
"domain",
|
|
domainEvent.Sequence,
|
|
domainEvent,
|
|
cancellationToken);
|
|
lastSent = domainEvent.Sequence;
|
|
}
|
|
}
|
|
}
|
|
catch (DomainEventSubscriberOverflowException)
|
|
{
|
|
await WriteResyncRequiredAsync(lastSent, "subscriber_overflow", cancellationToken);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Normal browser disconnect.
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "Domain event stream ended unexpectedly");
|
|
}
|
|
}
|
|
|
|
private long? ResolveCursor(long? queryCursor)
|
|
{
|
|
if (queryCursor.HasValue)
|
|
return queryCursor.Value >= 0 ? queryCursor : null;
|
|
if (!Request.Headers.TryGetValue("Last-Event-ID", out var header))
|
|
return null;
|
|
return long.TryParse(header.FirstOrDefault(), out var parsed) && parsed >= 0
|
|
? parsed
|
|
: null;
|
|
}
|
|
|
|
private async Task WriteResyncRequiredAsync(
|
|
long staleSequence,
|
|
string reason,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
NexusTelemetry.SseResyncs.Add(1);
|
|
// Resume after the latest sequence that was visible when the resync
|
|
// decision was made. Re-emitting the stale cursor would make a client
|
|
// reconnect into the same retention/replay gap forever.
|
|
var resumeSequence = eventStream.CurrentSequence;
|
|
var payload = JsonSerializer.SerializeToElement(new
|
|
{
|
|
reason,
|
|
staleSequence,
|
|
resumeSequence
|
|
});
|
|
var domainEvent = new DomainEventDto(
|
|
resumeSequence,
|
|
DomainEventTypes.ResyncRequired,
|
|
new EntityRefDto("event-stream", "*"),
|
|
0,
|
|
DateTimeOffset.UtcNow,
|
|
payload);
|
|
await WriteEventAsync(
|
|
DomainEventTypes.ResyncRequired,
|
|
resumeSequence,
|
|
domainEvent,
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task WriteEventAsync(
|
|
string eventName,
|
|
long sequence,
|
|
object payload,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await Response.WriteAsync($"id: {sequence}\n", cancellationToken);
|
|
await Response.WriteAsync($"event: {eventName}\n", cancellationToken);
|
|
await Response.WriteAsync(
|
|
$"data: {JsonSerializer.Serialize(payload, JsonOptions)}\n\n",
|
|
cancellationToken);
|
|
await Response.Body.FlushAsync(cancellationToken);
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions =
|
|
new(JsonSerializerDefaults.Web);
|
|
|
|
private static IReadOnlySet<string> ParseChannels(string? channels)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(channels))
|
|
return new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase);
|
|
|
|
var parsed = channels
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(channel => channel.ToLowerInvariant())
|
|
.Where(channel => channel.All(character =>
|
|
char.IsLetterOrDigit(character) || character is '-' or '_'))
|
|
.Take(16)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
return parsed.Count == 0
|
|
? new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase)
|
|
: parsed;
|
|
}
|
|
|
|
private static bool MatchesChannel(
|
|
DomainEventDto domainEvent,
|
|
IReadOnlySet<string> channels)
|
|
{
|
|
if (channels.Contains("*"))
|
|
return true;
|
|
var channel = domainEvent.Entity.Type switch
|
|
{
|
|
"agent-proposal" => "agents",
|
|
"task" => "tasks",
|
|
"run" => "runs",
|
|
"cron" => "cron",
|
|
_ => $"{domainEvent.Entity.Type}s"
|
|
};
|
|
return channels.Contains(channel);
|
|
}
|
|
}
|