diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json index 8b4e615f..c866f268 100644 --- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json +++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json @@ -71,7 +71,7 @@ { "id": "R2-03-T10", "subject": "P7 fix: gate the OnApply ClearCompiledScripts on HashSet.SetEquals of desired vs _planByVtag expression sets (ordinal), with explanatory comment (turns T9 green)", - "status": "pending", + "status": "completed", "blockedBy": [ "R2-03-T9" ] diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 4f63c9ae..52f007fb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -85,11 +85,24 @@ public sealed class VirtualTagHostActor : ReceiveActor private void OnApply(ApplyVirtualTags msg) { - // Apply-boundary compile-cache drop: each deploy generation may carry edited script sources, and - // the production evaluator roots one collectible AssemblyLoadContext per compiled expression. Drop - // them here (mirroring ScriptedAlarmEngine's per-generation clear) so stale ALCs are reclaimed - // instead of accreting across edits. No-op for evaluators without a cache (NullVirtualTagEvaluator). - (_evaluator as IScriptCacheOwner)?.ClearCompiledScripts(); + // Apply-boundary compile-cache drop (gated — 02/P7): the production evaluator roots one + // collectible AssemblyLoadContext per compiled expression, so a changed script set must drop the + // stale ALCs. But clearing on EVERY apply forces a full recompilation even on a byte-identical + // redeploy (and each unnecessary clear is an unnecessary 02/S12 race window). Clear only when the + // deployed EXPRESSION set actually changed. The cache is keyed by source only, so a rename / + // FolderPath move / Historize toggle (which respawns the child but keeps the source) needs no + // recompile; set semantics also mean two vtags sharing one expression keep it alive while either + // remains. _planByVtag still holds the PREVIOUS generation here (it is rebuilt to the desired set + // at the end of this method); on the first apply after (re)start it is empty, so the sets differ + // and the clear fires (harmless — converges the singleton evaluator's cache to this generation). + // NOTE: this gate reduces clear FREQUENCY only; the S12 retry-once in RoslynVirtualTagEvaluator — + // not this gate — is the correctness fix for a clear racing an in-flight evaluation. + var newExpressions = msg.Plans.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal); + var prevExpressions = _planByVtag.Values.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal); + if (!newExpressions.SetEquals(prevExpressions)) + { + (_evaluator as IScriptCacheOwner)?.ClearCompiledScripts(); + } var desired = new HashSet(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);