Restructure solution layout by module
This commit is contained in:
4
src/Notify/StellaOps.Notify.Worker/AGENTS.md
Normal file
4
src/Notify/StellaOps.Notify.Worker/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Worker — Agent Charter
|
||||
|
||||
## Mission
|
||||
Consume events, evaluate rules, and dispatch deliveries per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Queue;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Handlers;
|
||||
|
||||
public interface INotifyEventHandler
|
||||
{
|
||||
Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Handlers;
|
||||
|
||||
internal sealed class NoOpNotifyEventHandler : INotifyEventHandler
|
||||
{
|
||||
private readonly ILogger<NoOpNotifyEventHandler> _logger;
|
||||
|
||||
public NoOpNotifyEventHandler(ILogger<NoOpNotifyEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No-op handler acknowledged event {EventId} (tenant {TenantId}).",
|
||||
message.Event.EventId,
|
||||
message.TenantId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
52
src/Notify/StellaOps.Notify.Worker/NotifyWorkerOptions.cs
Normal file
52
src/Notify/StellaOps.Notify.Worker/NotifyWorkerOptions.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.Worker;
|
||||
|
||||
public sealed class NotifyWorkerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Worker identifier prefix; defaults to machine name.
|
||||
/// </summary>
|
||||
public string? WorkerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of messages to lease per iteration.
|
||||
/// </summary>
|
||||
public int LeaseBatchSize { get; set; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Duration a lease remains active before it becomes eligible for claim.
|
||||
/// </summary>
|
||||
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Delay applied when no work is available.
|
||||
/// </summary>
|
||||
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of event leases processed concurrently.
|
||||
/// </summary>
|
||||
public int MaxConcurrency { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of consecutive failures before the worker delays.
|
||||
/// </summary>
|
||||
public int FailureBackoffThreshold { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Delay applied when the failure threshold is reached.
|
||||
/// </summary>
|
||||
public TimeSpan FailureBackoffDelay { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
internal string ResolveWorkerId()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(WorkerId))
|
||||
{
|
||||
return WorkerId!;
|
||||
}
|
||||
|
||||
var host = Environment.MachineName;
|
||||
return $"{host}-{Guid.NewGuid():n}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Worker.Handlers;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Processing;
|
||||
|
||||
internal sealed class NotifyEventLeaseProcessor
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Notify.Worker");
|
||||
|
||||
private readonly INotifyEventQueue _queue;
|
||||
private readonly INotifyEventHandler _handler;
|
||||
private readonly NotifyWorkerOptions _options;
|
||||
private readonly ILogger<NotifyEventLeaseProcessor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _workerId;
|
||||
|
||||
public NotifyEventLeaseProcessor(
|
||||
INotifyEventQueue queue,
|
||||
INotifyEventHandler handler,
|
||||
IOptions<NotifyWorkerOptions> options,
|
||||
ILogger<NotifyEventLeaseProcessor> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_workerId = _options.ResolveWorkerId();
|
||||
}
|
||||
|
||||
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var leaseRequest = new NotifyQueueLeaseRequest(
|
||||
consumer: _workerId,
|
||||
batchSize: Math.Max(1, _options.LeaseBatchSize),
|
||||
leaseDuration: _options.LeaseDuration <= TimeSpan.Zero ? TimeSpan.FromSeconds(30) : _options.LeaseDuration);
|
||||
|
||||
IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>> leases;
|
||||
try
|
||||
{
|
||||
leases = await _queue.LeaseAsync(leaseRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to lease Notify events.");
|
||||
throw;
|
||||
}
|
||||
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var processed = 0;
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
processed++;
|
||||
await ProcessLeaseAsync(lease, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task ProcessLeaseAsync(
|
||||
INotifyQueueLease<NotifyQueueEventMessage> lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var message = lease.Message;
|
||||
var correlationId = message.TraceId ?? message.Event.EventId.ToString("N");
|
||||
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["notifyTraceId"] = correlationId,
|
||||
["notifyTenantId"] = message.TenantId,
|
||||
["notifyEventId"] = message.Event.EventId,
|
||||
["notifyAttempt"] = lease.Attempt
|
||||
});
|
||||
|
||||
using var activity = ActivitySource.StartActivity("notify.event.process", ActivityKind.Consumer);
|
||||
activity?.SetTag("notify.tenant_id", message.TenantId);
|
||||
activity?.SetTag("notify.event_id", message.Event.EventId);
|
||||
activity?.SetTag("notify.attempt", lease.Attempt);
|
||||
activity?.SetTag("notify.worker_id", _workerId);
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing notify event {EventId} (tenant {TenantId}, attempt {Attempt}).",
|
||||
message.Event.EventId,
|
||||
message.TenantId,
|
||||
lease.Attempt);
|
||||
|
||||
await _handler.HandleAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Acknowledged notify event {EventId} (tenant {TenantId}).",
|
||||
message.Event.EventId,
|
||||
message.TenantId);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Worker cancellation requested while processing event {EventId}; returning lease to queue.",
|
||||
message.Event.EventId);
|
||||
|
||||
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to process notify event {EventId}; scheduling retry.",
|
||||
message.Event.EventId);
|
||||
|
||||
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SafeReleaseAsync(
|
||||
INotifyQueueLease<NotifyQueueEventMessage> lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await lease.ReleaseAsync(disposition, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Suppress release errors during shutdown.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Processing;
|
||||
|
||||
internal sealed class NotifyEventLeaseWorker : BackgroundService
|
||||
{
|
||||
private readonly NotifyEventLeaseProcessor _processor;
|
||||
private readonly NotifyWorkerOptions _options;
|
||||
private readonly ILogger<NotifyEventLeaseWorker> _logger;
|
||||
|
||||
public NotifyEventLeaseWorker(
|
||||
NotifyEventLeaseProcessor processor,
|
||||
IOptions<NotifyWorkerOptions> options,
|
||||
ILogger<NotifyEventLeaseWorker> logger)
|
||||
{
|
||||
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var idleDelay = _options.IdleDelay <= TimeSpan.Zero
|
||||
? TimeSpan.FromMilliseconds(500)
|
||||
: _options.IdleDelay;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
int processed;
|
||||
try
|
||||
{
|
||||
processed = await _processor.ProcessOnceAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify worker processing loop encountered an error.");
|
||||
await Task.Delay(_options.FailureBackoffDelay, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processed == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(idleDelay, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Notify/StellaOps.Notify.Worker/Program.cs
Normal file
33
src/Notify/StellaOps.Notify.Worker/Program.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Worker;
|
||||
using StellaOps.Notify.Worker.Handlers;
|
||||
using StellaOps.Notify.Worker.Processing;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables(prefix: "NOTIFY_");
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSimpleConsole(options =>
|
||||
{
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
|
||||
options.UseUtcTimestamp = true;
|
||||
});
|
||||
|
||||
builder.Services.Configure<NotifyWorkerOptions>(builder.Configuration.GetSection("notify:worker"));
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services.AddNotifyEventQueue(builder.Configuration, "notify:queue");
|
||||
builder.Services.AddNotifyDeliveryQueue(builder.Configuration, "notify:deliveryQueue");
|
||||
|
||||
builder.Services.AddSingleton<INotifyEventHandler, NoOpNotifyEventHandler>();
|
||||
builder.Services.AddSingleton<NotifyEventLeaseProcessor>();
|
||||
builder.Services.AddHostedService<NotifyEventLeaseWorker>();
|
||||
|
||||
await builder.Build().RunAsync().ConfigureAwait(false);
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Worker.Tests")]
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
2
src/Notify/StellaOps.Notify.Worker/TASKS.md
Normal file
2
src/Notify/StellaOps.Notify.Worker/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify Worker Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — worker responsibilities handled in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
43
src/Notify/StellaOps.Notify.Worker/appsettings.json
Normal file
43
src/Notify/StellaOps.Notify.Worker/appsettings.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"worker": {
|
||||
"leaseBatchSize": 16,
|
||||
"leaseDuration": "00:00:30",
|
||||
"idleDelay": "00:00:00.250",
|
||||
"maxConcurrency": 4,
|
||||
"failureBackoffThreshold": 3,
|
||||
"failureBackoffDelay": "00:00:05"
|
||||
},
|
||||
"queue": {
|
||||
"transport": "Redis",
|
||||
"redis": {
|
||||
"connectionString": "localhost:6379",
|
||||
"streams": [
|
||||
{
|
||||
"stream": "notify:events",
|
||||
"consumerGroup": "notify-workers",
|
||||
"idempotencyKeyPrefix": "notify:events:idemp:",
|
||||
"approximateMaxLength": 100000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deliveryQueue": {
|
||||
"transport": "Redis",
|
||||
"redis": {
|
||||
"connectionString": "localhost:6379",
|
||||
"streamName": "notify:deliveries",
|
||||
"consumerGroup": "notify-delivery",
|
||||
"idempotencyKeyPrefix": "notify:deliveries:idemp:",
|
||||
"deadLetterStreamName": "notify:deliveries:dead"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user