Skip to content

Persistence: register plugins in code through Akka.Persistence.Hosting (AOT) - #8730

Closed
Aaronontheweb wants to merge 9 commits into
akkadotnet:devfrom
Aaronontheweb:feature/persistence-plugin-registration
Closed

Aaronontheweb wants to merge 9 commits into
akkadotnet:devfrom
Aaronontheweb:feature/persistence-plugin-registration

Conversation

@Aaronontheweb

@Aaronontheweb Aaronontheweb commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

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 with Akka.DynamicTypeLoading off. Plugin authors add one override to their existing options class.

// user: unchanged Hosting code, now AOT-safe
services.AddAkka("app", builder => builder
    .WithJournalAndSnapshot(new MyJournalOptions(), new MySnapshotOptions(),
        configureJournal: j => j.AddWriteEventAdapter<MyTagger>("tagger", new[] { typeof(MyEvent) }),
        configureSnapshot: null)
    .WithReadJournal("akka.persistence.query.journal.my-journal", (sys, cfg) => new MyReadJournalProvider(sys, cfg)));

// plugin author: one override on the options class they already have
protected override PluginActorFactory? CreatePluginActorFactory()
    => PluginActorFactory.For(config => new MyJournal(config));

Changes

  • Internal 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.
  • Lookup at every site: registration, then built-in plugins, then a guard on Akka.DynamicTypeLoading, then reflection. With the switch on, HOCON decides as before and registrations only fill gaps.
  • Akka.Persistence.Hosting: AddEventAdapter/AddReadEventAdapter/AddWriteEventAdapter also register the adapter and gain factory overloads. JournalOptions and SnapshotOptions get a virtual CreatePluginActorFactory (default null). New PluginActorFactory, 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 an AotCanary CI step and warning baseline.
  • Docs sections in the Native AOT page and the custom persistence provider guide.

Examples

  • src/aot/Akka.Persistence.AOT.App/Program.cs and CanaryTypes.cs
  • docs/articles/deployment/native-aot.md, section Persistence Plugins
  • docs/articles/persistence/custom-persistence-provider.md, section Registering Your Plugin for Native AOT

Testing

  • Lookup arms and merge rules at the core level (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, and HoconSnapshotSpecs pins the HOCON the builders emit to what they emitted before.
  • The canary publishes with PublishAot on linux-x64 and persists, recovers from a snapshot, runs CurrentEventsByPersistenceId and CurrentEventsByTag, and checks that a journal with no factory fails at start.

Breaking changes

None: new API. With Akka.DynamicTypeLoading off, an unregistered journal, snapshot store, read journal, event adapter, binding or stash overflow configurator now fails at start with a ConfigurationException that names the setting and points at Akka.Persistence.Hosting.

Depends on #8707 decisions for: names, module precedence (re-check when #8707 resumes).

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".

@Aaronontheweb Aaronontheweb left a comment •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review: what each change and test is for. Inline comments have the detail.

What this PR does

  • Adds two public setups: PersistencePluginSetup and PersistenceQuerySetup.
  • Uses one lookup order at every plugin site (journal / snapshot store class, stash overflow, event adapters and bindings, read journals):
    1. a registration in the setup
    2. a built-in plugin
    3. if Akka.DynamicTypeLoading is off: a ConfigurationException naming the setting
    4. 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 read event-adapters.<name>.
  • With the switch off, an app must include a read journal's DefaultConfiguration() itself.

Depends on other work

  • Names, precedence and InjectTopLevelFallback follow the #8707 decisions; re-check when #8707 resumes.
  • StreamsRoots.xml in 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" />

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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">

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/aot/Akka.Persistence.AOT.App/aot-warnings.baseline.txt Outdated
--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 ---

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread build-system/pr-validation.yaml Outdated
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@Aaronontheweb Aaronontheweb changed the title Persistence: register plugins, event adapters and read journals in code (AOT) Persistence: register plugins in code with PersistenceSetup (AOT) Oct 2, 2026
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.
@Aaronontheweb Aaronontheweb changed the title Persistence: register plugins in code with PersistenceSetup (AOT) Persistence: register plugins in code through Akka.Persistence.Hosting (AOT) Oct 3, 2026
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.
@Aaronontheweb

Copy link
Copy Markdown
Member Author

Superseded by #8738: the branch moved to akkadotnet so the two PRs can be a GitHub stack (#8738 → #8739), with each diff holding only its own changes. Review threads here are covered by the fixes already in that branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

akka-persistence AOT Ahead-of-Time (AOT) Compilation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant