Skip to content

Experimental Avro/Protobuf/Arrow reference encoders + PubSub adapters - #7

Closed
marcschier wants to merge 14 commits into
mainfrom
marcschier/experimental-encodings
Closed

marcschier wants to merge 14 commits into
mainfrom
marcschier/experimental-encodings

Conversation

@marcschier

@marcschier marcschier commented Jul 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds two experimental class libraries to the stack that implement the draft OPC UA DataEncoding extensions (Avro binary, Protobuf, Apache Arrow) as hand-rolled reference encoder/decoders, plus PubSub NetworkMessage adapters, with NUnit round-trip tests. These are the C# reference implementation of the spec drafts in the marcschier/opcua-drafts repo.

New projects

  • Libraries/Opc.Ua.Core.Experimental — IEncoder/IDecoder implementations:
    • Avro — streaming Avro binary (zig-zag varint, little-endian floats, nullable unions, NodeId-by-parts), full Variant/Matrix support, plus Action invoke/response + Discovery. SchemaId = CRC-64-AVRO (Rabin) fingerprint with Avro single-object prefix.
    • Protobuf — hand-rolled protobuf wire format; scoped to gRPC service messages only.
    • Arrow — genuinely typed Apache Arrow (Apache.Arrow 18.1.0): typed arrays, StructArray, ListArray, dense UnionArray, FixedSizeBinaryArray. No JSON/base64/blob carrier (asserted by tests).
  • Libraries/Opc.Ua.PubSub.Experimental — INetworkMessageEncoder/Decoder:
    • Avro adapter drives the real AvroEncoder per field (self-describing Avro-in-Avro bytes).
    • Arrow adapter emits a real columnar RecordBatch (rows = DataSetMessages, typed per-field + leading header columns; NetworkMessage headers in schema metadata) per the Part 14 Arrow batch mapping (ADBC/historian oriented).
  • Test projects for both, Apache.Arrow 18.1.0 added to central package management, four projects added to UA.slnx.

Target frameworks / CI

  • The experimental libraries target net8.0;net9.0;net10.0 only (they rely on Apache.Arrow and net5+ BCL APIs such as BitConverter.SingleToUInt32Bits, Convert.ToHexString, Stream.ReadExactly). They opt into RestrictForLegacyTfm so the per-TFM CI matrix entries pinned to legacy TFMs (net472/net48/netstandard2.x) no-op them (verified locally).
  • Verified: both libraries build 0 warnings / 0 errors on net8.0/net9.0/net10.0; Opc.Ua.Core.Experimental.Tests 17/17 and Opc.Ua.PubSub.Experimental.Tests 3/3 pass on net8.0 and net10.0; legacy CustomTestTarget builds no-op cleanly.

Conformance / scope

  • Round-trip-only conformance for this first reference (encode → decode reproduces the input), including null vs empty vs present-zero, nullable scalars, arrays, matrices (dimensions + values), all four NodeId kinds, Variant/ExtensionObject/DataValue/DiagnosticInfo, and float NaN/±0/±Inf bit preservation.
  • Out-of-scope / unsupported decode paths fail loud (NotSupportedException) rather than corrupt.

Code-review fixes included

  • Avro (critical): a present-empty array previously emitted a spurious Avro block terminator that desynced the following field — and, because every 1-D array Variant carries a present-empty dimensions array, such Variants decoded as Null. Fixed in AvroEncoder.WriteArray + a dedicated multi-field regression test.
  • Arrow: dense-union declared type-ids now include the written code so external Arrow readers (pyarrow/ADBC) can map Variant/DataValue union slots; XmlElement now round-trips instead of being silently dropped.
  • Removed a dead/misleading tautology guard in the Arrow PubSub adapter.

Known limitation

  • Protobuf optional presence: the reference decoder reconstructs optional-field presence by probing field names against the wire; combined with positional field numbering this is unreliable for structures with two or more optional fields where an earlier optional is absent. The in-scope gRPC service messages do not use such structures. A general fix requires an explicit on-wire encoding mask (documented in ProtobufDecoder).

Companion to the encoding spec drafts in marcschier/opcua-drafts (PR #4).


Follow-up: closed encoder gaps, Avro perf, and performance numbers

  • Protobuf Variant arrays/matrices now encode/decode across all built-in body types (previously threw InvalidCastException for matrix/array Variants). Round-trip tests added.
  • Arrow Variant dense-union completed to cover the full OPC UA type model — every built-in scalar/array/matrix body (previously only a handful; e.g. Variant<Boolean> was unsupported). Round-trip tests added.
  • Avro codec performance: buffered, span-based varint IO and pooled UTF-8; IO buffers rented from ArrayPool so small-message allocation is not penalised (Avro scalar encode alloc ~9.1 KB/op → ~0.9 KB/op, below Binary) while retaining the large-payload batching win.
  • The Part 6 + Part 14 comparison harness now runs with no n/a cells. Build 0/0 across net8/9/10; Core 118 + PubSub 8 tests + all round-trips green.
  • Measured results and value-add analysis are captured in the companion spec PR (core-specs/extras/performance/OPC-UA-Encoding-Performance-Comparison.md).

Follow-up: Arrow bare-RecordBatch (batch) framing

Adds ArrowIpcFraming {Stream, Batch} to the Arrow PubSub encoder. Batch emits a bare Arrow RecordBatch without the embedded Schema message (a fixed ~1.2 KB/message); the schema is announced once out of band and resolved by SchemaId (the Arrow Flight model). The decoder caches schemaId -> schema (from a full stream or CacheSchema) and reconstructs the stream to decode bare batches. Measured savings: ~39% for a single sample, ~37% at 10 samples, shrinking to ~1.8% at 1000 (schema amortised). Bare-batch round-trip tests + an IPC framing-breakdown measurement added; build 0/0 all TFMs, PubSub tests 10/10.

Update: ArrowIpcFraming.Batch is now the encoder default (aligns with the spec default and the Avro schema-exchange model); the Part 14 harness measures both framings.

…ters

Adds two net8.0+ experimental libraries implementing the draft OPC UA
DataEncoding extensions (opcua-drafts) as hand-rolled reference codecs plus
PubSub NetworkMessage adapters, with NUnit round-trip tests.

Libraries/Opc.Ua.Core.Experimental (IEncoder/IDecoder):
- Avro: streaming Avro binary (zig-zag varint, LE floats, nullable unions,
  NodeId-by-parts); Variant/Action/Discovery support. SchemaId = CRC-64-AVRO
  Rabin fingerprint + single-object prefix.
- Protobuf: hand-rolled protobuf wire; scoped to gRPC service messages.
- Arrow: genuinely typed Apache Arrow (Apache.Arrow 18.1.0) - typed arrays,
  StructArray, ListArray, dense UnionArray; no JSON/blob carrier.

Libraries/Opc.Ua.PubSub.Experimental:
- Avro adapter drives the real AvroEncoder per field (Avro-in-Avro bytes).
- Arrow adapter emits a real columnar RecordBatch (rows = DataSetMessages,
  typed per-field + header columns) per the Part 14 Arrow batch mapping.

Build/scope notes:
- net8.0;net9.0;net10.0 only (Apache.Arrow + net5+ BCL); RestrictForLegacyTfm
  no-ops the legacy CI CustomTestTarget matrix entries.
- Round-trip-only conformance for this first reference; unsupported/out-of-scope
  decode paths fail loud (NotSupportedException), never corrupt.

Includes fixes from code review: Avro present-empty-array terminator (fixed +
regression test), Arrow dense-union declared type-ids made conformant, Arrow
XmlElement decode implemented. Known limitation: Protobuf optional-presence
reconstruction is unreliable for structures with 2+ optionals where an earlier
one is absent (documented; not exercised by in-scope gRPC messages).
… no pragmas)

Reflects the latest Part 14 spec state and applies a full quality pass to the
experimental Avro/Protobuf/Arrow reference stack.

Schema exchange (spec alignment):
- Add AvroSchemaAnnouncement/Request and ArrowSchemaAnnouncement/Request message
  types matching the Part 14 §9 / §5.2 descriptors.
- Add SchemaCache (thread-safe, announce-once per destination, SchemaId verified
  on ingest) + ISchemaResolver (pluggable cache-miss resolver), wired into the
  Avro and Arrow PubSub adapters (encoder announces on first SchemaId use;
  decoder resolves on miss). A resolver returning bytes inconsistent with the
  requested SchemaId is treated as "schema not available" so Try* decoders
  return null rather than throwing. Documented limitation: announcements
  currently carry an internal shape descriptor (change-tracking proxy), not the
  canonical Avro PCF / serialized Arrow Schema (cross-implementation interop is
  a follow-up). New round-trip + handshake tests.

Quality hardening (all experimental files):
- Add the long-form OPC Foundation MIT copyright header to every file.
- Remove all blanket #pragma warning disable and fix the underlying issues
  (reformat dense one-liners to braced/<=120-col; fix CA and compiler
  nullable/obsolete warnings). Only 6 narrowly-scoped, justified suppressions
  remain (5x CA2000 Arrow array ownership handoff, 1x CS0618 raw byte[] body).
- Document every public and internal symbol: <inheritdoc/> on IEncoder/IDecoder
  members (matching JsonEncoder), meaningful summaries elsewhere. Enable
  GenerateDocumentationFile on both libraries so CS1591 enforces documentation.

Builds 0 warnings/0 errors on net8.0/net9.0/net10.0 (docs enforced); Core 21 and
PubSub 8 tests pass; legacy-TFM RestrictForLegacyTfm no-op intact. No canonical
encoding or SchemaId-algorithm changes.
Comment thread Libraries/Opc.Ua.Core.Experimental/Arrow/ArrowEncoder.cs Outdated
Comment thread Libraries/Opc.Ua.Core.Experimental/Arrow/ArrowEncoder.cs Outdated
Comment thread Libraries/Opc.Ua.Core.Experimental/Arrow/ArrowEncoder.cs Outdated
Comment thread Libraries/Opc.Ua.Core.Experimental/Avro/AvroDecoder.Variant.cs Outdated
Comment thread Tests/Opc.Ua.Core.Experimental.Tests/Arrow/ArrowRoundTripTests.cs
…le, folders

Addresses the maintainer's structural review of the experimental encoders:
- Namespace: Opc.Ua.Core.Experimental -> Opc.Ua (matching the base encoders),
  Opc.Ua.PubSub.Experimental -> Opc.Ua.PubSub.Encoding, and the test projects
  -> Opc.Ua.Core.Tests / Opc.Ua.PubSub.Encoding.Tests.
- Always use block method bodies: converted all expression-bodied members to
  braced bodies across the libraries and tests.
- One class per file: split ArrowEncoder (ArrowEncoder/ArrowDecoder/Slot/A),
  AvroBinary (Reader/Writer), ProtobufWire (ProtoField/ProtoMessage/Proto),
  ProtobufGrpcMessages (one file per message), and SchemaCacheEntry.
- Merged the Avro *.Variant partial classes into the concrete AvroEncoder/
  AvroDecoder classes (single, non-partial, sealed) and deleted the partials.
- Moved the PubSub Arrow* files into Encoding/Arrow/ (mirroring Encoding/Avro/).
- Reorganized the tests into subfolders mirroring the source projects
  (Avro/Arrow/Protobuf/SchemaExchange, Encoding/Avro, Encoding/Arrow).

Behavior, wire encodings, method signatures/semantics and the SchemaId
algorithms are unchanged. Copyright headers, XML docs (CS1591 enforced via
GenerateDocumentationFile) and the 6 narrowly-scoped justified pragmas are
preserved. Builds 0 warnings/0 errors on net8.0/net9.0/net10.0; Core 21 and
PubSub 8 tests pass; legacy-TFM no-op intact.
Comment thread Libraries/Opc.Ua.Core.Experimental/SchemaExchange/AvroSchemaAnnouncement.cs Outdated
marcschier and others added 10 commits July 6, 2026 16:59
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix Variant array and matrix payload handling so containers are written as ArrayValue/MatrixValue messages instead of scalar Value bodies, and add Protobuf round-trip coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add scalar, array, and matrix branches for every OPC UA Variant body built-in supported by the canonical dense union layout. Add Arrow round-trip coverage across all body types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… allocation

The buffered writer/reader introduced an 8 KB heap buffer per encoder/decoder
instance, which regressed small-message allocation (Avro scalar encode went to
~9 KB/op). Rent the buffers from ArrayPool.Shared and return them from the
codec Close() path via a Release() method (not IDisposable, to avoid pushing
CA2000 disposal obligations onto every call site). Avro scalar allocation drops
back to ~0.9 KB/op (below Binary) while keeping the large-payload batching win.
Adds ArrowIpcFraming {Stream, Batch}. In Batch mode the encoder emits a bare
Arrow RecordBatch message without the embedded Schema message (a fixed ~1.2 KB
per message), exposing the schema-message bytes/length so the schema can be
announced once out of band and resolved by SchemaId (the Arrow Flight model).
The decoder caches schemaId->schema (from a full stream or CacheSchema) and
reconstructs schema+batch+EOS to decode bare batches, reusing the existing
decode path. Adds bare-batch round-trip tests and an IPC framing-breakdown
measurement (schema vs batch bytes, stream vs bare-batch size per sample).
Addresses PR feedback that batch (which exchanges the schema out of band like
Avro) should be the default. ArrowNetworkMessageEncoder.Framing now defaults to
ArrowIpcFraming.Batch; tests that need a self-contained stream set Framing=Stream
explicitly. Extends the Part 14 framing measurement to report full metrics
(payload/encode/decode/alloc) for both stream and batch framings.
@marcschier

Copy link
Copy Markdown
Owner Author

Superseded by three focused PRs that port each reference encoder into the production stack (next to Binary/Json/Xml/Uadp), removing the need for the *.Experimental libraries:

Recommended merge order: #11 (independent), then #10, then #12 (rebases onto main automatically once #10 merges). Closing this PR in favor of the three above.

@marcschier marcschier closed this Jul 10, 2026
@marcschier
marcschier deleted the marcschier/experimental-encodings branch July 24, 2026 10:47
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.

1 participant