Repository navigation
Persistence: register plugins in code through Akka.Persistence.Hosting (AOT) - #8730
Aaronontheweb wants to merge 9 commits into
Conversation
Add PersistencePluginSetup and PersistenceQuerySetup so a Native AOT or trimmed app can start journals, snapshot stores, event adapters, bindings, stash-overflow configurators and read journals without Type.GetType. Every lookup site now runs Setup, then built-in, then a guard on AkkaFeatures.IsDynamicTypeLoadingSupported, then reflection moved into [RequiresUnreferencedCode] methods. With the switch on and nothing registered, the same objects get built. With it off, an unregistered type throws a ConfigurationException that names the HOCON setting.
src/aot/Akka.Persistence.AOT.App runs the in-memory journal, snapshot store and read journal with Akka.DynamicTypeLoading off and everything registered through PersistencePluginSetup and PersistenceQuerySetup. It persists, recovers from a snapshot, runs CurrentEventsByPersistenceId and CurrentEventsByTag, and checks that an unregistered journal fails at start. The AotCanary CI job publishes it, runs it and compares its warnings with a baseline. StreamsRoots.xml roots the Akka.Streams boundary types the in-memory read journal's queries build by reflection.
Add a "Persistence plugins" section to the Native AOT page and a "Registering your plugin for Native AOT" section to the custom persistence provider guide.
The public EventAdapters.Create overload passed "event-adapters" as the plugin path, so a failure read event-adapters.event-adapters.<name>. It now passes an empty path, and the setting names read event-adapters.<name> and event-adapter-bindings. Add a test that asserts the exact messages.
Two new headings in the Native AOT and custom persistence docs failed the titlecase lint rule. The word "configurators" failed cspell, so the sentence now says "stash overflow strategies".
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
- Adds two public setups:
PersistencePluginSetupandPersistenceQuerySetup. - Uses one lookup order at every plugin site (journal / snapshot store
class, stash overflow, event adapters and bindings, read journals):- a registration in the setup
- a built-in plugin
- if
Akka.DynamicTypeLoadingis off: aConfigurationExceptionnaming the setting - otherwise: reflection, as before
- Adds a persistence AOT canary (
PublishAot) and wires it into CI.
Behavior changes to notice
- The supervisor-strategy setting is now resolved before the plugin type, so it wins when both are invalid.
- Built-in plugins are built through a factory even with the switch on (same objects, different
Props.Arguments). - Errors from the public
EventAdapters.Create(system, config)now readevent-adapters.<name>. - With the switch off, an app must include a read journal's
DefaultConfiguration()itself.
Depends on other work
- Names, precedence and
InjectTopLevelFallbackfollow the #8707 decisions; re-check when #8707 resumes. StreamsRoots.xmlin the canary goes away once #8732 (fixes #8731) merges.
Not covered (as of this review)
- Cluster.Sharding / remember-entities and Hosting with persistence were not run locally (CI runs them).
- A built-in plugin name with a foreign assembly name.
Fixed since this review (f0c0f08): registered journal, snapshot store and stash configurator under AOT; switch-on tests for registered snapshot stores, adapters, bindings and stash configurators; an unregistered snapshot store; PersistencePluginProxy now runs; the duplicate event-adapter error test is merged.
| /// Like any <see cref="Setup"/>, a second instance passed to <see cref="ActorSystemSetup.And{T}"/> | ||
| /// replaces the first; use <see cref="Merge"/> to combine registrations. | ||
| /// </summary> | ||
| public sealed class PersistencePluginSetup : Setup |
There was a problem hiding this comment.
We add this public Setup because a trimmed or AOT app has no way to name a journal, snapshot store, adapter or stash configurator except through HOCON plus reflection. HOCON stays the source of truth: a registration only applies when its type is the one a setting names. It is immutable, and a second instance passed to .And replaces the first, so users combine with Merge.
| /// <param name="factory">Creates the adapter. Called once per journal section that names the adapter.</param> | ||
| /// <returns>A new setup that also holds this registration.</returns> | ||
| /// <exception cref="ArgumentException"><typeparamref name="TAdapter"/> implements none of IEventAdapter, IWriteEventAdapter, IReadEventAdapter.</exception> | ||
| public PersistencePluginSetup WithEventAdapter<TAdapter>(Func<ExtendedActorSystem, TAdapter> factory) where TAdapter : class |
There was a problem hiding this comment.
We wrap write-only and read-only adapters in the Noop adapters here, with the same precedence as the reflection path in EventAdapters, so both paths build the same object. A type with no adapter interface throws ArgumentException when you register it, not when the system starts.
| /// lookup sites. Answers the Setup arm only: each site writes the built-in, guard and reflection arms | ||
| /// out itself, see <see cref="AkkaFeatures"/>. | ||
| /// </summary> | ||
| internal sealed class PersistencePluginRegistry |
There was a problem hiding this comment.
We read every code registration through this one class because the four lookup sites then share one place, and a future module source (#8707) can plug in here without touching them. It answers the Setup arm only. Each site still writes the built-in, guard and reflection arms itself, as AkkaFeatures asks.
| } | ||
| } | ||
|
|
||
| // a registered adapter type is also a legal binding key |
There was a problem hiding this comment.
We also accept a registered adapter type as a binding key. Spec section 12.4 asks for it, and the event-adapters test relies on it (it binds ReadOnlyAdapter itself). Real apps bind event types, so this arm is rarely hit.
| /// | ||
| /// Builds a plugin actor from a factory, so Props never needs reflection to find a constructor. | ||
| /// </summary> | ||
| internal sealed class PluginActorProducer<[DynamicallyAccessedMembers(Props.ActorTypeMembers)] TActor> : IIndirectActorProducer |
There was a problem hiding this comment.
We build plugin actors through a factory producer because Props(Type, args) finds a constructor by reflection, which a trimmer can remove. The DynamicallyAccessedMembers on TActor and ActorType keep what Props needs.
| <!-- Roots the Akka.Streams boundary types the in-memory read journal's queries instantiate by | ||
| reflection. See StreamsRoots.xml. --> | ||
| <ItemGroup> | ||
| <TrimmerRootDescriptor Include="StreamsRoots.xml" /> |
There was a problem hiding this comment.
We root the Akka.Streams boundary types for this app only, because the in-memory read journal's queries build them with MakeGenericType and Activator, and the trimmer removes the constructors. Without it, every query dies with MissingMethodException. This is a workaround for the canary, not for users: an AOT app that runs persistence queries has no such root. Issue #8731 changes Akka.Streams so the root file is not needed.
| Until Akka.Streams marks these constructors for the trimmer, this app roots them itself. | ||
| Reference-type elements only (EventEnvelope, string) - they share one compiled instantiation. | ||
| --> | ||
| <assembly fullname="Akka.Streams"> |
There was a problem hiding this comment.
We preserve these nine closed-generic boundary types by hand because ILC cannot see the reflection that creates them. Delete this file when #8731 lands, and add src/core/Akka.Streams/ to the baseline scope then.
| --repo-root "$(Build.SourcesDirectory)" | ||
| displayName: 'Compare Hosting AOT warnings against the baseline' | ||
|
|
||
| # --- Persistence canary (src/aot/Akka.Persistence.AOT.App): same job, runs after the Hosting canary --- |
There was a problem hiding this comment.
We add the persistence canary to the existing AotCanary job, after the Hosting one: clean, one publish (log is reused for the warning check), run with timeout 300 and a grep for [canary-persistence] OK, then the baseline check. Exit 0 alone is not trusted. The job's *.log artifact step already picks up the two new logs. Cost: a second ILC publish in the same job.
| echo "Persistence AOT canary: exit code 0 and '[canary-persistence] OK' on stdout." | ||
| displayName: 'Run the Persistence AOT canary' | ||
|
|
||
| # Fails on any IL2xxx/IL3xxx warning from the persistence core, PersistenceQuery or the in-memory |
There was a problem hiding this comment.
We scope the warning check to Persistence, Persistence.Query and the in-memory read journal, and leave out src/core/Akka/ (the core canary gates it) and Akka.Streams (see baseline). The baseline holds two PersistentFSM serializer warnings, so any new warning from persistence code fails CI.
The persistence canary now registers a journal, a snapshot store and a stash overflow configurator that Akka.Persistence does not ship and runs them with Akka.DynamicTypeLoading off, next to the built-in inmem plugins. The comment that called two sends a burst now says what the actor does. Tests: add the unregistered snapshot store guard, switch-on tests for a registered snapshot store, event adapter, binding and stash configurator, and a run of PersistencePluginProxy with the switch off. Fold the exact-message event adapter test into the existing one, keep Merge in its own test, and make the read journal switch-on test assert the default config injection. Docs: show how to add a read journal's default HOCON with the switch off, and note in the baseline that Streams joins the gate once akkadotnet#8732 merges.
Replace PersistencePluginSetup and PersistenceQuerySetup with one PersistenceSetup that holds records keyed by plugin id, the way SerializationSetup holds serializers. A registered plugin needs no HOCON class, and its default config sits under the plugin's section, so switch-off apps no longer add a read journal's DefaultConfiguration by hand. - PersistenceSetup: Create(), Create(factory), WithJournal, WithSnapshotStore, WithPlugin, WithPlugins, WithStashOverflowStrategy, Merge. - JournalDetails, SnapshotStoreDetails and EventAdapterDetails (an adapter with its bound event types); ReadJournalDetails and a WithReadJournal extension in Akka.Persistence.Query. - Akka.Persistence.Hosting: WithPersistenceSetup builds on the setup earlier calls left. - One internal registry per actor system serves persistence and the query extension. Type-name matching is gone. - The canary, tests and docs use the new API.
PersistenceSetup and its records are internal now. The Hosting builders build them, so a Hosting app runs under Native AOT with Akka.DynamicTypeLoading off and writes the same code it writes today. - The journal builder's AddEventAdapter, AddReadEventAdapter and AddWriteEventAdapter keep their signatures, keep emitting their HOCON, and also register the adapter in code. New overloads take a factory. - JournalOptions and SnapshotOptions get a virtual CreatePluginActorFactory that returns null by default. A plugin returns PluginActorFactory.For(...) to start without a HOCON class. - New builders: WithReadJournal, WithInMemoryReadJournal and WithStashOverflowStrategy. - Every call appends to one PersistenceSetup on the builder. Core merges it: per plugin id the later registration wins, and a journal's adapters add up across calls in any order. - With the switch on, plugins and adapters resolve from HOCON as before and built-ins take no new path; registrations fill in only where HOCON says nothing. Only the switch-off path uses them first. - The canary is a Hosting app. A snapshot test pins the HOCON the builders emit to what they emitted before this change.
Akka.Persistence.Hosting references Akka.Persistence.Query only. The in-memory read journal is for tests, so it gets no AOT helper in Hosting. WithInMemoryReadJournal is removed (it never shipped). The canary registers the in-memory read journal with the generic WithReadJournal, and the canary project references Akka.Persistence.Query.InMemory itself.
Summary
Core resolves every persistence plugin type from a HOCON string with reflection, so no persistence plugin can start under Native AOT. The Akka.Persistence.Hosting builders now also hand core those plugins, event adapters and read journals as code, through an internal
PersistenceSetup. Hosting users write the code they write today and it runs withAkka.DynamicTypeLoadingoff. Plugin authors add one override to their existing options class.Changes
PersistenceSetup: an ordered list of plugin, event adapter and read journal registrations. Core merges it per plugin id: the later registration wins, and a journal's adapters add up across registrations.Akka.DynamicTypeLoading, then reflection. With the switch on, HOCON decides as before and registrations only fill gaps.AddEventAdapter/AddReadEventAdapter/AddWriteEventAdapteralso register the adapter and gain factory overloads.JournalOptionsandSnapshotOptionsget a virtualCreatePluginActorFactory(defaultnull). NewPluginActorFactory,WithReadJournal,WithStashOverflowStrategy. The HOCON the builders emit does not change. Akka.Persistence.Hosting now references Akka.Persistence.Query, and nothing else new. The in-memory read journal is for tests and has no Hosting helper.src/aot/Akka.Persistence.AOT.App, a Hosting app, plus anAotCanaryCI step and warning baseline.Examples
src/aot/Akka.Persistence.AOT.App/Program.csandCanaryTypes.csdocs/articles/deployment/native-aot.md, section Persistence Pluginsdocs/articles/persistence/custom-persistence-provider.md, section Registering Your Plugin for Native AOTTesting
PersistenceSetupSpec,ReadJournalDetailsSpec), and end to end through successive Hosting calls with the switch off and on (PersistenceSetupHostingSpecs). Existing Hosting, TestKit, persistence and TCK suites pass unchanged, andHoconSnapshotSpecspins the HOCON the builders emit to what they emitted before.PublishAoton linux-x64 and persists, recovers from a snapshot, runsCurrentEventsByPersistenceIdandCurrentEventsByTag, and checks that a journal with no factory fails at start.Breaking changes
None: new API. With
Akka.DynamicTypeLoadingoff, an unregistered journal, snapshot store, read journal, event adapter, binding or stash overflow configurator now fails at start with aConfigurationExceptionthat names the setting and points at Akka.Persistence.Hosting.Depends on #8707 decisions for: names, module precedence (re-check when #8707 resumes).