diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs index 52dac9e..f2168a3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs @@ -27,6 +27,24 @@ public sealed class DriverInstance /// Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91). public required string DriverConfig { get; set; } + /// + /// 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: + /// + /// { + /// "bulkheadMaxConcurrent": 16, + /// "bulkheadMaxQueue": 64, + /// "capabilityPolicies": { + /// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 }, + /// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 } + /// } + /// } + /// + /// Parsed at startup by DriverResilienceOptionsParser; every key is optional + + /// unrecognised keys are ignored so future shapes land without a migration. + /// + public string? ResilienceConfig { get; set; } + public ConfigGeneration? Generation { get; set; } public ServerCluster? Cluster { get; set; } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs new file mode 100644 index 0000000..ea8ef07 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs @@ -0,0 +1,1347 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260419161932_AddDriverInstanceResilienceConfig")] + partial class AddDriverInstanceResilienceConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("RedundancyRole") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") + .HasFilter("[RedundancyRole] = 'Primary'"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CurrentGenerationId") + .HasColumnType("bigint"); + + b.Property("LastAppliedAt") + .HasColumnType("datetime2(3)"); + + b.Property("LastAppliedError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAppliedStatus") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId"); + + b.HasIndex("CurrentGenerationId") + .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); + + b.ToTable("ClusterNodeGenerationState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.Property("GenerationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ParentGenerationId") + .HasColumnType("bigint"); + + b.Property("PublishedAt") + .HasColumnType("datetime2(3)"); + + b.Property("PublishedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("GenerationId"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") + .HasFilter("[Status] = 'Draft'"); + + b.HasIndex("ParentGenerationId"); + + b.HasIndex("ClusterId", "Status", "GenerationId") + .IsDescending(false, false, true) + .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); + + b.ToTable("ConfigGeneration", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("GenerationId", "DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Device_Generation_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceId") + .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b => + { + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BaselineFootprintBytes") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CurrentBulkheadDepth") + .HasColumnType("int"); + + b.Property("CurrentFootprintBytes") + .HasColumnType("bigint"); + + b.Property("LastCircuitBreakerOpenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastRecycleUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastSampledUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("DriverInstanceId", "HostName"); + + b.HasIndex("LastSampledUtc") + .HasDatabaseName("IX_DriverResilience_LastSampled"); + + b.ToTable("DriverInstanceResilienceStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Equipment_Generation_Driver"); + + b.HasIndex("GenerationId", "EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_Uuid"); + + b.HasIndex("GenerationId", "MachineCode") + .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + + b.HasIndex("GenerationId", "SAPID") + .HasDatabaseName("IX_Equipment_Generation_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId") + .HasDatabaseName("IX_Equipment_Generation_Line"); + + b.HasIndex("GenerationId", "ZTag") + .HasDatabaseName("IX_Equipment_Generation_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_Namespace_Generation_Cluster"); + + b.HasIndex("GenerationId", "NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + + b.HasIndex("GenerationId", "ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); + + b.HasIndex("GenerationId", "NamespaceId", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + + b.HasIndex("GenerationId", "LdapGroup") + .HasDatabaseName("IX_NodeAcl_Generation_Group"); + + b.HasIndex("GenerationId", "NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Generation_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Generation_Driver"); + + b.HasIndex("GenerationId", "PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("GenerationId", "EquipmentId") + .HasDatabaseName("IX_Tag_Generation_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + + b.HasIndex("GenerationId", "EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_UnsArea_Generation_Cluster"); + + b.HasIndex("GenerationId", "UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("GenerationId", "UnsAreaId") + .HasDatabaseName("IX_UnsLine_Generation_Area"); + + b.HasIndex("GenerationId", "UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") + .WithMany() + .HasForeignKey("CurrentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithOne("GenerationState") + .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentGeneration"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Generations") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") + .WithMany() + .HasForeignKey("ParentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Cluster"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + + b.Navigation("GenerationState"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Generations"); + + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.cs new file mode 100644 index 0000000..568944f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class AddDriverInstanceResilienceConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_DriverInstance_ResilienceConfig_IsJson", + table: "DriverInstance"); + + migrationBuilder.DropColumn( + name: "ResilienceConfig", + table: "DriverInstance"); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index 0b413d1..b1dd394 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -413,6 +413,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + b.HasKey("DriverInstanceRowId"); b.HasIndex("ClusterId"); @@ -431,6 +434,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.ToTable("DriverInstance", null, t => { t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); }); }); diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index 3c5f360..0a4ac35 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -251,6 +251,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.DriverInstanceRowId); e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); @@ -260,6 +262,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.Name).HasMaxLength(128); e.Property(x => x.DriverType).HasMaxLength(32); 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.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs new file mode 100644 index 0000000..b05b54a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Resilience; + +/// +/// Parses the DriverInstance.ResilienceConfig JSON column into a +/// instance layered on top of the tier defaults. +/// Every key in the JSON is optional; missing keys fall back to the tier defaults from +/// . +/// +/// +/// Example JSON shape per Phase 6.1 Stream A.2: +/// +/// { +/// "bulkheadMaxConcurrent": 16, +/// "bulkheadMaxQueue": 64, +/// "capabilityPolicies": { +/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 }, +/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 } +/// } +/// } +/// +/// +/// 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. +/// +/// 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. +/// +public static class DriverResilienceOptionsParser +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + /// + /// Parse the JSON payload layered on 's defaults. Returns the + /// effective options; is null on success, or a + /// human-readable error message when the JSON was malformed (options still returned + /// = tier defaults). + /// + 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(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(baseDefaults); + if (shape.CapabilityPolicies is not null) + { + foreach (var (capName, overridePolicy) in shape.CapabilityPolicies) + { + if (!Enum.TryParse(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? CapabilityPolicies { get; set; } + } + + private sealed class CapabilityPolicyShape + { + public int? TimeoutSeconds { get; set; } + public int? RetryCount { get; set; } + public int? BreakerFailureThreshold { get; set; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index 1e49386..cba0b49 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -27,6 +27,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private readonly AuthorizationGate? _authzGate; private readonly NodeScopeResolver? _scopeResolver; private readonly StaleConfigFlag? _staleConfigFlag; + private readonly Func? _tierLookup; + private readonly Func? _resilienceConfigLookup; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private ApplicationInstance? _application; @@ -39,7 +41,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable DriverResiliencePipelineBuilder? pipelineBuilder = null, AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null, - StaleConfigFlag? staleConfigFlag = null) + StaleConfigFlag? staleConfigFlag = null, + Func? tierLookup = null, + Func? resilienceConfigLookup = null) { _options = options; _driverHost = driverHost; @@ -48,6 +52,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _authzGate = authzGate; _scopeResolver = scopeResolver; _staleConfigFlag = staleConfigFlag; + _tierLookup = tierLookup; + _resilienceConfigLookup = resilienceConfigLookup; _loggerFactory = loggerFactory; _logger = logger; } @@ -75,7 +81,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable $"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}"); _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); _logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}", diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs index f0cbc04..d3c25de 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs @@ -23,6 +23,8 @@ public sealed class OtOpcUaServer : StandardServer private readonly DriverResiliencePipelineBuilder _pipelineBuilder; private readonly AuthorizationGate? _authzGate; private readonly NodeScopeResolver? _scopeResolver; + private readonly Func? _tierLookup; + private readonly Func? _resilienceConfigLookup; private readonly ILoggerFactory _loggerFactory; private readonly List _driverNodeManagers = new(); @@ -32,13 +34,17 @@ public sealed class OtOpcUaServer : StandardServer DriverResiliencePipelineBuilder pipelineBuilder, ILoggerFactory loggerFactory, AuthorizationGate? authzGate = null, - NodeScopeResolver? scopeResolver = null) + NodeScopeResolver? scopeResolver = null, + Func? tierLookup = null, + Func? resilienceConfigLookup = null) { _driverHost = driverHost; _authenticator = authenticator; _pipelineBuilder = pipelineBuilder; _authzGate = authzGate; _scopeResolver = scopeResolver; + _tierLookup = tierLookup; + _resilienceConfigLookup = resilienceConfigLookup; _loggerFactory = loggerFactory; } @@ -59,10 +65,16 @@ public sealed class OtOpcUaServer : StandardServer if (driver is null) continue; var logger = _loggerFactory.CreateLogger(); - // Per-driver resilience options: default Tier A pending Stream B.1 which wires - // per-type tiers into DriverTypeRegistry. Read ResilienceConfig JSON from the - // DriverInstance row in a follow-up PR; for now every driver gets Tier A defaults. - var options = new DriverResilienceOptions { Tier = DriverTier.A }; + // Per-driver resilience options: tier comes from lookup (Phase 6.1 Stream B.1 + // DriverTypeRegistry in the prod wire-up) or falls back to Tier A. ResilienceConfig + // JSON comes from the DriverInstance row via the optional lookup Func; parser + // 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 manager = new DriverNodeManager(server, configuration, driver, invoker, logger, authzGate: _authzGate, scopeResolver: _scopeResolver); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs index cc8ae95..dae0bf5 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs @@ -78,6 +78,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;", "CK_ServerCluster_RedundancyMode_NodeCount", "CK_Device_DeviceConfig_IsJson", "CK_DriverInstance_DriverConfig_IsJson", + "CK_DriverInstance_ResilienceConfig_IsJson", "CK_PollGroup_IntervalMs_Min", "CK_Tag_TagConfig_IsJson", "CK_ConfigAuditLog_DetailsJson_IsJson", diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs new file mode 100644 index 0000000..3e97611 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs @@ -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()) + options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]); + } +}