package actor import ( "context" "errors" "fmt" "sync/atomic" "testing" "time" "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" ) // TestActorSystemNewActorSystem verifies the basic initialization of an // ActorSystem, including its default DLO. func TestActorSystemNewActorSystem(t *testing.T) { t.Parallel() as := NewActorSystem() require.NotNil(t, as, "newActorSystem should not return nil") require.NotNil(t, as.Receptionist(), "receptionist should not be nil") require.NotNil(t, as.DeadLetters(), "deadLetters should not be nil") require.Equal(t, "dead-letters", as.DeadLetters().ID(), "dLO ID mismatch") // Test the DLO's behavior (it should return an error for Ask). testDLOMsg := newTestMsg("to-dlo") future := as.DeadLetters().Ask(context.Background(), testDLOMsg) result := future.Await(context.Background()) // We should get back an error for asks. require.True( t, result.IsErr(), "system DLO should return an error on Ask", ) expectedErrStr := "message undeliverable: " + testDLOMsg.MessageType() require.EqualError( t, result.Err(), expectedErrStr, "dLO error message mismatch", ) // Shutdown the system to clean up resources. err := as.Shutdown() require.NoError(t, err, "actorSystem shutdown failed") } // TestActorSystemRegisterWithSystem verifies actor registration, lifecycle // management within the system. func TestActorSystemRegisterWithSystem(t *testing.T) { t.Parallel() as := NewActorSystem() defer func() { err := as.Shutdown() require.NoError(t, err) }() actorID := "test-actor-sys-reg" serviceKey := NewServiceKey[*testMsg, string]("test-service") // Using echoBehavior from actor_test.go (implicitly available) beh := newEchoBehavior(t, 0) // We'll start off by registering the actor. actorRef, err := RegisterWithSystem(as, actorID, serviceKey, beh) require.NoError(t, err) require.NotNil(t, actorRef, "registerWithSystem should return a valid ActorRef") require.Equal(t, actorID, actorRef.ID(), "registered actor ID mismatch") // The actor should be found in the receptionist. foundActors := FindInReceptionist(as.Receptionist(), serviceKey) require.Len(t, foundActors, 1, "actor not found in receptionist") require.Equal(t, actorRef, foundActors[0], "incorrect actor in receptionist") // Next, we'll send out a simple tell, using our reply channel to make // sure it's actually processed. msgData := "hello-system-actor" replyChan := make(chan string, 1) actorRef.Tell(context.Background(), newTestMsgWithReply(msgData, replyChan)) received, err := fn.RecvOrTimeout(replyChan, 100*time.Millisecond) require.NoError(t, err, "timed out waiting for actor to process message") require.Equal(t, msgData, received, "actor did not process message") // Stop the actor through the system. stopped := as.StopAndRemoveActor(actorID) require.True(t, stopped, "StopAndRemoveActor failed") // Wait for actor to fully stop. time.Sleep(50 * time.Millisecond) // Send a message to the now-stopped actor's ref. This should go to the // system's DLO. afterStopMsg := newTestMsg("after-stop-to-dlo") require.NotPanics(t, func() { actorRef.Tell(context.Background(), afterStopMsg) }, "tell to stopped actor should not panic") } // TestActorSystemShutdown verifies that all actors are stopped and the system // context is cancelled upon shutdown. func TestActorSystemShutdown(t *testing.T) { t.Parallel() as := NewActorSystem() // We'll start by making 3 new actors, each with a unique ID. numActors := 3 actorRefs := make([]ActorRef[*testMsg, string], numActors) for i := 0; i < numActors; i++ { actorID := fmt.Sprintf("shutdown-test-actor-%d", i) key := NewServiceKey[*testMsg, string]( fmt.Sprintf("service-%d", i), ) beh := newEchoBehavior(t, 0) ref, regErr := RegisterWithSystem(as, actorID, key, beh) require.NoError(t, regErr) actorRefs[i] = ref } // We'll now send a message to each actor to ensure that they're // running. for i, ref := range actorRefs { future := ref.Ask( context.Background(), newTestMsg(fmt.Sprintf("ping-%d", i)), ) ctxAwait, cancelAwait := context.WithTimeout( context.Background(), time.Second, ) res := future.Await(ctxAwait) cancelAwait() require.False( t, res.IsErr(), "actor %d failed to respond before shutdown: %v", i, res.Err(), ) } // Next, trigger a shutdown, and assert that the done channel gets // closed. err := as.Shutdown() require.NoError(t, err, "actorSystem shutdown failed") // Check if the system context is done using RecvOrTimeout with a zero // timeout for a non-blocking check. _, err = fn.RecvOrTimeout(as.ctx.Done(), time.Millisecond*100) require.NoError(t, err, "actorSystem context not cancelled after shutdown") // We'll now try to send a message to each of the actors, this should // result in an error. for i, ref := range actorRefs { future := ref.Ask( context.Background(), newTestMsg(fmt.Sprintf("ping-after-shutdown-%d", i)), ) res := future.Await(context.Background()) require.True( t, res.IsErr(), "actor %d Ask should fail after shutdown", i, ) require.ErrorIs(t, res.Err(), ErrActorTerminated) } as.mu.RLock() require.Nil(t, as.actors, "actors map should be nil after shutdown") as.mu.RUnlock() // Once shutdown, we shouldn't be able to send to the DLO either. dloRef := as.DeadLetters() futureDLO := dloRef.Ask( context.Background(), newTestMsg("ping-dlo-after-shutdown"), ) resDLO := futureDLO.Await(context.Background()) require.True( t, resDLO.IsErr(), "DLO Ask should fail after system shutdown", ) require.ErrorIs( t, resDLO.Err(), ErrActorTerminated, ) } // TestActorSystemStopAndRemoveActor verifies specific actor stopping and // removal. func TestActorSystemStopAndRemoveActor(t *testing.T) { t.Parallel() as := NewActorSystem() defer func() { err := as.Shutdown() require.NoError(t, err) }() // Make some actor IDs, then unique service keys, then use that to // register two actors. actor1ID := "actor-to-stop" actor2ID := "actor-to-keep" key1 := NewServiceKey[*testMsg, string]("service1") key2 := NewServiceKey[*testMsg, string]("service2") beh := newEchoBehavior(t, 0) ref1, err := RegisterWithSystem(as, actor1ID, key1, beh) require.NoError(t, err) ref2, err := RegisterWithSystem(as, actor2ID, key2, beh) require.NoError(t, err) // If we remove one actor, then try to send to it, we should get an // error. stopped := as.StopAndRemoveActor(actor1ID) require.True(t, stopped, "failed to stop and remove actor1") future1 := ref1.Ask(context.Background(), newTestMsg("ping-actor1")) res1 := future1.Await(context.Background()) require.True(t, res1.IsErr(), "actor1 should be stopped") require.ErrorIs(t, res1.Err(), ErrActorTerminated) as.mu.RLock() _, exists := as.actors[actor1ID] as.mu.RUnlock() // The actor should no longer be found. require.False(t, exists, "actor1 still in system's actor map") // Make sure that we can still send messages to the existing actor. future2 := ref2.Ask( context.Background(), newTestMsg("ping-actor2"), ) ctxAwait2, cancelAwait2 := context.WithTimeout( context.Background(), time.Second, ) res2 := future2.Await(ctxAwait2) cancelAwait2() require.False( t, res2.IsErr(), "actor2 should still be running: %v", res2.Err(), ) res2.WhenOk(func(s string) { require.Equal(t, "echo: ping-actor2", s) }) stoppedNonExistent := as.StopAndRemoveActor("non-existent-actor") require.False( t, stoppedNonExistent, "stopping non-existent actor should "+ "return false", ) } // TestReceptionist covers basic registration, finding, and unregistration. func TestReceptionist(t *testing.T) { t.Parallel() as := NewActorSystem() defer func() { err := as.Shutdown() require.NoError(t, err) }() receptionist := as.Receptionist() key1 := NewServiceKey[*testMsg, string]("key1") key2 := NewServiceKey[*testMsg, string]("key2") key1Again := NewServiceKey[*testMsg, string]("key1") // Register 3 actor instance using the service keys we created above. beh := newEchoBehavior(t, 0) actor1Ref, err := RegisterWithSystem(as, "actor1-rec", key1, beh) require.NoError(t, err) actor2Ref, err := RegisterWithSystem(as, "actor2-rec", key1, beh) require.NoError(t, err) actor3Ref, err := RegisterWithSystem(as, "actor3-rec", key2, beh) require.NoError(t, err) // We should be able to find the actors we registered. foundForKey1 := FindInReceptionist(receptionist, key1) require.Len(t, foundForKey1, 2, "should find 2 actors for key1") require.Contains(t, foundForKey1, actor1Ref) require.Contains(t, foundForKey1, actor2Ref) foundForKey1Again := FindInReceptionist(receptionist, key1Again) require.ElementsMatch(t, foundForKey1, foundForKey1Again) // Same goes for the second key we added. foundForKey2 := FindInReceptionist(receptionist, key2) require.Len(t, foundForKey2, 1, "should find 1 actor for key2") require.Equal(t, actor3Ref, foundForKey2[0]) // We shouldn't be able to find a key we didn't add. nonExistentKey := NewServiceKey[*testMsg, string]("non-existent") foundForNonExistent := FindInReceptionist(receptionist, nonExistentKey) require.Empty(t, foundForNonExistent) // We should be able to unregister the actors we added. unregistered := UnregisterFromReceptionist( receptionist, key1, actor1Ref, ) require.True(t, unregistered, "failed to unregister actor1Ref") foundForKey1AfterUnreg := FindInReceptionist(receptionist, key1) require.Len(t, foundForKey1AfterUnreg, 1) require.Equal(t, actor2Ref, foundForKey1AfterUnreg[0]) // If we try to unregister the same actor again, it should fail. unregisteredAgain := UnregisterFromReceptionist(receptionist, key1, actor1Ref) require.False(t, unregisteredAgain) unregisteredLast := UnregisterFromReceptionist(receptionist, key1, actor2Ref) require.True(t, unregisteredLast) foundForKey1AfterAllUnreg := FindInReceptionist(receptionist, key1) require.Empty(t, foundForKey1AfterAllUnreg) receptionist.mu.RLock() _, exists := receptionist.registrations[key1.name] receptionist.mu.RUnlock() require.False(t, exists, "key1 should be removed from registrations map") // Finally, if we use the wrong key, or one that doesn't exist, that // should also fail. unregisteredWrongKey := UnregisterFromReceptionist(receptionist, key1, actor3Ref) require.False(t, unregisteredWrongKey) unregisteredNonExistentKey := UnregisterFromReceptionist(receptionist, nonExistentKey, actor1Ref) require.False(t, unregisteredNonExistentKey) } // TestServiceKeyMethods tests Spawn and Unregister methods on ServiceKey. func TestServiceKeyMethods(t *testing.T) { t.Parallel() as := NewActorSystem() defer func() { err := as.Shutdown() require.NoError(t, err) }() key := NewServiceKey[*testMsg, string]("sk-service") beh := newEchoBehavior(t, 0) // Attempt to spawn a new actor using the service key and desired // behavior. actorRef, err := key.Spawn(as, "actor-sk-spawn", beh) require.NoError(t, err) require.NotNil(t, actorRef) require.Equal(t, "actor-sk-spawn", actorRef.ID()) // We should be able to find the actor in the receptionist. found := FindInReceptionist(as.Receptionist(), key) require.Len(t, found, 1) require.Equal(t, actorRef, found[0]) as.mu.RLock() _, sysExists := as.actors[actorRef.ID()] as.mu.RUnlock() require.True(t, sysExists) // Next, try to unregister the actor using the service key. success := key.Unregister(as, actorRef) require.True(t, success, "serviceKey.Unregister failed") // The actor should no longer be found in the receptionist. foundAfter := FindInReceptionist(as.Receptionist(), key) require.Empty(t, foundAfter) as.mu.RLock() _, sysExistsAfter := as.actors[actorRef.ID()] as.mu.RUnlock() require.False(t, sysExistsAfter) // If we try to send a message to the actor after unregistering it, then // we should get an error. future := actorRef.Ask(context.Background(), newTestMsg("ping")) res := future.Await(context.Background()) require.True(t, res.IsErr() && errors.Is(res.Err(), ErrActorTerminated)) successAgain := key.Unregister(as, actorRef) require.False(t, successAgain) otherSys := NewActorSystem() // Create a different actor system defer func() { err := otherSys.Shutdown() require.NoError(t, err) }() // Create a dummy actor in otherSys of the correct generic type for the // key. This actor won't be found in 'as', so Unregister should fail. dummyBehOther := newEchoBehavior(t, 0) dummyKeyOther := NewServiceKey[*testMsg, string]("dummy-other") dummyActorRefOtherSys, err := RegisterWithSystem( otherSys, "dummy-other-actor", dummyKeyOther, dummyBehOther, ) require.NoError(t, err) successNonMember := key.Unregister(as, dummyActorRefOtherSys) require.False(t, successNonMember) } // TestServiceKeyUnregisterAll tests the UnregisterAll method on ServiceKey. // It covers scenarios including basic unregistration of multiple actors, // attempting to unregister with no actors present, unregistering actors for // one key while leaving others intact, and the idempotency of the operation. func TestServiceKeyUnregisterAll(t *testing.T) { t.Parallel() // Common setup for all sub-tests. as := NewActorSystem() defer func() { err := as.Shutdown() require.NoError(t, err, "ActorSystem shutdown failed.") }() // Common behavior for test actors used across sub-tests. beh := newEchoBehavior(t, 0) t.Run("unregister all multiple actors", func(st *testing.T) { key1 := NewServiceKey[*testMsg, string]("sk-ua-key1") actor1Key1, err := key1.Spawn(as, "actor1-k1-ua", beh) require.NoError(st, err) actor2Key1, err := key1.Spawn(as, "actor2-k1-ua", beh) require.NoError(st, err) // Verify they are registered in the receptionist. foundActorsForKey1 := FindInReceptionist( as.Receptionist(), key1, ) require.Len( st, foundActorsForKey1, 2, "actors for key1 not in receptionist initially.", ) // Verify they are in the system's actor map. as.mu.RLock() _, actor1Key1Exists := as.actors[actor1Key1.ID()] _, actor2Key1Exists := as.actors[actor2Key1.ID()] as.mu.RUnlock() require.True( st, actor1Key1Exists, "actor1 for key1 not in system actors map initially.", ) require.True( st, actor2Key1Exists, "actor2 for key1 not in system actors map initially.", ) // Unregister all for key1. stoppedCountKey1 := key1.UnregisterAll(as) require.Equal( st, 2, stoppedCountKey1, "UnregisterAll for key1 returned incorrect count.", ) // Verify they are unregistered from the receptionist. foundActorsForKey1After := FindInReceptionist( as.Receptionist(), key1, ) require.Empty( st, foundActorsForKey1After, "actors for key1 still in receptionist after "+ "UnregisterAll.", ) // Verify they are removed from system actors map. as.mu.RLock() _, actor1Key1ExistsAfter := as.actors[actor1Key1.ID()] _, actor2Key1ExistsAfter := as.actors[actor2Key1.ID()] as.mu.RUnlock() require.False( st, actor1Key1ExistsAfter, "Actor1 for key1 still in system actors "+ "map after UnregisterAll.", ) require.False( st, actor2Key1ExistsAfter, "Actor2 for key1 still in system actors "+ "map after UnregisterAll.", ) // Verify actors are stopped. resultActor1Key1 := actor1Key1.Ask( context.Background(), newTestMsg("ping-k1-a1"), ).Await(context.Background()) require.True( st, resultActor1Key1.IsErr(), "Actor1 key1 Ask should fail after UnregisterAll.", ) require.ErrorIs( st, resultActor1Key1.Err(), ErrActorTerminated, "Actor1 key1 not terminated with correct error.", ) resultActor2Key1 := actor2Key1.Ask( context.Background(), newTestMsg("ping-k1-a2"), ).Await(context.Background()) require.True( st, resultActor2Key1.IsErr(), "Actor2 key1 Ask should fail after UnregisterAll.", ) require.ErrorIs( st, resultActor2Key1.Err(), ErrActorTerminated, "Actor2 key1 not terminated with correct error.", ) }) t.Run("unregister all with no actors for the key", func(st *testing.T) { keyEmpty := NewServiceKey[*testMsg, string]("sk-ua-key-empty") stoppedCountEmptyKey := keyEmpty.UnregisterAll(as) require.Equal( st, 0, stoppedCountEmptyKey, "UnregisterAll for empty key returned non-zero count.", ) foundActorsForKeyEmpty := FindInReceptionist( as.Receptionist(), keyEmpty, ) require.Empty( st, foundActorsForKeyEmpty, "Receptionist not empty for keyEmpty "+ "after UnregisterAll.", ) }) t.Run("unregister all with mixed keys", func(st *testing.T) { keyA := NewServiceKey[*testMsg, string]("sk-ua-keyA") keyB := NewServiceKey[*testMsg, string]("sk-ua-keyB") // Spawn 3 actors, two of them will share the same service key. actorA1, err := keyA.Spawn(as, "actorA1-ua-mixed", beh) require.NoError(st, err) actorA2, err := keyA.Spawn(as, "actorA2-ua-mixed", beh) require.NoError(st, err) actorB1, err := keyB.Spawn(as, "actorB1-ua-mixed", beh) require.NoError(st, err) // Make sure we're able to find them in the receptionist. require.Len( st, FindInReceptionist(as.Receptionist(), keyA), 2, "KeyA initial registration count mismatch.", ) require.Len( st, FindInReceptionist(as.Receptionist(), keyB), 1, "KeyB initial registration count mismatch.", ) // We'll start by unregistering all actors for keyA. stoppedCountKeyA := keyA.UnregisterAll(as) require.Equal( st, 2, stoppedCountKeyA, "UnregisterAll for keyA returned incorrect count.", ) // Verify keyA actors are gone from receptionist, keyB actor // remains. require.Empty( st, FindInReceptionist(as.Receptionist(), keyA), "actors for keyA still in receptionist after "+ "UnregisterAll.", ) foundActorsForKeyBAfterA := FindInReceptionist( as.Receptionist(), keyB, ) require.Len( st, foundActorsForKeyBAfterA, 1, "Actor for keyB affected by UnregisterAll on keyA.", ) require.Equal( st, actorB1, foundActorsForKeyBAfterA[0], "Wrong actor found for keyB.", ) // Verify keyA actors are removed from system map, keyB actor // remains. as.mu.RLock() _, actorA1ExistsAfterMixed := as.actors[actorA1.ID()] _, actorA2ExistsAfterMixed := as.actors[actorA2.ID()] _, actorB1ExistsAfterMixed := as.actors[actorB1.ID()] as.mu.RUnlock() require.False( st, actorA1ExistsAfterMixed, "ActorA1 still in system actors map after "+ "mixed UnregisterAll.", ) require.False( st, actorA2ExistsAfterMixed, "ActorA2 still in system actors map after "+ "mixed UnregisterAll.", ) require.True( st, actorB1ExistsAfterMixed, "ActorB1 removed from system actors map incorrectly.", ) // Verify keyA actors are stopped, keyB actor is running. resultActorA1Mixed := actorA1.Ask( context.Background(), newTestMsg("ping-kA-a1"), ).Await(context.Background()) require.True(st, resultActorA1Mixed.IsErr()) require.ErrorIs( st, resultActorA1Mixed.Err(), ErrActorTerminated, ) resultActorB1Mixed := actorB1.Ask( context.Background(), newTestMsg("ping-kB-a1"), ).Await(context.Background()) require.False( st, resultActorB1Mixed.IsErr(), "ActorB1 terminated incorrectly (mixed test): %v", resultActorB1Mixed.Err(), ) resultActorB1Mixed.WhenOk(func(s string) { require.Equal(st, "echo: ping-kB-a1", s) }) }) t.Run("idempotency of UnregisterAll", func(st *testing.T) { keyIdempotent := NewServiceKey[*testMsg, string]( "sk-ua-key-idem", ) actorIdem, err := keyIdempotent.Spawn(as, "actor-idem-ua", beh) require.NoError(st, err) // First call should unregister and stop. stoppedCountFirstCall := keyIdempotent.UnregisterAll(as) require.Equal( st, 1, stoppedCountFirstCall, "UnregisterAll (first call) incorrect count.", ) // Second call should do nothing and return 0. stoppedCountSecondCall := keyIdempotent.UnregisterAll(as) require.Equal( st, 0, stoppedCountSecondCall, "UnregisterAll (second call) incorrect count, not "+ "idempotent.", ) // Verify actor is gone from receptionist and system map, and is // stopped. require.Empty( st, FindInReceptionist(as.Receptionist(), keyIdempotent), "Actors for keyIdempotent still in receptionist "+ "after calls.", ) as.mu.RLock() _, actorIdemExistsAfter := as.actors[actorIdem.ID()] as.mu.RUnlock() require.False( st, actorIdemExistsAfter, "ActorIdem still in system actors map after calls.", ) resultActorIdem := actorIdem.Ask( context.Background(), newTestMsg("ping-kidem-a1"), ).Await(context.Background()) require.True(st, resultActorIdem.IsErr()) require.ErrorIs(st, resultActorIdem.Err(), ErrActorTerminated) }) } // routerTestHarness helps set up routers and their associated actors for testing. // It uses an actorTestHarness internally for DLO observation for the router. type routerTestHarness struct { *actorTestHarness as *ActorSystem receptionist *Receptionist } // newRouterTestHarness sets up a new harness for router testing. // It creates an ActorSystem for actors that the router will route to, // and uses the embedded actorTestHarness for the router's own DLO. func newRouterTestHarness(t *testing.T) *routerTestHarness { t.Helper() system := NewActorSystem() t.Cleanup(func() { err := system.Shutdown() require.NoError(t, err, "router test actor system shutdown failed") }) // The DLO for the router itself will come from actorTestHarness. // Actors managed by `system` (router targets) will use `system.DeadLetters()`. return &routerTestHarness{ actorTestHarness: newActorTestHarness(t), as: system, receptionist: system.Receptionist(), } } // newRouterTargetActor creates an actor, registers it with the harness's // ActorSystem (h.as) and Receptionist under the given service key. This actor // is intended to be a target for the router. func (h *routerTestHarness) newRouterTargetActor(id string, key ServiceKey[*testMsg, string], beh ActorBehavior[*testMsg, string]) ActorRef[*testMsg, string] { h.t.Helper() ref, err := RegisterWithSystem(h.as, id, key, beh) require.NoError(h.t, err) return ref } // TestRouterNewRouter verifies that a new router can be created as expected. func TestRouterNewRouter(t *testing.T) { t.Parallel() h := newRouterTestHarness(t) key := NewServiceKey[*testMsg, string]("router-service") strategy := NewRoundRobinStrategy[*testMsg, string]() router := NewRouter(h.receptionist, key, strategy, h.dlo.Ref()) require.NotNil(t, router, "newRouter should not return nil") require.Equal(t, "router(router-service)", router.ID(), "router ID mismatch") } // countingEchoBehavior is an echo behavior that also counts how many messages // it has processed. type countingEchoBehavior struct { *echoBehavior id string processedMsgs int64 } func newCountingEchoBehavior(t *testing.T, id string) *countingEchoBehavior { return &countingEchoBehavior{ echoBehavior: newEchoBehavior(t, 0), id: id, } } func (b *countingEchoBehavior) Receive(ctx context.Context, msg *testMsg) fn.Result[string] { atomic.AddInt64(&b.processedMsgs, 1) // Include actor ID in reply for easier verification. res := b.echoBehavior.Receive(ctx, msg) val, err := res.Unpack() if err == nil { return fn.Ok(fmt.Sprintf("%s:%s", b.id, val)) } return res } // TestRouterTellAndAskRoundRobin verifies that the router distributes messages // in a round robin properly. func TestRouterTellAndAskRoundRobin(t *testing.T) { t.Parallel() h := newRouterTestHarness(t) // Make a new router for the given service key and round robin strategy. serviceKey := NewServiceKey[*testMsg, string]("rr-service") strategy := NewRoundRobinStrategy[*testMsg, string]() router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref()) // We'll now register two actors with the router, each with a different // service key. actor1Beh := newCountingEchoBehavior(t, "actor1") actor2Beh := newCountingEchoBehavior(t, "actor2") _ = h.newRouterTargetActor("actor1-rr", serviceKey, actor1Beh) _ = h.newRouterTargetActor("actor2-rr", serviceKey, actor2Beh) // Nxet, we'll send a mix of Tell and Ask messages to the router. numMessages := 6 for i := 0; i < numMessages; i++ { msgData := fmt.Sprintf("message-%d", i) if i%2 == 0 { router.Tell(context.Background(), newTestMsg(msgData)) } else { future := router.Ask( context.Background(), newTestMsg(msgData), ) ctxAwait, cancelAwait := context.WithTimeout( context.Background(), time.Second, ) result := future.Await(ctxAwait) cancelAwait() require.False( t, result.IsErr(), "ask failed: %v", result.Err(), ) } } // Wait a bit for Tell messages to be processed. time.Sleep(100 * time.Millisecond) // Each actor should have processed numMessages / 2 messages. require.EqualValues( t, numMessages/2, atomic.LoadInt64(&actor1Beh.processedMsgs), "actor1 processed message count mismatch", ) require.EqualValues( t, numMessages/2, atomic.LoadInt64(&actor2Beh.processedMsgs), "actor2 processed message count mismatch", ) // Router's DLO should be empty. h.assertNoDLOMessages() } // TestRouterNoActorsAvailable verifies that if no actors are available for the // message, then an error is returned. func TestRouterNoActorsAvailable(t *testing.T) { t.Parallel() h := newRouterTestHarness(t) serviceKey := NewServiceKey[*testMsg, string]("no-actor-service") strategy := NewRoundRobinStrategy[*testMsg, string]() router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref()) // We'll send a message, then assert that it goes to the DLO. tellMsg := newTestMsg("tell-no-actor") router.Tell(context.Background(), tellMsg) h.assertDLOMessage(tellMsg) // If we use an ask instead, then we should get an error. askMsg := newTestMsg("ask-no-actor") future := router.Ask(context.Background(), askMsg) result := future.Await(context.Background()) require.True( t, result.IsErr(), "ask should fail when no actors are available", ) require.ErrorIs(t, result.Err(), ErrNoActorsAvailable, "error mismatch") } // TestRouterTellAskContextCancellation verifies that if the context is // canceled, then sending aborts. func TestRouterTellAskContextCancellation(t *testing.T) { t.Parallel() h := newRouterTestHarness(t) serviceKey := NewServiceKey[*testMsg, string]("ctx-cancel-service") strategy := NewRoundRobinStrategy[*testMsg, string]() router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref()) // Use a regular echo actor, but we'll control context for Tell/Ask. targetActorBeh := newEchoBehavior(t, 50*time.Millisecond) _ = h.newRouterTargetActor("target-ctx", serviceKey, targetActorBeh) // Next, we'll send a Tell message with a context that will be cancelled // before we even send. ctxTell, cancelTell := context.WithCancel(context.Background()) cancelTell() router.Tell(ctxTell, newTestMsg("tell-ctx-cancelled")) // The Message should be dropped by actorRefImpl.Tell if ctx is // cancelled. Router's DLO should not receive it from this path. h.assertNoDLOMessages() // Next, we'll do the same for Ask. This time, we should get an error. ctxAsk, cancelAsk := context.WithCancel(context.Background()) cancelAsk() futureAsk := router.Ask(ctxAsk, newTestMsg("ask-ctx-cancelled")) resultAsk := futureAsk.Await(context.Background()) require.True( t, resultAsk.IsErr(), "ask with cancelled context should fail", ) require.ErrorIs( t, resultAsk.Err(), context.Canceled, "error should be context.Canceled", ) } // TestRouterDynamicActorRegistration tests that we're able to dynamically add // and remove actors from the router. func TestRouterDynamicActorRegistration(t *testing.T) { t.Parallel() h := newRouterTestHarness(t) serviceKey := NewServiceKey[*testMsg, string]("dynamic-service") strategy := NewRoundRobinStrategy[*testMsg, string]() router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref()) // If we try to send a mesasge to the router before any actors are // added, we should get an error. futureNoActor := router.Ask(context.Background(), newTestMsg("ping-no-actors")) resNoActor := futureNoActor.Await(context.Background()) require.ErrorIs(t, resNoActor.Err(), ErrNoActorsAvailable) actor1Beh := newCountingEchoBehavior(t, "actor1") actor1Ref := h.newRouterTargetActor("actor1-dynamic", serviceKey, actor1Beh) // At this point, we have a new actor added, but we'll try to send a // message to a different actor ID. This should go to the router's DLO. futureActor1 := router.Ask(context.Background(), newTestMsg("ping-actor1")) ctxAwaitA1, cancelAwaitA1 := context.WithTimeout(context.Background(), time.Second) resActor1 := futureActor1.Await(ctxAwaitA1) cancelAwaitA1() require.False(t, resActor1.IsErr(), "ask to actor1 failed: %v", resActor1.Err()) resActor1.WhenOk(func(s string) { require.Equal(t, "actor1:echo: ping-actor1", s) }) actor2Beh := newCountingEchoBehavior(t, "actor2") actor2Ref := h.newRouterTargetActor( "actor2-dynamic", serviceKey, actor2Beh, ) // Now that we've added two actors above, we should round robin between // them when sending. ctxAwaitDA1, cancelAwaitDA1 := context.WithTimeout( context.Background(), time.Second, ) router.Ask(context.Background(), newTestMsg("dynamic-ask1")).Await( ctxAwaitDA1, ) cancelAwaitDA1() ctxAwaitDA2, cancelAwaitDA2 := context.WithTimeout(context.Background(), time.Second) router.Ask(context.Background(), newTestMsg("dynamic-ask2")).Await(ctxAwaitDA2) cancelAwaitDA2() time.Sleep(50 * time.Millisecond) // actor1 should have processed 2 messages (ping-actor1, dynamic-ask1), require.EqualValues(t, 2, atomic.LoadInt64(&actor1Beh.processedMsgs)) require.EqualValues(t, 1, atomic.LoadInt64(&actor2Beh.processedMsgs)) // Next, we'll unregister the first actor ref. unregistered := UnregisterFromReceptionist( h.receptionist, serviceKey, actor1Ref, ) require.True(t, unregistered) // All the messages should now go to the second actor. for i := 0; i < 2; i++ { msgData := fmt.Sprintf("to-actor2-%d", i) future := router.Ask(context.Background(), newTestMsg(msgData)) ctxAwaitLoop, cancelAwaitLoop := context.WithTimeout( context.Background(), time.Second, ) res := future.Await(ctxAwaitLoop) cancelAwaitLoop() require.False( t, res.IsErr(), "ask to actor2 failed: %v", res.Err(), ) res.WhenOk(func(s string) { require.Equal(t, "actor2:echo: "+msgData, s) }) } // Actor 1 shouldn't have got any of the messages, they should go to // actor 2. require.EqualValues(t, 2, atomic.LoadInt64(&actor1Beh.processedMsgs)) require.EqualValues(t, 1+2, atomic.LoadInt64(&actor2Beh.processedMsgs)) // Next, we'll unregister the second actor ref. unregistered2 := UnregisterFromReceptionist( h.receptionist, serviceKey, actor2Ref, ) require.True(t, unregistered2) // If we try to send another message, it should go to the DL. tellMsg := newTestMsg("dynamic-tell-no-actors") router.Tell(context.Background(), tellMsg) h.assertDLOMessage(tellMsg) }