Repository navigation
Persistence.Hosting: start plugin types from code (Native AOT) - #8738
Conversation
Aaronontheweb
left a comment
There was a problem hiding this comment.
Self-review: what each change and test is for. Inline comments have the detail.
What this PR does
- Core read every persistence plugin type from a HOCON
classstring with reflection, so no plugin started withAkka.DynamicTypeLoadingoff. - The Akka.Persistence.Hosting builders now also hand core plugins, event adapters and read journals as code, through an internal
PersistenceSetup. - Core merges the list per plugin id: the later plugin wins, and a journal's adapters add up across registrations.
- Each lookup site tries the registration, then built-ins, then the switch guard, then reflection. The exact order differs by site; see "Behavior changes".
- With the switch off, an unregistered type fails at start with a
ConfigurationExceptionthat names the setting, the type and Akka.Persistence.Hosting. - A plugin author opts in with one override,
CreatePluginActorFactory(), on the options class they already have. - A new canary app (
Akka.Persistence.AOT.App), a CI gate with a warning baseline, and two docs sections prove and explain it.
Public API added
PluginActorFactorywithFor<TActor>.JournalOptions.CreatePluginActorFactory()andSnapshotOptions.CreatePluginActorFactory(): protected virtual, defaultnull.WithReadJournal<TProvider>andWithStashOverflowStrategyonAkkaConfigurationBuilder.- Factory overloads of
AddEventAdapter,AddReadEventAdapterandAddWriteEventAdapter. [DynamicallyAccessedMembers]onTAdapterof the three existing generic overloads. This edits an existing signature.- New
InternalsVisibleToentries (they show in the approval files), and a new Hosting reference to Akka.Persistence.Query.
How Hosting stays unchanged
- No existing Hosting test changes. Both Hosting test files are new.
HoconSnapshotSpecscompares the merged HOCON against a fixed string, with and without a factory override. It sorts keys and trims whitespace, and "captured before the change" is a code comment, so it proves the merged result, not byte equality.- With the switch on, HOCON
classand HOCON adapters win over registrations.PersistenceSetupSpecpins this (Should_keep_the_hocon_class...,Should_build_an_adapter_from_hocon...). - Options that do not override the hook return
nulland register nothing, so downstream plugin packages take the old path. - The Hosting theories run the same calls with the switch on and off.
- Gap: no Hosting-level test starts a plugin that has both a
classand a factory with the switch on. Only the core spec does.
Behavior changes to notice
- Read journals and the stash overflow strategy: a registration wins over HOCON even with the switch on. Plugins and adapters do the opposite. The PR text ("registrations only fill gaps") is wrong for these two.
- A plugin registered with no HOCON
classnow starts on the JIT where it used to throw. Only options that override the hook can reach this. - An adapter added with no bound types has no HOCON, so on the JIT it is now built once where it was ignored.
[DynamicallyAccessedMembers]onAddEventAdapter<TAdapter>and its siblings can raise IL2091 for callers that pass an unannotated generic type.- A null
boundTypesnow throwsArgumentNullExceptioninstead ofNullReferenceException. - Akka.Persistence.Hosting now depends on Akka.Persistence.Query, which depends on Akka.Streams. Every Hosting package that depends on it gets both.
- Hosting builds adapters with an exact
(ExtendedActorSystem)constructor lookup. Core'sActivator.CreateInstance(type, system)also takes anActorSystemparameter, so such an adapter works from HOCON on the JIT and fails with the switch off. - With the switch off, HOCON bindings that name only registered adapters are skipped silently, even when they bind a type the registration does not.
Overlap
- Streams warnings are now gated by two canary baselines (Hosting and Persistence).
PersistenceSetupSpecand the Hosting specs both test several plugin ids, later-wins and the switch-off error.ReadJournalDetailsSpechas a setup-sharing test thatPersistenceSetupSpecand the Hosting tests already cover.Merge,PersistenceSetupExtensions.WithReadJournalandPersistencePluginDetails.Equalshave no product caller, only tests.JournalDetails.Createrejects a repeated adapter name, the registry and Hosting let the later one win, and only tests reach the strict check.- Three AOT apps carry their own log watchdog, and several test projects carry their own
DynamicTypeLoadingCollection. - The Hosting tests depend on the string
WithJournaloverload, which is marked for removal in v1.6.
Not covered
- No test starts a real stash overflow through a registered configurator, in tests or in the canary.
- The canary runs the parameterless adapter constructor only. The
ExtendedActorSystemconstructor branch runs on the JIT only. - The read and read-write factory overloads of
AddReadEventAdapterandAddEventAdapterhave no Hosting-level test. - No test calls the builders after the actor system starts, so the
AddSetupno-op guard is unexercised. - No test covers a downstream plugin that returns a factory and also writes a
class, started through Hosting with the switch on. - The canary journal subclasses
MemoryJournal. A real plugin's own startup (database, child actors) is not tested under AOT. - PersistentFSM snapshots stay a baselined AOT gap that the canary never runs.
WithStashOverflowStrategydoes not null-checkbuilder.
| /// results as calls come in. | ||
| /// </para> | ||
| /// </summary> | ||
| internal sealed class PersistenceSetup : Setup |
There was a problem hiding this comment.
We keep PersistenceSetup internal because Akka.Persistence.Hosting is the configuration API and users never build one. It is a plain ordered list, so each Hosting call can append and core decides the winner later.
| /// wins the factory and the default config. Event adapters of a journal accumulate over all registrations | ||
| /// for its id, and the later one wins a name clash. | ||
| /// </summary> | ||
| public static PersistencePluginRegistry Build(PersistenceSetup? setup) |
There was a problem hiding this comment.
We walk the registrations in order. A later plugin replaces an earlier one with the same id, default config included, with no merge. Adapters pile up per journal id, so call order between a journal and its adapters does not matter.
| // decides as it always did, and a registration only fills in when there is no `class`. With reflection off: | ||
| // registration (by plugin id), built-in, guard. | ||
| Props pluginProps; | ||
| if (registeredProps is not null && (!AkkaFeatures.IsDynamicTypeLoadingSupported || string.IsNullOrEmpty(pluginTypeName))) |
There was a problem hiding this comment.
We let HOCON class decide when the switch is on and use the registration only when class is missing. Existing Hosting users have a class in their HOCON, so their JIT path is the same code as before. One difference: a registered plugin with no class now starts on the JIT where it used to throw, and only options that override the new hook can get there. With the switch off and no registration we fail at start naming the setting, the class and Hosting, instead of a reflection error deep in actor creation.
| var host = Host.CreateApplicationBuilder(); | ||
| host.Logging.ClearProviders(); | ||
| host.Services.AddAkka(label, builder => builder | ||
| .WithJournalAndSnapshot(new CanaryJournalOptions(), new CanarySnapshotOptions(), |
There was a problem hiding this comment.
We build the system with Hosting calls only and the switch off because it proves the whole chain under real Native AOT: factories, adapter, read journal, stash configurator and serializer. No HOCON names a plugin class, so any reflection fallback fails the start.
| /// The default journal, snapshot store and stash overflow strategy are the registered ones, built by the | ||
| /// factories, once each, with the default config in the journal's section. The persist, recovery and queries above only work when they ran. | ||
| /// </summary> | ||
| private static void AssertRegisteredPluginsAreUsed(ActorSystem system) |
There was a problem hiding this comment.
We count constructions and read the default-config marker because a green round trip alone would also pass if some other journal served the calls. A count of one proves the factory ran once. It does not prove the stash configurator handles a real overflow.
| # PersistentFSM snapshots are serialized by reading the generic argument's members by name. The | ||
| # open generic is not in the serializer's built-in table (PersistenceManifestSpec), so it stays a | ||
| # reflection site. The canary never serializes one. | ||
| IL2070|src/core/Akka.Persistence/Serialization/PersistenceMessageSerializer.cs|Akka.Persistence.Serialization.PersistenceMessageSerializer.GetPersistentFSMSnapshot(Type,Byte[])|'this'argumentdoesnotsatisfy'DynamicallyAccessedMemberTypes.PublicConstructors'incallto'System.Type.GetConstructor(Type[])'.Theparameter'type'ofmethod'Akka.Persistence.Serialization.PersistenceMessageSerializer.GetPersistentFSMSnapshot(Type,Byte[])'doesnothavematchingannotations.Thesourcevaluemustdeclareatleastthesamerequirementsasthosedeclaredonthetargetlocationitisassignedto. |
There was a problem hiding this comment.
We baseline two warnings, both from PersistentFSM snapshot serialization, which the canary never runs. That path stays untested under AOT. Streams has no entries, so any new Streams IL warning fails here.
ff04551 to
1966349
Compare
04dd9cf to
07b520e
Compare
Aaronontheweb
left a comment
There was a problem hiding this comment.
Self-review of the rebuilt PR: notes on why the key pieces exist, plus a few gaps I found (Sql options bases, the adapter rebinding edge case, the sharding adapter's test coverage, and a wrong test name).
| /// <typeparam name="TJournal">The journal actor. It needs a public constructor that takes the plugin's | ||
| /// <see cref="Config"/>, or a public parameterless one, the same rule HOCON <c>class</c> follows.</typeparam> | ||
| public abstract class JournalOptions< | ||
| [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] TJournal> |
There was a problem hiding this comment.
This base is the only change a plugin author makes: swap JournalOptions for JournalOptions<TJournal>. The flags spell out core's Props.ActorTypeMembers by hand because that constant is internal to Akka, so if core ever adds a flag this has to follow. Hosting targets netstandard2.0 and its own build runs no trim analyzer, so the persistence canary is the only thing that checks these annotations.
| /// The config path of this journal's read journal, which <c>ReadJournalFor</c> is called with. | ||
| /// <b>Default</b>: <c>akka.persistence.query.journal.{Identifier}</c>. Override it when the plugin uses another path. | ||
| /// </summary> | ||
| protected virtual string ReadJournalPluginId => $"akka.persistence.query.journal.{Identifier}"; |
There was a problem hiding this comment.
Most plugins keep their query section at akka.persistence.query.journal.{Identifier}, so the default needs no code. A plugin with a fixed read journal id (Mongo's MongoDbReadJournal.Identifier, the in-memory one in the canary) overrides it. Protected, because only the plugin knows where its query HOCON lives.
| /// The <c>(Config)</c> constructor where the type has one, else the parameterless one. Called inside the | ||
| /// actor's creation context, so a failure surfaces where a HOCON <c>class</c> failure would. | ||
| /// </summary> | ||
| public static Func<Config, T> ActorFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() |
There was a problem hiding this comment.
Picks the constructor once, at registration, by the rule core applies to a HOCON class: (Config) first, else parameterless. A constructor that throws comes out wrapped in TargetInvocationException, as it does on the Props reflection path, so failures look the same either way. The read journal version throws on use, not at registration, so an odd provider still registers on the JIT, where HOCON decides anyway.
| else if (BuiltInPersistencePlugins.TryCreatePluginProps(pluginTypeName, pluginConfig, out var builtInProps)) | ||
| pluginProps = builtInProps; | ||
| else | ||
| throw new ConfigurationException(AkkaFeatures.NotBuiltIn( |
There was a problem hiding this comment.
With the switch off and nothing registered, we fail at start and name the setting, the class and the Hosting route (the generic bases), not just that reflection is off. A user who hits this learns which package needs the update. The canary and the Hosting 'fail the plugin start' test both check that the message names the setting, the switch and Akka.Persistence.Hosting.
| echo "##vso[task.logissue type=error]The Persistence AOT canary exited with code $rc." | ||
| exit 1 | ||
| fi | ||
| if ! grep -qF '[canary-persistence] OK' "$(Agent.TempDirectory)/persistence-aot-run.log"; then |
There was a problem hiding this comment.
Exit code 0 alone doesn't pass: the step also needs [canary-persistence] OK, which the canary prints only after every Require holds and the log watchdog saw no warning. One publish log feeds both this run and the warning check below, so the run and the baseline always look at the same binary.
| [Theory(DisplayName = "The Hosting builders should emit the HOCON they always emitted When a representative set of calls is made")] | ||
| [InlineData(false)] | ||
| [InlineData(true)] | ||
| public void Should_emit_the_HOCON_they_always_emitted_When_a_representative_set_of_calls_is_made(bool typed) |
There was a problem hiding this comment.
This is the 'existing Hosting users see no change' test. The same builder calls, with plain options and with generic ones, must both give the HOCON captured from dev, byte for byte apart from line endings. If registration ever starts touching HOCON, this fails.
With Akka.DynamicTypeLoading off, persistence loads its journals, snapshot stores, read journals and event adapters from an internal PersistenceSetup that Akka.Persistence.Hosting fills, not from HOCON type names. A plugin names its types by deriving its options from JournalOptions<TJournal>, JournalOptions<TJournal, TReadJournalProvider> or SnapshotOptions<TSnapshotStore>. The typed AddEventAdapter, AddReadEventAdapter and AddWriteEventAdapter builder methods name the adapter type the same way. With the switch on, nothing changes: HOCON decides and the emitted HOCON is byte-identical to dev. The persistence setup, its records and the registry are internal. Built-in plugins start through the same records. Remove the obsolete JournalOptions.Adapters property, which Akka.Hosting has ignored since 1.5.55. Use the journal builder's Add*EventAdapter<T> instead. Add a Native AOT canary that is a Hosting app, a CI gate with a warning baseline, and a plugin-author guide.
- Re-adding an event adapter name now works as it does in HOCON with the switch off: the later adapter type wins the name and the event types of every registration are bound to it. A test runs the scenario with the switch on and off, in one call and in separate calls. - WithInMemoryJournal and WithInMemorySnapshotStore no longer register in the setup. The built-in table resolves the in-memory types from the HOCON class, so a test that points that class elsewhere fails at start as before. - The persistence canary calls WithClusterShardingJournalMigrationAdapter and checks the adapter resolves with the switch off. Only the canary references Akka.Cluster.Sharding. - Rename the sharding-absent test to say what it checks. - Remove the abstract SqlJournalOptions and SqlSnapshotOptions. Only the Sql.Common based plugins used them, and Sql.Common is gone in 1.6.
…ation With Akka.DynamicTypeLoading off and a plugin registered, a HOCON `class` that names another type than the registered one now throws a ConfigurationException that names the setting, the HOCON type and the registered type, instead of quietly starting the registered type. This covers journals, snapshot stores and read journals. A missing class or a matching one starts the registration as before, and with the switch on HOCON still decides. The records keep the registered type's full name for a name-only comparison, so nothing is loaded. With the switch off, a hand-written event adapter binding is skipped only when a registration binds that type. Any other binding fails with the existing ConfigurationException that names it.
- Docs: the obsolete JournalOptions.Adapters property is removed, not ignored. The Native AOT page now says a HOCON class that names another type than the registration fails at start, and a hand-written binding of another type fails. - One path for event adapters: drop the eventAdapters parameter and property from JournalDetails, with the duplicate-name check that contradicted the registry's merge rule. PersistenceSetup.WithEventAdapters is the only way in. - Delete the test-only PersistenceSetup.WithJournal and WithSnapshotStore; tests use WithPlugin with the details records. - The journal builder calls the typed EventAdapterDetails.Create overloads and no longer wraps adapters itself. - Trim comments that restate the code, and the speculative registry remark. - Move LogWatchdogFilter to src/aot/Shared and link it from the core and persistence canaries. The Hosting canary has its own ILogger based watchdog.
861819e to
a00e13f
Compare
Aaronontheweb
left a comment
There was a problem hiding this comment.
LGTM, after many, many rounds of iteration
Summary
Persistence reads the type of every journal, snapshot store, read journal and event adapter from a HOCON string with reflection, so no persistence plugin can start under Native AOT. Akka.Persistence.Hosting now fills an internal persistence setup, and with
Akka.DynamicTypeLoadingoff persistence loads those types from it. A plugin names its types by changing the base class of its options; user code does not change.Part of #8753.
Changes
JournalOptions<TJournal>,JournalOptions<TJournal, TReadJournalProvider>(withprotected virtual string ReadJournalPluginId, defaultakka.persistence.query.journal.{Identifier}) andSnapshotOptions<TSnapshotStore>. Their type parameters carryDynamicallyAccessedMembers.AddEventAdapter<T>,AddReadEventAdapter<T>andAddWriteEventAdapter<T>keep their signatures and gain the same annotation onT.WithJournal,WithSnapshot,WithJournalAndSnapshot,WithInMemoryJournal,WithInMemorySnapshotStoreandWithClusterShardingJournalMigrationAdapteralso fill the setup. The HOCON they emit does not change.PersistenceSetup, records and registry. Per plugin id the later registration wins, and a journal's adapters add up across calls.classthat names another type than the registered one fails at start with a message that names both. A missing or matchingclassstarts the registration.ConfigurationExceptionthat names the setting and Akka.Persistence.Hosting. With it on, HOCON decides as before.src/aot/Akka.Persistence.AOT.App, a Hosting app that also checks the Cluster Sharding migration adapter, plus anAotCanaryCI step and warning baseline.Testing
PersistenceSetupHostingSpecsruns generic-options plugins with the switch off and on: journal, snapshot store, read journal, adapters (one added in a later call), several plugin ids, default and non-default. Options that derive from the old bases run unchanged on the JIT.HoconSnapshotSpecscompares the HOCON the builders emit, byte for byte, to a file generated fromdev.DeprecatedAdaptersPropertySpec, which tested the removed property.PublishAoton linux-x64: persist, recover from a snapshot, both queries, and a journal with no type in code fails at start.Breaking changes
JournalOptions.Adaptersis removed. It was[Obsolete]and ignored since Akka.Hosting 1.5.55, so setting it never did anything. Migration: add adapters with the journal builder,WithJournal(options, journal => journal.AddWriteEventAdapter<T>(name, boundTypes)).Abstract
Akka.Persistence.Hosting.SqlJournalOptionsandSqlSnapshotOptionsare removed. Only the Sql.Common-based plugins used them, and Sql.Common is gone in 1.6. Akka.Persistence.Sql is not affected (its options derive fromJournalOptions/SnapshotOptions).Everything else is new API or switch-off behavior.
Depends on #8707 decisions for: names, module precedence (re-check when #8707 resumes).