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
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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user