Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Email — Agent Charter
## Mission
Implement SMTP connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
# Notify Email Connector Task Board (Sprint 15)
> Archived 2025-10-26 — connector maintained under `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Slack — Agent Charter
## Mission
Deliver Slack connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
# Notify Slack Connector Task Board (Sprint 15)
> Archived 2025-10-26 — connector scope now in `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

@@ -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"
}
}

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Teams — Agent Charter
## Mission
Implement Microsoft Teams connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

@@ -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>

View File

@@ -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 3840).
> 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.

View File

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

View File

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

View File

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

View File

@@ -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"
}
}

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Webhook — Agent Charter
## Mission
Implement generic webhook connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
# Notify Webhook Connector Task Board (Sprint 15)
> Archived 2025-10-26 — webhook connector maintained in `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

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

View File

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

View File

@@ -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"
}
}

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Engine — Agent Charter
## Mission
Deliver rule evaluation, digest, and rendering logic per `docs/ARCHITECTURE_NOTIFY.md`.

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
# Notify Engine Task Board (Sprint 15)
> Archived 2025-10-26 — runtime responsibilities moved to `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Models — Agent Charter
## Mission
Define Notify DTOs and contracts per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

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

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

View File

@@ -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,
}

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

View File

@@ -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";
}

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,2 @@
# Notify Models Task Board (Sprint 15)
> Archived 2025-10-26 — scope moved to `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Queue — Agent Charter
## Mission
Provide event & delivery queues for Notify per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:";
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Notify.Queue;
/// <summary>
/// Supported transports for the Notify event queue.
/// </summary>
public enum NotifyQueueTransportKind
{
Redis,
Nats
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Queue.Tests")]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
# Notify Queue Task Board (Sprint 15)
> Archived 2025-10-26 — queue infrastructure maintained in `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

@@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Storage.Mongo.Tests")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)..]);
});
}

View File

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

View File

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

View File

@@ -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)..]);
});
}

View File

@@ -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