feat(deploy): warn-only script-compile-cost advisory on deploy
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<!-- Roslyn-free; gives HandleStartDeploymentAsync PassthroughScript.TryMatch to exclude
|
||||
mirror scripts (which compile to ~nothing) from the deploy compile-cost advisory. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -189,6 +189,120 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
||||
verify.Deployments.Count().ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough
|
||||
/// (genuinely-compiled) Script rows yields an <see cref="StartDeploymentOutcome.Accepted"/>
|
||||
/// deploy whose <see cref="StartDeploymentResult.Message"/> carries a compile-cost advisory.
|
||||
/// The guardrail NEVER rejects — it only surfaces the estimated RSS pressure to the operator.</summary>
|
||||
[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<IDriverProbe>()));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
// Still dispatches — the advisory is non-blocking.
|
||||
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
||||
|
||||
var reply = ExpectMsg<StartDeploymentResult>(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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that passthrough "mirror" scripts (<c>return ctx.GetTag("X").Value;</c>)
|
||||
/// compile to ~nothing and therefore do NOT count toward the compile-cost advisory: a deploy
|
||||
/// of only passthrough scripts is <see cref="StartDeploymentOutcome.Accepted"/> with no advisory.</summary>
|
||||
[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<IDriverProbe>()));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
||||
|
||||
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
||||
// No compiled scripts → no advisory emitted.
|
||||
(reply.Message is null || !reply.Message.Contains("will compile")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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<IDriverProbe>()));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
||||
|
||||
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldContain("will compile");
|
||||
reply.Message.ShouldContain("1 script(s) will compile");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that starting a deployment is refused when another is in flight.</summary>
|
||||
[Fact]
|
||||
public void StartDeployment_refuses_when_another_is_in_flight()
|
||||
|
||||
Reference in New Issue
Block a user