Closes the Phase 6.1 Stream A.2 "per-instance overrides bound from DriverInstance.ResilienceConfig JSON column" work flagged as a follow-up when Stream A.1 shipped in PR #78. Every driver can now override its Polly pipeline policy per instance instead of inheriting pure tier defaults. Configuration: - DriverInstance entity gains a nullable `ResilienceConfig` string column (nvarchar(max)) + SQL check constraint `CK_DriverInstance_ResilienceConfig_IsJson` that enforces ISJSON when not null. Null = use tier defaults (decision #143 / unchanged from pre-Phase-6.1). - EF migration `20260419161008_AddDriverInstanceResilienceConfig`. - SchemaComplianceTests expected-constraint list gains the new CK name. Core.Resilience.DriverResilienceOptionsParser: - Pure-function parser. ParseOrDefaults(tier, json, out diag) returns the effective DriverResilienceOptions — tier defaults with per-capability / bulkhead overrides layered on top when the JSON payload supplies them. Partial policies (e.g. Read { retryCount: 10 }) fill missing fields from the tier default for that capability. - Malformed JSON falls back to pure tier defaults + surfaces a human-readable diagnostic via the out parameter. Callers log the diag but don't fail startup — a misconfigured ResilienceConfig must not brick a working driver. - Property names + capability keys are case-insensitive; unrecognised capability names are logged-and-skipped; unrecognised shape-level keys are ignored so future shapes land without a migration. Server wire-in: - OtOpcUaServer gains two optional ctor params: `tierLookup` (driverType → DriverTier) + `resilienceConfigLookup` (driverInstanceId → JSON string). CreateMasterNodeManager now resolves tier + JSON for each driver, parses via DriverResilienceOptionsParser, logs the diagnostic if any, and constructs CapabilityInvoker with the merged options instead of pure Tier A defaults. - OpcUaApplicationHost threads both lookups through. Default null keeps existing tests constructing without either Func unchanged (falls back to Tier A + tier defaults exactly as before). Tests (13 new DriverResilienceOptionsParserTests): - null / whitespace / empty-object JSON returns pure tier defaults. - Malformed JSON falls back + surfaces diagnostic. - Read override merged into tier defaults; other capabilities untouched. - Partial policy fills missing fields from tier default. - Bulkhead overrides honored. - Unknown capability skipped + surfaced in diagnostic. - Property names + capability keys are case-insensitive. - Every tier × every capability × empty-JSON round-trips tier defaults exactly (theory). Full solution dotnet test: 1215 passing (was 1202, +13). Pre-existing Client.CLI Subscribe flake unchanged. Production wiring (Program.cs) example: Func<string, DriverTier> tierLookup = type => type switch { "Galaxy" => DriverTier.C, "Modbus" or "S7" => DriverTier.B, "OpcUaClient" => DriverTier.A, _ => DriverTier.A, }; Func<string, string?> cfgLookup = id => db.DriverInstances.AsNoTracking().FirstOrDefault(x => x.DriverInstanceId == id)?.ResilienceConfig; var host = new OpcUaApplicationHost(..., tierLookup: tierLookup, resilienceConfigLookup: cfgLookup); Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
5.4 KiB
C#
167 lines
5.4 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DriverResilienceOptionsParserTests
|
|
{
|
|
[Fact]
|
|
public void NullJson_ReturnsPureTierDefaults()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, null, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Tier.ShouldBe(DriverTier.A);
|
|
options.Resolve(DriverCapability.Read).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
|
}
|
|
|
|
[Fact]
|
|
public void WhitespaceJson_ReturnsDefaults()
|
|
{
|
|
DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, " ", out var diag);
|
|
diag.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void MalformedJson_FallsBack_WithDiagnostic()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{not json", out var diag);
|
|
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("malformed");
|
|
options.Tier.ShouldBe(DriverTier.A);
|
|
options.Resolve(DriverCapability.Read).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyObject_ReturnsDefaults()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{}", out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Resolve(DriverCapability.Write).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadOverride_MergedIntoTierDefaults()
|
|
{
|
|
var json = """
|
|
{
|
|
"capabilityPolicies": {
|
|
"Read": { "timeoutSeconds": 5, "retryCount": 7, "breakerFailureThreshold": 2 }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
var read = options.Resolve(DriverCapability.Read);
|
|
read.TimeoutSeconds.ShouldBe(5);
|
|
read.RetryCount.ShouldBe(7);
|
|
read.BreakerFailureThreshold.ShouldBe(2);
|
|
|
|
// Other capabilities untouched
|
|
options.Resolve(DriverCapability.Write).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
|
}
|
|
|
|
[Fact]
|
|
public void PartialPolicy_FillsMissingFieldsFromTierDefault()
|
|
{
|
|
var json = """
|
|
{
|
|
"capabilityPolicies": {
|
|
"Read": { "retryCount": 10 }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
|
|
|
|
var read = options.Resolve(DriverCapability.Read);
|
|
var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read];
|
|
read.RetryCount.ShouldBe(10);
|
|
read.TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds, "partial override; timeout falls back to tier default");
|
|
read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
|
|
}
|
|
|
|
[Fact]
|
|
public void BulkheadOverrides_AreHonored()
|
|
{
|
|
var json = """
|
|
{ "bulkheadMaxConcurrent": 100, "bulkheadMaxQueue": 500 }
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, json, out _);
|
|
|
|
options.BulkheadMaxConcurrent.ShouldBe(100);
|
|
options.BulkheadMaxQueue.ShouldBe(500);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
|
|
{
|
|
var json = """
|
|
{
|
|
"capabilityPolicies": {
|
|
"InventedCapability": { "timeoutSeconds": 99 }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
|
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("InventedCapability");
|
|
// Known capabilities untouched.
|
|
options.Resolve(DriverCapability.Read).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
|
}
|
|
|
|
[Fact]
|
|
public void PropertyNames_AreCaseInsensitive()
|
|
{
|
|
var json = """
|
|
{ "BULKHEADMAXCONCURRENT": 42 }
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
|
|
|
|
options.BulkheadMaxConcurrent.ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public void CapabilityName_IsCaseInsensitive()
|
|
{
|
|
var json = """
|
|
{ "capabilityPolicies": { "read": { "retryCount": 99 } } }
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DriverTier.A)]
|
|
[InlineData(DriverTier.B)]
|
|
[InlineData(DriverTier.C)]
|
|
public void EveryTier_WithEmptyJson_RoundTrips_Its_Defaults(DriverTier tier)
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, "{}", out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Tier.ShouldBe(tier);
|
|
foreach (var cap in Enum.GetValues<DriverCapability>())
|
|
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
|
|
}
|
|
}
|