Files
git.stella-ops.org/src/StellaOps.Bench/Notify/StellaOps.Bench.Notify/NotifyScenarioRunner.cs
master 96d52884e8
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add Policy DSL Validator, Schema Exporter, and Simulation Smoke tools
- Implemented PolicyDslValidator with command-line options for strict mode and JSON output.
- Created PolicySchemaExporter to generate JSON schemas for policy-related models.
- Developed PolicySimulationSmoke tool to validate policy simulations against expected outcomes.
- Added project files and necessary dependencies for each tool.
- Ensured proper error handling and usage instructions across tools.
2025-10-27 08:00:11 +02:00

387 lines
12 KiB
C#

using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using StellaOps.Notify.Models;
namespace StellaOps.Bench.Notify;
internal sealed class NotifyScenarioRunner
{
private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 26, 0, 0, 0, TimeSpan.Zero);
private const string EventKind = NotifyEventKinds.ScannerReportReady;
private readonly NotifyScenarioConfig _config;
private readonly EventDescriptor[] _events;
private readonly RuleDescriptor[][] _rulesByTenant;
private readonly int _totalEvents;
private readonly int _ruleCount;
private readonly int _actionsPerRule;
private readonly int _totalMatches;
private readonly int _totalDeliveries;
private readonly double _averageMatchesPerEvent;
private readonly double _averageDeliveriesPerEvent;
private readonly int _minMatchesPerEvent;
private readonly int _maxMatchesPerEvent;
public NotifyScenarioRunner(NotifyScenarioConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
var eventCount = config.ResolveEventCount();
var ruleCount = config.ResolveRuleCount();
var actionsPerRule = config.ResolveActionsPerRule();
var matchRate = config.ResolveMatchRate();
var tenantCount = config.ResolveTenantCount();
var channelCount = config.ResolveChannelCount();
var seed = config.ResolveSeed();
if (tenantCount > ruleCount)
{
tenantCount = Math.Max(1, ruleCount);
}
_totalEvents = eventCount;
_ruleCount = ruleCount;
_actionsPerRule = actionsPerRule;
var tenants = BuildTenants(tenantCount);
var channels = BuildChannels(channelCount);
var random = new Random(seed);
var targetMatchesPerEvent = Math.Max(1, (int)Math.Round(ruleCount * matchRate));
targetMatchesPerEvent = Math.Min(targetMatchesPerEvent, ruleCount);
var ruleDescriptors = new List<RuleDescriptor>(ruleCount);
var groups = new List<RuleGroup>();
var ruleIndex = 0;
var groupIndex = 0;
var channelCursor = 0;
while (ruleIndex < ruleCount)
{
var groupSize = Math.Min(targetMatchesPerEvent, ruleCount - ruleIndex);
var tenantIndex = groupIndex % tenantCount;
var tenantId = tenants[tenantIndex];
var namespaceValue = $"svc-{tenantIndex:D2}-{groupIndex:D3}";
var repositoryValue = $"registry.local/{tenantId}/service-{groupIndex:D3}";
var digestValue = GenerateDigest(random, groupIndex);
var rules = new RuleDescriptor[groupSize];
for (var local = 0; local < groupSize && ruleIndex < ruleCount; local++, ruleIndex++)
{
var ruleId = $"rule-{groupIndex:D3}-{local:D3}";
var actions = new ActionDescriptor[actionsPerRule];
for (var actionIndex = 0; actionIndex < actionsPerRule; actionIndex++)
{
var channel = channels[channelCursor % channelCount];
channelCursor++;
var actionId = $"{ruleId}-act-{actionIndex:D2}";
actions[actionIndex] = new ActionDescriptor(
actionId,
channel,
StableHash($"{actionId}|{channel}"));
}
rules[local] = new RuleDescriptor(
ruleId,
StableHash(ruleId),
tenantIndex,
namespaceValue,
repositoryValue,
digestValue,
actions);
ruleDescriptors.Add(rules[local]);
}
groups.Add(new RuleGroup(tenantIndex, namespaceValue, repositoryValue, digestValue, rules));
groupIndex++;
}
_rulesByTenant = BuildRulesByTenant(tenantCount, ruleDescriptors);
var events = new EventDescriptor[eventCount];
long totalMatches = 0;
var minMatches = int.MaxValue;
var maxMatches = 0;
for (var eventIndex = 0; eventIndex < eventCount; eventIndex++)
{
var group = groups[eventIndex % groups.Count];
var matchingRules = group.Rules.Length;
totalMatches += matchingRules;
if (matchingRules < minMatches)
{
minMatches = matchingRules;
}
if (matchingRules > maxMatches)
{
maxMatches = matchingRules;
}
var eventId = GenerateEventId(random, group.TenantIndex, eventIndex);
var timestamp = BaseTimestamp.AddMilliseconds(eventIndex * 10d);
// Materialize NotifyEvent to reflect production payload shape.
_ = NotifyEvent.Create(
eventId,
EventKind,
tenants[group.TenantIndex],
timestamp,
payload: null,
scope: NotifyEventScope.Create(
@namespace: group.Namespace,
repo: group.Repository,
digest: group.Digest));
events[eventIndex] = new EventDescriptor(
group.TenantIndex,
EventKind,
group.Namespace,
group.Repository,
group.Digest,
ComputeEventHash(eventId));
}
_events = events;
_totalMatches = checked((int)totalMatches);
_totalDeliveries = checked(_totalMatches * actionsPerRule);
_averageMatchesPerEvent = totalMatches / (double)eventCount;
_averageDeliveriesPerEvent = _averageMatchesPerEvent * actionsPerRule;
_minMatchesPerEvent = minMatches;
_maxMatchesPerEvent = maxMatches;
}
public ScenarioExecutionResult Execute(int iterations, CancellationToken cancellationToken)
{
if (iterations <= 0)
{
throw new ArgumentOutOfRangeException(nameof(iterations), iterations, "Iterations must be positive.");
}
var durations = new double[iterations];
var throughputs = new double[iterations];
var allocations = new double[iterations];
for (var index = 0; index < iterations; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var beforeAllocated = GC.GetTotalAllocatedBytes();
var stopwatch = Stopwatch.StartNew();
var accumulator = new DispatchAccumulator();
var observedMatches = 0;
var observedDeliveries = 0;
foreach (ref readonly var @event in _events.AsSpan())
{
var tenantRules = _rulesByTenant[@event.TenantIndex];
foreach (var rule in tenantRules)
{
if (!Matches(rule, @event))
{
continue;
}
observedMatches++;
var actions = rule.Actions;
for (var actionIndex = 0; actionIndex < actions.Length; actionIndex++)
{
observedDeliveries++;
accumulator.Add(rule.RuleHash, actions[actionIndex].Hash, @event.EventHash);
}
}
}
stopwatch.Stop();
if (observedMatches != _totalMatches)
{
throw new InvalidOperationException($"Scenario '{_config.ScenarioId}' expected {_totalMatches} matches but observed {observedMatches}.");
}
if (observedDeliveries != _totalDeliveries)
{
throw new InvalidOperationException($"Scenario '{_config.ScenarioId}' expected {_totalDeliveries} deliveries but observed {observedDeliveries}.");
}
accumulator.AssertConsumed();
var elapsedMs = stopwatch.Elapsed.TotalMilliseconds;
if (elapsedMs <= 0d)
{
elapsedMs = 0.0001d;
}
var afterAllocated = GC.GetTotalAllocatedBytes();
durations[index] = elapsedMs;
throughputs[index] = observedDeliveries / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.0001d);
allocations[index] = Math.Max(0, afterAllocated - beforeAllocated) / (1024d * 1024d);
}
return new ScenarioExecutionResult(
durations,
throughputs,
allocations,
_totalEvents,
_ruleCount,
_actionsPerRule,
_averageMatchesPerEvent,
_minMatchesPerEvent,
_maxMatchesPerEvent,
_averageDeliveriesPerEvent,
_totalMatches,
_totalDeliveries);
}
private static bool Matches(in RuleDescriptor rule, in EventDescriptor @event)
{
if (!string.Equals(@event.Kind, EventKind, StringComparison.Ordinal))
{
return false;
}
if (!string.Equals(rule.Namespace, @event.Namespace, StringComparison.Ordinal))
{
return false;
}
if (!string.Equals(rule.Repository, @event.Repository, StringComparison.Ordinal))
{
return false;
}
if (!string.Equals(rule.Digest, @event.Digest, StringComparison.Ordinal))
{
return false;
}
return true;
}
private static int ComputeEventHash(Guid eventId)
{
var bytes = eventId.ToByteArray();
var value = BitConverter.ToInt32(bytes, 0);
return value & int.MaxValue;
}
private static string GenerateDigest(Random random, int groupIndex)
{
var buffer = new byte[16];
random.NextBytes(buffer);
var hex = Convert.ToHexString(buffer).ToLowerInvariant();
return $"sha256:{hex}{groupIndex:D3}";
}
private static Guid GenerateEventId(Random random, int tenantIndex, int eventIndex)
{
Span<byte> buffer = stackalloc byte[16];
random.NextBytes(buffer);
buffer[^1] = (byte)(tenantIndex & 0xFF);
buffer[^2] = (byte)(eventIndex & 0xFF);
return new Guid(buffer);
}
private static RuleDescriptor[][] BuildRulesByTenant(int tenantCount, List<RuleDescriptor> rules)
{
var result = new RuleDescriptor[tenantCount][];
for (var tenantIndex = 0; tenantIndex < tenantCount; tenantIndex++)
{
result[tenantIndex] = rules
.Where(rule => rule.TenantIndex == tenantIndex)
.ToArray();
}
return result;
}
private static string[] BuildTenants(int tenantCount)
{
var tenants = new string[tenantCount];
for (var index = 0; index < tenantCount; index++)
{
tenants[index] = $"tenant-{index:D2}";
}
return tenants;
}
private static string[] BuildChannels(int channelCount)
{
var channels = new string[channelCount];
for (var index = 0; index < channelCount; index++)
{
var kind = (index % 4) switch
{
0 => "slack",
1 => "teams",
2 => "email",
_ => "webhook"
};
channels[index] = $"{kind}:channel-{index:D2}";
}
return channels;
}
private static int StableHash(string value)
{
unchecked
{
const int offset = unchecked((int)2166136261);
const int prime = 16777619;
var hash = offset;
foreach (var ch in value.AsSpan())
{
hash ^= ch;
hash *= prime;
}
return hash & int.MaxValue;
}
}
private readonly record struct RuleDescriptor(
string RuleId,
int RuleHash,
int TenantIndex,
string Namespace,
string Repository,
string Digest,
ActionDescriptor[] Actions);
private readonly record struct ActionDescriptor(
string ActionId,
string Channel,
int Hash);
private readonly record struct RuleGroup(
int TenantIndex,
string Namespace,
string Repository,
string Digest,
RuleDescriptor[] Rules);
private readonly record struct EventDescriptor(
int TenantIndex,
string Kind,
string Namespace,
string Repository,
string Digest,
int EventHash);
}