Skip to content

Persistence.Hosting: start plugin types from code (Native AOT) - #8738

Merged
Aaronontheweb merged 4 commits into
devfrom
feature/persistence-hosting-aot-registration
Oct 6, 2026
Merged

Aaronontheweb merged 4 commits into
devfrom
feature/persistence-hosting-aot-registration

Conversation

@Aaronontheweb

@Aaronontheweb Aaronontheweb commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

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.DynamicTypeLoading off persistence loads those types from it. A plugin names its types by changing the base class of its options; user code does not change.

// plugin author: one line per options class
public sealed class SqlJournalOptions : JournalOptions<SqlWriteJournal, SqlReadJournalProvider> { /* as before */ }
public sealed class SqlSnapshotOptions : SnapshotOptions<SqlSnapshotStore> { /* as before */ }

// user: the same Hosting code as today
builder.WithJournalAndSnapshot(new SqlJournalOptions(...), new SqlSnapshotOptions(...),
    journal => journal.AddWriteEventAdapter<MyTagger>("tagger", new[] { typeof(MyEvent) }), null);

Part of #8753.

Changes

  • New public bases JournalOptions<TJournal>, JournalOptions<TJournal, TReadJournalProvider> (with protected virtual string ReadJournalPluginId, default akka.persistence.query.journal.{Identifier}) and SnapshotOptions<TSnapshotStore>. Their type parameters carry DynamicallyAccessedMembers.
  • AddEventAdapter<T>, AddReadEventAdapter<T> and AddWriteEventAdapter<T> keep their signatures and gain the same annotation on T.
  • WithJournal, WithSnapshot, WithJournalAndSnapshot, WithInMemoryJournal, WithInMemorySnapshotStore and WithClusterShardingJournalMigrationAdapter also fill the setup. The HOCON they emit does not change.
  • Internal PersistenceSetup, records and registry. Per plugin id the later registration wins, and a journal's adapters add up across calls.
  • With the switch off, a HOCON class that names another type than the registered one fails at start with a message that names both. A missing or matching class starts the registration.
  • With the switch off, a hand-written event adapter binding is skipped only when a registration binds that type; any other binding fails and names it.
  • Lookup with the switch off: setup, then built-in plugins, then a ConfigurationException that names the setting and Akka.Persistence.Hosting. With it on, HOCON decides as before.
  • Akka.Persistence.Hosting references Akka.Persistence.Query, which brings Akka.Streams transitively.
  • src/aot/Akka.Persistence.AOT.App, a Hosting app that also checks the Cluster Sharding migration adapter, plus an AotCanary CI step and warning baseline.
  • Plugin-author guide (custom-persistence-provider.md) and the Native AOT page.

Testing

  • PersistenceSetupHostingSpecs runs 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.
  • HoconSnapshotSpecs compares the HOCON the builders emit, byte for byte, to a file generated from dev.
  • Existing Hosting, TestKit and persistence suites pass. The only existing test removed is DeprecatedAdaptersPropertySpec, which tested the removed property.
  • The canary publishes with PublishAot on linux-x64: persist, recover from a snapshot, both queries, and a journal with no type in code fails at start.

Breaking changes

JournalOptions.Adapters is 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.SqlJournalOptions and SqlSnapshotOptions are 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 from JournalOptions / SnapshotOptions).

Everything else is new API or switch-off behavior.

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

@Aaronontheweb
Aaronontheweb added this pull request to stack #8740 October 3, 2026 02:40
@Aaronontheweb Aaronontheweb added akka-persistence AOT Ahead-of-Time (AOT) Compilation labels Oct 3, 2026
@Aaronontheweb Aaronontheweb added this to the 1.6.0 milestone Oct 3, 2026
@Aaronontheweb Aaronontheweb changed the title feature/persistence hosting aot registration Persistence: register plugins in code through Akka.Persistence.Hosting (AOT) Oct 3, 2026

@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

  • Core read every persistence plugin type from a HOCON class string with reflection, so no plugin started with Akka.DynamicTypeLoading off.
  • 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 ConfigurationException that 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

  • PluginActorFactory with For<TActor>.
  • JournalOptions.CreatePluginActorFactory() and SnapshotOptions.CreatePluginActorFactory(): protected virtual, default null.
  • WithReadJournal<TProvider> and WithStashOverflowStrategy on AkkaConfigurationBuilder.
  • Factory overloads of AddEventAdapter, AddReadEventAdapter and AddWriteEventAdapter.
  • [DynamicallyAccessedMembers] on TAdapter of the three existing generic overloads. This edits an existing signature.
  • New InternalsVisibleTo entries (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.
  • HoconSnapshotSpecs compares 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 class and HOCON adapters win over registrations. PersistenceSetupSpec pins this (Should_keep_the_hocon_class..., Should_build_an_adapter_from_hocon...).
  • Options that do not override the hook return null and 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 class and 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 class now 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] on AddEventAdapter<TAdapter> and its siblings can raise IL2091 for callers that pass an unannotated generic type.
  • A null boundTypes now throws ArgumentNullException instead of NullReferenceException.
  • 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's Activator.CreateInstance(type, system) also takes an ActorSystem parameter, 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).
  • PersistenceSetupSpec and the Hosting specs both test several plugin ids, later-wins and the switch-off error.
  • ReadJournalDetailsSpec has a setup-sharing test that PersistenceSetupSpec and the Hosting tests already cover.
  • Merge, PersistenceSetupExtensions.WithReadJournal and PersistencePluginDetails.Equals have no product caller, only tests.
  • JournalDetails.Create rejects 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 WithJournal overload, 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 ExtendedActorSystem constructor branch runs on the JIT only.
  • The read and read-write factory overloads of AddReadEventAdapter and AddEventAdapter have no Hosting-level test.
  • No test calls the builders after the actor system starts, so the AddSetup no-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.
  • WithStashOverflowStrategy does not null-check builder.

/// results as calls come in.
/// </para>
/// </summary>
internal sealed class PersistenceSetup : 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 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.

Comment thread src/core/Akka.Persistence/PersistenceSetup.cs Outdated
/// 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)

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

Comment thread src/core/Akka.Persistence/Persistence.cs Outdated
// 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)))

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 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(),

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

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

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

Comment thread docs/articles/deployment/native-aot.md Outdated
Comment thread docs/articles/persistence/custom-persistence-provider.md Outdated

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

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.

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

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.

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.

Comment thread src/contrib/hosting/Akka.Persistence.Hosting/TypedPluginOptions.cs
/// 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>()

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.

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.

Comment thread src/contrib/hosting/Akka.Persistence.Hosting/JournalOptions.cs
Comment thread src/core/Akka.Persistence/PersistencePluginRegistry.cs Outdated
else if (BuiltInPersistencePlugins.TryCreatePluginProps(pluginTypeName, pluginConfig, out var builtInProps))
pluginProps = builtInProps;
else
throw new ConfigurationException(AkkaFeatures.NotBuiltIn(

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.

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

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.

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)

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.

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.
@Aaronontheweb
Aaronontheweb force-pushed the feature/persistence-hosting-aot-registration branch from 861819e to a00e13f Compare October 6, 2026 14:47

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

LGTM, after many, many rounds of iteration

@Aaronontheweb
Aaronontheweb merged commit 4e9606d into dev Oct 6, 2026
17 checks passed
@Aaronontheweb
Aaronontheweb deleted the feature/persistence-hosting-aot-registration branch October 6, 2026 16:06
@Aaronontheweb Aaronontheweb added the akka.net v1.6 Akka.NET v1.6-related issues label Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

akka.net v1.6 Akka.NET v1.6-related issues akka-persistence AOT Ahead-of-Time (AOT) Compilation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant