Repository navigation
Akka.Serialization.V2: closed generic messages + manifest-discriminated union fields (PoC for #8384) - #8385
Merged
Aaronontheweb merged 14 commits intoJul 16, 2026
Conversation
…discriminated) Part 2 of the design in akkadotnet#8384: a union-typed field is a closed, explicitly enumerated set of [AkkaSerializable] member types, encoded structurally inline as { 1: manifest, 2: member fields } and discriminated by each member's serializer-owned manifest -- the typed alternative to [AkkaEnvelopePayload] for payload sets known at compile time. - AkkaUnionAttribute(params Type[] memberTypes) on [AkkaField] properties - FieldKind.Union + per-field generated Write/Read/SizeOf dispatch helpers - Exact-runtime-type write dispatch (undeclared subtypes fail, STJ FailSerialization semantics); unknown manifests throw on read; unknown union-frame keys skip for forward compat - Diagnostics AKKASG015-019 (member not serializable / missing manifest / manifest collision / not assignable / invalid member set) - Fix latent codegen bug: locals for properties named after C# keywords (e.g. 'Event') are now @-escaped in generated code
…eric messages Part 1 of the design in akkadotnet#8384, modeled on System.Text.Json source gen closed construction registration: a generic [AkkaSerializable] type is never serialized as an open definition -- each closed construction is registered on the [AkkaSerializer] module with its own manifest, and its generic fields are extracted from the constructed symbol (Roslyn substitutes member types, so a "T Payload" property arrives concretely typed with no type-parameter special-casing). - AkkaSerializableInstantiationAttribute(Type closedType) { Manifest } - Arity-aware member naming (Wrapper<Foo> folds type arguments into generated identifiers) with AKKASG024 collision detection instead of silent dropping - Arity-aware Object field mapping: a Wrapper<Foo>-typed field resolves to its registered instantiation (or fails AKKASG023) instead of colliding on the arity-less name - Generic definitions become non-serialized placeholders; AKKASG022 fires when one implements a serializer protocol with no registered constructions - AKKASG020/021 validate instantiation targets and duplicates - Combined scenario green: EventWrapper<IOrderEvent> with a union payload round-trips fully typed, inline, exact-sized
DX fix from the PoC audit: per-field union declarations were a drift hazard (every field of the same interface repeated the member list; lists drifting apart silently split wire compat). The union member set is now declared ONCE on the union base type (interface or abstract class), mirroring [JsonDerivedType] and the case list of the proposed C# language unions -- every field of that type inherits it. A field-level [AkkaUnion] remains as a per-field override (e.g. to narrow the accepted set for one schema). Type-level lookup runs against the SUBSTITUTED static type inside registered closed constructions, so a generic wrapper needs no union declaration at all: EventWrapper<T> instantiated with T := IOrderEvent inherits the union declared on IOrderEvent.
… helper dedupe) Three DX-audit follow-ups from akkadotnet#8384/akkadotnet#8385: - Generated member names now fold STJ-style (WriteWrapperOrderRequest, ReadUnionEnvelope) instead of namespace-qualified sanitized FQNs with _Of_/_End_ markers -- these names appear in stack traces. Collisions from flattening are detected by AKKASG024 and fail compilation, the same trade System.Text.Json makes with DuplicateTypeName. - New advisory AKKASG025 (Info): a non-sealed union member works, but an undeclared subtype of it fails at write time under exact-runtime-type dispatch -- surfaced without failing the build. - Union dispatch helpers are deduplicated by (static type, member set) signature: all fields sharing a type-level union declaration now share one Write/Read/SizeOf helper trio (Union_IOrderEvent); field-level overrides with distinct member sets get numbered variants (Union_IOrderEvent_2). Helper exception text references the union type rather than a field name.
Design-review feedback on akkadotnet#8385: too many attribute names, protocol interface hanging off the base type. Two consolidations, no behavior change: - [AkkaSerializer<TProtocol>(Name, SerializerId)]: the protocol marker moves into the serializer attribute. It was always pure generator input (TProtocol appeared exactly once in the runtime -- the class declaration), so the base class is now non-generic MessagePackSerializer. - [AkkaSerializable<T>] replaces [AkkaSerializableInstantiation]: closed generic constructions are registered with a generic form of the SAME attribute that marks every other serializable type. One rule: everything on the wire is [AkkaSerializable]; a closed construction has no declaration site of its own, so its marking lives on the serializer class. Bonus pit-of-success: the AKKASG020 unbound-generic case is now unrepresentable ([AkkaSerializable<Wrapper<>>] is not legal C#), so that failure mode moved from analyzer diagnostic to compiler error; its test is deleted. Also migrates the Artery control-message serializer (name/id/binding unchanged, wire compatible), benchmarks, and stale crefs. Known trade-off: non-generic MessagePackSerializer now collides with MessagePack's own MessagePackSerializer under dual top-level usings (CS0104) -- flagged for review, worked around with full qualification where it bit. 134/134 tests, Akka.Remote clean, API approvals pass.
… AkkaSerializer Design review on akkadotnet#8385: de-generifying the base class made it collide with MessagePack.MessagePackSerializer (CS0104) under dual top-level usings, and the old name leaked the wire format into the type users inherit. AkkaSerializer collides with nothing, echoes the [AkkaSerializer<TProtocol>] attribute, and keeps MessagePack an implementation detail: [AkkaSerializer<IMyProtocol>(Name = "my-protocol", SerializerId = 1234)] public sealed partial class MyProtocolSerializer : AkkaSerializer { } File renamed to AkkaSerializer.cs; all global::-qualified CS0104 workarounds simplified back to the plain name. MessagePack LIBRARY references and spec/benchmark class names intentionally untouched.
…stics and generator internals
The [AkkaSerializable<T>] consolidation killed the attribute that gave
"instantiation" its meaning here; this finishes the job so user-facing and
internal language match the mechanism's real name:
- AKKASG020 retitled "Closed generic registration is invalid"
(InvalidClosedGenericRegistration)
- AKKASG021 retitled "Duplicate closed generic registration"
(DuplicateClosedGenericRegistration)
- AKKASG022 retitled "Generic serializable type requires closed generic
registrations" (GenericSerializableRequiresRegistration)
- Generator internals renamed: ExtractClosedGenericRegistrations,
ValidateClosedGenericRegistrations, ClosedGenericRegistrationInfo,
SerializerInfo.ClosedGenericRegistrations
- Two diagnostics-spec test names/DisplayNames updated to match
Plain-English generics usage ("at instantiation time, T := Foo") retained
where it describes type substitution rather than the registration mechanism.
Verified: full Akka.slnx build 0 errors/0 warnings; V2 suite 134/134; Artery
integration specs (ArteryControlMessageSerializerSpec,
SystemMessageAckerStageSpec, ArteryHandshakeSpec) 51/51.
Azure hosted images rolled a newer .NET SDK whose implicit FSharp.Core (10.1.302) conflicts with the repo-pinned 10.1.301, failing restore with NU1605 on every branch. Pin FSharp.Core explicitly in Akka.FSharp so the resolved version no longer depends on the build agent's SDK.
…ASG026-028 Read-side reconstruction previously bound constructor arguments positionally in [AkkaField]-index order with no validation: same-typed properties whose index order differed from ctor-parameter order silently swapped values on round-trip, and defaulted ctor params not covered by a field silently reset. - Emit NAMED constructor arguments (ordinal, then unique case-insensitive param-name matching with implicit-conversion checks) + object-initializer assignments for settable/init-only leftovers (STJ-style hybrid), so init-only POCO and mixed shapes now serialize - AKKASG026 (Error): no eligible constructor / leftover property unsettable - AKKASG027 (Warning): chosen ctor has defaulted param no [AkkaField] covers - AKKASG028 (Error): [AkkaField] on static or getter-inaccessible property - Prefix generator-owned read locals (__fieldCount/__entryIndex/__fieldId, __has guards) so adversarial property names (FieldCount, Foo+HasFoo) can no longer collide (CS0128/CS0136) 146/146 V2 tests (12 new); wire format unchanged (byte assertions intact); Akka.Remote builds clean.
- AKKASG029 (Error): concrete type implements the serializer's protocol interface but lacks [AkkaSerializable] -- previously vanished silently from the generated switches and only failed at runtime when first sent. Exempts interfaces/abstract classes (concrete subtypes are checked individually) and [AkkaSerializable]-marked generic definitions (governed by AKKASG022) - AKKASG031 (Error): same protocol interface bound by multiple serializer classes (was silent last-wins at runtime registration) - AKKASG032 (Error): serializer class must be partial, non-generic, and derive from AkkaSerializer (was a wall of raw CS errors in generated code) - AKKASG033 (Error): protocol type must be an interface (a class protocol generated a serializer with empty dispatch switches) - AKKASG034 (Error): closed-generic registration that neither implements the protocol nor is reachable from any top-level message (dead registration; reachable nested registrations remain legitimate) - No AKKASG030: compiler already rejects duplicate [AkkaSerializer<T>] attributes via CS0579 even with different type args -- pinned by test Wires CompilationProvider into the terminal stage for the AKKASG029 scan; cached models already retained ISymbols (pre-existing), so a proper incremental-discipline pass is tracked as follow-up work. 162/162 V2 tests (16 new); Akka.Remote builds clean with no false positives on the Artery serializer.
- [AkkaSerializer<TProtocol>] now requires (string name, int serializerId) ctor args; Name/SerializerId are get-only. Serializer identity is always explicit by design: source-generator ordering is nondeterministic and config-defined id collisions are invisible to the generator, so there is no safe default. AKKASG001/002 demoted to constant-validity checks (null/empty/whitespace name, non-positive id) - [AkkaUnion] ctor is now (Type first, params Type[] rest): an empty union set no longer compiles; the 'at least one member' half of AKKASG019 is deleted, the duplicate-member half remains (now with a pinning test). Union extraction rewritten to concatenate both ctor args - [AkkaSerializerFormatter<TTarget, TFormatter>] with TFormatter : IAkkaMessagePackFormatter<TTarget>: interface conformance is now compiler-enforced; AKKASG008's interface half and AKKASG011's null-target half deleted as unrepresentable. Kept: abstract-formatter check (no new() constraint -- ExtendedActorSystem-only ctors are legitimate), AKKASG010, AKKASG011 array/closed-generic halves - Migrated all usages: Artery control-message serializer (name/id/binding byte-identical -- wire compatible), benchmarks, 7 test files 161/161 V2 tests; Akka.Remote, Akka.Benchmarks, RemotePingPong build clean; API approval suite unaffected (V2 not covered) -- 18/18 pass.
…nd diagnostic text - Hand-written runtime and test source now uses plain names + usings (every reference resolved unambiguously via enclosing-namespace lookup; no aliases needed) -- zero global:: remains outside the generator - Diagnostic messages no longer print global::-prefixed type names: ToDisplayName strips the prefix at Diagnostic.Create sites only, leaving dictionary keys, equality comparisons, and emitted-code type names untouched (29 AKKASG messages affected, no spec assertions broke) - Emitted-code string literals deliberately KEEP global:: -- generated source compiles inside arbitrary user namespaces where MessagePack/ System/Akka can be shadowed by user types 161/161 V2 tests; wire format unchanged; Akka.Remote builds clean.
Aaronontheweb
enabled auto-merge (squash)
July 16, 2026 11:01
Aaronontheweb
disabled auto-merge
July 16, 2026 12:46
Aaronontheweb
deleted the
feature/serialization-v2-unions-closed-generics
branch
July 16, 2026 12:53
Aaronontheweb
added a commit
that referenced
this pull request
Jul 16, 2026
…ric benchmarks, OpenSpec sync (follow-up to #8385) (#8392) * OpenSpec: sync messagepack-sourcegen-validation with implemented reality - tasks.md: check off 4.5/4.8/5.10/5.11 with AKKASG/test citations; annotate 5.7 (only T[], List<T>, IReadOnlyList<T>, Dictionary<K,V> implemented -- immutable collections remain future work); add sections 10-12 for unions, closed generic registrations, and the attribute-surface/illegal-states pass - design.md: fix stale MessagePackSerializer naming and dead attribute syntax; rewrite Decision 11's AKKASG008/011 description for the generic formatter attribute; add Decisions 13 (unions + closed generics), 14 (always-explicit serializer identity), 15 (illegal-states pass) - spec.md: add construction-validation scenarios and requirement sections for unions and closed generic registrations - proposal.md: name the concrete attribute set serializer-v2 change intentionally untouched (different owner scope); its sections 1-3 are done-in-code-but-unchecked, sections 4-5/7 genuinely incomplete -- reported separately. * Benchmarks: cover union dispatch and closed-generic registration paths Neither path existed when GeneratedMessagePackSerializerBenchmarks was written, so they had no baseline at all: - Union field round-trips: representative (first-declared) member and worst-case (last-declared) member. Write dispatch is a sequential exact-runtime-type check chain in [AkkaUnion] declaration order, so the last member pays N-1 failed checks; read dispatch is a manifest switch (jump table) with no ordering effect -- documented at point of use - Closed-generic registration round-trips: plain Wrapper<OrderDetailPayload> and the flagship combined shape Wrapper<IOrderEventBenchmark> (generic parameter is itself the union interface) Payload shapes match SubmitOrder field counts/types so numbers are comparable across the file. Serializer ids 120004/120005 (unused). Builds clean; all 8 execute under --job dry. * Serialization.V2: incremental-pipeline discipline + AKKASG035-037 advisories The generator's cached models retained live INamedTypeSymbols (SerializerInfo.ProtocolType, MessageInfo.Protocols) and several models had reference/broken equality (TypeMapping's default struct equality compared its ImmutableArray by underlying-array reference), so incremental caching was silently defeated; batch two had also coupled the single terminal output stage to CompilationProvider for AKKASG029. - All ten pipeline model types are now symbol-free and IEquatable with structural collection equality; protocol matching is ordinal string comparison against names captured at extraction - Pipeline split: emission consumes only the collected models; the AKKASG029 whole-compilation scan moved to a separate diagnostics-only output combining CompilationProvider. Emission is no longer gated on coverage errors (the Error still fails the build); source is now emitted alongside AKKASG029, documented by spec - New GeneratorIncrementalCachingSpec drives CSharpGeneratorDriver with trackIncrementalGeneratorSteps and asserts every named step reports Cached/Unchanged across an unrelated edit -- reintroducing a retained symbol or non-equatable model now fails a test - CancellationToken propagated through extraction and the coverage walk - AKKASG035 (Info): field-level [AkkaUnion] ignored under [AkkaEnvelopePayload] (type-level declarations exempt) - AKKASG036 (Warning): abstract union member has dead dispatch (fires instead of AKKASG025, no double-fire) - AKKASG037 (Info): Manifest on a generic [AkkaSerializable] definition is ignored (closed constructions supply their own) 169/169 V2 tests (8 new); Akka.Remote and both benchmark projects build clean with no advisory fires. * De-flake JsonPersistentTestRunStoreSpec: async ExpectMsgAsync with explicit timeout Both facts (in both TestAdapter test projects) used synchronous ExpectMsg<TestRunTree>() with the default 3s single-message timeout, after telling the coordinator ~320+ queued messages -- routinely exceeded on loaded CI agents (failed PR #8392's Linux unit-test run in a project the PR does not touch). Now async Task + await ExpectMsgAsync with a 15s timeout, per the repo's async-TestKit guideline.
Aaronontheweb
added a commit
to Aaronontheweb/akka.net
that referenced
this pull request
Jul 18, 2026
…net#8385+) The worktree's original base (84524b3) predated the Akka.Serialization.V2 surface reshape that landed via PR akkadotnet#8385 and successors. After the coordinator merged current dev into this branch, the four new integration specs from the prior commit no longer compiled against the new surface: - Attribute/base-class rename: `[AkkaSerializer(Name = ..., SerializerId = ...)]` + `MessagePackSerializer<TProtocol>` became the generic `[AkkaSerializer<TProtocol>("name", id)]` (positional, required args) + non-generic `AkkaSerializer` base class. `CreateRegistration()` partial method declarations were unaffected. Fixed in all four new files (ClassicRemotingSpec, PersistenceIntegrationSpec, ReliableDeliveryIntegrationSpec, GeneratedV2WrapperSpec). - Type collision: dev's GeneratedUnionSpec.cs (new since the base commit) declares a top-level `Akka.Serialization.V2.Tests.OrderPlaced` record with manifest "order-placed-v1". PersistenceIntegrationSpec.cs declared its own `OrderPlaced`/`OrderState`, causing CS0101/CS0579/CS8863 and a knock-on AKKASG012 + CS0534 on the unrelated UnionTestSerializer (the duplicate symbol confused the generator's per-serializer manifest scan). Renamed to `PersistedOrderPlaced`/`PersistedOrderState` with distinct manifests ("persisted-order-placed-v1"/"persisted-order-state-v1"); confirmed no other identifier collisions exist against the merged tree (checked every type declared across all four new spec files). Verification against the merged tree: - dotnet test src/core/Akka.Serialization.V2.Tests -c Release: 260/260 passing (was 106 pre-merge baseline + our 4; merged dev tip's own baseline is 256, confirming the orchestrator's original "256 tests" figure referred to current dev, not the stale base this worktree started from). - dotnet test src/core/Akka.Cluster.Tests --filter ReliableDeliveryIntegrationSpec: 2/2 passing. - dotnet test src/contrib/cluster/Akka.DistributedData.Tests --filter GeneratedV2WrapperSpec: 2/2 passing. - dotnet format --verify-no-changes: clean on all three touched projects. - dotnet build -warnaserror: clean on Akka.Cluster.Tests and Akka.DistributedData.Tests. Akka.Serialization.V2.Tests only fails -warnaserror when built as a standalone .csproj outside solution context, because the Verify package's build props can't auto-discover SolutionDir/ SolutionName from a project 3 directories below the repo root (its search only walks 2 parents) and downgrades that to a real warning under -warnaserror; this is pre-existing since dev's wire-format-snapshot Verify tests (86976dd), reproducible on an unmodified file in that project, and resolves cleanly once SolutionDir/SolutionName are supplied (confirmed with explicit /p:SolutionDir/SolutionName, matching how CI builds via the solution) -- not something introduced by or fixable in these test files. All new specs re-run twice post-fix with no flakiness. No product code (generator or V2 runtime) touched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proof of concept for #8384
Implements both parts of the design in #8384 for the source-generated MessagePack serializer:
Part 1 —
[AkkaSerializable<T>]closed generic messages. A generic[AkkaSerializable]type is never serialized as an open definition (a source generator cannot reify open generics — same rule STJ enforces). Each closed construction is registered on the[AkkaSerializer]module using a generic form of the same[AkkaSerializable]attribute with its own manifest; generic fields are extracted from the constructed symbol, so Roslyn's member substitution deliversT-typed properties already concretely typed. One rule: everything on the wire is[AkkaSerializable]— a closed construction just has no declaration site of its own, so its marking lives on the serializer class.Part 2 —
[AkkaUnion]closed, manifest-discriminated unions. A field whose static type is an interface/abstract base with a declared closed member set encodes structurally inline as{ 1: manifest, 2: member fields }— the typed alternative to[AkkaEnvelopePayload]for payload sets known at compile time. The union member set is declared once on the union base type (field-level[AkkaUnion]remains as a per-field override); the existing serializer-owned manifest is the discriminator, so no new identity scheme is introduced. Write dispatch is exact-runtime-type: an undeclared subtype fails serialization instead of silently truncating to its base (STJFailSerializationsemantics).The combined scenario — a generic wrapper with a polymorphic payload, fully typed with no
objectboundary:The protocol marker moved off the base type and into
[AkkaSerializer<TProtocol>]— it was always pure generator input (the runtime never usedTProtocol), so the base class is now non-generic. It is also renamedMessagePackSerializer→AkkaSerializer: the old name collided withMessagePack.MessagePackSerializer(CS0104) once de-generified, and leaked the wire format into the type users inherit; the new name echoes the[AkkaSerializer<TProtocol>]attribute and keeps MessagePack an implementation detail.Changes
[AkkaSerializer<TProtocol>],[AkkaSerializable](+ generic form for closed constructions),[AkkaField],[AkkaUnion], plus the[AkkaEnvelopePayload]/[AkkaSerializerFormatter]escape hatches; base class de-generified and renamed toAkkaSerializerAkkaUnionAttribute: type-level declaration on the union base (stated once, inherited by every field of that type) + field-level overrideFieldKind.Unionwith generated dispatch helpers deduplicated by (static type, member set) signature; exactSizeHintfor all member kindsWriteWrapperOrderRequest) with AKKASG024 collision detection[AkkaSerializable<Wrapper<>>]is not legal C#) — compiler error instead of analyzer diagnosticEvent) generated uncompilable locals; now@-escapedTesting
134/134 (27 new): round-trips for class/struct/nested-only union members, nullable unions, type-level inheritance + field-level narrowing override, wire-format byte assertions, undeclared type/subtype rejection, unknown-manifest and payload-ordering failures, forward-compat key skipping, closed-construction round-trips + manifest dispatch + nesting, the combined scenario, and 8 diagnostics specs. API approval tests pass; Akka.Remote builds clean.
Out of scope (follow-ups tracked in #8384)
Closes nothing — PoC for discussion on #8384.
Update (2026-07-16): illegal-states-unrepresentable hardening pass
Three follow-up commits from an adversarial audit of the surface (plus a CI fix —
e0c71bfbbrepairs the repo-wide NU1605 from the Azure image SDK roll; unrelated to this PR's code):c507f0f73— name-bound hybrid construction on deserialize. Read-side reconstruction previously bound ctor args positionally in[AkkaField]-index order with no validation — same-typed properties whose index order differed from ctor order silently swapped values on round-trip. Now emits named arguments (ordinal, then unique case-insensitive matching) plus object-initializer assignments for settable/initleftovers, so init-only POCO shapes serialize too. New: AKKASG026 (no matching ctor), AKKASG027 (defaulted ctor param covered by no field — silent reset), AKKASG028 ([AkkaField] on static/inaccessible property). Generator-owned read locals are__-prefixed so property names likeFieldCountcan't collide.1b2ea0116— serializer-level validation. AKKASG029 (concrete protocol implementor missing[AkkaSerializable]— previously vanished silently from dispatch), AKKASG031 (same protocol bound by two serializers), AKKASG032 (serializer class must be partial/non-generic/derive fromAkkaSerializer), AKKASG033 (protocol type must be an interface), AKKASG034 (closed-generic registration that neither implements the protocol nor is reachable from any message — dead registration). No AKKASG030 needed: the compiler already rejects duplicate[AkkaSerializer<T>]attributes via CS0579 even with different type args (pinned by test).5ea975e30— attribute reshapes.[AkkaSerializer<TProtocol>(string name, int serializerId)]— identity is now required and always explicit (no auto-ids: sourcegen ordering is nondeterministic and config-defined collisions are invisible to the generator); AKKASG001/002 demote to constant-validity checks.[AkkaUnion(Type first, params Type[] rest)]— empty union sets no longer compile.[AkkaSerializerFormatter<TTarget, TFormatter>] where TFormatter : IAkkaMessagePackFormatter<TTarget>— interface conformance is compiler-enforced; the null-target and interface-mismatch diagnostics are deleted as unrepresentable. Artery serializer and benchmarks migrated with byte-identical name/id bindings (wire compatible).Diagnostics now span AKKASG001–034 (022 has structural gaps closed; 030 intentionally absent). 161/161 tests. Wire format unchanged throughout — byte-assertion specs untouched. Known follow-ups: incremental-pipeline discipline pass (cached models retain symbols; AKKASG029 couples the terminal stage to
CompilationProvider), benchmark coverage for union/closed-generic dispatch paths.