up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-01 21:16:22 +02:00
parent c11d87d252
commit 909d9b6220
208 changed files with 860954 additions and 832 deletions

View File

@@ -0,0 +1,127 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Workers;
/// <summary>
/// Executes activation/expiry flows for exceptions and emits lifecycle events.
/// Split from the hosted worker for testability.
/// </summary>
internal sealed class ExceptionLifecycleService
{
private readonly IExceptionRepository _repository;
private readonly IExceptionEventPublisher _publisher;
private readonly IOptions<PolicyEngineOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionLifecycleService> _logger;
public ExceptionLifecycleService(
IExceptionRepository repository,
IExceptionEventPublisher publisher,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider,
ILogger<ExceptionLifecycleService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ProcessOnceAsync(CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var lifecycle = _options.Value.ExceptionLifecycle;
var pendingActivations = await _repository
.ListExceptionsAsync(new ExceptionQueryOptions
{
Statuses = ImmutableArray.Create("approved"),
}, cancellationToken)
.ConfigureAwait(false);
pendingActivations = pendingActivations
.Where(ex => ex.EffectiveFrom is null || ex.EffectiveFrom <= now)
.Take(lifecycle.MaxBatchSize)
.ToImmutableArray();
foreach (var ex in pendingActivations)
{
var activated = await _repository.UpdateExceptionStatusAsync(
ex.TenantId, ex.Id, "active", now, cancellationToken).ConfigureAwait(false);
if (!activated)
{
continue;
}
PolicyEngineTelemetry.RecordExceptionLifecycle(ex.TenantId, "activated");
await _publisher.PublishAsync(new ExceptionEvent
{
EventType = "activated",
TenantId = ex.TenantId,
ExceptionId = ex.Id,
ExceptionName = ex.Name,
ExceptionType = ex.ExceptionType,
OccurredAt = now,
}, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Activated exception {ExceptionId} for tenant {TenantId} (effective from {EffectiveFrom:o})",
ex.Id,
ex.TenantId,
ex.EffectiveFrom);
}
var expiryWindowStart = now - lifecycle.ExpiryLookback;
var expiryWindowEnd = now + lifecycle.ExpiryHorizon;
var expiring = await _repository
.ListExceptionsAsync(new ExceptionQueryOptions
{
Statuses = ImmutableArray.Create("active"),
}, cancellationToken)
.ConfigureAwait(false);
expiring = expiring
.Where(ex => ex.ExpiresAt is not null && ex.ExpiresAt >= expiryWindowStart && ex.ExpiresAt <= expiryWindowEnd)
.Take(lifecycle.MaxBatchSize)
.ToImmutableArray();
foreach (var ex in expiring)
{
var expired = await _repository.UpdateExceptionStatusAsync(
ex.TenantId, ex.Id, "expired", now, cancellationToken).ConfigureAwait(false);
if (!expired)
{
continue;
}
PolicyEngineTelemetry.RecordExceptionLifecycle(ex.TenantId, "expired");
await _publisher.PublishAsync(new ExceptionEvent
{
EventType = "expired",
TenantId = ex.TenantId,
ExceptionId = ex.Id,
ExceptionName = ex.Name,
ExceptionType = ex.ExceptionType,
OccurredAt = now,
}, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Expired exception {ExceptionId} for tenant {TenantId} at {ExpiresAt:o}",
ex.Id,
ex.TenantId,
ex.ExpiresAt);
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Workers;
/// <summary>
/// Hosted service that periodically runs exception activation/expiry checks.
/// </summary>
internal sealed class ExceptionLifecycleWorker : BackgroundService
{
private readonly ExceptionLifecycleService _service;
private readonly PolicyEngineExceptionLifecycleOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionLifecycleWorker> _logger;
public ExceptionLifecycleWorker(
ExceptionLifecycleService service,
PolicyEngineExceptionLifecycleOptions options,
TimeProvider timeProvider,
ILogger<ExceptionLifecycleWorker> logger)
{
_service = service ?? throw new ArgumentNullException(nameof(service));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting exception lifecycle worker (interval {Interval}s)", _options.PollIntervalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _service.ProcessOnceAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception lifecycle worker iteration failed");
}
await Task.Delay(_options.PollInterval, stoppingToken).ConfigureAwait(false);
}
}
}