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 6d368b54..169bf54a 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 @@ -1,9 +1,9 @@ { "planPath": "docs/plans/2026-06-07-virtualtag-script-memory.md", "tasks": [ - {"id": 1, "subject": "Task 1: A0 — extract script types into lean Roslyn-free assembly", "status": "pending", "classification": "high-risk"}, - {"id": 2, "subject": "Task 2: A0 measurement gate — re-run probe, confirm ~11x drop", "status": "pending", "classification": "small", "blockedBy": [1]}, - {"id": 3, "subject": "Task 3: A — passthrough fast-path in evaluator", "status": "pending", "classification": "small", "blockedBy": [1]}, + {"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": 5, "subject": "Task 5: live docker-dev verification (1036-vtag overlay, no OOM)", "status": "pending", "classification": "standard", "blockedBy": [1, 3, 4]} ], diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/PassthroughScript.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/PassthroughScript.cs new file mode 100644 index 00000000..c89a1fa7 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/PassthroughScript.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +using System.Text.RegularExpressions; + +/// +/// Classifies the trivial "mirror" VirtualTag script shape return ctx.GetTag("X").Value;, +/// which can be evaluated by returning the dependency value directly — no Roslyn compilation. +/// Narrow, exact pattern: any near-miss returns false and falls through to the Roslyn path. +/// +public static partial class PassthroughScript +{ + // ^ \s* return \s+ ctx . GetTag ( "X" ) . Value ; \s* $ (whitespace-tolerant around tokens) + [GeneratedRegex(@"^\s*return\s+ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)\s*\.\s*Value\s*;\s*$")] + private static partial Regex MirrorRegex(); + + /// True if is the mirror passthrough shape; outputs the referenced tag. + /// The VirtualTag script source to classify. + /// On success, the tag reference captured from the mirror shape; otherwise empty. + /// if the source is the mirror passthrough shape; otherwise . + public static bool TryMatch(string? source, out string tagRef) + { + tagRef = string.Empty; + if (string.IsNullOrEmpty(source)) return false; + var m = MirrorRegex().Match(source); + if (!m.Success) return false; + tagRef = m.Groups[1].Value; + return true; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs index 33bd98fb..4ec0049e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs @@ -50,6 +50,18 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed"); if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression"); + // A — passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no + // Roslyn. Narrow exact pattern; near-misses fall through to the compiled path below. + // Semantics are byte-identical to the compiled mirror: a present dependency returns its + // value (Ok), and an absent one makes GetTag yield a Bad snapshot whose .Value is null, + // so the script returns null — Ok(null) here too. + if (PassthroughScript.TryMatch(expression, out var passthroughRef)) + { + return dependencies.TryGetValue(passthroughRef, out var ptValue) + ? VirtualTagEvalResult.Ok(ptValue) + : VirtualTagEvalResult.Ok(null); + } + ScriptEvaluator evaluator; try { diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/PassthroughScriptTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/PassthroughScriptTests.cs new file mode 100644 index 00000000..466788bf --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/PassthroughScriptTests.cs @@ -0,0 +1,79 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// Unit tests for — the Roslyn-free classifier that recognises +/// the trivial mirror shape return ctx.GetTag("X").Value;. The pattern must be narrow: +/// any near-miss (arithmetic, a different member, multi-statement, a different method) falls +/// through so the Roslyn path stays authoritative. +/// +public sealed class PassthroughScriptTests +{ + /// The canonical mirror shape matches and captures the referenced tag. + [Fact] + public void Matches_canonical_mirror_and_captures_tag() + { + PassthroughScript.TryMatch("return ctx.GetTag(\"a\").Value;", out var tag).ShouldBeTrue(); + tag.ShouldBe("a"); + } + + /// A dotted / hierarchical tag reference is captured verbatim. + [Fact] + public void Captures_dotted_tag_reference() + { + PassthroughScript.TryMatch("return ctx.GetTag(\"Area1.Machine.Speed\").Value;", out var tag) + .ShouldBeTrue(); + tag.ShouldBe("Area1.Machine.Speed"); + } + + /// Whitespace around every token is tolerated. + [Fact] + public void Tolerates_surrounding_and_inner_whitespace() + { + PassthroughScript.TryMatch(" return ctx . GetTag( \"a\" ) . Value ; ", out var tag) + .ShouldBeTrue(); + tag.ShouldBe("a"); + } + + /// Arithmetic on the mirror value is NOT a passthrough. + [Fact] + public void Rejects_arithmetic_on_value() + { + PassthroughScript.TryMatch("return (int)ctx.GetTag(\"a\").Value + 1;", out _).ShouldBeFalse(); + } + + /// Reading a different member (StatusCode) is NOT a passthrough. + [Fact] + public void Rejects_other_member_access() + { + PassthroughScript.TryMatch("return ctx.GetTag(\"a\").StatusCode;", out _).ShouldBeFalse(); + } + + /// A multi-statement body is NOT a passthrough. + [Fact] + public void Rejects_multi_statement_body() + { + PassthroughScript.TryMatch("var x = ctx.GetTag(\"a\").Value; return x;", out _).ShouldBeFalse(); + } + + /// A different method name is NOT a passthrough. + [Fact] + public void Rejects_different_method() + { + PassthroughScript.TryMatch("return ctx.GetVirtualTag(\"a\").Value;", out _).ShouldBeFalse(); + } + + /// Null / empty / whitespace input is rejected. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Rejects_null_or_blank(string? source) + { + PassthroughScript.TryMatch(source, out var tag).ShouldBeFalse(); + tag.ShouldBe(string.Empty); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs index 25046078..520fa4ba 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Reflection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; @@ -95,4 +97,135 @@ public sealed class RoslynVirtualTagEvaluatorTests result.Success.ShouldBeFalse(); result.Reason!.ShouldContain("disposed"); } + + // ── A — passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` + // is answered directly from the dependency value, skipping Roslyn compilation. ── + + /// Mirror passthrough returns the dependency value verbatim without compiling. + [Fact] + public void Passthrough_returns_dependency_value_without_compiling() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + var result = sut.Evaluate( + virtualTagId: "vt-mirror", + expression: "return ctx.GetTag(\"a\").Value;", + dependencies: new Dictionary { ["a"] = 42 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(42); + } + + /// The passthrough fast-path returns the raw object reference, not a re-boxed copy — + /// proof the value flowed straight through without round-tripping a Roslyn script run. + [Fact] + public void Passthrough_returns_same_object_reference_as_dependency() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + var payload = new object(); + + var result = sut.Evaluate( + virtualTagId: "vt-ref", + expression: "return ctx.GetTag(\"a\").Value;", + dependencies: new Dictionary { ["a"] = payload }); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBeSameAs(payload); + } + + /// Whitespace-tolerant mirror shapes still take the passthrough fast-path. + [Fact] + public void Passthrough_whitespace_variants_match() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + var result = sut.Evaluate( + virtualTagId: "vt-ws", + expression: " return ctx . GetTag( \"a\" ) . Value ; ", + dependencies: new Dictionary { ["a"] = 7 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(7); + } + + /// A near-miss (arithmetic on the mirror value) falls through to Roslyn and still works. + [Fact] + public void Non_passthrough_falls_through_to_Roslyn() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + var result = sut.Evaluate( + virtualTagId: "vt-plus1", + expression: "return (int)ctx.GetTag(\"a\").Value + 1;", + dependencies: new Dictionary { ["a"] = 42 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(43); + } + + /// Passthrough with an absent dependency yields the SAME result the Roslyn path + /// produces: GetTag returns a Bad snapshot whose .Value is null, so the script + /// returns null and the evaluator wraps it as Ok(null) (success, null value). + [Fact] + public void Passthrough_missing_dependency_matches_Roslyn_behaviour() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + const string mirror = "return ctx.GetTag(\"missing\").Value;"; + + // Roslyn baseline: same source, but force a near-miss that compiles, to capture the + // not-found semantics independently. Here we just assert the mirror's own missing-dep + // result equals the documented Ok(null) shape. + var passthrough = sut.Evaluate("vt-miss", mirror, new Dictionary()); + + passthrough.Success.ShouldBeTrue(passthrough.Reason); + passthrough.Value.ShouldBeNull(); + passthrough.Reason.ShouldBeNull(); + } + + /// Cross-check: the equivalent Roslyn-compiled read of a missing dependency + /// produces exactly the same Ok(null) result, proving the fast-path is byte-identical. + [Fact] + public void Roslyn_missing_dependency_also_returns_Ok_null() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + // `(object?)...Value` forces the compiled path (not the mirror shape) but reads the same + // missing tag; result must match the passthrough missing-dep result above. + var result = sut.Evaluate( + "vt-miss-roslyn", + "return (object?)ctx.GetTag(\"missing\").Value;", + new Dictionary()); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBeNull(); + result.Reason.ShouldBeNull(); + } + + /// Decisive proof the fast-path skips Roslyn: the compiled-script cache (which every + /// Roslyn evaluation populates via GetOrAdd) stays EMPTY after a mirror evaluation, then + /// grows to one entry once a genuine (non-mirror) expression forces compilation. + [Fact] + public void Passthrough_does_not_populate_the_compiled_script_cache() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + // Mirror shape — must take the fast-path, leaving the Roslyn cache untouched. + sut.Evaluate("vt-mirror", "return ctx.GetTag(\"a\").Value;", + new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(0); + + // Non-mirror shape — must compile, populating exactly one cache entry. + sut.Evaluate("vt-real", "return (int)ctx.GetTag(\"a\").Value + 1;", + new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(1); + } + + /// Reads the count of the private compiled-script cache via reflection. + private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut) + { + var field = typeof(RoslynVirtualTagEvaluator) + .GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic); + field.ShouldNotBeNull(); + var cache = (ICollection)field.GetValue(sut)!; + return cache.Count; + } }