up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -5,7 +5,7 @@ using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Processing;
internal sealed class DefaultNotifyRuleEvaluator : INotifyRuleEvaluator
public sealed class DefaultNotifyRuleEvaluator : INotifyRuleEvaluator
{
private static readonly IDictionary<string, int> SeverityRank = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Processing;
/// <summary>
/// Renders notification templates with event payload data.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template body using the provided data context.
/// </summary>
/// <param name="template">The template containing the body pattern.</param>
/// <param name="payload">The event payload data to interpolate.</param>
/// <returns>The rendered string.</returns>
string Render(NotifyTemplate template, JsonNode? payload);
}

View File

@@ -0,0 +1,288 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Channels;
using StellaOps.Notifier.Worker.Options;
namespace StellaOps.Notifier.Worker.Processing;
/// <summary>
/// Background worker that picks up pending deliveries, renders templates, and dispatches through channels.
/// </summary>
public sealed class NotifierDispatchWorker : BackgroundService
{
private readonly INotifyDeliveryRepository _deliveryRepository;
private readonly INotifyTemplateRepository _templateRepository;
private readonly INotifyChannelRepository _channelRepository;
private readonly INotifyTemplateRenderer _templateRenderer;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> _channelAdapters;
private readonly NotifierWorkerOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifierDispatchWorker> _logger;
private readonly string _workerId;
public NotifierDispatchWorker(
INotifyDeliveryRepository deliveryRepository,
INotifyTemplateRepository templateRepository,
INotifyChannelRepository channelRepository,
INotifyTemplateRenderer templateRenderer,
IEnumerable<INotifyChannelAdapter> channelAdapters,
IOptions<NotifierWorkerOptions> options,
TimeProvider timeProvider,
ILogger<NotifierDispatchWorker> logger)
{
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
_templateRepository = templateRepository ?? throw new ArgumentNullException(nameof(templateRepository));
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
_templateRenderer = templateRenderer ?? throw new ArgumentNullException(nameof(templateRenderer));
_channelAdapters = BuildAdapterMap(channelAdapters);
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_workerId = $"notifier-dispatch-{Environment.MachineName}-{Guid.NewGuid():N}";
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Notifier dispatch worker {WorkerId} started.", _workerId);
var pollInterval = _options.DispatchPollInterval > TimeSpan.Zero
? _options.DispatchPollInterval
: TimeSpan.FromSeconds(5);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var processed = await ProcessPendingDeliveriesAsync(stoppingToken).ConfigureAwait(false);
if (processed == 0)
{
await Task.Delay(pollInterval, stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception in dispatch worker loop.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
}
}
_logger.LogInformation("Notifier dispatch worker {WorkerId} stopping.", _workerId);
}
private async Task<int> ProcessPendingDeliveriesAsync(CancellationToken cancellationToken)
{
// Query for pending deliveries across all tenants (simplified - production would partition)
var result = await _deliveryRepository.QueryAsync(
tenantId: "tenant-sample", // In production, would iterate tenants
since: null,
status: "pending",
limit: _options.DispatchBatchSize > 0 ? _options.DispatchBatchSize : 10,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (result.Items.Count == 0)
{
return 0;
}
var processed = 0;
foreach (var delivery in result.Items)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await ProcessDeliveryAsync(delivery, cancellationToken).ConfigureAwait(false);
processed++;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process delivery {DeliveryId}.", delivery.DeliveryId);
}
}
return processed;
}
private async Task ProcessDeliveryAsync(NotifyDelivery delivery, CancellationToken cancellationToken)
{
var tenantId = delivery.TenantId;
// Look up channel from metadata
if (!delivery.Metadata.TryGetValue("channel", out var channelId) || string.IsNullOrWhiteSpace(channelId))
{
await MarkDeliveryFailedAsync(delivery, "Channel reference missing in delivery metadata", cancellationToken)
.ConfigureAwait(false);
return;
}
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
if (channel is null)
{
await MarkDeliveryFailedAsync(delivery, $"Channel {channelId} not found", cancellationToken)
.ConfigureAwait(false);
return;
}
// Look up template from metadata
delivery.Metadata.TryGetValue("template", out var templateKey);
delivery.Metadata.TryGetValue("locale", out var locale);
locale ??= "en-us";
NotifyTemplate? template = null;
if (!string.IsNullOrWhiteSpace(templateKey))
{
// GetAsync uses templateId, so we look up by the template reference from metadata
template = await _templateRepository.GetAsync(tenantId, templateKey, cancellationToken)
.ConfigureAwait(false);
}
// Build rendered content
NotifyDeliveryRendered rendered;
if (template is not null)
{
// Create a payload from the delivery kind and metadata
var payload = BuildPayloadFromDelivery(delivery);
var renderedBody = _templateRenderer.Render(template, payload);
var subject = template.Metadata.TryGetValue("subject", out var subj)
? _templateRenderer.Render(
NotifyTemplate.Create(
templateId: "subject-inline",
tenantId: tenantId,
channelType: template.ChannelType,
key: "subject",
locale: locale,
body: subj),
payload)
: $"Notification: {delivery.Kind}";
rendered = NotifyDeliveryRendered.Create(
channelType: channel.Type,
format: template.Format,
target: channel.Config?.Target ?? string.Empty,
title: subject,
body: renderedBody,
locale: locale);
}
else
{
// Fallback rendering without template
rendered = NotifyDeliveryRendered.Create(
channelType: channel.Type,
format: NotifyDeliveryFormat.Json,
target: channel.Config?.Target ?? string.Empty,
title: $"Notification: {delivery.Kind}",
body: $"Event {delivery.EventId} triggered rule {delivery.RuleId}",
locale: locale);
}
// Dispatch through channel adapter
if (!_channelAdapters.TryGetValue(channel.Type, out var adapter))
{
await MarkDeliveryFailedAsync(delivery, $"No adapter for channel type {channel.Type}", cancellationToken)
.ConfigureAwait(false);
return;
}
var dispatchResult = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
// Update delivery with result
var attempt = new NotifyDeliveryAttempt(
timestamp: _timeProvider.GetUtcNow(),
status: dispatchResult.Success ? NotifyDeliveryAttemptStatus.Succeeded : NotifyDeliveryAttemptStatus.Failed,
statusCode: dispatchResult.StatusCode,
reason: dispatchResult.Reason);
var newStatus = dispatchResult.Success
? NotifyDeliveryStatus.Sent
: (dispatchResult.ShouldRetry ? NotifyDeliveryStatus.Pending : NotifyDeliveryStatus.Failed);
var updatedDelivery = NotifyDelivery.Create(
deliveryId: delivery.DeliveryId,
tenantId: delivery.TenantId,
ruleId: delivery.RuleId,
actionId: delivery.ActionId,
eventId: delivery.EventId,
kind: delivery.Kind,
status: newStatus,
statusReason: dispatchResult.Reason,
rendered: rendered,
attempts: delivery.Attempts.Add(attempt),
metadata: delivery.Metadata,
createdAt: delivery.CreatedAt,
sentAt: dispatchResult.Success ? _timeProvider.GetUtcNow() : delivery.SentAt,
completedAt: newStatus == NotifyDeliveryStatus.Sent || newStatus == NotifyDeliveryStatus.Failed
? _timeProvider.GetUtcNow()
: null);
await _deliveryRepository.UpdateAsync(updatedDelivery, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Delivery {DeliveryId} dispatched via {ChannelType}: {Status}",
delivery.DeliveryId,
channel.Type,
newStatus);
}
private async Task MarkDeliveryFailedAsync(
NotifyDelivery delivery,
string reason,
CancellationToken cancellationToken)
{
var failedDelivery = NotifyDelivery.Create(
deliveryId: delivery.DeliveryId,
tenantId: delivery.TenantId,
ruleId: delivery.RuleId,
actionId: delivery.ActionId,
eventId: delivery.EventId,
kind: delivery.Kind,
status: NotifyDeliveryStatus.Failed,
statusReason: reason,
attempts: delivery.Attempts,
metadata: delivery.Metadata,
createdAt: delivery.CreatedAt,
completedAt: _timeProvider.GetUtcNow());
await _deliveryRepository.UpdateAsync(failedDelivery, cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Delivery {DeliveryId} marked failed: {Reason}", delivery.DeliveryId, reason);
}
private static JsonObject BuildPayloadFromDelivery(NotifyDelivery delivery)
{
var payload = new JsonObject
{
["eventId"] = delivery.EventId.ToString(),
["kind"] = delivery.Kind,
["ruleId"] = delivery.RuleId,
["actionId"] = delivery.ActionId
};
foreach (var (key, value) in delivery.Metadata)
{
payload[key] = value;
}
return payload;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> BuildAdapterMap(
IEnumerable<INotifyChannelAdapter> adapters)
{
var builder = ImmutableDictionary.CreateBuilder<NotifyChannelType, INotifyChannelAdapter>();
foreach (var adapter in adapters)
{
builder[adapter.ChannelType] = adapter;
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,100 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Processing;
/// <summary>
/// Simple Handlebars-like template renderer supporting {{property}} and {{#each}} blocks.
/// </summary>
public sealed partial class SimpleTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
public string Render(NotifyTemplate template, JsonNode? payload)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
// Process {{#each}} blocks first
body = ProcessEachBlocks(body, payload);
// Then substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
return body;
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is not JsonObject obj)
{
return string.Empty;
}
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else
{
return null;
}
}
return current;
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
}