Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 181 additions & 13 deletions docs/articles/serialization/source-generated-serialization.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/cSpell.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"denormalization",
"deque",
"deserialization",
"dmac",
"downstream",
"downstreams",
"ddata",
Expand Down
18 changes: 18 additions & 0 deletions openspec/changes/messagepack-sourcegen-validation/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,24 @@ A registration attribute on the serializer class no longer means only "register

Anything the generator cannot see at build time does not exist. A construction can be built through `Activator.CreateInstance` and `MakeGenericType`, over a type argument outside the registered set. That construction has no matching case. It fails at send time with an `ArgumentException` naming the unsupported type. Reflection is not a path the generator accommodates. This rule closes a real gap. A generic type declared in Core.dll cannot implement a protocol interface declared in Comms.dll. Core.dll does not reference Comms.dll. So a closed construction of that generic fails the old test. The old test requires the type to implement the protocol, or be reachable from a field. Today that gap surfaces as AKKASG034. As pinned in `CrossAssemblyBaselineSpec`, on branch `feature/serialization-v2-cross-assembly-baseline`, that one diagnostic also suppresses emission of the entire serializer. One stray registration then turns into a pile of unrelated missing-partial compile errors.

**Implementation addendum (F2).** The points above were decided while building the expansion and adoption stage; the design text above did not fully cover them.

*Decision 17 needed no code change.* The generated union write/read dispatch already throws `SerializationException` on a runtime type or a manifest it has no case for (`GenerateUnionWrite`/`GenerateUnionRead`, `AkkaSerializerGenerator.Emission.cs`), unconditionally, with no opt-in and no fallback. This predates F2. Confirmed by inspection; nothing in this phase changed it.

*The three new diagnostic ids.* AKKASG040 (`ClosedSetExpansionRequiresClosedSet`), the next free id after F1's AKKASG039: a `ManifestPrefix` registration whose target has no closed set to expand, gate-level (alongside AKKASG020/AKKASG021), reported at the registration's own attribute. AKKASG041 (`AdoptedMessageOwnedByMultipleSerializers`): the one-owner rule, reported once per offending serializer, at that serializer's own `[AkkaSerializer<TProtocol>]` attribute (not at a specific registration, since a conflicting ownership can come from plain protocol membership, which has no registration site of its own). AKKASG042 (`ClosedSetExpansionCount`), Info: the construction count, reported once per successfully-expanding registration.

*The construction-count diagnostic has no size threshold.* The design text above and the maintainer's own decision-record page both describe an unconditional info diagnostic ("the generator reports the number of constructions it produced"), not a warning gated on a count. AKKASG042 is Info severity and fires for every expanding registration, regardless of how many constructions it produces.

*The closed-set walk for expansion is scoped to the current compilation only.* `ManifestPrefix`'s protocol-set discovery (`ComputeLocalMarkedProtocolImplementors`, `AkkaSerializerGenerator.Extraction.cs`) walks only this compilation's own source-declared types, matching every other top-level-dispatch walk before Decision 19 lands. A member of the set that is itself a generic construction is out of scope for this walk (it would need its own nested expansion to discover); the "G&lt;H&lt;M&gt;&gt;" nested-construction shape in the manifest formula covers that case instead, through a *fixed*, non-expanding type-argument position resolved against a sibling literal registration's own manifest (`TryResolveFixedArgumentManifest`), not through the protocol walk.

*The manifest formula's five rows collapse to one mechanism.* Every row but the multi-argument one is the single-argument case applied to a different kind of member: a member that is itself a generic construction (row 5, "G&lt;H&lt;M&gt;&gt;") composes exactly the same way an ordinary concrete member does (row 3), because both are just "the top-level manifest of this argument's chosen member" (row 2). The implementation is one cartesian-product loop over every type-argument position (`CartesianProduct`, `ExpandClosedGenericRegistration`), each position independently resolved to a closed set or a single fixed choice, with no special-casing for nesting depth.

*Explicit override during expansion.* An explicit, non-prefix registration's manifest for a construction is captured in a first pass over every attribute on the serializer (`explicitManifestByTarget`), before any expansion runs, so declaration order never matters. A combination the expansion would otherwise produce is skipped when that table already has an entry for its exact constructed type; the explicit registration (already extracted independently, earlier in the same method) keeps its own manifest and schema.

*A non-generic adoption target can collide with its own ordinary declaration.* A concrete type registered via `[AkkaSerializable<T>(Manifest = ...)]` on the serializer is very often *also* an ordinarily-declared `[AkkaSerializable]` type in the same compilation (it has to be, for the registration to be valid at all). `ResolveSerializerMessages` excludes such a type's plain `declaredMessages` entry from its message table when a `ClosedGenericSchemas` entry exists for the same key, so the registration's own (possibly overridden) manifest and schema win, and no duplicate-key failure occurs building the per-serializer message dictionary.

*The adoption rule's effect on a construction registered only for nested-field use.* Before this decision, a closed generic construction registered purely so it could appear as a nested `Object` field -- never top-level, never implementing the protocol -- needed no manifest of its own. Decision 18 retires that allowance along with AKKASG034: every registered construction is now unconditionally top-level, so it now needs a manifest the same way any other top-level message does (AKKASG006), even when it is also reachable as a nested field. This is a deliberate, direct consequence of "every construction registered on the serializer... is a top-level message," not an oversight; the golden corpus's own `Pair<int, string>` registration needed a `Manifest` added for this reason.

### 19. Protocol Implementors From Referenced Assemblies, And Serializer Placement

The generator finds top-level messages for a protocol not only in the current compilation. It also walks every referenced assembly that itself references `Akka.Serialization.V2`. For each such assembly, it enumerates types from metadata. It adopts every `[AkkaSerializable]` implementor of the protocol interface it finds. No attribute is needed to opt an assembly in. No runtime type scanning happens. No scanning of how or whether a type is actually used happens either. This amends Decision 16. Top-level dispatch, not only nested-field and union-member resolution, covers this compilation plus every referenced assembly that references V2.
Expand Down
90 changes: 90 additions & 0 deletions openspec/changes/messagepack-sourcegen-validation/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,93 @@ not implement Decisions 17-21 (later PRs).
- [x] 13.7 Document cross-assembly support in the user guide (new "Cross-Assembly Types" section,
AKKASG039 in the diagnostics table, updated Limitations section) and record the implementation
choices the design text did not cover as an addendum under Decision 16 in design.md

## 14. Closed-Set Expansion And Adoption On The Serializer (Decisions 17 And 18)

Stacks on F1 (`feature/serialization-v2-metadata-schemas`, PR #8534). F2 of the follow-on
cross-assembly work. Decision 17 ("Unknown Union Members: The Generator Throws, The Caller
Decides") needed no code change: the generated union write/read dispatch already throws
`SerializationException` on an unmatched runtime type or an unrecognized manifest, confirmed by
inspection of `GenerateUnionWrite`/`GenerateUnionRead` and unchanged by this phase. Decision 18
("Closed-Set Expansion And Adoption On The Serializer") is the substance of this phase. Does not
implement Decisions 19 or 21 (the referenced-assembly implementor walk and the union-without-a-
member-list form; later PRs) -- the closed set `ManifestPrefix` expands over, and the walk a
one-owner conflict compares against, are both scoped to the current compilation only.

- [x] 14.1 Add `ManifestPrefix` to `AkkaSerializableAttribute<TMessage>` (extend-only; `Attributes.cs`),
alongside the existing `Manifest`. `ClosedGenericRegistrationInfo` gains `ManifestPrefix`,
`ExpansionGroup` (empty for a directly-written registration; the base target's display name for
a synthesized expansion member), and `ExpansionError` (non-empty when a `ManifestPrefix`
registration could not expand)
- [x] 14.2 Relax `ExtractClosedGenericRegistrations`'s target validity check to accept a non-generic
concrete type (Decision 18's adoption rule), not only a closed generic construction; AKKASG020
stops rejecting a non-generic type argument
- [x] 14.3 Implement `ManifestPrefix` expansion (`ExpandClosedGenericRegistration`,
`AkkaSerializerGenerator.Extraction.cs`): a type argument's closed set is either the
serializer's own protocol interface (`TryGetClosedSetMembers`, walking this compilation's own
non-generic `[AkkaSerializable]` implementors, `ComputeLocalMarkedProtocolImplementors`) or a
type-level `[AkkaUnion]`'s explicit member list. Every type argument is tested independently, so
a multi-argument registration expands to the product of its arguments' sets
(`CartesianProduct`); an argument with no closed set must instead resolve to exactly one
already-known manifest (`TryResolveFixedArgumentManifest`) -- its own, for an ordinary concrete
type, or a sibling literal registration's, for a nested generic construction. Each combination
is `Construct()`-ed off the open generic definition and run through the same `ExtractMessageCore`
every other schema uses, with a manifest derived by the formula
`prefix + "/" + string.Join("/", memberManifests)`. An explicit registration for one specific
construction is skipped during expansion and keeps its own manifest, overriding the derived one
- [x] 14.4 Add AKKASG040 (`ClosedSetExpansionRequiresClosedSet`, Error): a `ManifestPrefix`
registration whose target has no closed set to expand (not generic, a concrete class, or one
of its arguments resolves to neither a closed set nor a registered sibling manifest). Gate-level,
alongside AKKASG020/AKKASG021 in `ValidateClosedGenericRegistrations`
- [x] 14.5 Add AKKASG042 (`ClosedSetExpansionCount`, Info): reported once per successfully-expanding
`ManifestPrefix` registration, naming the resulting construction count. Unconditional -- the
design specifies no size threshold
- [x] 14.6 Implement the adoption rule in `ResolveSerializerMessages`
(`AkkaSerializerGenerator.Emission.cs`): every registered/expanded closed-generic-schema target
is unconditionally top-level, whether or not it implements the protocol. Retire AKKASG034 (its
descriptor, `DiagnosticKey` entry, and registry mapping removed; the id stays a permanent gap
like AKKASG030/AKKASG035) -- the "registration has no effect" condition it guarded can no
longer occur. `GenerateRegistration` binds the concrete type of every entry in
`SerializerInfo.ClosedGenericSchemas`, not only the protocol interface, sorted by fully-qualified
name for deterministic output
- [x] 14.7 Implement the protocol-interface-field-as-union rule in `ResolveMessages`
(`AkkaSerializerGenerator.Emission.cs`): a field whose static type is exactly the serializer's
own protocol interface is reclassified from `FieldKind.Unsupported` to a union over the
protocol closed set, with no `[AkkaUnion]` attribute needed. `MessageInfo` gains `IsSealed`/
`IsAbstract`/`IsValueType`/`ForeignAssemblyName` (captured once at extraction) so the implicit
union's `UnionMemberInfo` list needs no symbol access at resolve time; `FieldInfo.WithUnion`
performs the reclassification. AKKASG003's polymorphic hint text gains the protocol-interface
option
- [x] 14.8 Add AKKASG041 (`AdoptedMessageOwnedByMultipleSerializers`, Error): the one-owner rule.
`ComputeMultiOwnedMessages` (`AkkaSerializerGenerator.Emission.cs`) maps every message type to
its owning serializer(s) -- by protocol membership or by `ClosedGenericSchemas` adoption --
across the whole compilation; a type with more than one owner is reported at every owning
serializer's own attribute, from `ReportCrossSerializerDiagnostics`
- [x] 14.9 Fix a duplicate-key crash `ResolveSerializerMessages` would otherwise hit when a
non-generic type is both ordinarily declared (`declaredMessages`) and separately adopted
(`ClosedGenericSchemas`): the adopted entry wins and the plain declaration is excluded from the
concat, preserving declaration order and the registration's own manifest override
- [x] 14.10 Rewrite `CrossAssemblyBaselineSpec`'s case 4 (`Unreachable_closed_generic_registration_of_customer_envelope`)
from a pinned AKKASG034 failure to a pinned adoption success: the customer's own motivating
shape now emits, dispatches by its derived/explicit manifest, and gets its own concrete
`typeof()` binding. Rewrite the AKKASG020/AKKASG034 cases in `GeneratorValidatorSpec.cs` and
`AkkaSerializerGeneratorDiagnosticsSpec.cs` the same way; add a `Manifest` to the golden
corpus's nested-only `Pair<int, string>` registration, now that every registration is
unconditionally top-level too
- [x] 14.11 Add `GeneratedClosedGenericExpansionSpec.cs` (round-trip specs: derived manifest,
explicit-override manifest, the literal construction's implicit protocol union, a multi-argument
expansion with a nested fixed-argument manifest lookup, a non-generic non-protocol adoption with
a manifest override, and reflection-built constructions both inside and outside the registered
set) and `ClosedGenericExpansionDiagnosticsSpec.cs` (AKKASG040, AKKASG041, AKKASG042, an
AKKASG012 collision from a derived manifest, and AKKASG003's non-firing for a protocol-interface
field)
- [x] 14.12 Add `ClosedGenericExpansionGoldenOutputSpec.cs`: golden-output coverage for a derived
manifest, an explicit override, the implicit protocol union, a nested nested-fixed-argument
construction, and a concrete type adopted from a referenced assembly, each pinned against a
checked-in baseline
- [x] 14.13 Document `ManifestPrefix`, the manifest formula (with worked examples), the adoption
rule, the protocol-interface-field-as-union rule, and the one-owner rule in the user guide (new
subsections under "Closed Generic Registrations"); update the diagnostics table (AKKASG034
removed, AKKASG040/041/042 added, AKKASG020/003 descriptions updated); remove the delivered
`ManifestPrefix` bullet from "Limitations Today and Planned Changes"; add an addendum under
Decision 18 in design.md for choices the design text left to the implementation
Loading
Loading