diff --git a/src/core/Akka.Persistence.Tests/ReceivePersistentActorAsyncAwaitSpec.cs b/src/core/Akka.Persistence.Tests/ReceivePersistentActorAsyncAwaitSpec.cs index d038ee16a1d..264b22e0054 100644 --- a/src/core/Akka.Persistence.Tests/ReceivePersistentActorAsyncAwaitSpec.cs +++ b/src/core/Akka.Persistence.Tests/ReceivePersistentActorAsyncAwaitSpec.cs @@ -376,6 +376,102 @@ public RestartMessage(object message) } } + /// + /// Test actor that uses DeferAsync with an async handler inside CommandAsync. + /// This reproduces the bug where DeferAsync throws "RunTask calls cannot be nested" + /// when called from within a CommandAsync handler after an await, with no pending persist operations. + /// See: https://github.com/akkadotnet/akka.net/issues/7998 + /// + public sealed class DeferAsyncFromCommandAsyncActor : ReceivePersistentActor + { + public override string PersistenceId { get; } + + public DeferAsyncFromCommandAsyncActor(string persistenceId) + { + PersistenceId = persistenceId; + + RecoverAny(_ => { }); + + // This pattern triggers the bug: CommandAsync -> await -> DeferAsync(async handler) + // with no prior Persist/PersistAsync calls + CommandAsync(async msg => + { + // Any await causes us to be in a RunTask context + await Task.Delay(10); + + // DeferAsync with async handler, no pending persist operations + // BUG: This throws "RunTask calls cannot be nested" because + // _pendingInvocations.Count == 0 triggers immediate RunTask execution + DeferAsync(msg, async evt => + { + await Task.CompletedTask; + Sender.Tell("deferred-" + evt); + }); + }); + } + } + + /// + /// Test actor that uses DeferAsync with a sync handler inside CommandAsync. + /// This should work correctly (sync handler doesn't use RunTask). + /// + public sealed class DeferAsyncSyncHandlerFromCommandAsyncActor : ReceivePersistentActor + { + public override string PersistenceId { get; } + + public DeferAsyncSyncHandlerFromCommandAsyncActor(string persistenceId) + { + PersistenceId = persistenceId; + + RecoverAny(_ => { }); + + CommandAsync(async msg => + { + await Task.Delay(10); + + // DeferAsync with sync handler - this should work + DeferAsync(msg, evt => + { + Sender.Tell("deferred-" + evt); + }); + }); + } + } + + /// + /// Test actor that uses DeferAsync with an async handler after PersistAsync. + /// This should work because _pendingInvocations.Count > 0. + /// + public sealed class DeferAsyncAfterPersistAsyncActor : ReceivePersistentActor + { + public override string PersistenceId { get; } + + public DeferAsyncAfterPersistAsyncActor(string persistenceId) + { + PersistenceId = persistenceId; + + RecoverAny(_ => { }); + + CommandAsync(async msg => + { + await Task.Delay(10); + + // PersistAsync first - this populates _pendingInvocations + PersistAsync(msg, evt => + { + Sender.Tell("persisted-" + evt); + }); + + // DeferAsync with async handler - should queue and work + DeferAsync(msg, async evt => + { + await Task.CompletedTask; + Sender.Tell("deferred-" + evt); + }); + }); + } + } + public class ReceivePersistentActorAsyncAwaitSpec : AkkaSpec { public ReceivePersistentActorAsyncAwaitSpec(ITestOutputHelper output = null) @@ -677,6 +773,52 @@ public Task Actor_receiveasync_overloads_should_work() "Expected 'handled' for double via CommandAsync(typeof(double))"); return Task.CompletedTask; } + + /// + /// Regression test for https://github.com/akkadotnet/akka.net/issues/7998 + /// DeferAsync with async handler should work when called from CommandAsync, + /// even without prior Persist/PersistAsync calls. + /// + [Fact(DisplayName = "DeferAsync with async handler should work from CommandAsync without prior persist calls")] + public async Task DeferAsync_with_async_handler_should_work_from_CommandAsync_without_prior_persist_calls() + { + var actor = Sys.ActorOf(Props.Create(() => new DeferAsyncFromCommandAsyncActor("defer-async-pid"))); + + actor.Tell("hello"); + var response = await ExpectMsgAsync(TimeSpan.FromSeconds(5)); + response.ShouldBe("deferred-hello"); + } + + /// + /// Verify that DeferAsync with sync handler still works from CommandAsync. + /// + [Fact(DisplayName = "DeferAsync with sync handler should work from CommandAsync")] + public async Task DeferAsync_with_sync_handler_should_work_from_CommandAsync() + { + var actor = Sys.ActorOf(Props.Create(() => new DeferAsyncSyncHandlerFromCommandAsyncActor("defer-sync-pid"))); + + actor.Tell("world"); + var response = await ExpectMsgAsync(TimeSpan.FromSeconds(5)); + response.ShouldBe("deferred-world"); + } + + /// + /// Verify that DeferAsync with async handler works after PersistAsync + /// (this path was already working since _pendingInvocations.Count > 0). + /// + [Fact(DisplayName = "DeferAsync with async handler should work after PersistAsync")] + public async Task DeferAsync_with_async_handler_should_work_after_PersistAsync() + { + var actor = Sys.ActorOf(Props.Create(() => new DeferAsyncAfterPersistAsyncActor("defer-after-persist-pid"))); + + actor.Tell("test"); + // Should receive both persist and defer responses in order + var first = await ExpectMsgAsync(TimeSpan.FromSeconds(5)); + first.ShouldBe("persisted-test"); + + var second = await ExpectMsgAsync(TimeSpan.FromSeconds(5)); + second.ShouldBe("deferred-test"); + } } } diff --git a/src/core/Akka.Persistence/Eventsourced.cs b/src/core/Akka.Persistence/Eventsourced.cs index fa8fcfa5084..6b5d4c30ad4 100644 --- a/src/core/Akka.Persistence/Eventsourced.cs +++ b/src/core/Akka.Persistence/Eventsourced.cs @@ -800,8 +800,8 @@ public void DeferAsync(TEvent evt, Action handler) /// /// This call will NOT result in being persisted. /// - /// If there are no pending persist handler calls, the will be called immediately - /// via . + /// The handler is always queued and will be executed after the current command handler completes. + /// This avoids "RunTask calls cannot be nested" errors when called from within CommandAsync handlers. /// /// If persistence of an earlier event fails, the persistent actor will stop, and the /// will not be run. @@ -816,15 +816,14 @@ public void DeferAsync(TEvent evt, Func handler) throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); } - if (_pendingInvocations.Count == 0) - { - RunTask(() => handler(evt)); - } - else - { - _pendingInvocations.AddLast(new AsyncAsyncHandlerInvocation(evt, o => handler((TEvent)o))); - _eventBatch.AddLast(new NonPersistentMessage(evt, Sender)); - } + // Always queue the async handler - do not use RunTask directly here. + // This avoids "RunTask calls cannot be nested" errors when DeferAsync + // is called from within a CommandAsync handler (which is already in a RunTask context). + // The handler will be executed via PeekApplyHandler when the batch is processed + // after the command handler completes. + // See: https://github.com/akkadotnet/akka.net/issues/7998 + _pendingInvocations.AddLast(new AsyncAsyncHandlerInvocation(evt, o => handler((TEvent)o))); + _eventBatch.AddLast(new NonPersistentMessage(evt, Sender)); } ///