Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Email — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement SMTP connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class EmailChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = EmailMetadataBuilder.CreateBuilder(context)
|
||||
.Add("email.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("email.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Email channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Email channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Email channel target/configuration incomplete.",
|
||||
_ => "Email channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Target) ||
|
||||
(channel.Config.Properties is not null &&
|
||||
channel.Config.Properties.TryGetValue("fromAddress", out var from) &&
|
||||
!string.IsNullOrWhiteSpace(from));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class EmailChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var subject = context.Request.Title ?? "Stella Ops Notify Preview";
|
||||
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
|
||||
var htmlBody = !string.IsNullOrWhiteSpace(context.Request.Body)
|
||||
? context.Request.Body!
|
||||
: $"<p>{summary}</p><p><small>Trace: {context.TraceId}</small></p>";
|
||||
var textBody = context.Request.TextBody ?? $"{summary}{Environment.NewLine}Trace: {context.TraceId}";
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Email,
|
||||
NotifyDeliveryFormat.Email,
|
||||
context.Target,
|
||||
subject,
|
||||
htmlBody,
|
||||
summary,
|
||||
textBody,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(htmlBody),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = EmailMetadataBuilder.Build(context);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Email previews and health diagnostics with redacted secrets.
|
||||
/// </summary>
|
||||
internal static class EmailMetadataBuilder
|
||||
{
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("email.target", target)
|
||||
.AddTimestamp("email.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("email.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigProperties("email.config.", properties);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Notify Email Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — connector maintained under `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.email",
|
||||
"displayName": "StellaOps Email Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Email.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"email"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "email"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Common hashing helpers for Notify connector metadata.
|
||||
/// </summary>
|
||||
public static class ConnectorHashing
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a lowercase hex SHA-256 hash and truncates it to the requested number of bytes.
|
||||
/// </summary>
|
||||
public static string ComputeSha256Hash(string value, int lengthBytes = 8)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must not be null or whitespace.", nameof(value));
|
||||
}
|
||||
|
||||
if (lengthBytes <= 0 || lengthBytes > 32)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lengthBytes), "Length must be between 1 and 32 bytes.");
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(value.Trim());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash.AsSpan(0, lengthBytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Utility for constructing connector metadata payloads with consistent redaction rules.
|
||||
/// </summary>
|
||||
public sealed class ConnectorMetadataBuilder
|
||||
{
|
||||
private readonly Dictionary<string, string> _metadata;
|
||||
|
||||
public ConnectorMetadataBuilder(StringComparer? comparer = null)
|
||||
{
|
||||
_metadata = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
|
||||
SensitiveFragments = new HashSet<string>(ConnectorValueRedactor.DefaultSensitiveKeyFragments, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection of key fragments treated as sensitive when redacting values.
|
||||
/// </summary>
|
||||
public ISet<string> SensitiveFragments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds or replaces a metadata entry when the value is non-empty.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder Add(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_metadata[key.Trim()] = value.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the target value metadata. The value is trimmed but not redacted.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddTarget(string key, string target)
|
||||
=> Add(key, target);
|
||||
|
||||
/// <summary>
|
||||
/// Adds ISO-8601 timestamp metadata.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddTimestamp(string key, DateTimeOffset timestamp)
|
||||
=> Add(key, timestamp.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>
|
||||
/// Adds a hash of the secret reference when present.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddSecretRefHash(string key, string? secretRef, int lengthBytes = 8)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(secretRef))
|
||||
{
|
||||
Add(key, ConnectorHashing.ComputeSha256Hash(secretRef, lengthBytes));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds configuration target metadata only when the stored configuration differs from the resolved target.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigTarget(string key, string? configuredTarget, string resolvedTarget)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredTarget) &&
|
||||
!string.Equals(configuredTarget, resolvedTarget, StringComparison.Ordinal))
|
||||
{
|
||||
Add(key, configuredTarget);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds configuration endpoint metadata when present.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigEndpoint(string key, string? endpoint)
|
||||
=> Add(key, endpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Adds key/value metadata pairs from the provided dictionary, applying redaction to sensitive entries.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigProperties(
|
||||
string prefix,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
Func<string, string, string>? valueSelector = null)
|
||||
{
|
||||
if (properties is null || properties.Count == 0)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
foreach (var pair in properties)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = prefix + pair.Key.Trim();
|
||||
var value = valueSelector is null
|
||||
? Redact(pair.Key, pair.Value)
|
||||
: valueSelector(pair.Key, pair.Value);
|
||||
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges additional metadata entries into the builder.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string>> entries)
|
||||
{
|
||||
foreach (var (key, value) in entries)
|
||||
{
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the redacted representation for the supplied key/value pair.
|
||||
/// </summary>
|
||||
public string Redact(string key, string value)
|
||||
{
|
||||
if (ConnectorValueRedactor.IsSensitiveKey(key, SensitiveFragments))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactSecret(value);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an immutable view of the accumulated metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Build()
|
||||
=> new ReadOnlyDictionary<string, string>(_metadata);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for redacting sensitive connector metadata.
|
||||
/// </summary>
|
||||
public static class ConnectorValueRedactor
|
||||
{
|
||||
private static readonly string[] DefaultSensitiveFragments =
|
||||
{
|
||||
"token",
|
||||
"secret",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"password",
|
||||
"key",
|
||||
"credential"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default set of sensitive key fragments.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DefaultSensitiveKeyFragments => DefaultSensitiveFragments;
|
||||
|
||||
/// <summary>
|
||||
/// Uses a constant mask for sensitive values.
|
||||
/// </summary>
|
||||
public static string RedactSecret(string value) => "***";
|
||||
|
||||
/// <summary>
|
||||
/// Redacts the middle portion of a token while keeping stable prefix/suffix bytes.
|
||||
/// </summary>
|
||||
public static string RedactToken(string value, int prefixLength = 6, int suffixLength = 4)
|
||||
{
|
||||
var trimmed = value?.Trim() ?? string.Empty;
|
||||
if (trimmed.Length <= prefixLength + suffixLength)
|
||||
{
|
||||
return RedactSecret(trimmed);
|
||||
}
|
||||
|
||||
var prefix = trimmed[..prefixLength];
|
||||
var suffix = trimmed[^suffixLength..];
|
||||
return string.Concat(prefix, "***", suffix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the provided key appears to represent sensitive data.
|
||||
/// </summary>
|
||||
public static bool IsSensitiveKey(string key, IEnumerable<string>? fragments = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fragments ??= DefaultSensitiveFragments;
|
||||
var span = key.AsSpan();
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fragment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (span.IndexOf(fragment.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Slack — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver Slack connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class SlackChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = SlackMetadataBuilder.CreateBuilder(context)
|
||||
.Add("slack.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("slack.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Slack channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Slack channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Slack channel is missing a configured destination (target).",
|
||||
_ => "Slack channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Target);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class SlackChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static readonly string DefaultTitle = "Stella Ops Notify Preview";
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = !string.IsNullOrWhiteSpace(context.Request.Title)
|
||||
? context.Request.Title!
|
||||
: DefaultTitle;
|
||||
var summary = !string.IsNullOrWhiteSpace(context.Request.Summary)
|
||||
? context.Request.Summary!
|
||||
: $"Preview generated for Slack destination at {context.Timestamp:O}.";
|
||||
var bodyText = !string.IsNullOrWhiteSpace(context.Request.Body)
|
||||
? context.Request.Body!
|
||||
: summary;
|
||||
var workspace = context.Channel.Config.Properties.TryGetValue("workspace", out var workspaceName)
|
||||
? workspaceName
|
||||
: null;
|
||||
|
||||
var contextElements = new List<object>
|
||||
{
|
||||
new { type = "mrkdwn", text = $"Preview generated {context.Timestamp:O} · Trace `{context.TraceId}`" }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workspace))
|
||||
{
|
||||
contextElements.Add(new { type = "mrkdwn", text = $"Workspace: `{workspace}`" });
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
channel = context.Target,
|
||||
text = $"{title}\n{bodyText}",
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text = $"*{title}*\n{bodyText}" }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "context",
|
||||
elements = contextElements.ToArray()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Slack,
|
||||
NotifyDeliveryFormat.Slack,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
context.Request.TextBody ?? bodyText,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = SlackMetadataBuilder.Build(context);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Slack previews and health diagnostics while redacting sensitive material.
|
||||
/// </summary>
|
||||
internal static class SlackMetadataBuilder
|
||||
{
|
||||
private static readonly string[] RequiredScopes = { "chat:write", "chat:write.public" };
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("slack.channel", target)
|
||||
.Add("slack.scopes.required", string.Join(',', RequiredScopes))
|
||||
.AddTimestamp("slack.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("slack.secretRef.hash", secretRef)
|
||||
.AddConfigTarget("slack.config.target", channel.Config.Target, target)
|
||||
.AddConfigProperties("slack.config.", properties, (key, value) => RedactSlackValue(builder, key, value));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string RedactSlackValue(ConnectorMetadataBuilder builder, string key, string value)
|
||||
{
|
||||
if (LooksLikeSlackToken(value))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactToken(value);
|
||||
}
|
||||
|
||||
return builder.Redact(key, value);
|
||||
}
|
||||
|
||||
private static bool LooksLikeSlackToken(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length < 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return trimmed.StartsWith("xox", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Notify Slack Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — connector scope now in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.slack",
|
||||
"displayName": "StellaOps Slack Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Slack.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"slack"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "slack",
|
||||
"org.stellaops.notify.connector.requiredScopes": "chat:write,chat:write.public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Teams — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Microsoft Teams connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
# Notify Teams Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — connector work now owned by `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
|
||||
> Remark (2025-10-20): Teams test-send now emits Adaptive Card 1.5 payloads with legacy fallback text (`teams.fallbackText` metadata) and hashed webhook secret refs; coverage lives in `StellaOps.Notify.Connectors.Teams.Tests`. `/channels/{id}/health` shares the same metadata builder via `TeamsChannelHealthProvider`, ensuring webhook hashes and sensitive keys stay redacted.
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class TeamsChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Teams;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = TeamsMetadataBuilder.CreateBuilder(context)
|
||||
.Add("teams.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("teams.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Teams channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Teams channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Teams channel is missing a target/endpoint configuration.",
|
||||
_ => "Teams channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Endpoint) ||
|
||||
!string.IsNullOrWhiteSpace(channel.Config.Target);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class TeamsChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private const string DefaultTitle = "Stella Ops Notify Preview";
|
||||
private const int MaxFallbackLength = 512;
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Teams;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = ResolveTitle(context);
|
||||
var summary = ResolveSummary(context, title);
|
||||
var bodyContent = ResolveBodyContent(context, summary);
|
||||
var fallbackText = BuildFallbackText(context, title, summary, bodyContent);
|
||||
|
||||
var card = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = TeamsMetadataBuilder.CardVersion,
|
||||
body = new object[]
|
||||
{
|
||||
new { type = "TextBlock", weight = "Bolder", text = title, wrap = true },
|
||||
new { type = "TextBlock", text = bodyContent, wrap = true },
|
||||
new { type = "TextBlock", spacing = "None", isSubtle = true, text = $"Trace: {context.TraceId}", wrap = true }
|
||||
}
|
||||
};
|
||||
|
||||
var payload = new
|
||||
{
|
||||
type = "message",
|
||||
summary,
|
||||
text = fallbackText,
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = card
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Teams,
|
||||
NotifyDeliveryFormat.Teams,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
fallbackText,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = TeamsMetadataBuilder.Build(context, fallbackText);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
}
|
||||
|
||||
private static string ResolveTitle(ChannelTestPreviewContext context)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(context.Request.Title)
|
||||
? context.Request.Title!.Trim()
|
||||
: DefaultTitle;
|
||||
}
|
||||
|
||||
private static string ResolveSummary(ChannelTestPreviewContext context, string title)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(context.Request.Summary))
|
||||
{
|
||||
return context.Request.Summary!.Trim();
|
||||
}
|
||||
|
||||
return $"Preview generated for Teams destination at {context.Timestamp:O}. Title: {title}";
|
||||
}
|
||||
|
||||
private static string ResolveBodyContent(ChannelTestPreviewContext context, string summary)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(context.Request.Body))
|
||||
{
|
||||
return context.Request.Body!.Trim();
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static string BuildFallbackText(ChannelTestPreviewContext context, string title, string summary, string bodyContent)
|
||||
{
|
||||
var fallback = !string.IsNullOrWhiteSpace(context.Request.TextBody)
|
||||
? context.Request.TextBody!.Trim()
|
||||
: summary;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fallback))
|
||||
{
|
||||
fallback = $"{title}: {bodyContent}";
|
||||
}
|
||||
|
||||
fallback = fallback.Trim();
|
||||
fallback = fallback.ReplaceLineEndings(" ");
|
||||
|
||||
if (fallback.Length > MaxFallbackLength)
|
||||
{
|
||||
fallback = fallback[..MaxFallbackLength];
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Teams previews and health diagnostics while redacting sensitive material.
|
||||
/// </summary>
|
||||
internal static class TeamsMetadataBuilder
|
||||
{
|
||||
internal const string CardVersion = "1.5";
|
||||
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context, string fallbackText)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
fallbackText: fallbackText,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef,
|
||||
endpoint: context.Channel.Config.Endpoint);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
fallbackText: null,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef,
|
||||
endpoint: context.Channel.Config.Endpoint);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context, string fallbackText)
|
||||
=> CreateBuilder(context, fallbackText).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
string? fallbackText,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef,
|
||||
string? endpoint)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("teams.webhook", target)
|
||||
.AddTimestamp("teams.preview.generatedAt", timestamp)
|
||||
.Add("teams.card.version", CardVersion)
|
||||
.AddSecretRefHash("teams.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigTarget("teams.config.target", channel.Config.Target, target)
|
||||
.AddConfigEndpoint("teams.config.endpoint", endpoint)
|
||||
.AddConfigProperties("teams.config.", properties, (key, value) => RedactTeamsValue(builder, key, value));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackText))
|
||||
{
|
||||
builder.Add("teams.fallbackText", fallbackText!);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string RedactTeamsValue(ConnectorMetadataBuilder builder, string key, string value)
|
||||
{
|
||||
if (ConnectorValueRedactor.IsSensitiveKey(key, builder.SensitiveFragments))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactSecret(value);
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (LooksLikeGuid(trimmed))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactToken(trimmed, prefixLength: 8, suffixLength: 4);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static bool LooksLikeGuid(string value)
|
||||
=> value.Length >= 32 && Guid.TryParse(value, out _);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.teams",
|
||||
"displayName": "StellaOps Teams Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Teams.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"teams"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "teams",
|
||||
"org.stellaops.notify.connector.cardVersion": "1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Webhook — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement generic webhook connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Notify Webhook Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — webhook connector maintained in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Webhook;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class WebhookChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = context.Request.Title ?? "Stella Ops Notify Preview";
|
||||
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
title,
|
||||
summary,
|
||||
traceId = context.TraceId,
|
||||
timestamp = context.Timestamp,
|
||||
body = context.Request.Body,
|
||||
metadata = context.Request.Metadata
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Webhook,
|
||||
NotifyDeliveryFormat.Webhook,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
context.Request.TextBody ?? summary,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = WebhookMetadataBuilder.Build(context);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Webhook;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Webhook previews and health diagnostics.
|
||||
/// </summary>
|
||||
internal static class WebhookMetadataBuilder
|
||||
{
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("webhook.endpoint", target)
|
||||
.AddTimestamp("webhook.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("webhook.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigProperties("webhook.config.", properties);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.webhook",
|
||||
"displayName": "StellaOps Webhook Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Webhook.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"webhook"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "webhook"
|
||||
}
|
||||
}
|
||||
4
src/Notify/__Libraries/StellaOps.Notify.Engine/AGENTS.md
Normal file
4
src/Notify/__Libraries/StellaOps.Notify.Engine/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Engine — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver rule evaluation, digest, and rendering logic per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by channel plug-ins to provide health diagnostics.
|
||||
/// </summary>
|
||||
public interface INotifyChannelHealthProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type supported by the provider.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Executes a health check for the supplied channel.
|
||||
/// </summary>
|
||||
Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable context describing a channel health request.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthContext(
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
string Target,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by channel plug-ins when reporting health diagnostics.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthResult(
|
||||
ChannelHealthStatus Status,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Supported channel health states.
|
||||
/// </summary>
|
||||
public enum ChannelHealthStatus
|
||||
{
|
||||
Healthy,
|
||||
Degraded,
|
||||
Unhealthy
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by Notify channel plug-ins to generate channel-specific test preview payloads.
|
||||
/// </summary>
|
||||
public interface INotifyChannelTestProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type supported by the provider.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a channel-specific preview for a test-send request.
|
||||
/// </summary>
|
||||
Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitised request payload passed to channel plug-ins when building a preview.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewRequest(
|
||||
string? TargetOverride,
|
||||
string? TemplateId,
|
||||
string? Title,
|
||||
string? Summary,
|
||||
string? Body,
|
||||
string? TextBody,
|
||||
string? Locale,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
IReadOnlyList<string> Attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable context describing the channel and request for a test preview.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewContext(
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
string Target,
|
||||
ChannelTestPreviewRequest Request,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by channel plug-ins for test preview generation.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewResult(
|
||||
NotifyDeliveryRendered Preview,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown by plug-ins when preview input is invalid.
|
||||
/// </summary>
|
||||
public sealed class ChannelTestPreviewException : Exception
|
||||
{
|
||||
public ChannelTestPreviewException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for channel preview generation.
|
||||
/// </summary>
|
||||
public static class ChannelTestPreviewUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a lowercase hex SHA-256 body hash for preview payloads.
|
||||
/// </summary>
|
||||
public static string ComputeBodyHash(string body)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates Notify rules against platform events.
|
||||
/// </summary>
|
||||
public interface INotifyRuleEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a single rule against an event and returns the match outcome.
|
||||
/// </summary>
|
||||
NotifyRuleEvaluationOutcome Evaluate(
|
||||
NotifyRule rule,
|
||||
NotifyEvent @event,
|
||||
DateTimeOffset? evaluationTimestamp = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a collection of rules against an event.
|
||||
/// </summary>
|
||||
ImmutableArray<NotifyRuleEvaluationOutcome> Evaluate(
|
||||
IEnumerable<NotifyRule> rules,
|
||||
NotifyEvent @event,
|
||||
DateTimeOffset? evaluationTimestamp = null);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome produced when evaluating a notify rule against an event.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleEvaluationOutcome
|
||||
{
|
||||
private NotifyRuleEvaluationOutcome(
|
||||
NotifyRule rule,
|
||||
bool isMatch,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
DateTimeOffset? matchedAt,
|
||||
string? reason)
|
||||
{
|
||||
Rule = rule ?? throw new ArgumentNullException(nameof(rule));
|
||||
IsMatch = isMatch;
|
||||
Actions = actions;
|
||||
MatchedAt = matchedAt;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public NotifyRule Rule { get; }
|
||||
|
||||
public bool IsMatch { get; }
|
||||
|
||||
public ImmutableArray<NotifyRuleAction> Actions { get; }
|
||||
|
||||
public DateTimeOffset? MatchedAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public static NotifyRuleEvaluationOutcome NotMatched(NotifyRule rule, string reason)
|
||||
=> new(rule, false, ImmutableArray<NotifyRuleAction>.Empty, null, reason);
|
||||
|
||||
public static NotifyRuleEvaluationOutcome Matched(
|
||||
NotifyRule rule,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
DateTimeOffset matchedAt)
|
||||
=> new(rule, true, actions, matchedAt, null);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
2
src/Notify/__Libraries/StellaOps.Notify.Engine/TASKS.md
Normal file
2
src/Notify/__Libraries/StellaOps.Notify.Engine/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify Engine Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — runtime responsibilities moved to `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
4
src/Notify/__Libraries/StellaOps.Notify.Models/AGENTS.md
Normal file
4
src/Notify/__Libraries/StellaOps.Notify.Models/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Models — Agent Charter
|
||||
|
||||
## Mission
|
||||
Define Notify DTOs and contracts per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Xml;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
internal sealed class Iso8601DurationConverter : JsonConverter<TimeSpan>
|
||||
{
|
||||
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType is JsonTokenType.String)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return XmlConvert.ToTimeSpan(value);
|
||||
}
|
||||
}
|
||||
|
||||
throw new JsonException("Expected ISO 8601 duration string.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
|
||||
{
|
||||
var normalized = XmlConvert.ToString(value);
|
||||
writer.WriteStringValue(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic JSON serializer tuned for Notify canonical documents.
|
||||
/// </summary>
|
||||
public static class NotifyCanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false, useDeterministicResolver: true);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true, useDeterministicResolver: true);
|
||||
private static readonly JsonSerializerOptions ReadOptions = CreateOptions(writeIndented: false, useDeterministicResolver: false);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
|
||||
{
|
||||
{
|
||||
typeof(NotifyRule),
|
||||
new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"ruleId",
|
||||
"tenantId",
|
||||
"name",
|
||||
"description",
|
||||
"enabled",
|
||||
"match",
|
||||
"actions",
|
||||
"labels",
|
||||
"metadata",
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
"updatedBy",
|
||||
"updatedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyRuleMatch),
|
||||
new[]
|
||||
{
|
||||
"eventKinds",
|
||||
"namespaces",
|
||||
"repositories",
|
||||
"digests",
|
||||
"labels",
|
||||
"componentPurls",
|
||||
"minSeverity",
|
||||
"verdicts",
|
||||
"kevOnly",
|
||||
"vex",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyRuleAction),
|
||||
new[]
|
||||
{
|
||||
"actionId",
|
||||
"channel",
|
||||
"template",
|
||||
"locale",
|
||||
"digest",
|
||||
"throttle",
|
||||
"metadata",
|
||||
"enabled",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyChannel),
|
||||
new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"channelId",
|
||||
"tenantId",
|
||||
"name",
|
||||
"type",
|
||||
"displayName",
|
||||
"description",
|
||||
"config",
|
||||
"enabled",
|
||||
"labels",
|
||||
"metadata",
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
"updatedBy",
|
||||
"updatedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyChannelConfig),
|
||||
new[]
|
||||
{
|
||||
"secretRef",
|
||||
"target",
|
||||
"endpoint",
|
||||
"properties",
|
||||
"limits",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyTemplate),
|
||||
new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"templateId",
|
||||
"tenantId",
|
||||
"channelType",
|
||||
"key",
|
||||
"locale",
|
||||
"description",
|
||||
"renderMode",
|
||||
"body",
|
||||
"format",
|
||||
"metadata",
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
"updatedBy",
|
||||
"updatedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyEvent),
|
||||
new[]
|
||||
{
|
||||
"eventId",
|
||||
"kind",
|
||||
"version",
|
||||
"tenant",
|
||||
"ts",
|
||||
"actor",
|
||||
"scope",
|
||||
"payload",
|
||||
"attributes",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyEventScope),
|
||||
new[]
|
||||
{
|
||||
"namespace",
|
||||
"repo",
|
||||
"digest",
|
||||
"component",
|
||||
"image",
|
||||
"labels",
|
||||
"attributes",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyDelivery),
|
||||
new[]
|
||||
{
|
||||
"deliveryId",
|
||||
"tenantId",
|
||||
"ruleId",
|
||||
"actionId",
|
||||
"eventId",
|
||||
"kind",
|
||||
"status",
|
||||
"statusReason",
|
||||
"createdAt",
|
||||
"sentAt",
|
||||
"completedAt",
|
||||
"rendered",
|
||||
"attempts",
|
||||
"metadata",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyDeliveryAttempt),
|
||||
new[]
|
||||
{
|
||||
"timestamp",
|
||||
"status",
|
||||
"statusCode",
|
||||
"reason",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyDeliveryRendered),
|
||||
new[]
|
||||
{
|
||||
"title",
|
||||
"summary",
|
||||
"target",
|
||||
"locale",
|
||||
"channelType",
|
||||
"format",
|
||||
"body",
|
||||
"textBody",
|
||||
"bodyHash",
|
||||
"attachments",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
{
|
||||
if (typeof(T) == typeof(NotifyRule))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyRuleDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyRule payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyChannel))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyChannelDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyChannel payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyTemplate))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyTemplateDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyTemplate payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyEvent))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyEventDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyEvent payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyDelivery))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyDeliveryDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyDelivery payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}.");
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented, bool useDeterministicResolver)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = writeIndented,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
if (useDeterministicResolver)
|
||||
{
|
||||
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
|
||||
}
|
||||
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
options.Converters.Add(new Iso8601DurationConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _inner;
|
||||
|
||||
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = _inner.GetTypeInfo(type, options)
|
||||
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => GetPropertyOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int GetPropertyOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrderOverrides.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is { } index and >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleDto
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
public string? RuleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public NotifyRuleMatchDto? Match { get; set; }
|
||||
public List<NotifyRuleActionDto>? Actions { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public NotifyRule ToModel()
|
||||
=> NotifyRule.Create(
|
||||
RuleId ?? throw new InvalidOperationException("ruleId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
Name ?? throw new InvalidOperationException("name missing"),
|
||||
(Match ?? new NotifyRuleMatchDto()).ToModel(),
|
||||
Actions?.Select(action => action.ToModel()) ?? Array.Empty<NotifyRuleAction>(),
|
||||
Enabled.GetValueOrDefault(true),
|
||||
Description,
|
||||
Labels,
|
||||
Metadata,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedBy,
|
||||
UpdatedAt,
|
||||
SchemaVersion);
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleMatchDto
|
||||
{
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? Namespaces { get; set; }
|
||||
public List<string>? Repositories { get; set; }
|
||||
public List<string>? Digests { get; set; }
|
||||
public List<string>? Labels { get; set; }
|
||||
public List<string>? ComponentPurls { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<string>? Verdicts { get; set; }
|
||||
public bool? KevOnly { get; set; }
|
||||
public NotifyRuleMatchVexDto? Vex { get; set; }
|
||||
|
||||
public NotifyRuleMatch ToModel()
|
||||
=> NotifyRuleMatch.Create(
|
||||
EventKinds,
|
||||
Namespaces,
|
||||
Repositories,
|
||||
Digests,
|
||||
Labels,
|
||||
ComponentPurls,
|
||||
MinSeverity,
|
||||
Verdicts,
|
||||
KevOnly,
|
||||
Vex?.ToModel());
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleMatchVexDto
|
||||
{
|
||||
public bool IncludeAcceptedJustifications { get; set; } = true;
|
||||
public bool IncludeRejectedJustifications { get; set; }
|
||||
public bool IncludeUnknownJustifications { get; set; }
|
||||
public List<string>? JustificationKinds { get; set; }
|
||||
|
||||
public NotifyRuleMatchVex ToModel()
|
||||
=> NotifyRuleMatchVex.Create(
|
||||
IncludeAcceptedJustifications,
|
||||
IncludeRejectedJustifications,
|
||||
IncludeUnknownJustifications,
|
||||
JustificationKinds);
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleActionDto
|
||||
{
|
||||
public string? ActionId { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public TimeSpan? Throttle { get; set; }
|
||||
public string? Locale { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
public NotifyRuleAction ToModel()
|
||||
=> NotifyRuleAction.Create(
|
||||
ActionId ?? throw new InvalidOperationException("actionId missing"),
|
||||
Channel ?? throw new InvalidOperationException("channel missing"),
|
||||
Template,
|
||||
Digest,
|
||||
Throttle,
|
||||
Locale,
|
||||
Enabled.GetValueOrDefault(true),
|
||||
Metadata);
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelDto
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
public string? ChannelId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public NotifyChannelType Type { get; set; }
|
||||
public NotifyChannelConfigDto? Config { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public NotifyChannel ToModel()
|
||||
=> NotifyChannel.Create(
|
||||
ChannelId ?? throw new InvalidOperationException("channelId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
Name ?? throw new InvalidOperationException("name missing"),
|
||||
Type,
|
||||
(Config ?? new NotifyChannelConfigDto()).ToModel(),
|
||||
DisplayName,
|
||||
Description,
|
||||
Enabled.GetValueOrDefault(true),
|
||||
Labels,
|
||||
Metadata,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedBy,
|
||||
UpdatedAt,
|
||||
SchemaVersion);
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelConfigDto
|
||||
{
|
||||
public string? SecretRef { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public string? Endpoint { get; set; }
|
||||
public Dictionary<string, string>? Properties { get; set; }
|
||||
public NotifyChannelLimitsDto? Limits { get; set; }
|
||||
|
||||
public NotifyChannelConfig ToModel()
|
||||
=> NotifyChannelConfig.Create(
|
||||
SecretRef ?? throw new InvalidOperationException("secretRef missing"),
|
||||
Target,
|
||||
Endpoint,
|
||||
Properties,
|
||||
Limits?.ToModel());
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelLimitsDto
|
||||
{
|
||||
public int? Concurrency { get; set; }
|
||||
public int? RequestsPerMinute { get; set; }
|
||||
public TimeSpan? Timeout { get; set; }
|
||||
public int? MaxBatchSize { get; set; }
|
||||
|
||||
public NotifyChannelLimits ToModel()
|
||||
=> new(
|
||||
Concurrency,
|
||||
RequestsPerMinute,
|
||||
Timeout,
|
||||
MaxBatchSize);
|
||||
}
|
||||
|
||||
internal sealed class NotifyTemplateDto
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
public string? TemplateId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public NotifyChannelType ChannelType { get; set; }
|
||||
public string? Key { get; set; }
|
||||
public string? Locale { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public NotifyTemplateRenderMode RenderMode { get; set; } = NotifyTemplateRenderMode.Markdown;
|
||||
public NotifyDeliveryFormat Format { get; set; } = NotifyDeliveryFormat.Json;
|
||||
public string? Description { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public NotifyTemplate ToModel()
|
||||
=> NotifyTemplate.Create(
|
||||
TemplateId ?? throw new InvalidOperationException("templateId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
ChannelType,
|
||||
Key ?? throw new InvalidOperationException("key missing"),
|
||||
Locale ?? throw new InvalidOperationException("locale missing"),
|
||||
Body ?? throw new InvalidOperationException("body missing"),
|
||||
RenderMode,
|
||||
Format,
|
||||
Description,
|
||||
Metadata,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedBy,
|
||||
UpdatedAt,
|
||||
SchemaVersion);
|
||||
}
|
||||
|
||||
internal sealed class NotifyEventDto
|
||||
{
|
||||
public Guid EventId { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? Tenant { get; set; }
|
||||
public DateTimeOffset Ts { get; set; }
|
||||
public JsonNode? Payload { get; set; }
|
||||
public NotifyEventScopeDto? Scope { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
|
||||
public NotifyEvent ToModel()
|
||||
=> NotifyEvent.Create(
|
||||
EventId,
|
||||
Kind ?? throw new InvalidOperationException("kind missing"),
|
||||
Tenant ?? throw new InvalidOperationException("tenant missing"),
|
||||
Ts,
|
||||
Payload,
|
||||
Scope?.ToModel(),
|
||||
Version,
|
||||
Actor,
|
||||
Attributes);
|
||||
}
|
||||
|
||||
internal sealed class NotifyEventScopeDto
|
||||
{
|
||||
public string? Namespace { get; set; }
|
||||
public string? Repo { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public string? Component { get; set; }
|
||||
public string? Image { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
|
||||
public NotifyEventScope ToModel()
|
||||
=> NotifyEventScope.Create(
|
||||
Namespace,
|
||||
Repo,
|
||||
Digest,
|
||||
Component,
|
||||
Image,
|
||||
Labels,
|
||||
Attributes);
|
||||
}
|
||||
|
||||
internal sealed class NotifyDeliveryDto
|
||||
{
|
||||
public string? DeliveryId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? RuleId { get; set; }
|
||||
public string? ActionId { get; set; }
|
||||
public Guid EventId { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public NotifyDeliveryStatus Status { get; set; }
|
||||
public string? StatusReason { get; set; }
|
||||
public NotifyDeliveryRenderedDto? Rendered { get; set; }
|
||||
public List<NotifyDeliveryAttemptDto>? Attempts { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public DateTimeOffset? SentAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public NotifyDelivery ToModel()
|
||||
=> NotifyDelivery.Create(
|
||||
DeliveryId ?? throw new InvalidOperationException("deliveryId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
RuleId ?? throw new InvalidOperationException("ruleId missing"),
|
||||
ActionId ?? throw new InvalidOperationException("actionId missing"),
|
||||
EventId,
|
||||
Kind ?? throw new InvalidOperationException("kind missing"),
|
||||
Status,
|
||||
StatusReason,
|
||||
Rendered?.ToModel(),
|
||||
Attempts?.Select(attempt => attempt.ToModel()),
|
||||
Metadata,
|
||||
CreatedAt,
|
||||
SentAt,
|
||||
CompletedAt);
|
||||
}
|
||||
|
||||
internal sealed class NotifyDeliveryAttemptDto
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public NotifyDeliveryAttemptStatus Status { get; set; }
|
||||
public int? StatusCode { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public NotifyDeliveryAttempt ToModel()
|
||||
=> new(Timestamp, Status, StatusCode, Reason);
|
||||
}
|
||||
|
||||
internal sealed class NotifyDeliveryRenderedDto
|
||||
{
|
||||
public NotifyChannelType ChannelType { get; set; }
|
||||
public NotifyDeliveryFormat Format { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? TextBody { get; set; }
|
||||
public string? Locale { get; set; }
|
||||
public string? BodyHash { get; set; }
|
||||
public List<string>? Attachments { get; set; }
|
||||
|
||||
public NotifyDeliveryRendered ToModel()
|
||||
=> NotifyDeliveryRendered.Create(
|
||||
ChannelType,
|
||||
Format,
|
||||
Target ?? throw new InvalidOperationException("target missing"),
|
||||
Title ?? throw new InvalidOperationException("title missing"),
|
||||
Body ?? throw new InvalidOperationException("body missing"),
|
||||
Summary,
|
||||
TextBody,
|
||||
Locale,
|
||||
BodyHash,
|
||||
Attachments);
|
||||
}
|
||||
235
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyChannel.cs
Normal file
235
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyChannel.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configured delivery channel (Slack workspace, Teams webhook, SMTP profile, etc.).
|
||||
/// </summary>
|
||||
public sealed record NotifyChannel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannel(
|
||||
string channelId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyChannelType type,
|
||||
NotifyChannelConfig config,
|
||||
string? displayName = null,
|
||||
string? description = null,
|
||||
bool enabled = true,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureChannel(schemaVersion);
|
||||
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Type = type;
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
DisplayName = NotifyValidation.TrimToNull(displayName);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Enabled = enabled;
|
||||
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyChannel Create(
|
||||
string channelId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyChannelType type,
|
||||
NotifyChannelConfig config,
|
||||
string? displayName = null,
|
||||
string? description = null,
|
||||
bool enabled = true,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyChannel(
|
||||
channelId,
|
||||
tenantId,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
displayName,
|
||||
description,
|
||||
enabled,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public NotifyChannelType Type { get; }
|
||||
|
||||
public NotifyChannelConfig Config { get; }
|
||||
|
||||
public string? DisplayName { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel configuration payload (secret reference, destination coordinates, connector-specific metadata).
|
||||
/// </summary>
|
||||
public sealed record NotifyChannelConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannelConfig(
|
||||
string secretRef,
|
||||
string? target = null,
|
||||
string? endpoint = null,
|
||||
ImmutableDictionary<string, string>? properties = null,
|
||||
NotifyChannelLimits? limits = null)
|
||||
{
|
||||
SecretRef = NotifyValidation.EnsureNotNullOrWhiteSpace(secretRef, nameof(secretRef));
|
||||
Target = NotifyValidation.TrimToNull(target);
|
||||
Endpoint = NotifyValidation.TrimToNull(endpoint);
|
||||
Properties = NotifyValidation.NormalizeStringDictionary(properties);
|
||||
Limits = limits;
|
||||
}
|
||||
|
||||
public static NotifyChannelConfig Create(
|
||||
string secretRef,
|
||||
string? target = null,
|
||||
string? endpoint = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? properties = null,
|
||||
NotifyChannelLimits? limits = null)
|
||||
{
|
||||
return new NotifyChannelConfig(
|
||||
secretRef,
|
||||
target,
|
||||
endpoint,
|
||||
ToImmutableDictionary(properties),
|
||||
limits);
|
||||
}
|
||||
|
||||
public string SecretRef { get; }
|
||||
|
||||
public string? Target { get; }
|
||||
|
||||
public string? Endpoint { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Properties { get; }
|
||||
|
||||
public NotifyChannelLimits? Limits { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-channel limits that influence worker behaviour.
|
||||
/// </summary>
|
||||
public sealed record NotifyChannelLimits
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannelLimits(
|
||||
int? concurrency = null,
|
||||
int? requestsPerMinute = null,
|
||||
TimeSpan? timeout = null,
|
||||
int? maxBatchSize = null)
|
||||
{
|
||||
if (concurrency is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(concurrency), "Concurrency must be positive when specified.");
|
||||
}
|
||||
|
||||
if (requestsPerMinute is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(requestsPerMinute), "Requests per minute must be positive when specified.");
|
||||
}
|
||||
|
||||
if (maxBatchSize is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxBatchSize), "Max batch size must be positive when specified.");
|
||||
}
|
||||
|
||||
Concurrency = concurrency;
|
||||
RequestsPerMinute = requestsPerMinute;
|
||||
Timeout = timeout is { Ticks: > 0 } ? timeout : null;
|
||||
MaxBatchSize = maxBatchSize;
|
||||
}
|
||||
|
||||
public int? Concurrency { get; }
|
||||
|
||||
public int? RequestsPerMinute { get; }
|
||||
|
||||
public TimeSpan? Timeout { get; }
|
||||
|
||||
public int? MaxBatchSize { get; }
|
||||
}
|
||||
252
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyDelivery.cs
Normal file
252
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyDelivery.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Delivery ledger entry capturing render output, attempts, and status transitions.
|
||||
/// </summary>
|
||||
public sealed record NotifyDelivery
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDelivery(
|
||||
string deliveryId,
|
||||
string tenantId,
|
||||
string ruleId,
|
||||
string actionId,
|
||||
Guid eventId,
|
||||
string kind,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason = null,
|
||||
NotifyDeliveryRendered? rendered = null,
|
||||
ImmutableArray<NotifyDeliveryAttempt> attempts = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? sentAt = null,
|
||||
DateTimeOffset? completedAt = null)
|
||||
{
|
||||
DeliveryId = NotifyValidation.EnsureNotNullOrWhiteSpace(deliveryId, nameof(deliveryId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
|
||||
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
|
||||
EventId = eventId;
|
||||
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
|
||||
Status = status;
|
||||
StatusReason = NotifyValidation.TrimToNull(statusReason);
|
||||
Rendered = rendered;
|
||||
|
||||
Attempts = NormalizeAttempts(attempts);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
SentAt = NotifyValidation.EnsureUtc(sentAt);
|
||||
CompletedAt = NotifyValidation.EnsureUtc(completedAt);
|
||||
}
|
||||
|
||||
public static NotifyDelivery Create(
|
||||
string deliveryId,
|
||||
string tenantId,
|
||||
string ruleId,
|
||||
string actionId,
|
||||
Guid eventId,
|
||||
string kind,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason = null,
|
||||
NotifyDeliveryRendered? rendered = null,
|
||||
IEnumerable<NotifyDeliveryAttempt>? attempts = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? sentAt = null,
|
||||
DateTimeOffset? completedAt = null)
|
||||
{
|
||||
return new NotifyDelivery(
|
||||
deliveryId,
|
||||
tenantId,
|
||||
ruleId,
|
||||
actionId,
|
||||
eventId,
|
||||
kind,
|
||||
status,
|
||||
statusReason,
|
||||
rendered,
|
||||
ToImmutableArray(attempts),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdAt,
|
||||
sentAt,
|
||||
completedAt);
|
||||
}
|
||||
|
||||
public string DeliveryId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string RuleId { get; }
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public Guid EventId { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public NotifyDeliveryStatus Status { get; }
|
||||
|
||||
public string? StatusReason { get; }
|
||||
|
||||
public NotifyDeliveryRendered? Rendered { get; }
|
||||
|
||||
public ImmutableArray<NotifyDeliveryAttempt> Attempts { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset? SentAt { get; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyDeliveryAttempt> NormalizeAttempts(ImmutableArray<NotifyDeliveryAttempt> attempts)
|
||||
{
|
||||
var source = attempts.IsDefault ? Array.Empty<NotifyDeliveryAttempt>() : attempts.AsEnumerable();
|
||||
return source
|
||||
.Where(static attempt => attempt is not null)
|
||||
.OrderBy(static attempt => attempt.Timestamp)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyDeliveryAttempt> ToImmutableArray(IEnumerable<NotifyDeliveryAttempt>? attempts)
|
||||
{
|
||||
if (attempts is null)
|
||||
{
|
||||
return ImmutableArray<NotifyDeliveryAttempt>.Empty;
|
||||
}
|
||||
|
||||
return attempts.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery attempt outcome.
|
||||
/// </summary>
|
||||
public sealed record NotifyDeliveryAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDeliveryAttempt(
|
||||
DateTimeOffset timestamp,
|
||||
NotifyDeliveryAttemptStatus status,
|
||||
int? statusCode = null,
|
||||
string? reason = null)
|
||||
{
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
Status = status;
|
||||
if (statusCode is < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(statusCode), "Status code must be positive when specified.");
|
||||
}
|
||||
|
||||
StatusCode = statusCode;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
}
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public NotifyDeliveryAttemptStatus Status { get; }
|
||||
|
||||
public int? StatusCode { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendered payload snapshot for audit purposes (redacted as needed).
|
||||
/// </summary>
|
||||
public sealed record NotifyDeliveryRendered
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDeliveryRendered(
|
||||
NotifyChannelType channelType,
|
||||
NotifyDeliveryFormat format,
|
||||
string target,
|
||||
string title,
|
||||
string body,
|
||||
string? summary = null,
|
||||
string? textBody = null,
|
||||
string? locale = null,
|
||||
string? bodyHash = null,
|
||||
ImmutableArray<string> attachments = default)
|
||||
{
|
||||
ChannelType = channelType;
|
||||
Format = format;
|
||||
Target = NotifyValidation.EnsureNotNullOrWhiteSpace(target, nameof(target));
|
||||
Title = NotifyValidation.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
||||
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
|
||||
Summary = NotifyValidation.TrimToNull(summary);
|
||||
TextBody = NotifyValidation.TrimToNull(textBody);
|
||||
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
|
||||
BodyHash = NotifyValidation.TrimToNull(bodyHash);
|
||||
Attachments = NotifyValidation.NormalizeStringSet(attachments.IsDefault ? Array.Empty<string>() : attachments.AsEnumerable());
|
||||
}
|
||||
|
||||
public static NotifyDeliveryRendered Create(
|
||||
NotifyChannelType channelType,
|
||||
NotifyDeliveryFormat format,
|
||||
string target,
|
||||
string title,
|
||||
string body,
|
||||
string? summary = null,
|
||||
string? textBody = null,
|
||||
string? locale = null,
|
||||
string? bodyHash = null,
|
||||
IEnumerable<string>? attachments = null)
|
||||
{
|
||||
return new NotifyDeliveryRendered(
|
||||
channelType,
|
||||
format,
|
||||
target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
textBody,
|
||||
locale,
|
||||
bodyHash,
|
||||
attachments is null ? ImmutableArray<string>.Empty : attachments.ToImmutableArray());
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public NotifyDeliveryFormat Format { get; }
|
||||
|
||||
public string Target { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string? Summary { get; }
|
||||
|
||||
public string? TextBody { get; }
|
||||
|
||||
public string? Locale { get; }
|
||||
|
||||
public string? BodyHash { get; }
|
||||
|
||||
public ImmutableArray<string> Attachments { get; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Supported Notify channel types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyChannelType
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery lifecycle states tracked for audit and retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryStatus
|
||||
{
|
||||
Pending,
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
Digested,
|
||||
Dropped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attempt status recorded during delivery retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryAttemptStatus
|
||||
{
|
||||
Enqueued,
|
||||
Sending,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Throttled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendering modes for templates to help connectors decide format handling.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyTemplateRenderMode
|
||||
{
|
||||
Markdown,
|
||||
Html,
|
||||
AdaptiveCard,
|
||||
PlainText,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured representation of rendered payload format.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryFormat
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Json,
|
||||
}
|
||||
168
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyEvent.cs
Normal file
168
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyEvent.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical platform event envelope consumed by Notify.
|
||||
/// </summary>
|
||||
public sealed record NotifyEvent
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEvent(
|
||||
Guid eventId,
|
||||
string kind,
|
||||
string tenant,
|
||||
DateTimeOffset ts,
|
||||
JsonNode? payload,
|
||||
NotifyEventScope? scope = null,
|
||||
string? version = null,
|
||||
string? actor = null,
|
||||
ImmutableDictionary<string, string>? attributes = null)
|
||||
{
|
||||
EventId = eventId;
|
||||
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
|
||||
Tenant = NotifyValidation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
|
||||
Ts = NotifyValidation.EnsureUtc(ts);
|
||||
Payload = NotifyValidation.NormalizeJsonNode(payload);
|
||||
Scope = scope;
|
||||
Version = NotifyValidation.TrimToNull(version);
|
||||
Actor = NotifyValidation.TrimToNull(actor);
|
||||
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
|
||||
}
|
||||
|
||||
public static NotifyEvent Create(
|
||||
Guid eventId,
|
||||
string kind,
|
||||
string tenant,
|
||||
DateTimeOffset ts,
|
||||
JsonNode? payload,
|
||||
NotifyEventScope? scope = null,
|
||||
string? version = null,
|
||||
string? actor = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? attributes = null)
|
||||
{
|
||||
return new NotifyEvent(
|
||||
eventId,
|
||||
kind,
|
||||
tenant,
|
||||
ts,
|
||||
payload,
|
||||
scope,
|
||||
version,
|
||||
actor,
|
||||
ToImmutableDictionary(attributes));
|
||||
}
|
||||
|
||||
public Guid EventId { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public string Tenant { get; }
|
||||
|
||||
public DateTimeOffset Ts { get; }
|
||||
|
||||
public JsonNode? Payload { get; }
|
||||
|
||||
public NotifyEventScope? Scope { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
|
||||
public string? Actor { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional scope block describing where the event originated (namespace/repo/digest/etc.).
|
||||
/// </summary>
|
||||
public sealed record NotifyEventScope
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEventScope(
|
||||
string? @namespace = null,
|
||||
string? repo = null,
|
||||
string? digest = null,
|
||||
string? component = null,
|
||||
string? image = null,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? attributes = null)
|
||||
{
|
||||
Namespace = NotifyValidation.TrimToNull(@namespace);
|
||||
Repo = NotifyValidation.TrimToNull(repo);
|
||||
Digest = NotifyValidation.TrimToNull(digest);
|
||||
Component = NotifyValidation.TrimToNull(component);
|
||||
Image = NotifyValidation.TrimToNull(image);
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
|
||||
}
|
||||
|
||||
public static NotifyEventScope Create(
|
||||
string? @namespace = null,
|
||||
string? repo = null,
|
||||
string? digest = null,
|
||||
string? component = null,
|
||||
string? image = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? attributes = null)
|
||||
{
|
||||
return new NotifyEventScope(
|
||||
@namespace,
|
||||
repo,
|
||||
digest,
|
||||
component,
|
||||
image,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(attributes));
|
||||
}
|
||||
|
||||
public string? Namespace { get; }
|
||||
|
||||
public string? Repo { get; }
|
||||
|
||||
public string? Digest { get; }
|
||||
|
||||
public string? Component { get; }
|
||||
|
||||
public string? Image { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Known platform event kind identifiers consumed by Notify.
|
||||
/// </summary>
|
||||
public static class NotifyEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
|
||||
public const string AttestorLogged = "attestor.logged";
|
||||
public const string ZastavaAdmission = "zastava.admission";
|
||||
public const string FeedserExportCompleted = "feedser.export.completed";
|
||||
public const string VexerExportCompleted = "vexer.export.completed";
|
||||
}
|
||||
388
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyRule.cs
Normal file
388
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyRule.cs
Normal file
@@ -0,0 +1,388 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Rule definition describing how platform events are matched and routed to delivery actions.
|
||||
/// </summary>
|
||||
public sealed record NotifyRule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRule(
|
||||
string ruleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyRuleMatch match,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureRule(schemaVersion);
|
||||
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Match = match ?? throw new ArgumentNullException(nameof(match));
|
||||
Enabled = enabled;
|
||||
|
||||
Actions = NormalizeActions(actions);
|
||||
if (Actions.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one action is required.", nameof(actions));
|
||||
}
|
||||
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyRule Create(
|
||||
string ruleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyRuleMatch match,
|
||||
IEnumerable<NotifyRuleAction>? actions,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyRule(
|
||||
ruleId,
|
||||
tenantId,
|
||||
name,
|
||||
match,
|
||||
ToImmutableArray(actions),
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string RuleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public NotifyRuleMatch Match { get; }
|
||||
|
||||
public ImmutableArray<NotifyRuleAction> Actions { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyRuleAction> NormalizeActions(ImmutableArray<NotifyRuleAction> actions)
|
||||
{
|
||||
var source = actions.IsDefault ? Array.Empty<NotifyRuleAction>() : actions.AsEnumerable();
|
||||
return source
|
||||
.Where(static action => action is not null)
|
||||
.Distinct()
|
||||
.OrderBy(static action => action.ActionId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyRuleAction> ToImmutableArray(IEnumerable<NotifyRuleAction>? actions)
|
||||
{
|
||||
if (actions is null)
|
||||
{
|
||||
return ImmutableArray<NotifyRuleAction>.Empty;
|
||||
}
|
||||
|
||||
return actions.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matching criteria used to evaluate whether an event should trigger the rule.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleMatch
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleMatch(
|
||||
ImmutableArray<string> eventKinds,
|
||||
ImmutableArray<string> namespaces,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> digests,
|
||||
ImmutableArray<string> labels,
|
||||
ImmutableArray<string> componentPurls,
|
||||
string? minSeverity,
|
||||
ImmutableArray<string> verdicts,
|
||||
bool? kevOnly,
|
||||
NotifyRuleMatchVex? vex)
|
||||
{
|
||||
EventKinds = NormalizeStringSet(eventKinds, lowerCase: true);
|
||||
Namespaces = NormalizeStringSet(namespaces);
|
||||
Repositories = NormalizeStringSet(repositories);
|
||||
Digests = NormalizeStringSet(digests, lowerCase: true);
|
||||
Labels = NormalizeStringSet(labels);
|
||||
ComponentPurls = NormalizeStringSet(componentPurls);
|
||||
Verdicts = NormalizeStringSet(verdicts, lowerCase: true);
|
||||
MinSeverity = NotifyValidation.TrimToNull(minSeverity)?.ToLowerInvariant();
|
||||
KevOnly = kevOnly;
|
||||
Vex = vex;
|
||||
}
|
||||
|
||||
public static NotifyRuleMatch Create(
|
||||
IEnumerable<string>? eventKinds = null,
|
||||
IEnumerable<string>? namespaces = null,
|
||||
IEnumerable<string>? repositories = null,
|
||||
IEnumerable<string>? digests = null,
|
||||
IEnumerable<string>? labels = null,
|
||||
IEnumerable<string>? componentPurls = null,
|
||||
string? minSeverity = null,
|
||||
IEnumerable<string>? verdicts = null,
|
||||
bool? kevOnly = null,
|
||||
NotifyRuleMatchVex? vex = null)
|
||||
{
|
||||
return new NotifyRuleMatch(
|
||||
ToImmutableArray(eventKinds),
|
||||
ToImmutableArray(namespaces),
|
||||
ToImmutableArray(repositories),
|
||||
ToImmutableArray(digests),
|
||||
ToImmutableArray(labels),
|
||||
ToImmutableArray(componentPurls),
|
||||
minSeverity,
|
||||
ToImmutableArray(verdicts),
|
||||
kevOnly,
|
||||
vex);
|
||||
}
|
||||
|
||||
public ImmutableArray<string> EventKinds { get; }
|
||||
|
||||
public ImmutableArray<string> Namespaces { get; }
|
||||
|
||||
public ImmutableArray<string> Repositories { get; }
|
||||
|
||||
public ImmutableArray<string> Digests { get; }
|
||||
|
||||
public ImmutableArray<string> Labels { get; }
|
||||
|
||||
public ImmutableArray<string> ComponentPurls { get; }
|
||||
|
||||
public string? MinSeverity { get; }
|
||||
|
||||
public ImmutableArray<string> Verdicts { get; }
|
||||
|
||||
public bool? KevOnly { get; }
|
||||
|
||||
public NotifyRuleMatchVex? Vex { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values, bool lowerCase = false)
|
||||
{
|
||||
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
|
||||
var normalized = NotifyValidation.NormalizeStringSet(enumerable);
|
||||
|
||||
if (!lowerCase)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Select(static value => value.ToLowerInvariant())
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional VEX (Vulnerability Exploitability eXchange) gating options.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleMatchVex
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleMatchVex(
|
||||
bool includeAcceptedJustifications = true,
|
||||
bool includeRejectedJustifications = false,
|
||||
bool includeUnknownJustifications = false,
|
||||
ImmutableArray<string> justificationKinds = default)
|
||||
{
|
||||
IncludeAcceptedJustifications = includeAcceptedJustifications;
|
||||
IncludeRejectedJustifications = includeRejectedJustifications;
|
||||
IncludeUnknownJustifications = includeUnknownJustifications;
|
||||
JustificationKinds = NormalizeStringSet(justificationKinds);
|
||||
}
|
||||
|
||||
public static NotifyRuleMatchVex Create(
|
||||
bool includeAcceptedJustifications = true,
|
||||
bool includeRejectedJustifications = false,
|
||||
bool includeUnknownJustifications = false,
|
||||
IEnumerable<string>? justificationKinds = null)
|
||||
{
|
||||
return new NotifyRuleMatchVex(
|
||||
includeAcceptedJustifications,
|
||||
includeRejectedJustifications,
|
||||
includeUnknownJustifications,
|
||||
ToImmutableArray(justificationKinds));
|
||||
}
|
||||
|
||||
public bool IncludeAcceptedJustifications { get; }
|
||||
|
||||
public bool IncludeRejectedJustifications { get; }
|
||||
|
||||
public bool IncludeUnknownJustifications { get; }
|
||||
|
||||
public ImmutableArray<string> JustificationKinds { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values)
|
||||
{
|
||||
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
|
||||
return NotifyValidation.NormalizeStringSet(enumerable);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action executed when a rule matches an event.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleAction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleAction(
|
||||
string actionId,
|
||||
string channel,
|
||||
string? template = null,
|
||||
string? digest = null,
|
||||
TimeSpan? throttle = null,
|
||||
string? locale = null,
|
||||
bool enabled = true,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
|
||||
Channel = NotifyValidation.EnsureNotNullOrWhiteSpace(channel, nameof(channel));
|
||||
Template = NotifyValidation.TrimToNull(template);
|
||||
Digest = NotifyValidation.TrimToNull(digest);
|
||||
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
|
||||
Enabled = enabled;
|
||||
Throttle = throttle is { Ticks: > 0 } ? throttle : null;
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
}
|
||||
|
||||
public static NotifyRuleAction Create(
|
||||
string actionId,
|
||||
string channel,
|
||||
string? template = null,
|
||||
string? digest = null,
|
||||
TimeSpan? throttle = null,
|
||||
string? locale = null,
|
||||
bool enabled = true,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null)
|
||||
{
|
||||
return new NotifyRuleAction(
|
||||
actionId,
|
||||
channel,
|
||||
template,
|
||||
digest,
|
||||
throttle,
|
||||
locale,
|
||||
enabled,
|
||||
ToImmutableDictionary(metadata));
|
||||
}
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public string Channel { get; }
|
||||
|
||||
public string? Template { get; }
|
||||
|
||||
public string? Digest { get; }
|
||||
|
||||
public TimeSpan? Throttle { get; }
|
||||
|
||||
public string? Locale { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades Notify documents emitted by older schema revisions to the current DTOs.
|
||||
/// </summary>
|
||||
public static class NotifySchemaMigration
|
||||
{
|
||||
public static NotifyRule UpgradeRule(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Rule);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Rule => Deserialize<NotifyRule>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify rule schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public static NotifyChannel UpgradeChannel(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Channel);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Channel => Deserialize<NotifyChannel>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify channel schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public static NotifyTemplate UpgradeTemplate(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Template);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Template => Deserialize<NotifyTemplate>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify template schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, string fallback)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ArgumentException("Document must be a JSON object.", nameof(node));
|
||||
}
|
||||
|
||||
if (obj.DeepClone() is not JsonObject clone)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clone document as JsonObject.");
|
||||
}
|
||||
|
||||
string schemaVersion;
|
||||
if (clone.TryGetPropertyValue("schemaVersion", out var value) && value is JsonValue jsonValue && jsonValue.TryGetValue(out string? version) && !string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
schemaVersion = version.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
schemaVersion = fallback;
|
||||
clone["schemaVersion"] = schemaVersion;
|
||||
}
|
||||
|
||||
return (clone, schemaVersion);
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(JsonObject json)
|
||||
=> NotifyCanonicalJsonSerializer.Deserialize<T>(json.ToJsonString());
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical schema version identifiers for Notify documents.
|
||||
/// </summary>
|
||||
public static class NotifySchemaVersions
|
||||
{
|
||||
public const string Rule = "notify.rule@1";
|
||||
public const string Channel = "notify.channel@1";
|
||||
public const string Template = "notify.template@1";
|
||||
|
||||
public static string EnsureRule(string? value)
|
||||
=> Normalize(value, Rule);
|
||||
|
||||
public static string EnsureChannel(string? value)
|
||||
=> Normalize(value, Channel);
|
||||
|
||||
public static string EnsureTemplate(string? value)
|
||||
=> Normalize(value, Template);
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
130
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyTemplate.cs
Normal file
130
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyTemplate.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Stored template metadata and content for channel-specific rendering.
|
||||
/// </summary>
|
||||
public sealed record NotifyTemplate
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTemplate(
|
||||
string templateId,
|
||||
string tenantId,
|
||||
NotifyChannelType channelType,
|
||||
string key,
|
||||
string locale,
|
||||
string body,
|
||||
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureTemplate(schemaVersion);
|
||||
TemplateId = NotifyValidation.EnsureNotNullOrWhiteSpace(templateId, nameof(templateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
ChannelType = channelType;
|
||||
Key = NotifyValidation.EnsureNotNullOrWhiteSpace(key, nameof(key));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
RenderMode = renderMode;
|
||||
Format = format;
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyTemplate Create(
|
||||
string templateId,
|
||||
string tenantId,
|
||||
NotifyChannelType channelType,
|
||||
string key,
|
||||
string locale,
|
||||
string body,
|
||||
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyTemplate(
|
||||
templateId,
|
||||
tenantId,
|
||||
channelType,
|
||||
key,
|
||||
locale,
|
||||
body,
|
||||
renderMode,
|
||||
format,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string TemplateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public string Locale { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public NotifyTemplateRenderMode RenderMode { get; }
|
||||
|
||||
public NotifyDeliveryFormat Format { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight validation helpers shared across Notify model constructors.
|
||||
/// </summary>
|
||||
public static class NotifyValidation
|
||||
{
|
||||
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
public static string? TrimToNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values)
|
||||
=> (values ?? Array.Empty<string>())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
public static ImmutableDictionary<string, string> NormalizeStringDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.Trim();
|
||||
var normalizedValue = value?.Trim() ?? string.Empty;
|
||||
builder[normalizedKey] = normalizedValue;
|
||||
}
|
||||
|
||||
return ImmutableDictionary.CreateRange(StringComparer.Ordinal, builder);
|
||||
}
|
||||
|
||||
public static DateTimeOffset EnsureUtc(DateTimeOffset value)
|
||||
=> value.ToUniversalTime();
|
||||
|
||||
public static DateTimeOffset? EnsureUtc(DateTimeOffset? value)
|
||||
=> value?.ToUniversalTime();
|
||||
|
||||
public static JsonNode? NormalizeJsonNode(JsonNode? node)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject jsonObject:
|
||||
{
|
||||
var normalized = new JsonObject();
|
||||
foreach (var property in jsonObject
|
||||
.Where(static pair => pair.Key is not null)
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
normalized[property.Key!] = NormalizeJsonNode(property.Value?.DeepClone());
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
case JsonArray jsonArray:
|
||||
{
|
||||
var normalized = new JsonArray();
|
||||
foreach (var element in jsonArray)
|
||||
{
|
||||
normalized.Add(NormalizeJsonNode(element?.DeepClone()));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
default:
|
||||
return node.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
2
src/Notify/__Libraries/StellaOps.Notify.Models/TASKS.md
Normal file
2
src/Notify/__Libraries/StellaOps.Notify.Models/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify Models Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — scope moved to `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
4
src/Notify/__Libraries/StellaOps.Notify.Queue/AGENTS.md
Normal file
4
src/Notify/__Libraries/StellaOps.Notify.Queue/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Queue — Agent Charter
|
||||
|
||||
## Mission
|
||||
Provide event & delivery queues for Notify per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
private readonly NatsNotifyDeliveryQueue _queue;
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
private int _completed;
|
||||
|
||||
internal NatsNotifyDeliveryLease(
|
||||
NatsNotifyDeliveryQueue queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
string messageId,
|
||||
NotifyDeliveryQueueMessage payload,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string idempotencyKey)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_message = message;
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = payload ?? throw new ArgumentNullException(nameof(payload));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage Message { get; }
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisposable
|
||||
{
|
||||
private const string TransportName = "nats";
|
||||
|
||||
private static readonly INatsSerializer<byte[]> PayloadSerializer = NatsRawSerializer<byte[]>.Default;
|
||||
|
||||
private readonly NotifyDeliveryQueueOptions _queueOptions;
|
||||
private readonly NotifyNatsDeliveryQueueOptions _options;
|
||||
private readonly ILogger<NatsNotifyDeliveryQueue> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private readonly Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>> _connectionFactory;
|
||||
|
||||
private NatsConnection? _connection;
|
||||
private NatsJSContext? _jsContext;
|
||||
private INatsJSConsumer? _consumer;
|
||||
private bool _disposed;
|
||||
|
||||
public NatsNotifyDeliveryQueue(
|
||||
NotifyDeliveryQueueOptions queueOptions,
|
||||
NotifyNatsDeliveryQueueOptions options,
|
||||
ILogger<NatsNotifyDeliveryQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
{
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_connectionFactory = connectionFactory ?? ((opts, token) => new ValueTask<NatsConnection>(new NatsConnection(opts)));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Url))
|
||||
{
|
||||
throw new InvalidOperationException("NATS connection URL must be configured for the Notify delivery queue.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Stream) || string.IsNullOrWhiteSpace(_options.Subject))
|
||||
{
|
||||
throw new InvalidOperationException("NATS stream and subject must be configured for the Notify delivery queue.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
|
||||
NotifyDeliveryQueueMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(message.Delivery));
|
||||
var headers = BuildHeaders(message);
|
||||
|
||||
var publishOpts = new NatsJSPubOpts
|
||||
{
|
||||
MsgId = message.IdempotencyKey,
|
||||
RetryAttempts = 0
|
||||
};
|
||||
|
||||
var ack = await js.PublishAsync(
|
||||
_options.Subject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
publishOpts,
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ack.Duplicate)
|
||||
{
|
||||
NotifyQueueMetrics.RecordDeduplicated(TransportName, _options.Stream);
|
||||
_logger.LogDebug(
|
||||
"Duplicate Notify delivery enqueue detected for delivery {DeliveryId}.",
|
||||
message.Delivery.DeliveryId);
|
||||
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), true);
|
||||
}
|
||||
|
||||
NotifyQueueMetrics.RecordEnqueued(TransportName, _options.Stream);
|
||||
_logger.LogDebug(
|
||||
"Enqueued Notify delivery {DeliveryId} into NATS stream {Stream} (sequence {Sequence}).",
|
||||
message.Delivery.DeliveryId,
|
||||
ack.Stream,
|
||||
ack.Seq);
|
||||
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> LeaseAsync(
|
||||
NotifyQueueLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = request.BatchSize,
|
||||
Expires = request.LeaseDuration,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(request.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var lease = CreateLease(msg, request.Consumer, now, request.LeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> ClaimExpiredAsync(
|
||||
NotifyQueueClaimOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = options.BatchSize,
|
||||
Expires = options.MinIdleTime,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(options.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var deliveries = (int)(msg.Metadata?.NumDelivered ?? 1);
|
||||
if (deliveries <= 1)
|
||||
{
|
||||
await msg.NakAsync(new AckOpts(), TimeSpan.Zero, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lease = CreateLease(msg, options.ClaimantConsumer, now, _queueOptions.DefaultLeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
internal async Task AcknowledgeAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Acknowledged Notify delivery {DeliveryId} (sequence {Sequence}).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.MessageId);
|
||||
}
|
||||
|
||||
internal async Task RenewLeaseAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await lease.RawMessage.AckProgressAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
|
||||
lease.RefreshLease(expires);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Renewed NATS lease for Notify delivery {DeliveryId} until {Expires:u}.",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
expires);
|
||||
}
|
||||
|
||||
internal async Task ReleaseAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry
|
||||
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Notify delivery {DeliveryId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt);
|
||||
|
||||
await DeadLetterAsync(
|
||||
lease,
|
||||
$"max-delivery-attempts:{lease.Attempt}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry)
|
||||
{
|
||||
var delay = CalculateBackoff(lease.Attempt);
|
||||
await lease.RawMessage.NakAsync(new AckOpts(), delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordRetry(TransportName, _options.Stream);
|
||||
_logger.LogInformation(
|
||||
"Scheduled Notify delivery {DeliveryId} for retry with delay {Delay} (attempt {Attempt}).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
delay,
|
||||
lease.Attempt);
|
||||
}
|
||||
else
|
||||
{
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
_logger.LogInformation(
|
||||
"Abandoned Notify delivery {DeliveryId} after {Attempt} attempt(s).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task DeadLetterAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(lease.Message.Delivery));
|
||||
var headers = BuildDeadLetterHeaders(lease, reason);
|
||||
|
||||
await js.PublishAsync(
|
||||
_options.DeadLetterSubject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
new NatsJSPubOpts(),
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordDeadLetter(TransportName, _options.DeadLetterStream);
|
||||
_logger.LogError(
|
||||
"Dead-lettered Notify delivery {DeliveryId} (attempt {Attempt}): {Reason}",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt,
|
||||
reason);
|
||||
}
|
||||
|
||||
internal async Task PingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<NatsJSContext> GetJetStreamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_jsContext is not null)
|
||||
{
|
||||
return _jsContext;
|
||||
}
|
||||
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_jsContext ??= new NatsJSContext(connection);
|
||||
return _jsContext;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<INatsJSConsumer> EnsureStreamAndConsumerAsync(
|
||||
NatsJSContext js,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await EnsureStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumerConfig = new ConsumerConfig
|
||||
{
|
||||
DurableName = _options.DurableConsumer,
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit,
|
||||
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
|
||||
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
|
||||
AckWait = ToNanoseconds(_options.AckWait),
|
||||
MaxAckPending = _options.MaxAckPending,
|
||||
MaxDeliver = Math.Max(1, _queueOptions.MaxDeliveryAttempts),
|
||||
FilterSubjects = new[] { _options.Subject }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.CreateConsumerAsync(
|
||||
_options.Stream,
|
||||
consumerConfig,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException apiEx)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
|
||||
apiEx.Error?.Code,
|
||||
_options.DurableConsumer);
|
||||
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
_options.Stream,
|
||||
_options.DurableConsumer,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _consumer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NatsConnection> EnsureConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
var opts = new NatsOpts
|
||||
{
|
||||
Url = _options.Url!,
|
||||
Name = "stellaops-notify-delivery",
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
RequestTimeout = TimeSpan.FromSeconds(20),
|
||||
PingInterval = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_connection = await _connectionFactory(opts, cancellationToken).ConfigureAwait(false);
|
||||
await _connection.ConnectAsync().ConfigureAwait(false);
|
||||
return _connection;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify delivery stream {Stream} ({Subject}).", _options.Stream, _options.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureDeadLetterStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify delivery dead-letter stream {Stream} ({Subject}).", _options.DeadLetterStream, _options.DeadLetterSubject);
|
||||
}
|
||||
}
|
||||
|
||||
private NatsNotifyDeliveryLease? CreateLease(
|
||||
NatsJSMsg<byte[]> message,
|
||||
string consumer,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration)
|
||||
{
|
||||
var payloadBytes = message.Data ?? Array.Empty<byte>();
|
||||
if (payloadBytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NotifyDelivery delivery;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(payloadBytes);
|
||||
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to deserialize Notify delivery payload for NATS message {Sequence}.",
|
||||
message.Metadata?.Sequence.Stream);
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = message.Headers ?? new NatsHeaders();
|
||||
|
||||
var deliveryId = TryGetHeader(headers, NotifyQueueFields.DeliveryId) ?? delivery.DeliveryId;
|
||||
var channelId = TryGetHeader(headers, NotifyQueueFields.ChannelId);
|
||||
var channelTypeRaw = TryGetHeader(headers, NotifyQueueFields.ChannelType);
|
||||
if (channelId is null || channelTypeRaw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(channelTypeRaw, ignoreCase: true, out var channelType))
|
||||
{
|
||||
_logger.LogWarning("Unknown channel type '{ChannelType}' for delivery {DeliveryId}.", channelTypeRaw, deliveryId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var traceId = TryGetHeader(headers, NotifyQueueFields.TraceId);
|
||||
var partitionKey = TryGetHeader(headers, NotifyQueueFields.PartitionKey) ?? channelId;
|
||||
var idempotencyKey = TryGetHeader(headers, NotifyQueueFields.IdempotencyKey) ?? delivery.DeliveryId;
|
||||
|
||||
var enqueuedAt = TryGetHeader(headers, NotifyQueueFields.EnqueuedAt) is { } enqueuedRaw
|
||||
&& long.TryParse(enqueuedRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix)
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(unix)
|
||||
: now;
|
||||
|
||||
var attempt = TryGetHeader(headers, NotifyQueueFields.Attempt) is { } attemptRaw
|
||||
&& int.TryParse(attemptRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempt)
|
||||
? parsedAttempt
|
||||
: 1;
|
||||
|
||||
if (message.Metadata?.NumDelivered is ulong delivered && delivered > 0)
|
||||
{
|
||||
var deliveredInt = delivered > int.MaxValue ? int.MaxValue : (int)delivered;
|
||||
if (deliveredInt > attempt)
|
||||
{
|
||||
attempt = deliveredInt;
|
||||
}
|
||||
}
|
||||
|
||||
var attributes = ExtractAttributes(headers);
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
var messageId = message.Metadata?.Sequence.Stream.ToString() ?? Guid.NewGuid().ToString("n");
|
||||
|
||||
var queueMessage = new NotifyDeliveryQueueMessage(
|
||||
delivery,
|
||||
channelId,
|
||||
channelType,
|
||||
_options.Subject,
|
||||
traceId,
|
||||
attributes);
|
||||
|
||||
return new NatsNotifyDeliveryLease(
|
||||
this,
|
||||
message,
|
||||
messageId,
|
||||
queueMessage,
|
||||
attempt,
|
||||
consumer,
|
||||
enqueuedAt,
|
||||
leaseExpires,
|
||||
idempotencyKey);
|
||||
}
|
||||
|
||||
private NatsHeaders BuildHeaders(NotifyDeliveryQueueMessage message)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.DeliveryId, message.Delivery.DeliveryId },
|
||||
{ NotifyQueueFields.ChannelId, message.ChannelId },
|
||||
{ NotifyQueueFields.ChannelType, message.ChannelType.ToString() },
|
||||
{ NotifyQueueFields.Tenant, message.Delivery.TenantId },
|
||||
{ NotifyQueueFields.Attempt, "1" },
|
||||
{ NotifyQueueFields.EnqueuedAt, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, message.IdempotencyKey },
|
||||
{ NotifyQueueFields.PartitionKey, message.PartitionKey }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, message.TraceId!);
|
||||
}
|
||||
|
||||
foreach (var kvp in message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private NatsHeaders BuildDeadLetterHeaders(NatsNotifyDeliveryLease lease, string reason)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.DeliveryId, lease.Message.Delivery.DeliveryId },
|
||||
{ NotifyQueueFields.ChannelId, lease.Message.ChannelId },
|
||||
{ NotifyQueueFields.ChannelType, lease.Message.ChannelType.ToString() },
|
||||
{ NotifyQueueFields.Tenant, lease.Message.Delivery.TenantId },
|
||||
{ NotifyQueueFields.Attempt, lease.Attempt.ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey },
|
||||
{ "deadletter-reason", reason }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lease.Message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, lease.Message.TraceId!);
|
||||
}
|
||||
|
||||
foreach (var kvp in lease.Message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static string? TryGetHeader(NatsHeaders headers, string key)
|
||||
{
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
var value = values[0];
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ExtractAttributes(NatsHeaders headers)
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var key in headers.Keys)
|
||||
{
|
||||
if (!key.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
attributes[key[NotifyQueueFields.AttributePrefix.Length..]] = values[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
return attributes.Count == 0
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(attributes);
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var initial = _queueOptions.RetryInitialBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryInitialBackoff
|
||||
: _options.RetryDelay;
|
||||
|
||||
if (initial <= TimeSpan.Zero)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (attempt <= 1)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryMaxBackoff
|
||||
: initial;
|
||||
|
||||
var exponent = attempt - 1;
|
||||
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
|
||||
return TimeSpan.FromTicks(resultTicks);
|
||||
}
|
||||
|
||||
private static long ToNanoseconds(TimeSpan value)
|
||||
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
|
||||
|
||||
private static class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
|
||||
{
|
||||
private readonly NatsNotifyEventQueue _queue;
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
private int _completed;
|
||||
|
||||
internal NatsNotifyEventLease(
|
||||
NatsNotifyEventQueue queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
string messageId,
|
||||
NotifyQueueEventMessage payload,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
|
||||
{
|
||||
throw new ArgumentException("Message must be provided.", nameof(message));
|
||||
}
|
||||
|
||||
_message = message;
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = payload ?? throw new ArgumentNullException(nameof(payload));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey => Message.IdempotencyKey;
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyQueueEventMessage Message { get; }
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
{
|
||||
private const string TransportName = "nats";
|
||||
|
||||
private static readonly INatsSerializer<byte[]> PayloadSerializer = NatsRawSerializer<byte[]>.Default;
|
||||
|
||||
private readonly NotifyEventQueueOptions _queueOptions;
|
||||
private readonly NotifyNatsEventQueueOptions _options;
|
||||
private readonly ILogger<NatsNotifyEventQueue> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private readonly Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>> _connectionFactory;
|
||||
|
||||
private NatsConnection? _connection;
|
||||
private NatsJSContext? _jsContext;
|
||||
private INatsJSConsumer? _consumer;
|
||||
private bool _disposed;
|
||||
|
||||
public NatsNotifyEventQueue(
|
||||
NotifyEventQueueOptions queueOptions,
|
||||
NotifyNatsEventQueueOptions options,
|
||||
ILogger<NatsNotifyEventQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
{
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_connectionFactory = connectionFactory ?? ((opts, cancellationToken) => new ValueTask<NatsConnection>(new NatsConnection(opts)));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Url))
|
||||
{
|
||||
throw new InvalidOperationException("NATS connection URL must be configured for the Notify event queue.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Stream) || string.IsNullOrWhiteSpace(_options.Subject))
|
||||
{
|
||||
throw new InvalidOperationException("NATS stream and subject must be configured for the Notify event queue.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
|
||||
NotifyQueueEventMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var idempotencyKey = string.IsNullOrWhiteSpace(message.IdempotencyKey)
|
||||
? message.Event.EventId.ToString("N")
|
||||
: message.IdempotencyKey;
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(message.Event));
|
||||
var headers = BuildHeaders(message, idempotencyKey);
|
||||
|
||||
var publishOpts = new NatsJSPubOpts
|
||||
{
|
||||
MsgId = idempotencyKey,
|
||||
RetryAttempts = 0
|
||||
};
|
||||
|
||||
var ack = await js.PublishAsync(
|
||||
_options.Subject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
publishOpts,
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ack.Duplicate)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Duplicate Notify event enqueue detected for idempotency token {Token}.",
|
||||
idempotencyKey);
|
||||
|
||||
NotifyQueueMetrics.RecordDeduplicated(TransportName, _options.Stream);
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), true);
|
||||
}
|
||||
|
||||
NotifyQueueMetrics.RecordEnqueued(TransportName, _options.Stream);
|
||||
_logger.LogDebug(
|
||||
"Enqueued Notify event {EventId} into NATS stream {Stream} (sequence {Sequence}).",
|
||||
message.Event.EventId,
|
||||
ack.Stream,
|
||||
ack.Seq);
|
||||
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(
|
||||
NotifyQueueLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = request.BatchSize,
|
||||
Expires = request.LeaseDuration,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(request.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var lease = CreateLease(msg, request.Consumer, now, request.LeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(
|
||||
NotifyQueueClaimOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = options.BatchSize,
|
||||
Expires = options.MinIdleTime,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(options.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var deliveries = (int)(msg.Metadata?.NumDelivered ?? 1);
|
||||
if (deliveries <= 1)
|
||||
{
|
||||
await msg.NakAsync(new AckOpts(), TimeSpan.Zero, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lease = CreateLease(msg, options.ClaimantConsumer, now, _queueOptions.DefaultLeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
internal async Task AcknowledgeAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Acknowledged Notify event {EventId} (sequence {Sequence}).",
|
||||
lease.Message.Event.EventId,
|
||||
lease.MessageId);
|
||||
}
|
||||
|
||||
internal async Task RenewLeaseAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await lease.RawMessage.AckProgressAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
|
||||
lease.RefreshLease(expires);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Renewed NATS lease for Notify event {EventId} until {Expires:u}.",
|
||||
lease.Message.Event.EventId,
|
||||
expires);
|
||||
}
|
||||
|
||||
internal async Task ReleaseAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry
|
||||
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Notify event {EventId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Attempt);
|
||||
|
||||
await DeadLetterAsync(
|
||||
lease,
|
||||
$"max-delivery-attempts:{lease.Attempt}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry)
|
||||
{
|
||||
var delay = CalculateBackoff(lease.Attempt);
|
||||
await lease.RawMessage.NakAsync(new AckOpts(), delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordRetry(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Scheduled Notify event {EventId} for retry with delay {Delay} (attempt {Attempt}).",
|
||||
lease.Message.Event.EventId,
|
||||
delay,
|
||||
lease.Attempt);
|
||||
}
|
||||
else
|
||||
{
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Abandoned Notify event {EventId} after {Attempt} attempt(s).",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task DeadLetterAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var headers = BuildDeadLetterHeaders(lease, reason);
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(lease.Message.Event));
|
||||
|
||||
await js.PublishAsync(
|
||||
_options.DeadLetterSubject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
new NatsJSPubOpts(),
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordDeadLetter(TransportName, _options.DeadLetterStream);
|
||||
|
||||
_logger.LogError(
|
||||
"Dead-lettered Notify event {EventId} (attempt {Attempt}): {Reason}",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Attempt,
|
||||
reason);
|
||||
}
|
||||
|
||||
internal async Task PingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<NatsJSContext> GetJetStreamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_jsContext is not null)
|
||||
{
|
||||
return _jsContext;
|
||||
}
|
||||
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_jsContext ??= new NatsJSContext(connection);
|
||||
return _jsContext;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<INatsJSConsumer> EnsureStreamAndConsumerAsync(
|
||||
NatsJSContext js,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await EnsureStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumerConfig = new ConsumerConfig
|
||||
{
|
||||
DurableName = _options.DurableConsumer,
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit,
|
||||
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
|
||||
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
|
||||
AckWait = ToNanoseconds(_options.AckWait),
|
||||
MaxAckPending = _options.MaxAckPending,
|
||||
MaxDeliver = Math.Max(1, _queueOptions.MaxDeliveryAttempts),
|
||||
FilterSubjects = new[] { _options.Subject }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.CreateConsumerAsync(
|
||||
_options.Stream,
|
||||
consumerConfig,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException apiEx)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
|
||||
apiEx.Error?.Code,
|
||||
_options.DurableConsumer);
|
||||
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
_options.Stream,
|
||||
_options.DurableConsumer,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _consumer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NatsConnection> EnsureConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
var opts = new NatsOpts
|
||||
{
|
||||
Url = _options.Url!,
|
||||
Name = "stellaops-notify-queue",
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
RequestTimeout = TimeSpan.FromSeconds(20),
|
||||
PingInterval = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_connection = await _connectionFactory(opts, cancellationToken).ConfigureAwait(false);
|
||||
await _connection.ConnectAsync().ConfigureAwait(false);
|
||||
return _connection;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify stream {Stream} ({Subject}).", _options.Stream, _options.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureDeadLetterStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify dead-letter stream {Stream} ({Subject}).", _options.DeadLetterStream, _options.DeadLetterSubject);
|
||||
}
|
||||
}
|
||||
|
||||
private NatsNotifyEventLease? CreateLease(
|
||||
NatsJSMsg<byte[]> message,
|
||||
string consumer,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration)
|
||||
{
|
||||
var payloadBytes = message.Data ?? Array.Empty<byte>();
|
||||
if (payloadBytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NotifyEvent notifyEvent;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(payloadBytes);
|
||||
notifyEvent = NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to deserialize Notify event payload for NATS message {Sequence}.",
|
||||
message.Metadata?.Sequence.Stream);
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = message.Headers ?? new NatsHeaders();
|
||||
|
||||
var idempotencyKey = TryGetHeader(headers, NotifyQueueFields.IdempotencyKey)
|
||||
?? notifyEvent.EventId.ToString("N");
|
||||
|
||||
var partitionKey = TryGetHeader(headers, NotifyQueueFields.PartitionKey);
|
||||
var traceId = TryGetHeader(headers, NotifyQueueFields.TraceId);
|
||||
var enqueuedAt = TryGetHeader(headers, NotifyQueueFields.EnqueuedAt) is { } enqueuedRaw
|
||||
&& long.TryParse(enqueuedRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix)
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(unix)
|
||||
: now;
|
||||
|
||||
var attempt = TryGetHeader(headers, NotifyQueueFields.Attempt) is { } attemptRaw
|
||||
&& int.TryParse(attemptRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempt)
|
||||
? parsedAttempt
|
||||
: 1;
|
||||
|
||||
if (message.Metadata?.NumDelivered is ulong delivered && delivered > 0)
|
||||
{
|
||||
var deliveredInt = delivered > int.MaxValue ? int.MaxValue : (int)delivered;
|
||||
if (deliveredInt > attempt)
|
||||
{
|
||||
attempt = deliveredInt;
|
||||
}
|
||||
}
|
||||
|
||||
var attributes = ExtractAttributes(headers);
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
var messageId = message.Metadata?.Sequence.Stream.ToString() ?? Guid.NewGuid().ToString("n");
|
||||
|
||||
var queueMessage = new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
_options.Subject,
|
||||
idempotencyKey,
|
||||
partitionKey,
|
||||
traceId,
|
||||
attributes);
|
||||
|
||||
return new NatsNotifyEventLease(
|
||||
this,
|
||||
message,
|
||||
messageId,
|
||||
queueMessage,
|
||||
attempt,
|
||||
consumer,
|
||||
enqueuedAt,
|
||||
leaseExpires);
|
||||
}
|
||||
|
||||
private NatsHeaders BuildHeaders(NotifyQueueEventMessage message, string idempotencyKey)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.EventId, message.Event.EventId.ToString("D") },
|
||||
{ NotifyQueueFields.Tenant, message.TenantId },
|
||||
{ NotifyQueueFields.Kind, message.Event.Kind },
|
||||
{ NotifyQueueFields.Attempt, "1" },
|
||||
{ NotifyQueueFields.EnqueuedAt, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, idempotencyKey }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, message.TraceId!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.PartitionKey))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.PartitionKey, message.PartitionKey!);
|
||||
}
|
||||
|
||||
foreach (var kvp in message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private NatsHeaders BuildDeadLetterHeaders(NatsNotifyEventLease lease, string reason)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.EventId, lease.Message.Event.EventId.ToString("D") },
|
||||
{ NotifyQueueFields.Tenant, lease.Message.TenantId },
|
||||
{ NotifyQueueFields.Kind, lease.Message.Event.Kind },
|
||||
{ NotifyQueueFields.Attempt, lease.Attempt.ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey },
|
||||
{ "deadletter-reason", reason }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lease.Message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, lease.Message.TraceId!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lease.Message.PartitionKey))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.PartitionKey, lease.Message.PartitionKey!);
|
||||
}
|
||||
|
||||
foreach (var kvp in lease.Message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static string? TryGetHeader(NatsHeaders headers, string key)
|
||||
{
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
var value = values[0];
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ExtractAttributes(NatsHeaders headers)
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var key in headers.Keys)
|
||||
{
|
||||
if (!key.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
attributes[key[NotifyQueueFields.AttributePrefix.Length..]] = values[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
return attributes.Count == 0
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(attributes);
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var initial = _queueOptions.RetryInitialBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryInitialBackoff
|
||||
: _options.RetryDelay;
|
||||
|
||||
if (initial <= TimeSpan.Zero)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (attempt <= 1)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryMaxBackoff
|
||||
: initial;
|
||||
|
||||
var exponent = attempt - 1;
|
||||
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
|
||||
return TimeSpan.FromTicks(resultTicks);
|
||||
}
|
||||
|
||||
private static long ToNanoseconds(TimeSpan value)
|
||||
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
|
||||
|
||||
private static class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly INotifyDeliveryQueue _queue;
|
||||
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
|
||||
|
||||
public NotifyDeliveryQueueHealthCheck(
|
||||
INotifyDeliveryQueue queue,
|
||||
ILogger<NotifyDeliveryQueueHealthCheck> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
switch (_queue)
|
||||
{
|
||||
case RedisNotifyDeliveryQueue redisQueue:
|
||||
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
|
||||
|
||||
case NatsNotifyDeliveryQueue natsQueue:
|
||||
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
|
||||
|
||||
default:
|
||||
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify delivery queue health check failed.");
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
"Notify delivery queue transport unreachable.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notify delivery queue abstraction.
|
||||
/// </summary>
|
||||
public sealed class NotifyDeliveryQueueOptions
|
||||
{
|
||||
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
|
||||
|
||||
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
|
||||
|
||||
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
|
||||
|
||||
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public sealed class NotifyRedisDeliveryQueueOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
public int? Database { get; set; }
|
||||
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public string StreamName { get; set; } = "notify:deliveries";
|
||||
|
||||
public string ConsumerGroup { get; set; } = "notify-deliveries";
|
||||
|
||||
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
|
||||
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
|
||||
|
||||
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
|
||||
}
|
||||
|
||||
public sealed class NotifyNatsDeliveryQueueOptions
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
|
||||
|
||||
public string Subject { get; set; } = "notify.deliveries";
|
||||
|
||||
public string DurableConsumer { get; set; } = "notify-deliveries";
|
||||
|
||||
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
|
||||
|
||||
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
|
||||
|
||||
public int MaxAckPending { get; set; } = 128;
|
||||
|
||||
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notify event queue abstraction.
|
||||
/// </summary>
|
||||
public sealed class NotifyEventQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport backing the queue.
|
||||
/// </summary>
|
||||
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
|
||||
|
||||
/// <summary>
|
||||
/// Redis-specific configuration.
|
||||
/// </summary>
|
||||
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// NATS JetStream-specific configuration.
|
||||
/// </summary>
|
||||
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default lease duration to use when consumers do not specify one explicitly.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of deliveries before a message should be considered failed.
|
||||
/// </summary>
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Initial retry backoff applied when a message is released for retry.
|
||||
/// </summary>
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Cap applied to exponential retry backoff.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum idle window before a pending message becomes eligible for claim.
|
||||
/// </summary>
|
||||
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redis transport options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyRedisEventQueueOptions
|
||||
{
|
||||
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
|
||||
{
|
||||
NotifyRedisEventStreamOptions.ForDefaultStream()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the Redis instance.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional logical database to select when connecting.
|
||||
/// </summary>
|
||||
public int? Database { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time allowed for initial connection/consumer-group creation.
|
||||
/// </summary>
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// TTL applied to idempotency keys stored alongside events.
|
||||
/// </summary>
|
||||
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
/// <summary>
|
||||
/// Streams consumed by Notify. Ordering is preserved during leasing.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
|
||||
{
|
||||
get => _streams;
|
||||
set => _streams = value is null || value.Count == 0
|
||||
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
|
||||
: value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-Redis-stream options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyRedisEventStreamOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the Redis stream containing events.
|
||||
/// </summary>
|
||||
public string Stream { get; set; } = "notify:events";
|
||||
|
||||
/// <summary>
|
||||
/// Consumer group used by Notify workers.
|
||||
/// </summary>
|
||||
public string ConsumerGroup { get; set; } = "notify-workers";
|
||||
|
||||
/// <summary>
|
||||
/// Prefix used when storing idempotency keys in Redis.
|
||||
/// </summary>
|
||||
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
|
||||
|
||||
/// <summary>
|
||||
/// Approximate maximum length for the stream; when set Redis will trim entries.
|
||||
/// </summary>
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public static NotifyRedisEventStreamOptions ForDefaultStream()
|
||||
=> new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS JetStream options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyNatsEventQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// URL for the JetStream-enabled NATS cluster.
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stream name carrying Notify events.
|
||||
/// </summary>
|
||||
public string Stream { get; set; } = "NOTIFY_EVENTS";
|
||||
|
||||
/// <summary>
|
||||
/// Subject that producers publish Notify events to.
|
||||
/// </summary>
|
||||
public string Subject { get; set; } = "notify.events";
|
||||
|
||||
/// <summary>
|
||||
/// Durable consumer identifier for Notify workers.
|
||||
/// </summary>
|
||||
public string DurableConsumer { get; set; } = "notify-workers";
|
||||
|
||||
/// <summary>
|
||||
/// Dead-letter stream name used when deliveries exhaust retry budget.
|
||||
/// </summary>
|
||||
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
|
||||
|
||||
/// <summary>
|
||||
/// Subject used for dead-letter publications.
|
||||
/// </summary>
|
||||
public string DeadLetterSubject { get; set; } = "notify.events.dead";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum pending messages before backpressure is applied.
|
||||
/// </summary>
|
||||
public int MaxAckPending { get; set; } = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Visibility timeout applied to leased events.
|
||||
/// </summary>
|
||||
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Delay applied when releasing a message for retry.
|
||||
/// </summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Idle heartbeat emitted by the server to detect consumer disconnects.
|
||||
/// </summary>
|
||||
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Message queued for Notify event processing.
|
||||
/// </summary>
|
||||
public sealed class NotifyQueueEventMessage
|
||||
{
|
||||
private readonly NotifyEvent _event;
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
|
||||
public NotifyQueueEventMessage(
|
||||
NotifyEvent @event,
|
||||
string stream,
|
||||
string? idempotencyKey = null,
|
||||
string? partitionKey = null,
|
||||
string? traceId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
_event = @event ?? throw new ArgumentNullException(nameof(@event));
|
||||
if (string.IsNullOrWhiteSpace(stream))
|
||||
{
|
||||
throw new ArgumentException("Stream must be provided.", nameof(stream));
|
||||
}
|
||||
|
||||
Stream = stream;
|
||||
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
|
||||
? @event.EventId.ToString("N")
|
||||
: idempotencyKey!;
|
||||
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
|
||||
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public NotifyEvent Event => _event;
|
||||
|
||||
public string Stream { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string TenantId => _event.Tenant;
|
||||
|
||||
public string? PartitionKey { get; }
|
||||
|
||||
public string? TraceId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message queued for channel delivery execution.
|
||||
/// </summary>
|
||||
public sealed class NotifyDeliveryQueueMessage
|
||||
{
|
||||
public const string DefaultStream = "notify:deliveries";
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage(
|
||||
NotifyDelivery delivery,
|
||||
string channelId,
|
||||
NotifyChannelType channelType,
|
||||
string? stream = null,
|
||||
string? traceId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
|
||||
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
|
||||
ChannelType = channelType;
|
||||
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
|
||||
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public NotifyDelivery Delivery { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public string Stream { get; }
|
||||
|
||||
public string? TraceId { get; }
|
||||
|
||||
public string TenantId => Delivery.TenantId;
|
||||
|
||||
public string IdempotencyKey => Delivery.DeliveryId;
|
||||
|
||||
public string PartitionKey => ChannelId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
}
|
||||
|
||||
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
|
||||
|
||||
public sealed class NotifyQueueLeaseRequest
|
||||
{
|
||||
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (leaseDuration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
|
||||
}
|
||||
|
||||
Consumer = consumer;
|
||||
BatchSize = batchSize;
|
||||
LeaseDuration = leaseDuration;
|
||||
}
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
}
|
||||
|
||||
public sealed class NotifyQueueClaimOptions
|
||||
{
|
||||
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claimantConsumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (minIdleTime < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
|
||||
}
|
||||
|
||||
ClaimantConsumer = claimantConsumer;
|
||||
BatchSize = batchSize;
|
||||
MinIdleTime = minIdleTime;
|
||||
}
|
||||
|
||||
public string ClaimantConsumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan MinIdleTime { get; }
|
||||
}
|
||||
|
||||
public enum NotifyQueueReleaseDisposition
|
||||
{
|
||||
Retry,
|
||||
Abandon
|
||||
}
|
||||
|
||||
public interface INotifyQueue<TMessage>
|
||||
{
|
||||
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface INotifyQueueLease<out TMessage>
|
||||
{
|
||||
string MessageId { get; }
|
||||
|
||||
int Attempt { get; }
|
||||
|
||||
DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
DateTimeOffset LeaseExpiresAt { get; }
|
||||
|
||||
string Consumer { get; }
|
||||
|
||||
string Stream { get; }
|
||||
|
||||
string TenantId { get; }
|
||||
|
||||
string? PartitionKey { get; }
|
||||
|
||||
string IdempotencyKey { get; }
|
||||
|
||||
string? TraceId { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
TMessage Message { get; }
|
||||
|
||||
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
|
||||
{
|
||||
}
|
||||
|
||||
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
}
|
||||
|
||||
internal static class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
internal static class NotifyQueueFields
|
||||
{
|
||||
public const string Payload = "payload";
|
||||
public const string EventId = "eventId";
|
||||
public const string DeliveryId = "deliveryId";
|
||||
public const string Tenant = "tenant";
|
||||
public const string Kind = "kind";
|
||||
public const string Attempt = "attempt";
|
||||
public const string EnqueuedAt = "enqueuedAt";
|
||||
public const string TraceId = "traceId";
|
||||
public const string PartitionKey = "partitionKey";
|
||||
public const string ChannelId = "channelId";
|
||||
public const string ChannelType = "channelType";
|
||||
public const string IdempotencyKey = "idempotency";
|
||||
public const string AttributePrefix = "attr:";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public sealed class NotifyQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly INotifyEventQueue _queue;
|
||||
private readonly ILogger<NotifyQueueHealthCheck> _logger;
|
||||
|
||||
public NotifyQueueHealthCheck(
|
||||
INotifyEventQueue queue,
|
||||
ILogger<NotifyQueueHealthCheck> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
switch (_queue)
|
||||
{
|
||||
case RedisNotifyEventQueue redisQueue:
|
||||
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
|
||||
|
||||
case NatsNotifyEventQueue natsQueue:
|
||||
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
|
||||
|
||||
default:
|
||||
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify queue health check failed.");
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
"Notify queue transport unreachable.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
internal static class NotifyQueueMetrics
|
||||
{
|
||||
private const string TransportTag = "transport";
|
||||
private const string StreamTag = "stream";
|
||||
|
||||
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
|
||||
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
|
||||
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
|
||||
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
|
||||
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
|
||||
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
|
||||
|
||||
public static void RecordEnqueued(string transport, string stream)
|
||||
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordDeduplicated(string transport, string stream)
|
||||
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordAck(string transport, string stream)
|
||||
=> AckCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordRetry(string transport, string stream)
|
||||
=> RetryCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordDeadLetter(string transport, string stream)
|
||||
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
|
||||
=> new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>(TransportTag, transport),
|
||||
new KeyValuePair<string, object?>(StreamTag, stream)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public static class NotifyQueueServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddNotifyEventQueue(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "notify:queue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var eventOptions = new NotifyEventQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(eventOptions);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(eventOptions);
|
||||
|
||||
services.AddSingleton<INotifyEventQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
|
||||
|
||||
return opts.Transport switch
|
||||
{
|
||||
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
|
||||
opts,
|
||||
opts.Redis,
|
||||
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
|
||||
timeProvider),
|
||||
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
|
||||
opts,
|
||||
opts.Nats,
|
||||
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
|
||||
timeProvider),
|
||||
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<NotifyQueueHealthCheck>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddNotifyDeliveryQueue(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "notify:deliveryQueue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var deliveryOptions = new NotifyDeliveryQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(deliveryOptions);
|
||||
|
||||
services.AddSingleton(deliveryOptions);
|
||||
|
||||
services.AddSingleton<INotifyDeliveryQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
|
||||
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
|
||||
|
||||
ApplyDeliveryFallbacks(opts, eventOpts);
|
||||
|
||||
return opts.Transport switch
|
||||
{
|
||||
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
|
||||
opts,
|
||||
opts.Redis,
|
||||
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
|
||||
timeProvider),
|
||||
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
|
||||
opts,
|
||||
opts.Nats,
|
||||
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
|
||||
timeProvider),
|
||||
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
|
||||
this IHealthChecksBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
|
||||
builder.AddCheck<NotifyQueueHealthCheck>(
|
||||
name: "notify-queue",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "notify", "queue" });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
|
||||
this IHealthChecksBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
|
||||
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
|
||||
name: "notify-delivery-queue",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "notify", "queue", "delivery" });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void ApplyDeliveryFallbacks(
|
||||
NotifyDeliveryQueueOptions deliveryOptions,
|
||||
NotifyEventQueueOptions? eventOptions)
|
||||
{
|
||||
if (eventOptions is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
|
||||
{
|
||||
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
|
||||
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
|
||||
{
|
||||
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Supported transports for the Notify event queue.
|
||||
/// </summary>
|
||||
public enum NotifyQueueTransportKind
|
||||
{
|
||||
Redis,
|
||||
Nats
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Queue.Tests")]
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
private readonly RedisNotifyDeliveryQueue _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisNotifyDeliveryLease(
|
||||
RedisNotifyDeliveryQueue queue,
|
||||
string messageId,
|
||||
NotifyDeliveryQueueMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer,
|
||||
string? idempotencyKey,
|
||||
string partitionKey)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = message ?? throw new ArgumentNullException(nameof(message));
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
|
||||
PartitionKey = partitionKey ?? message.ChannelId;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string PartitionKey { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage Message { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisposable
|
||||
{
|
||||
private const string TransportName = "redis";
|
||||
|
||||
private readonly NotifyDeliveryQueueOptions _options;
|
||||
private readonly NotifyRedisDeliveryQueueOptions _redisOptions;
|
||||
private readonly ILogger<RedisNotifyDeliveryQueue> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _groupLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, bool> _streamInitialized = new(StringComparer.Ordinal);
|
||||
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public RedisNotifyDeliveryQueue(
|
||||
NotifyDeliveryQueueOptions options,
|
||||
NotifyRedisDeliveryQueueOptions redisOptions,
|
||||
ILogger<RedisNotifyDeliveryQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_redisOptions = redisOptions ?? throw new ArgumentNullException(nameof(redisOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_connectionFactory = connectionFactory ?? (async config =>
|
||||
{
|
||||
var connection = await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false);
|
||||
return (IConnectionMultiplexer)connection;
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_redisOptions.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Redis connection string must be configured for the Notify delivery queue.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
|
||||
NotifyDeliveryQueueMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var attempt = 1;
|
||||
var entries = BuildEntries(message, now, attempt);
|
||||
|
||||
var messageId = await AddToStreamAsync(
|
||||
db,
|
||||
_redisOptions.StreamName,
|
||||
entries)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var idempotencyKey = BuildIdempotencyKey(message.IdempotencyKey);
|
||||
var stored = await db.StringSetAsync(
|
||||
idempotencyKey,
|
||||
messageId,
|
||||
when: When.NotExists,
|
||||
expiry: _options.ClaimIdleThreshold)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!stored)
|
||||
{
|
||||
await db.StreamDeleteAsync(
|
||||
_redisOptions.StreamName,
|
||||
new RedisValue[] { messageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
|
||||
var duplicateId = existing.IsNullOrEmpty ? messageId : existing;
|
||||
|
||||
NotifyQueueMetrics.RecordDeduplicated(TransportName, _redisOptions.StreamName);
|
||||
_logger.LogDebug(
|
||||
"Duplicate Notify delivery enqueue detected for delivery {DeliveryId}.",
|
||||
message.Delivery.DeliveryId);
|
||||
|
||||
return new NotifyQueueEnqueueResult(duplicateId.ToString()!, true);
|
||||
}
|
||||
|
||||
NotifyQueueMetrics.RecordEnqueued(TransportName, _redisOptions.StreamName);
|
||||
_logger.LogDebug(
|
||||
"Enqueued Notify delivery {DeliveryId} (channel {ChannelId}) into stream {Stream}.",
|
||||
message.Delivery.DeliveryId,
|
||||
message.ChannelId,
|
||||
_redisOptions.StreamName);
|
||||
|
||||
return new NotifyQueueEnqueueResult(messageId.ToString()!, false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> LeaseAsync(
|
||||
NotifyQueueLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = await db.StreamReadGroupAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
request.Consumer,
|
||||
StreamPosition.NewMessages,
|
||||
request.BatchSize)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entries is null || entries.Length == 0)
|
||||
{
|
||||
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(entries.Length);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var lease = TryMapLease(entry, request.Consumer, now, request.LeaseDuration, attemptOverride: null);
|
||||
if (lease is null)
|
||||
{
|
||||
await AckPoisonAsync(db, entry.Id).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> ClaimExpiredAsync(
|
||||
NotifyQueueClaimOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var pending = await db.StreamPendingMessagesAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
options.BatchSize,
|
||||
RedisValue.Null,
|
||||
(long)options.MinIdleTime.TotalMilliseconds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pending is null || pending.Length == 0)
|
||||
{
|
||||
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
|
||||
}
|
||||
|
||||
var eligible = pending
|
||||
.Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
|
||||
.ToArray();
|
||||
|
||||
if (eligible.Length == 0)
|
||||
{
|
||||
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
|
||||
}
|
||||
|
||||
var messageIds = eligible
|
||||
.Select(static p => (RedisValue)p.MessageId)
|
||||
.ToArray();
|
||||
|
||||
var entries = await db.StreamClaimAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
options.ClaimantConsumer,
|
||||
0,
|
||||
messageIds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entries is null || entries.Length == 0)
|
||||
{
|
||||
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var attemptLookup = eligible
|
||||
.Where(static info => !info.MessageId.IsNullOrEmpty)
|
||||
.ToDictionary(
|
||||
info => info.MessageId!.ToString(),
|
||||
info => (int)Math.Max(1, info.DeliveryCount),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(entries.Length);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
attemptLookup.TryGetValue(entry.Id.ToString(), out var attempt);
|
||||
var lease = TryMapLease(entry, options.ClaimantConsumer, now, _options.DefaultLeaseDuration, attempt == 0 ? null : attempt);
|
||||
if (lease is null)
|
||||
{
|
||||
await AckPoisonAsync(db, entry.Id).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.CloseAsync().ConfigureAwait(false);
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionLock.Dispose();
|
||||
_groupLock.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
internal async Task AcknowledgeAsync(
|
||||
RedisNotifyDeliveryLease lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StreamAcknowledgeAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await db.StreamDeleteAsync(
|
||||
_redisOptions.StreamName,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _redisOptions.StreamName);
|
||||
_logger.LogDebug(
|
||||
"Acknowledged Notify delivery {DeliveryId} (message {MessageId}).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.MessageId);
|
||||
}
|
||||
|
||||
internal async Task RenewLeaseAsync(
|
||||
RedisNotifyDeliveryLease lease,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StreamClaimAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
lease.Consumer,
|
||||
0,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
|
||||
lease.RefreshLease(expires);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Renewed Notify delivery lease {DeliveryId} until {Expires:u}.",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
expires);
|
||||
}
|
||||
|
||||
internal async Task ReleaseAsync(
|
||||
RedisNotifyDeliveryLease lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry
|
||||
&& lease.Attempt >= _options.MaxDeliveryAttempts)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Notify delivery {DeliveryId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt);
|
||||
|
||||
await DeadLetterAsync(
|
||||
lease,
|
||||
$"max-delivery-attempts:{lease.Attempt}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await db.StreamAcknowledgeAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
await db.StreamDeleteAsync(
|
||||
_redisOptions.StreamName,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry)
|
||||
{
|
||||
NotifyQueueMetrics.RecordRetry(TransportName, _redisOptions.StreamName);
|
||||
|
||||
var delay = CalculateBackoff(lease.Attempt);
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entries = BuildEntries(lease.Message, now, lease.Attempt + 1);
|
||||
|
||||
await AddToStreamAsync(
|
||||
db,
|
||||
_redisOptions.StreamName,
|
||||
entries)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordEnqueued(TransportName, _redisOptions.StreamName);
|
||||
_logger.LogInformation(
|
||||
"Retrying Notify delivery {DeliveryId} (attempt {Attempt}).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _redisOptions.StreamName);
|
||||
_logger.LogInformation(
|
||||
"Abandoned Notify delivery {DeliveryId} after {Attempt} attempt(s).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task DeadLetterAsync(
|
||||
RedisNotifyDeliveryLease lease,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StreamAcknowledgeAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await db.StreamDeleteAsync(
|
||||
_redisOptions.StreamName,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await EnsureDeadLetterStreamAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = BuildDeadLetterEntries(lease, reason);
|
||||
await AddToStreamAsync(
|
||||
db,
|
||||
_redisOptions.DeadLetterStreamName,
|
||||
entries)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordDeadLetter(TransportName, _redisOptions.DeadLetterStreamName);
|
||||
_logger.LogError(
|
||||
"Dead-lettered Notify delivery {DeliveryId} (attempt {Attempt}): {Reason}",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt,
|
||||
reason);
|
||||
}
|
||||
|
||||
internal async ValueTask PingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
_ = await db.PingAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is { IsConnected: true })
|
||||
{
|
||||
return _connection.GetDatabase(_redisOptions.Database ?? -1);
|
||||
}
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is { IsConnected: true })
|
||||
{
|
||||
return _connection.GetDatabase(_redisOptions.Database ?? -1);
|
||||
}
|
||||
|
||||
var configuration = ConfigurationOptions.Parse(_redisOptions.ConnectionString!);
|
||||
configuration.AbortOnConnectFail = false;
|
||||
if (_redisOptions.Database.HasValue)
|
||||
{
|
||||
configuration.DefaultDatabase = _redisOptions.Database.Value;
|
||||
}
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_redisOptions.InitializationTimeout);
|
||||
|
||||
_connection = await _connectionFactory(configuration).WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
return _connection.GetDatabase(_redisOptions.Database ?? -1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureConsumerGroupAsync(
|
||||
IDatabase database,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_streamInitialized.ContainsKey(_redisOptions.StreamName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _groupLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_streamInitialized.ContainsKey(_redisOptions.StreamName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.StreamCreateConsumerGroupAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
StreamPosition.Beginning,
|
||||
createStream: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// group already exists
|
||||
}
|
||||
|
||||
_streamInitialized[_redisOptions.StreamName] = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_groupLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureDeadLetterStreamAsync(
|
||||
IDatabase database,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_streamInitialized.ContainsKey(_redisOptions.DeadLetterStreamName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _groupLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_streamInitialized.ContainsKey(_redisOptions.DeadLetterStreamName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.StreamCreateConsumerGroupAsync(
|
||||
_redisOptions.DeadLetterStreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
StreamPosition.Beginning,
|
||||
createStream: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
_streamInitialized[_redisOptions.DeadLetterStreamName] = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_groupLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private NameValueEntry[] BuildEntries(
|
||||
NotifyDeliveryQueueMessage message,
|
||||
DateTimeOffset enqueuedAt,
|
||||
int attempt)
|
||||
{
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(message.Delivery);
|
||||
var attributeCount = message.Attributes.Count;
|
||||
|
||||
var entries = ArrayPool<NameValueEntry>.Shared.Rent(8 + attributeCount);
|
||||
var index = 0;
|
||||
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.Payload, json);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.DeliveryId, message.Delivery.DeliveryId);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelId, message.ChannelId);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelType, message.ChannelType.ToString());
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.Tenant, message.Delivery.TenantId);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.Attempt, attempt);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.IdempotencyKey, message.IdempotencyKey);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.TraceId, message.TraceId ?? string.Empty);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.PartitionKey, message.PartitionKey);
|
||||
|
||||
if (attributeCount > 0)
|
||||
{
|
||||
foreach (var kvp in message.Attributes)
|
||||
{
|
||||
entries[index++] = new NameValueEntry(
|
||||
NotifyQueueFields.AttributePrefix + kvp.Key,
|
||||
kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.AsSpan(0, index).ToArray();
|
||||
}
|
||||
|
||||
private NameValueEntry[] BuildDeadLetterEntries(RedisNotifyDeliveryLease lease, string reason)
|
||||
{
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(lease.Message.Delivery);
|
||||
var attributes = lease.Message.Attributes;
|
||||
var attributeCount = attributes.Count;
|
||||
|
||||
var entries = ArrayPool<NameValueEntry>.Shared.Rent(9 + attributeCount);
|
||||
var index = 0;
|
||||
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.Payload, json);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.DeliveryId, lease.Message.Delivery.DeliveryId);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelId, lease.Message.ChannelId);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelType, lease.Message.ChannelType.ToString());
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.Tenant, lease.Message.Delivery.TenantId);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.Attempt, lease.Attempt);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey);
|
||||
entries[index++] = new NameValueEntry("deadletter-reason", reason);
|
||||
entries[index++] = new NameValueEntry(NotifyQueueFields.TraceId, lease.Message.TraceId ?? string.Empty);
|
||||
|
||||
foreach (var kvp in attributes)
|
||||
{
|
||||
entries[index++] = new NameValueEntry(
|
||||
NotifyQueueFields.AttributePrefix + kvp.Key,
|
||||
kvp.Value);
|
||||
}
|
||||
|
||||
return entries.AsSpan(0, index).ToArray();
|
||||
}
|
||||
|
||||
private RedisNotifyDeliveryLease? TryMapLease(
|
||||
StreamEntry entry,
|
||||
string consumer,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration,
|
||||
int? attemptOverride)
|
||||
{
|
||||
if (entry.Values is null || entry.Values.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? payload = null;
|
||||
string? deliveryId = null;
|
||||
string? channelId = null;
|
||||
string? channelTypeRaw = null;
|
||||
string? traceId = null;
|
||||
string? idempotency = null;
|
||||
string? partitionKey = null;
|
||||
long? enqueuedAtUnix = null;
|
||||
var attempt = attemptOverride ?? 1;
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var value in entry.Values)
|
||||
{
|
||||
var name = value.Name.ToString();
|
||||
var data = value.Value;
|
||||
if (name.Equals(NotifyQueueFields.Payload, StringComparison.Ordinal))
|
||||
{
|
||||
payload = data.ToString();
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.DeliveryId, StringComparison.Ordinal))
|
||||
{
|
||||
deliveryId = data.ToString();
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.ChannelId, StringComparison.Ordinal))
|
||||
{
|
||||
channelId = data.ToString();
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.ChannelType, StringComparison.Ordinal))
|
||||
{
|
||||
channelTypeRaw = data.ToString();
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.Attempt, StringComparison.Ordinal))
|
||||
{
|
||||
if (int.TryParse(data.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
attempt = Math.Max(parsed, attempt);
|
||||
}
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.EnqueuedAt, StringComparison.Ordinal))
|
||||
{
|
||||
if (long.TryParse(data.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix))
|
||||
{
|
||||
enqueuedAtUnix = unix;
|
||||
}
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.IdempotencyKey, StringComparison.Ordinal))
|
||||
{
|
||||
idempotency = data.ToString();
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.TraceId, StringComparison.Ordinal))
|
||||
{
|
||||
var text = data.ToString();
|
||||
traceId = string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.PartitionKey, StringComparison.Ordinal))
|
||||
{
|
||||
partitionKey = data.ToString();
|
||||
}
|
||||
else if (name.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
attributes[name[NotifyQueueFields.AttributePrefix.Length..]] = data.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
if (payload is null || deliveryId is null || channelId is null || channelTypeRaw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NotifyDelivery delivery;
|
||||
try
|
||||
{
|
||||
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to deserialize Notify delivery payload for entry {EntryId}.",
|
||||
entry.Id.ToString());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(channelTypeRaw, ignoreCase: true, out var channelType))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unknown channel type '{ChannelType}' for delivery {DeliveryId}; acknowledging as poison.",
|
||||
channelTypeRaw,
|
||||
deliveryId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var attributeView = attributes.Count == 0
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(attributes);
|
||||
|
||||
var enqueuedAt = enqueuedAtUnix is null
|
||||
? now
|
||||
: DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
|
||||
|
||||
var message = new NotifyDeliveryQueueMessage(
|
||||
delivery,
|
||||
channelId,
|
||||
channelType,
|
||||
_redisOptions.StreamName,
|
||||
traceId,
|
||||
attributeView);
|
||||
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
|
||||
return new RedisNotifyDeliveryLease(
|
||||
this,
|
||||
entry.Id.ToString(),
|
||||
message,
|
||||
attempt,
|
||||
enqueuedAt,
|
||||
leaseExpires,
|
||||
consumer,
|
||||
idempotency,
|
||||
partitionKey ?? channelId);
|
||||
}
|
||||
|
||||
private async Task AckPoisonAsync(IDatabase database, RedisValue messageId)
|
||||
{
|
||||
await database.StreamAcknowledgeAsync(
|
||||
_redisOptions.StreamName,
|
||||
_redisOptions.ConsumerGroup,
|
||||
new RedisValue[] { messageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await database.StreamDeleteAsync(
|
||||
_redisOptions.StreamName,
|
||||
new RedisValue[] { messageId })
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<RedisValue> AddToStreamAsync(
|
||||
IDatabase database,
|
||||
string stream,
|
||||
IReadOnlyList<NameValueEntry> entries)
|
||||
{
|
||||
return await database.StreamAddAsync(
|
||||
stream,
|
||||
entries.ToArray())
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildIdempotencyKey(string token)
|
||||
=> string.Concat(_redisOptions.IdempotencyKeyPrefix, token);
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var initial = _options.RetryInitialBackoff > TimeSpan.Zero
|
||||
? _options.RetryInitialBackoff
|
||||
: TimeSpan.FromSeconds(1);
|
||||
|
||||
if (initial <= TimeSpan.Zero)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (attempt <= 1)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var max = _options.RetryMaxBackoff > TimeSpan.Zero
|
||||
? _options.RetryMaxBackoff
|
||||
: initial;
|
||||
|
||||
var exponent = attempt - 1;
|
||||
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
|
||||
return TimeSpan.FromTicks(resultTicks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
|
||||
{
|
||||
private readonly RedisNotifyEventQueue _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisNotifyEventLease(
|
||||
RedisNotifyEventQueue queue,
|
||||
NotifyRedisEventStreamOptions streamOptions,
|
||||
string messageId,
|
||||
NotifyQueueEventMessage message,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = message ?? throw new ArgumentNullException(nameof(message));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
}
|
||||
|
||||
internal NotifyRedisEventStreamOptions StreamOptions { get; }
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => StreamOptions.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey => Message.IdempotencyKey;
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyQueueEventMessage Message { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
{
|
||||
private const string TransportName = "redis";
|
||||
|
||||
private readonly NotifyEventQueueOptions _options;
|
||||
private readonly NotifyRedisEventQueueOptions _redisOptions;
|
||||
private readonly ILogger<RedisNotifyEventQueue> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _groupInitLock = new(1, 1);
|
||||
private readonly IReadOnlyDictionary<string, NotifyRedisEventStreamOptions> _streamsByName;
|
||||
private readonly ConcurrentDictionary<string, bool> _initializedStreams = new(StringComparer.Ordinal);
|
||||
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public RedisNotifyEventQueue(
|
||||
NotifyEventQueueOptions options,
|
||||
NotifyRedisEventQueueOptions redisOptions,
|
||||
ILogger<RedisNotifyEventQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_redisOptions = redisOptions ?? throw new ArgumentNullException(nameof(redisOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_connectionFactory = connectionFactory ?? (async config =>
|
||||
{
|
||||
var connection = await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false);
|
||||
return (IConnectionMultiplexer)connection;
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_redisOptions.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Redis connection string must be configured for Notify event queue.");
|
||||
}
|
||||
|
||||
_streamsByName = _redisOptions.Streams.ToDictionary(
|
||||
stream => stream.Stream,
|
||||
stream => stream,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
|
||||
NotifyQueueEventMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var streamOptions = GetStreamOptions(message.Stream);
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entries = BuildEntries(message, now, attempt: 1);
|
||||
|
||||
var messageId = await AddToStreamAsync(
|
||||
db,
|
||||
streamOptions,
|
||||
entries)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var idempotencyToken = string.IsNullOrWhiteSpace(message.IdempotencyKey)
|
||||
? message.Event.EventId.ToString("N")
|
||||
: message.IdempotencyKey;
|
||||
|
||||
var idempotencyKey = streamOptions.IdempotencyKeyPrefix + idempotencyToken;
|
||||
var stored = await db.StringSetAsync(
|
||||
idempotencyKey,
|
||||
messageId,
|
||||
when: When.NotExists,
|
||||
expiry: _redisOptions.IdempotencyWindow)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!stored)
|
||||
{
|
||||
await db.StreamDeleteAsync(
|
||||
streamOptions.Stream,
|
||||
new RedisValue[] { messageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
|
||||
var duplicateId = existing.IsNullOrEmpty ? messageId : existing;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Duplicate Notify event enqueue detected for idempotency token {Token}; returning existing stream id {StreamId}.",
|
||||
idempotencyToken,
|
||||
duplicateId.ToString());
|
||||
|
||||
NotifyQueueMetrics.RecordDeduplicated(TransportName, streamOptions.Stream);
|
||||
return new NotifyQueueEnqueueResult(duplicateId.ToString()!, true);
|
||||
}
|
||||
|
||||
NotifyQueueMetrics.RecordEnqueued(TransportName, streamOptions.Stream);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Enqueued Notify event {EventId} for tenant {Tenant} on stream {Stream} (id {StreamId}).",
|
||||
message.Event.EventId,
|
||||
message.TenantId,
|
||||
streamOptions.Stream,
|
||||
messageId.ToString());
|
||||
|
||||
return new NotifyQueueEnqueueResult(messageId.ToString()!, false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(
|
||||
NotifyQueueLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(request.BatchSize);
|
||||
|
||||
foreach (var streamOptions in _streamsByName.Values)
|
||||
{
|
||||
await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var remaining = request.BatchSize - leases.Count;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var entries = await db.StreamReadGroupAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
request.Consumer,
|
||||
StreamPosition.NewMessages,
|
||||
remaining)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entries is null || entries.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var lease = TryMapLease(
|
||||
streamOptions,
|
||||
entry,
|
||||
request.Consumer,
|
||||
now,
|
||||
request.LeaseDuration,
|
||||
attemptOverride: null);
|
||||
|
||||
if (lease is null)
|
||||
{
|
||||
await AckPoisonAsync(db, streamOptions, entry.Id).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
|
||||
if (leases.Count >= request.BatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(
|
||||
NotifyQueueClaimOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(options.BatchSize);
|
||||
|
||||
foreach (var streamOptions in _streamsByName.Values)
|
||||
{
|
||||
await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var pending = await db.StreamPendingMessagesAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
options.BatchSize,
|
||||
RedisValue.Null,
|
||||
(long)options.MinIdleTime.TotalMilliseconds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pending is null || pending.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var eligible = pending
|
||||
.Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
|
||||
.ToArray();
|
||||
|
||||
if (eligible.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var messageIds = eligible
|
||||
.Select(static p => (RedisValue)p.MessageId)
|
||||
.ToArray();
|
||||
|
||||
var entries = await db.StreamClaimAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
options.ClaimantConsumer,
|
||||
0,
|
||||
messageIds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entries is null || entries.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attemptById = eligible
|
||||
.Where(static info => !info.MessageId.IsNullOrEmpty)
|
||||
.ToDictionary(
|
||||
info => info.MessageId!.ToString(),
|
||||
info => (int)Math.Max(1, info.DeliveryCount),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var entryId = entry.Id.ToString();
|
||||
attemptById.TryGetValue(entryId, out var attempt);
|
||||
|
||||
var lease = TryMapLease(
|
||||
streamOptions,
|
||||
entry,
|
||||
options.ClaimantConsumer,
|
||||
now,
|
||||
_options.DefaultLeaseDuration,
|
||||
attempt == 0 ? null : attempt);
|
||||
|
||||
if (lease is null)
|
||||
{
|
||||
await AckPoisonAsync(db, streamOptions, entry.Id).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
if (leases.Count >= options.BatchSize)
|
||||
{
|
||||
return leases;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionLock.Dispose();
|
||||
_groupInitLock.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
internal async Task AcknowledgeAsync(
|
||||
RedisNotifyEventLease lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var streamOptions = lease.StreamOptions;
|
||||
|
||||
await db.StreamAcknowledgeAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await db.StreamDeleteAsync(
|
||||
streamOptions.Stream,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordAck(TransportName, streamOptions.Stream);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Acknowledged Notify event {EventId} on consumer {Consumer} (stream {Stream}, id {MessageId}).",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Consumer,
|
||||
streamOptions.Stream,
|
||||
lease.MessageId);
|
||||
}
|
||||
|
||||
internal async Task RenewLeaseAsync(
|
||||
RedisNotifyEventLease lease,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var streamOptions = lease.StreamOptions;
|
||||
|
||||
await db.StreamClaimAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
lease.Consumer,
|
||||
0,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
|
||||
lease.RefreshLease(expires);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Renewed Notify event lease for {EventId} until {Expires:u}.",
|
||||
lease.Message.Event.EventId,
|
||||
expires);
|
||||
}
|
||||
|
||||
internal Task ReleaseAsync(
|
||||
RedisNotifyEventLease lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.FromException(new NotSupportedException("Retry/abandon is not supported for Notify event streams."));
|
||||
|
||||
internal async Task DeadLetterAsync(
|
||||
RedisNotifyEventLease lease,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var streamOptions = lease.StreamOptions;
|
||||
|
||||
await db.StreamAcknowledgeAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await db.StreamDeleteAsync(
|
||||
streamOptions.Stream,
|
||||
new RedisValue[] { lease.MessageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dead-lettered Notify event {EventId} on stream {Stream} with reason '{Reason}'.",
|
||||
lease.Message.Event.EventId,
|
||||
streamOptions.Stream,
|
||||
reason);
|
||||
}
|
||||
|
||||
internal async ValueTask PingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
_ = await db.PingAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private NotifyRedisEventStreamOptions GetStreamOptions(string stream)
|
||||
{
|
||||
if (!_streamsByName.TryGetValue(stream, out var options))
|
||||
{
|
||||
throw new InvalidOperationException($"Stream '{stream}' is not configured for the Notify event queue.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is { IsConnected: true })
|
||||
{
|
||||
return _connection.GetDatabase(_redisOptions.Database ?? -1);
|
||||
}
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is { IsConnected: true })
|
||||
{
|
||||
return _connection.GetDatabase(_redisOptions.Database ?? -1);
|
||||
}
|
||||
|
||||
var configuration = ConfigurationOptions.Parse(_redisOptions.ConnectionString!);
|
||||
configuration.AbortOnConnectFail = false;
|
||||
if (_redisOptions.Database.HasValue)
|
||||
{
|
||||
configuration.DefaultDatabase = _redisOptions.Database;
|
||||
}
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_redisOptions.InitializationTimeout);
|
||||
|
||||
_connection = await _connectionFactory(configuration).WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
return _connection.GetDatabase(_redisOptions.Database ?? -1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureStreamInitializedAsync(
|
||||
IDatabase database,
|
||||
NotifyRedisEventStreamOptions streamOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_initializedStreams.ContainsKey(streamOptions.Stream))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_initializedStreams.ContainsKey(streamOptions.Stream))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.StreamCreateConsumerGroupAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
StreamPosition.Beginning,
|
||||
createStream: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Consumer group already exists — nothing to do.
|
||||
}
|
||||
|
||||
_initializedStreams[streamOptions.Stream] = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_groupInitLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RedisValue> AddToStreamAsync(
|
||||
IDatabase database,
|
||||
NotifyRedisEventStreamOptions streamOptions,
|
||||
IReadOnlyList<NameValueEntry> entries)
|
||||
{
|
||||
return await database.StreamAddAsync(
|
||||
streamOptions.Stream,
|
||||
entries.ToArray(),
|
||||
maxLength: streamOptions.ApproximateMaxLength,
|
||||
useApproximateMaxLength: streamOptions.ApproximateMaxLength is not null)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IReadOnlyList<NameValueEntry> BuildEntries(
|
||||
NotifyQueueEventMessage message,
|
||||
DateTimeOffset enqueuedAt,
|
||||
int attempt)
|
||||
{
|
||||
var payload = NotifyCanonicalJsonSerializer.Serialize(message.Event);
|
||||
|
||||
var entries = new List<NameValueEntry>(8 + message.Attributes.Count)
|
||||
{
|
||||
new(NotifyQueueFields.Payload, payload),
|
||||
new(NotifyQueueFields.EventId, message.Event.EventId.ToString("D")),
|
||||
new(NotifyQueueFields.Tenant, message.TenantId),
|
||||
new(NotifyQueueFields.Kind, message.Event.Kind),
|
||||
new(NotifyQueueFields.Attempt, attempt),
|
||||
new(NotifyQueueFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds()),
|
||||
new(NotifyQueueFields.IdempotencyKey, message.IdempotencyKey),
|
||||
new(NotifyQueueFields.PartitionKey, message.PartitionKey ?? string.Empty),
|
||||
new(NotifyQueueFields.TraceId, message.TraceId ?? string.Empty)
|
||||
};
|
||||
|
||||
foreach (var kvp in message.Attributes)
|
||||
{
|
||||
entries.Add(new NameValueEntry(
|
||||
NotifyQueueFields.AttributePrefix + kvp.Key,
|
||||
kvp.Value));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private RedisNotifyEventLease? TryMapLease(
|
||||
NotifyRedisEventStreamOptions streamOptions,
|
||||
StreamEntry entry,
|
||||
string consumer,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration,
|
||||
int? attemptOverride)
|
||||
{
|
||||
if (entry.Values is null || entry.Values.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? payloadJson = null;
|
||||
string? eventIdRaw = null;
|
||||
long? enqueuedAtUnix = null;
|
||||
string? idempotency = null;
|
||||
string? partitionKey = null;
|
||||
string? traceId = null;
|
||||
var attempt = attemptOverride ?? 1;
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var field in entry.Values)
|
||||
{
|
||||
var name = field.Name.ToString();
|
||||
var value = field.Value;
|
||||
if (name.Equals(NotifyQueueFields.Payload, StringComparison.Ordinal))
|
||||
{
|
||||
payloadJson = value.ToString();
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.EventId, StringComparison.Ordinal))
|
||||
{
|
||||
eventIdRaw = value.ToString();
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.Attempt, StringComparison.Ordinal))
|
||||
{
|
||||
if (int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
attempt = Math.Max(parsed, attempt);
|
||||
}
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.EnqueuedAt, StringComparison.Ordinal))
|
||||
{
|
||||
if (long.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix))
|
||||
{
|
||||
enqueuedAtUnix = unix;
|
||||
}
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.IdempotencyKey, StringComparison.Ordinal))
|
||||
{
|
||||
var text = value.ToString();
|
||||
idempotency = string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.PartitionKey, StringComparison.Ordinal))
|
||||
{
|
||||
var text = value.ToString();
|
||||
partitionKey = string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
else if (name.Equals(NotifyQueueFields.TraceId, StringComparison.Ordinal))
|
||||
{
|
||||
var text = value.ToString();
|
||||
traceId = string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
else if (name.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var key = name[NotifyQueueFields.AttributePrefix.Length..];
|
||||
attributes[key] = value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
if (payloadJson is null || enqueuedAtUnix is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NotifyEvent notifyEvent;
|
||||
try
|
||||
{
|
||||
notifyEvent = NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(payloadJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to deserialize Notify event payload for stream {Stream} entry {EntryId}.",
|
||||
streamOptions.Stream,
|
||||
entry.Id.ToString());
|
||||
return null;
|
||||
}
|
||||
|
||||
var attributeView = attributes.Count == 0
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(attributes);
|
||||
|
||||
var message = new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
streamOptions.Stream,
|
||||
idempotencyKey: idempotency ?? notifyEvent.EventId.ToString("N"),
|
||||
partitionKey: partitionKey,
|
||||
traceId: traceId,
|
||||
attributes: attributeView);
|
||||
|
||||
var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
|
||||
var leaseExpiresAt = now.Add(leaseDuration);
|
||||
|
||||
return new RedisNotifyEventLease(
|
||||
this,
|
||||
streamOptions,
|
||||
entry.Id.ToString(),
|
||||
message,
|
||||
attempt,
|
||||
consumer,
|
||||
enqueuedAt,
|
||||
leaseExpiresAt);
|
||||
}
|
||||
|
||||
private async Task AckPoisonAsync(
|
||||
IDatabase database,
|
||||
NotifyRedisEventStreamOptions streamOptions,
|
||||
RedisValue messageId)
|
||||
{
|
||||
await database.StreamAcknowledgeAsync(
|
||||
streamOptions.Stream,
|
||||
streamOptions.ConsumerGroup,
|
||||
new RedisValue[] { messageId })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await database.StreamDeleteAsync(
|
||||
streamOptions.Stream,
|
||||
new RedisValue[] { messageId })
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="NATS.Client.Core" Version="2.0.0" />
|
||||
<PackageReference Include="NATS.Client.JetStream" Version="2.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
2
src/Notify/__Libraries/StellaOps.Notify.Queue/TASKS.md
Normal file
2
src/Notify/__Libraries/StellaOps.Notify.Queue/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify Queue Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — queue infrastructure maintained in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Storage.Mongo — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Mongo persistence (rules, channels, deliveries, digests, locks, audit) per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,31 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class NotifyAuditEntryDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; init; }
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[BsonElement("actor")]
|
||||
public required string Actor { get; init; }
|
||||
|
||||
[BsonElement("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
[BsonElement("entityId")]
|
||||
public string EntityId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("entityType")]
|
||||
public string EntityType { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[BsonElement("payload")]
|
||||
public BsonDocument Payload { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class NotifyDigestDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[BsonElement("actionKey")]
|
||||
public required string ActionKey { get; init; }
|
||||
|
||||
[BsonElement("window")]
|
||||
public required string Window { get; init; }
|
||||
|
||||
[BsonElement("openedAt")]
|
||||
public required DateTimeOffset OpenedAt { get; init; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[BsonElement("items")]
|
||||
public List<NotifyDigestItemDocument> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class NotifyDigestItemDocument
|
||||
{
|
||||
[BsonElement("eventId")]
|
||||
public string EventId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("scope")]
|
||||
public Dictionary<string, string> Scope { get; init; } = new();
|
||||
|
||||
[BsonElement("delta")]
|
||||
public Dictionary<string, int> Delta { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class NotifyLockDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[BsonElement("resource")]
|
||||
public required string Resource { get; init; }
|
||||
|
||||
[BsonElement("acquiredAt")]
|
||||
public required DateTimeOffset AcquiredAt { get; init; }
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
[BsonElement("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
internal sealed class NotifyMongoContext
|
||||
{
|
||||
public NotifyMongoContext(IOptions<NotifyMongoOptions> options, ILogger<NotifyMongoContext> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
var value = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Notify Mongo connection string is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value.Database))
|
||||
{
|
||||
throw new InvalidOperationException("Notify Mongo database name is not configured.");
|
||||
}
|
||||
|
||||
Client = new MongoClient(value.ConnectionString);
|
||||
var settings = new MongoDatabaseSettings();
|
||||
if (value.UseMajorityReadConcern)
|
||||
{
|
||||
settings.ReadConcern = ReadConcern.Majority;
|
||||
}
|
||||
if (value.UseMajorityWriteConcern)
|
||||
{
|
||||
settings.WriteConcern = WriteConcern.WMajority;
|
||||
}
|
||||
|
||||
Database = Client.GetDatabase(value.Database, settings);
|
||||
Options = value;
|
||||
}
|
||||
|
||||
public MongoClient Client { get; }
|
||||
|
||||
public IMongoDatabase Database { get; }
|
||||
|
||||
public NotifyMongoOptions Options { get; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
internal interface INotifyMongoInitializer
|
||||
{
|
||||
Task EnsureIndexesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class NotifyMongoInitializer : INotifyMongoInitializer
|
||||
{
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoMigrationRunner _migrationRunner;
|
||||
private readonly ILogger<NotifyMongoInitializer> _logger;
|
||||
|
||||
public NotifyMongoInitializer(
|
||||
NotifyMongoContext context,
|
||||
NotifyMongoMigrationRunner migrationRunner,
|
||||
ILogger<NotifyMongoInitializer> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task EnsureIndexesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Ensuring Notify Mongo migrations are applied for database {Database}.", _context.Options.Database);
|
||||
await _migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class EnsureNotifyCollectionsMigration : INotifyMongoMigration
|
||||
{
|
||||
private readonly ILogger<EnsureNotifyCollectionsMigration> _logger;
|
||||
|
||||
public EnsureNotifyCollectionsMigration(ILogger<EnsureNotifyCollectionsMigration> logger)
|
||||
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public string Id => "20251019_notify_collections_v1";
|
||||
|
||||
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var requiredCollections = new[]
|
||||
{
|
||||
context.Options.RulesCollection,
|
||||
context.Options.ChannelsCollection,
|
||||
context.Options.TemplatesCollection,
|
||||
context.Options.DeliveriesCollection,
|
||||
context.Options.DigestsCollection,
|
||||
context.Options.LocksCollection,
|
||||
context.Options.AuditCollection,
|
||||
context.Options.MigrationsCollection
|
||||
};
|
||||
|
||||
var cursor = await context.Database
|
||||
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existingNames = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var collection in requiredCollections)
|
||||
{
|
||||
if (existingNames.Contains(collection, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Creating Notify Mongo collection '{CollectionName}'.", collection);
|
||||
await context.Database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class EnsureNotifyIndexesMigration : INotifyMongoMigration
|
||||
{
|
||||
public string Id => "20251019_notify_indexes_v1";
|
||||
|
||||
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
await EnsureRulesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureChannelsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureTemplatesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeliveriesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDigestsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureLocksIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureAuditIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureRulesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("enabled");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_enabled"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureChannelsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("type")
|
||||
.Ascending("enabled");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_type_enabled"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureTemplatesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("channelType")
|
||||
.Ascending("key")
|
||||
.Ascending("locale");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_channel_key_locale",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureDeliveriesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.DeliveriesCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("sortKey");
|
||||
|
||||
var sortModel = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_sortKey"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(sortModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var statusModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("status"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_status"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(statusModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (context.Options.DeliveryHistoryRetention > TimeSpan.Zero)
|
||||
{
|
||||
var ttlModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("completedAt"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "completedAt_ttl",
|
||||
ExpireAfter = context.Options.DeliveryHistoryRetention
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(ttlModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureDigestsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.DigestsCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("actionKey");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_actionKey"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureLocksIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.LocksCollection);
|
||||
var uniqueModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("resource"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_resource",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(uniqueModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var ttlModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "expiresAt_ttl",
|
||||
ExpireAfter = TimeSpan.Zero
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(ttlModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureAuditIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.AuditCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("timestamp");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_timestamp"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal interface INotifyMongoMigration
|
||||
{
|
||||
string Id { get; }
|
||||
|
||||
ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class NotifyMongoMigrationRecord
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; init; }
|
||||
|
||||
[BsonElement("migrationId")]
|
||||
public required string MigrationId { get; init; }
|
||||
|
||||
[BsonElement("appliedAt")]
|
||||
public required DateTimeOffset AppliedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class NotifyMongoMigrationRunner
|
||||
{
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly IReadOnlyList<INotifyMongoMigration> _migrations;
|
||||
private readonly ILogger<NotifyMongoMigrationRunner> _logger;
|
||||
|
||||
public NotifyMongoMigrationRunner(
|
||||
NotifyMongoContext context,
|
||||
IEnumerable<INotifyMongoMigration> migrations,
|
||||
ILogger<NotifyMongoMigrationRunner> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
ArgumentNullException.ThrowIfNull(migrations);
|
||||
_migrations = migrations.OrderBy(migration => migration.Id, StringComparer.Ordinal).ToArray();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_migrations.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collection = _context.Database.GetCollection<NotifyMongoMigrationRecord>(_context.Options.MigrationsCollection);
|
||||
await EnsureMigrationIndexAsync(collection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var applied = await collection
|
||||
.Find(FilterDefinition<NotifyMongoMigrationRecord>.Empty)
|
||||
.Project(record => record.MigrationId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedSet = applied.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var migration in _migrations)
|
||||
{
|
||||
if (appliedSet.Contains(migration.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying Notify Mongo migration {MigrationId}.", migration.Id);
|
||||
await migration.ExecuteAsync(_context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var record = new NotifyMongoMigrationRecord
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
MigrationId = migration.Id,
|
||||
AppliedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Completed Notify Mongo migration {MigrationId}.", migration.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureMigrationIndexAsync(
|
||||
IMongoCollection<NotifyMongoMigrationRecord> collection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var keys = Builders<NotifyMongoMigrationRecord>.IndexKeys.Ascending(record => record.MigrationId);
|
||||
var model = new CreateIndexModel<NotifyMongoMigrationRecord>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "migrationId_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Options;
|
||||
|
||||
public sealed class NotifyMongoOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
|
||||
|
||||
public string Database { get; set; } = "stellaops_notify";
|
||||
|
||||
public string RulesCollection { get; set; } = "rules";
|
||||
|
||||
public string ChannelsCollection { get; set; } = "channels";
|
||||
|
||||
public string TemplatesCollection { get; set; } = "templates";
|
||||
|
||||
public string DeliveriesCollection { get; set; } = "deliveries";
|
||||
|
||||
public string DigestsCollection { get; set; } = "digests";
|
||||
|
||||
public string LocksCollection { get; set; } = "locks";
|
||||
|
||||
public string AuditCollection { get; set; } = "audit";
|
||||
|
||||
public string MigrationsCollection { get; set; } = "_notify_migrations";
|
||||
|
||||
public TimeSpan DeliveryHistoryRetention { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
public bool UseMajorityReadConcern { get; set; } = true;
|
||||
|
||||
public bool UseMajorityWriteConcern { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Storage.Mongo.Tests")]
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyAuditRepository
|
||||
{
|
||||
Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyChannelRepository
|
||||
{
|
||||
Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyDeliveryRepository
|
||||
{
|
||||
Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyDeliveryQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since,
|
||||
string? status,
|
||||
int? limit,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyDigestRepository
|
||||
{
|
||||
Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyLockRepository
|
||||
{
|
||||
Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyRuleRepository
|
||||
{
|
||||
Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyTemplateRepository
|
||||
{
|
||||
Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyAuditRepository : INotifyAuditRepository
|
||||
{
|
||||
private readonly IMongoCollection<NotifyAuditEntryDocument> _collection;
|
||||
|
||||
public NotifyAuditRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<NotifyAuditEntryDocument>(context.Options.AuditCollection);
|
||||
}
|
||||
|
||||
public async Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
await _collection.InsertOneAsync(entry, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyAuditEntryDocument>.Filter.Eq(x => x.TenantId, tenantId);
|
||||
if (since is not null)
|
||||
{
|
||||
filter &= Builders<NotifyAuditEntryDocument>.Filter.Gte(x => x.Timestamp, since.Value);
|
||||
}
|
||||
|
||||
var ordered = _collection.Find(filter).SortByDescending(x => x.Timestamp);
|
||||
IFindFluent<NotifyAuditEntryDocument, NotifyAuditEntryDocument> query = ordered;
|
||||
if (limit is > 0)
|
||||
{
|
||||
query = query.Limit(limit);
|
||||
}
|
||||
|
||||
return await query.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyChannelRepository(NotifyMongoContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
var document = NotifyChannelDocumentMapper.ToBsonDocument(channel);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(channel.TenantId, channel.ChannelId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId))
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyChannelDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(NotifyChannelDocumentMapper.FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow).Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public sealed record NotifyDeliveryQueryResult(IReadOnlyList<NotifyDelivery> Items, string? ContinuationToken);
|
||||
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyDeliveryRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.DeliveriesCollection);
|
||||
}
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
=> UpdateAsync(delivery, cancellationToken);
|
||||
|
||||
public async Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
var document = NotifyDeliveryDocumentMapper.ToBsonDocument(delivery);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(delivery.TenantId, delivery.DeliveryId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, deliveryId));
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyDeliveryDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since,
|
||||
string? status,
|
||||
int? limit,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = Builders<BsonDocument>.Filter;
|
||||
var filter = builder.Eq("tenantId", tenantId);
|
||||
if (since is not null)
|
||||
{
|
||||
filter &= builder.Gte("sortKey", since.Value.UtcDateTime);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var statuses = status
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static value => value.ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
if (statuses.Length == 1)
|
||||
{
|
||||
filter &= builder.Eq("status", statuses[0]);
|
||||
}
|
||||
else if (statuses.Length > 1)
|
||||
{
|
||||
filter &= builder.In("status", statuses);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(continuationToken))
|
||||
{
|
||||
if (!TryParseContinuationToken(continuationToken, out var continuationSortKey, out var continuationId))
|
||||
{
|
||||
throw new ArgumentException("The continuation token is invalid.", nameof(continuationToken));
|
||||
}
|
||||
|
||||
var lessThanSort = builder.Lt("sortKey", continuationSortKey);
|
||||
var equalSortLowerId = builder.And(builder.Eq("sortKey", continuationSortKey), builder.Lte("_id", continuationId));
|
||||
filter &= builder.Or(lessThanSort, equalSortLowerId);
|
||||
}
|
||||
|
||||
var find = _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Descending("sortKey").Descending("_id"));
|
||||
|
||||
List<BsonDocument> documents;
|
||||
if (limit is > 0)
|
||||
{
|
||||
documents = await find.Limit(limit.Value + 1).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
documents = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string? nextToken = null;
|
||||
if (limit is > 0 && documents.Count > limit.Value)
|
||||
{
|
||||
var overflow = documents[^1];
|
||||
documents.RemoveAt(documents.Count - 1);
|
||||
nextToken = BuildContinuationToken(overflow);
|
||||
}
|
||||
|
||||
var deliveries = documents.Select(NotifyDeliveryDocumentMapper.FromBsonDocument).ToArray();
|
||||
return new NotifyDeliveryQueryResult(deliveries, nextToken);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
|
||||
private static string BuildContinuationToken(BsonDocument document)
|
||||
{
|
||||
var sortKey = ResolveSortKey(document);
|
||||
if (!document.TryGetValue("_id", out var idValue) || !idValue.IsString)
|
||||
{
|
||||
throw new InvalidOperationException("Delivery document missing string _id required for continuation token.");
|
||||
}
|
||||
|
||||
return BuildContinuationToken(sortKey, idValue.AsString);
|
||||
}
|
||||
|
||||
private static DateTime ResolveSortKey(BsonDocument document)
|
||||
{
|
||||
if (document.TryGetValue("sortKey", out var sortValue) && sortValue.IsValidDateTime)
|
||||
{
|
||||
return sortValue.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (document.TryGetValue("completedAt", out var completed) && completed.IsValidDateTime)
|
||||
{
|
||||
return completed.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (document.TryGetValue("sentAt", out var sent) && sent.IsValidDateTime)
|
||||
{
|
||||
return sent.ToUniversalTime();
|
||||
}
|
||||
|
||||
var created = document["createdAt"];
|
||||
return created.ToUniversalTime();
|
||||
}
|
||||
|
||||
private static string BuildContinuationToken(DateTime sortKey, string id)
|
||||
=> FormattableString.Invariant($"{sortKey:O}|{id}");
|
||||
|
||||
private static bool TryParseContinuationToken(string token, out DateTime sortKey, out string id)
|
||||
{
|
||||
sortKey = default;
|
||||
id = string.Empty;
|
||||
|
||||
var parts = token.Split('|', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(parts[0], "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedSort))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sortKey = parsedSort.ToUniversalTime();
|
||||
id = parts[1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyDigestRepository : INotifyDigestRepository
|
||||
{
|
||||
private readonly IMongoCollection<NotifyDigestDocument> _collection;
|
||||
|
||||
public NotifyDigestRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<NotifyDigestDocument>(context.Options.DigestsCollection);
|
||||
}
|
||||
|
||||
public async Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, actionKey));
|
||||
return await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
document.Id = CreateDocumentId(document.TenantId, document.ActionKey);
|
||||
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, document.Id);
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, actionKey));
|
||||
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string actionKey)
|
||||
=> string.Create(tenantId.Length + actionKey.Length + 1, (tenantId, actionKey), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.actionKey.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly IMongoCollection<NotifyLockDocument> _collection;
|
||||
|
||||
public NotifyLockRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<NotifyLockDocument>(context.Options.LocksCollection);
|
||||
}
|
||||
|
||||
public async Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var document = new NotifyLockDocument
|
||||
{
|
||||
Id = CreateDocumentId(tenantId, resource),
|
||||
TenantId = tenantId,
|
||||
Resource = resource,
|
||||
Owner = owner,
|
||||
AcquiredAt = now,
|
||||
ExpiresAt = now.Add(ttl)
|
||||
};
|
||||
|
||||
var candidateFilter = Builders<NotifyLockDocument>.Filter.Eq(x => x.Id, document.Id);
|
||||
var takeoverFilter = candidateFilter & Builders<NotifyLockDocument>.Filter.Lt(x => x.ExpiresAt, now.UtcDateTime);
|
||||
var sameOwnerFilter = candidateFilter & Builders<NotifyLockDocument>.Filter.Eq(x => x.Owner, owner);
|
||||
|
||||
var update = Builders<NotifyLockDocument>.Update
|
||||
.Set(x => x.TenantId, document.TenantId)
|
||||
.Set(x => x.Resource, document.Resource)
|
||||
.Set(x => x.Owner, document.Owner)
|
||||
.Set(x => x.AcquiredAt, document.AcquiredAt)
|
||||
.Set(x => x.ExpiresAt, document.ExpiresAt);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _collection.UpdateOneAsync(
|
||||
takeoverFilter | sameOwnerFilter,
|
||||
update.SetOnInsert(x => x.Id, document.Id),
|
||||
new UpdateOptions { IsUpsert = true },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.MatchedCount > 0 || result.UpsertedId != null;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyLockDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, resource))
|
||||
& Builders<NotifyLockDocument>.Filter.Eq(x => x.Owner, owner);
|
||||
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user