diff --git a/actor/example_singleton_test.go b/actor/example_singleton_test.go new file mode 100644 index 00000000000..30bc7d8b3ed --- /dev/null +++ b/actor/example_singleton_test.go @@ -0,0 +1,69 @@ +package actor_test + +import ( + "context" + "fmt" + + "github.com/lightningnetwork/lnd/actor" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// SingletonGreetingMsg is a message type for the singleton example. +type SingletonGreetingMsg struct { + actor.BaseMessage + Name string +} + +// MessageType implements actor.Message. +func (m SingletonGreetingMsg) MessageType() string { + return "SingletonGreetingMsg" +} + +// ExampleServiceKey_SpawnSingleton demonstrates the singleton pattern: a +// single actor is registered under a service key, and consumers reach it +// through a SingletonRef that performs receptionist lookups on each call. +// This avoids the overhead of a Router+RoutingStrategy when there is never +// more than one actor per key. +func ExampleServiceKey_SpawnSingleton() { + system := actor.NewActorSystem() + defer func() { _ = system.Shutdown() }() + + greeterKey := actor.NewServiceKey[SingletonGreetingMsg, string]( + "singleton-greeter", + ) + + // The spawner registers exactly one actor for the key. Calling + // SpawnSingleton again would replace any existing actor atomically + // from the caller's perspective (stop-then-register). + behavior := actor.NewFunctionBehavior( + func(_ context.Context, + msg SingletonGreetingMsg) fn.Result[string] { + + return fn.Ok("Hello, " + msg.Name + "!") + }, + ) + if _, err := greeterKey.SpawnSingleton( + system, "greeter-actor", behavior, + ); err != nil { + fmt.Printf("spawn failed: %v\n", err) + return + } + + // Consumers get a SingletonRef. They don't need to know the actor + // ID or hold a direct reference — the ref resolves the actor via + // the receptionist on each Tell/Ask. + ref := greeterKey.Singleton(system) + + for _, name := range []string{"Alice", "Bob"} { + result := ref.Ask( + context.Background(), SingletonGreetingMsg{Name: name}, + ).Await(context.Background()) + result.WhenOk(func(s string) { + fmt.Println(s) + }) + } + + // Output: + // Hello, Alice! + // Hello, Bob! +} diff --git a/actor/singleton.go b/actor/singleton.go new file mode 100644 index 00000000000..f3474d5ab69 --- /dev/null +++ b/actor/singleton.go @@ -0,0 +1,159 @@ +package actor + +import ( + "context" + "errors" + + "github.com/lightningnetwork/lnd/fn/v2" +) + +// Compile-time assertion that SingletonRef satisfies the ActorRef interface. +var _ ActorRef[Message, any] = (*SingletonRef[Message, any])(nil) + +// SingletonRef is an ActorRef implementation that acts as a lookup proxy for +// service keys expected to have exactly one registered actor. It holds no +// direct reference to the target actor; instead, it performs a receptionist +// lookup on each Tell/Ask and forwards to whichever actor is currently +// registered. Compared to Router, this skips the routing strategy entirely, +// which is both semantically correct for "one-actor-per-key" patterns (e.g. +// "the RBF closer for channel X") and avoids unnecessary allocations. +// +// Because SingletonRef does not own the target actor's lifecycle, spawning +// and unregistering are done through the ServiceKey, typically via +// ServiceKey.SpawnSingleton. The only ActorRef this type holds is the +// optional DLO used when no actor is registered. +type SingletonRef[M Message, R any] struct { + receptionist *Receptionist + serviceKey ServiceKey[M, R] + dlo ActorRef[Message, any] +} + +// NewSingletonRef creates a new SingletonRef for the given service key. The +// receptionist is used to discover the actor registered under the key. The +// optional dlo is used as the destination for Tell messages sent when no +// actor is registered; if dlo is nil, such messages are dropped with a log +// warning. +func NewSingletonRef[M Message, R any](receptionist *Receptionist, + key ServiceKey[M, R], + dlo ActorRef[Message, any]) *SingletonRef[M, R] { + + return &SingletonRef[M, R]{ + receptionist: receptionist, + serviceKey: key, + dlo: dlo, + } +} + +// getActor performs a receptionist lookup for the singleton's service key. It +// returns ErrNoActorsAvailable if no actor is registered. If more than one +// actor is registered, it logs a warning about the invariant violation and +// returns the first registered actor, so callers remain functional during +// transient registration races. +func (s *SingletonRef[M, R]) getActor() (ActorRef[M, R], error) { + refs := FindInReceptionist(s.receptionist, s.serviceKey) + switch len(refs) { + case 0: + return nil, ErrNoActorsAvailable + + case 1: + return refs[0], nil + + default: + // Invariant violation: a singleton service key must have at + // most one registered actor. This can happen transiently + // during a re-registration race; log loudly so the bug is + // visible, then fall through and use the first registered + // actor to keep the system functional. + log.Warnf("SingletonRef(%s): %d actors registered for "+ + "singleton service key, expected 1; using first", + s.serviceKey.name, len(refs)) + + return refs[0], nil + } +} + +// Tell sends a message to the singleton actor. If no actor is currently +// registered and a DLO is configured, the message is forwarded to the DLO; +// otherwise it is dropped with a log warning, matching Router's behavior. +func (s *SingletonRef[M, R]) Tell(ctx context.Context, msg M) { + ref, err := s.getActor() + if err != nil { + if errors.Is(err, ErrNoActorsAvailable) && s.dlo != nil { + s.dlo.Tell(context.Background(), msg) + } else { + log.Warnf("SingletonRef(%s): message %s dropped "+ + "(no actor registered, no DLO configured)", + s.serviceKey.name, msg.MessageType()) + } + + return + } + + ref.Tell(ctx, msg) +} + +// Ask sends a message to the singleton actor and returns a Future for the +// response. If no actor is registered, the Future is completed immediately +// with ErrNoActorsAvailable. +func (s *SingletonRef[M, R]) Ask(ctx context.Context, msg M) Future[R] { + ref, err := s.getActor() + if err != nil { + promise := NewPromise[R]() + promise.Complete(fn.Err[R](err)) + + return promise.Future() + } + + return ref.Ask(ctx, msg) +} + +// ID returns an identifier for this singleton reference. Since SingletonRef +// is a lookup proxy rather than a direct reference to a concrete actor, its +// ID is derived from the service key. +func (s *SingletonRef[M, R]) ID() string { + return "singleton(" + s.serviceKey.name + ")" +} + +// SpawnSingleton registers a singleton actor under this service key. Any +// existing actors registered under the same key are unregistered and stopped +// first, so this method is safe to call repeatedly (e.g. when a channel +// closer is re-initialized for the same channel point). It returns the +// ActorRef of the newly spawned actor. +// +// NOTE: The unregister-then-register sequence is not atomic under the +// receptionist lock. Concurrent callers racing to spawn a singleton for the +// same key may temporarily leave two actors registered. SingletonRef +// tolerates this by logging and using the first registered actor. If strict +// at-most-one registration is required, callers must coordinate externally. +// +// NOTE: SpawnSingleton is also not transactional with respect to failure. +// UnregisterAll runs before RegisterWithSystem, so if the registration step +// returns an error (e.g. ErrEmptyActorID, ErrNilBehavior, +// ErrDuplicateActorID) any previously registered actor has already been +// stopped and there is no rollback. Callers must pass a valid config. +// +// TODO: make SpawnSingleton transactional by validating the config and +// reserving the actor ID before tearing down the previous registration, so a +// failed replacement leaves the existing singleton intact. +func (sk ServiceKey[M, R]) SpawnSingleton(as *ActorSystem, id string, + behavior ActorBehavior[M, R], + opts ...ActorOption[M, R]) (ActorRef[M, R], error) { + + // Stop and unregister any previous actor for this key so that only one + // instance exists after we return. + sk.UnregisterAll(as) + + return RegisterWithSystem(as, id, sk, behavior, opts...) +} + +// Singleton returns a SingletonRef that can be used to reach the actor +// registered under this service key. The returned ref does not spawn an +// actor — it performs receptionist lookups on each Tell/Ask. Combined with +// SpawnSingleton (called elsewhere to register the actor), this is the +// preferred pattern for "one-actor-per-key" services: the spawner manages +// the actor lifecycle while consumers simply hold a Singleton ref. +func (sk ServiceKey[M, R]) Singleton( + as *ActorSystem) *SingletonRef[M, R] { + + return NewSingletonRef(as.Receptionist(), sk, as.DeadLetters()) +} diff --git a/actor/singleton_test.go b/actor/singleton_test.go new file mode 100644 index 00000000000..9bcbc5a588d --- /dev/null +++ b/actor/singleton_test.go @@ -0,0 +1,359 @@ +package actor + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// singletonTestHarness bundles an ActorSystem and a SingletonRef-friendly +// receptionist for singleton tests. +type singletonTestHarness struct { + *actorTestHarness + as *ActorSystem + receptionist *Receptionist +} + +// newSingletonTestHarness creates a new harness with its own ActorSystem. +func newSingletonTestHarness(t *testing.T) *singletonTestHarness { + t.Helper() + system := NewActorSystem() + t.Cleanup(func() { + require.NoError(t, system.Shutdown()) + }) + + return &singletonTestHarness{ + actorTestHarness: newActorTestHarness(t), + as: system, + receptionist: system.Receptionist(), + } +} + +// TestSingletonRefNotRegistered verifies that when no actor is registered, +// Ask returns ErrNoActorsAvailable and Tell forwards to the DLO. +func TestSingletonRefNotRegistered(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-not-registered") + ref := NewSingletonRef(h.receptionist, key, h.dlo.Ref()) + + // Ask should fail immediately with ErrNoActorsAvailable. + askMsg := newTestMsg("ask-no-actor") + result := ref.Ask(context.Background(), askMsg).Await( + context.Background(), + ) + require.True(t, result.IsErr()) + require.ErrorIs(t, result.Err(), ErrNoActorsAvailable) + + // Tell should forward the message to the DLO. + tellMsg := newTestMsg("tell-no-actor") + ref.Tell(context.Background(), tellMsg) + h.assertDLOMessage(tellMsg) +} + +// TestSingletonRefTellAskBasic verifies that Tell and Ask correctly forward +// to the one registered actor. +func TestSingletonRefTellAskBasic(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-basic") + _, err := key.SpawnSingleton(h.as, "singleton-basic-actor", + newEchoBehavior(t, 0)) + require.NoError(t, err) + + ref := key.Singleton(h.as) + require.Equal(t, "singleton(singleton-basic)", ref.ID()) + + // Ask should echo through the actor. + result := ref.Ask( + context.Background(), newTestMsg("hello"), + ).Await(context.Background()) + require.False(t, result.IsErr()) + result.WhenOk(func(s string) { + require.Equal(t, "echo: hello", s) + }) + + // Tell should reach the actor; we observe via reply channel. + replyChan := make(chan string, 1) + ref.Tell( + context.Background(), + newTestMsgWithReply("tell-data", replyChan), + ) + received, err := fn.RecvOrTimeout(replyChan, time.Second) + require.NoError(t, err) + require.Equal(t, "tell-data", received) +} + +// TestSingletonRefID verifies the generated ID format. +func TestSingletonRefID(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("my-service") + ref := NewSingletonRef(h.receptionist, key, h.dlo.Ref()) + require.Equal(t, "singleton(my-service)", ref.ID()) +} + +// TestSpawnSingletonReplacesExisting verifies that calling SpawnSingleton a +// second time for the same key stops the previous actor and replaces it with +// a fresh one. +func TestSpawnSingletonReplacesExisting(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-replace") + + // Spawn the first actor. + beh1 := newCountingEchoBehavior(t, "actor1") + ref1, err := key.SpawnSingleton(h.as, "singleton-replace-1", beh1) + require.NoError(t, err) + + // Verify it's reachable through the singleton lookup. + lookup := key.Singleton(h.as) + r := lookup.Ask( + context.Background(), newTestMsg("m1"), + ).Await(context.Background()) + require.False(t, r.IsErr()) + r.WhenOk(func(s string) { + require.Equal(t, "actor1:echo: m1", s) + }) + require.EqualValues(t, 1, atomic.LoadInt64(&beh1.processedMsgs)) + + // Spawn a second actor under the same key. This must stop the first + // and leave exactly one registered. + beh2 := newCountingEchoBehavior(t, "actor2") + ref2, err := key.SpawnSingleton(h.as, "singleton-replace-2", beh2) + require.NoError(t, err) + + // Only one actor should be registered under the key. + refs := FindInReceptionist(h.receptionist, key) + require.Len(t, refs, 1) + require.Equal(t, ref2, refs[0]) + + // The first actor should be stopped. + staleRes := ref1.Ask( + context.Background(), newTestMsg("m-to-stale"), + ).Await(context.Background()) + require.True(t, staleRes.IsErr()) + require.ErrorIs(t, staleRes.Err(), ErrActorTerminated) + + // The singleton lookup should now hit the second actor. + r = lookup.Ask( + context.Background(), newTestMsg("m2"), + ).Await(context.Background()) + require.False(t, r.IsErr()) + r.WhenOk(func(s string) { + require.Equal(t, "actor2:echo: m2", s) + }) + require.EqualValues(t, 1, atomic.LoadInt64(&beh2.processedMsgs)) + // beh1 must not have received the second message. + require.EqualValues(t, 1, atomic.LoadInt64(&beh1.processedMsgs)) +} + +// TestSingletonRefDynamicRegistration verifies that a SingletonRef picks up +// an actor that is registered after the ref was created, and that it returns +// to the no-actor state after the actor is unregistered. +func TestSingletonRefDynamicRegistration(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-dynamic") + ref := NewSingletonRef(h.receptionist, key, h.dlo.Ref()) + + // No actor registered yet — Ask should return ErrNoActorsAvailable. + res := ref.Ask( + context.Background(), newTestMsg("before"), + ).Await(context.Background()) + require.ErrorIs(t, res.Err(), ErrNoActorsAvailable) + + // Spawn the actor. + _, err := key.SpawnSingleton( + h.as, "singleton-dynamic-actor", newEchoBehavior(t, 0), + ) + require.NoError(t, err) + + // Now Ask should succeed. + res = ref.Ask( + context.Background(), newTestMsg("after-spawn"), + ).Await(context.Background()) + require.False(t, res.IsErr()) + res.WhenOk(func(s string) { + require.Equal(t, "echo: after-spawn", s) + }) + + // Unregister the actor; Ask should fail again. + require.Equal(t, 1, key.UnregisterAll(h.as)) + res = ref.Ask( + context.Background(), newTestMsg("after-unreg"), + ).Await(context.Background()) + require.ErrorIs(t, res.Err(), ErrNoActorsAvailable) +} + +// TestSingletonRefMultipleRegistrations verifies that if two actors somehow +// end up registered under the same singleton key, the ref still works by +// forwarding to the first registered actor (invariant violation tolerance). +func TestSingletonRefMultipleRegistrations(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-multi") + + // Register two actors directly, bypassing SpawnSingleton, to simulate + // a registration race. + beh1 := newCountingEchoBehavior(t, "actor1") + beh2 := newCountingEchoBehavior(t, "actor2") + _, err := RegisterWithSystem(h.as, "singleton-multi-1", key, beh1) + require.NoError(t, err) + _, err = RegisterWithSystem(h.as, "singleton-multi-2", key, beh2) + require.NoError(t, err) + + require.Len(t, FindInReceptionist(h.receptionist, key), 2) + + // Ask should still succeed; our tolerant implementation routes to the + // first registered actor. + ref := NewSingletonRef(h.receptionist, key, h.dlo.Ref()) + res := ref.Ask( + context.Background(), newTestMsg("hello"), + ).Await(context.Background()) + require.False(t, res.IsErr()) + res.WhenOk(func(s string) { + require.Equal(t, "actor1:echo: hello", s) + }) + + // Only the first actor should have processed the message. + require.EqualValues(t, 1, atomic.LoadInt64(&beh1.processedMsgs)) + require.EqualValues(t, 0, atomic.LoadInt64(&beh2.processedMsgs)) +} + +// TestSingletonRefNoDLOConfigured verifies that Tell with no actor and no +// DLO configured does not panic and drops the message cleanly. +func TestSingletonRefNoDLOConfigured(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-no-dlo") + ref := NewSingletonRef(h.receptionist, key, nil) + + require.NotPanics(t, func() { + ref.Tell(context.Background(), newTestMsg("lost-message")) + }) + + // Nothing should go to the harness's DLO either, since it isn't + // wired up to the ref. + h.assertNoDLOMessages() +} + +// TestSingletonRefContextCancellation verifies that a cancelled context +// propagates to Ask. +func TestSingletonRefContextCancellation(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-cancel") + + // The behavior's processing delay is irrelevant here: Ask short- + // circuits on an already-cancelled context before reaching the + // mailbox, so the behavior's Receive is never invoked. + _, err := key.SpawnSingleton( + h.as, "singleton-cancel-actor", newEchoBehavior(t, 0), + ) + require.NoError(t, err) + + ref := key.Singleton(h.as) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result := ref.Ask(ctx, newTestMsg("cancel-me")).Await( + context.Background(), + ) + require.True(t, result.IsErr()) + require.ErrorIs(t, result.Err(), context.Canceled) +} + +// TestServiceKeySingletonHelper verifies that ServiceKey.Singleton returns a +// ref pointing to the same receptionist and key as the ActorSystem. +func TestServiceKeySingletonHelper(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-helper") + ref := key.Singleton(h.as) + + require.Equal(t, "singleton(singleton-helper)", ref.ID()) + require.Equal(t, h.as.Receptionist(), ref.receptionist) + require.Equal(t, h.as.DeadLetters(), ref.dlo) +} + +// TestSingletonRefImplementsActorRef verifies at runtime that SingletonRef +// can be used as an ActorRef. +func TestSingletonRefImplementsActorRef(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-iface") + _, err := key.SpawnSingleton( + h.as, "singleton-iface-actor", newEchoBehavior(t, 0), + ) + require.NoError(t, err) + + var ref ActorRef[*testMsg, string] = key.Singleton(h.as) + res := ref.Ask( + context.Background(), newTestMsg("via-interface"), + ).Await(context.Background()) + require.False(t, res.IsErr()) + res.WhenOk(func(s string) { + require.Equal(t, "echo: via-interface", s) + }) +} + +// TestSpawnSingletonIdempotentReplace verifies that calling SpawnSingleton +// repeatedly for the same key results in exactly one registered actor each +// time, and that the fresh actor (not a stale predecessor) is the one that +// receives messages. Each behavior closes over its own actor ID and echoes +// it back, so the assertion directly compares the reply to the just-spawned +// actor's ID. +func TestSpawnSingletonIdempotentReplace(t *testing.T) { + t.Parallel() + h := newSingletonTestHarness(t) + + key := NewServiceKey[*testMsg, string]("singleton-idem") + + const replacements = 5 + for i := 0; i < replacements; i++ { + actorID := fmt.Sprintf("idem-actor-%d", i) + + // The closure captures actorID so that whichever actor + // responds reveals its identity in the reply. + beh := NewFunctionBehavior( + func(_ context.Context, + _ *testMsg) fn.Result[string] { + + return fn.Ok(actorID) + }, + ) + + _, err := key.SpawnSingleton(h.as, actorID, beh) + require.NoError(t, err) + + // After each spawn, exactly one actor should be registered. + require.Len(t, FindInReceptionist(h.receptionist, key), 1) + + // The reply must identify the just-spawned actor. If a stale + // predecessor answered, we'd see its earlier actorID here. + ref := key.Singleton(h.as) + res := ref.Ask( + context.Background(), newTestMsg("who-replies"), + ).Await(context.Background()) + require.False(t, res.IsErr()) + res.WhenOk(func(replier string) { + require.Equal(t, actorID, replier) + }) + } +} diff --git a/docs/release-notes/release-notes-0.21.1.md b/docs/release-notes/release-notes-0.21.1.md new file mode 100644 index 00000000000..a056bd0abc6 --- /dev/null +++ b/docs/release-notes/release-notes-0.21.1.md @@ -0,0 +1,69 @@ +# Release Notes +- [Bug Fixes](#bug-fixes) +- [New Features](#new-features) + - [Functional Enhancements](#functional-enhancements) + - [RPC Additions](#rpc-additions) + - [lncli Additions](#lncli-additions) +- [Improvements](#improvements) + - [Functional Updates](#functional-updates) + - [RPC Updates](#rpc-updates) + - [lncli Updates](#lncli-updates) + - [Breaking Changes](#breaking-changes) + - [Performance Improvements](#performance-improvements) + - [Deprecations](#deprecations) +- [Technical and Architectural Updates](#technical-and-architectural-updates) + - [BOLT Spec Updates](#bolt-spec-updates) + - [Testing](#testing) + - [Database](#database) + - [Code Health](#code-health) + - [Tooling and Documentation](#tooling-and-documentation) + +# Bug Fixes + +# New Features + +## Functional Enhancements + +## RPC Additions + +## lncli Additions + +# Improvements + +## Functional Updates + +## RPC Updates + +## lncli Updates + +## Code Health + +## Breaking Changes + +## Performance Improvements + +## Deprecations + +# Technical and Architectural Updates + +## BOLT Spec Updates + +## Testing + +## Database + +## Code Health + +* [Added `SingletonRef` to the `actor` + package](https://github.com/lightningnetwork/lnd/pull/10759) for service + keys that are expected to have at most one registered actor. + `ServiceKey.SpawnSingleton` and `ServiceKey.Singleton` give spawners and + consumers a direct, type-safe path to a single-actor service, replacing + the `Router + RoundRobinStrategy` pattern previously needed to front a + singleton. `SingletonRef` tolerates transient double-registration by + logging the invariant violation and using the first registered actor, so + the system stays functional through brief registration races. + +## Tooling and Documentation + +# Contributors (Alphabetical Order)