From 3edef09f510822e89f397458c94e0dc081583ea4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 14:40:38 -0400 Subject: [PATCH] feat(runtime): per-script execution timeout overriding the global default (#9) Spec promised a per-script timeout but only the global ScriptExecutionTimeoutSeconds existed. Add nullable TemplateScript.ExecutionTimeoutSeconds threaded through EF + flattening (ResolvedScript) to ScriptExecutionActor/AlarmExecutionActor, which use perScript ?? global for the execution CTS. Includes the EF migration for the new column. --- .../Entities/Templates/TemplateScript.cs | 9 + .../Flattening/FlattenedConfiguration.cs | 8 + .../Configurations/TemplateConfiguration.cs | 5 + ...TemplateScriptExecutionTimeout.Designer.cs | 1733 +++++++++++++++++ ...83757_AddTemplateScriptExecutionTimeout.cs | 28 + .../ScadaBridgeDbContextModelSnapshot.cs | 3 + .../Actors/AlarmActor.cs | 21 +- .../Actors/AlarmExecutionActor.cs | 19 +- .../Actors/InstanceActor.cs | 9 +- .../Actors/ScriptActor.cs | 12 +- .../Actors/ScriptExecutionActor.cs | 18 +- .../Flattening/DiffService.cs | 3 +- .../Flattening/FlatteningService.cs | 4 + .../Flattening/RevisionHashService.cs | 9 +- .../LockEnforcer.cs | 2 +- .../TemplateService.cs | 3 + .../Import/BundleImporter.cs | 1 + .../Serialization/EntityDtos.cs | 5 +- .../Serialization/EntitySerializer.cs | 4 +- .../TemplateEngineRepositoryTests.cs | 28 + .../Actors/ExecutionActorTests.cs | 99 + .../Flattening/FlatteningServiceTests.cs | 88 + 22 files changed, 2094 insertions(+), 17 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateScript.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateScript.cs index ea0e7295..3dbf4292 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateScript.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateScript.cs @@ -52,6 +52,15 @@ public class TemplateScript /// public TimeSpan? MinTimeBetweenRuns { get; set; } + /// + /// Per-script execution timeout in seconds, or null to use the site's global + /// default (SiteRuntimeOptions.ScriptExecutionTimeoutSeconds). A + /// non-positive value (≤ 0) is treated the same as null — i.e. fall back to + /// the global default — by the Site Runtime. Seconds (not a TimeSpan) to keep + /// the unit consistent with the global option it overrides. + /// + public int? ExecutionTimeoutSeconds { get; set; } + /// /// True when this row was copied from the base template and has not been /// overridden on the derived template. Changes to the base flow downward diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs index f70f7c13..448d1313 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs @@ -174,6 +174,14 @@ public sealed record ResolvedScript /// Gets the minimum time between script executions. public TimeSpan? MinTimeBetweenRuns { get; init; } + + /// + /// Per-script execution timeout in seconds, or null to use the site's global + /// default. A non-positive value is treated as null (use global) by the Site + /// Runtime. Seconds (not TimeSpan) to match the global option it overrides. + /// + public int? ExecutionTimeoutSeconds { get; init; } + /// Gets the source of this script. public string Source { get; init; } = "Template"; diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs index ac7c1e4a..d4ac008c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs @@ -178,6 +178,11 @@ public class TemplateScriptConfiguration : IEntityTypeConfiguration s.ReturnDefinition) .HasMaxLength(4000); + // M2.5 (#9): nullable per-script execution timeout (seconds). Null = use + // the site's global ScriptExecutionTimeoutSeconds default. + builder.Property(s => s.ExecutionTimeoutSeconds) + .IsRequired(false); + builder.HasIndex(s => new { s.TemplateId, s.Name }).IsUnique(); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.Designer.cs new file mode 100644 index 00000000..4c5932d6 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.Designer.cs @@ -0,0 +1,1733 @@ +// +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.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260615183757_AddTemplateScriptExecutionTimeout")] + partial class AddTemplateScriptExecutionTimeout + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }, + new + { + Id = 5, + LdapGroupName = "SCADA-Viewers", + Role = "Viewer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.cs new file mode 100644 index 00000000..0913cf6a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615183757_AddTemplateScriptExecutionTimeout.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + public partial class AddTemplateScriptExecutionTimeout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExecutionTimeoutSeconds", + table: "TemplateScripts", + type: "int", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExecutionTimeoutSeconds", + table: "TemplateScripts"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 7b882cab..763d6861 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -1313,6 +1313,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + b.Property("IsInherited") .HasColumnType("bit"); diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs index ca9e2db6..041681a5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs @@ -72,6 +72,15 @@ public class AlarmActor : ReceiveActor private readonly string? _onTriggerScriptName; private readonly Script? _onTriggerCompiledScript; + /// + /// M2.5 (#9): the on-trigger script's per-script execution timeout in seconds, + /// or null to use the global default. Forwarded to each spawned + /// , which applies perScript ?? global + /// (treating ≤ 0 as "use global"). The value comes from the referenced + /// on-trigger script's . + /// + private readonly int? _onTriggerExecutionTimeoutSeconds; + // Expression trigger: compiled expression + the attribute snapshot it // evaluates against. This field is the single home for the compiled // expression on the hot path. @@ -107,6 +116,9 @@ public class AlarmActor : ReceiveActor /// Optional DI service provider used to resolve the optional /// for M1.5 alarm operational events. Fire-and-forget; /// a logging failure never affects alarm evaluation. + /// M2.5 (#9): the on-trigger script's per-script + /// execution timeout in seconds (from its ), + /// or null/non-positive to use the global default. public AlarmActor( string alarmName, string instanceName, @@ -119,7 +131,9 @@ public class AlarmActor : ReceiveActor Script? compiledTriggerExpression = null, IReadOnlyDictionary? initialAttributes = null, ISiteHealthCollector? healthCollector = null, - IServiceProvider? serviceProvider = null) + IServiceProvider? serviceProvider = null, + // M2.5 (#9): per-script timeout for the on-trigger script (null = global). + int? onTriggerExecutionTimeoutSeconds = null) { _alarmName = alarmName; _instanceName = instanceName; @@ -135,6 +149,7 @@ public class AlarmActor : ReceiveActor _priority = alarmConfig.PriorityLevel; _onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName; _onTriggerCompiledScript = onTriggerCompiledScript; + _onTriggerExecutionTimeoutSeconds = onTriggerExecutionTimeoutSeconds; _compiledTriggerExpression = compiledTriggerExpression; // Seed the trigger-expression attribute snapshot from the instance's @@ -574,7 +589,9 @@ public class AlarmActor : ReceiveActor _instanceActor, _sharedScriptLibrary, _options, - _logger)); + _logger, + // M2.5 (#9): per-script timeout from the on-trigger script (null = global). + _onTriggerExecutionTimeoutSeconds)); Context.ActorOf(props, executionId); } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs index 0a8e1f6b..623457e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs @@ -28,6 +28,7 @@ public class AlarmExecutionActor : ReceiveActor /// Shared script library providing common utilities. /// Site runtime configuration options, including the execution timeout. /// Logger for execution diagnostics. + /// M2.5 (#9): the on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global . public AlarmExecutionActor( string alarmName, string instanceName, @@ -38,7 +39,10 @@ public class AlarmExecutionActor : ReceiveActor IActorRef instanceActor, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, - ILogger logger) + ILogger logger, + // M2.5 (#9): per-script execution timeout override (seconds) for the + // alarm on-trigger script. Null or non-positive falls back to the global. + int? executionTimeoutSeconds = null) { var self = Self; var parent = Context.Parent; @@ -46,7 +50,8 @@ public class AlarmExecutionActor : ReceiveActor ExecuteAlarmScript( alarmName, instanceName, level, priority, message, compiledScript, instanceActor, - sharedScriptLibrary, options, self, parent, logger); + sharedScriptLibrary, options, self, parent, logger, + executionTimeoutSeconds); } private static void ExecuteAlarmScript( @@ -61,9 +66,15 @@ public class AlarmExecutionActor : ReceiveActor SiteRuntimeOptions options, IActorRef self, IActorRef parent, - ILogger logger) + ILogger logger, + int? executionTimeoutSeconds) { - var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); + // M2.5 (#9): per-script timeout overrides the global default. A null or + // non-positive per-script value (≤ 0) falls back to the global. + var timeout = TimeSpan.FromSeconds( + executionTimeoutSeconds is { } perScript && perScript > 0 + ? perScript + : options.ScriptExecutionTimeoutSeconds); // SiteRuntime-009: run the alarm on-trigger body on the dedicated // script-execution scheduler, not the shared .NET thread pool. diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index e6f9a3fc..6588dd8b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -754,6 +754,10 @@ public class InstanceActor : ReceiveActor foreach (var alarm in _configuration.Alarms) { Script? onTriggerScript = null; + // M2.5 (#9): the on-trigger script's per-script execution timeout, + // captured from its ResolvedScript so the AlarmExecutionActor can + // apply perScript ?? global. Null when there is no on-trigger script. + int? onTriggerTimeoutSeconds = null; // Compile on-trigger script if defined if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName)) @@ -763,6 +767,7 @@ public class InstanceActor : ReceiveActor if (triggerScriptDef != null) { + onTriggerTimeoutSeconds = triggerScriptDef.ExecutionTimeoutSeconds; var result = _compilationService.Compile( $"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code); if (result.IsSuccess) @@ -794,7 +799,9 @@ public class InstanceActor : ReceiveActor triggerExpression, attributeSnapshot, _healthCollector, - _serviceProvider)); + _serviceProvider, + // M2.5 (#9): per-script timeout for the alarm on-trigger script. + onTriggerTimeoutSeconds)); var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}"); _alarmActors[alarm.CanonicalName] = actorRef; diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs index 9372ede3..942a0517 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs @@ -43,6 +43,13 @@ public class ScriptActor : ReceiveActor, IWithTimers private Script? _compiledScript; private ScriptTriggerConfig? _triggerConfig; private TimeSpan? _minTimeBetweenRuns; + + /// + /// M2.5 (#9): the per-script execution timeout in seconds, or null to use the + /// global default. Threaded down to each spawned , + /// which applies perScript ?? global (and treats ≤ 0 as "use global"). + /// + private readonly int? _executionTimeoutSeconds; private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue; private int _executionCounter; private readonly Commons.Types.Scripts.ScriptScope _scope; @@ -112,6 +119,7 @@ public class ScriptActor : ReceiveActor, IWithTimers _healthCollector = healthCollector; _serviceProvider = serviceProvider; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; + _executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds; _scope = scriptConfig.Scope; _compiledTriggerExpression = compiledTriggerExpression; @@ -426,7 +434,9 @@ public class ScriptActor : ReceiveActor, IWithTimers _serviceProvider, // Audit Log #23 (ParentExecutionId): null for trigger-driven runs; // an inbound-API-routed call supplies the inbound request's id. - parentExecutionId)); + parentExecutionId, + // M2.5 (#9): per-script timeout override (null = use global). + _executionTimeoutSeconds)); Context.ActorOf(props, executionId); } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptExecutionActor.cs index 22cc6034..f4c2348e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -47,6 +47,7 @@ public class ScriptExecutionActor : ReceiveActor /// Optional health collector for recording execution metrics. /// Optional DI service provider for script execution services. /// ExecutionId of the spawning inbound-API execution for audit correlation; null for normal runs. + /// M2.5 (#9): per-script execution timeout in seconds. Null or non-positive falls back to the global . public ScriptExecutionActor( string scriptName, string instanceName, @@ -65,7 +66,10 @@ public class ScriptExecutionActor : ReceiveActor // Audit Log #23 (ParentExecutionId): the spawning execution's // ExecutionId for an inbound-API-routed call. Null for normal // (tag-change / timer) runs and nested Script.Call invocations. - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + // M2.5 (#9): per-script execution timeout override (seconds). Null or + // non-positive falls back to the global ScriptExecutionTimeoutSeconds. + int? executionTimeoutSeconds = null) { // Immediately begin execution var self = Self; @@ -75,7 +79,7 @@ public class ScriptExecutionActor : ReceiveActor scriptName, instanceName, compiledScript, parameters, callDepth, instanceActor, sharedScriptLibrary, options, replyTo, correlationId, self, parent, logger, scope, healthCollector, serviceProvider, - parentExecutionId); + parentExecutionId, executionTimeoutSeconds); } private static void ExecuteScript( @@ -95,9 +99,15 @@ public class ScriptExecutionActor : ReceiveActor Commons.Types.Scripts.ScriptScope scope, ISiteHealthCollector? healthCollector, IServiceProvider? serviceProvider, - Guid? parentExecutionId) + Guid? parentExecutionId, + int? executionTimeoutSeconds) { - var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); + // M2.5 (#9): per-script timeout overrides the global default. A null or + // non-positive per-script value (≤ 0) falls back to the global. + var timeout = TimeSpan.FromSeconds( + executionTimeoutSeconds is { } perScript && perScript > 0 + ? perScript + : options.ScriptExecutionTimeoutSeconds); // SiteRuntime-009: run the script body on the dedicated script-execution // scheduler, not the shared .NET thread pool, so blocking script I/O cannot diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs index d6bc6a9f..1182af78 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs @@ -141,7 +141,8 @@ public class DiffService a.TriggerConfiguration == b.TriggerConfiguration && a.ParameterDefinitions == b.ParameterDefinitions && a.ReturnDefinition == b.ReturnDefinition && - a.MinTimeBetweenRuns == b.MinTimeBetweenRuns; + a.MinTimeBetweenRuns == b.MinTimeBetweenRuns && + a.ExecutionTimeoutSeconds == b.ExecutionTimeoutSeconds; /// /// Compares two instances for equality across diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs index 6e8f0d56..7b45d22a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs @@ -830,6 +830,10 @@ public class FlatteningService ParameterDefinitions = script.ParameterDefinitions, ReturnDefinition = script.ReturnDefinition, MinTimeBetweenRuns = script.MinTimeBetweenRuns, + // M2.5 (#9): per-script timeout rides along on the winning row. + // Scripts inherit/override at whole-row granularity (no per-field + // merge), so this follows the same rule as the script body/MinTime. + ExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds, Source = source }; idByName[script.Name] = script.Id; diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs index f2e74b74..19337841 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs @@ -83,7 +83,10 @@ public class RevisionHashService TriggerConfiguration = s.TriggerConfiguration, ParameterDefinitions = s.ParameterDefinitions, ReturnDefinition = s.ReturnDefinition, - MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks + MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks, + // M2.5 (#9): include the per-script timeout so a change to it + // is detected as a configuration change (staleness/redeploy). + ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds }) .ToList(), Connections = configuration.Connections is { Count: > 0 } @@ -244,6 +247,10 @@ public class RevisionHashService /// public string Code { get; init; } = string.Empty; /// + /// M2.5 (#9): the per-script execution timeout in seconds (null = global). + /// + public int? ExecutionTimeoutSeconds { get; init; } + /// /// Whether the script is locked. /// public bool IsLocked { get; init; } diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/LockEnforcer.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/LockEnforcer.cs index 3d42a921..d6d62c67 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/LockEnforcer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/LockEnforcer.cs @@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine; /// Override granularity: /// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed. /// - Alarms: Priority, TriggerConfiguration, Description, OnTriggerScript overridable; Name and TriggerType fixed. -/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, params/return overridable; Name fixed. +/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, ExecutionTimeoutSeconds, params/return overridable; Name fixed. /// - Lock flag applies to the entire member (attribute/alarm/script). /// public static class LockEnforcer diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs index e9e138ee..f099c389 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs @@ -687,6 +687,8 @@ public class TemplateService existing.TriggerType = proposed.TriggerType; existing.TriggerConfiguration = proposed.TriggerConfiguration; existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns; + // M2.5 (#9): per-script execution timeout is an overridable field. + existing.ExecutionTimeoutSeconds = proposed.ExecutionTimeoutSeconds; existing.ParameterDefinitions = proposed.ParameterDefinitions; existing.ReturnDefinition = proposed.ReturnDefinition; existing.IsLocked = proposed.IsLocked; @@ -1013,6 +1015,7 @@ public class TemplateService ParameterDefinitions = script.ParameterDefinitions, ReturnDefinition = script.ReturnDefinition, MinTimeBetweenRuns = script.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds, IsInherited = true, LockedInDerived = false, }); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 168889c5..1d76adb7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -2339,6 +2339,7 @@ public sealed class BundleImporter : IBundleImporter ParameterDefinitions = s.ParameterDefinitions, ReturnDefinition = s.ReturnDefinition, MinTimeBetweenRuns = s.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds, Source = "Template", }); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs index be8a881d..4ffa0fba 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs @@ -99,7 +99,10 @@ public sealed record TemplateScriptDto( string? ParameterDefinitions, string? ReturnDefinition, bool IsLocked, - TimeSpan? MinTimeBetweenRuns); + TimeSpan? MinTimeBetweenRuns, + // M2.5 (#9): per-script execution timeout (seconds). Additive trailing field; + // null on bundles written before this field existed. + int? ExecutionTimeoutSeconds = null); public sealed record TemplateCompositionDto( string InstanceName, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index eebe6089..3ee64a07 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs @@ -74,7 +74,8 @@ public sealed class EntitySerializer ParameterDefinitions: s.ParameterDefinitions, ReturnDefinition: s.ReturnDefinition, IsLocked: s.IsLocked, - MinTimeBetweenRuns: s.MinTimeBetweenRuns)).ToList(), + MinTimeBetweenRuns: s.MinTimeBetweenRuns, + ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds)).ToList(), Compositions: t.Compositions.Select(c => new TemplateCompositionDto( InstanceName: c.InstanceName, ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList()); @@ -227,6 +228,7 @@ public sealed class EntitySerializer ReturnDefinition = s.ReturnDefinition, IsLocked = s.IsLocked, MinTimeBetweenRuns = s.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds, }); } return t; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs index d5dc2112..8aac5576 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs @@ -61,6 +61,34 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Equal("Slot1", loaded.Compositions.First().InstanceName); } + [Fact] + public async Task TemplateScript_ExecutionTimeoutSeconds_RoundTripsThroughEf() + { + // M2.5 (#9): the nullable per-script execution timeout must persist and + // reload through EF — both an explicit value and a null (use-global). + var template = new Template("TimeoutTemplate"); + template.Scripts.Add(new TemplateScript("WithTimeout", "return 1;") + { + ExecutionTimeoutSeconds = 45 + }); + template.Scripts.Add(new TemplateScript("NoTimeout", "return 2;")); // null + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + // Detach so the reload comes from the store, not the change tracker. + _context.ChangeTracker.Clear(); + + var loaded = await _context.Templates + .Include(t => t.Scripts) + .SingleAsync(t => t.Name == "TimeoutTemplate"); + + var withTimeout = loaded.Scripts.Single(s => s.Name == "WithTimeout"); + Assert.Equal(45, withTimeout.ExecutionTimeoutSeconds); + + var noTimeout = loaded.Scripts.Single(s => s.Name == "NoTimeout"); + Assert.Null(noTimeout.ExecutionTimeoutSeconds); + } + [Fact] public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs index 22fe87c6..62421045 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs @@ -185,6 +185,84 @@ public class ExecutionActorTests : TestKit, IDisposable ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } + [Fact] + public void ScriptExecutionActor_PerScriptTimeout_OverridesLongerGlobal() + { + // M2.5 (#9): a short per-script timeout (1s) must win over a long global + // (300s), so the busy loop is cancelled at the per-script value. + var compiled = CompileScript( + "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); + var replyTo = CreateTestProbe(); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( + "Slow", "Inst1", compiled, null, 0, + instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300), + replyTo.Ref, "corr-perscript", NullLogger.Instance, + ScriptScope.Root, null, null, null, + /* executionTimeoutSeconds */ 1))); + + Watch(exec); + + var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(result.Success); + Assert.Contains("timed out", result.ErrorMessage); + + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ScriptExecutionActor_NullPerScriptTimeout_FallsBackToGlobal() + { + // M2.5 (#9): a null per-script timeout falls back to the global (1s here), + // so the busy loop is still cancelled at the global value. + var compiled = CompileScript( + "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); + var replyTo = CreateTestProbe(); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( + "Slow", "Inst1", compiled, null, 0, + instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), + replyTo.Ref, "corr-fallback", NullLogger.Instance, + ScriptScope.Root, null, null, null, + /* executionTimeoutSeconds */ null))); + + Watch(exec); + + var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(result.Success); + Assert.Contains("timed out", result.ErrorMessage); + + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ScriptExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal() + { + // M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use + // global", so the busy loop is cancelled at the global (1s) value. + var compiled = CompileScript( + "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); + var replyTo = CreateTestProbe(); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( + "Slow", "Inst1", compiled, null, 0, + instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), + replyTo.Ref, "corr-clamp", NullLogger.Instance, + ScriptScope.Root, null, null, null, + /* executionTimeoutSeconds */ 0))); + + Watch(exec); + + var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(result.Success); + Assert.Contains("timed out", result.ErrorMessage); + + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + [Fact] public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion() { @@ -234,4 +312,25 @@ public class ExecutionActorTests : TestKit, IDisposable // Even on a throwing on-trigger body, the actor must self-stop. ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } + + [Fact] + public void AlarmExecutionActor_PerScriptTimeout_OverridesLongerGlobal() + { + // M2.5 (#9): the alarm on-trigger script's per-script timeout (1s) wins + // over a long global (300s). The busy loop is cancelled and the actor + // self-stops (the timeout is logged, alarm continues). + var compiled = CompileScript( + "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( + "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", + compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300), + NullLogger.Instance, /* executionTimeoutSeconds */ 1))); + + Watch(exec); + // If the per-script timeout were ignored it would block ~300s and this + // ExpectTerminated would fail; with the override it stops within ~1s. + ExpectTerminated(exec, TimeSpan.FromSeconds(10)); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs index 0c0ab646..47e92e8a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs @@ -371,6 +371,94 @@ public class FlatteningServiceTests Assert.Equal("return base;", script.Code); } + // ── M2.5 (#9): per-script execution timeout threads to ResolvedScript ─── + + [Fact] + public void Flatten_SingleTemplate_ScriptExecutionTimeoutSecondsThreadsThrough() + { + var template = CreateTemplate(1, "Base"); + template.Scripts.Add(new TemplateScript("Slow", "// slow") { ExecutionTimeoutSeconds = 5 }); + template.Scripts.Add(new TemplateScript("Default", "// default")); // null → use global + + var instance = CreateInstance(); + var result = _sut.Flatten( + instance, + [template], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + + Assert.True(result.IsSuccess); + var slow = result.Value.Scripts.First(s => s.CanonicalName == "Slow"); + Assert.Equal(5, slow.ExecutionTimeoutSeconds); + var dflt = result.Value.Scripts.First(s => s.CanonicalName == "Default"); + Assert.Null(dflt.ExecutionTimeoutSeconds); + } + + [Fact] + public void Flatten_DerivedScriptOverride_ExecutionTimeoutFollowsWinningRow() + { + // Scripts inherit/override at whole-row granularity: an explicit override + // row on the derived template (IsInherited = false) fully replaces the + // base row, so its ExecutionTimeoutSeconds wins — exactly like the body. + var baseTemplate = CreateTemplate(2, "Sensor"); + baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;") + { + ExecutionTimeoutSeconds = 10 + }); + + var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2); + derived.Scripts.Add(new TemplateScript("Sample", "return derived;") + { + ExecutionTimeoutSeconds = 3 + }); + + var instance = CreateInstance(); + var result = _sut.Flatten( + instance, + [derived, baseTemplate], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + + Assert.True(result.IsSuccess); + var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample"); + Assert.Equal("return derived;", script.Code); + Assert.Equal(3, script.ExecutionTimeoutSeconds); + } + + [Fact] + public void Flatten_InheritedScriptOnDerived_ExecutionTimeoutFollowsBaseRow() + { + // A stale inherited copy on the derived template (IsInherited = true) is + // ignored; the base row wins, carrying the base ExecutionTimeoutSeconds. + var baseTemplate = CreateTemplate(2, "Sensor"); + baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;") + { + ExecutionTimeoutSeconds = 10 + }); + + var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2); + derived.Scripts.Add(new TemplateScript("Sample", "stale code") + { + IsInherited = true, + ExecutionTimeoutSeconds = 3 + }); + + var instance = CreateInstance(); + var result = _sut.Flatten( + instance, + [derived, baseTemplate], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + + Assert.True(result.IsSuccess); + var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample"); + Assert.Equal("return base;", script.Code); + Assert.Equal(10, script.ExecutionTimeoutSeconds); + } + // ── TemplateEngine-002: per-slot alarm override ──────────────────────── [Fact]