consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,16 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal interface IPolicyRunService
{
Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken);
Task<IReadOnlyList<PolicyRunStatus>> ListAsync(string tenantId, PolicyRunQueryOptions options, CancellationToken cancellationToken);
Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken);
Task<PolicyRunStatus?> RequestCancellationAsync(string tenantId, string runId, string? reason, CancellationToken cancellationToken);
Task<PolicyRunStatus> RetryAsync(string tenantId, string runId, string? requestedBy, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,260 @@
using StellaOps.Determinism;
using StellaOps.Scheduler.Models;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal sealed class InMemoryPolicyRunService : IPolicyRunService
{
private readonly ConcurrentDictionary<string, PolicyRunStatus> _runs = new(StringComparer.Ordinal);
private readonly List<PolicyRunStatus> _orderedRuns = new();
private readonly object _gate = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryPolicyRunService(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
public Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var now = _timeProvider.GetUtcNow();
var runId = string.IsNullOrWhiteSpace(request.RunId)
? GenerateRunId(request.PolicyId, request.QueuedAt ?? now)
: request.RunId;
var queuedAt = request.QueuedAt ?? now;
var status = new PolicyRunStatus(
runId,
tenantId,
request.PolicyId ?? throw new ValidationException("policyId must be provided."),
request.PolicyVersion ?? throw new ValidationException("policyVersion must be provided."),
request.Mode,
PolicyRunExecutionStatus.Queued,
request.Priority,
queuedAt,
PolicyRunStats.Empty,
request.Inputs ?? PolicyRunInputs.Empty,
null,
null,
null,
null,
null,
0,
null,
null,
request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty,
cancellationRequested: false,
cancellationRequestedAt: null,
cancellationReason: null,
SchedulerSchemaVersions.PolicyRunStatus);
lock (_gate)
{
if (_runs.TryGetValue(runId, out var existing))
{
return Task.FromResult(existing);
}
_runs[runId] = status;
_orderedRuns.Add(status);
}
return Task.FromResult(status);
}
public Task<IReadOnlyList<PolicyRunStatus>> ListAsync(string tenantId, PolicyRunQueryOptions options, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
List<PolicyRunStatus> snapshot;
lock (_gate)
{
snapshot = _orderedRuns
.Where(run => string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
.ToList();
}
if (options.PolicyId is { Length: > 0 } policyId)
{
snapshot = snapshot
.Where(run => string.Equals(run.PolicyId, policyId, StringComparison.OrdinalIgnoreCase))
.ToList();
}
if (options.Mode is { } mode)
{
snapshot = snapshot
.Where(run => run.Mode == mode)
.ToList();
}
if (options.Status is { } status)
{
snapshot = snapshot
.Where(run => run.Status == status)
.ToList();
}
if (options.QueuedAfter is { } since)
{
snapshot = snapshot
.Where(run => run.QueuedAt >= since)
.ToList();
}
var result = snapshot
.OrderByDescending(run => run.QueuedAt)
.ThenBy(run => run.RunId, StringComparer.Ordinal)
.Take(options.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<PolicyRunStatus>>(result);
}
public Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
if (!_runs.TryGetValue(runId, out var run))
{
return Task.FromResult<PolicyRunStatus?>(null);
}
if (!string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<PolicyRunStatus?>(null);
}
return Task.FromResult<PolicyRunStatus?>(run);
}
public Task<PolicyRunStatus?> RequestCancellationAsync(string tenantId, string runId, string? reason, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
PolicyRunStatus? updated;
lock (_gate)
{
if (!_runs.TryGetValue(runId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<PolicyRunStatus?>(null);
}
if (IsTerminal(existing.Status))
{
return Task.FromResult<PolicyRunStatus?>(existing);
}
var cancellationReason = NormalizeCancellationReason(reason);
var now = _timeProvider.GetUtcNow();
updated = existing with
{
Status = PolicyRunExecutionStatus.Cancelled,
FinishedAt = now,
CancellationRequested = true,
CancellationRequestedAt = now,
CancellationReason = cancellationReason
};
_runs[runId] = updated;
var index = _orderedRuns.FindIndex(status => string.Equals(status.RunId, runId, StringComparison.Ordinal));
if (index >= 0)
{
_orderedRuns[index] = updated;
}
}
return Task.FromResult<PolicyRunStatus?>(updated);
}
public async Task<PolicyRunStatus> RetryAsync(string tenantId, string runId, string? requestedBy, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
PolicyRunStatus existing;
lock (_gate)
{
if (!_runs.TryGetValue(runId, out var status) || !string.Equals(status.TenantId, tenantId, StringComparison.Ordinal))
{
throw new KeyNotFoundException($"Policy simulation {runId} was not found for tenant {tenantId}.");
}
if (!IsTerminal(status.Status))
{
throw new InvalidOperationException("Simulation is still in progress and cannot be retried.");
}
existing = status;
}
var metadataBuilder = (existing.Metadata ?? ImmutableSortedDictionary<string, string>.Empty).ToBuilder();
metadataBuilder["retry-of"] = runId;
var request = new PolicyRunRequest(
tenantId,
existing.PolicyId,
PolicyRunMode.Simulate,
existing.Inputs,
existing.Priority,
runId: null,
policyVersion: existing.PolicyVersion,
requestedBy: NormalizeActor(requestedBy),
queuedAt: _timeProvider.GetUtcNow(),
correlationId: null,
metadata: metadataBuilder.ToImmutable());
return await EnqueueAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
}
private string GenerateRunId(string policyId, DateTimeOffset timestamp)
{
var normalizedPolicyId = string.IsNullOrWhiteSpace(policyId) ? "policy" : policyId.Trim();
var suffix = _guidProvider.NewGuid().ToString("N")[..8];
return $"run:{normalizedPolicyId}:{timestamp:yyyyMMddTHHmmssZ}:{suffix}";
}
private static bool IsTerminal(PolicyRunExecutionStatus status)
=> status is PolicyRunExecutionStatus.Succeeded or PolicyRunExecutionStatus.Failed or PolicyRunExecutionStatus.Cancelled;
private static string? NormalizeCancellationReason(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
const int maxLength = 512;
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
}
private static string? NormalizeActor(string? actor)
{
if (string.IsNullOrWhiteSpace(actor))
{
return null;
}
var trimmed = actor.Trim();
const int maxLength = 256;
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
}
}

View File

@@ -0,0 +1,209 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.WebService.Auth;
using StellaOps.Scheduler.WebService.Security;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal static class PolicyRunEndpointExtensions
{
private const string Scope = StellaOpsScopes.PolicyRun;
public static void MapPolicyRunEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/scheduler/policy/runs")
.RequireAuthorization(SchedulerPolicies.Read)
.RequireTenant();
group.MapGet("/", ListPolicyRunsAsync)
.WithName("ListPolicyRuns")
.WithDescription("Lists policy run records for the tenant with optional filters by status, mode, and time range. Returns a paginated collection ordered by queue time. Requires policy.run scope.");
group.MapGet("/{runId}", GetPolicyRunAsync)
.WithName("GetPolicyRun")
.WithDescription("Returns the full policy run record for a specific run ID including status, policy reference, inputs, and verdict counts. Returns 404 if the run ID is not found. Requires policy.run scope.");
group.MapPost("/", CreatePolicyRunAsync)
.WithName("CreatePolicyRun")
.WithDescription("Enqueues a new policy evaluation run for the specified policy ID and version. Returns 201 Created with the run ID and initial queued status. Requires policy.run scope.")
.RequireAuthorization(SchedulerPolicies.Operate);
}
internal static async Task<IResult> ListPolicyRunsAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IPolicyRunService policyRunService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, Scope);
var tenant = tenantAccessor.GetTenant(httpContext);
var options = PolicyRunQueryOptions.FromRequest(httpContext.Request);
var runs = await policyRunService
.ListAsync(tenant.TenantId, options, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new PolicyRunCollectionResponse(runs));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
internal static async Task<IResult> GetPolicyRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IPolicyRunService policyRunService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, Scope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await policyRunService
.GetAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
return run is null
? Results.NotFound()
: Results.Ok(new PolicyRunResponse(run));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
internal static async Task<IResult> CreatePolicyRunAsync(
HttpContext httpContext,
PolicyRunCreateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IPolicyRunService policyRunService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, Scope);
var tenant = tenantAccessor.GetTenant(httpContext);
var actorId = SchedulerEndpointHelpers.ResolveActorId(httpContext);
var now = timeProvider.GetUtcNow();
if (request.PolicyVersion is null || request.PolicyVersion <= 0)
{
throw new ValidationException("policyVersion must be provided and greater than zero.");
}
if (string.IsNullOrWhiteSpace(request.PolicyId))
{
throw new ValidationException("policyId must be provided.");
}
var normalizedMetadata = NormalizeMetadata(request.Metadata);
var policyRunRequest = new PolicyRunRequest(
tenant.TenantId,
request.PolicyId,
request.PolicyVersion,
request.Mode,
request.Priority,
request.RunId,
now,
actorId,
request.CorrelationId,
normalizedMetadata,
request.Inputs ?? PolicyRunInputs.Empty,
request.SchemaVersion);
var status = await policyRunService
.EnqueueAsync(tenant.TenantId, policyRunRequest, cancellationToken)
.ConfigureAwait(false);
return Results.Created($"/api/v1/scheduler/policy/runs/{status.RunId}", new PolicyRunResponse(status));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
internal sealed record PolicyRunCollectionResponse(
[property: JsonPropertyName("runs")] IReadOnlyList<PolicyRunStatus> Runs);
internal sealed record PolicyRunResponse(
[property: JsonPropertyName("run")] PolicyRunStatus Run);
internal sealed record PolicyRunCreateRequest(
[property: JsonPropertyName("schemaVersion")] string? SchemaVersion,
[property: JsonPropertyName("policyId")] string PolicyId,
[property: JsonPropertyName("policyVersion")] int? PolicyVersion,
[property: JsonPropertyName("mode")] PolicyRunMode Mode = PolicyRunMode.Incremental,
[property: JsonPropertyName("priority")] PolicyRunPriority Priority = PolicyRunPriority.Normal,
[property: JsonPropertyName("runId")] string? RunId = null,
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata = null,
[property: JsonPropertyName("inputs")] PolicyRunInputs? Inputs = null);
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableSortedDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = pair.Key?.Trim();
var value = pair.Value?.Trim();
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value))
{
continue;
}
var normalizedKey = key.ToLowerInvariant();
if (!builder.ContainsKey(normalizedKey))
{
builder[normalizedKey] = value;
}
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using StellaOps.Scheduler.Models;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal sealed class PolicyRunQueryOptions
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;
private PolicyRunQueryOptions()
{
}
public string? PolicyId { get; private set; }
public PolicyRunMode? Mode { get; private set; }
public PolicyRunExecutionStatus? Status { get; private set; }
public DateTimeOffset? QueuedAfter { get; private set; }
public int Limit { get; private set; } = DefaultLimit;
public PolicyRunQueryOptions ForceMode(PolicyRunMode mode)
{
Mode = mode;
return this;
}
public static PolicyRunQueryOptions FromRequest(HttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var options = new PolicyRunQueryOptions();
var query = request.Query;
if (query.TryGetValue("policyId", out var policyValues))
{
var policyId = policyValues.ToString().Trim();
if (!string.IsNullOrEmpty(policyId))
{
options.PolicyId = policyId;
}
}
options.Mode = ParseEnum<PolicyRunMode>(query, "mode");
options.Status = ParseEnum<PolicyRunExecutionStatus>(query, "status");
options.QueuedAfter = ParseTimestamp(query);
options.Limit = ParseLimit(query);
return options;
}
private static TEnum? ParseEnum<TEnum>(IQueryCollection query, string key)
where TEnum : struct, Enum
{
if (!query.TryGetValue(key, out var values) || values == StringValues.Empty)
{
return null;
}
var value = values.ToString().Trim();
if (string.IsNullOrEmpty(value))
{
return null;
}
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
throw new ValidationException($"Value '{value}' is not valid for parameter '{key}'.");
}
private static DateTimeOffset? ParseTimestamp(IQueryCollection query)
{
if (!query.TryGetValue("since", out var values) || values == StringValues.Empty)
{
return null;
}
var candidate = values.ToString().Trim();
if (string.IsNullOrEmpty(candidate))
{
return null;
}
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp))
{
return timestamp.ToUniversalTime();
}
throw new ValidationException($"Value '{candidate}' is not a valid ISO-8601 timestamp.");
}
private static int ParseLimit(IQueryCollection query)
{
if (!query.TryGetValue("limit", out var values) || values == StringValues.Empty)
{
return DefaultLimit;
}
var candidate = values.ToString().Trim();
if (string.IsNullOrEmpty(candidate))
{
return DefaultLimit;
}
if (!int.TryParse(candidate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
{
throw new ValidationException("Parameter 'limit' must be a positive integer.");
}
if (parsed > MaxLimit)
{
throw new ValidationException($"Parameter 'limit' must not exceed {MaxLimit}.");
}
return parsed;
}
}

View File

@@ -0,0 +1,301 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
using StellaOps.Scheduler.WebService;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal sealed class PolicyRunService : IPolicyRunService
{
private readonly IPolicyRunJobRepository _repository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicyRunService> _logger;
public PolicyRunService(
IPolicyRunJobRepository repository,
TimeProvider timeProvider,
ILogger<PolicyRunService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var now = _timeProvider.GetUtcNow();
var runId = string.IsNullOrWhiteSpace(request.RunId)
? GenerateRunId(request.PolicyId, now)
: request.RunId!;
// Idempotency: return existing job if present when a runId was supplied.
if (!string.IsNullOrWhiteSpace(request.RunId))
{
var existing = await _repository
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
_logger.LogDebug("Policy run job already exists for tenant {TenantId} and run {RunId}.", tenantId, runId);
return PolicyRunStatusFactory.Create(existing, now);
}
}
var jobId = SchedulerEndpointHelpers.GenerateIdentifier("policyjob");
var queuedAt = request.QueuedAt ?? now;
var metadata = request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
var job = new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: jobId,
TenantId: tenantId,
PolicyId: request.PolicyId,
PolicyVersion: request.PolicyVersion,
Mode: request.Mode,
Priority: request.Priority,
PriorityRank: -1,
RunId: runId,
RequestedBy: request.RequestedBy,
CorrelationId: request.CorrelationId,
Metadata: metadata,
Inputs: request.Inputs ?? PolicyRunInputs.Empty,
QueuedAt: queuedAt,
Status: PolicyRunJobStatus.Pending,
AttemptCount: 0,
LastAttemptAt: null,
LastError: null,
CreatedAt: now,
UpdatedAt: now,
AvailableAt: now,
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
await _repository.InsertAsync(job, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Enqueued policy run job {JobId} for tenant {TenantId} policy {PolicyId} (runId={RunId}, mode={Mode}).",
job.Id,
tenantId,
job.PolicyId,
job.RunId,
job.Mode);
return PolicyRunStatusFactory.Create(job, now);
}
public async Task<IReadOnlyList<PolicyRunStatus>> ListAsync(
string tenantId,
PolicyRunQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var statuses = options.Status is null
? null
: MapExecutionStatus(options.Status.Value);
var jobs = await _repository
.ListAsync(
tenantId,
options.PolicyId,
options.Mode,
statuses,
options.QueuedAfter,
options.Limit,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
return jobs
.Select(job => PolicyRunStatusFactory.Create(job, now))
.ToList();
}
public async Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
var job = await _repository
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (job is null)
{
return null;
}
var now = _timeProvider.GetUtcNow();
return PolicyRunStatusFactory.Create(job, now);
}
public async Task<PolicyRunStatus?> RequestCancellationAsync(
string tenantId,
string runId,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
var job = await _repository
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (job is null)
{
return null;
}
var now = _timeProvider.GetUtcNow();
if (IsTerminal(job.Status))
{
return PolicyRunStatusFactory.Create(job, now);
}
if (job.CancellationRequested && string.Equals(job.CancellationReason, reason, StringComparison.Ordinal))
{
return PolicyRunStatusFactory.Create(job, now);
}
var updated = job with
{
CancellationRequested = true,
CancellationRequestedAt = now,
CancellationReason = NormalizeCancellationReason(reason),
UpdatedAt = now,
AvailableAt = now
};
var replaced = await _repository
.ReplaceAsync(updated, expectedLeaseOwner: job.LeaseOwner, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (!replaced)
{
_logger.LogWarning(
"Failed to persist cancellation request for policy run job {JobId} (runId={RunId}).",
job.Id,
job.RunId ?? "(pending)");
return PolicyRunStatusFactory.Create(job, now);
}
_logger.LogInformation(
"Cancellation requested for policy run job {JobId} (runId={RunId}, reason={Reason}).",
updated.Id,
updated.RunId ?? "(pending)",
updated.CancellationReason ?? "none");
return PolicyRunStatusFactory.Create(updated, now);
}
public async Task<PolicyRunStatus> RetryAsync(
string tenantId,
string runId,
string? requestedBy,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
var job = await _repository
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
.ConfigureAwait(false)
?? throw new KeyNotFoundException($"Policy simulation {runId} was not found for tenant {tenantId}.");
if (job.Mode != PolicyRunMode.Simulate)
{
throw new InvalidOperationException("Only simulation runs can be retried through this endpoint.");
}
if (!IsTerminal(job.Status))
{
throw new InvalidOperationException("Simulation is still in progress and cannot be retried.");
}
var now = _timeProvider.GetUtcNow();
var metadataBuilder = (job.Metadata ?? ImmutableSortedDictionary<string, string>.Empty).ToBuilder();
metadataBuilder["retry-of"] = runId;
var request = new PolicyRunRequest(
tenantId,
job.PolicyId,
PolicyRunMode.Simulate,
job.Inputs ?? PolicyRunInputs.Empty,
job.Priority,
runId: null,
policyVersion: job.PolicyVersion,
requestedBy: NormalizeActor(requestedBy),
queuedAt: now,
correlationId: job.CorrelationId,
metadata: metadataBuilder.ToImmutable());
return await EnqueueAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
}
private static IReadOnlyCollection<PolicyRunJobStatus>? MapExecutionStatus(PolicyRunExecutionStatus status)
=> status switch
{
PolicyRunExecutionStatus.Queued => new[] { PolicyRunJobStatus.Pending },
PolicyRunExecutionStatus.Running => new[] { PolicyRunJobStatus.Dispatching, PolicyRunJobStatus.Submitted },
PolicyRunExecutionStatus.Succeeded => new[] { PolicyRunJobStatus.Completed },
PolicyRunExecutionStatus.Failed => new[] { PolicyRunJobStatus.Failed },
PolicyRunExecutionStatus.Cancelled => new[] { PolicyRunJobStatus.Cancelled },
PolicyRunExecutionStatus.ReplayPending => Array.Empty<PolicyRunJobStatus>(),
_ => null
};
private static string GenerateRunId(string policyId, DateTimeOffset timestamp)
{
var normalizedPolicyId = string.IsNullOrWhiteSpace(policyId) ? "policy" : policyId.Trim();
var suffix = Guid.NewGuid().ToString("N")[..8];
return $"run:{normalizedPolicyId}:{timestamp:yyyyMMddTHHmmssZ}:{suffix}";
}
private static bool IsTerminal(PolicyRunJobStatus status)
=> status is PolicyRunJobStatus.Completed or PolicyRunJobStatus.Failed or PolicyRunJobStatus.Cancelled;
private static string? NormalizeCancellationReason(string? reason)
{
if (string.IsNullOrWhiteSpace(reason))
{
return null;
}
var trimmed = reason.Trim();
const int maxLength = 512;
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
}
private static string? NormalizeActor(string? actor)
{
if (string.IsNullOrWhiteSpace(actor))
{
return null;
}
var trimmed = actor.Trim();
const int maxLength = 256;
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
}
}