From cfbf0b2a1783c560b92595ec989e44a097afb8a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 15:35:53 -0400 Subject: [PATCH] feat(deploy): warn-only script-compile-cost advisory on deploy --- ...-07-virtualtag-script-memory.md.tasks.json | 2 +- .../AdminOperations/AdminOperationsActor.cs | 25 +++- .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 3 + .../AdminOperationsActorTests.cs | 114 ++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json b/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json index 169bf54a..e2996da5 100644 --- a/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json +++ b/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json @@ -4,7 +4,7 @@ {"id": 1, "subject": "Task 1: A0 — extract script types into lean Roslyn-free assembly", "status": "completed", "classification": "high-risk"}, {"id": 2, "subject": "Task 2: A0 measurement gate — re-run probe, confirm ~11x drop", "status": "completed", "classification": "small", "blockedBy": [1]}, {"id": 3, "subject": "Task 3: A — passthrough fast-path in evaluator", "status": "completed", "classification": "small", "blockedBy": [1]}, - {"id": 4, "subject": "Task 4: warn-only deploy guardrail", "status": "pending", "classification": "standard", "blockedBy": [1]}, + {"id": 4, "subject": "Task 4: warn-only deploy guardrail", "status": "completed", "classification": "standard", "blockedBy": [1]}, {"id": 5, "subject": "Task 5: live docker-dev verification (1036-vtag overlay, no OOM)", "status": "pending", "classification": "standard", "blockedBy": [1, 3, 4]} ], "lastUpdated": "2026-06-07" diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index eb31b42e..786fbc4f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -10,6 +10,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; @@ -105,6 +106,28 @@ public sealed class AdminOperationsActor : ReceiveActor return; } + // Warn-only compile-cost guardrail (NEVER blocks). Each genuinely-compiled (non-passthrough) + // script costs ~1.66 MiB RSS per node to materialize via Roslyn (measured post-A0; design doc + // 2026-06-07). Passthrough "mirror" scripts compile to ~nothing, so they are excluded; distinct + // sources are counted (the compile cache keys on source, so duplicates collapse to one unit). + // This only surfaces the estimate to the operator — the DraftValidator gate above is the hard + // reject; this advisory rides in the Accepted Message so the UI can show it. + const double PerScriptMiB = 1.66; + var scriptSources = await db.Scripts.AsNoTracking().Select(s => s.SourceCode).ToListAsync(); + var compiled = scriptSources + .Where(src => !PassthroughScript.TryMatch(src, out _)) + .Distinct(StringComparer.Ordinal) + .Count(); + string? advisory = null; + if (compiled > 0) + { + var estMiB = compiled * PerScriptMiB; + _log.Warning( + "StartDeployment: {Compiled} script(s) will compile (~{EstMiB:F0} MiB RSS per node); ensure node mem_limit covers it", + compiled, estMiB); + advisory = $"{compiled} script(s) will compile (~{estMiB:F0} MiB/node)"; + } + var artifact = await ConfigComposer.SnapshotAndFlattenAsync(db); var deploymentId = DeploymentId.NewId(); var revHash = RevisionHash.Parse(artifact.RevisionHash); @@ -136,7 +159,7 @@ public sealed class AdminOperationsActor : ReceiveActor StartDeploymentOutcome.Accepted, deploymentId, revHash, - Message: null, + Message: advisory, msg.CorrelationId)); } catch (Exception ex) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index 6993960c..2e937405 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -23,6 +23,9 @@ + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs index 39840f33..b52f9905 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs @@ -189,6 +189,120 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase verify.Deployments.Count().ShouldBe(0); } + /// Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough + /// (genuinely-compiled) Script rows yields an + /// deploy whose carries a compile-cost advisory. + /// The guardrail NEVER rejects — it only surfaces the estimated RSS pressure to the operator. + [Fact] + public void StartDeployment_warns_when_many_scripts_will_compile() + { + const int n = 10; + var dbFactory = NewInMemoryDbFactory(); + using (var db = dbFactory.CreateDbContext()) + { + for (var i = 0; i < n; i++) + { + db.Scripts.Add(new Configuration.Entities.Script + { + ScriptId = $"s-{i}", + Name = $"script-{i}", + SourceCode = $"return (int)ctx.GetTag(\"a\").Value + {i};", // distinct, non-passthrough + SourceHash = $"hash-{i}", + }); + } + db.SaveChanges(); + } + + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + // Still dispatches — the advisory is non-blocking. + coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); + reply.Message.ShouldNotBeNull(); + reply.Message.ShouldContain("will compile"); + reply.Message.ShouldContain($"{n} script"); // the distinct compiled count + + using var verify = dbFactory.CreateDbContext(); + verify.Deployments.Count().ShouldBe(1); + } + + /// Verifies that passthrough "mirror" scripts (return ctx.GetTag("X").Value;) + /// compile to ~nothing and therefore do NOT count toward the compile-cost advisory: a deploy + /// of only passthrough scripts is with no advisory. + [Fact] + public void StartDeployment_passthrough_scripts_do_not_count() + { + var dbFactory = NewInMemoryDbFactory(); + using (var db = dbFactory.CreateDbContext()) + { + for (var i = 0; i < 5; i++) + { + db.Scripts.Add(new Configuration.Entities.Script + { + ScriptId = $"mirror-{i}", + Name = $"mirror-{i}", + SourceCode = $"return ctx.GetTag(\"tag-{i}\").Value;", // passthrough mirror — costs ~nothing + SourceHash = $"mhash-{i}", + }); + } + db.SaveChanges(); + } + + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); + // No compiled scripts → no advisory emitted. + (reply.Message is null || !reply.Message.Contains("will compile")).ShouldBeTrue(); + } + + /// Verifies the advisory counts DISTINCT non-passthrough sources: many rows sharing one + /// identical source collapse to a single compiled unit (the compile cache keys on source), so the + /// advisory reports 1 — not the row count. + [Fact] + public void StartDeployment_duplicate_sources_collapse_to_one_compiled_unit() + { + const string identical = "return (int)ctx.GetTag(\"a\").Value + 1;"; + var dbFactory = NewInMemoryDbFactory(); + using (var db = dbFactory.CreateDbContext()) + { + for (var i = 0; i < 7; i++) + { + db.Scripts.Add(new Configuration.Entities.Script + { + ScriptId = $"dup-{i}", + Name = $"dup-{i}", + SourceCode = identical, // same source across all rows + SourceHash = "dup-hash", + }); + } + db.SaveChanges(); + } + + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); + reply.Message.ShouldNotBeNull(); + reply.Message.ShouldContain("will compile"); + reply.Message.ShouldContain("1 script(s) will compile"); + } + /// Verifies that starting a deployment is refused when another is in flight. [Fact] public void StartDeployment_refuses_when_another_is_in_flight()