Merge pull request (#103) - Phase 6.1 Stream A ResilienceConfig
This commit was merged in pull request #103.
This commit is contained in:
@@ -27,6 +27,24 @@ public sealed class DriverInstance
|
|||||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||||
public required string DriverConfig { get; set; }
|
public required string DriverConfig { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
|
||||||
|
/// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
|
||||||
|
/// <code>
|
||||||
|
/// {
|
||||||
|
/// "bulkheadMaxConcurrent": 16,
|
||||||
|
/// "bulkheadMaxQueue": 64,
|
||||||
|
/// "capabilityPolicies": {
|
||||||
|
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
|
||||||
|
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// </code>
|
||||||
|
/// Parsed at startup by <c>DriverResilienceOptionsParser</c>; every key is optional +
|
||||||
|
/// unrecognised keys are ignored so future shapes land without a migration.
|
||||||
|
/// </summary>
|
||||||
|
public string? ResilienceConfig { get; set; }
|
||||||
|
|
||||||
public ConfigGeneration? Generation { get; set; }
|
public ConfigGeneration? Generation { get; set; }
|
||||||
public ServerCluster? Cluster { get; set; }
|
public ServerCluster? Cluster { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
1347
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
generated
Normal file
1347
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDriverInstanceResilienceConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ResilienceConfig",
|
||||||
|
table: "DriverInstance",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddCheckConstraint(
|
||||||
|
name: "CK_DriverInstance_ResilienceConfig_IsJson",
|
||||||
|
table: "DriverInstance",
|
||||||
|
sql: "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropCheckConstraint(
|
||||||
|
name: "CK_DriverInstance_ResilienceConfig_IsJson",
|
||||||
|
table: "DriverInstance");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ResilienceConfig",
|
||||||
|
table: "DriverInstance");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -413,6 +413,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ResilienceConfig")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.HasKey("DriverInstanceRowId");
|
b.HasKey("DriverInstanceRowId");
|
||||||
|
|
||||||
b.HasIndex("ClusterId");
|
b.HasIndex("ClusterId");
|
||||||
@@ -431,6 +434,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
|||||||
b.ToTable("DriverInstance", null, t =>
|
b.ToTable("DriverInstance", null, t =>
|
||||||
{
|
{
|
||||||
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
|
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
|
||||||
|
|
||||||
|
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
{
|
{
|
||||||
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
|
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
|
||||||
"ISJSON(DriverConfig) = 1");
|
"ISJSON(DriverConfig) = 1");
|
||||||
|
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson",
|
||||||
|
"ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
|
||||||
});
|
});
|
||||||
e.HasKey(x => x.DriverInstanceRowId);
|
e.HasKey(x => x.DriverInstanceRowId);
|
||||||
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
@@ -260,6 +262,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
e.Property(x => x.Name).HasMaxLength(128);
|
e.Property(x => x.Name).HasMaxLength(128);
|
||||||
e.Property(x => x.DriverType).HasMaxLength(32);
|
e.Property(x => x.DriverType).HasMaxLength(32);
|
||||||
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
|
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
|
||||||
|
e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <c>DriverInstance.ResilienceConfig</c> JSON column into a
|
||||||
|
/// <see cref="DriverResilienceOptions"/> instance layered on top of the tier defaults.
|
||||||
|
/// Every key in the JSON is optional; missing keys fall back to the tier defaults from
|
||||||
|
/// <see cref="DriverResilienceOptions.GetTierDefaults(DriverTier)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Example JSON shape per Phase 6.1 Stream A.2:</para>
|
||||||
|
/// <code>
|
||||||
|
/// {
|
||||||
|
/// "bulkheadMaxConcurrent": 16,
|
||||||
|
/// "bulkheadMaxQueue": 64,
|
||||||
|
/// "capabilityPolicies": {
|
||||||
|
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
|
||||||
|
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para>Unrecognised keys + values are ignored so future shapes land without a migration.
|
||||||
|
/// Per-capability overrides are layered on top of tier defaults — a partial policy (only
|
||||||
|
/// some of TimeoutSeconds/RetryCount/BreakerFailureThreshold) fills in the other fields
|
||||||
|
/// from the tier default for that capability.</para>
|
||||||
|
///
|
||||||
|
/// <para>Parser failures (malformed JSON, type mismatches) fall back to pure tier defaults
|
||||||
|
/// + surface through an out-parameter diagnostic. Callers may log the diagnostic but should
|
||||||
|
/// NOT fail driver startup — a misconfigured ResilienceConfig should never brick a
|
||||||
|
/// working driver.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class DriverResilienceOptionsParser
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the JSON payload layered on <paramref name="tier"/>'s defaults. Returns the
|
||||||
|
/// effective options; <paramref name="parseDiagnostic"/> is null on success, or a
|
||||||
|
/// human-readable error message when the JSON was malformed (options still returned
|
||||||
|
/// = tier defaults).
|
||||||
|
/// </summary>
|
||||||
|
public static DriverResilienceOptions ParseOrDefaults(
|
||||||
|
DriverTier tier,
|
||||||
|
string? resilienceConfigJson,
|
||||||
|
out string? parseDiagnostic)
|
||||||
|
{
|
||||||
|
parseDiagnostic = null;
|
||||||
|
var baseDefaults = DriverResilienceOptions.GetTierDefaults(tier);
|
||||||
|
var baseOptions = new DriverResilienceOptions { Tier = tier, CapabilityPolicies = baseDefaults };
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(resilienceConfigJson))
|
||||||
|
return baseOptions;
|
||||||
|
|
||||||
|
ResilienceConfigShape? shape;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
shape = JsonSerializer.Deserialize<ResilienceConfigShape>(resilienceConfigJson, JsonOpts);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
parseDiagnostic = $"ResilienceConfig JSON malformed; falling back to tier {tier} defaults. Detail: {ex.Message}";
|
||||||
|
return baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shape is null) return baseOptions;
|
||||||
|
|
||||||
|
var merged = new Dictionary<DriverCapability, CapabilityPolicy>(baseDefaults);
|
||||||
|
if (shape.CapabilityPolicies is not null)
|
||||||
|
{
|
||||||
|
foreach (var (capName, overridePolicy) in shape.CapabilityPolicies)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<DriverCapability>(capName, ignoreCase: true, out var capability))
|
||||||
|
{
|
||||||
|
parseDiagnostic ??= $"Unknown capability '{capName}' in ResilienceConfig; skipped.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var basePolicy = merged[capability];
|
||||||
|
merged[capability] = new CapabilityPolicy(
|
||||||
|
TimeoutSeconds: overridePolicy.TimeoutSeconds ?? basePolicy.TimeoutSeconds,
|
||||||
|
RetryCount: overridePolicy.RetryCount ?? basePolicy.RetryCount,
|
||||||
|
BreakerFailureThreshold: overridePolicy.BreakerFailureThreshold ?? basePolicy.BreakerFailureThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DriverResilienceOptions
|
||||||
|
{
|
||||||
|
Tier = tier,
|
||||||
|
CapabilityPolicies = merged,
|
||||||
|
BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent ?? baseOptions.BulkheadMaxConcurrent,
|
||||||
|
BulkheadMaxQueue = shape.BulkheadMaxQueue ?? baseOptions.BulkheadMaxQueue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ResilienceConfigShape
|
||||||
|
{
|
||||||
|
public int? BulkheadMaxConcurrent { get; set; }
|
||||||
|
public int? BulkheadMaxQueue { get; set; }
|
||||||
|
public Dictionary<string, CapabilityPolicyShape>? CapabilityPolicies { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapabilityPolicyShape
|
||||||
|
{
|
||||||
|
public int? TimeoutSeconds { get; set; }
|
||||||
|
public int? RetryCount { get; set; }
|
||||||
|
public int? BreakerFailureThreshold { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
private readonly AuthorizationGate? _authzGate;
|
private readonly AuthorizationGate? _authzGate;
|
||||||
private readonly NodeScopeResolver? _scopeResolver;
|
private readonly NodeScopeResolver? _scopeResolver;
|
||||||
private readonly StaleConfigFlag? _staleConfigFlag;
|
private readonly StaleConfigFlag? _staleConfigFlag;
|
||||||
|
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
||||||
|
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||||
private ApplicationInstance? _application;
|
private ApplicationInstance? _application;
|
||||||
@@ -39,7 +41,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
DriverResiliencePipelineBuilder? pipelineBuilder = null,
|
DriverResiliencePipelineBuilder? pipelineBuilder = null,
|
||||||
AuthorizationGate? authzGate = null,
|
AuthorizationGate? authzGate = null,
|
||||||
NodeScopeResolver? scopeResolver = null,
|
NodeScopeResolver? scopeResolver = null,
|
||||||
StaleConfigFlag? staleConfigFlag = null)
|
StaleConfigFlag? staleConfigFlag = null,
|
||||||
|
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
||||||
|
Func<string, string?>? resilienceConfigLookup = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
@@ -48,6 +52,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
_authzGate = authzGate;
|
_authzGate = authzGate;
|
||||||
_scopeResolver = scopeResolver;
|
_scopeResolver = scopeResolver;
|
||||||
_staleConfigFlag = staleConfigFlag;
|
_staleConfigFlag = staleConfigFlag;
|
||||||
|
_tierLookup = tierLookup;
|
||||||
|
_resilienceConfigLookup = resilienceConfigLookup;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -75,7 +81,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
||||||
|
|
||||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
||||||
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||||
|
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
|
||||||
await _application.Start(_server).ConfigureAwait(false);
|
await _application.Start(_server).ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
||||||
private readonly AuthorizationGate? _authzGate;
|
private readonly AuthorizationGate? _authzGate;
|
||||||
private readonly NodeScopeResolver? _scopeResolver;
|
private readonly NodeScopeResolver? _scopeResolver;
|
||||||
|
private readonly Func<string, DriverTier>? _tierLookup;
|
||||||
|
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||||
|
|
||||||
@@ -32,13 +34,17 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
DriverResiliencePipelineBuilder pipelineBuilder,
|
DriverResiliencePipelineBuilder pipelineBuilder,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
AuthorizationGate? authzGate = null,
|
AuthorizationGate? authzGate = null,
|
||||||
NodeScopeResolver? scopeResolver = null)
|
NodeScopeResolver? scopeResolver = null,
|
||||||
|
Func<string, DriverTier>? tierLookup = null,
|
||||||
|
Func<string, string?>? resilienceConfigLookup = null)
|
||||||
{
|
{
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
_authenticator = authenticator;
|
_authenticator = authenticator;
|
||||||
_pipelineBuilder = pipelineBuilder;
|
_pipelineBuilder = pipelineBuilder;
|
||||||
_authzGate = authzGate;
|
_authzGate = authzGate;
|
||||||
_scopeResolver = scopeResolver;
|
_scopeResolver = scopeResolver;
|
||||||
|
_tierLookup = tierLookup;
|
||||||
|
_resilienceConfigLookup = resilienceConfigLookup;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +65,16 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
if (driver is null) continue;
|
if (driver is null) continue;
|
||||||
|
|
||||||
var logger = _loggerFactory.CreateLogger<DriverNodeManager>();
|
var logger = _loggerFactory.CreateLogger<DriverNodeManager>();
|
||||||
// Per-driver resilience options: default Tier A pending Stream B.1 which wires
|
// Per-driver resilience options: tier comes from lookup (Phase 6.1 Stream B.1
|
||||||
// per-type tiers into DriverTypeRegistry. Read ResilienceConfig JSON from the
|
// DriverTypeRegistry in the prod wire-up) or falls back to Tier A. ResilienceConfig
|
||||||
// DriverInstance row in a follow-up PR; for now every driver gets Tier A defaults.
|
// JSON comes from the DriverInstance row via the optional lookup Func; parser
|
||||||
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
// layers JSON overrides on top of tier defaults (Phase 6.1 Stream A.2).
|
||||||
|
var tier = _tierLookup?.Invoke(driver.DriverType) ?? DriverTier.A;
|
||||||
|
var resilienceJson = _resilienceConfigLookup?.Invoke(driver.DriverInstanceId);
|
||||||
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceJson, out var diag);
|
||||||
|
if (diag is not null)
|
||||||
|
logger.LogWarning("ResilienceConfig parse diagnostic for driver {DriverId}: {Diag}", driver.DriverInstanceId, diag);
|
||||||
|
|
||||||
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
||||||
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
||||||
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;",
|
|||||||
"CK_ServerCluster_RedundancyMode_NodeCount",
|
"CK_ServerCluster_RedundancyMode_NodeCount",
|
||||||
"CK_Device_DeviceConfig_IsJson",
|
"CK_Device_DeviceConfig_IsJson",
|
||||||
"CK_DriverInstance_DriverConfig_IsJson",
|
"CK_DriverInstance_DriverConfig_IsJson",
|
||||||
|
"CK_DriverInstance_ResilienceConfig_IsJson",
|
||||||
"CK_PollGroup_IntervalMs_Min",
|
"CK_PollGroup_IntervalMs_Min",
|
||||||
"CK_Tag_TagConfig_IsJson",
|
"CK_Tag_TagConfig_IsJson",
|
||||||
"CK_ConfigAuditLog_DetailsJson_IsJson",
|
"CK_ConfigAuditLog_DetailsJson_IsJson",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user