Skip to content

Latest commit

 

History

History

README.md

Actor Package

Introduction to Actors

The actor model is a conceptual model for concurrent computation that treats "actors" as the universal primitives of concurrent computation. Originating from Carl Hewitt's work in the 1970s and popularized by languages like Erlang and frameworks like Akka, actors provide a high-level abstraction for building robust, concurrent, and distributed systems.

At its core, an actor is an independent unit of computation that encapsulates:

  • State: An actor can maintain private state that it alone can modify.
  • Behavior: An actor defines how it reacts to messages it receives.
  • Mailbox: Each actor has a mailbox to queue incoming messages.

Actors communicate exclusively through asynchronous message passing. When an actor receives a message, it can:

  1. Send a finite number of messages to other actors.
  2. Create a finite number of new actors.
  3. Designate the behavior to be used for the next message it receives (which can be the same behavior).

Concurrency is managed by the actor system, allowing many actors to execute concurrently without explicit lock management by the developer for actor state. This model inherently promotes loose coupling, as actors do not share state and interact only through messages.

Motivation for this Package

In large, long-lived systems like lnd, managing complexity, concurrency, and component lifecycles becomes increasingly challenging. This actor package is introduced to address several key motivations:

Structured Message Passing

To move away from direct, synchronous method calls between major components, especially where concurrency or complex state interactions are involved. Message passing encourages clearer, more auditable interactions and helps manage concurrent access to component state.

Eliminating "God Structs"

Over time, systems can develop large "god structs" that hold references to numerous sub-systems. This leads to tight coupling, makes dependency management difficult, and can obscure the flow of control and data. Actors, by encapsulating state and behavior and interacting via messages, help break down these monolithic structures into more manageable, independent units.

Decoupled Lifecycles

Often, the lifecycle of a sub-system is unnecessarily tied to a parent system, or access to a sub-system requires traversing through a central "manager" object. Actors can have independent lifecycles managed by an actor system, allowing for more granular control over starting, stopping, and restarting components.

An example of such interaction is when an RPC call needs to go through several other structs to obtain a reference to a given sub-system, in order to make a direct method call on that sub-system.

With the model described in this document, the RPC server just needs to know about what is effectively an abstract address of that sub-system. It can then use that to obtain something similar to a mailbox to do the method call.

This allows for a more decoupled architecture, as the RPC server doesn't need to know the exact "shape" of the method to call, just which message to send. Refactors of the sub-system won't break the RPC server, as long as the message (which can be constructed via a dedicated constructor) is the same.


This package provides a foundational actor framework tailored for Go, enabling developers to build components that are easier to reason about, test, and maintain in a concurrent environment.

Core Concepts

Let's explore the fundamental building blocks provided by this package.

Messages

Actors communicate by sending and receiving messages. Any type that an actor needs to process must implement the actor.Message interface. A simple way to do this is by embedding actor.BaseMessage:

package mymodule

import "github.com/lightningnetwork/lnd/actor"

// MyRequest is a custom message type.
type MyRequest struct {
    // Embed BaseMessage to satisfy the Message interface.
    actor.BaseMessage 
    Data string
}

// MessageType returns a string identifier for this message type.
func (m *MyRequest) MessageType() string {
    return "MyRequest"
}

// MyResponse might be a corresponding response type.
type MyResponse struct {
    actor.BaseMessage
    Reply string
}

func (m *MyResponse) MessageType() string {
    return "MyResponse"
}

The MessageType() method provides a string representation of the message type, which can be useful for debugging or routing.

Actor Behavior

The logic of an actor (how it responds to messages) is defined by its ActorBehavior. This is an interface that you implement:

package actor

// ActorBehavior defines the logic for how an actor processes incoming messages.
type ActorBehavior[M Message, R any] interface {
    Receive(actorCtx context.Context, msg M) fn.Result[R]
}

The Receive method passes in a caller context (useful for shutdown detection) and the incoming message. It returns an fn.Result[R], which can encapsulate either a successful response of type R or an error.

For simple cases, you can use actor.FunctionBehavior to adapt a Go function into an ActorBehavior:

import (
    "context"
    "fmt"
    "github.com/lightningnetwork/lnd/actor"
    "github.com/lightningnetwork/lnd/fn/v2"
)

// myActorLogic defines the processing for MyRequest messages.
func myActorLogic(ctx context.Context, msg *MyRequest) fn.Result[*MyResponse] {
    // In a real actor, you might interact with state or other services.
    // The actor's context (ctx) can be checked for shutdown signals.
    select {
    case <-ctx.Done():
        return fn.Err[*MyResponse](errors.New("actor shutting down"))
    default:
    }

    response := &MyResponse{Reply: fmt.Sprintf("Processed: %s", msg.Data)}
    return fn.Ok(response)
}

// Create a behavior from the function.
behavior := actor.NewFunctionBehavior(myActorLogic)

For more complex cases, you can implement the Receive method on a new struct, and pass that around directly.

Service Keys and Actor References: The Interaction Layer

Direct interaction with an actor's internal state or its concrete struct is discouraged. Instead, communication and discovery are managed through two key abstractions: ServiceKey and ActorRef. These provide a layer of indirection, promoting loose coupling and location transparency (though the current implementation is in-process).

ServiceKey[M Message, R any]

A ServiceKey is a type-safe identifier used for registering actors that provide a particular service and for discovering them later. The generic type parameters M (the type of message the actor handles) and R (the type of response the actor produces for Ask operations) ensure that you discover actors compatible with the interactions you intend to perform.

// Define a service key for actors that handle MyRequest and produce MyResponse.
myServiceKey := actor.NewServiceKey[*MyRequest, *MyResponse]("my-custom-service")

// Later, this key would be used with a Receptionist (part of an ActorSystem)
// to find ActorRefs for actors offering this service.

ActorRef[M Message, R any]

An ActorRef is a lightweight, shareable reference to an actor. It's the primary means by which you send messages to an actor. It is also generic over the message type M and response type R that the target actor handles.

You typically obtain an ActorRef by looking it up in a Receptionist using a ServiceKey (covered later when discussing the ActorSystem), or directly from an actor instance via its .Ref() method (e.g., sampleActor.Ref() if you have the Actor instance).

There are two main ways to send messages using an ActorRef:

  1. Tell (Fire-and-Forget): Used for sending messages when you don't need a direct reply. The call returns immediately after attempting to enqueue the message.

    // Assuming 'actorRef' is an ActorRef[*MyRequest, *MyResponse] obtained for an actor.
    requestMsg := &MyRequest{Data: "A fire-and-forget message"}
    actorRef.Tell(context.Background(), requestMsg)
    // The message is now in the actor's mailbox (or will be shortly).

    The context.Context passed to Tell can be used to cancel the send operation if, for example, the actor's mailbox is full and the send would block for too long.

  2. Ask (Request-Response): Used when you need a response from the actor. This returns a Future[R], which represents the eventual reply.

    // Assuming 'actorRef' is an ActorRef[*MyRequest, *MyResponse].
    askMsg := &MyRequest{Data: "A request needing a response"}
    futureResponse := actorRef.Ask(context.Background(), askMsg)

    A Future[R] represents a result that will be available at some point. You can block until it's ready using Await:

    // Await the result. It's good practice to use a context with a timeout.
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    result := futureResponse.Await(ctx)
    response, err := result.Unpack() 
    if err != nil {
        fmt.Printf("Ask failed: %v\n", err)
        // return or handle error
    } else {
        fmt.Printf("Received reply: %s\n", response.Reply)
    }

    The Future interface also offers non-blocking ways to handle results, like OnComplete (for callbacks) and ThenApply (for chaining transformations). A more restricted TellOnlyRef[M] is also available if only fire-and-forget semantics are required (obtained via an actor's TellRef() method).

Actors

An Actor is the concrete entity that runs a behavior, manages a mailbox, and has a lifecycle. You create an actor using actor.NewActor with an ActorConfig:

cfg := actor.ActorConfig[*MyRequest, *MyResponse]{
    ID:          "my-sample-actor",
    Behavior:    behavior,
    MailboxSize: 10,
    // Dead Letter Office (covered later)
    DLO:         nil,
}
sampleActor, err := actor.NewActor(cfg)
if err != nil {
    // Handle invalid config (empty ID, nil behavior).
    return err
}

An actor doesn't start processing messages until its Start() method is called. This launches a dedicated goroutine for the actor.

sampleActor.Start()

To stop an actor, you call its Stop() method. This cancels the actor's internal context, causing its goroutine to clean up and exit.

// Sometime later...
sampleActor.Stop()

Visualizing Actor Relationships

The following diagram illustrates the primary components of the actor package and their relationships. It provides a high-level overview of how actors are managed, discovered, and interacted with.

classDiagram
    direction TB
    
    class ActorSystem {
      +Receptionist
      +DeadLetters
      +Shutdown()
    }
    
    class Receptionist {
      +Find(ServiceKey) ActorRef[]
      +Register(ServiceKey, ActorRef)
    }
    
    class DeadLetterOffice {
      +Receive(undeliverable Message)
    }
    
    class ServiceKey {
      +Spawn(ActorSystem, Behavior) ActorRef
    }
    
    class Actor {
      -mailbox
      -behavior
      +Ref() ActorRef
      +Start()
      +Stop()
    }
    
    class ActorRef {
      <<Interface>>
      +Tell(Message)
      +Ask(Message) Future
    }
    
    class Message {
      <<Interface>>
    }
    
    class Future {
      +Await() Result
    }
    
    class Router {
      +Tell(Message)
      +Ask(Message) Future
    }
    
    %% Core system relationships
    ActorSystem *-- Receptionist : has
    ActorSystem *-- DeadLetterOffice : provides
    ActorSystem o-- "manages" Actor
    
    %% Actor and communication
    Actor --> ActorRef : provides
    Actor ..> Message : processes
    ActorRef ..> Message : sends
    ActorRef ..> Future : returns for Ask
    
    %% Service discovery and routing
    Receptionist o-- ServiceKey : uses for lookup
    ServiceKey ..> Actor : creates
    Router --> ActorRef : routes to
    Router --> Receptionist : discovers actors via
    
    note for ActorSystem "Central manager for actor lifecycle and service discovery"
    note for Actor "Independent unit with encapsulated state and behavior"
    note for ActorRef "Location-transparent handle for sending messages"
    note for Message "Data exchanged between actors"
    note for ServiceKey "Type-safe identifier for actor registration and discovery"
    note for Router "Distributes messages among multiple actors"
    note for DeadLetterOffice "Handles messages that cannot be delivered"
Loading

The Actor System

While individual actors are useful, they often need to be managed and coordinated. The ActorSystem serves this purpose.

system := actor.NewActorSystem()
// Ensures all actors in the system are stopped.
defer system.Shutdown()

Actor Lifecycle and Registration

The ActorSystem can manage the lifecycle of actors. You can register actors with the system:

// Using 'behavior' from earlier and 'myServiceKey' defined in the 
// "Service Keys and Actor References" section.

// RegisterWithSystem creates, starts, and registers the actor.
actorRefFromSystem := actor.RegisterWithSystem(
    system, "system-managed-actor", myServiceKey, behavior,
)

Alternatively, a ServiceKey itself provides a Spawn method for convenience:

actorRefSpawned := myServiceKey.Spawn(system, "spawned-actor", behavior)

Actors registered with the system are automatically stopped when system.Shutdown() is called. You can also stop and remove individual actors using system.StopAndRemoveActor(actorID).

A ServiceKey is essentially the mailbox address of an actor.

Receptionist: Service Discovery

Actors often need to find other actors to communicate with. The Receptionist facilitates this. Actors are registered with the receptionist using a ServiceKey, which is type-safe.

// Get the system's receptionist.
receptionist := system.Receptionist()

// Find actors registered for a specific service key.
foundRefs := actor.FindInReceptionist(receptionist, myServiceKey)
if len(foundRefs) > 0 {
    targetActor := foundRefs[0]
    targetActor.Tell(context.Background(), &MyRequest{Data: "Hello from a discoverer!"})
} else {
    fmt.Println("No actors found for service key:", myServiceKey)
}

When an actor is stopped (e.g., via ServiceKey.Unregister or system shutdown), it should also be unregistered from the receptionist.

Dead Letter Office (DLO)

What happens to messages that cannot be delivered? For example, if an actor is stopped while messages are still in its mailbox, or if a message is sent to an actor that doesn't exist (though the current ActorRef design makes the latter less likely for direct sends).

The ActorSystem provides a default DeadLetterActor. When an actor is configured (via ActorConfig.DLO), undeliverable messages (e.g., those drained from its mailbox upon shutdown) can be routed to this DLO. This allows for logging, auditing, or potential manual intervention for "lost" messages.

// Actors created via RegisterWithSystem or ServiceKey.Spawn
// are automatically configured to use the system's DLO.
// system.DeadLetters() returns an ActorRef to the system's DLO.

Routers: Distributing Work

Sometimes, you might have multiple actors performing the same kind of task, and you want to distribute messages among them. A Router can do this. It's not an actor itself but acts as a dispatcher.

A Router uses a RoutingStrategy to pick one actor from a group registered under a ServiceKey.

// Assume 'system' and 'myServiceKey' are set up, and multiple actors
// are registered with 'myServiceKey'.

// Create a round-robin routing strategy.
roundRobinStrategy := actor.NewRoundRobinStrategy[*MyRequest, *MyResponse]()

// Create a router for 'myServiceKey' using this strategy.
// Messages sent to this router will be forwarded to one of the actors
// registered under 'myServiceKey'.
// The router also needs a DLO for messages it can't route (e.g., if no actors are available).
serviceRouter := actor.NewRouter(
    system.Receptionist(),
    myServiceKey,
    roundRobinStrategy,
    system.DeadLetters(),
)

// Now, interact with the router as if it were an ActorRef:
serviceRouter.Tell(context.Background(), &MyRequest{Data: "Message via router"})

futureReplyFromRouter := serviceRouter.Ask(context.Background(), &MyRequest{Data: "Ask via router"})
// ... await futureReplyFromRouter ...

If the router cannot find any available actors for the ServiceKey (e.g., none are registered or running), Tell operations will typically send the message to the router's configured DLO, and Ask operations will return a Future completed with ErrNoActorsAvailable.