Files
nexus/backend/Controllers/DomainEventsController.cs
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

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);
}
}