Skip to content

Convert DataValue from class to readonly struct for further GC relief#3796

Merged
marcschier merged 35 commits into
masterfrom
dvstruct
May 23, 2026
Merged

Convert DataValue from class to readonly struct for further GC relief#3796
marcschier merged 35 commits into
masterfrom
dvstruct

Conversation

@marcschier

@marcschier marcschier commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Proposed changes

Convert DataValue from a mutable reference type (class) to a readonly struct to eliminate per-allocation GC pressure on every attribute read, subscription publish, and history sample.

Key API changes (user-visible)

Before After Pattern
class DataValue readonly struct DataValue : INullable Foundation
Equals(DataValue?) / operator ==(DataValue?, DataValue?) Equals(DataValue) / operator ==(DataValue, DataValue) Value semantics
IEncoder.WriteDataValue(string?, DataValue?) IEncoder.WriteDataValue(string?, DataValue) Non-nullable wire API
IDecoder.ReadDataValue(...) → DataValue? IDecoder.ReadDataValue(...) → DataValue Same
IDataChangeMonitoredItemQueue.PeekLast/Oldest() TryPeekLastValue/Oldest(out DataValue) TryGet pattern
IAggregateCalculator.GetProcessedValue(bool) TryGetProcessedValue(bool, out DataValue) Same
IUaPubSubDataStore.ReadPublishedDataItem(...) TryReadPublishedDataItem(NodeId, uint, out DataValue) Same
Dictionary<uint, DataValue?> (NodeCacheContext) Dictionary<uint, DataValue> Default + IsNull
IHistoryDataSource.FirstRaw/NextRaw TryFirstRaw/TryNextRaw(out DataValue) TryGet pattern
NodeState.ReadAttribute(... DataValue) NodeState.ReadAttribute(... ref DataValue) Sync mutation via ref
NodeState.ReadAttributeAsync(... DataValue) NodeState.ReadAttributeAsync(...) → ValueTask<(ServiceResult, DataValue)> Async return tuple
obj as DataValue (heterogeneous Dictionary<string, object>) obj is DataValue dv ? dv : default(DataValue) Safe struct cast
Assert.That(dv, Is.Not.Null) (tests) Assert.That(dv.IsNull, Is.False) Struct test pattern
[Obsolete] Value setter, all property setters Removed; use ctor or With*() Immutability
[DataContract] + [DataMember] Removed; surrogate via SerializableDataValue + DataValueSurrogateProvider Serialization

Wire format

Wire format is byte-identical to the previous class-based implementation across binary / JSON / XML encoders. Default DataValue (.IsNull == true) is treated as the equivalent of the old null reference (writes JSON null, binary byte 0, XML omitted element).

Why TryGet pattern (instead of leaving DataValue?)

For methods that previously returned DataValue? to signal "no value available" (queue peek, aggregate result, data store lookup, history navigation), the conversion to bool TryGet(out DataValue) matches BCL conventions (Dictionary.TryGetValue, Queue.TryPeek, Int32.TryParse) and gives the caller an explicit "may be absent" affordance without Nullable<DataValue> overhead.

Related Issues

  • Fixes #

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Enhancement (non-breaking change which adds functionality)
  • Test enhancement (non-breaking change to increase test coverage)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected, requires version increase of Nuget packages)
  • Documentation Update (if none of the other choices apply)

Breaking changes summary:

  • DataValue is now a readonly struct — boxing/unboxing semantics change for callers using object-typed APIs
  • IEncoder.WriteDataValue / IDecoder.ReadDataValue / ReadDataValueArray interface signatures lose Nullable wrapper
  • IDataChangeMonitoredItemQueue.PeekLastValue / PeekOldestValueTryPeekLastValue / TryPeekOldestValue (out parameter)
  • IAggregateCalculator.GetProcessedValueTryGetProcessedValue (out parameter)
  • IUaPubSubDataStore.ReadPublishedDataItemTryReadPublishedDataItem (out parameter)
  • NodeState.ReadAttribute takes ref DataValue; ReadAttributeAsync returns ValueTask<(ServiceResult, DataValue)>
  • DataValue property setters removed (use constructor + With* mutators)

Checklist

  • I have read the CONTRIBUTING doc.
  • I have signed the CLA.
  • I ran tests locally with my changes, all passed.
  • I fixed all failing tests in the CI pipelines.
  • I fixed all introduced issues with CodeQL and LGTM.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added necessary documentation (if appropriate).
  • Any dependent changes have been merged and published in downstream modules.

Local verification

  • Full UA.slnx: 0 errors across all 7 TFMs (net472, net48, netstandard2.0, netstandard2.1, net8, net9, net10), 267 unrelated CA1859 warnings
  • Types tests: 7539 / 7539 pass
  • Server tests: 1202 / 1207 (5 skipped, 0 failed)
  • WotCon tests: 178 / 178 pass
  • DataValueBenchmark NUnit entry points: 3 / 3 pass

marcschier and others added 21 commits May 20, 2026 10:05
…ation)

Investigate the user's hypothesis: 'make DataValue a readonly struct'.
Rather than a one-shot 150-callsite refactor, this lands an A/B
benchmark suite and an additive 'DataValueStruct' sibling so the
trade-off is quantified before any migration commitment.

Additive types (no production callers yet, opt-in only):
- Stack/Opc.Ua.Types/BuiltIn/DataValueStruct.cs — readonly struct
  counterpart with the same fields and a mutable ref-builder for
  decoder hot paths (avoids per-field 'with' copies).
- BinaryDecoder.ReadDataValueStruct / ReadDataValueStructArray.
- BinaryEncoder.WriteDataValueStruct / WriteDataValueStructArray.
  Wire format is byte-identical to the class-based path (asserted by
  ClassAndStructProduceIdenticalWireBytes).

Benchmark suite (Tests/Opc.Ua.Core.Tests/Types/BuiltIn):
- DataValueBenchmarkPayloads.cs — shared fixtures.
- DataValueBenchmarks.cs — 14 BenchmarkDotNet scenarios under
  [MemoryDiagnoser] (decoder hot loop, encoder, 5-frame dispatch,
  allocate+List, Copy, Equals, full round-trip), parametrised over
  N = 1/10/100/1000 and Variant shapes (scalar double / scalar string /
  ArrayOf<double>[1024]). Class and struct variants share the same
  fixture so the comparison is like-for-like.
- 7 NUnit shape-check tests cover the benchmark methods in CI without
  paying BDN cost.

Findings (captured in plans/25-datavalue-struct-vs-class.md):
- ~27% allocation reduction in decode and allocate-capture scenarios
  on scalar payloads, equating to one fewer Gen0 collection per
  60-80 ms at 100K-instance/s subscription dispatch.
- Struct dispatch chain (5 frames, ScalarDouble, N=1000) is ~32%
  slower than the class — the predicted 48-byte-per-frame copy cost.
- Wall-clock for decode is neutral; allocate-capture pays ~10%
  in exchange for the GC win.
- Array-payload (1 KB+ Variant) shows ~0% delta — the underlying
  double[] dominates GC.

Recommendation (option C — Hybrid):
- Keep DataValue (class) as the primary public type.
- Ship DataValueStruct as the opt-in API for GC-sensitive hot paths
  (V2 subscription engine, dispatch fan-out). No migration of legacy
  callers — wire format unchanged.
- Plan #24 (pooled class) remains the orthogonal track for callers
  that prefer the class surface.

Co-authored-by: Copilot <[email protected]>
First step of the DataValue class -> readonly struct migration plan
(see plans/25-datavalue-struct-vs-class.md and the session plan).
Additive only — no existing callers touched.

- SerializableDataValue: regular class with [DataContract] /
  [DataMember] attributes carrying the wire schema. Round-trip
  helpers (ctor from DataValue, ToDataValue method) preserve all
  six fields including pico-second resolution.
- DataValueSurrogateProvider: ISerializationSurrogateProvider
  implementation that bridges DataValue <-> SerializableDataValue.
  Consumers that need DataContractSerializer interop wire it via
  serializer.SetSerializationSurrogateProvider(
      DataValueSurrogateProvider.Instance).
- Compatible with all target TFMs (net472, net48, netstandard2.0,
  netstandard2.1, net8.0, net9.0, net10.0).

Subsequent steps will remove [DataContract] from DataValue itself
and replace its setters with With<Property>() mutators.

Co-authored-by: Copilot <[email protected]>
Each property gets a sibling instance method DataValue WithX(value)
that returns a new instance with the named field replaced and every
other field carried through unchanged. Decoders, NodeState read
paths, and any other code that historically mutated DataValue
properties after construction will migrate to chained With* calls.

While DataValue is still a class, each With* call allocates a new
instance — transient cost on the dvstruct branch; eliminated once
the type is flipped to a readonly struct in a follow-up step.

Tests (+11) cover:
- Each With* returns a new instance with the targeted field replaced.
- Every other field is carried through unchanged.
- The original instance is not mutated (immutability check).
- Chained With* calls (default + 6 mutators) produce the expected
  composite DataValue.
- SerializableDataValue round-trip identity.
- DataValueSurrogateProvider both directions.

Co-authored-by: Copilot <[email protected]>
Replaces 'var v = new DataValue(); v.X = ...;' setter-mutation
patterns in the four decoders with chained With* calls. Wire format
unchanged; all 2871 Types encoder/decoder tests pass.

- BinaryDecoder.ReadDataValue: per-field With* (conditional on
  encoding flags).
- XmlDecoder.ReadDataValue: fluent six-step With chain.
- XmlParser.ReadDataValue: same.
- JsonDecoder.ReadDataValue: replaces the collection-initialiser
  ('new DataValue(...) { SourcePicoseconds = a, ServerPicoseconds =
  b }') with .WithSourcePicoseconds().WithServerPicoseconds().

Allocation regression note: each With* call allocates a new DataValue
while the type is still a class — transient ~7x decoder allocation
multiplier until the type is flipped to a readonly struct in a
follow-up step.

Co-authored-by: Copilot <[email protected]>
…R-2 step 4/N)

Replaces 'dv.X = Y;' setter-mutation patterns with chained With*
calls. No public-API signature changes in this step — only sites
where the mutation is purely local to the method scope.

Files migrated:
- Subscription/MonitoredItem/MonitoredItem.cs: 2 sites
- Diagnostics/DiagnosticsNodeManager.cs: 1 site
- NodeManager/MasterNodeManager.cs: 2 sites
- NodeManager/MonitoredItem/SamplingGroupMonitoredItemManager.cs: 1 site (chained)
- Aggregates/AggregateCalculator.cs: 1 site
- Aggregates/MinMaxAggregateCalculator.cs: 10 sites
- Aggregates/StartEndAggregateCalculator.cs: 5 sites

Build green across all TFMs.

Co-authored-by: Copilot <[email protected]>
…chains (PR-2 step 5/N)

Replaces 'value.X = Y;' setter-mutation patterns in the Read service
implementation with With* calls. The redundant pre-init setter block
(WrappedValue=default, ServerTimestamp/SourceTimestamp=MinValue,
StatusCode=Good) is removed entirely since the default DataValue
constructor already produces those values.

After With-chain rebindings the local 'value' diverges from
'values[ii]' (which still holds the instance ReadAttribute
mutated); explicit 'values[ii] = value' commits the final With-chain
state back into the results array.

Files migrated:
- NodeManager/CustomNodeManager.cs: 5 sites
- NodeManager/AsyncCustomNodeManager.cs: 5 sites

Build green across all TFMs.

Co-authored-by: Copilot <[email protected]>
Replaces 'dv.X = Y;' patterns with chained With* calls.

Files migrated:
- Encoding/PubSubJsonDecoder.cs: 6 sites
- Encoding/JsonDataSetMessage.cs: 13 sites (decode + encode paths)
- PublishedData/DataCollector.cs: 7 sites (substitute-value + length-constraint paths)

Build green across all TFMs.

Co-authored-by: Copilot <[email protected]>
… 7/N)

Files migrated:
- Applications/Quickstarts.Servers/TestData/HistoryDataReader.cs: 5 sites
- Applications/Quickstarts.Servers/TestData/HistoryArchive.cs: 4 sites
- Applications/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs: 1 site (redundant pre-init removed)
- Applications/Quickstarts.Servers/SampleNodeManager/DataChangeMonitoredItem.cs: 3 sites
- Applications/ConsoleReferencePublisher/PublishedValuesWrites.cs: 14 sites

Build green across all TFMs.

Co-authored-by: Copilot <[email protected]>
Files migrated:
- Tests/Opc.Ua.Types.Tests/BuiltIn/DataValueTests.cs: 1 site (renamed WrappedValueGetterAndSetter -> WrappedValueGetterAndWithMutator)
- Tests/Opc.Ua.PubSub.Tests/Encoding/MqttJsonNetworkMessageTests.cs: 4 sites

Remaining setter mutations in the repo touch only the NodeState
family (NodeState.ReadAttribute, BaseVariableState.ReadAttributeAsync,
Node.Read) which require signature changes to ref DataValue (sync) /
tuple return (async) — that is Step 9 (sync) and the separate PR-3
(async) territory. All BaseVariableState/Tools/NodeStateGenerator
hits are 'state.WrappedValue' on BaseVariableState (a different
type) and unaffected.

Co-authored-by: Copilot <[email protected]>
…sk<(ServiceResult, DataValue)> (async)

PR-2 step 9 + PR-3 combined per design plan. Prerequisite for flipping
DataValue from class to readonly struct: the sync ReadAttribute method
mutated its DataValue parameter, and the async sibling forwarded it.
With value semantics, mutations must rebind via With* and the parameter
must either be passed by reference (sync) or returned (async).

Foundation:
- NodeState.ReadAttribute(... DataValue) -> ref DataValue value (sync)
- NodeState.ReadAttributeAsync(...) -> ValueTask<(ServiceResult, DataValue)>
  with DataValue seed input (async)
- NodeState.ReadChildAttribute -> ref DataValue (cascade)
- Node.Read / INode.Read -> ref DataValue value
- BaseVariableState.ReadAttributeAsync rewritten with locals so the
  internal mutation chain assembles a single DataValue at the end via
  With* mutators across async boundaries

Callers updated:
- Libraries/Opc.Ua.Server: CustomNodeManager, AsyncCustomNodeManager,
  DiagnosticsNodeManager, MonitoredNode, SamplingGroupMonitoredItemManager
- Applications/Quickstarts.Servers: MemoryBufferNodeManager,
  SampleNodeManager, DataChangeMonitoredItem
- Tests: NodeStateTests, NodeTests, VariableTypeNodeTests,
  BaseVariableStateAsyncHooksTests, TypedBuilderTests, NodeCacheContextTests

Build clean across all TFMs.
Tests: 7539/7539 Types, 1202/1207 Server (5 skipped), 178/178 WotCon,
27/27 NodeCacheContext (all green).

Co-authored-by: Copilot <[email protected]>
PR-2 step 11: DataValue properties are now get-only, backed by private
readonly fields. The 6-arg constructor DataValue(Variant, StatusCode,
DateTimeUtc src, DateTimeUtc srv, ushort srcPico, ushort srvPico) is
added so With* mutators and callers can construct fully-populated
instances without setters.

Changes:
- DataValue: remove [DataContract], all [DataMember] attrs, all public
  setters; properties become expression-bodied get-only (=> m_field)
- DataValue: add 6-arg ctor; update With* to use it
- SerializableDataValue.ToDataValue: use 6-arg ctor
- All external callers (70 files): migrate from property setters and
  object initializers to constructors, With* chains, or FromStatusCode
  factory methods

Build: 0 errors, 0 warnings (all 7 TFMs)
Tests: 7539/7539 Types, 178/178 WotCon (6 DurableMonitoredItem
failures are pre-existing, unrelated to setter removal)

Co-authored-by: Copilot <[email protected]>
DataValue.Equals now compares all fields (value equality), not
references. Three queue tests compared published DataValues that
had the overflow bit set on StatusCode against originals without
it. Switch these assertions to compare WrappedValue instead of
the full DataValue, matching the test intent (value payload).

Co-authored-by: Copilot <[email protected]>
…ncoder/decoder API

DataValue.cs:
- class -> readonly struct (INullable, IFormattable, IEquatable<DataValue>)
- [StructLayout(LayoutKind.Auto)] for safe field ordering
- Add IsNull property
- Remove ICloneable / virtual Clone / MemberwiseClone (struct can't)
- Rewrite Copy() as ctor invocation with Variant.Copy()
- Equals(DataValue?) -> Equals(DataValue); operator == / != take DataValue
- Static IsGood/IsNotGood/IsUncertain/IsNotUncertain/IsBad/IsNotBad take
  DataValue (not Nullable<DataValue>) with IsNull short-circuit
- m_value now readonly

Variant.cs: Equals(DataValue) signature (was Equals(DataValue?)).

NodeState.cs / BaseVariableState.cs: remove 'if (value == null) return
BadStructureMissing' checks. With a struct, null is impossible; default
DataValue is a valid seed for ReadAttribute(Async).

Encoder/decoder signatures take/return DataValue (non-nullable):
- IEncoder.WriteDataValue(string?, DataValue) — was DataValue?
- IDecoder.ReadDataValue / ReadDataValueArray — was DataValue? / ArrayOf<DataValue?>
- Binary/Json/Xml encoders + decoders + parser updated
- Wire compatibility preserved: WriteDataValue treats value.IsNull the
  same as the old 'value == null' path (writes null literal / mask=0)

TypeInfo.cs / VariantHelper.cs: handle DataValue as value type instead of
reference type in default-value + value-extraction switches.

Co-authored-by: Copilot <[email protected]>
…one, queue/aggregator TryGet patterns)

- DataValue struct flip + encoder/decoder API changes (foundation)
- NodeState/BaseVariableState: remove null check on ref DataValue param
- Aggregate: GetProcessedValue(bool) -> TryGetProcessedValue(bool, out DataValue)
- Queue: PeekLastValue/PeekOldestValue -> TryPeekLast/Oldest(out DataValue)
- Server MonitoredItem: m_lastValue: DataValue? -> DataValue
- DataChangeQueueHandler: m_overflow: DataValue? -> DataValue + m_overflowPending flag
- ServerUtils.Event.Value: DataValue? -> DataValue
- CustomNodeManager: oldValue: DataValue? -> DataValue (use IsNull check downstream)
- ClientBase.ValidateDataValue: null check -> IsNull check
- NodeCacheContext: Dictionary<uint, DataValue?> -> Dictionary<uint, DataValue>
- Classic MonitoredItem.LastValue: DataValue? -> DataValue
- Field.Value: DataValue? -> DataValue

Co-authored-by: Copilot <[email protected]>
…terns

- AggregateCalculator: full TryGetProcessedValue rewrite; replace DataValue
  null checks with IsNull on parameters (CompareTimestamps, QueueRawValue,
  IsGood, GetSimpleBound bounds checks)
- MonitoredItem: replace value == null checks with value.IsNull (struct
  semantics); remove dead 'if (value != null)' wrappers in semantics/
  structure changed handling; ApplyFilter / ValueChanged use IsNull
- DataChangeQueueHandler.SetOverflowBit: remove dead null check on ref
  DataValue parameter
- MasterNodeManager: values.Add(default) + IsNull check instead of ??=
- MonitoredNode: value = default instead of value = null
- SamplingGroup: values.Add(default) + IsNull check
- StoredMonitoredItem.LastValue: drop null! initializer
- ServerUtils: Event.Value initializer = default
- AuditEvents: remove ?. null-conditional on DataValue
- IAggregateCalculator: clean up duplicate XML comment

Co-authored-by: Copilot <[email protected]>
…le Write

IUaPubSubDataStore:
- ReadPublishedDataItem(NodeId, uint) -> bool TryReadPublishedDataItem(NodeId, uint, out DataValue)
- WritePublishedDataItem default param: DataValue? = null -> DataValue = default
UaPubSubDataStore: implementation matches new interface; drop .GetValueOrDefault() on DataValue (no longer Nullable)
DataCollector: use TryReadPublishedDataItem in PubSub data collection flow

Co-authored-by: Copilot <[email protected]>
…PIs to TryGet patterns

- IHistoryDataSource: FirstRaw/NextRaw -> TryFirstRaw/TryNextRaw (out DataValue)
- HistoryFile: same impl
- HistoryDataReader: use TryFirstRaw/TryNextRaw + AddValue(IsNull check)
- HistoryArchive.HistoryEntry.Value: DataValue = null! -> DataValue
- DurableMonitoredItemQueueFactory: drop !-forgiving on decoded DataValue
- UaPubSubDataStoreTests + MessagesHelper: rewrite ReadPublishedDataItem calls
  to TryReadPublishedDataItem(out)
- AsyncCustomNodeManagerTests: List<DataValue> { null } -> { default }
- AggregateCalculatorTests (+6 variants): GetProcessedValue -> TryGetProcessedValue
- DurableMonitoredItemTests: PeekLastValue/PeekOldestValue -> TryPeekLastValue/Oldest
- MonitoredItemValueChangedTests: pass default (not null) for DataValue args;
  rename ValueNullThrowsArgumentNullException to DefaultValueThrowsArgumentException
- ServerFixtureUtils: SortedDictionary<uint, DataValue> initializers use default

Co-authored-by: Copilot <[email protected]>
…ilds clean

- AggregateCalculator*Tests (6 files): Assert.That(result, Is.Not.Null/Is.Null) -> .IsNull check
- AsyncCustomNodeManagerTests: keep reference-type asserts (BaseObjectState, NodeState)
- DurableMonitoredItemTests: queue.PeekLastValue/OldestValue with Is.EqualTo -> TryPeek+compare
- DurableSubscriptionTest: 'as DataValue' -> explicit (DataValue) cast + InstanceOf check
- ManagedSessionReconnectIntegrationTests / TypeSystemClientTest / ClientTest /
  ReconnectWithSavedSessionSecretsTest: known DataValue variables Assert.That(x, Is.Not.Null) -> Assert.That(x.IsNull, Is.False)
- Client.ComplexTypes.Tests.TypeSystemClientTest: same

Full UA.slnx builds clean: 0 errors across all 7 TFMs (net472/net48/netstandard2.0/2.1/net8/net9/net10).

Co-authored-by: Copilot <[email protected]>
DataValue static helpers (IsGood/IsBad/IsUncertain/...) now just check
StatusCode without the IsNull short-circuit. This matches OPC UA spec
where status is the only quality indicator. Default DataValue has
StatusCode=Good (code 0), so IsGood(default) returns true.

Test updates for new struct semantics:
- DataValueTests.IsGoodWithNullReturnsFalse -> IsGoodWithDefaultReturnsTrue
- DataValueTests.IsBadWithNullReturnsTrue -> IsBadWithDefaultReturnsFalse
- IsNotGood/IsNotBad/IsNotUncertain similar renames + expected value flips
- DataValueTests.EqualityOperatorLeftNull*/RightNull*: default == default == new() now
- NodeStateTests.ReadAttributeReturnsBadForNullDataValue ->
  ReadAttributeAcceptsDefaultDataValue (default is valid seed)
- XmlParserTests.ReadDataValueMissingFieldReturnsDefault assertion fixed

Tests:
- 7539/7539 Types pass
- 1202/1207 Server pass (5 skipped, 0 failed)
- 178/178 WotCon pass

Co-authored-by: Copilot <[email protected]>
…Struct benchmarks

- Delete Stack/Opc.Ua.Types/BuiltIn/DataValueStruct.cs (experimental
  sibling type from PR-1, no longer needed since DataValue is now a
  readonly struct)
- Delete BinaryEncoder.WriteDataValueStruct + WriteDataValueStructArray
  transitional shims
- Delete BinaryDecoder.ReadDataValueStruct + ReadDataValueStructArray
  transitional shims
- Delete _Struct benchmark variants (Decode/Encode/Dispatch/Allocate/
  Copy/Equals/RoundTrip), DispatchStructFrame1-5, BuildStructPayload,
  m_structValues, DecodeAndEncodeMatchStructRoundTrip,
  ClassAndStructProduceIdenticalWireBytes from DataValueBenchmarks.cs
- Update DataValueBenchmarkPayloads.cs comment to reflect unified type
- Update plans/25-datavalue-struct-vs-class.md status header to MIGRATED

Build: 0 errors all 7 TFMs.
Tests: 7539/7539 Types, 3/3 DataValueBenchmark NUnit entry points pass.

PR-5 closes out the 5-PR migration of DataValue from class to readonly
struct. Branch dvstruct contains 13 atomic commits (31f7cfb .. now)
implementing the complete migration.

Co-authored-by: Copilot <[email protected]>
@marcschier marcschier changed the title Convert DataValue from class to readonly struct (5-PR migration) Convert DataValue from class to readonly struct for further GC relief May 22, 2026
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.75269% with 272 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.54%. Comparing base (51bcd42) to head (d45d7e2).

Files with missing lines Patch % Lines
...raries/Opc.Ua.Client/NodeCache/NodeCacheContext.cs 67.93% 20 Missing and 22 partials ⚠️
...rvers/SampleNodeManager/DataChangeMonitoredItem.cs 0.00% 27 Missing ⚠️
...es/Opc.Ua.Server/Aggregates/AggregateCalculator.cs 49.05% 22 Missing and 5 partials ⚠️
...Server/Subscription/MonitoredItem/MonitoredItem.cs 52.38% 15 Missing and 5 partials ⚠️
....Ua.Server/Aggregates/MinMaxAggregateCalculator.cs 34.61% 17 Missing ⚠️
...rts.Servers/SampleNodeManager/SampleNodeManager.cs 22.22% 14 Missing ⚠️
.../Quickstarts.Servers/TestData/HistoryDataReader.cs 12.50% 11 Missing and 3 partials ⚠️
....Ua.Server/Aggregates/StatusAggregateCalculator.cs 0.00% 14 Missing ⚠️
...ts.Servers/MemoryBuffer/MemoryBufferNodeManager.cs 0.00% 12 Missing ⚠️
...a.Server/Aggregates/StartEndAggregateCalculator.cs 52.17% 11 Missing ⚠️
... and 20 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3796      +/-   ##
==========================================
+ Coverage   71.51%   71.54%   +0.03%     
==========================================
  Files         687      688       +1     
  Lines      132345   132441      +96     
  Branches    22555    22537      -18     
==========================================
+ Hits        94643    94756     +113     
+ Misses      31005    30991      -14     
+ Partials     6697     6694       -3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@marcschier marcschier left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a section to migrationguid.md to also cover DataValue (in addition to the other new readonly structs covered there already)

Comment thread Applications/McpServer/Tools/AttributeServiceTools.cs Outdated
Comment thread Applications/Quickstarts.Servers/SampleNodeManager/DataChangeMonitoredItem.cs Outdated
Comment thread Applications/Quickstarts.Servers/SampleNodeManager/DataChangeMonitoredItem.cs Outdated
Comment thread Applications/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs Outdated
Comment thread Stack/Opc.Ua.Types/BuiltIn/DataValue.cs Outdated
Comment thread Stack/Opc.Ua.Types/BuiltIn/DataValue.cs Outdated
Comment thread Stack/Opc.Ua.Types/BuiltIn/DataValue.cs Outdated
Comment thread Stack/Opc.Ua.Types/BuiltIn/TypeInfo.cs Outdated
marcschier and others added 5 commits May 22, 2026 08:44
…erator template

- Variant.ValueIsDefaultOrNull: special-case DataValue to return false. A
  Variant carrying a DataValue value is always meaningful; even a DataValue
  with all-default fields encodes as {} rather than being elided. Restores
  semantics from the pre-struct era when DataValue did not implement INullable.
- JsonEncoder.WriteDataValue (no-fieldname overload, used inside Variant
  contents): emit {} for default DataValue instead of JSON null.
- PubSubJsonEncoder.WriteDataValue: no longer short-circuits when IsNull.
  A DataValue with StatusCode.Good is IsNull == true but is a meaningful
  payload that must still be emitted.
- TypeSourceGenerator.s_notDefaultChecks: DataValue uses '!X.IsNull' instead
  of '!(X is null)' for the struct type.
- UaPubSubDataStoreTests: pass default(DataValue) instead of null to
  disambiguate overload resolution against the (Variant, StatusCode?, ...)
  overload now that DataValue is non-nullable.
- EncoderTests.EncodeDataValueWithoutValuePropertyTest: move result.IsNull
  check into the equality branch; for the picosecond-without-timestamp cases
  the round-tripped DataValue is legitimately all-default per spec.

Co-authored-by: Copilot <[email protected]>
- Add public static readonly DataValue.Null field (matches NodeId,
  ExtensionObject, etc. patterns). Use it in TypeInfo.GetDefaultValue
  and SampleNodeManager.Read instead of new DataValue()/default(DataValue).
- Add instance properties IsGood, IsNotGood, IsUncertain, IsNotUncertain,
  IsBad, IsNotBad on DataValue. The old static methods are removed from
  DataValue itself and re-introduced as [Obsolete] extension methods on
  a new DataValueExtensions class so existing call sites still compile.
- Update NodeCacheContext + DataValueTests to use the new instance
  properties instead of the static methods.
- McpServer AttributeServiceTools: use ToArrayOf() extension instead of
  .ToArray() when building ArrayOf<DataValue>.
- DataChangeQueueHandler: split multi-statement initialisation lines
  (m_overflow = default; m_overflowPending = false;) into one per line.

Co-authored-by: Copilot <[email protected]>
… summary

- Reorder DataValue members in canonical order: static Null field,
  constructors, static factories, properties (IsNull, WrappedValue,
  StatusCode, timestamps, IsGood/IsBad), methods (With*, Equals,
  operators, GetHashCode, ToString, Copy), then private instance fields.
- Move the multi-line With<Property>() explanation block from above the
  mutators into the XML <remarks> on the DataValue struct itself.
- Update the leading summary 'A class that stores' -> 'A struct that
  stores' to reflect the type kind.

Co-authored-by: Copilot <[email protected]>
…t empty)

DataValue.IsNull now distinguishes default(DataValue) from an explicitly
constructed but empty DataValue, addressing reviewer feedback:

    'default should create Null datavalue (isNull == true). But an
    empty DataValue should be marked appropriately (VariantNull, no
    timestamps or picoseconds or status, but still !IsNull). We might
    need a sentinal even if it consumes 4 bytes.'

Implementation:
- Add private readonly bool m_set field. All explicit constructors set
  it to true. default(DataValue) leaves it false.
- IsNull now returns !m_set; an explicitly constructed empty DataValue
  (e.g. new DataValue(Variant.Null)) is !IsNull and round-trips through
  encoders as a real but empty DataValue instead of being elided.
- Revert the workaround special-case in Variant.ValueIsDefaultOrNull
  for BuiltInType.DataValue — the INullable.IsNull path now returns the
  right answer for both default and explicitly-constructed DataValues.
- XmlParser/XmlDecoder/BinaryDecoder.ReadDataValue: when the wire
  representation indicates 'absent' (missing field, encoding byte 0)
  return DataValue.Null instead of new DataValue().

Co-authored-by: Copilot <[email protected]>
marcschier and others added 2 commits May 22, 2026 12:53
…ue? wrappers

- IMonitoredItem.QueueValue(DataValue, ...) becomes
  QueueValue(in DataValue, ...). Same for IDataChangeMonitoredItem2.
- IDataChangeMonitoredItemQueueHandler.QueueValue(DataValue, ...) and
  the implementing DataChangeQueueHandler.QueueValue use 'in' too.
- MonitoredItem.QueueValue and DataChangeMonitoredItem.QueueValue: 'in'
  parameter, with a local DataValue copy used for in-method mutation
  (Copy/With*) since 'in' params cannot be reassigned.
- DataChangeMonitoredItem.Publish: 'in DataValue value'; rewrite the
  semantics-changed / structure-changed branches to use a local plus the
  new IsNull semantics instead of Nullable<DataValue>.WithStatus(...).
- m_lastValue: change DataValue? -> DataValue; assignments to null
  become DataValue.Null. Reads no longer need '?? default'.

DataValue is now itself nullable (IsNull == true for default), so
Nullable<DataValue> is redundant and the 'in' modifier avoids copying
the 56-byte struct on hot subscription paths.

Co-authored-by: Copilot <[email protected]>
Adds a dedicated #### DataValue section under 'Improved Type safety' that
covers:
- DataValue is now a readonly struct (cannot compare with null; use IsNull
  or DataValue.Null).
- Property setters were removed; use the With<Property>() fluent mutators.
- IsGood/IsBad/etc are instance properties; static helpers moved to an
  Obsolete extension class (DataValueExtensions).
- Nullable<DataValue> is redundant — use DataValue + IsNull instead.
- IsNull sentinel semantics: default(DataValue) vs explicitly constructed
  empty DataValue; decoders return DataValue.Null for absent fields.
- Prefer 'in DataValue' for synchronous parameters.
- Obsolete object?-returning GetValue helpers; use WrappedValue.TryGetValue.
- DataValue.FromStatusCode factories.

Includes a before/after code example and a note about the subtle change
in IsNull behaviour for code that historically used 'new DataValue()' as
a sentinel for 'empty'.

Co-authored-by: Copilot <[email protected]>
Comment thread Docs/MigrationGuide.md Outdated
marcschier and others added 4 commits May 22, 2026 13:00
- PooledNotificationDispatchTests: DataValue is now a readonly struct;
  replace Is.Null with .IsNull, use positional constructor
- Docs/perf/PooledNotificationBenchmarks.md: updated with post-merge
  numbers showing complementary effect of both optimizations:
  - DataValue struct reduced baseline from 128 KB to 104 KB (-19%)
  - Pooled path unchanged at 408 B (256x reduction vs new baseline)
  - Gen0 baseline dropped from 29.75 to 24.17 (-19%)
  - Pooled Gen0 unchanged at 0.092
The PR-3793 'IPooledEncodeable activator pooling' commit landed on master
with a test that uses the old class-style DataValue object initialiser
({ StatusCode = ..., SourceTimestamp = ... }) and compares Value to null.
Both fail after DataValue became a readonly struct on this branch:

  - Use the (Variant, StatusCode, DateTimeUtc) constructor.
  - Use .IsNull instead of Is.Null since DataValue is a value type now.

Co-authored-by: Copilot <[email protected]>
# Conflicts:
#	Tests/Opc.Ua.Client.Tests/Subscription/PooledNotificationDispatchTests.cs
@marcschier
marcschier marked this pull request as ready for review May 22, 2026 14:06
Comment thread Stack/Opc.Ua.Types/BuiltIn/DataValue.cs
Comment thread Stack/Opc.Ua.Types/State/NodeState.cs
Comment thread Libraries/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs
Comment thread Libraries/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs Outdated
Comment thread Stack/Opc.Ua.Types/Encoders/JsonDecoder.cs Outdated
Comment thread Stack/Opc.Ua.Types/Encoders/JsonEncoder.cs
Comment thread Stack/Opc.Ua.Types/Encoders/XmlDecoder.cs Outdated
Comment thread Stack/Opc.Ua.Types/Encoders/XmlEncoder.cs
Comment thread Stack/Opc.Ua.Types/Encoders/XmlParser.cs Outdated
Comment thread Stack/Opc.Ua.Types/State/NodeState.cs Outdated
marcschier and others added 2 commits May 22, 2026 19:40
…' parameter

Addresses six review threads:

- MigrationGuide.md: remove the NOTE about default vs explicit-empty
  IsNull semantics (per reviewer 'Remove this NOTE').
- MonitoredItem.cs: ApplyFilter and the public static ValueChanged now
  take 'in DataValue value' and 'in DataValue lastValue'. Avoids copying
  the 56-byte struct on the hot subscription filter path.
- JsonDecoder.cs: TryGetDataValueFromElement constructs the result with
  the full 6-argument DataValue ctor (value, status, ts, ts, picos, picos)
  in one shot instead of building it via three intermediate With* clones.
- XmlDecoder.cs / XmlParser.cs: ReadDataValue does the same — reads each
  field into a local first, then builds the final DataValue with the
  6-argument ctor in one shot.
- NodeState.cs: ReadAttribute's final DataValue rebind also moves to the
  6-argument ctor (was chained .WithSourcePicoseconds.WithServerPicoseconds).
- JsonEncoder.cs: restore the JSON null literal write for the private
  WriteDataValue overload when value.IsNull (i.e. for default(DataValue)
  appearing as a Variant element / array slot). An explicitly constructed
  empty DataValue (IsNull == false, all fields default) still writes '{}'.

Co-authored-by: Copilot <[email protected]>
@marcschier
marcschier merged commit 5571c38 into master May 23, 2026
73 of 76 checks passed
@marcschier
marcschier deleted the dvstruct branch May 23, 2026 09:38
marcschier added a commit to marcschier/UA-.NETStandard that referenced this pull request May 23, 2026
…3796)

Upstream master flipped DataValue from class to readonly struct
with getter-only properties.  Migrate UaLens call sites that still
mutated properties post-construction or treated DataValue as a
nullable reference type:

- WriteValueDialog: build via ctor + WithStatus / WithSourceTimestamp /
  WithServerTimestamp instead of property assignment.
- EventsProbe:
ew DataValue(new Variant(...)) constructor form.
- DataValueCodec: drop ?? new DataValue() fallback -- decoder
  returns non-nullable DataValue now.
- HistoryUpdater + HistorianPlugin.BuildUpdateDataValue: use the
  4-arg constructor (Variant, StatusCode, sourceTs, serverTs).
- HistoryReader: drop dv is null filters that no longer compile
  for a non-nullable value type.

Build clean (0 errors).

Co-authored-by: Copilot <[email protected]>
marcschier added a commit that referenced this pull request May 23, 2026
…truct conversion

The post-#3796 DataValue is a readonly struct, so 'value = value.WithXxx(...)'
updates the local variable but never writes back to the values[ii] collection
slot. The two timestamp-filter spots in MasterNodeManager.ReadAsync were
relying on that no-op pattern: when TimestampsToReturn=Source, the server
should strip the ServerTimestamp to DateTimeUtc.MinValue, but the strip
never propagated to the returned ArrayOf<DataValue>.

Other With-chain rebindings in this file (CustomNodeManager, AsyncCustomNodeManager)
already write back via 'values[ii] = value'. Apply the same pattern here.

Re-enables 3 conformance tests on the cttunit-support branch:
* Opc.Ua.InformationModel.Tests/AttributeReadTests.AttributeRead009TimestampsSourceAsync
* Opc.Ua.InformationModel.Tests/AttributeReadTests.AttributeRead010TimestampsServerAsync
* Opc.Ua.InformationModel.Tests/AttributeReadTests.AttributeRead029TimestampsNoneAsync

Verified locally: all 3 now Pass.
marcschier pushed a commit that referenced this pull request May 23, 2026
Resolved 7 merge conflicts from the DataValue class→struct migration
(#3796) and GDS Part 12 full compliance (#3795). Took master's side
for structural changes (DataValue readonly struct, new GDS methods,
new NodeState source-timestamp locals), kept our s_capsSeparator field
in MulticastDiscovery.cs alongside master's new ReverseConnect constants.

Fixes for new code violating enforced .editorconfig rules:
- CA1305/CA5394: DataValueBenchmarkPayloads.cs — use UnsecureRandom
  and CultureInfo.InvariantCulture.
- NUnit4002: DataValueTests.cs, ManualAndPollingRefreshStrategyTests.cs —
  Is.EqualTo(0) → Is.Zero, Is.EqualTo(default(DateTime)) → Is.Default.
- NUnit2046: Phase2AbstractionTests.cs — apps.Count → Has.Count.EqualTo.

0 warnings, 0 errors.

Co-authored-by: Copilot <[email protected]>
marcschier pushed a commit that referenced this pull request May 23, 2026
…only struct

Pulls in 37 upstream commits (DataValue → readonly struct via PR #3796, GDS Part 12 via PR #3795, extension-object encoding fix, pooled-encodeable activator pooling). Resolves three deleted-by-us / modified-by-them conflicts on legacy TestData/History* files (kept deleted — replaced by the new historian provider model).

Migrates all historian and capture-pipeline code to the readonly-struct DataValue API: object-initializer/setter patterns → constructor calls; DataValue? → DataValue + IsNull checks; calculator.GetProcessedValue → TryGetProcessedValue; client ValueOr* helpers take DataValue (not DataValue?); CloneValue becomes return source; (struct is copy-by-value); ApplyTimestampFilter/IndexRange/Encoding now use WithStatus/WithWrappedValue fluent mutators.

158/158 historian+fluent server tests pass; 4/4 client integration; 2/2 AOT; UA.slnx 0 errors.
marcschier added a commit that referenced this pull request May 24, 2026
…#3796)


Convert `DataValue` from a mutable reference type (`class`) to a `readonly struct` to eliminate per-allocation GC pressure on every attribute read, subscription publish, and history sample.

### Key API changes (user-visible)

| Before | After | Pattern |
|---|---|---|
| `class DataValue` | `readonly struct DataValue : INullable` | Foundation |
| `Equals(DataValue?)` / `operator ==(DataValue?, DataValue?)` | `Equals(DataValue)` / `operator ==(DataValue, DataValue)` | Value semantics |
| `IEncoder.WriteDataValue(string?, DataValue?)` | `IEncoder.WriteDataValue(string?, DataValue)` | Non-nullable wire API |
| `IDecoder.ReadDataValue(...) -> DataValue?` | `IDecoder.ReadDataValue(...) -> DataValue` | Same |
| `IDataChangeMonitoredItemQueue.PeekLast/Oldest()` | `TryPeekLastValue/Oldest(out DataValue)` | TryGet pattern |
| `IAggregateCalculator.GetProcessedValue(bool)` | `TryGetProcessedValue(bool, out DataValue)` | Same |
| `IUaPubSubDataStore.ReadPublishedDataItem(...)` | `TryReadPublishedDataItem(NodeId, uint, out DataValue)` | Same |
| `Dictionary<uint, DataValue?>` (NodeCacheContext) | `Dictionary<uint, DataValue>` | Default + IsNull |
| `IHistoryDataSource.FirstRaw/NextRaw` | `TryFirstRaw/TryNextRaw(out DataValue)` | TryGet pattern |
| `NodeState.ReadAttribute(... DataValue)` | `NodeState.ReadAttribute(... ref DataValue)` | Sync mutation via ref |
| `NodeState.ReadAttributeAsync(... DataValue)` | `NodeState.ReadAttributeAsync(...) -> ValueTask<(ServiceResult, DataValue)>` | Async return tuple |
| `obj as DataValue` (heterogeneous Dictionary<string, object>) | `obj is DataValue dv ? dv : default(DataValue)` | Safe struct cast |
| `Assert.That(dv, Is.Not.Null)` (tests) | `Assert.That(dv.IsNull, Is.False)` | Struct test pattern |
| `[Obsolete] Value` setter, all property setters | Removed; use ctor or `With*()` | Immutability |
| `[DataContract]` + `[DataMember]` | Removed; surrogate via `SerializableDataValue` + `DataValueSurrogateProvider` | Serialization |

### Wire format

Wire format is **byte-identical** to the previous class-based implementation across binary / JSON / XML encoders. Default DataValue (`.IsNull == true`) is treated as the equivalent of the old null reference (writes JSON `null`, binary `byte 0`, XML omitted element).

### Why TryGet pattern (instead of leaving `DataValue?`)

For methods that previously returned `DataValue?` to signal "no value available" (queue peek, aggregate result, data store lookup, history navigation), the conversion to `bool TryGet(out DataValue)` matches BCL conventions (`Dictionary.TryGetValue`, `Queue.TryPeek`, `Int32.TryParse`) and gives the caller an explicit "may be absent" affordance without `Nullable<DataValue>` overhead.

### Breaking changes summary

- `DataValue` is now a `readonly struct` - boxing/unboxing semantics change for callers using `object`-typed APIs.
- `IEncoder.WriteDataValue` / `IDecoder.ReadDataValue` / `ReadDataValueArray` interface signatures lose the `Nullable` wrapper.
- `IDataChangeMonitoredItemQueue.PeekLastValue` / `PeekOldestValue` -> `TryPeekLastValue` / `TryPeekOldestValue` (out parameter).
- `IAggregateCalculator.GetProcessedValue` -> `TryGetProcessedValue` (out parameter).
- `IUaPubSubDataStore.ReadPublishedDataItem` -> `TryReadPublishedDataItem` (out parameter).
- `NodeState.ReadAttribute` takes `ref DataValue`; `ReadAttributeAsync` returns `ValueTask<(ServiceResult, DataValue)>`.
- `DataValue` property setters removed (use constructor + `With*` mutators).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants