From 90f6e5b012910ccbfd424d370f4b854d72964e5d Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 15 Oct 2026 12:33:55 -0500 Subject: [PATCH] GH-5649 Name the explicit DCB tag registration when inference cannot 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() reflects over the tag's members with ValueTypeInfo and closes TagTypeRegistration 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(). 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 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3 --- docs/configuration/aot-publishing.md | 7 + docs/events/dcb.md | 34 +- .../Dcb/explicit_tag_type_registration.cs | 296 ++++++++++++++++++ src/Marten.AotRuntimeSmoke/Program.cs | 147 ++++++++- src/Marten.AotSmoke/Program.cs | 16 +- src/Marten/Events/EventGraph.cs | 180 ++++++++++- src/Marten/Events/IEventStoreOptions.cs | 11 +- 7 files changed, 675 insertions(+), 16 deletions(-) create mode 100644 src/EventSourcingTests/Dcb/explicit_tag_type_registration.cs diff --git a/docs/configuration/aot-publishing.md b/docs/configuration/aot-publishing.md index 56f95c7ef9..0688548e7c 100644 --- a/docs/configuration/aot-publishing.md +++ b/docs/configuration/aot-publishing.md @@ -187,6 +187,13 @@ As of Marten 9.0.0-alpha: This one is **not Marten's code**, which is worth knowing before you go looking in the wrong place. Writing `.Where(x => x.Amount > 10)` against an `IQueryable` makes the compiler emit code in *your* assembly that builds the expression tree at runtime, and `Expression.GreaterThan` on two decimals cannot use an IL comparison — `decimal` has no primitive `>` — so it looks up `System.Decimal.op_GreaterThan` by reflection. The trimmer has no reason to keep an operator nothing statically calls, and the factory then reports it as *"The binary operator GreaterThan is not defined for the types 'System.Decimal' and 'System.Decimal'"*. Equality is fine; it is the ordering comparisons that need the operator. See [#5597](https://github.com/JasperFx/marten/issues/5597), and note `Marten.AotRuntimeSmoke` covers this so the behaviour stays pinned if the runtime changes it. - **Event storage** — `StartStream`, `Append`, `FetchStream`, `FetchStreamStateAsync`, `AggregateStreamAsync`, `QueryAllRawEvents`, the async daemon. Reading an event needed a JIT until [#5373](https://github.com/JasperFx/marten/issues/5373): the events table closed its per-column reader over the column's member type at runtime. +- **Dynamic Consistency Boundary tags** — **once you register them with the explicit overload**, one line per tag type: + + ```cs + opts.Events.RegisterTagType(x => x.Value, "student"); + ``` + + `RegisterTagType()` reflects over the tag's members and closes `TagTypeRegistration` at runtime, so it fails while the store is being configured when the image has no code for that instantiation — and the exception names the line above. Tag types found by [automatic registration](/events/dcb#automatic-tag-type-registration) take the same path and are left unregistered when it fails, with a logged warning naming the line, so register those explicitly too. With explicit registrations, tagging, tag rules, `EventTagQuery`, `EventsExistAsync`, `AggregateByTagsAsync` and `FetchForWritingByTags` into a `[BoundaryAggregate]` run natively. See [DCB → Registering Tag Types under Trimming or Native AOT](/events/dcb#registering-tag-types-under-trimming-or-native-aot). - **Projections** — registered either as a projection type (`Projections.Add(...)`) or, for a self-aggregating type, through the identity-typed `LiveStreamAggregation()` / `Snapshot(...)` overloads: `SingleStreamProjection`, `MultiStreamProjection`, `EventProjection`, `CustomProjection`, and `EventApplier` — the JasperFx.Events source generator emits `[GeneratedEvolver]` dispatchers at compile time for each registration. Marten calls `Options.Projections.DiscoverGeneratedEvolvers(...)` at startup (`src/Marten/DocumentStore.cs:84`) to pick them up. - **Compiled queries** registered through `Marten.SourceGenerator` in an assembly marked `[JasperFxAssembly]`. - **Secondary stores** registered via `services.AddMartenStore()` — Marten 9 builds the implementation type via `System.Reflection.Emit` (PR [#4459](https://github.com/JasperFx/marten/pull/4459)). The trimmer warning at the emit site carries a `[RequiresDynamicCode]` annotation; published apps that use this surface can either suppress or rely on the runtime fall-through to keep working. diff --git a/docs/events/dcb.md b/docs/events/dcb.md index 9e3ce8cf20..c126a15ac7 100644 --- a/docs/events/dcb.md +++ b/docs/events/dcb.md @@ -80,8 +80,38 @@ opts.Events.RegisterTagType(id => id.Value, "student") ``` The second type argument is the primitive the tag table stores, typically `Guid`, `string`, `int` -or `long`. Automatic tag type registration from projection identities still uses reflection, so -under Native AOT register those identity types explicitly this way as well. +or `long`. The suffix is optional and defaults to the tag type's own name, exactly as it does for +the inferring overloads — so for a tag the inferring overload could have registered, the explicit +one registers the same inner type and the same table. + +If you call an inferring overload anyway and it cannot do its work, it says which line to write. +A native image that has no instantiation of `TagTypeRegistration` — or a trimmed +image that has lost the tag's members — throws an `InvalidOperationException` while the store is +being configured, naming the registration for the tag type in question and keeping the original +failure as its inner exception: + +```text +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(x => x.Value, "student"); +``` + +That message matters because the `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` warnings only +reach an application that is itself AOT-analyzed. A store configured from inside a library gets no +warning at build time, so the runtime message is the only thing that names the fix. + +[Automatic tag type registration](#automatic-tag-type-registration) uses the inferring path too. It +cannot throw — it runs while the store initializes, and a tag type is not worth refusing to start +over — so it logs a warning naming the same line, including the projection's `ForAggregate()`. +Under Native AOT, register those identity types explicitly as well rather than relying on it. + +::: warning +An empty table suffix is refused. `mt_event_tag_` is a real table name, so `RegisterTagType("")` +used to be accepted and would put every tag type registered that way into one shared table. Pass a +suffix, or `null` for the tag type's own name. +::: ### Tag Type Requirements diff --git a/src/EventSourcingTests/Dcb/explicit_tag_type_registration.cs b/src/EventSourcingTests/Dcb/explicit_tag_type_registration.cs new file mode 100644 index 0000000000..c538b2c9e2 --- /dev/null +++ b/src/EventSourcingTests/Dcb/explicit_tag_type_registration.cs @@ -0,0 +1,296 @@ +#nullable enable +using System; +using System.Reflection; +using System.Threading.Tasks; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; +using Marten.Events; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Dcb; + +/// +/// RegisterTagType<TTag, TInner>(unwrapper, tableSuffix), the overload that works under trimming +/// and Native AOT, and the diagnostics that point at it. The inferring overloads reflect over the tag's +/// members and close TagTypeRegistration<TTag, TInner> with MakeGenericType, neither of +/// which a native image can do. Under a JIT both overloads must register the same thing, and everything +/// downstream of the registration -- the tag tables, tag queries and the consistency boundary -- must not be +/// able to tell them apart. The native half is Marten.AotRuntimeSmoke. +/// +public class explicit_tag_type_registration: OneOffConfigurationsContext +{ + [Fact] + public void registers_the_same_shape_as_the_inferring_overload() + { + var inferred = new StoreOptions().EventGraph.RegisterTagType(); + var explicitly = new StoreOptions().EventGraph.RegisterTagType(x => x.Value, inferred.TableSuffix); + + explicitly.TagType.ShouldBe(inferred.TagType); + explicitly.SimpleType.ShouldBe(inferred.SimpleType); + explicitly.TableSuffix.ShouldBe(inferred.TableSuffix); + } + + [Fact] + public void a_null_suffix_takes_the_same_default_as_the_inferring_overload() + { + // The suffix is optional precisely so that the AOT-safe overload is not more verbose than it has + // to be; null has to mean the same table the inferring overload would have named. + new StoreOptions().EventGraph.RegisterTagType(x => x.Value) + .TableSuffix.ShouldBe(new StoreOptions().EventGraph.RegisterTagType().TableSuffix); + } + + [Fact] + public void honours_a_custom_table_suffix() + { + new StoreOptions().EventGraph.RegisterTagType(x => x.Value, "custom") + .TableSuffix.ShouldBe("custom"); + } + + [Fact] + public void the_first_registration_of_a_tag_type_wins_either_way() + { + var events = new StoreOptions().EventGraph; + + var first = events.RegisterTagType(x => x.Value, "enrollee"); + events.RegisterTagType("other").ShouldBeSameAs(first); + events.RegisterTagType(x => x.Value, "another").ShouldBeSameAs(first); + + events.TagTypes.Count.ShouldBe(1); + } + + [Fact] + public void an_explicit_registration_after_an_inferred_one_returns_the_first() + { + var events = new StoreOptions().EventGraph; + + var first = events.RegisterTagType("enrollee"); + events.RegisterTagType(x => x.Value, "another").ShouldBeSameAs(first); + + events.TagTypes.Count.ShouldBe(1); + } + + [Fact] + public void a_conflicting_inner_type_is_refused() + { + // Silently keeping the first would leave this call's unwrapper unused, so every tag value would be + // read by the other registration's rule + var events = new StoreOptions().EventGraph; + events.RegisterTagType("enrollee"); + + Should.Throw(() => + events.RegisterTagType(x => x.Value.ToString(), "enrollee")) + .Message.ShouldContain("already registered with the inner type System.Guid"); + } + + [Fact] + public void unwrapper_is_required() + { + Should.Throw(() => + new StoreOptions().EventGraph.RegisterTagType(null!, "enrollee")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void an_empty_table_suffix_is_refused_on_the_explicit_overload(string tableSuffix) + { + // It would name mt_event_tag_, a table every tag type registered that way would share + Should.Throw(() => + new StoreOptions().EventGraph.RegisterTagType(x => x.Value, tableSuffix)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void an_empty_table_suffix_is_refused_on_the_inferring_overload_too(string tableSuffix) + { + // BEHAVIOUR CHANGE: this used to be accepted and name mt_event_tag_ + Should.Throw(() => + new StoreOptions().EventGraph.RegisterTagType(tableSuffix)); + } + + [Theory] + [InlineData(DcbStorageMode.TagTables)] + [InlineData(DcbStorageMode.HStore)] + public async Task tags_queries_and_the_boundary_work_from_an_explicit_registration(DcbStorageMode mode) + { + StoreOptions(opts => + { + opts.Events.DcbStorageMode = mode; + opts.Events.AddEventType(); + opts.Events.AddEventType(); + + opts.Events.RegisterTagType(x => x.Value, "enrollee").ForAggregate(); + opts.Events.RegisterTagType(x => x.Value, "string_tag").ForAggregate(); + }); + + var enrollee = new EnrolleeId(Guid.NewGuid()); + var cohort = new StringTagId("cohort-" + Guid.NewGuid().ToString("N")); + + await using (var session = theStore.LightweightSession()) + { + var enrolled = session.Events.BuildEvent(new Enrolled("Alice")); + enrolled.WithTag(enrollee, cohort); + session.Events.Append(Guid.NewGuid(), enrolled); + await session.SaveChangesAsync(); + } + + await using (var session = theStore.LightweightSession()) + { + (await session.Events.QueryByTagsAsync(new EventTagQuery().Or(enrollee))).Count.ShouldBe(1); + (await session.Events.QueryByTagsAsync(new EventTagQuery().Or(cohort))).Count.ShouldBe(1); + + var boundary = await session.Events.FetchForWritingByTags( + new EventTagQuery().Or(enrollee)); + boundary.Aggregate!.EnrollmentCount.ShouldBe(1); + + var progress = session.Events.BuildEvent(new ProgressRecorded("week 1")); + progress.WithTag(enrollee); + boundary.AppendOne(progress); + await session.SaveChangesAsync(); + } + + await using (var session = theStore.LightweightSession()) + { + var state = await session.Events.AggregateByTagsAsync( + new EventTagQuery().Or(enrollee)); + state!.ProgressCount.ShouldBe(1); + } + } + + [Fact] + public void a_generic_that_cannot_be_closed_natively_names_the_explicit_overload() + { + var failure = new NotSupportedException("missing native code or metadata"); + + var ex = Should.Throw(() => + EventGraph.CreateTagType(typeof(EnrolleeId), "enrollee", _ => throw failure, canEmit: false)); + + ex.Message.ShouldContain("opts.Events.RegisterTagType(x => x.Value, \"enrollee\");"); + ex.Message.ShouldContain("Native AOT"); + ex.InnerException.ShouldBeSameAs(failure); + } + + [Fact] + public void a_missing_constructor_natively_names_the_explicit_overload_too() + { + var failure = new MissingMethodException("no parameterless constructor"); + + Should.Throw(() => + EventGraph.CreateTagType(typeof(EnrolleeId), "enrollee", _ => throw failure, canEmit: false)) + .InnerException.ShouldBeSameAs(failure); + } + + [Fact] + public void an_unreadable_tag_names_the_explicit_overload_under_a_jit_as_well() + { + // #5649 follow-up. 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. A trimmed + // non-AOT image still has dynamic code, so no runtime flag separates the two -- and the explicit + // overload is the answer either way, so it is named either way. + var failure = new InvalidValueTypeException(typeof(EnrolleeId), "Must be only a single public, 'gettable' property"); + + foreach (var canEmit in new[] { true, false }) + { + var ex = Should.Throw(() => + EventGraph.CreateTagType(typeof(EnrolleeId), "enrollee", _ => throw failure, canEmit)); + + ex.Message.ShouldContain("opts.Events.RegisterTagType(x => x.Value, \"enrollee\");"); + ex.Message.ShouldContain("trimmed"); + ex.InnerException.ShouldBeSameAs(failure); + } + } + + [Fact] + public void an_unrelated_failure_is_not_reinterpreted() + { + var failure = new ArgumentException("something else"); + + Should.Throw(() => + EventGraph.CreateTagType(typeof(EnrolleeId), null, _ => throw failure, canEmit: false)) + .ShouldBeSameAs(failure); + } + + [Fact] + public void a_closing_failure_under_a_jit_is_not_reinterpreted() + { + // Unlike the unreadable-tag case above, this one is unambiguous: a JIT closed the generic, so a + // NotSupportedException from inside the registration is about something else entirely. + var failure = new NotSupportedException("something else"); + + Should.Throw(() => + EventGraph.CreateTagType(typeof(EnrolleeId), null, _ => throw failure, canEmit: true)) + .ShouldBeSameAs(failure); + } + + [Fact] + public void the_remedy_uses_placeholders_when_the_tag_cannot_be_read() + { + EventGraph.TagTypeRegistrationLine(typeof(TwoPropertyTagId), "two", null) + .ShouldBe("opts.Events.RegisterTagType(x => x.Value, \"two\");"); + } + + [Fact] + public void the_remedy_follows_the_inner_type_and_supplies_the_default_suffix() + { + // A string-backed id has to get a line that compiles, not a hard-coded Guid, and the suffix the + // inferring overload would have used keeps the same tag table + EventGraph.TagTypeRegistrationLine(typeof(StringTagId), null, null) + .ShouldBe("opts.Events.RegisterTagType(x => x.Value, \"string_tag_id\");"); + } + + [Fact] + public void the_remedy_follows_the_member_name() + { + EventGraph.TagTypeRegistrationLine(typeof(CodeTagId), "code", null) + .ShouldBe("opts.Events.RegisterTagType(x => x.Code, \"code\");"); + } + + [Fact] + public void an_unreadable_identity_is_tolerated_quietly_under_a_jit() + { + // The ordinary case the discovery pass exists to walk past: a projection whose identity is simply + // not a strong-typed identifier. A warning here would fire for most projections. + var wrapped = new TargetInvocationException( + new InvalidValueTypeException(typeof(TwoPropertyTagId), "Must be only a single public, 'gettable' property")); + + EventGraph.IsToleratedDiscoveryFailure(wrapped, canEmit: true).ShouldBeTrue(); + } + + [Fact] + public void an_unreadable_identity_is_NOT_tolerated_quietly_with_no_jit() + { + // #5649 follow-up: natively, the same exception means the tag's members were trimmed away, which is + // a tag type silently going unregistered -- the failure #5649 actually reported, and the one that + // used to be the only one with no warning. + var wrapped = new TargetInvocationException( + new InvalidValueTypeException(typeof(EnrolleeId), "Must be only a single public, 'gettable' property")); + + EventGraph.IsToleratedDiscoveryFailure(wrapped, canEmit: false).ShouldBeFalse(); + } + + [Fact] + public void a_closing_failure_is_never_tolerated_quietly() + { + EventGraph.IsToleratedDiscoveryFailure(new NotSupportedException("missing native code or metadata"), canEmit: true) + .ShouldBeFalse(); + EventGraph.IsToleratedDiscoveryFailure(new NotSupportedException("missing native code or metadata"), canEmit: false) + .ShouldBeFalse(); + } + + [Fact] + public void the_discovery_line_keeps_the_aggregate() + { + // Auto-discovery registers the tag for the projection's aggregate, and a boundary append routes by it + EventGraph.TagTypeRegistrationLine(typeof(EnrolleeId), null, typeof(SubscriptionState)) + .ShouldBe("opts.Events.RegisterTagType(x => x.Value, \"enrollee_id\").ForAggregate();"); + } +} + +public record CodeTagId(string Code); + +public record TwoPropertyTagId(Guid Value, string Label); diff --git a/src/Marten.AotRuntimeSmoke/Program.cs b/src/Marten.AotRuntimeSmoke/Program.cs index 140bf74b59..541244fc0d 100644 --- a/src/Marten.AotRuntimeSmoke/Program.cs +++ b/src/Marten.AotRuntimeSmoke/Program.cs @@ -39,6 +39,12 @@ // ILC has no such instantiation, so every DCB boundary fetch threw // "No parameterless constructor defined for type // SingleStreamProjection`2[...]" (#5649). +// DCB tags RegisterTagType() let JasperFx close TagTypeRegistration +// with MakeGenericType and read the tag's members with ValueTypeInfo, so the +// store could not even be CONFIGURED. RegisterTagType(unwrapper) +// closes it at compile time and reflects over nothing; the checks below run +// tagging, tag rules, tag queries and the consistency boundary on it, and hold +// the inferring overload to naming that remedy instead of failing opaquely. // diagnostics criteria IDocumentStoreDiagnostics Where / OrderBy (jasperfx#869) are translated with // runtime code generation, so a native binary must REFUSE them -- before any // generic method is closed over the document type -- not crash, and never @@ -56,6 +62,7 @@ using JasperFx.Documents; using JasperFx.Events; using JasperFx.Events.Aggregation; +using JasperFx.Events.Tags; using Marten; using Marten.Exceptions; using Marten.Linq; @@ -92,6 +99,15 @@ // SingleStreamProjection<,> at runtime. Dossier is deliberately not named by a Schema.For() // either, so this also covers the aggregate's mapping being built from a Type. o.Projections.LiveStreamAggregation(); + + // DCB: the inner type is named and the unwrapper passed, so nothing is closed and nothing is + // reflected over at runtime. The tag types are records over a Guid and a string, the two inner + // types a tag table is most often keyed by. + o.Events.AddEventType(); + o.Events.RegisterTagType(x => x.Value, "patient").ForAggregate(); + o.Events.RegisterTagType(x => x.Value, "zorgverlener") + .ForAggregate(); + o.Events.TagWith(e => new ZorgverlenerCode(e.Zorgverlener)); }); DecimalOperatorRoots.Root(); @@ -443,18 +459,17 @@ await Check("diagnostics criteria refused under Native AOT", async () => } }); - // #5649. Exercised directly rather than through AggregateByTagsAsync, because reaching the - // boundary fetch needs a registered tag type and RegisterTagType closes its own open - // generic -- a separate AOT hole. marten#5673 marks that overload RequiresDynamicCode and adds - // the closed RegisterTagType(unwrapper) (TagTypeRegistration.FromUnwrapper), which - // a native smoke of the tag path should use. This is the one site this fix moves, and it is - // the site the reported MissingMethodException came from. + // #5649. Exercised directly rather than through AggregateByTagsAsync, because this aggregate's + // tag type is deliberately not registered at all. This is the one site that fix moves, and it is + // the site the reported MissingMethodException came from; the DCB checks below reach the same + // branch through the fetch itself, on explicit RegisterTagType registrations. await Check("boundary aggregate aggregator source", () => { var source = store.Events.Build(); return Task.FromResult(source is Marten.Events.Aggregation.SingleStreamProjection); }); + await CheckDcbAsync(); } catch (Exception e) { @@ -539,6 +554,103 @@ await Check("an unregistered enum scalar projection names RegisterScalarType", a }); } +// Dynamic Consistency Boundary tags, all from the explicit RegisterTagType registrations +// in the store above. +async Task CheckDcbAsync() +{ + var patient = new PatientId(Guid.NewGuid()); + var zorgverlener = new ZorgverlenerCode("01059910"); + var traject = new EventTagQuery().Or(patient); + + ConsultGehouden Consult(string verrichting) => new(patient.Value, zorgverlener.Value, verrichting); + + await Check("DCB append with tags", async () => + { + await using var s = store.LightweightSession(); + var eerste = s.Events.BuildEvent(Consult("intake")); + eerste.WithTag(patient); // the zorgverlener tag comes from the TagWith rule + s.Events.Append("traject-1", eerste); + await s.SaveChangesAsync(); + return true; + }); + + await Check("DCB query by a Guid-backed tag", async () => + { + await using var s = store.LightweightSession(); + return (await s.Events.QueryByTagsAsync(traject)).Count == 1; + }); + + await Check("DCB query by a string-backed tag added by a TagWith rule", async () => + { + await using var s = store.LightweightSession(); + return (await s.Events.QueryByTagsAsync(new EventTagQuery().Or(zorgverlener))).Count == 1 + && await s.Events.EventsExistAsync(new EventTagQuery().Or(zorgverlener)); + }); + + await Check("DCB FetchForWritingByTags into a [BoundaryAggregate] and append", async () => + { + await using var s = store.LightweightSession(); + var boundary = await s.Events.FetchForWritingByTags(traject); + if (boundary.Aggregate?.Consulten != 1) return false; + + var vervolg = s.Events.BuildEvent(Consult("controle")); + vervolg.WithTag(patient); + boundary.AppendOne(vervolg); + await s.SaveChangesAsync(); + + await using var q = store.LightweightSession(); + return (await q.Events.AggregateByTagsAsync(traject))?.Consulten == 2; + }); + + await Check("DCB boundary refuses a concurrent append", async () => + { + await using var first = store.LightweightSession(); + await using var second = store.LightweightSession(); + var b1 = await first.Events.FetchForWritingByTags(traject); + var b2 = await second.Events.FetchForWritingByTags(traject); + + var e1 = first.Events.BuildEvent(Consult("telefonisch")); + e1.WithTag(patient); + b1.AppendOne(e1); + await first.SaveChangesAsync(); + + var e2 = second.Events.BuildEvent(Consult("telefonisch")); + e2.WithTag(patient); + b2.AppendOne(e2); + try + { + await second.SaveChangesAsync(); + return false; + } + catch (DcbConcurrencyException) + { + return true; + } + }); + + // The inferring overload cannot work natively, so it has to say what to write instead. A JIT process + // (`dotnet run` of this project) closes the generic and simply registers it. Neither RuntimeFeature flag + // tells the two apart here -- PublishAot turns IsDynamicCodeSupported off under `dotnet run` as well, and + // CoreCLR derives IsDynamicCodeCompiled from it -- so the check accepts either outcome, and nothing else. + await Check("inferred tag registration names the explicit overload under Native AOT", () => + { + try + { + var options = new StoreOptions(); + var registered = options.Events.RegisterTagType("verwijzing").SimpleType == typeof(Guid); + Console.WriteLine(" (inferred registration succeeded, as under a JIT)"); + return Task.FromResult(registered); + } + catch (InvalidOperationException e) + { + Console.WriteLine(" (inferred registration refused with the remedy, as natively)"); + return Task.FromResult(e.InnerException is NotSupportedException or MissingMethodException + or JasperFx.Core.Reflection.InvalidValueTypeException + && e.Message.Contains("RegisterTagType(x => x.Value, \"verwijzing\")")); + } + }); +} + async Task Check(string description, Func> check) { try @@ -662,6 +774,27 @@ public record DossierGeopend(string Nummer, string Naam); public record RegelToegevoegd(string Prestatiecode); +public readonly record struct PatientId(Guid Value); + +public readonly record struct ZorgverlenerCode(string Value); + +/// Only ever registered through the inferring overload, so nothing roots its instantiation. +public readonly record struct VerwijzingId(Guid Value); + +public record ConsultGehouden(Guid Patient, string Zorgverlener, string Verrichting); + +/// +/// A pure boundary aggregate over the DCB tags: no identity, so only the [BoundaryAggregate] marker gets the +/// source generator to emit its evolver, and only a tag query can load it. +/// +[BoundaryAggregate] +public class Behandeltraject +{ + public int Consulten { get; set; } + + public void Apply(ConsultGehouden _) => Consulten++; +} + // #5649: a *pure* DCB boundary aggregate -- Apply methods, no identity of any kind. The // [BoundaryAggregate] marker is the only thing that makes EventGraph.Build() take the branch // under test, and its id is unconditionally string (jasperfx#324). @@ -684,4 +817,6 @@ public class Toegangsgrens [JsonSerializable(typeof(Dossier))] [JsonSerializable(typeof(DossierGeopend))] [JsonSerializable(typeof(RegelToegevoegd))] +[JsonSerializable(typeof(ConsultGehouden))] +[JsonSerializable(typeof(Behandeltraject))] public partial class SmokeJson: JsonSerializerContext; diff --git a/src/Marten.AotSmoke/Program.cs b/src/Marten.AotSmoke/Program.cs index 8270211ed1..249bde4db8 100644 --- a/src/Marten.AotSmoke/Program.cs +++ b/src/Marten.AotSmoke/Program.cs @@ -37,6 +37,11 @@ // (The build itself doesn't catch a missing emission — the SG silently // skips when its preconditions don't hold; the runtime check is the // sentinel.) +// - opts.Events.RegisterTagType(unwrapper, tableSuffix) — +// the DCB tag registration an AOT consumer uses (#5649). The inferring +// RegisterTagType() is annotated RequiresDynamicCode and +// RequiresUnreferencedCode and is not exercised here; calling it would +// be exactly the warning this project proves absent. // - Host.CreateApplicationBuilder + Build + DI resolution of // IDocumentStore — the boot path consumers actually hit. // @@ -123,6 +128,13 @@ // on the aggregate type itself (still SG-dispatched because the // emission targets the aggregate's Evolve signature directly). opts.Projections.Snapshot(SnapshotLifecycle.Inline); + + // DCB tag registration with the inner type named and the unwrapper passed, so nothing + // is closed and nothing is reflected over at runtime (#5649). The inferring + // RegisterTagType() is annotated RequiresDynamicCode/RequiresUnreferencedCode and + // is deliberately NOT exercised here -- calling it would be the IL3050/IL2026 this + // project exists to prove absent. + opts.Events.RegisterTagType(x => x.Value, "smoke_tag"); }); using var host = builder.Build(); @@ -134,7 +146,7 @@ return 1; } -Console.WriteLine("Marten AOT smoke OK — AddMarten + Schema.For + AddEventType + Projections.Add + DI resolve clean."); +Console.WriteLine("Marten AOT smoke OK — AddMarten + Schema.For + AddEventType + Projections.Add + RegisterTagType + DI resolve clean."); return 0; @@ -147,6 +159,8 @@ public sealed class SmokeDoc public sealed record SmokeEvent(int Delta); +public sealed record SmokeTagId(Guid Value); + public sealed class SmokeProjection : SingleStreamProjection { public override SmokeDoc Evolve(SmokeDoc? snapshot, Guid id, IEvent e) diff --git a/src/Marten/Events/EventGraph.cs b/src/Marten/Events/EventGraph.cs index 06f3ee0736..0064f11db1 100644 --- a/src/Marten/Events/EventGraph.cs +++ b/src/Marten/Events/EventGraph.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using ImTools; @@ -590,7 +591,8 @@ public ITagTypeRegistration RegisterTagType() where TTag : notnull var existing = _tagTypes.FirstOrDefault(t => t.TagType == typeof(TTag)); if (existing != null) return existing; - var registration = TagTypeRegistration.Create(); + var registration = CreateTagType(typeof(TTag), null, TagTypeRegistration.Create, + RuntimeFeature.IsDynamicCodeSupported); _tagTypes.Add(registration); return registration; } @@ -602,10 +604,13 @@ public ITagTypeRegistration RegisterTagType() where TTag : notnull [RequiresDynamicCode("Closes TagTypeRegistration with MakeGenericType (TagTypeRegistration.Create). Under Native AOT use RegisterTagType(unwrapper), backed by TagTypeRegistration.FromUnwrapper.")] public ITagTypeRegistration RegisterTagType(string tableSuffix) where TTag : notnull { + assertUsableTableSuffix(tableSuffix); + var existing = _tagTypes.FirstOrDefault(t => t.TagType == typeof(TTag)); if (existing != null) return existing; - var registration = TagTypeRegistration.Create(tableSuffix); + var registration = CreateTagType(typeof(TTag), tableSuffix, TagTypeRegistration.Create, + RuntimeFeature.IsDynamicCodeSupported); _tagTypes.Add(registration); return registration; } @@ -615,17 +620,153 @@ public ITagTypeRegistration RegisterTagType(string tableSuffix) where TTag /// Native AOT-safe form of (jasperfx#1012). Nothing is /// discovered by reflection and no generic type is closed at runtime. /// + /// Reads the inner value out of the tag, e.g. x => x.Value + /// + /// Table name suffix. null takes the same default the inferring overloads use — the tag + /// type's name as a table alias — and an empty or whitespace suffix is refused. + /// + /// + /// The tag type is already registered with a different inner type + /// public ITagTypeRegistration RegisterTagType(Func unwrapper, string? tableSuffix = null) where TTag : notnull { + ArgumentNullException.ThrowIfNull(unwrapper); + assertUsableTableSuffix(tableSuffix); + var existing = _tagTypes.FirstOrDefault(t => t.TagType == typeof(TTag)); - if (existing != null) return existing; + if (existing != null) + { + // The first registration of a tag type wins, as it does for the inferring overloads. But a + // DIFFERENT inner type is a conflicting registration rather than a repeated one, and silently + // keeping the first would leave the unwrapper this call passed unused -- every tag value would + // then be read by the other registration's rule. Inference can only ever produce one inner + // type for a given tag, so a disagreement means one of the two call sites is wrong. + if (existing.SimpleType != typeof(TInner)) + { + throw new InvalidOperationException( + $"The tag type {typeof(TTag).FullNameInCode()} is already registered with the inner type " + + $"{existing.SimpleType.FullNameInCode()}, so it cannot be registered again with {typeof(TInner).FullNameInCode()}."); + } + + return existing; + } var registration = TagTypeRegistration.FromUnwrapper(unwrapper, tableSuffix); _tagTypes.Add(registration); return registration; } + /// + /// A tag table is named mt_event_tag_<suffix>, so an empty suffix names + /// mt_event_tag_ — a real table, which is why nothing failed before, and a table every tag + /// type registered that way would share. null is the way to ask for the default; "" never + /// meant anything a caller wanted. + /// + private static void assertUsableTableSuffix(string? tableSuffix) + { + if (tableSuffix == null) return; + + if (tableSuffix.IsEmpty() || tableSuffix.Trim().Length == 0) + { + throw new ArgumentException( + "An empty tag table suffix would name the table mt_event_tag_, which every tag type registered " + + "that way would share. Pass a suffix, or null for the tag type's own name.", + nameof(tableSuffix)); + } + } + + /// + /// + /// #5649. reads the inner type with + /// ValueTypeInfo, which reflects over the tag's members, and closes + /// TagTypeRegistration<TTag, TInner> with MakeGenericType. A Native AOT image + /// has no code for that instantiation, and a trimmed one may have lost the tag's members. Neither + /// failure says that the fix is one registration call, and the + /// [RequiresDynamicCode]/[RequiresUnreferencedCode] annotations only reach an + /// application that is itself AOT-analyzed — a store configured from inside a library is not. + /// + /// + /// The two halves are gated differently on purpose. + /// / mean the generic + /// could not be closed, which only happens with no JIT, so they are reinterpreted only when + /// is false. is + /// ambiguous — the tag may genuinely not be a single-value wrapper, or its members may + /// have been trimmed away — and no runtime flag separates those two, because a trimmed + /// non-AOT image still has dynamic code. It is reinterpreted either way, with a message that + /// covers both readings, because the explicit overload is the answer to both. + /// + /// + /// and are parameters so that a JIT test + /// can exercise the native branch, as in ValueTypeIdGeneration.BuildSelectClause (#5600). + /// A guard reachable only by publishing natively is a guard nothing checks. + /// + /// + internal static ITagTypeRegistration CreateTagType(Type tagType, string? tableSuffix, + Func create, bool canEmit) + { + try + { + return create(tableSuffix); + } + catch (InvalidValueTypeException e) + { + throw new InvalidOperationException( + $"The tag type {tagType.FullNameInCode()} 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:{Environment.NewLine}{Environment.NewLine}" + + $" {TagTypeRegistrationLine(tagType, tableSuffix, null)}{Environment.NewLine}{Environment.NewLine}" + + "The unwrapper replaces reflecting over the tag's members, and naming the inner type closes the generic at " + + "compile time. The overload works the same under a JIT. " + + "See https://martendb.io/configuration/aot-publishing and https://github.com/JasperFx/marten/issues/5649.", + e); + } + catch (Exception e) when (!canEmit && e is NotSupportedException or MissingMethodException) + { + throw new InvalidOperationException( + $"The tag type {tagType.FullNameInCode()} could not be registered by inference, and this is a Native AOT image, " + + $"which cannot close TagTypeRegistration<,> over it at runtime. Register it with its inner type and unwrapper instead:{Environment.NewLine}{Environment.NewLine}" + + $" {TagTypeRegistrationLine(tagType, tableSuffix, null)}{Environment.NewLine}{Environment.NewLine}" + + "Naming the inner type is what closes the generic at compile time. The overload works the same under a JIT. " + + "See https://martendb.io/configuration/aot-publishing and https://github.com/JasperFx/marten/issues/5649.", + e); + } + } + + /// + /// The explicit registration of as a line of code. The inner type and member + /// follow the tag when it can be read, with TInner and Value as placeholders when it can't, and a + /// missing or empty suffix becomes the one the inferring overload uses by default. + /// + /// + /// NameInCode() rather than FullNameInCode() for the types inside the line, matching the + /// #5600 and #5635 remedies — the fully qualified name is in the sentence above it. A tag type nested + /// inside another type therefore prints a line that needs its outer scope added; that is true of all + /// three remedies and is not worth diverging here. + /// + [UnconditionalSuppressMessage("Trimming", "IL2067", + Justification = "Diagnostic text only; if the tag type's members were trimmed the line falls back to placeholders.")] + internal static string TagTypeRegistrationLine(Type tagType, string? tableSuffix, Type? aggregateType) + { + var inner = "TInner"; + var member = "Value"; + try + { + var valueType = ValueTypeInfo.ForType(tagType); + inner = valueType.SimpleType.NameInCode(); + member = valueType.ValueProperty.Name; + } + catch (Exception) + { + // Not a shape ValueTypeInfo understands, or its members were trimmed; the placeholders still name + // the overload + } + + var suffix = string.IsNullOrEmpty(tableSuffix) ? tagType.Name.ToTableAlias() : tableSuffix; + var forAggregate = aggregateType == null ? "" : $".ForAggregate<{aggregateType.NameInCode()}>()"; + return $"opts.Events.RegisterTagType<{tagType.NameInCode()}, {inner}>(x => x.{member}, \"{suffix}\"){forAggregate};"; + } + /// /// The registered tag types for DCB support. /// @@ -1168,7 +1309,7 @@ private void autoDiscoverTagTypesFromProjections(ILogger logger) registration.ForAggregate(projection.AggregateType); _tagTypes.Add(registration); } - catch (Exception e) when (isNotAValueType(e)) + catch (Exception e) when (IsToleratedDiscoveryFailure(e, RuntimeFeature.IsDynamicCodeSupported)) { // An identity that is simply not a strong-typed identifier is the ordinary case this // discovery pass has to tolerate, and it is not worth a word. @@ -1188,13 +1329,40 @@ private void autoDiscoverTagTypesFromProjections(ILogger logger) // traceable to "could not register" instead of being indistinguishable from "there // was nothing to register". logger.LogWarning(e, - "Unable to automatically register the identity type {IdentityType} of projection {Projection} as an event tag type. Tag queries and boundary-aggregate fetches using this tag will not work. Under Native AOT, register it explicitly with StoreOptions.Events.RegisterTagType<{IdentityType}>().", + "Unable to automatically register the identity type {IdentityType} of projection {Projection} as an event tag type. Tag queries and boundary-aggregate fetches using this tag will not work. Register it explicitly with {Registration}", identityType.FullNameInCode(), projection.GetType().FullNameInCode(), - identityType.FullNameInCode()); + TagTypeRegistrationLine(identityType, null, projection.AggregateType)); } } } + /// + /// Whether an auto-discovery failure is the ordinary, uninteresting kind: a projection identity that + /// is not a strong-typed identifier at all, which this pass exists to walk past quietly. + /// + /// + /// + /// #5649 follow-up. Gated on because + /// does not only mean "not a value type". Under Native + /// AOT, an application that does not root its own assembly loses the tag's members, and + /// ValueTypeInfo reports a perfectly good tag exactly the same way — which made the one + /// scenario #5649 reported the one scenario that stayed silent, while the + /// path got a warning. With no JIT, nothing here is + /// ordinary, so it falls through to the warning. + /// + /// + /// A trimmed non-AOT image still has dynamic code and so is still silent. Nothing at + /// runtime distinguishes it from an identity that was never a value type, and guessing wrong + /// would mean a warning on every ordinary projection whose identity is a plain custom type. + /// + /// + /// A parameter rather than a direct read so + /// a JIT test can exercise the native branch, as in and + /// ValueTypeIdGeneration.BuildSelectClause (#5600). + /// + /// + internal static bool IsToleratedDiscoveryFailure(Exception e, bool canEmit) => canEmit && isNotAValueType(e); + /// /// An identity type that is not a strong-typed identifier at all. ValueTypeInfo.ForType raises /// this before any generic is closed, and MethodInfo.Invoke wraps it, so the chain is walked. diff --git a/src/Marten/Events/IEventStoreOptions.cs b/src/Marten/Events/IEventStoreOptions.cs index d3fbcba715..f287ad9daa 100644 --- a/src/Marten/Events/IEventStoreOptions.cs +++ b/src/Marten/Events/IEventStoreOptions.cs @@ -598,8 +598,17 @@ public IEventStoreOptions Upcast( /// A strong-typed identifier type (e.g., StudentId) /// The primitive the tag table stores, e.g. Guid, string, int or long /// Reads the primitive out of the tag, e.g. id => id.Value - /// Optional table name suffix; defaults to the tag type's name + /// + /// Optional table name suffix; null defaults to the tag type's name, and an empty or + /// whitespace suffix is refused rather than naming the shared mt_event_tag_ table. + /// /// The tag type registration for further configuration + /// is null + /// is empty or whitespace + /// + /// The tag type is already registered with a different inner type. Registering it again with the + /// same inner type returns the first registration, as the inferring overloads do. + /// ITagTypeRegistration RegisterTagType(Func unwrapper, string? tableSuffix = null) where TTag : notnull;