feat(vtag): passthrough fast-path skips Roslyn for mirror scripts (A)
This commit is contained in:
@@ -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]}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies the trivial "mirror" VirtualTag script shape <c>return ctx.GetTag("X").Value;</c>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>True if <paramref name="source"/> is the mirror passthrough shape; outputs the referenced tag.</summary>
|
||||
/// <param name="source">The VirtualTag script source to classify.</param>
|
||||
/// <param name="tagRef">On success, the tag reference captured from the mirror shape; otherwise empty.</param>
|
||||
/// <returns><see langword="true"/> if the source is the mirror passthrough shape; otherwise <see langword="false"/>.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<VirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PassthroughScript"/> — the Roslyn-free classifier that recognises
|
||||
/// the trivial mirror shape <c>return ctx.GetTag("X").Value;</c>. 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.
|
||||
/// </summary>
|
||||
public sealed class PassthroughScriptTests
|
||||
{
|
||||
/// <summary>The canonical mirror shape matches and captures the referenced tag.</summary>
|
||||
[Fact]
|
||||
public void Matches_canonical_mirror_and_captures_tag()
|
||||
{
|
||||
PassthroughScript.TryMatch("return ctx.GetTag(\"a\").Value;", out var tag).ShouldBeTrue();
|
||||
tag.ShouldBe("a");
|
||||
}
|
||||
|
||||
/// <summary>A dotted / hierarchical tag reference is captured verbatim.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Whitespace around every token is tolerated.</summary>
|
||||
[Fact]
|
||||
public void Tolerates_surrounding_and_inner_whitespace()
|
||||
{
|
||||
PassthroughScript.TryMatch(" return ctx . GetTag( \"a\" ) . Value ; ", out var tag)
|
||||
.ShouldBeTrue();
|
||||
tag.ShouldBe("a");
|
||||
}
|
||||
|
||||
/// <summary>Arithmetic on the mirror value is NOT a passthrough.</summary>
|
||||
[Fact]
|
||||
public void Rejects_arithmetic_on_value()
|
||||
{
|
||||
PassthroughScript.TryMatch("return (int)ctx.GetTag(\"a\").Value + 1;", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Reading a different member (StatusCode) is NOT a passthrough.</summary>
|
||||
[Fact]
|
||||
public void Rejects_other_member_access()
|
||||
{
|
||||
PassthroughScript.TryMatch("return ctx.GetTag(\"a\").StatusCode;", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A multi-statement body is NOT a passthrough.</summary>
|
||||
[Fact]
|
||||
public void Rejects_multi_statement_body()
|
||||
{
|
||||
PassthroughScript.TryMatch("var x = ctx.GetTag(\"a\").Value; return x;", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A different method name is NOT a passthrough.</summary>
|
||||
[Fact]
|
||||
public void Rejects_different_method()
|
||||
{
|
||||
PassthroughScript.TryMatch("return ctx.GetVirtualTag(\"a\").Value;", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Null / empty / whitespace input is rejected.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Rejects_null_or_blank(string? source)
|
||||
{
|
||||
PassthroughScript.TryMatch(source, out var tag).ShouldBeFalse();
|
||||
tag.ShouldBe(string.Empty);
|
||||
}
|
||||
}
|
||||
+133
@@ -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. ──
|
||||
|
||||
/// <summary>Mirror passthrough returns the dependency value verbatim without compiling.</summary>
|
||||
[Fact]
|
||||
public void Passthrough_returns_dependency_value_without_compiling()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-mirror",
|
||||
expression: "return ctx.GetTag(\"a\").Value;",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = 42 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[Fact]
|
||||
public void Passthrough_returns_same_object_reference_as_dependency()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
var payload = new object();
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-ref",
|
||||
expression: "return ctx.GetTag(\"a\").Value;",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = payload });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBeSameAs(payload);
|
||||
}
|
||||
|
||||
/// <summary>Whitespace-tolerant mirror shapes still take the passthrough fast-path.</summary>
|
||||
[Fact]
|
||||
public void Passthrough_whitespace_variants_match()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-ws",
|
||||
expression: " return ctx . GetTag( \"a\" ) . Value ; ",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = 7 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(7);
|
||||
}
|
||||
|
||||
/// <summary>A near-miss (arithmetic on the mirror value) falls through to Roslyn and still works.</summary>
|
||||
[Fact]
|
||||
public void Non_passthrough_falls_through_to_Roslyn()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-plus1",
|
||||
expression: "return (int)ctx.GetTag(\"a\").Value + 1;",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = 42 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(43);
|
||||
}
|
||||
|
||||
/// <summary>Passthrough with an absent dependency yields the SAME result the Roslyn path
|
||||
/// produces: <c>GetTag</c> returns a Bad snapshot whose <c>.Value</c> is null, so the script
|
||||
/// returns null and the evaluator wraps it as <c>Ok(null)</c> (success, null value).</summary>
|
||||
[Fact]
|
||||
public void Passthrough_missing_dependency_matches_Roslyn_behaviour()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.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<string, object?>());
|
||||
|
||||
passthrough.Success.ShouldBeTrue(passthrough.Reason);
|
||||
passthrough.Value.ShouldBeNull();
|
||||
passthrough.Reason.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Cross-check: the equivalent Roslyn-compiled read of a missing dependency
|
||||
/// produces exactly the same <c>Ok(null)</c> result, proving the fast-path is byte-identical.</summary>
|
||||
[Fact]
|
||||
public void Roslyn_missing_dependency_also_returns_Ok_null()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.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<string, object?>());
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBeNull();
|
||||
result.Reason.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Decisive proof the fast-path skips Roslyn: the compiled-script cache (which every
|
||||
/// Roslyn evaluation populates via <c>GetOrAdd</c>) stays EMPTY after a mirror evaluation, then
|
||||
/// grows to one entry once a genuine (non-mirror) expression forces compilation.</summary>
|
||||
[Fact]
|
||||
public void Passthrough_does_not_populate_the_compiled_script_cache()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
// Mirror shape — must take the fast-path, leaving the Roslyn cache untouched.
|
||||
sut.Evaluate("vt-mirror", "return ctx.GetTag(\"a\").Value;",
|
||||
new Dictionary<string, object?> { ["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<string, object?> { ["a"] = 1 });
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Reads the count of the private compiled-script cache via reflection.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user