T4: Add BatchExceptionLoader for batch evaluation optimization

- Add IBatchExceptionLoader interface with PreLoadExceptionsAsync, GetExceptionsAsync, ClearBatchCache
- Add BatchExceptionLoader using ConcurrentDictionary for batch-level caching
- Add BatchExceptionLoaderOptions with EagerLoadThreshold and EnablePreWarming
- Add AddBatchExceptionLoader DI extension in PolicyEngineServiceCollectionExtensions
- Fix missing System.Collections.Immutable using in ExceptionAwareEvaluationService

Sprint: 3900.0002.0001
This commit is contained in:
StellaOps Bot
2025-12-21 10:48:55 +02:00
parent 01a2a2dc16
commit 1e0e61659f
3 changed files with 188 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
// <copyright file="BatchExceptionLoader.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Adapters;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.BatchEvaluation;
/// <summary>
/// Options for batch exception loading.
/// </summary>
public sealed class BatchExceptionLoaderOptions
{
/// <summary>
/// Maximum number of items in a batch before forcing eager load. Default: 10.
/// </summary>
public int EagerLoadThreshold { get; set; } = 10;
/// <summary>
/// Whether to pre-warm the cache for all tenants in the batch. Default: true.
/// </summary>
public bool EnablePreWarming { get; set; } = true;
}
/// <summary>
/// Interface for batch-optimized exception loading.
/// </summary>
internal interface IBatchExceptionLoader
{
/// <summary>
/// Pre-loads exceptions for all tenants in a batch to minimize database round-trips.
/// </summary>
/// <param name="tenantIds">Distinct tenant IDs from the batch.</param>
/// <param name="asOf">Point in time for expiry filtering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PreLoadExceptionsAsync(
IEnumerable<Guid> tenantIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exceptions for a tenant, using pre-loaded cache if available.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="asOf">Point in time for expiry filtering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Policy evaluation exceptions for the tenant.</returns>
Task<PolicyEvaluationExceptions> GetExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Clears the batch-level cache after processing is complete.
/// </summary>
void ClearBatchCache();
}
/// <summary>
/// Batch-optimized exception loader that pre-warms the cache and provides
/// memory-efficient access to exceptions across batch evaluation items.
/// </summary>
internal sealed class BatchExceptionLoader : IBatchExceptionLoader
{
private readonly IExceptionAdapter _adapter;
private readonly BatchExceptionLoaderOptions _options;
private readonly ILogger<BatchExceptionLoader> _logger;
// Batch-level cache to avoid repeated dictionary lookups during batch processing
private readonly ConcurrentDictionary<Guid, PolicyEvaluationExceptions> _batchCache = new();
public BatchExceptionLoader(
IExceptionAdapter adapter,
Microsoft.Extensions.Options.IOptions<BatchExceptionLoaderOptions> options,
ILogger<BatchExceptionLoader> logger)
{
_adapter = adapter ?? throw new ArgumentNullException(nameof(adapter));
_options = options?.Value ?? new BatchExceptionLoaderOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task PreLoadExceptionsAsync(
IEnumerable<Guid> tenantIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantIds);
var distinctTenants = tenantIds.Distinct().ToList();
if (!_options.EnablePreWarming || distinctTenants.Count < _options.EagerLoadThreshold)
{
_logger.LogDebug(
"Skipping pre-warm: EnablePreWarming={Enabled}, TenantCount={Count}, Threshold={Threshold}",
_options.EnablePreWarming,
distinctTenants.Count,
_options.EagerLoadThreshold);
return;
}
_logger.LogDebug("Pre-loading exceptions for {Count} tenants", distinctTenants.Count);
// Load exceptions for all tenants concurrently, but limit parallelism
await Parallel.ForEachAsync(
distinctTenants,
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Min(4, distinctTenants.Count),
CancellationToken = cancellationToken
},
async (tenantId, ct) =>
{
try
{
var exceptions = await _adapter.LoadExceptionsAsync(tenantId, asOf, ct)
.ConfigureAwait(false);
// Store in batch cache for fast lookup during evaluation
_batchCache.TryAdd(tenantId, exceptions);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to pre-load exceptions for tenant {TenantId}", tenantId);
// Continue with other tenants; this tenant will load on-demand
}
}).ConfigureAwait(false);
_logger.LogDebug("Pre-loaded exceptions for {Count} tenants into batch cache", _batchCache.Count);
}
/// <inheritdoc />
public async Task<PolicyEvaluationExceptions> GetExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
// Check batch cache first (populated by PreLoadExceptionsAsync)
if (_batchCache.TryGetValue(tenantId, out var cached))
{
return cached;
}
// Fall back to adapter (which has its own caching)
var exceptions = await _adapter.LoadExceptionsAsync(tenantId, asOf, cancellationToken)
.ConfigureAwait(false);
// Add to batch cache for potential subsequent lookups in same batch
_batchCache.TryAdd(tenantId, exceptions);
return exceptions;
}
/// <inheritdoc />
public void ClearBatchCache()
{
var count = _batchCache.Count;
_batchCache.Clear();
_logger.LogDebug("Cleared batch cache with {Count} entries", count);
}
}

View File

@@ -318,4 +318,26 @@ public static class PolicyEngineServiceCollectionExtensions
return services; return services;
} }
/// <summary>
/// Adds batch exception loader services for optimized batch evaluation scenarios.
/// Pre-warms exception cache when evaluating multiple findings in a single batch.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Optional configuration for batch exception loader options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddBatchExceptionLoader(
this IServiceCollection services,
Action<BatchEvaluation.BatchExceptionLoaderOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
// Register the batch exception loader (transient - per-request batch scope)
services.TryAddTransient<BatchEvaluation.IBatchExceptionLoader, BatchEvaluation.BatchExceptionLoader>();
return services;
}
} }

View File

@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Adapters; using StellaOps.Policy.Engine.Adapters;
using StellaOps.Policy.Engine.Evaluation; using StellaOps.Policy.Engine.Evaluation;