289 lines
10 KiB
C#
289 lines
10 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Data;
|
|
using System.Text.Json;
|
|
using System.Threading.Channels;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Nexus.Api.Data;
|
|
using Nexus.Api.Models;
|
|
using Nexus.Api.Observability;
|
|
|
|
namespace Nexus.Api.Services;
|
|
|
|
/// <summary>
|
|
/// Publishes the PostgreSQL transactional outbox to bounded in-process SSE
|
|
/// subscribers. PostgreSQL remains authoritative: the in-process channel only
|
|
/// reduces latency, while reconnect replay is always read from the database.
|
|
/// </summary>
|
|
public sealed class DomainEventStreamService(
|
|
IServiceScopeFactory scopeFactory,
|
|
ILogger<DomainEventStreamService> logger) :
|
|
BackgroundService,
|
|
IDomainEventStreamService
|
|
{
|
|
private const int BatchSize = 128;
|
|
private const int SubscriberCapacity = 64;
|
|
private const int MinimumRetainedSequences = 10_000;
|
|
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
|
|
private static readonly TimeSpan Retention = TimeSpan.FromHours(24);
|
|
|
|
private readonly ConcurrentDictionary<Guid, Subscriber> subscribers = new();
|
|
private long currentSequence;
|
|
private long reportedBacklog;
|
|
private DateTimeOffset nextRetentionSweep = DateTimeOffset.UtcNow.AddMinutes(10);
|
|
|
|
public long CurrentSequence => Interlocked.Read(ref currentSequence);
|
|
|
|
public DomainEventSubscription Subscribe(IReadOnlySet<string> channels)
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var queue = Channel.CreateBounded<DomainEventDto>(
|
|
new BoundedChannelOptions(SubscriberCapacity)
|
|
{
|
|
FullMode = BoundedChannelFullMode.Wait,
|
|
SingleReader = true,
|
|
SingleWriter = true,
|
|
AllowSynchronousContinuations = false
|
|
});
|
|
subscribers[id] = new Subscriber(queue, channels);
|
|
NexusTelemetry.SseSubscribers.Add(1);
|
|
|
|
return new DomainEventSubscription(
|
|
queue.Reader,
|
|
CurrentSequence,
|
|
() =>
|
|
{
|
|
if (subscribers.TryRemove(id, out var removed))
|
|
{
|
|
removed.Queue.Writer.TryComplete();
|
|
NexusTelemetry.SseSubscribers.Add(-1);
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
});
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
await InitializeSequenceAsync(stoppingToken);
|
|
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var published = await PublishNextBatchAsync(stoppingToken);
|
|
if (DateTimeOffset.UtcNow >= nextRetentionSweep)
|
|
{
|
|
await PruneRetainedEventsAsync(stoppingToken);
|
|
nextRetentionSweep = DateTimeOffset.UtcNow.AddHours(1);
|
|
}
|
|
|
|
if (!published)
|
|
await Task.Delay(PollInterval, stoppingToken);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(exception, "Domain outbox iteration failed");
|
|
await Task.Delay(PollInterval, stoppingToken);
|
|
}
|
|
}
|
|
|
|
foreach (var (id, subscriber) in subscribers)
|
|
{
|
|
if (subscribers.TryRemove(id, out _))
|
|
{
|
|
subscriber.Queue.Writer.TryComplete();
|
|
NexusTelemetry.SseSubscribers.Add(-1);
|
|
}
|
|
}
|
|
|
|
var backlog = Interlocked.Exchange(ref reportedBacklog, 0);
|
|
if (backlog != 0)
|
|
NexusTelemetry.OutboxBacklog.Add(-backlog);
|
|
}
|
|
|
|
internal static DomainEventDto Map(OutboxEvent item)
|
|
{
|
|
using var document = JsonDocument.Parse(item.PayloadJson);
|
|
var entityType = NormalizeEntityType(item.AggregateType);
|
|
return new DomainEventDto(
|
|
item.Sequence,
|
|
item.Type,
|
|
new EntityRefDto(entityType, item.AggregateId),
|
|
item.AggregateRevision,
|
|
item.OccurredAt,
|
|
document.RootElement.Clone());
|
|
}
|
|
|
|
private async Task InitializeSequenceAsync(CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = scopeFactory.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
|
var latest = await db.OutboxEvents
|
|
.AsNoTracking()
|
|
.Where(item => item.PublishedAt != null)
|
|
.Select(item => (long?)item.Sequence)
|
|
.MaxAsync(cancellationToken) ?? 0;
|
|
Interlocked.Exchange(ref currentSequence, latest);
|
|
}
|
|
|
|
private async Task<bool> PublishNextBatchAsync(CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = scopeFactory.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
|
List<OutboxEvent> pending;
|
|
|
|
if (db.Database.IsRelational())
|
|
{
|
|
await using var transaction = await db.Database.BeginTransactionAsync(
|
|
IsolationLevel.ReadCommitted,
|
|
cancellationToken);
|
|
pending = await db.OutboxEvents
|
|
.FromSqlInterpolated($"""
|
|
SELECT * FROM "OutboxEvents"
|
|
WHERE "PublishedAt" IS NULL
|
|
ORDER BY "Sequence"
|
|
LIMIT {BatchSize}
|
|
FOR UPDATE SKIP LOCKED
|
|
""")
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var publishedAt = DateTimeOffset.UtcNow;
|
|
foreach (var item in pending)
|
|
{
|
|
item.PublishedAt = publishedAt;
|
|
item.PublishAttempts++;
|
|
item.LastErrorCode = null;
|
|
}
|
|
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
pending = await db.OutboxEvents
|
|
.Where(item => item.PublishedAt == null)
|
|
.OrderBy(item => item.Sequence)
|
|
.Take(BatchSize)
|
|
.ToListAsync(cancellationToken);
|
|
var publishedAt = DateTimeOffset.UtcNow;
|
|
foreach (var item in pending)
|
|
{
|
|
item.PublishedAt = publishedAt;
|
|
item.PublishAttempts++;
|
|
item.LastErrorCode = null;
|
|
}
|
|
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
foreach (var item in pending)
|
|
{
|
|
var domainEvent = Map(item);
|
|
Interlocked.Exchange(ref currentSequence, domainEvent.Sequence);
|
|
Publish(domainEvent);
|
|
NexusTelemetry.OutboxPublished.Add(1);
|
|
}
|
|
|
|
var nextBacklogEstimate = pending.Count == BatchSize ? BatchSize : 0;
|
|
var previousBacklog = Interlocked.Exchange(
|
|
ref reportedBacklog,
|
|
nextBacklogEstimate);
|
|
NexusTelemetry.OutboxBacklog.Add(nextBacklogEstimate - previousBacklog);
|
|
return pending.Count > 0;
|
|
}
|
|
|
|
private void Publish(DomainEventDto domainEvent)
|
|
{
|
|
var channel = ChannelFor(domainEvent);
|
|
foreach (var (id, subscriber) in subscribers)
|
|
{
|
|
if (!subscriber.Channels.Contains("*") &&
|
|
!subscriber.Channels.Contains(channel))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (subscriber.Queue.Writer.TryWrite(domainEvent))
|
|
continue;
|
|
|
|
if (subscribers.TryRemove(id, out var removed))
|
|
{
|
|
removed.Queue.Writer.TryComplete(
|
|
new DomainEventSubscriberOverflowException());
|
|
NexusTelemetry.SseSubscribers.Add(-1);
|
|
NexusTelemetry.SseResyncs.Add(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task PruneRetainedEventsAsync(CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = scopeFactory.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
|
var maximum = await db.OutboxEvents
|
|
.Where(item => item.PublishedAt != null)
|
|
.Select(item => (long?)item.Sequence)
|
|
.MaxAsync(cancellationToken) ?? 0;
|
|
var sequenceCutoff = Math.Max(0, maximum - MinimumRetainedSequences);
|
|
var timeCutoff = DateTimeOffset.UtcNow - Retention;
|
|
if (sequenceCutoff == 0)
|
|
return;
|
|
|
|
if (db.Database.IsRelational())
|
|
{
|
|
await db.OutboxEvents
|
|
.Where(item =>
|
|
item.PublishedAt != null &&
|
|
item.Sequence < sequenceCutoff &&
|
|
item.OccurredAt < timeCutoff)
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
return;
|
|
}
|
|
|
|
var expired = await db.OutboxEvents
|
|
.Where(item =>
|
|
item.PublishedAt != null &&
|
|
item.Sequence < sequenceCutoff &&
|
|
item.OccurredAt < timeCutoff)
|
|
.ToListAsync(cancellationToken);
|
|
db.OutboxEvents.RemoveRange(expired);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private static string ChannelFor(DomainEventDto domainEvent) =>
|
|
domainEvent.Entity.Type switch
|
|
{
|
|
"agent-proposal" => "agents",
|
|
"task" => "tasks",
|
|
"run" => "runs",
|
|
"cron" => "cron",
|
|
"notification" => "notifications",
|
|
"incident" => "incidents",
|
|
_ => $"{domainEvent.Entity.Type}s"
|
|
};
|
|
|
|
private static string NormalizeEntityType(string aggregateType)
|
|
{
|
|
var normalized = aggregateType
|
|
.Trim()
|
|
.Replace("_", "-", StringComparison.Ordinal)
|
|
.ToLowerInvariant();
|
|
return normalized switch
|
|
{
|
|
"agentproposal" or "agent-proposal" => "agent-proposal",
|
|
"worktask" or "task" => "task",
|
|
"openclawrun" or "run" => "run",
|
|
"cronjob" or "cron" => "cron",
|
|
_ => normalized
|
|
};
|
|
}
|
|
|
|
private sealed record Subscriber(
|
|
Channel<DomainEventDto> Queue,
|
|
IReadOnlySet<string> Channels);
|
|
}
|