mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
d08d4faec1
# Description Consolidate the complete B01/B02 NodeState memory work into one PR. - Replace eager cold-path NodeState locks with lazy canonical publication and update the event-monitor counter atomically. - Store six design-metadata properties and three security properties in separate lazy private bags while preserving public APIs, null/empty distinctions, masks, copying and existing serialization behavior. - Avoid allocating the ReportEvent scheduling closure when the async-dispatch path is unused. - Retain the logger factory's category logger directly, avoiding a redundant typed wrapper on telemetry initialization. - Keep Variant at 24 bytes while removing eligible outer boxes for QualifiedName, NodeId, NET8+ ByteString and text-only LocalizedText values. - Add reproducible allocation/layout/occupancy fixtures, real registered and monitored populations, reference contracts, generated-consumer coverage and NativeAOT cases. ## Measured allocation changes Matched .NET 10.0.11 x64 observations against the integrated master baseline: | Construction/assignment | Master | This PR | |---|---:|---:| | Bare/minimal object | 824 B | 640 B | | Bare/minimal variable | 1160 B | 976 B | | Variable with both optional groups populated or reset | 1160 B | 1088 B | | Variable with a shared ByteString value | 1192 B | 976 B | Separate paired measurements show: - telemetry initialization: 24 B typed-logger wrapper removed; - eligible QualifiedName, NodeId and ByteString Variant construction: 32 B to 0 B; - eligible text-only LocalizedText Variant construction: 40 B to 0 B; - variable attributes read into caller-provided Variant destinations: 136 B to 0 B. These are runtime-specific per-operation observations, not additive whole-server savings. Optional bags retain their documented populated/high-water premiums and are not reclaimed on reset. ## Reference-storage decision A complete private indexed reference backend and a sorted-traversal alternative were measured, but both changed the existing filtered hash-index enumeration order. They were rejected despite lower memory use. The production reference backend remains unchanged; strengthened ordering, identity, serialization and concurrency contracts are retained. Adaptive small-set storage remains deferred. ## Consolidation This PR now contains all changes from #4451 and the former narrower #4452 branch. Every #4452 changed path was present in #4451; after merging, the #4451 head `836e45226` is a direct ancestor of this branch. #4451 is closed as superseded. Draft #4425 remains unchanged and is also superseded by this consolidated work. ## Review feedback - Removed the standalone Variant storage document and its README entry. - Made the raw NodeId constructor private; the documented internal `GetRawState`/`SetRawState` bridge is explicitly Variant-only and must never become public. - Marked the two 32-thread counter tests non-parallel. - Added 10-second timeouts to the concurrent browse benchmark's main and worker barriers and completion waits. - Migrated the registered-population fixture to the current async `SealAsync` API. All review threads on #4451 and #4452 were replied to with the action and fix commit, then resolved. ## Related issues - Related to #4275. - Supersedes #4425 and #4451. ## Validation - Consolidated Types/Core/Server selection: **1241 passed** on net10.0. - Consolidated Types/Core/Server selection: **1238 passed** on net48. - Focused #4451 feedback selection: **189 passed** on net10.0 and **187 passed** on net48. - Earlier integrated NativeAOT validation passed **163/163** cases across the main, MCP and Historian applications; current PR CI provides the authoritative final merged-source native validation. The first #4452 CI run was blocked by merge conflicts. Seven later jobs completed their tests but encountered a shared artifact-service HTTP 403 while uploading results; no workflow or test was weakened to mask that infrastructure failure. Generated `report.*.json` crash artifacts remain untracked and are not part of this PR. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove the changes and improve coverage. - [x] I have added the necessary current-behavior documentation. - [ ] I have verified that the fresh consolidated CI run introduces no build or analyzer warnings. - [ ] I ran the complete UA.slnx suite locally on net48 and net10.0 with no failures. - [ ] I fixed all failing and flaky CI tests and all CodeQL warnings. - [x] I have addressed all PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2569e2e-7115-4969-bb44-8159e302b450
381 lines
18 KiB
C#
381 lines
18 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
* of this software and associated documentation files (the "Software"), to deal
|
|
* in the Software without restriction, including without limitation the rights
|
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the Software is
|
|
* furnished to do so, subject to the following conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be included in
|
|
* all copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
* THE SOFTWARE.
|
|
* ======================================================================*/
|
|
|
|
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Runtime;
|
|
using System.Runtime.InteropServices;
|
|
using System.Runtime.Versioning;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using NUnit.Framework;
|
|
|
|
namespace Opc.Ua.Tests
|
|
{
|
|
/// <summary>
|
|
/// Managed-test-only evidence writer shared by node and server population fixtures.
|
|
/// </summary>
|
|
internal sealed class NodeStateMemoryEvidence : IDisposable
|
|
{
|
|
internal NodeStateMemoryEvidence(
|
|
string output,
|
|
string sourceRoot,
|
|
string? sdk = null,
|
|
string? command = null,
|
|
string? revision = null)
|
|
{
|
|
sourceRoot = Path.GetFullPath(sourceRoot).TrimEnd(Path.DirectorySeparatorChar);
|
|
output = Path.GetFullPath(output);
|
|
if (output.Equals(sourceRoot, StringComparison.OrdinalIgnoreCase) ||
|
|
output.StartsWith(sourceRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new ArgumentException("Evidence must be outside the source tree.", nameof(output));
|
|
}
|
|
if (!File.Exists(Path.Combine(sourceRoot, "UA.slnx")))
|
|
{
|
|
throw new ArgumentException("Expected the source worktree used for this build.", nameof(sourceRoot));
|
|
}
|
|
Directory.CreateDirectory(output);
|
|
m_samples = Create(output, "samples.csv");
|
|
m_occupancy = Create(output, "occupancy.csv");
|
|
m_samples.WriteLine("Metric,Scenario,Sample,Count,Total,PerUnit");
|
|
m_occupancy.WriteLine(
|
|
"Phase,NodeId,ConcreteType,Groups,CallbackSlots,Description,Metadata,Security," +
|
|
"RoleCount,UserRoleCount,Children,DynamicChildren,ExplicitReferences,BrowseReferences," +
|
|
"SynthesizedReferences,Notifiers");
|
|
using StreamWriter definitions = Create(output, "definitions.csv");
|
|
definitions.WriteLine("Key,Value");
|
|
Pair(definitions, "Names",
|
|
"Cached: one global 16-character string. Unique: one X16 string per node.");
|
|
Pair(definitions, "NameSharing",
|
|
"NodeId when string, BrowseName, DisplayName, Description share that string.");
|
|
Pair(definitions, "Metadata",
|
|
"Both name modes share the same XML/list/URI payloads; bag cost is included.");
|
|
Pair(definitions, "Security",
|
|
"Cached: global permission entry/array. Unique: one entry/array per node.");
|
|
Pair(definitions, "Value",
|
|
"Double 42 except Payload cases: cached or newly allocated zero-filled 64 bytes.");
|
|
Pair(definitions, "ReferenceModes",
|
|
"0: cached NodeId converted; 1: new absolute wrapper; 2: preexpanded.");
|
|
Pair(definitions, "Allocation",
|
|
"Warm synchronous work on the calling thread only; includes discarded objects.");
|
|
Pair(definitions, "Live",
|
|
"Full-GC deltas; roots preallocated; shared services/payloads excluded; estimates only.");
|
|
Pair(definitions, "Index",
|
|
"Separate index component holds preexisting nodes; do not add to whole-manager.");
|
|
Pair(definitions, "Occupancy",
|
|
"Per-node rows allow group co-occurrence; no assumed deployment percentages.");
|
|
Pair(definitions, "Browse",
|
|
"Diagnostic browsing touches lazy state after memory measurement, never before.");
|
|
Pair(definitions, "WholeManager",
|
|
"Mock-backed test-manager graph includes context/mocks/index, not a server.");
|
|
Pair(definitions, "Telemetry",
|
|
"Field-free variable subclass invokes protected Initialize(ITelemetryContext); distinct from Create.");
|
|
using StreamWriter identity = Create(output, "identity.csv");
|
|
identity.WriteLine("Key,Value");
|
|
Pair(identity, "Schema", "NodeStateMemory-v1");
|
|
Pair(identity, "Utc", DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
|
|
Pair(identity, "Command", Environment.CommandLine);
|
|
Pair(identity, "Runtime", RuntimeInformation.FrameworkDescription);
|
|
Pair(identity, "Architecture", RuntimeInformation.ProcessArchitecture.ToString());
|
|
Pair(identity, "OS", RuntimeInformation.OSDescription);
|
|
Pair(identity, "ServerGC", GCSettings.IsServerGC.ToString());
|
|
Pair(identity, "LatencyMode", GCSettings.LatencyMode.ToString());
|
|
Pair(identity, "TieredCompilation",
|
|
Environment.GetEnvironmentVariable("DOTNET_TieredCompilation") ?? string.Empty);
|
|
Pair(identity, "SourceRoot", Path.GetFullPath(sourceRoot));
|
|
Pair(identity, "SDK", sdk ??
|
|
Environment.GetEnvironmentVariable("NODESTATE_MEMORY_SDK")
|
|
?? throw new InvalidOperationException("Set NODESTATE_MEMORY_SDK to dotnet --version."));
|
|
Pair(identity, "BuildCommand", command ??
|
|
Environment.GetEnvironmentVariable("NODESTATE_MEMORY_COMMAND")
|
|
?? throw new InvalidOperationException("Set NODESTATE_MEMORY_COMMAND to the fresh-build command."));
|
|
Pair(identity, "SourceRevision", revision ??
|
|
Environment.GetEnvironmentVariable("NODESTATE_MEMORY_REVISION")
|
|
?? throw new InvalidOperationException("Set NODESTATE_MEMORY_REVISION to git rev-parse HEAD."));
|
|
using StreamWriter assemblies = Create(output, "assemblies.csv");
|
|
assemblies.WriteLine("Name,Path,SHA256,MVID,TFM");
|
|
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()
|
|
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
|
|
.OrderBy(a => a.FullName, StringComparer.Ordinal))
|
|
{
|
|
assemblies.WriteLine(AssemblyRow(assembly));
|
|
}
|
|
using StreamWriter sources = Create(output, "sources.csv");
|
|
sources.WriteLine("Path,SHA256");
|
|
foreach (string path in SourceFiles(sourceRoot).OrderBy(p => p, StringComparer.Ordinal))
|
|
{
|
|
string relative = path[(sourceRoot.TrimEnd(Path.DirectorySeparatorChar).Length + 1)..];
|
|
sources.WriteLine($"{Csv(relative)},{HashFile(path)}");
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
m_samples.Dispose();
|
|
m_occupancy.Dispose();
|
|
}
|
|
|
|
internal void Sample(string metric, string scenario, int sample, int count, long total)
|
|
{
|
|
if (count <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(count));
|
|
}
|
|
m_samples.WriteLine(FormattableString.Invariant(
|
|
$"{Csv(metric)},{Csv(scenario)},{sample},{count},{total},{(double)total / count:F4}"));
|
|
m_samples.Flush();
|
|
}
|
|
|
|
internal void Occupancy(string phase, NodeState node, ISystemContext context)
|
|
{
|
|
m_occupancy.WriteLine(OccupancyRow(phase, node, context));
|
|
m_occupancy.Flush();
|
|
}
|
|
|
|
internal static string OccupancyRow(string phase, NodeState node, ISystemContext context)
|
|
{
|
|
var children = new List<BaseInstanceState>();
|
|
node.GetChildren(context, children);
|
|
var references = new List<IReference>();
|
|
node.GetReferences(context, references);
|
|
var notifiers = new List<NodeState.Notifier>();
|
|
node.GetNotifiers(context, notifiers);
|
|
int browseCount = 0;
|
|
int synthesized = 0;
|
|
using (INodeBrowser browser = node.CreateBrowser(
|
|
context, null, default, true, BrowseDirection.Both, default, default, false))
|
|
{
|
|
IReference? reference;
|
|
while ((reference = browser.Next()) is not null)
|
|
{
|
|
browseCount++;
|
|
if (!references.Exists(r => r.ReferenceTypeId == reference.ReferenceTypeId &&
|
|
r.IsInverse == reference.IsInverse &&
|
|
r.TargetId == reference.TargetId))
|
|
{
|
|
synthesized++;
|
|
}
|
|
}
|
|
}
|
|
var dynamicChildren = (List<BaseInstanceState>?)s_children.GetValue(node);
|
|
(string groups, int slots) = CallbackGroups(node);
|
|
bool metadata = node.Extensions is not null ||
|
|
node.Categories is not null ||
|
|
node.ReleaseStatus != Export.ReleaseStatus.Released ||
|
|
node.Specification is not null ||
|
|
node.NodeSetDocumentation is not null ||
|
|
node.DesignToolOnly;
|
|
bool security = !node.RolePermissions.IsNull ||
|
|
!node.UserRolePermissions.IsNull ||
|
|
node.AccessRestrictions.HasValue;
|
|
return
|
|
$"{Csv(phase)},{Csv(node.NodeId.ToString())},{Csv(node.GetType().FullName ?? string.Empty)}," +
|
|
$"{Csv(groups)},{slots},{!node.Description.IsNull},{metadata},{security}," +
|
|
$"{(node.RolePermissions.IsNull ? -1 : node.RolePermissions.Count)}," +
|
|
$"{(node.UserRolePermissions.IsNull ? -1 : node.UserRolePermissions.Count)}," +
|
|
$"{children.Count},{dynamicChildren?.Count ?? 0},{references.Count},{browseCount}," +
|
|
$"{synthesized},{notifiers.Count}";
|
|
}
|
|
|
|
internal static (string Groups, int Slots) CallbackGroups(NodeState node)
|
|
{
|
|
var groups = new SortedSet<string>(StringComparer.Ordinal);
|
|
int slots = 0;
|
|
for (Type? type = node.GetType(); type is not null; type = type.BaseType)
|
|
{
|
|
foreach (FieldInfo field in type.GetFields(
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
|
{
|
|
if (!typeof(Delegate).IsAssignableFrom(field.FieldType) || field.GetValue(node) is not Delegate)
|
|
{
|
|
continue;
|
|
}
|
|
slots++;
|
|
string group = field.Name switch
|
|
{
|
|
"StateChanged" or "StateChangedAsync" or "EventReported" => "EventSubscription",
|
|
"OnSimpleReadValue" or "OnSimpleWriteValue" or "OnReadValue" or "OnWriteValue" or
|
|
"OnReadValueAsync" or "OnWriteValueAsync" or "OnSimpleReadValueAsync" or
|
|
"OnSimpleWriteValueAsync" => "Value",
|
|
_ when type == typeof(NodeState) =>
|
|
field.Name.StartsWith("OnRead", StringComparison.Ordinal) ||
|
|
field.Name.StartsWith("OnWrite", StringComparison.Ordinal) ? "BaseAttribute" : "Behavior",
|
|
_ when type == typeof(BaseVariableState) => "VariableAttribute",
|
|
_ when type == typeof(MethodState) => "Method",
|
|
_ => "Derived"
|
|
};
|
|
groups.Add(group);
|
|
}
|
|
}
|
|
return (string.Join("+", groups), slots);
|
|
}
|
|
|
|
internal static string AssemblyRow(Assembly assembly)
|
|
{
|
|
return $"{Csv(assembly.GetName().Name ?? string.Empty)},{Csv(assembly.Location)}," +
|
|
$"{HashFile(assembly.Location)}," +
|
|
$"{assembly.ManifestModule.ModuleVersionId:D}," +
|
|
$"{Csv(assembly.GetCustomAttribute<TargetFrameworkAttribute>()?.FrameworkName ?? string.Empty)}";
|
|
}
|
|
|
|
internal static string HashFile(string path)
|
|
{
|
|
using FileStream stream = File.OpenRead(path);
|
|
using var algorithm = SHA256.Create();
|
|
#if NET5_0_OR_GREATER
|
|
return Convert.ToHexString(algorithm.ComputeHash(stream));
|
|
#else
|
|
return BitConverter.ToString(algorithm.ComputeHash(stream)).Replace("-", string.Empty);
|
|
#endif
|
|
}
|
|
|
|
internal static string Csv(string value)
|
|
{
|
|
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
|
|
}
|
|
|
|
internal static long FullHeap()
|
|
{
|
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
|
|
GC.WaitForPendingFinalizers();
|
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
|
|
return GC.GetTotalMemory(forceFullCollection: true);
|
|
}
|
|
|
|
internal static IEnumerable<string> SourceFiles(string root)
|
|
{
|
|
foreach (string file in Directory.EnumerateFiles(root))
|
|
{
|
|
string extension = Path.GetExtension(file);
|
|
if (extension is ".cs" or ".csproj" or ".props" or ".targets" or ".json" or ".slnx" or
|
|
".xml" or ".csv" or ".resx" or ".config" ||
|
|
Path.GetFileName(file) == ".editorconfig")
|
|
{
|
|
yield return file;
|
|
}
|
|
}
|
|
foreach (string directory in Directory.EnumerateDirectories(root))
|
|
{
|
|
string name = Path.GetFileName(directory);
|
|
if (name is "bin" or "obj" or "TestResults" or "BenchmarkDotNet.Artifacts" ||
|
|
name[0] == '.')
|
|
{
|
|
continue;
|
|
}
|
|
foreach (string file in SourceFiles(directory))
|
|
{
|
|
yield return file;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static StreamWriter Create(string directory, string name)
|
|
{
|
|
return new StreamWriter(
|
|
new FileStream(Path.Combine(directory, name), FileMode.CreateNew, FileAccess.Write, FileShare.Read),
|
|
new UTF8Encoding(false));
|
|
}
|
|
|
|
private static void Pair(StreamWriter writer, string key, string value)
|
|
{
|
|
writer.WriteLine($"{Csv(key)},{Csv(value)}");
|
|
}
|
|
|
|
private readonly StreamWriter m_samples;
|
|
private readonly StreamWriter m_occupancy;
|
|
|
|
private static readonly FieldInfo s_children = typeof(NodeState).GetField(
|
|
"m_children", BindingFlags.Instance | BindingFlags.NonPublic)
|
|
?? throw new InvalidOperationException("Update the diagnostic dynamic-child probe for this node layout.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks the shared CSV/source writer in each consuming test assembly and target framework.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("NodeStateOccupancy")]
|
|
public sealed class NodeStateMemoryEvidenceTests
|
|
{
|
|
/// <summary>
|
|
/// Output pins actual input bytes and sample units, and cannot overwrite a baseline.
|
|
/// </summary>
|
|
[Test]
|
|
public void EvidencePreservesSourceIdentityAndSampleUnits()
|
|
{
|
|
string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
|
string output = root + "-evidence";
|
|
Directory.CreateDirectory(root);
|
|
string solution = Path.Combine(root, "UA.slnx");
|
|
File.WriteAllText(solution, "abc", new UTF8Encoding(false));
|
|
try
|
|
{
|
|
Assert.Throws<ArgumentException>(() =>
|
|
{
|
|
using var invalid = new NodeStateMemoryEvidence(Path.Combine(root, "output"), root);
|
|
});
|
|
using (var evidence = new NodeStateMemoryEvidence(
|
|
output, root, "test-sdk", "test-command", "test-revision"))
|
|
{
|
|
evidence.Sample("Bytes", "TwoNodes", 1, 2, 20);
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => evidence.Sample("Bytes", "Invalid", 1, 0, 20));
|
|
}
|
|
string samples = File.ReadAllText(Path.Combine(output, "samples.csv"));
|
|
Assert.That(samples, Does.Contain("\"Bytes\",\"TwoNodes\",1,2,20,10.0000"));
|
|
string sources = File.ReadAllText(Path.Combine(output, "sources.csv"));
|
|
Assert.That(sources, Does.Contain(
|
|
"\"UA.slnx\",BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"));
|
|
string identity = File.ReadAllText(Path.Combine(output, "identity.csv"));
|
|
Assert.That(identity, Does.Contain("\"SDK\",\"test-sdk\""));
|
|
Assert.That(identity, Does.Contain("\"BuildCommand\",\"test-command\""));
|
|
Assert.That(identity, Does.Contain("\"SourceRevision\",\"test-revision\""));
|
|
Assert.Throws<IOException>(() =>
|
|
{
|
|
using var duplicate = new NodeStateMemoryEvidence(output, root, "test-sdk", "command", "revision");
|
|
});
|
|
Assert.That(File.ReadAllText(Path.Combine(output, "samples.csv")), Is.EqualTo(samples));
|
|
}
|
|
finally
|
|
{
|
|
foreach (string name in s_evidenceNames)
|
|
{
|
|
File.Delete(Path.Combine(output, name));
|
|
}
|
|
File.Delete(solution);
|
|
Directory.Delete(output);
|
|
Directory.Delete(root);
|
|
}
|
|
}
|
|
|
|
private static readonly string[] s_evidenceNames =
|
|
["samples.csv", "occupancy.csv", "definitions.csv", "identity.csv", "assemblies.csv", "sources.csv"];
|
|
}
|
|
}
|