From e2d50f3cb7e9cfa61d44df3d9fc2082be542f217 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Fri, 17 Apr 2026 17:14:34 +0200 Subject: [PATCH 1/2] actor: add SingletonRef for service keys with one actor Introduce SingletonRef, an ActorRef implementation that acts as a lookup proxy for service keys expected to have exactly one registered actor. Unlike Router + RoundRobinStrategy, which is overkill (and semantically misleading) for the "one-actor-per-key" pattern, SingletonRef performs a direct receptionist lookup on each Tell/Ask with no routing strategy or extra allocations. Two ServiceKey helpers tie this together: - SpawnSingleton unregisters any existing actor under the key and spawns a replacement, so it is safe to call repeatedly (e.g. when a per-channel actor is re-initialized). It is documented as neither concurrent-atomic nor transactional on registration failure, with a TODO to harden the latter. - Singleton returns a SingletonRef wired to the system's receptionist and dead-letter office, letting consumers reach the actor without holding a direct ActorRef. When more than one actor is registered (a transient race or caller bug), SingletonRef logs the invariant violation and forwards to the first registered actor, so the system remains functional rather than failing closed. Tests cover: no-actor Tell/Ask, basic Tell/Ask, ID format, repeated spawn replacing the prior actor, dynamic register/unregister, multiple-registration tolerance, no-DLO-configured, context cancellation, the Singleton helper, ActorRef interface satisfaction, and repeated-spawn freshness via actor-ID echo. --- actor/example_singleton_test.go | 69 ++++++ actor/singleton.go | 159 ++++++++++++++ actor/singleton_test.go | 359 ++++++++++++++++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 actor/example_singleton_test.go create mode 100644 actor/singleton.go create mode 100644 actor/singleton_test.go 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) + }) + } +} From 46526dbfb4f2f8f92cb0f8713f7abe8dd325ef02 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Fri, 17 Apr 2026 17:35:23 +0200 Subject: [PATCH 2/2] docs: add release note for actor SingletonRef --- docs/release-notes/release-notes-0.21.1.md | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/release-notes/release-notes-0.21.1.md 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)