Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/Notifications/AzureQueueNotificationDispatchProducer.cs
T
Oliver Kieselbach 86fdc5aca9 backend: stateless queue consumers become [QueueTrigger] functions; webhooks go through a queue; poll loop receives one message at a time
Audit 2026-09-05 F01/F02. Analyze, VulnerabilityCorrelate, TenantAutoApprove,
IndexReconcile and ImeMsiArchive are now host-managed queue functions under
Functions/Queue bound to the DataStorage connection (identity-based, resolved
by the host): the Flex scale controller wakes an instance for a message, the
host renews the lease and owns the retry/poison ladder. The four cascade
workers with lease/CAS state stay self-polling BackgroundServices; their base
now receives one message per poll so waiting messages can no longer expire
under another worker. Terminal enrollment alerts and hardware-rejection alerts
are enqueued on notification-dispatch (channel ids only, resolved at send
time) instead of fire-and-forget sends the host could drop on scale-in.
Requires the app setting DataStorage__queueServiceUri before deploy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 02:52:37 +02:00

90 lines
3.8 KiB
C#

using System;
using System.Threading;
using System.Threading.Tasks;
using Azure.Storage.Queues;
using AutopilotMonitor.Functions.Services.Queueing;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Azure Storage Queue implementation of <see cref="INotificationDispatchProducer"/>. Mirrors
/// <see cref="Analyze.AzureQueueAnalyzeOnEnrollmentEndProducer"/>: Managed Identity preferred,
/// connection-string fallback, Base64 encoding (matches the host's queue trigger default).
/// <para>
/// <b>Fault tolerance:</b> queue-side exceptions are logged but never rethrown — the agent's
/// HTTP 200 must not be blocked. A missed enqueue means the alert is not delivered; the
/// warning names tenant and event type so an operator can see it.
/// </para>
/// </summary>
public sealed class AzureQueueNotificationDispatchProducer : INotificationDispatchProducer
{
private readonly QueueClient _queueClient;
private readonly ILogger<AzureQueueNotificationDispatchProducer> _logger;
private int _queueEnsured; // 0 = not yet ensured, 1 = CreateIfNotExistsAsync has run
public AzureQueueNotificationDispatchProducer(
QueueClientFactory queueFactory,
ILogger<AzureQueueNotificationDispatchProducer> logger)
{
_logger = logger;
_queueClient = queueFactory.Create(Constants.QueueNames.NotificationDispatch);
}
/// <summary>Test seam: construct directly with a mock <see cref="QueueClient"/>.</summary>
internal AzureQueueNotificationDispatchProducer(
QueueClient queueClient,
ILogger<AzureQueueNotificationDispatchProducer> logger)
{
_queueClient = queueClient;
_logger = logger;
}
public async Task EnqueueAsync(NotificationDispatchEnvelope envelope, CancellationToken cancellationToken = default)
{
if (envelope is null) return;
if (string.IsNullOrEmpty(envelope.TenantId) || envelope.Alert is null || envelope.ChannelIds.Count == 0)
{
_logger.LogWarning(
"NotificationDispatch enqueue skipped — missing TenantId/Alert/ChannelIds (tenant={Tenant} event={EventType})",
envelope.TenantId, envelope.Alert?.EventType);
return;
}
await EnsureQueueExistsAsync(cancellationToken).ConfigureAwait(false);
try
{
var body = JsonConvert.SerializeObject(envelope);
await _queueClient.SendMessageAsync(body, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"NotificationDispatch enqueue failed (tenant={Tenant} session={Session} event={EventType} channels={Channels}) — alert not delivered",
envelope.TenantId, envelope.SessionId, envelope.Alert.EventType, envelope.ChannelIds.Count);
}
}
private async Task EnsureQueueExistsAsync(CancellationToken cancellationToken)
{
if (_queueEnsured == 1) return;
try
{
await _queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
Interlocked.Exchange(ref _queueEnsured, 1);
}
catch (Exception ex)
{
// Leave _queueEnsured=0 so a later call retries.
_logger.LogWarning(ex, "NotificationDispatch queue CreateIfNotExists failed — will retry next enqueue");
}
}
}
}