Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- 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.
387 lines
12 KiB
C#
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);
|
|
}
|