refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.AuditLog;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D (M5-T9) hot-path latency budget for <see cref="IAuditPayloadFilter"/>.
|
||||
/// The filter sits between event construction and persistence on every audit
|
||||
/// row — site SQLite hot-path and central direct-write both — so it MUST stay
|
||||
/// out of the way of script-thread latency.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Methodology: warm-up + N iterations, time each <see cref="IAuditPayloadFilter.Apply"/>
|
||||
/// with <see cref="Stopwatch"/>, sort, take p95, assert under threshold. Matches
|
||||
/// the simple-loop style of the existing <c>StaggeredStartupTests</c> /
|
||||
/// <c>HealthAggregationTests</c> in this project (no BenchmarkDotNet).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Threshold note: the spec says "set during M5 brainstorm" — pick targets that
|
||||
/// are an order of magnitude faster than the SQLite write they precede (the
|
||||
/// site writer's bottleneck is the disk fsync, not the in-memory filter).
|
||||
/// Reality may diverge on slow CI; the assertions include the empirical
|
||||
/// fall-back the task brief calls for (p95 + 30% regression guard) wired
|
||||
/// through environment-variable override so a slow shared runner doesn't
|
||||
/// flake the build but a 10x regression still does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class HotPathLatencyTests
|
||||
{
|
||||
private const int WarmupIterations = 200;
|
||||
private const int MeasureIterations = 2_000;
|
||||
|
||||
private static DefaultAuditPayloadFilter Filter(AuditLogOptions opts) => new(
|
||||
new StaticMonitor(opts),
|
||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||
|
||||
private static AuditEvent NewEvent(string request)
|
||||
{
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
Target = "esg.target",
|
||||
RequestSummary = request,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run <paramref name="fn"/> N times, returning the p95 in microseconds.
|
||||
/// Single-threaded; <see cref="Stopwatch.GetTimestamp"/> for high-res
|
||||
/// timing.
|
||||
/// </summary>
|
||||
private static double MeasureP95Microseconds(int iterations, Action fn)
|
||||
{
|
||||
var samples = new double[iterations];
|
||||
var ticksToMicroseconds = 1_000_000d / Stopwatch.Frequency;
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
fn();
|
||||
var end = Stopwatch.GetTimestamp();
|
||||
samples[i] = (end - start) * ticksToMicroseconds;
|
||||
}
|
||||
Array.Sort(samples);
|
||||
var p95Index = (int)Math.Ceiling(iterations * 0.95) - 1;
|
||||
return samples[p95Index];
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void Filter_Apply_4KB_Body_DefaultRedactors_P95_LessThan_50_Microseconds()
|
||||
{
|
||||
// 4 KiB body laced with a 16-digit token + a `password` field so the
|
||||
// header-redact stage is a no-op (input isn't a JSON object with a
|
||||
// headers field), the body regex stage matches twice, and the
|
||||
// truncation stage runs after redaction. Mirrors a typical
|
||||
// medium-sized HTTP POST body that an outbound API audit row would
|
||||
// carry.
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
// Keep the cap modest so the truncation path actually fires.
|
||||
DefaultCapBytes = 4096,
|
||||
GlobalBodyRedactors = new List<string>
|
||||
{
|
||||
"\"password\":\\s*\"[^\"]*\"",
|
||||
"\\d{16}",
|
||||
},
|
||||
};
|
||||
var pad = new string('x', 4 * 1024);
|
||||
var body = "{\"user\":\"alice\",\"password\":\"hunter2\",\"card\":\"4111111111111111\",\"pad\":\"" + pad + "\"}";
|
||||
// Sanity: we actually want > 4 KiB so the truncate stage runs.
|
||||
Assert.True(Encoding.UTF8.GetByteCount(body) > 4096);
|
||||
|
||||
var filter = Filter(opts);
|
||||
var evt = NewEvent(body);
|
||||
|
||||
// Warm-up — JIT, regex compile, dictionary populate.
|
||||
for (var i = 0; i < WarmupIterations; i++) _ = filter.Apply(evt);
|
||||
|
||||
var p95Us = MeasureP95Microseconds(MeasureIterations, () => _ = filter.Apply(evt));
|
||||
|
||||
// Default budget 50 µs (spec target). Override via env for slow CI:
|
||||
// SCADALINK_AUDIT_FILTER_4KB_P95_US — interpret as the regression
|
||||
// guard threshold. Print the observed value so a missed budget gives
|
||||
// useful telemetry on the test output.
|
||||
var threshold = GetThresholdMicroseconds("SCADALINK_AUDIT_FILTER_4KB_P95_US", 50d);
|
||||
Assert.True(p95Us < threshold,
|
||||
$"4KB body filter p95 = {p95Us:F1} µs; threshold = {threshold:F1} µs");
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void Filter_Apply_RawEvent_NoRedactors_P95_LessThan_10_Microseconds()
|
||||
{
|
||||
// No redactors configured — header redactor short-circuits on the
|
||||
// non-JSON-object pre-check, body redactor list is empty, SQL param
|
||||
// redactor is gated on AuditChannel.DbOutbound (we're ApiOutbound).
|
||||
// Just the per-field truncation walk. Should be effectively free.
|
||||
var opts = new AuditLogOptions();
|
||||
var filter = Filter(opts);
|
||||
|
||||
// Small payload that fits under the 8 KiB default cap — no truncation,
|
||||
// just the byte-count check per field.
|
||||
var evt = NewEvent("hello world");
|
||||
|
||||
for (var i = 0; i < WarmupIterations; i++) _ = filter.Apply(evt);
|
||||
|
||||
var p95Us = MeasureP95Microseconds(MeasureIterations, () => _ = filter.Apply(evt));
|
||||
|
||||
var threshold = GetThresholdMicroseconds("SCADALINK_AUDIT_FILTER_RAW_P95_US", 10d);
|
||||
Assert.True(p95Us < threshold,
|
||||
$"Raw-event filter p95 = {p95Us:F1} µs; threshold = {threshold:F1} µs");
|
||||
}
|
||||
|
||||
private static double GetThresholdMicroseconds(string envVar, double defaultUs)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(envVar);
|
||||
if (raw != null && double.TryParse(raw, out var parsed) && parsed > 0)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
return defaultUs;
|
||||
}
|
||||
|
||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4 (Phase 8): Performance test framework for health reporting aggregation.
|
||||
/// Verifies health reporting from 10 sites can be aggregated correctly.
|
||||
/// </summary>
|
||||
public class HealthAggregationTests
|
||||
{
|
||||
private readonly CentralHealthAggregator _aggregator;
|
||||
|
||||
public HealthAggregationTests()
|
||||
{
|
||||
var options = Options.Create(new HealthMonitoringOptions
|
||||
{
|
||||
ReportInterval = TimeSpan.FromSeconds(30),
|
||||
OfflineTimeout = TimeSpan.FromSeconds(60)
|
||||
});
|
||||
_aggregator = new CentralHealthAggregator(
|
||||
options,
|
||||
NullLogger<CentralHealthAggregator>.Instance);
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void AggregateHealthReports_10Sites_AllTracked()
|
||||
{
|
||||
const int siteCount = 10;
|
||||
|
||||
for (var i = 0; i < siteCount; i++)
|
||||
{
|
||||
var siteId = $"site-{i + 1:D2}";
|
||||
var report = new SiteHealthReport(
|
||||
SiteId: siteId,
|
||||
SequenceNumber: 1,
|
||||
ReportTimestamp: DateTimeOffset.UtcNow,
|
||||
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
|
||||
{
|
||||
[$"opc-{siteId}"] = ConnectionHealth.Connected
|
||||
},
|
||||
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>
|
||||
{
|
||||
[$"opc-{siteId}"] = new(75, 72)
|
||||
},
|
||||
ScriptErrorCount: 0,
|
||||
AlarmEvaluationErrorCount: 0,
|
||||
StoreAndForwardBufferDepths: new Dictionary<string, int>
|
||||
{
|
||||
["ext-system"] = i * 2
|
||||
},
|
||||
DeadLetterCount: 0,
|
||||
DeployedInstanceCount: 0,
|
||||
EnabledInstanceCount: 0,
|
||||
DisabledInstanceCount: 0);
|
||||
|
||||
_aggregator.ProcessReport(report);
|
||||
}
|
||||
|
||||
var states = _aggregator.GetAllSiteStates();
|
||||
Assert.Equal(siteCount, states.Count);
|
||||
Assert.All(states.Values, s => Assert.True(s.IsOnline));
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void AggregateHealthReports_RapidUpdates_HandlesVolume()
|
||||
{
|
||||
const int siteCount = 10;
|
||||
const int updatesPerSite = 100;
|
||||
|
||||
for (var seq = 1; seq <= updatesPerSite; seq++)
|
||||
{
|
||||
for (var s = 0; s < siteCount; s++)
|
||||
{
|
||||
var report = new SiteHealthReport(
|
||||
SiteId: $"site-{s + 1:D2}",
|
||||
SequenceNumber: seq,
|
||||
ReportTimestamp: DateTimeOffset.UtcNow,
|
||||
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
||||
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
||||
ScriptErrorCount: seq % 5 == 0 ? 1 : 0,
|
||||
AlarmEvaluationErrorCount: 0,
|
||||
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
||||
DeadLetterCount: 0,
|
||||
DeployedInstanceCount: 0,
|
||||
EnabledInstanceCount: 0,
|
||||
DisabledInstanceCount: 0);
|
||||
|
||||
_aggregator.ProcessReport(report);
|
||||
}
|
||||
}
|
||||
|
||||
var states = _aggregator.GetAllSiteStates();
|
||||
Assert.Equal(siteCount, states.Count);
|
||||
|
||||
// Verify all sites have the latest sequence number
|
||||
Assert.All(states.Values, s =>
|
||||
{
|
||||
Assert.Equal(updatesPerSite, s.LastSequenceNumber);
|
||||
Assert.True(s.IsOnline);
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void AggregateHealthReports_StaleReportsRejected()
|
||||
{
|
||||
var siteId = "site-01";
|
||||
|
||||
// Send report with seq 10
|
||||
_aggregator.ProcessReport(new SiteHealthReport(
|
||||
siteId, 10, DateTimeOffset.UtcNow,
|
||||
new Dictionary<string, ConnectionHealth>(),
|
||||
new Dictionary<string, TagResolutionStatus>(),
|
||||
5, 0, new Dictionary<string, int>(), 0, 0, 0, 0));
|
||||
|
||||
// Send stale report with seq 5 — should be rejected
|
||||
_aggregator.ProcessReport(new SiteHealthReport(
|
||||
siteId, 5, DateTimeOffset.UtcNow,
|
||||
new Dictionary<string, ConnectionHealth>(),
|
||||
new Dictionary<string, TagResolutionStatus>(),
|
||||
99, 0, new Dictionary<string, int>(), 0, 0, 0, 0));
|
||||
|
||||
var state = _aggregator.GetSiteState(siteId);
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(10, state!.LastSequenceNumber);
|
||||
// The script error count from report 10 (5) should be kept, not replaced by 99
|
||||
Assert.Equal(5, state.LatestReport!.ScriptErrorCount);
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void HealthCollector_CollectReport_ResetsIntervalCounters()
|
||||
{
|
||||
var collector = new SiteHealthCollector();
|
||||
|
||||
// Simulate errors during an interval
|
||||
for (var i = 0; i < 10; i++) collector.IncrementScriptError();
|
||||
for (var i = 0; i < 3; i++) collector.IncrementAlarmError();
|
||||
for (var i = 0; i < 7; i++) collector.IncrementDeadLetter();
|
||||
|
||||
collector.UpdateConnectionHealth("opc-1", ConnectionHealth.Connected);
|
||||
collector.UpdateTagResolution("opc-1", 75, 72);
|
||||
|
||||
var report = collector.CollectReport("site-01");
|
||||
|
||||
Assert.Equal("site-01", report.SiteId);
|
||||
Assert.Equal(10, report.ScriptErrorCount);
|
||||
Assert.Equal(3, report.AlarmEvaluationErrorCount);
|
||||
Assert.Equal(7, report.DeadLetterCount);
|
||||
Assert.Single(report.DataConnectionStatuses);
|
||||
|
||||
// Second collect should have reset interval counters
|
||||
var report2 = collector.CollectReport("site-01");
|
||||
Assert.Equal(0, report2.ScriptErrorCount);
|
||||
Assert.Equal(0, report2.AlarmEvaluationErrorCount);
|
||||
Assert.Equal(0, report2.DeadLetterCount);
|
||||
// Connection status persists (not interval-based)
|
||||
Assert.Single(report2.DataConnectionStatuses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4 (Phase 8): Performance test framework for staggered startup.
|
||||
/// Target scale: 10 sites, 500 machines, 75 tags each.
|
||||
/// These are framework/scaffold tests — actual perf runs are manual.
|
||||
/// </summary>
|
||||
public class StaggeredStartupTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Target: 500 instance configurations created and validated within time budget.
|
||||
/// Verifies the staggered startup model can handle the target instance count.
|
||||
/// </summary>
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void StaggeredStartup_500Instances_CompletesWithinBudget()
|
||||
{
|
||||
// Scaffold: simulate 500 instance creation with staggered delay
|
||||
const int instanceCount = 500;
|
||||
const int staggerDelayMs = 50; // 50ms between each instance start
|
||||
var expectedTotalMs = instanceCount * staggerDelayMs; // ~25 seconds
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var instanceNames = new List<string>(instanceCount);
|
||||
|
||||
for (var i = 0; i < instanceCount; i++)
|
||||
{
|
||||
// Simulate instance name generation (real startup would create InstanceActor)
|
||||
var siteName = $"site-{(i / 50) + 1:D2}";
|
||||
var instanceName = $"{siteName}/machine-{(i % 50) + 1:D3}";
|
||||
instanceNames.Add(instanceName);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Verify all instances were "started"
|
||||
Assert.Equal(instanceCount, instanceNames.Count);
|
||||
Assert.Equal(instanceCount, instanceNames.Distinct().Count());
|
||||
|
||||
// Verify naming convention
|
||||
Assert.All(instanceNames, name => Assert.Contains("/machine-", name));
|
||||
|
||||
// Time budget for name generation should be trivial
|
||||
Assert.True(sw.ElapsedMilliseconds < 1000,
|
||||
$"Instance name generation took {sw.ElapsedMilliseconds}ms, expected < 1000ms");
|
||||
|
||||
// Verify expected total startup time with staggering
|
||||
Assert.True(expectedTotalMs <= 30000,
|
||||
$"Expected staggered startup {expectedTotalMs}ms exceeds 30s budget");
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void StaggeredStartup_DistributionAcross10Sites()
|
||||
{
|
||||
// Verify that 500 instances are evenly distributed across 10 sites
|
||||
const int siteCount = 10;
|
||||
const int machinesPerSite = 50;
|
||||
var sites = new Dictionary<string, int>();
|
||||
|
||||
for (var s = 0; s < siteCount; s++)
|
||||
{
|
||||
var siteId = $"site-{s + 1:D2}";
|
||||
sites[siteId] = 0;
|
||||
|
||||
for (var m = 0; m < machinesPerSite; m++)
|
||||
{
|
||||
sites[siteId]++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(siteCount, sites.Count);
|
||||
Assert.All(sites.Values, count => Assert.Equal(machinesPerSite, count));
|
||||
Assert.Equal(500, sites.Values.Sum());
|
||||
}
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
[Fact]
|
||||
public void TagCapacity_75TagsPer500Machines_37500Total()
|
||||
{
|
||||
// Verify the system can represent 37,500 tag subscriptions
|
||||
const int machines = 500;
|
||||
const int tagsPerMachine = 75;
|
||||
const int totalTags = machines * tagsPerMachine;
|
||||
|
||||
var tagPaths = new HashSet<string>(totalTags);
|
||||
for (var m = 0; m < machines; m++)
|
||||
{
|
||||
for (var t = 0; t < tagsPerMachine; t++)
|
||||
{
|
||||
tagPaths.Add($"site-{(m / 50) + 1:D2}/machine-{(m % 50) + 1:D3}/tag-{t + 1:D3}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(totalTags, tagPaths.Count);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user