Skip to content

Name the explicit DCB tag registration when inference cannot work (supersedes #5663) - #5676

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/5663-dcb-tag-aot-remedy
Oct 15, 2026
Merged

jeremydmiller merged 1 commit into
masterfrom
fix/5663-dcb-tag-aot-remedy

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Supersedes #5663 (@skoging), rebuilt on current master to eliminate its merge conflict, with that review's follow-ups fixed. Closes the remaining half of #5649.

Why a new PR

#5663 and #5674 independently implemented the same API. #5674 merged first (8689bc8), so master already has RegisterTagType<TTag, TInner>(unwrapper, tableSuffix) on EventGraph and IEventStoreOptions, the [RequiresUnreferencedCode]/[RequiresDynamicCode] annotations on the inferring overloads, the JasperFx 2.90.0 bump, and a dcb.md section. #5663 then conflicted in four files, and reconciling it in place would have meant choosing between two signatures of one method.

This branch keeps master's signature — string? tableSuffix = null, which matches TagTypeRegistration.FromUnwrapper's own default and the suffix-less RegisterTagType<TTag>() — and carries over only what #5674 did not: the diagnostics, the validation, and the native coverage. It touches no Directory.Packages.props, so there is nothing to conflict with.

What it adds

The remedy, at runtime

RegisterTagType<TTag>() reflects over the tag's members with ValueTypeInfo and closes TagTypeRegistration<TTag, TInner> with MakeGenericType. #5674's annotations warn at build time, but an analyzer warning only reaches an application that is itself AOT-analyzed — a store configured from inside a library gets nothing, which is how #5649 was reported. EventGraph.CreateTagType now turns the failure into an InvalidOperationException naming the line to write, with the original as InnerException:

The tag type Enrollment.StudentId could not be read as a strong-typed identifier. Either it is not
a single-value wrapper, or this image was trimmed and has lost the members that say so. Naming the
inner type and how to read it needs neither:

    opts.Events.RegisterTagType<StudentId, Guid>(x => x.Value, "student");

The shape follows #5600 and #5635: FullNameInCode() in the prose, NameInCode() in the line, canEmit as a parameter so a JIT test reaches the native branch.

The two exception families are gated differently, and that is the first review follow-up. NotSupportedException/MissingMethodException mean the generic could not be closed, which only happens with no JIT, so they are reinterpreted only when canEmit is false. InvalidValueTypeException is ambiguous — the tag may genuinely not be a single-value wrapper, or a trimmed image may have lost the members that say so — and no runtime flag separates those, because a trimmed non-AOT image still has dynamic code. It is reinterpreted either way, with a message covering both readings, because the explicit overload is the answer to both. #5663 gated all three on !canEmit, which left the one case that is actually about trimming with no remedy.

Auto-discovery's silent AOT failure

The second follow-up, and the sharper of the two. #5657 logs a warning when discovery from a projection identity fails, but screens InvalidValueTypeException into a silent catch as "this identity was never a value type" — which is exactly how a trimmed native image reports a perfectly good tag whose members were trimmed away. So the one scenario #5649 reported was the one scenario that stayed quiet, while the NotSupportedException path got a warning.

IsToleratedDiscoveryFailure(e, canEmit) now tolerates it only while dynamic code is available, and the warning names the registration line including the projection's ForAggregate<T>(). A trimmed non-AOT image is still silent, deliberately: nothing at runtime distinguishes it from an identity that was never a value type, and guessing the other way would warn on every projection whose identity is an ordinary custom type. The comment says so rather than leaving it to be rediscovered.

Validation the overload did not have

  • A null unwrapper is refused.
  • Re-registering a tag type under a different inner type throws instead of silently keeping the first registration — which would leave the unwrapper this call passed unused, so every tag value would be read by the other registration's rule. Same inner type still returns the first registration, as the inferring overloads do.
  • BEHAVIOUR CHANGE: an empty or whitespace table suffix is refused on every RegisterTagType overload. mt_event_tag_ is a real table name, so RegisterTagType<T>("") was accepted and put every tag type registered that way into one shared table. null is the way to ask for the default; "" never meant anything a caller wanted. Nothing in the suite relied on it.

The third review note, deliberately not fixed

TagTypeRegistrationLine uses NameInCode(), so a tag type nested inside another type prints a line that needs its outer scope added. That is true of the #5600 and #5635 remedies too, and fixing only this one would make the three inconsistent. It belongs in a cosmetic pass over all three, not here. A remark on the method records the choice.

Testing

  • Marten.AotRuntimeSmoke, published and run as a native osx-arm64 binary (.NET 10): 48/48. Six of those are new: tagging, a TagWith rule, Guid- and string-backed tag queries, EventsExistAsync, FetchForWritingByTags into a [BoundaryAggregate] with an append, DcbConcurrencyException, and the inferring overload's message. The native run prints (inferred registration refused with the remedy, as natively) — the remedy is verified by running the binary, not by publishing it. Master has no native DCB coverage at all; GH-5673 TombstoneStream (hard stream delete) on JasperFx 2.90.0 #5674 touched this project by three lines of comment.
  • explicit_tag_type_registration: 25 tests, all green. Covers both overloads registering the same shape, null taking the same default, first-registration-wins both ways, the conflict refusal, both validation refusals on both overloads, TagTables and HStore end-to-end on explicit registrations, the remedy text for a readable/unreadable/string-backed/renamed-member tag, and the discovery-tolerance predicate on both sides of canEmit.
  • Full EventSourcingTests on net10.0: 2534 passed, 27 skipped, 0 failed.
  • Marten.AotSmoke: builds clean, no IL warnings from the consumer's own Program.cs — the overload is analyzer-clean for consumers. It registers a tag through the new overload and deliberately never calls an inferring one.
  • markdownlint and cspell clean on both changed docs.

One thing worth knowing about the smoke app

dotnet run of Marten.AotRuntimeSmoke fails one pre-existing check, an unregistered enum scalar projection names RegisterScalarType. It is not a regression — I reproduced it on clean master (7b4b409) with none of these changes, and it passes in the native run above. PublishAot turns RuntimeFeature.IsDynamicCodeSupported off under dotnet run as well, so a JIT process takes the AOT refusal branch while still answering the query. That makes dotnet run of this project a misleading gate; only the published binary counts. The new DCB check is written to accept either outcome for the same reason.

Credit

The API, the test suite, the AOT smoke checks and the docs here are @skoging's work from #5663, carried over and adapted; they also did the JasperFx half (JasperFx/jasperfx#1012) and the original native verification. Closing #5663 in favour of this.

🤖 Generated with Claude Code

https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3

…work

Supersedes #5663, whose API half landed independently in #5674. What is left is
the part #5674 did not carry: the diagnostics that point at the overload, the
argument validation, and the native coverage.

RegisterTagType<TTag>() reflects over the tag's members with ValueTypeInfo and
closes TagTypeRegistration<TTag, TInner> with MakeGenericType. #5674 annotated
both inferring overloads RequiresUnreferencedCode/RequiresDynamicCode, but an
analyzer warning only reaches an application that is itself AOT-analyzed -- a
store configured from inside a library gets nothing, which is how #5649 was
reported in the first place. EventGraph.CreateTagType now turns the failure into
an InvalidOperationException naming the one line to write, keeping the original
as its inner exception.

The two halves are gated differently on purpose. NotSupportedException and
MissingMethodException mean the generic could not be closed, which only happens
with no JIT, so they are reinterpreted only when dynamic code is unavailable.
InvalidValueTypeException is ambiguous -- the tag may genuinely not be a
single-value wrapper, or a trimmed image may have lost the members that say so,
and no runtime flag separates those because a trimmed non-AOT image still has
dynamic code -- so it is reinterpreted either way, with a message covering both
readings. The explicit overload is the answer to both.

Auto-discovery from projection identities had the inverse of that problem.
#5657 logs a warning for a discovery failure, but screens InvalidValueTypeException
into a silent catch as "this identity was never a value type" -- which is exactly
how a trimmed native image reports a perfectly good tag whose members were
trimmed. The one scenario #5649 reported was the one scenario that stayed quiet.
IsToleratedDiscoveryFailure now tolerates it only while dynamic code is
available, and the warning names the registration line including the
projection's ForAggregate<T>(). A trimmed non-AOT image is still silent, because
guessing the other way would warn on every projection whose identity is an
ordinary custom type.

Argument validation the overload did not have: a null unwrapper is refused, and
re-registering a tag type under a DIFFERENT inner type now throws rather than
silently keeping the first registration and leaving this call's unwrapper unused.

BEHAVIOUR CHANGE: an empty or whitespace tag table suffix is refused on every
RegisterTagType overload. mt_event_tag_ is a real table name, so "" was accepted
and put every tag type registered that way into one shared table. Pass null for
the default.

Marten.AotRuntimeSmoke gains six DCB checks -- tagging, a TagWith rule, Guid- and
string-backed tag queries, EventsExistAsync, FetchForWritingByTags into a
[BoundaryAggregate] with an append, DcbConcurrencyException, and the inferring
overload's message. Verified by RUNNING the native osx-arm64 binary: 48/48, and
the remedy message does fire natively. Marten.AotSmoke registers a tag through
the overload to hold the consumer-side analyzer axis clean.

Co-authored-by: Tore Hammervoll <hammervoll.tore@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3
@jeremydmiller

Copy link
Copy Markdown
Member Author

Cross-reference: supersedes #5663, whose API half landed independently in #5674. Closes the remaining half of #5649.

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