From 897eb4aa074425e47256e75c7b26af19b8274dfb Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 12:49:19 +0100 Subject: [PATCH 1/8] baselib: add actor system This commit adds the actor system based on the actor branch in lnd, hash dc5d57f28adda2c894c094f37e29f79d52a99ed0 --- baselib/actor/README.md | 475 +++++++++++ baselib/actor/actor.go | 280 ++++++ baselib/actor/actor_test.go | 395 +++++++++ baselib/actor/example_basic_actor_test.go | 97 +++ baselib/actor/example_router_test.go | 112 +++ baselib/actor/example_struct_actor_test.go | 147 ++++ baselib/actor/example_tell_only_test.go | 133 +++ baselib/actor/func_actor.go | 45 + baselib/actor/future.go | 152 ++++ baselib/actor/future_test.go | 449 ++++++++++ baselib/actor/interface.go | 118 +++ baselib/actor/router.go | 143 ++++ baselib/actor/system.go | 390 +++++++++ baselib/actor/system_test.go | 942 +++++++++++++++++++++ baselib/go.mod | 17 + baselib/go.sum | 18 + 16 files changed, 3913 insertions(+) create mode 100644 baselib/actor/README.md create mode 100644 baselib/actor/actor.go create mode 100644 baselib/actor/actor_test.go create mode 100644 baselib/actor/example_basic_actor_test.go create mode 100644 baselib/actor/example_router_test.go create mode 100644 baselib/actor/example_struct_actor_test.go create mode 100644 baselib/actor/example_tell_only_test.go create mode 100644 baselib/actor/func_actor.go create mode 100644 baselib/actor/future.go create mode 100644 baselib/actor/future_test.go create mode 100644 baselib/actor/interface.go create mode 100644 baselib/actor/router.go create mode 100644 baselib/actor/system.go create mode 100644 baselib/actor/system_test.go create mode 100644 baselib/go.mod create mode 100644 baselib/go.sum diff --git a/baselib/actor/README.md b/baselib/actor/README.md new file mode 100644 index 000000000..5d968ebe6 --- /dev/null +++ b/baselib/actor/README.md @@ -0,0 +1,475 @@ +# 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 +This model inherently promotes loose coupling, as actors do not share state +execute concurrently without explicit lock management by the developer for actor +state. + +## 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`: + +```go +package mymodule + +import "github.com/lightninglabs/darepo-client/baselib/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: + +```go +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`: + +```go +import ( + "context" + "fmt" + "github.com/lightninglabs/darepo-client/baselib/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. + +```go +// 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. + + ```go + // 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. + + ```go + // 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`: + + ```go + // 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`: + +```go +cfg := actor.ActorConfig[*MyRequest, *MyResponse]{ + ID: "my-sample-actor", + Behavior: behavior, + MailboxSize: 10, + // Dead Letter Office (covered later) + DLO: nil, +} +sampleActor := actor.NewActor(cfg) +``` + +An actor doesn't start processing messages until its `Start()` method is called. +This launches a dedicated goroutine for the actor. + +```go +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. + +```go +// 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. + +```mermaid +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 { + <> + +Tell(Message) + +Ask(Message) Future + } + + class Message { + <> + } + + 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" +``` + +## The Actor System + +While individual actors are useful, they often need to be managed and +coordinated. The `ActorSystem` serves this purpose. + +```go +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: + +```go +// 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: +```go +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. + +```go +// 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. + +```go +// 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`. + +```go +// 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`. diff --git a/baselib/actor/actor.go b/baselib/actor/actor.go new file mode 100644 index 000000000..5776eaf76 --- /dev/null +++ b/baselib/actor/actor.go @@ -0,0 +1,280 @@ +package actor + +import ( + "context" + "sync" + + "github.com/lightningnetwork/lnd/fn/v2" +) + +// ActorConfig holds the configuration parameters for creating a new Actor. +// It is generic over M (Message type) and R (Response type) to accommodate +// the actor's specific behavior. +type ActorConfig[M Message, R any] struct { + // ID is the unique identifier for the actor. + ID string + + // Behavior defines how the actor responds to messages. + Behavior ActorBehavior[M, R] + + // DLO is a reference to the dead letter office for this actor system. + // If nil, undeliverable messages during shutdown or due to a full + // mailbox (if such logic were added) might be dropped. + DLO ActorRef[Message, any] + + // MailboxSize defines the buffer capacity of the actor's mailbox. + MailboxSize int +} + +// envelope wraps a message with its associated promise. This allows the sender +// of an "ask" message to await a response. If the promise is nil, it +// signifies a "tell" operation (fire-and-forget). +type envelope[M Message, R any] struct { + message M + promise Promise[R] +} + +// Actor represents a concrete actor implementation. It encapsulates a behavior, +// manages its internal state implicitly through that behavior, and processes +// messages from its mailbox sequentially in its own goroutine. +type Actor[M Message, R any] struct { + // id is the unique identifier for the actor. + id string + + // behavior defines how the actor responds to messages. + behavior ActorBehavior[M, R] + + // mailbox is the incoming message queue for the actor. + mailbox chan envelope[M, R] + + // ctx is the context governing the actor's lifecycle. + ctx context.Context + + // cancel is the function to cancel the actor's context. + cancel context.CancelFunc + + // dlo is a reference to the dead letter office for this actor system. + dlo ActorRef[Message, any] + + // startOnce ensures the actor's processing loop is started only once. + startOnce sync.Once + + // stopOnce ensures the actor's processing loop is stopped only once. + stopOnce sync.Once + + // ref is the cached ActorRef for this actor. + ref ActorRef[M, R] +} + +// NewActor creates a new actor instance with the given ID and behavior. +// It initializes the actor's internal structures but does not start its +// message processing goroutine. The Start() method must be called to begin +// processing messages. +func NewActor[M Message, R any](cfg ActorConfig[M, R]) *Actor[M, R] { + ctx, cancel := context.WithCancel(context.Background()) + + // Ensure MailboxSize has a sane default if not specified or zero. A + // capacity of 0 would make the channel unbuffered, which is generally + // not desired for actor mailboxes. + mailboxCapacity := cfg.MailboxSize + if mailboxCapacity <= 0 { + // Default to a small capacity if an invalid one is given. This + // could also come from a global constant. + mailboxCapacity = 1 + } + + actor := &Actor[M, R]{ + id: cfg.ID, + behavior: cfg.Behavior, + mailbox: make(chan envelope[M, R], mailboxCapacity), + ctx: ctx, + cancel: cancel, + dlo: cfg.DLO, + } + + // Create and cache the actor's own reference. + actor.ref = &actorRefImpl[M, R]{ + actor: actor, + } + + return actor +} + +// Start initiates the actor's message processing loop in a new goroutine. This +// method should be called once after the actor is created. +func (a *Actor[M, R]) Start() { + a.startOnce.Do(func() { + go a.process() + }) +} + +// process is the main event loop for the actor. It continuously monitors its +// mailbox for incoming messages and its context for cancellation signals. +func (a *Actor[M, R]) process() { + for { + select { + case env := <-a.mailbox: + result := a.behavior.Receive(a.ctx, env.message) + + // If a promise was provided (i.e., it was an "ask" + // operation), complete the promise with the result from + // the behavior. + if env.promise != nil { + env.promise.Complete(result) + } + + // The actor's context has been cancelled, signaling a stop + // request. Exit the processing loop to terminate the actor's + // goroutine. Before exiting, drain any remaining messages from + // the mailbox. + case <-a.ctx.Done(): + // Close the mailbox to prevent new incoming messages + // and to allow the range operator below to terminate. + close(a.mailbox) + + // Drain any remaining messages. + for env := range a.mailbox { + // If a DLO is configured, send the original + // message there for auditing or potential + // manual reprocessing. + if a.dlo != nil { + a.dlo.Tell( + context.Background(), + env.message, + ) + } + + // If it was an Ask, complete the promise with + // an error indicating the actor terminated. + if env.promise != nil { + env.promise.Complete(fn.Err[R]( + ErrActorTerminated), + ) + } + } + + return + } + } +} + +// Stop signals the actor to terminate its processing loop and shut down. +// This is achieved by cancelling the actor's internal context. The actor's +// goroutine will exit once it detects the context cancellation. +func (a *Actor[M, R]) Stop() { + a.stopOnce.Do(func() { + a.cancel() + }) +} + +// actorRefImpl provides a concrete implementation of the ActorRef interface. It +// holds a reference to the target Actor instance, enabling message sending. +type actorRefImpl[M Message, R any] struct { + actor *Actor[M, R] +} + +// Tell sends a message without waiting for a response. If the context is +// cancelled before the message can be sent to the actor's mailbox, the message +// may be dropped. +// +//nolint:lll +func (ref *actorRefImpl[M, R]) Tell(ctx context.Context, msg M) { + // If the actor's own context is already done, don't try to send. + // Route to DLO if available. + if ref.actor.ctx.Err() != nil { + ref.trySendToDLO(msg) + return + } + + select { + // Message successfully enqueued in the actor's mailbox. + case ref.actor.mailbox <- envelope[M, R]{message: msg, promise: nil}: + + // The context for the Tell operation was cancelled before the message + // could be enqueued. The message is dropped. + case <-ctx.Done(): + + // The actor itself has been stopped/terminated. + case <-ref.actor.ctx.Done(): + // If the actor is terminated and has a DLO, send the message + // there. Otherwise, it's dropped. + ref.trySendToDLO(msg) + } +} + +// Ask sends a message and returns a Future for the response. The Future will be +// completed with the actor's reply or an error if the operation fails (e.g., +// context cancellation before send). +// +//nolint:lll +func (ref *actorRefImpl[M, R]) Ask(ctx context.Context, msg M) Future[R] { + // Create a new promise that will be fulfilled with the actor's response. + promise := NewPromise[R]() + + // If the actor's own context is already done, complete the promise with + // ErrActorTerminated and return immediately. This is the primary guard + // against trying to send to a stopped actor. + if ref.actor.ctx.Err() != nil { + promise.Complete(fn.Err[R](ErrActorTerminated)) + return promise.Future() + } + + // Check if the context is already done before attempting to send. This + // ensures deterministic behavior and prevents a race where the message + // could be enqueued even though the context was already cancelled. + if ctx.Err() != nil { + promise.Complete(fn.Err[R](ctx.Err())) + return promise.Future() + } + + select { + // Attempt to send the message along with its promise to the actor's + // mailbox. + case ref.actor.mailbox <- envelope[M, R]{message: msg, promise: promise}: + + // The context for the Ask operation was cancelled before the message + // could be enqueued. Complete the promise with the context's error to + // unblock the caller. + case <-ctx.Done(): + promise.Complete(fn.Err[R](ctx.Err())) + + // The actor's context was cancelled (e.g., actor stopped) while this + // Ask operation was attempting to send (e.g., mailbox was full). + case <-ref.actor.ctx.Done(): + promise.Complete(fn.Err[R](ErrActorTerminated)) + } + + // Return the future associated with the promise, allowing the caller to + // await the response. + return promise.Future() +} + +// trySendToDLO attempts to send the message to the actor's DLO if configured. +func (ref *actorRefImpl[M, R]) trySendToDLO(msg M) { + if ref.actor.dlo != nil { + // Use context.Background() for sending to DLO as the + // original context might be done or the operation + // should not be bound by it. + // This Tell to DLO is fire-and-forget. + ref.actor.dlo.Tell(context.Background(), msg) + } +} + +// ID returns the unique identifier for this actor. +func (ref *actorRefImpl[M, R]) ID() string { + return ref.actor.id +} + +// Ref returns an ActorRef for this actor. This allows clients to interact with +// the actor (send messages) without having direct access to the Actor struct +// itself, promoting encapsulation and location transparency. +func (a *Actor[M, R]) Ref() ActorRef[M, R] { + return a.ref +} + +// TellRef returns a TellOnlyRef for this actor. This allows clients to send +// messages to the actor using only the "tell" pattern (fire-and-forget), +// without having access to "ask" capabilities. +func (a *Actor[M, R]) TellRef() TellOnlyRef[M] { + return a.ref +} diff --git a/baselib/actor/actor_test.go b/baselib/actor/actor_test.go new file mode 100644 index 000000000..d39fb028c --- /dev/null +++ b/baselib/actor/actor_test.go @@ -0,0 +1,395 @@ +package actor + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// testMsg is a simple message type for testing. It embeds BaseMessage to +// satisfy the actor.Message interface. +type testMsg struct { + BaseMessage + data string + + replyChan chan string +} + +// MessageType returns the type name of the message. +func (m *testMsg) MessageType() string { + return "testMsg" +} + +// newTestMsg creates a new test message. +func newTestMsg(data string) *testMsg { + return &testMsg{data: data} +} + +// newTestMsgWithReply creates a new test message that includes a reply channel. +// This can be used by test behaviors to send data back to the test +// synchronously, especially for Tell operations. +func newTestMsgWithReply(data string, replyChan chan string) *testMsg { + return &testMsg{data: data, replyChan: replyChan} +} + +// echoBehavior is a simple actor behavior that processes *testMsg messages. It +// stores the last message's data and, for Ask, echoes it back. For Tell, if +// replyChan is set in testMsg, it sends data back on it. +type echoBehavior struct { + lastMsgData atomic.Value + processingDelay time.Duration + t *testing.T +} + +// newEchoBehavior creates a new echoBehavior. +func newEchoBehavior(t *testing.T, delay time.Duration) *echoBehavior { + return &echoBehavior{t: t, processingDelay: delay} +} + +// Receive handles incoming messages. It simulates work if processingDelay is +// set, stores the message data, and responds for Ask operations or via +// replyChan for Tell. +func (b *echoBehavior) Receive(_ context.Context, + msg *testMsg) fn.Result[string] { + + if b.processingDelay > 0 { + time.Sleep(b.processingDelay) + } + + b.lastMsgData.Store(msg.data) + + if msg.replyChan != nil { + // Attempt to send the data on the reply channel, but quit if + // it takes longer than 1 second (e.g., channel unbuffered + // and no receiver). + select { + case msg.replyChan <- msg.data: + case <-time.After(time.Second): + b.t.Logf("warning: replyChan send timed out") + } + } + + return fn.Ok(fmt.Sprintf("echo: %s", msg.data)) +} + +// GetLastMsgData retrieves the data from the last message processed. +func (b *echoBehavior) GetLastMsgData() (string, bool) { + val := b.lastMsgData.Load() + if val == nil { + return "", false + } + data, ok := val.(string) + return data, ok +} + +// errorBehavior is an actor behavior that always returns a predefined error +// upon receiving a message. +type errorBehavior struct { + err error +} + +// newErrorBehavior creates a new errorBehavior. +func newErrorBehavior(err error) *errorBehavior { + return &errorBehavior{err: err} +} + +// Receive always returns the configured error. +func (b *errorBehavior) Receive(_ context.Context, + _ *testMsg) fn.Result[string] { + + return fn.Err[string](b.err) +} + +// blockingBehavior is an actor behavior that blocks until its actorCtx is done. +type blockingBehavior struct{} + +// Receive blocks until the actor's context is cancelled, then returns the +// context's error. +func (b *blockingBehavior) Receive(actorCtx context.Context, + _ *testMsg) fn.Result[string] { + + <-actorCtx.Done() + return fn.Err[string](actorCtx.Err()) +} + +// deadLetterTestMsg is a distinct message type used for testing DLO +// interactions. +type deadLetterTestMsg struct { + BaseMessage + id string +} + +// MessageType returns the type name of the message. +func (m *deadLetterTestMsg) MessageType() string { + return "deadLetterTestMsg" +} + +// deadLetterObserverBehavior is a behavior for a test Dead Letter Office actor. +// It records all messages sent to it, allowing tests to verify DLO +// interactions. +type deadLetterObserverBehavior struct { + mu sync.Mutex + receivedMsgs []Message +} + +// newDeadLetterObserverBehavior creates a new deadLetterObserverBehavior. +func newDeadLetterObserverBehavior() *deadLetterObserverBehavior { + return &deadLetterObserverBehavior{ + receivedMsgs: make([]Message, 0), + } +} + +// Receive records the incoming message and returns a successful result. +func (b *deadLetterObserverBehavior) Receive(_ context.Context, + msg Message) fn.Result[any] { + + b.mu.Lock() + b.receivedMsgs = append(b.receivedMsgs, msg) + b.mu.Unlock() + + return fn.Ok[any](nil) +} + +// GetReceivedMsgs returns a copy of all messages received by this DLO. +func (b *deadLetterObserverBehavior) GetReceivedMsgs() []Message { + b.mu.Lock() + defer b.mu.Unlock() + + msgs := make([]Message, len(b.receivedMsgs)) + copy(msgs, b.receivedMsgs) + + return msgs +} + +// actorTestHarness provides helper methods for setting up actors in tests. It +// manages a dedicated DLO for actors created through it. +type actorTestHarness struct { + t *testing.T + dlo *Actor[Message, any] + dloBeh *deadLetterObserverBehavior +} + +// newActorTestHarness sets up a test harness with a dedicated DLO. The DLO is +// automatically stopped when the test cleans up. +func newActorTestHarness(t *testing.T) *actorTestHarness { + t.Helper() + + dloBeh := newDeadLetterObserverBehavior() + dloCfg := ActorConfig[Message, any]{ + ID: "test-dlo-" + t.Name(), + Behavior: dloBeh, + DLO: nil, + MailboxSize: 10, + } + dloActor := NewActor[Message, any](dloCfg) + dloActor.Start() + + t.Cleanup(dloActor.Stop) + + return &actorTestHarness{ + t: t, + dlo: dloActor, + dloBeh: dloBeh, + } +} + +// newActor creates, starts, and registers a new actor for cleanup. The actor +// will use the harness's DLO. +func (h *actorTestHarness) newActor(id string, + beh ActorBehavior[*testMsg, string], + mailboxSize int) *Actor[*testMsg, string] { + + h.t.Helper() + + cfg := ActorConfig[*testMsg, string]{ + ID: id, + Behavior: beh, + DLO: h.dlo.Ref(), + MailboxSize: mailboxSize, + } + actor := NewActor(cfg) + actor.Start() + + h.t.Cleanup(actor.Stop) + + return actor +} + +// assertDLOMessage checks that the DLO eventually receives a specific message. +func (h *actorTestHarness) assertDLOMessage(expectedMsg Message) { + h.t.Helper() + require.Eventually(h.t, func() bool { + msgs := h.dloBeh.GetReceivedMsgs() + for _, m := range msgs { + if reflect.DeepEqual(m, expectedMsg) { + return true + } + } + return false + }, time.Second, 10*time.Millisecond, + "dLO did not receive expected message: %v", expectedMsg, + ) +} + +// assertNoDLOMessages checks that the DLO has not received any messages. +func (h *actorTestHarness) assertNoDLOMessages() { + h.t.Helper() + + // Allow a very brief moment for any async DLO sends to occur. + time.Sleep(20 * time.Millisecond) + + msgs := h.dloBeh.GetReceivedMsgs() + + require.Empty(h.t, msgs, "dLO received unexpected messages") +} + +// TestActorNewActorIDAndRefs verifies that NewActor correctly initializes an +// actor's ID and provides functional ActorRef and TellOnlyRef instances. +func TestActorNewActorIDAndRefs(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + actorID := "test-actor-1" + beh := newEchoBehavior(t, 0) + actor := h.newActor(actorID, beh, 1) + + require.Equal(t, actorID, actor.Ref().ID(), "actorRef ID mismatch") + require.Equal( + t, actorID, actor.TellRef().ID(), "tellOnlyRef ID mismatch", + ) + require.NotNil(t, actor.Ref(), "actorRef should not be nil") + require.NotNil(t, actor.TellRef(), "tellOnlyRef should not be nil") +} + +// TestActorStartStop verifies the basic lifecycle of an actor: starting, +// processing messages, and stopping. +func TestActorStartStop(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + beh := newEchoBehavior(t, 0) + actor := h.newActor("test-actor-lifecycle", beh, 1) + + // Actor should be running and process a message. + msgData := "hello" + replyChan := make(chan string, 1) + actor.Ref().Tell( + context.Background(), newTestMsgWithReply(msgData, replyChan), + ) + + received, err := fn.RecvOrTimeout(replyChan, 100*time.Millisecond) + if err != nil { + t.Fatal("timed out waiting for actor to process message") + } + require.Equal( + t, msgData, received, "actor did not process message before stop", + ) + + actor.Stop() + time.Sleep(50 * time.Millisecond) + + // Try sending another message; it should ideally not be processed or go + // to DLO. + msgDataAfterStop := "message-after-stop" + replyChanAfterStop := make(chan string, 1) + actor.Ref().Tell( + context.Background(), + newTestMsgWithReply(msgDataAfterStop, replyChanAfterStop), + ) + + // We expect a timeout here, meaning the message was not processed by + // the echoBehavior's replyChan. + _, err = fn.RecvOrTimeout(replyChanAfterStop, 100*time.Millisecond) + if err == nil { // err == nil means a message was received + t.Fatal("actor processed message after Stop()") + } + require.ErrorContains(t, err, "timeout hit") + + h.assertDLOMessage( + &testMsg{data: msgDataAfterStop, replyChan: replyChanAfterStop}, + ) +} + +// TestActorTellBasic verifies that a message sent via Tell is processed by the +// actor's behavior. +func TestActorTellBasic(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + beh := newEchoBehavior(t, 0) + actor := h.newActor("test-actor-tell", beh, 1) + + msgData := "tell-message" + replyChan := make(chan string, 1) + actor.Ref().Tell( + context.Background(), newTestMsgWithReply(msgData, replyChan), + ) + + receivedTell, errTell := fn.RecvOrTimeout(replyChan, 100*time.Millisecond) + if errTell != nil { + t.Fatal("timed out waiting for Tell message processing") + } + require.Equal( + t, msgData, receivedTell, "behavior did not receive Tell message data", + ) + + lastData, ok := beh.GetLastMsgData() + require.True(t, ok, "last message data not set in behavior") + require.Equal(t, msgData, lastData, "last message data mismatch") + h.assertNoDLOMessages() +} + +// TestActorAskSuccess verifies that a message sent via Ask is processed, and +// the returned Future is completed with the behavior's successful result. +func TestActorAskSuccess(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + beh := newEchoBehavior(t, 0) + actor := h.newActor("test-actor-ask-success", beh, 1) + + msgData := "ask-message" + future := actor.Ref().Ask(context.Background(), newTestMsg(msgData)) + + result := future.Await(context.Background()) + require.False(t, result.IsErr(), "ask returned an error: %v", result.Err()) + + result.WhenOk(func(val string) { + expectedReply := fmt.Sprintf("echo: %s", msgData) + require.Equal(t, expectedReply, val, "ask response mismatch") + }) + + lastData, ok := beh.GetLastMsgData() + require.True(t, ok, "last message data not set in behavior") + require.Equal(t, msgData, lastData, "last message data mismatch") + h.assertNoDLOMessages() +} + +// TestActorAskErrorBehavior verifies that if an actor's behavior returns an +// error, the Future from an Ask call is completed with that error. +func TestActorAskErrorBehavior(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + expectedErr := errors.New("behavior error") + beh := newErrorBehavior(expectedErr) + actor := h.newActor("test-actor-ask-error", beh, 1) + + future := actor.Ref().Ask( + context.Background(), newTestMsg("ask-error-test"), + ) + + result := future.Await(context.Background()) + require.True(t, result.IsErr(), "ask should have returned an error") + require.ErrorIs(t, result.Err(), expectedErr, "ask error mismatch") + + h.assertNoDLOMessages() +} diff --git a/baselib/actor/example_basic_actor_test.go b/baselib/actor/example_basic_actor_test.go new file mode 100644 index 000000000..ef79a666c --- /dev/null +++ b/baselib/actor/example_basic_actor_test.go @@ -0,0 +1,97 @@ +package actor_test + +import ( + "context" + "fmt" + "time" + + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// BasicGreetingMsg is a simple message type for the basic actor example. +type BasicGreetingMsg struct { + actor.BaseMessage + Name string +} + +// MessageType implements actor.Message. +func (m BasicGreetingMsg) MessageType() string { return "BasicGreetingMsg" } + +// BasicGreetingResponse is a simple response type. +type BasicGreetingResponse struct { + Greeting string +} + +// ExampleActor demonstrates creating a single actor, sending it a message +// directly using Ask, and then unregistering and stopping it. +func ExampleActor() { + system := actor.NewActorSystem() + defer system.Shutdown() + + //nolint:ll + greeterKey := actor.NewServiceKey[BasicGreetingMsg, BasicGreetingResponse]( + "basic-greeter", + ) + + actorID := "my-greeter" + greeterBehavior := actor.NewFunctionBehavior( + func(ctx context.Context, + msg BasicGreetingMsg) fn.Result[BasicGreetingResponse] { + + return fn.Ok(BasicGreetingResponse{ + Greeting: "Hello, " + msg.Name + " from " + + actorID, + }) + }, + ) + + // Spawn the actor. This registers it with the system and receptionist, + // and starts it. It returns an ActorRef. + greeterRef := greeterKey.Spawn(system, actorID, greeterBehavior) + fmt.Printf("Actor %s spawned.\n", greeterRef.ID()) + + // Send a message directly to the actor's reference. + askCtx, askCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + defer askCancel() + futureResponse := greeterRef.Ask( + askCtx, BasicGreetingMsg{Name: "World"}, + ) + + awaitCtx, awaitCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + defer awaitCancel() + result := futureResponse.Await(awaitCtx) + + result.WhenErr(func(err error) { + fmt.Printf("Error awaiting response: %v\n", err) + }) + result.WhenOk(func(response BasicGreetingResponse) { + fmt.Printf("Received: %s\n", response.Greeting) + }) + + // Unregister the actor. This also stops the actor. + unregistered := greeterKey.Unregister(system, greeterRef) + if unregistered { + fmt.Printf("Actor %s unregistered and stopped.\n", + greeterRef.ID()) + } else { + fmt.Printf("Failed to unregister actor %s.\n", greeterRef.ID()) + } + + // Verify it's no longer in the receptionist. + refsAfterUnregister := actor.FindInReceptionist( + system.Receptionist(), greeterKey, + ) + fmt.Printf("Actors for key '%s' after unregister: %d\n", + "basic-greeter", len(refsAfterUnregister)) + + // Output: + // Actor my-greeter spawned. + // Received: Hello, World from my-greeter + // Actor my-greeter unregistered and stopped. + // Actors for key 'basic-greeter' after unregister: 0 +} diff --git a/baselib/actor/example_router_test.go b/baselib/actor/example_router_test.go new file mode 100644 index 000000000..65b91cc44 --- /dev/null +++ b/baselib/actor/example_router_test.go @@ -0,0 +1,112 @@ +package actor_test + +import ( + "context" + "fmt" + "time" + + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// RouterGreetingMsg is a message type for the router example. +type RouterGreetingMsg struct { + actor.BaseMessage + Name string +} + +// MessageType implements actor.Message. +func (m RouterGreetingMsg) MessageType() string { return "RouterGreetingMsg" } + +// RouterGreetingResponse is a response type for the router example. +type RouterGreetingResponse struct { + Greeting string + HandlerID string +} + +// ExampleRouter demonstrates creating multiple actors under the same service +// key and using a router to dispatch messages to them. +func ExampleRouter() { + system := actor.NewActorSystem() + defer system.Shutdown() + + //nolint:ll + routerGreeterKey := actor.NewServiceKey[RouterGreetingMsg, RouterGreetingResponse]( + "router-greeter-service", + ) + + // Behavior for the first greeter actor. + actorID1 := "router-greeter-1" + greeterBehavior1 := actor.NewFunctionBehavior( + func(ctx context.Context, + msg RouterGreetingMsg) fn.Result[RouterGreetingResponse] { + + return fn.Ok(RouterGreetingResponse{ + Greeting: "Greetings, " + msg.Name + "!", + HandlerID: actorID1, + }) + }, + ) + routerGreeterKey.Spawn(system, actorID1, greeterBehavior1) + fmt.Printf("Actor %s spawned.\n", actorID1) + + // Behavior for the second greeter actor. + actorID2 := "router-greeter-2" + greeterBehavior2 := actor.NewFunctionBehavior( + func(ctx context.Context, + msg RouterGreetingMsg) fn.Result[RouterGreetingResponse] { + + return fn.Ok(RouterGreetingResponse{ + Greeting: "Salutations, " + msg.Name + "!", + HandlerID: actorID2, + }) + }, + ) + routerGreeterKey.Spawn(system, actorID2, greeterBehavior2) + fmt.Printf("Actor %s spawned.\n", actorID2) + + // Create a router for the "router-greeter-service". + greeterRouter := actor.NewRouter( + system.Receptionist(), routerGreeterKey, + actor.NewRoundRobinStrategy[RouterGreetingMsg, + RouterGreetingResponse](), + system.DeadLetters(), + ) + fmt.Printf("Router %s created for service key '%s'.\n", + greeterRouter.ID(), "router-greeter-service") + + // Send messages through the router. + names := []string{"Alice", "Bob", "Charlie", "David"} + for _, name := range names { + askCtx, askCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + futureResponse := greeterRouter.Ask( + askCtx, RouterGreetingMsg{Name: name}, + ) + + awaitCtx, awaitCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + result := futureResponse.Await(awaitCtx) + + result.WhenErr(func(err error) { + fmt.Printf("For %s: Error - %v\n", name, err) + }) + result.WhenOk(func(response RouterGreetingResponse) { + fmt.Printf("For %s: Received '%s' from %s\n", + name, response.Greeting, response.HandlerID) + }) + awaitCancel() + askCancel() + } + + // Output: + // Actor router-greeter-1 spawned. + // Actor router-greeter-2 spawned. + // Router router(router-greeter-service) created for service key 'router-greeter-service'. + // For Alice: Received 'Greetings, Alice!' from router-greeter-1 + // For Bob: Received 'Salutations, Bob!' from router-greeter-2 + // For Charlie: Received 'Greetings, Charlie!' from router-greeter-1 + // For David: Received 'Salutations, David!' from router-greeter-2 +} diff --git a/baselib/actor/example_struct_actor_test.go b/baselib/actor/example_struct_actor_test.go new file mode 100644 index 000000000..3beab9c89 --- /dev/null +++ b/baselib/actor/example_struct_actor_test.go @@ -0,0 +1,147 @@ +package actor_test + +import ( + "context" + "fmt" + "time" + + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// CounterMsg is a message type for the stateful counter actor. +// It can be used to increment the counter or get its current value. +type CounterMsg struct { + actor.BaseMessage + Increment int + GetValue bool + Who string +} + +// MessageType implements actor.Message. +func (m CounterMsg) MessageType() string { return "CounterMsg" } + +// CounterResponse is a response type for the counter actor. +type CounterResponse struct { + Value int + Responder string +} + +// StatefulCounterActor demonstrates an actor that maintains internal state (a +// counter) and processes messages to modify or query that state. +type StatefulCounterActor struct { + counter int + actorID string +} + +// NewStatefulCounterActor creates a new counter actor. +func NewStatefulCounterActor(id string) *StatefulCounterActor { + return &StatefulCounterActor{ + actorID: id, + } +} + +// Receive is the message handler for the StatefulCounterActor. +// It implements the actor.ActorBehavior interface implicitly when wrapped. +func (s *StatefulCounterActor) Receive(ctx context.Context, + msg CounterMsg) fn.Result[CounterResponse] { + + if msg.Increment > 0 { + // For increment, we can just acknowledge or return the new + // value. Messages are sent serially, so we don't need to worry + // about a mutex here. + s.counter += msg.Increment + + return fn.Ok(CounterResponse{ + Value: s.counter, + Responder: s.actorID, + }) + } + + if msg.GetValue { + return fn.Ok(CounterResponse{ + Value: s.counter, + Responder: s.actorID, + }) + } + + return fn.Err[CounterResponse](fmt.Errorf("invalid CounterMsg")) +} + +// ExampleActor_stateful demonstrates creating an actor whose behavior is defined +// by a struct with methods, allowing it to maintain internal state. +func ExampleActor_stateful() { + system := actor.NewActorSystem() + defer system.Shutdown() + + counterServiceKey := actor.NewServiceKey[CounterMsg, CounterResponse]( + "struct-counter-service", + ) + + // Create an instance of our stateful actor logic. + actorID := "counter-actor-1" + counterLogic := NewStatefulCounterActor(actorID) + + // Spawn the actor. + // The counterLogic instance itself satisfies the ActorBehavior + // interface because its Receive method matches the required signature. + counterRef := counterServiceKey.Spawn(system, actorID, counterLogic) + fmt.Printf("Actor %s spawned.\n", counterRef.ID()) + + // Send messages to increment the counter. + for i := 1; i <= 3; i++ { + askCtx, askCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + futureResp := counterRef.Ask(askCtx, + CounterMsg{ + Increment: i, + Who: fmt.Sprintf("Incrementer-%d", i), + }, + ) + awaitCtx, awaitCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + resp := futureResp.Await(awaitCtx) + + resp.WhenOk(func(r CounterResponse) { + fmt.Printf("Incremented by %d, new value: %d "+ + "(from %s)\n", i, r.Value, r.Responder) + }) + resp.WhenErr(func(e error) { + fmt.Printf("Error incrementing: %v\n", e) + }) + awaitCancel() + askCancel() + } + + // Send a message to get the current value. + askCtx, askCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + futureResp := counterRef.Ask( + askCtx, CounterMsg{GetValue: true, Who: "Getter"}, + ) + + awaitCtx, awaitCancel := context.WithTimeout( + context.Background(), 1*time.Second, + ) + + finalValueResp := futureResp.Await(awaitCtx) + finalValueResp.WhenOk(func(r CounterResponse) { + fmt.Printf("Final counter value: %d (from %s)\n", + r.Value, r.Responder) + }) + finalValueResp.WhenErr(func(e error) { + fmt.Printf("Error getting value: %v\n", e) + }) + awaitCancel() + askCancel() + + // Output: + // Actor counter-actor-1 spawned. + // Incremented by 1, new value: 1 (from counter-actor-1) + // Incremented by 2, new value: 3 (from counter-actor-1) + // Incremented by 3, new value: 6 (from counter-actor-1) + // Final counter value: 6 (from counter-actor-1) +} diff --git a/baselib/actor/example_tell_only_test.go b/baselib/actor/example_tell_only_test.go new file mode 100644 index 000000000..6650af29f --- /dev/null +++ b/baselib/actor/example_tell_only_test.go @@ -0,0 +1,133 @@ +package actor_test + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// LogMsg is a message type for the TellOnly example. +type LogMsg struct { + actor.BaseMessage + Text string +} + +// MessageType implements actor.Message. +func (m LogMsg) MessageType() string { return "LogMsg" } + +// LoggerActorBehavior is a simple actor behavior that logs messages. It doesn't +// produce a meaningful response for Ask, so it's a good candidate for TellOnly +// interactions. +type LoggerActorBehavior struct { + mu sync.Mutex + logs []string + actorID string +} + +func NewLoggerActorBehavior(id string) *LoggerActorBehavior { + return &LoggerActorBehavior{actorID: id} +} + +// Receive processes LogMsg messages by appending them to an internal log. The +// response type is 'any' as it's not typically used with Ask. +func (l *LoggerActorBehavior) Receive(ctx context.Context, + msg actor.Message) fn.Result[any] { + + logMessage, ok := msg.(LogMsg) + if !ok { + return fn.Err[any](fmt.Errorf("unexpected message "+ + "type: %s", msg.MessageType())) + } + + l.mu.Lock() + defer l.mu.Unlock() + + entry := fmt.Sprintf("[%s from %s]: %s", time.Now().Format("15:04:05"), + l.actorID, logMessage.Text) + l.logs = append(l.logs, entry) + + // For Tell, the result is often ignored, but we must return something. + return fn.Ok[any](nil) +} + +func (l *LoggerActorBehavior) GetLogs() []string { + l.mu.Lock() + defer l.mu.Unlock() + + copiedLogs := make([]string, len(l.logs)) + copy(copiedLogs, l.logs) + + return copiedLogs +} + +// ExampleTellOnlyRef demonstrates using a TellOnlyRef for fire-and-forget +// messaging with an actor. +func ExampleTellOnlyRef() { + system := actor.NewActorSystem() + defer system.Shutdown() + + // The logger actor doesn't really have a response type for Ask, so we + // use 'any'. + loggerServiceKey := actor.NewServiceKey[actor.Message, any]( + "tell-only-logger-service", + ) + + actorID := "my-logger" + loggerLogic := NewLoggerActorBehavior(actorID) + + // Spawn the actor. + fullRef := loggerServiceKey.Spawn(system, actorID, loggerLogic) + fmt.Printf("Actor %s spawned.\n", fullRef.ID()) + + // Get a TellOnlyRef for the actor. We can get this from the Actor + // instance itself if we had it, or by type assertion if we know the + // underlying ref supports it. Since fullRef is ActorRef[actor.Message, + // any], it already satisfies TellOnlyRef[actor.Message]. + // + // Or, if we had the *Actor instance: tellOnlyLogger = + // actorInstance.TellRef() + var tellOnlyLogger actor.TellOnlyRef[actor.Message] = fullRef + + fmt.Printf("Obtained TellOnlyRef for %s.\n", tellOnlyLogger.ID()) + + // Send messages using Tell. + tellOnlyLogger.Tell( + context.Background(), LogMsg{Text: "First log entry."}, + ) + tellOnlyLogger.Tell( + context.Background(), LogMsg{Text: "Second log entry."}, + ) + + // Allow some time for messages to be processed. + time.Sleep(10 * time.Millisecond) + + // Retrieve logs directly from the behavior for verification in this + // example. In a real scenario, this might not be possible or desired. + logs := loggerLogic.GetLogs() + fmt.Println("Logged entries:") + for _, entry := range logs { + // Strip the timestamp and actor ID for consistent example + // output. Example entry: "[15:04:05 from my-logger]: Actual log + // text" + parts := strings.SplitN(entry, "]: ", 2) + if len(parts) == 2 { + fmt.Println(parts[1]) + } + } + + // Attempting to Ask using tellOnlyLogger would be a compile-time error: + // tellOnlyLogger.Ask(context.Background(), LogMsg{Text: "This would + // fail"}) + + // Output: + // Actor my-logger spawned. + // Obtained TellOnlyRef for my-logger. + // Logged entries: + // First log entry. + // Second log entry. +} diff --git a/baselib/actor/func_actor.go b/baselib/actor/func_actor.go new file mode 100644 index 000000000..f1580f03f --- /dev/null +++ b/baselib/actor/func_actor.go @@ -0,0 +1,45 @@ +package actor + +import ( + "context" + + "github.com/lightningnetwork/lnd/fn/v2" +) + +// ActorFunc is a function type that represents an actor which functions purely +// based on a simple function processor. +type ActorFunc[M Message, R any] func(context.Context, M) fn.Result[R] + +// FunctionBehavior adapts a function to the ActorBehavior interface. +type FunctionBehavior[M Message, R any] struct { + fn ActorFunc[M, R] +} + +// NewFunctionBehavior creates a behavior from a function. +func NewFunctionBehavior[M Message, R any]( + fn ActorFunc[M, R]) *FunctionBehavior[M, R] { + + return &FunctionBehavior[M, R]{fn: fn} +} + +// Receive implements ActorBehavior interface for the function. +// +// TODO(roasbeef): just base it off the function direct instead? +func (b *FunctionBehavior[M, R]) Receive(ctx context.Context, + msg M) fn.Result[R] { + + return b.fn(ctx, msg) +} + +// FunctionBehaviorFromSimple adapts a simpler function to the ActorBehavior +// interface. +func FunctionBehaviorFromSimple[M Message, R any]( + sFunc func(M) (R, error)) *FunctionBehavior[M, R] { + + return NewFunctionBehavior( + func(ctx context.Context, msg M) fn.Result[R] { + val, err := sFunc(msg) + return fn.NewResult(val, err) + }, + ) +} diff --git a/baselib/actor/future.go b/baselib/actor/future.go new file mode 100644 index 000000000..a4db1c37d --- /dev/null +++ b/baselib/actor/future.go @@ -0,0 +1,152 @@ +package actor + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/lightningnetwork/lnd/fn/v2" +) + +// promiseImpl is a structure that can be used to complete a Future. It provides +// methods to set the result of an asynchronous operation and to obtain the +// Future interface for consumers. +// The promiseImpl itself is not typically exposed directly to consumers of the +// future's result; they interact with the Future interface. +type promiseImpl[T any] struct { + fut *futureImpl[T] +} + +// NewPromise creates a new Promise. The associated Future, which consumers can +// use to await the result, can be obtained via the Future() method. The Future +// is completed by calling the Complete() method on this Promise. +func NewPromise[T any]() Promise[T] { + return &promiseImpl[T]{ + fut: &futureImpl[T]{ + // done is a channel that will be closed when the future + // is completed. + done: make(chan struct{}), + }, + } +} + +// Future returns the Future interface associated with this Promise. Consumers +// can use this to Await the result or register callbacks. +func (p *promiseImpl[T]) Future() Future[T] { + return p.fut +} + +// Complete attempts to set the result of the future. It returns true if this +// call successfully set the result (i.e., it was the first to complete it), +// and false if the future had already been completed. This ensures that a +// future can only be completed once. The completion involves storing the result +// and signaling any goroutines waiting on the future's done channel. +func (p *promiseImpl[T]) Complete(result fn.Result[T]) bool { + var success bool + p.fut.completeOnce.Do(func() { + p.fut.resultCache.Store(&result) + close(p.fut.done) + success = true + }) + return success +} + +// futureImpl is the concrete implementation of the Future interface. It manages +// the state of an asynchronous computation's result. +type futureImpl[T any] struct { + // resultCache stores the fn.Result[T] after the future is completed. + // It's of type atomic.Pointer to allow lock-free reads after completion + // with improved type safety over atomic.Value. + resultCache atomic.Pointer[fn.Result[T]] + + // done is closed once the future is completed, signaling any waiting + // Await calls. + done chan struct{} + + // completeOnce ensures that the logic to set the result and close the + // done channel is executed only once. + completeOnce sync.Once +} + +// Await blocks until the result is available or the passed context is +// cancelled. If the future is already completed, it returns the result +// immediately. Otherwise, it waits for either the future's completion or the +// context's cancellation. +func (f *futureImpl[T]) Await(ctx context.Context) fn.Result[T] { + // First, try a non-blocking load from the cache. If the future is + // already completed, this will return the result directly. + if resPtr := f.resultCache.Load(); resPtr != nil { + return *resPtr + } + + // Wait for either the future to be done or the context to be cancelled. + select { + case <-f.done: + // The future has been completed. Load the result from the + // cache. It must be present now. Load and dereference. + // This load is safe because the 'done' channel is closed only + // after the resultCache is written (ensured by completeOnce). + resPtr := f.resultCache.Load() + + // resPtr should not be nil here as <-f.done was signaled. + return *resPtr + + case <-ctx.Done(): + // The waiting context was cancelled before the future completed. + return fn.Err[T](ctx.Err()) + } +} + +// ThenApply registers a function to transform the result of a future. The +// original future is not modified; a new Future instance representing the +// transformed result is returned. Once the original future completes +// successfully, the provided transformation function (fApply) is called with +// the result. The transformation is applied asynchronously in a new goroutine. +// If the passed context is cancelled while waiting for the +// original future to complete, the returned future will yield the context's +// error. +func (f *futureImpl[T]) ThenApply(ctx context.Context, fApply func(T) T) Future[T] { + // Create a new promise for the transformed result. + transformedPromise := NewPromise[T]() + + go func() { + // Await the original future's result, respecting the passed + // context for cancellation. + originalResult := f.Await(ctx) + + // If the original future completed with an error (or Await was + // cancelled by its context), complete the transformed future + // with the same error. + // This also handles the case where originalResult.Await(ctx) + // itself returned ctx.Err(). + if originalResult.IsErr() { + transformedPromise.Complete(originalResult) + return + } + + // Otherwise, the original future completed successfully. Apply the + // transformation function to its result. + originalResult.WhenOk(func(res T) { + newValue := fApply(res) + transformedPromise.Complete(fn.Ok(newValue)) + }) + }() + + return transformedPromise.Future() +} + +// OnComplete registers a function to be called when the result is ready. If the +// passed context is cancelled before the future completes, the callback +// function (cFunc) will be invoked with the context's error. The callback is +// executed in a new goroutine, so it does not block the completion path of the +// original future. +func (f *futureImpl[T]) OnComplete(ctx context.Context, cFunc func(fn.Result[T])) { + go func() { + // Await the original future's result, respecting the passed + // context for cancellation. + result := f.Await(ctx) + + // Call the callback function with the result. + cFunc(result) + }() +} diff --git a/baselib/actor/future_test.go b/baselib/actor/future_test.go new file mode 100644 index 000000000..07bd81fbc --- /dev/null +++ b/baselib/actor/future_test.go @@ -0,0 +1,449 @@ +package actor + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestFutureAwaitContextCancellation tests that Await respects context +// cancellation if the context is cancelled before the future resolves. +func TestFutureAwaitContextCancellation(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + // Test cancellation when the Await context is cancelled via + // context.Cancel. The underlying future will not be completed, allowing + // us to test the cancellation path of Await. + prom1 := NewPromise[int]() + fut1 := prom1.Future() + ctx1, cancel1 := context.WithCancel(context.Background()) + + // We'll cancel the future immediately after creating it. + cancel1() + + result1 := fut1.Await(ctx1) + if !result1.IsErr() || + !errors.Is(result1.Err(), context.Canceled) { + t.Fatalf("await with immediate cancel: expected "+ + "context.Canceled, got %v", result1.Err()) + } + + // Test cancellation when the Await context times out. The + // underlying future will also not be completed. + prom2 := NewPromise[int]() + fut2 := prom2.Future() + + // Use a very short timeout that will trigger. + ctx2, cancel2 := context.WithTimeout( + context.Background(), 1*time.Nanosecond, + ) + defer cancel2() + + // Await the future; it should fall through to the timeout + // because the future itself is not completed. + result2 := fut2.Await(ctx2) + if !result2.IsErr() || + !errors.Is(result2.Err(), context.DeadlineExceeded) { + + t.Fatalf("await with timeout: expected "+ + "context.DeadlineExceeded, got %v", + result2.Err()) + } + }) +} + +// TestFutureAwaitFutureCompletes tests that Await returns the future's +// result if the context is not cancelled before the future resolves. +func TestFutureAwaitFutureCompletes(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + valToSet := rapid.Int().Draw(t, "valToSet") + + // With a 50% chance, configure the test to complete the future + // with an error instead of a successful value. + var errToSet error + if rapid.Bool().Draw(t, "have_error") { + errToSet = fmt.Errorf("err") + } + + promise := NewPromise[int]() + fut := promise.Future() + + // Use a background context for Await, as we expect the future + // to complete normally. + ctx := context.Background() + + // Complete the future in a separate goroutine to simulate an + // asynchronous operation. + go func() { + if errToSet != nil { + promise.Complete(fn.Err[int](errToSet)) + } else { + promise.Complete(fn.Ok(valToSet)) + } + }() + + // Now we'll wait for the future to complete, then verify below + // that the result (value or error) is as expected. + result := fut.Await(ctx) + + if errToSet != nil { + // If an error was set, verify that Await returns that + // specific error. + if !result.IsErr() || + !errors.Is(result.Err(), errToSet) { + + t.Fatalf("await with error: expected "+ + "error %v, got %v", errToSet, + result.Err()) + } + } else { + // If no error was set, verify that Await returns the + // correct value. + if result.IsErr() { + t.Fatalf("await with value: expected success, "+ + "got error %v", result.Err()) + } + result.WhenOk(func(val int) { + if val != valToSet { + t.Fatalf("await with value: "+ + "expected %v, got %v", + valToSet, val) + } + }) + } + }) +} + +// TestFutureThenApplyContextCancellation tests that ThenApply respects its +// context, yielding a context error if cancelled before the original future +// completes. +func TestFutureThenApplyContextCancellation(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + // The original future will not be completed in this test case, + // allowing us to specifically test the cancellation behavior of + // the context passed to ThenApply. + originalPromise := NewPromise[int]() + originalFut := originalPromise.Future() + + // Create a context for ThenApply and cancel it immediately. + ctxApply, cancelApply := context.WithCancel( + context.Background(), + ) + cancelApply() + + var transformCalled atomic.Bool + transform := func(i int) int { + transformCalled.Store(true) + return i * 2 + } + + // Register the transformation. The ThenApply operation itself + // will start a goroutine to await the originalFut. + newFut := originalFut.ThenApply(ctxApply, transform) + + // Await the new (transformed) future. Use a background context + // for this Await to isolate the test to the cancellation of + // ctxApply. + result := newFut.Await(context.Background()) + + if !result.IsErr() || + !errors.Is(result.Err(), context.Canceled) { + + t.Fatalf("ThenApply with cancelled context: expected "+ + "context.Canceled, got %v", result.Err()) + } + if transformCalled.Load() { + t.Fatal("ThenApply transform function called " + + "despite context cancellation") + } + }) +} + +// TestFutureThenApplyOriginalFutureCompletes tests ThenApply's behavior when +// the original future completes (with a value or error) before ThenApply's +// context is cancelled. +func TestFutureThenApplyOriginalFutureCompletes(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + initialVal := rapid.Int().Draw(t, "initialVal") + + // Configure whether the original future completes with an error + // or a successful value. + var originalErr error + if rapid.Bool().Draw(t, "have_error") { + originalErr = fmt.Errorf("original error") + } + + originalPromise := NewPromise[int]() + originalFut := originalPromise.Future() + + // Create a context for ThenApply that should not cancel before + // the original future completes. + ctxApply, cancelApply := context.WithTimeout( + context.Background(), 50*time.Millisecond, + ) + defer cancelApply() + + var transformCalled atomic.Bool + transform := func(i int) int { + transformCalled.Store(true) + return i * 2 + } + + newFut := originalFut.ThenApply(ctxApply, transform) + + // Complete the original future in a separate goroutine to + // simulate asynchrony. + go func() { + if originalErr != nil { + originalPromise.Complete( + fn.Err[int](originalErr), + ) + } else { + originalPromise.Complete(fn.Ok(initialVal)) + } + }() + + // Await our new future which transforms the original future's + // result. Use a background context for this Await. + result := newFut.Await(context.Background()) + + if originalErr != nil { + // If the original future had an error, the transformed + // future should also yield that same error. + if !result.IsErr() || + !errors.Is(result.Err(), originalErr) { + + t.Fatalf("ThenApply with original error: "+ + "expected error %v, got %v", + originalErr, result.Err()) + } + if transformCalled.Load() { + t.Fatal("ThenApply transform function called " + + "despite original future having " + + "an error") + } + } else { + // If the original future completed successfully, the + // transformed future should contain the transformed value. + if result.IsErr() { + t.Fatalf("ThenApply with original value: "+ + "expected success, got "+ + "error %v", result.Err()) + } + + if !transformCalled.Load() { + t.Fatal("ThenApply transform function not " + + "called for successful original future") + } + + result.WhenOk(func(val int) { + expectedTransformedVal := initialVal * 2 + if val != expectedTransformedVal { + t.Fatalf("ThenApply with original "+ + "value: expected "+ + "transformed %v, got %v", + expectedTransformedVal, val) + } + }) + } + }) +} + +// TestFutureOnCompleteContextCancellation tests that OnComplete's callback +// receives a context error if its context is cancelled before the future +// completes. +func TestFutureOnCompleteContextCancellation(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + // The original future will not complete in this test, allowing + // us to focus on the cancellation of OnComplete's context. + originalPromise := NewPromise[int]() + originalFut := originalPromise.Future() + + // Create a context for OnComplete and cancel it immediately to + // simulate a premature cancellation. + ctxComplete, cancelComplete := context.WithCancel( + context.Background(), + ) + cancelComplete() + + var wg sync.WaitGroup + wg.Add(1) + var ( + callbackInvoked atomic.Bool + callbackResultValue fn.Result[int] + + // mu is a mutex to protect callbackResultValue as it's + // written by the callback goroutine and read by the + // test goroutine. + mu sync.Mutex + ) + + // Register an OnComplete callback. The callback itself runs in + // a new goroutine started by OnComplete. + originalFut.OnComplete(ctxComplete, func(res fn.Result[int]) { + mu.Lock() + callbackResultValue = res + mu.Unlock() + + callbackInvoked.Store(true) + wg.Done() + }) + + // Use a wait group and a channel to wait for the callback to + // be invoked. + waitChan := make(chan struct{}) + go func() { + wg.Wait() + close(waitChan) + }() + + select { + // The callback should be invoked, even if with a context error. + case <-waitChan: + case <-time.After(50 * time.Millisecond): + t.Fatal("OnComplete callback timed out " + + "waiting for execution after context cancel") + } + + require.True( + t, callbackInvoked.Load(), + "OnComplete callback not invoked", + ) + + mu.Lock() + defer mu.Unlock() + + // Verify that the callback received a context.Canceled error + // because its context (ctxComplete) was cancelled. + if !callbackResultValue.IsErr() || + !errors.Is(callbackResultValue.Err(), + context.Canceled) { + + t.Fatalf("OnComplete with cancelled context: callback "+ + "expected context.Canceled, got %v", + callbackResultValue.Err()) + } + }) +} + +// TestFutureOnCompleteFutureCompletes tests OnComplete's behavior when the +// future completes (with value or error) before its context is cancelled. +func TestFutureOnCompleteFutureCompletes(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + valToSet := rapid.Int().Draw(t, "valToSet") + + // Configure whether the original future completes with an error + // or a successful value. + var originalErr error + if rapid.Bool().Draw(t, "have_error") { + originalErr = fmt.Errorf("original error") + } + + originalPromise := NewPromise[int]() + originalFut := originalPromise.Future() + + // Use a background context for OnComplete, as we expect the + // future to complete normally. + ctxComplete := context.Background() + + var wg sync.WaitGroup + wg.Add(1) + + var ( + callbackInvoked atomic.Bool + callbackResultValue fn.Result[int] + mu sync.Mutex + ) + + // Register an OnComplete callback. This callback will execute + // once the originalFut completes. + originalFut.OnComplete(ctxComplete, func(res fn.Result[int]) { + mu.Lock() + callbackResultValue = res + mu.Unlock() + + callbackInvoked.Store(true) + + wg.Done() + }) + + // Complete the original future in a separate goroutine to + // simulate an asynchronous operation. + go func() { + if originalErr != nil { + originalPromise.Complete( + fn.Err[int](originalErr), + ) + } else { + originalPromise.Complete(fn.Ok(valToSet)) + } + }() + + // Use a wait group and a channel to wait for the callback's + // execution. + waitChan := make(chan struct{}) + go func() { + wg.Wait() + close(waitChan) + }() + + select { + // The callback should be invoked as the future completes. + case <-waitChan: + case <-time.After(50 * time.Millisecond): + t.Fatal("OnComplete callback timed out " + + "waiting for execution") + } + + require.True(t, callbackInvoked.Load()) + + mu.Lock() + defer mu.Unlock() + + // Verify that the callback received the correct result (either + // the error or the value from the completed future). + if originalErr != nil { + if !callbackResultValue.IsErr() || + !errors.Is(callbackResultValue.Err(), + originalErr) { + + t.Fatalf("OnComplete with error: callback "+ + "expected error %v, got %v", + originalErr, + callbackResultValue.Err()) + } + } else { + if callbackResultValue.IsErr() { + t.Fatalf("OnComplete with value: callback "+ + "expected success, got error %v", + callbackResultValue.Err()) + } + callbackResultValue.WhenOk(func(val int) { + if val != valToSet { + t.Fatalf("OnComplete with value: "+ + "callback expected %v, got %v", + valToSet, val) + } + }) + } + }) +} diff --git a/baselib/actor/interface.go b/baselib/actor/interface.go new file mode 100644 index 000000000..da555b4da --- /dev/null +++ b/baselib/actor/interface.go @@ -0,0 +1,118 @@ +package actor + +import ( + "context" + "fmt" + + "github.com/lightningnetwork/lnd/fn/v2" +) + +// ErrActorTerminated indicates that an operation failed because the target +// actor was terminated or in the process of shutting down. +var ErrActorTerminated = fmt.Errorf("actor terminated") + +// BaseMessage is a helper struct that can be embedded in message types defined +// outside the actor package to satisfy the Message interface's unexported +// messageMarker method. +type BaseMessage struct{} + +// messageMarker implements the unexported method for the Message interface, +// allowing types that embed BaseMessage to satisfy the Message interface. +func (BaseMessage) messageMarker() {} + +// Message is a sealed interface for actor messages. Actors will receive +// messages conforming to this interface. The interface is "sealed" by the +// unexported messageMarker method, meaning only types that can satisfy it +// (e.g., by embedding BaseMessage or being in the same package) can be Messages. +type Message interface { + // messageMarker is a private method that makes this a sealed interface + // (see BaseMessage for embedding). + messageMarker() + + // MessageType returns the type name of the message for + // routing/filtering. + MessageType() string +} + +// PriorityMessage is an extension of the Message interface for messages that +// carry a priority level. This can be used by actor mailboxes or schedulers +// to prioritize message processing. +type PriorityMessage interface { + Message + + // Priority returns the processing priority of this message (higher = + // more important). + Priority() int +} + +// Future represents the result of an asynchronous computation. It allows +// consumers to wait for the result (Await), apply transformations upon +// completion (ThenApply), or register a callback to be executed when the +// result is available (OnComplete). +type Future[T any] interface { + // Await blocks until the result is available or the context is + // cancelled, then returns it. + Await(ctx context.Context) fn.Result[T] + + // ThenApply registers a function to transform the result of a future. + // The original future is not modified, a new instance of the future is + // returned. If the passed context is cancelled while waiting for the + // original future to complete, the new future will complete with the + // context's error. + ThenApply(ctx context.Context, fn func(T) T) Future[T] + + // OnComplete registers a function to be called when the result of the + // future is ready. If the passed context is cancelled before the future + // completes, the callback function will be invoked with the context's + // error. + OnComplete(ctx context.Context, fn func(fn.Result[T])) +} + +// Promise is an interface that allows for the completion of an associated +// Future. It provides a way to set the result of an asynchronous operation. +// The producer of an asynchronous result uses a Promise to set the outcome, +// while consumers use the associated Future to retrieve it. +type Promise[T any] interface { + // Future returns the Future interface associated with this Promise. + // Consumers can use this to Await the result or register callbacks. + Future() Future[T] + + // Complete attempts to set the result of the future. It returns true if + // this call successfully set the result (i.e., it was the first to + // complete it), and false if the future had already been completed. + Complete(result fn.Result[T]) bool +} + +// TellOnlyRef is a reference to an actor that only supports "tell" operations. +// This is useful for scenarios where only fire-and-forget message passing is +// needed, or to restrict capabilities. +type TellOnlyRef[M Message] interface { + // Tell sends a message without waiting for a response. If the + // context is cancelled before the message can be sent to the actor's + // mailbox, the message may be dropped. + Tell(ctx context.Context, msg M) + + // ID returns the unique identifier for this actor. + ID() string +} + +// ActorRef is a reference to an actor that supports both "tell" and "ask" +// operations. It embeds TellOnlyRef and adds the Ask method for +// request-response interactions. +type ActorRef[M Message, R any] interface { + TellOnlyRef[M] + + // Ask sends a message and returns a Future for the response. + // The Future will be completed with the actor's reply or an error + // if the operation fails (e.g., context cancellation before send). + Ask(ctx context.Context, msg M) Future[R] +} + +// ActorBehavior defines the logic for how an actor processes incoming messages. +// It is a strategy interface that encapsulates the actor's reaction to messages. +type ActorBehavior[M Message, R any] interface { + // Receive processes a message and returns a Result. The provided + // context is the actor's internal context, which can be used to + // detect actor shutdown requests. + Receive(actorCtx context.Context, msg M) fn.Result[R] +} diff --git a/baselib/actor/router.go b/baselib/actor/router.go new file mode 100644 index 000000000..29ceed4b1 --- /dev/null +++ b/baselib/actor/router.go @@ -0,0 +1,143 @@ +package actor + +import ( + "context" + "errors" + "sync/atomic" + + "github.com/lightningnetwork/lnd/fn/v2" +) + +// ErrNoActorsAvailable is returned when a router cannot find any actors +// registered for its service key to forward a message to. +var ErrNoActorsAvailable = errors.New("no actors available for service key") + +// RoutingStrategy defines the interface for selecting an actor from a list of +// available actors. +// The M (Message) and R (Response) type parameters ensure that the strategy +// is compatible with the types of actors it will be selecting. +type RoutingStrategy[M Message, R any] interface { + // Select chooses an ActorRef from the provided slice. It returns the + // selected actor or an error if no actor can be selected (e.g., if the + // list is empty or another strategy-specific issue occurs). + Select(refs []ActorRef[M, R]) (ActorRef[M, R], error) +} + +// RoundRobinStrategy implements a round-robin selection strategy. It is generic +// over M and R to match the RoutingStrategy interface, though its logic doesn't +// depend on these types directly for the selection mechanism itself. +type RoundRobinStrategy[M Message, R any] struct { + // index is used to pick the next actor in a round-robin fashion. It + // must be accessed atomically to ensure thread-safety if multiple + // goroutines use the same strategy instance (which they will via the + // router). + index uint64 +} + +// NewRoundRobinStrategy creates a new RoundRobinStrategy, initialized for +// round-robin selection. +func NewRoundRobinStrategy[M Message, R any]() *RoundRobinStrategy[M, R] { + return &RoundRobinStrategy[M, R]{} +} + +// Select picks an actor from the list using a round-robin algorithm. +func (s *RoundRobinStrategy[M, R]) Select(refs []ActorRef[M, R]) (ActorRef[M, R], error) { + if len(refs) == 0 { + return nil, ErrNoActorsAvailable + } + + // Atomically increment and get the current index for selection. + // We subtract 1 because AddUint64 returns the new value (which is + // 1-based for the first call after initialization to 0), and slice + // indexing is 0-based. + idx := atomic.AddUint64(&s.index, 1) - 1 + selectedRef := refs[idx%uint64(len(refs))] + + return selectedRef, nil +} + +// Router is a message-dispatching component that fronts multiple actors +// registered under a specific ServiceKey. It uses a RoutingStrategy to +// distribute messages to one of the available actors. It is generic over M +// (Message type) and R (Response type) to match the actors it routes to. +type Router[M Message, R any] struct { + receptionist *Receptionist + serviceKey ServiceKey[M, R] + strategy RoutingStrategy[M, R] + dlo ActorRef[Message, any] // Dead Letter Office reference. +} + +// NewRouter creates a new Router for a given service key and strategy. The +// receptionist is used to discover actors registered with the service key. +// The router itself is not an actor but a message dispatcher that behaves like +// an ActorRef from the sender's perspective. +func NewRouter[M Message, R any](receptionist *Receptionist, + key ServiceKey[M, R], strategy RoutingStrategy[M, R], + dlo ActorRef[Message, any]) *Router[M, R] { + + return &Router[M, R]{ + receptionist: receptionist, + serviceKey: key, + strategy: strategy, + dlo: dlo, + } +} + +// getActor dynamically finds available actors for the service key and selects +// one using the configured strategy. This method is called internally by Tell +// and Ask on each invocation to ensure up-to-date actor discovery. +func (r *Router[M, R]) getActor() (ActorRef[M, R], error) { + // Discover available actors from the receptionist. + availableActors := FindInReceptionist(r.receptionist, r.serviceKey) + if len(availableActors) == 0 { + return nil, ErrNoActorsAvailable + } + + // Select one actor using the strategy. + return r.strategy.Select(availableActors) +} + +// Tell sends a message to one of the actors managed by the router, selected by +// the routing strategy. If no actors are available or the send context is +// cancelled before the message can be enqueued in the target actor's mailbox, +// the message may be dropped. Errors during actor selection (e.g., +// ErrNoActorsAvailable) are currently not propagated from Tell, aligning with +// its fire-and-forget nature. Such errors could be logged internally if needed. +func (r *Router[M, R]) Tell(ctx context.Context, msg M) { + selectedActor, err := r.getActor() + if err != nil { + // If no actors are available for the service, and a DLO is + // configured, forward the message there. + if errors.Is(err, ErrNoActorsAvailable) && r.dlo != nil { + r.dlo.Tell(context.Background(), msg) + } + return + } + + selectedActor.Tell(ctx, msg) +} + +// Ask sends a message to one of the actors managed by the router, selected by +// the routing strategy, and returns a Future for the response. If no actors are +// available (ErrNoActorsAvailable), the Future will be completed with this +// error. If the send context is cancelled before the message can be enqueued in +// the chosen actor's mailbox, the Future will be completed with the context's error. +func (r *Router[M, R]) Ask(ctx context.Context, msg M) Future[R] { + selectedActor, err := r.getActor() + if err != nil { + // If no actor could be selected (e.g., none available), + // complete the promise immediately with the selection error. + promise := NewPromise[R]() + promise.Complete(fn.Err[R](err)) + return promise.Future() + } + + return selectedActor.Ask(ctx, msg) +} + +// ID provides an identifier for the router. Since a router isn't an actor +// itself but a dispatcher for a service, its ID can be based on the service +// key. +func (r *Router[M, R]) ID() string { + return "router(" + r.serviceKey.name + ")" +} diff --git a/baselib/actor/system.go b/baselib/actor/system.go new file mode 100644 index 000000000..382e62aed --- /dev/null +++ b/baselib/actor/system.go @@ -0,0 +1,390 @@ +package actor + +import ( + "context" + "errors" + "sync" + + "github.com/lightningnetwork/lnd/fn/v2" +) + +// stoppable defines an interface for components that can be stopped. +// This is unexported as it's an internal detail of ActorSystem for managing +// actors that need to be shut down. +type stoppable interface { + Stop() +} + +// SystemConfig holds configuration parameters for the ActorSystem. +type SystemConfig struct { + // MailboxCapacity is the default capacity for actor mailboxes. + MailboxCapacity int +} + +// DefaultConfig returns a default configuration for the ActorSystem. +func DefaultConfig() SystemConfig { + return SystemConfig{ + MailboxCapacity: 100, + } +} + +// ActorSystem manages the lifecycle of actors and provides coordination +// services such as a receptionist for actor discovery and a dead letter office +// for undeliverable messages. It also handles the graceful shutdown of all +// managed actors. +type ActorSystem struct { + // receptionist is used for actor discovery. + receptionist *Receptionist + + // actors stores all actors managed by the system, keyed by their ID. + // This includes the deadLetterActor. + actors map[string]stoppable + + // deadLetterActor handles undeliverable messages. + deadLetterActor ActorRef[Message, any] + + // config holds the system-wide configuration. + config SystemConfig + + // mu protects the 'actors' map. + mu sync.RWMutex + + // ctx is the main context for the actor system. + ctx context.Context + + // cancel cancels the main system context. + cancel context.CancelFunc +} + +// NewActorSystem creates a new actor system using the default configuration. +func NewActorSystem() *ActorSystem { + return NewActorSystemWithConfig(DefaultConfig()) +} + +// NewActorSystemWithConfig creates a new actor system with custom configuration +func NewActorSystemWithConfig(config SystemConfig) *ActorSystem { + ctx, cancel := context.WithCancel(context.Background()) + + // Initialize the core ActorSystem components. + system := &ActorSystem{ + receptionist: newReceptionist(), + config: config, + actors: make(map[string]stoppable), + ctx: ctx, + cancel: cancel, + } + + // Define the behavior for the dead letter actor. It simply returns an + // error indicating the message was undeliverable. + deadLetterBehavior := NewFunctionBehavior( + func(ctx context.Context, msg Message) fn.Result[any] { + return fn.Err[any](errors.New( + "message undeliverable: " + msg.MessageType(), + )) + }, + ) + + // Create the raw dead letter actor (*Actor instance). The DLO's own DLO + // reference is nil to prevent loops if messages to the DLO itself fail. + deadLetterActorCfg := ActorConfig[Message, any]{ + ID: "dead-letters", + Behavior: deadLetterBehavior, + DLO: nil, + MailboxSize: config.MailboxCapacity, + } + deadLetterRawActor := NewActor[Message, any](deadLetterActorCfg) + deadLetterRawActor.Start() + system.deadLetterActor = deadLetterRawActor.Ref() + + // Add the raw actor to the map of stoppable actors. No lock needed here + // as 'system' is not yet accessible concurrently. + system.actors[deadLetterRawActor.id] = deadLetterRawActor + + // The system is now fully initialized and ready. + return system +} + +// RegisterWithSystem creates an actor with the given ID, service key, and +// behavior within the specified ActorSystem. It starts the actor, adds it to +// the system's management, registers it with the receptionist using the +// provided key, and returns its ActorRef. +func RegisterWithSystem[M Message, R any](as *ActorSystem, id string, key ServiceKey[M, R], + behavior ActorBehavior[M, R], +) ActorRef[M, R] { + + actorCfg := ActorConfig[M, R]{ + ID: id, + Behavior: behavior, + DLO: as.deadLetterActor, + MailboxSize: as.config.MailboxCapacity, + } + actorInstance := NewActor(actorCfg) + actorInstance.Start() + + // Add the actor instance to the system's list of stoppable actors. + // This map is protected by the system's mutex. + as.mu.Lock() + as.actors[actorInstance.id] = actorInstance + as.mu.Unlock() + + // Register the actor's reference with the receptionist under the given + // service key, making it discoverable by other parts of the system. + RegisterWithReceptionist(as.receptionist, key, actorInstance.Ref()) + + return actorInstance.Ref() +} + +// Receptionist returns the system's receptionist, which can be used for +// actor service discovery (finding actors by ServiceKey). +func (as *ActorSystem) Receptionist() *Receptionist { + return as.receptionist +} + +// DeadLetters returns a reference to the system's dead letter actor. Messages +// that cannot be delivered to their intended recipient (e.g., if an Ask +// context is cancelled before enqueuing) may be routed here if not otherwise +// handled. +func (as *ActorSystem) DeadLetters() ActorRef[Message, any] { + return as.deadLetterActor +} + +// Shutdown gracefully stops the actor system. It iterates through all managed +// actors, including the dead letter actor, and calls their Stop method. +// After initiating the stop for all actors, it cancels the main system context. +// This method is safe for concurrent use. +func (as *ActorSystem) Shutdown() error { + // Create a slice of actors to stop. This avoids holding the lock while + // calling Stop() on each actor, and includes the dead letter actor. + var actorsToStop []stoppable + as.mu.RLock() + for _, actor := range as.actors { + actorsToStop = append(actorsToStop, actor) + } + as.mu.RUnlock() + + // Notify all managed actors to stop. Actor.Stop() is non-blocking. + // Each actor's Stop method will cancel its internal context, leading + // to the termination of its processing goroutine. + for _, actor := range actorsToStop { + actor.Stop() + } + + // Clear the actors map after initiating their shutdown. + as.mu.Lock() + as.actors = nil + as.mu.Unlock() + + // Finally cancel the main context + // This signals to any other components observing the system's context + // that shutdown has been initiated. + as.cancel() + + return nil +} + +// StopAndRemoveActor stops a specific actor by its ID and removes it from the +// ActorSystem's management. It returns true if the actor was found and stopped, +// false otherwise. +func (as *ActorSystem) StopAndRemoveActor(id string) bool { + as.mu.Lock() + defer as.mu.Unlock() + + actorToStop, exists := as.actors[id] + if !exists { + return false + } + + // Stop the actor. This is non-blocking. + actorToStop.Stop() + + // Remove from the system's management. + delete(as.actors, id) + + return true +} + +// UnregisterFromReceptionist removes an actor reference from a service key in +// the given receptionist. It returns true if the reference was found and +// removed, and false otherwise. This is a package-level generic function +// because methods cannot have their own type parameters in Go. +func UnregisterFromReceptionist[M Message, R any](r *Receptionist, + key ServiceKey[M, R], refToRemove ActorRef[M, R]) bool { + + r.mu.Lock() + defer r.mu.Unlock() + + refs, exists := r.registrations[key.name] + if !exists { + return false + } + + found := false + + // Build a new slice containing only the references that are not the one + // to be removed. + newRefs := make([]any, 0, len(refs)-1) // Pre-allocate assuming one removal + for _, itemInSlice := range refs { // itemInSlice is of type 'any' + // Try to assert the item from the slice to the specific + // ActorRef[M,R] type we are trying to remove. + if specificActorRef, ok := itemInSlice.(ActorRef[M, R]); ok { + // If the type assertion is successful and it's the one + // we want to remove, mark as found and skip adding it + // to newRefs. + if specificActorRef == refToRemove { + found = true + continue // Don't add to newRefs, effectively removing it. + } + } + newRefs = append(newRefs, itemInSlice) + } + + if !found { + return false + } + + // If the new list of references is empty, remove the key from the map. + // Otherwise, update the map with the new slice. + if len(newRefs) == 0 { + delete(r.registrations, key.name) + } else { + r.registrations[key.name] = newRefs + } + + return true +} + +// ServiceKey is a type-safe identifier used for registering and discovering +// actors via the Receptionist. The generic type parameters M (Message) and R +// (Response) ensure that only actors handling compatible message/response types +// are associated with and retrieved for this key. +type ServiceKey[M Message, R any] struct { + name string +} + +// NewServiceKey creates a new service key with the given name. The name is used +// as the lookup key within the Receptionist. +func NewServiceKey[M Message, R any](name string) ServiceKey[M, R] { + return ServiceKey[M, R]{name: name} +} + +// Spawn registers an actor for this service key within the given ActorSystem. +// It's a convenience method that calls RegisterWithSystem, starting the actor +// and registering it with the receptionist. +func (sk ServiceKey[M, R]) Spawn(as *ActorSystem, id string, + behavior ActorBehavior[M, R]) ActorRef[M, R] { + + return RegisterWithSystem(as, id, sk, behavior) +} + +// Unregister removes an actor reference associated with this service key from +// the ActorSystem's receptionist and also stops the actor. +// It returns true if the actor was successfully unregistered from the +// receptionist AND successfully stopped and removed from the system's +// management. Otherwise, it returns false. +func (sk ServiceKey[M, R]) Unregister(as *ActorSystem, + refToRemove ActorRef[M, R]) bool { + + unregisteredFromReceptionist := UnregisterFromReceptionist( + as.Receptionist(), sk, refToRemove, + ) + + // If not found in receptionist, no need to try stopping. + if !unregisteredFromReceptionist { + return false + } + + // Attempt to stop and remove the actor from the system. + stoppedAndRemoved := as.StopAndRemoveActor(refToRemove.ID()) + + return unregisteredFromReceptionist && stoppedAndRemoved +} + +// UnregisterAll finds all actor references associated with this service key in +// the ActorSystem's receptionist. For each found actor, it attempts to stop it +// and remove it from system management, and also unregisters it from the +// receptionist. +func (sk ServiceKey[M, R]) UnregisterAll(as *ActorSystem) int { + // First find all the refs that match this service key. + refsFound := FindInReceptionist(as.Receptionist(), sk) + + actorsStoppedCount := 0 + for _, ref := range refsFound { + // Attempt to stop and remove the actor from the system's active + // management. This is the primary action to deactivate the + // actor. If StopAndRemoveActor returns true, it means an active + // actor was found in the system's `actors` map and was stopped. + if as.StopAndRemoveActor(ref.ID()) { + actorsStoppedCount++ + } + + // Regardless of whether the actor was actively managed by the + // system (i.e., found in as.actors), attempt to unregister its + // reference from the receptionist. This helps clean up any + // potentially stale entries in the receptionist if an actor was + // removed from the system's management without also being + // unregistered from the receptionist. + UnregisterFromReceptionist(as.Receptionist(), sk, ref) + } + + return actorsStoppedCount +} + +// Receptionist provides service discovery for actors. Actors can be registered +// under a ServiceKey and later discovered by other actors or system components. +type Receptionist struct { + // registrations stores ActorRef instances, keyed by ServiceKey.name. + registrations map[string][]any + + // mu protects access to registrations. + mu sync.RWMutex +} + +// newReceptionist creates a new Receptionist instance. +func newReceptionist() *Receptionist { + return &Receptionist{ + registrations: make(map[string][]any), + } +} + +// RegisterWithReceptionist registers an actor with a service key in the given +// receptionist. This is a package-level generic function because methods +// cannot have their own type parameters in Go (as of the current version). +// It appends the actor reference to the list associated with the key's name. +func RegisterWithReceptionist[M Message, R any]( + r *Receptionist, key ServiceKey[M, R], ref ActorRef[M, R]) { + r.mu.Lock() + defer r.mu.Unlock() + + // Initialize the slice for this key if it's the first registration. + if _, exists := r.registrations[key.name]; !exists { + r.registrations[key.name] = make([]interface{}, 0) + } + + r.registrations[key.name] = append(r.registrations[key.name], ref) +} + +// FindInReceptionist returns all actors registered with a service key in the +// given receptionist. This is a package-level generic function because methods +// cannot have their own type parameters. It performs a type assertion to ensure +// that only ActorRefs matching the ServiceKey's generic types (M, R) are +// returned, providing type safety. +func FindInReceptionist[M Message, R any]( + r *Receptionist, key ServiceKey[M, R]) []ActorRef[M, R] { + r.mu.RLock() + defer r.mu.RUnlock() + + if refs, exists := r.registrations[key.name]; exists { + typedRefs := make([]ActorRef[M, R], 0, len(refs)) + for _, ref := range refs { + // Make sure that the reference is of the correct type. + // This type assertion is crucial for type safety, ensuring + // that the returned ActorRefs match the expected M and R. + if typedRef, ok := ref.(ActorRef[M, R]); ok { + typedRefs = append(typedRefs, typedRef) + } + } + return typedRefs + } + + return nil +} diff --git a/baselib/actor/system_test.go b/baselib/actor/system_test.go new file mode 100644 index 000000000..e3e8deb9d --- /dev/null +++ b/baselib/actor/system_test.go @@ -0,0 +1,942 @@ +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 := RegisterWithSystem(as, actorID, serviceKey, beh) + 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) + if err != nil { + t.Fatal("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) + actorRefs[i] = RegisterWithSystem(as, actorID, key, beh) + } + + // 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) + if err != nil { + t.Fatal("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 := RegisterWithSystem(as, actor1ID, key1, beh) + ref2 := RegisterWithSystem(as, actor2ID, key2, beh) + + // 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 := RegisterWithSystem(as, "actor1-rec", key1, beh) + actor2Ref := RegisterWithSystem(as, "actor2-rec", key1, beh) + actor3Ref := RegisterWithSystem(as, "actor3-rec", key2, beh) + + // 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 := key.Spawn(as, "actor-sk-spawn", beh) + 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 := RegisterWithSystem( + otherSys, "dummy-other-actor", dummyKeyOther, dummyBehOther, + ) + + 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 := key1.Spawn(as, "actor1-k1-ua", beh) + actor2Key1 := key1.Spawn(as, "actor2-k1-ua", beh) + + // 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 := keyA.Spawn(as, "actorA1-ua-mixed", beh) + actorA2 := keyA.Spawn(as, "actorA2-ua-mixed", beh) + actorB1 := keyB.Spawn(as, "actorB1-ua-mixed", beh) + + // 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 := keyIdempotent.Spawn(as, "actor-idem-ua", beh) + + // 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() + return RegisterWithSystem(h.as, id, key, beh) +} + +// 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) +} diff --git a/baselib/go.mod b/baselib/go.mod new file mode 100644 index 000000000..412f07db9 --- /dev/null +++ b/baselib/go.mod @@ -0,0 +1,17 @@ +module github.com/lightninglabs/darepo-client/baselib + +require ( + github.com/lightningnetwork/lnd/fn/v2 v2.0.8 + github.com/stretchr/testify v1.9.0 + pgregory.net/rapid v1.2.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sync v0.7.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +go 1.23.12 diff --git a/baselib/go.sum b/baselib/go.sum new file mode 100644 index 000000000..805bb7eb0 --- /dev/null +++ b/baselib/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lightningnetwork/lnd/fn/v2 v2.0.8 h1:r2SLz7gZYQPVc3IZhU82M66guz3Zk2oY+Rlj9QN5S3g= +github.com/lightningnetwork/lnd/fn/v2 v2.0.8/go.mod h1:TOzwrhjB/Azw1V7aa8t21ufcQmdsQOQMDtxVOQWNl8s= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= From dc94220b198a224ed6e3485c16dcd9e0b0549150 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 12:51:12 +0100 Subject: [PATCH 2/8] baselib: add protofsm This commit adds lnd protofsm from the actor branch commit hash dc5d57f28adda2c894c094f37e29f79d52a99ed0 --- baselib/go.mod | 165 ++++- baselib/go.sum | 790 +++++++++++++++++++++- baselib/protofsm/daemon_events.go | 130 ++++ baselib/protofsm/log.go | 29 + baselib/protofsm/msg_mapper.go | 15 + baselib/protofsm/state_machine.go | 705 ++++++++++++++++++++ baselib/protofsm/state_machine_test.go | 870 +++++++++++++++++++++++++ 7 files changed, 2693 insertions(+), 11 deletions(-) create mode 100644 baselib/protofsm/daemon_events.go create mode 100644 baselib/protofsm/log.go create mode 100644 baselib/protofsm/msg_mapper.go create mode 100644 baselib/protofsm/state_machine.go create mode 100644 baselib/protofsm/state_machine_test.go diff --git a/baselib/go.mod b/baselib/go.mod index 412f07db9..5f25cab72 100644 --- a/baselib/go.mod +++ b/baselib/go.mod @@ -1,17 +1,174 @@ module github.com/lightninglabs/darepo-client/baselib require ( - github.com/lightningnetwork/lnd/fn/v2 v2.0.8 - github.com/stretchr/testify v1.9.0 + github.com/btcsuite/btcd v0.24.3-0.20250318170759-4f4ea81776d6 + github.com/btcsuite/btcd/btcec/v2 v2.3.4 + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 + github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b + github.com/lightningnetwork/lnd v0.20.0-beta + github.com/lightningnetwork/lnd/fn/v2 v2.0.9 + github.com/stretchr/testify v1.10.0 pgregory.net/rapid v1.2.0 ) require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/aead/siphash v1.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect + github.com/btcsuite/btcd/btcutil/psbt v1.1.8 // indirect + github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c // indirect + github.com/btcsuite/btcwallet v0.16.17 // indirect + github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 // indirect + github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 // indirect + github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5 // indirect + github.com/btcsuite/btcwallet/walletdb v1.5.1 // indirect + github.com/btcsuite/btcwallet/wtxmgr v1.5.6 // indirect + github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect + github.com/btcsuite/winsvc v1.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/containerd/continuity v0.3.0 // indirect + github.com/coreos/go-semver v0.3.0 // indirect + github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/decred/dcrd/lru v1.1.2 // indirect + github.com/docker/cli v28.1.1+incompatible // indirect + github.com/docker/docker v28.1.1+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fergusstrange/embedded-postgres v1.25.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-migrate/migrate/v4 v4.17.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/btree v1.0.1 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.14.3 // indirect + github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.3 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgtype v1.14.4 // indirect + github.com/jackc/pgx/v4 v4.18.3 // indirect + github.com/jackc/pgx/v5 v5.7.4 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jessevdk/go-flags v1.4.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/jrick/logrotate v1.1.2 // indirect + github.com/json-iterator/go v1.1.11 // indirect + github.com/kkdai/bstream v1.0.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect + github.com/lightninglabs/neutrino v0.16.1 // indirect + github.com/lightninglabs/neutrino/cache v1.1.2 // indirect + github.com/lightningnetwork/lnd/clock v1.1.1 // indirect + github.com/lightningnetwork/lnd/healthcheck v1.2.6 // indirect + github.com/lightningnetwork/lnd/kvdb v1.4.16 // indirect + github.com/lightningnetwork/lnd/queue v1.1.1 // indirect + github.com/lightningnetwork/lnd/sqldb v1.0.11 // indirect + github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect + github.com/lightningnetwork/lnd/tlv v1.3.2 // indirect + github.com/lightningnetwork/lnd/tor v1.1.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/miekg/dns v1.1.43 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.2 // indirect + github.com/opencontainers/runc v1.1.14 // indirect + github.com/ory/dockertest/v3 v3.10.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.11.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.26.0 // indirect + github.com/prometheus/procfs v0.6.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sirupsen/logrus v1.9.2 // indirect + github.com/soheilhy/cmux v0.1.5 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect + go.etcd.io/bbolt v1.4.3 // indirect + go.etcd.io/etcd/api/v3 v3.5.12 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.12 // indirect + go.etcd.io/etcd/client/v2 v2.305.12 // indirect + go.etcd.io/etcd/client/v3 v3.5.12 // indirect + go.etcd.io/etcd/pkg/v3 v3.5.12 // indirect + go.etcd.io/etcd/raft/v3 v3.5.12 // indirect + go.etcd.io/etcd/server/v3 v3.5.12 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.17.0 // indirect + golang.org/x/crypto v0.37.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect - golang.org/x/sync v0.7.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.49.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.29.10 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect + sigs.k8s.io/yaml v1.2.0 // indirect ) -go 1.23.12 +go 1.24.9 diff --git a/baselib/go.sum b/baselib/go.sum index 805bb7eb0..e86c3569a 100644 --- a/baselib/go.sum +++ b/baselib/go.sum @@ -1,18 +1,794 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= +cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= +github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.3-0.20250318170759-4f4ea81776d6 h1:8n9k3I7e8DkpdQ5YAP4j8ly/LSsbe6qX9vmVbrUGvVw= +github.com/btcsuite/btcd v0.24.3-0.20250318170759-4f4ea81776d6/go.mod h1:OmM4kFtB0klaG/ZqT86rQiyw/1iyXlJgc3UHClPhhbs= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/btcutil/psbt v1.1.8 h1:4voqtT8UppT7nmKQkXV+T9K8UyQjKOn2z/ycpmJK8wg= +github.com/btcsuite/btcd/btcutil/psbt v1.1.8/go.mod h1:kA6FLH/JfUx++j9pYU0pyu+Z8XGBQuuTmuKYUf6q7/U= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c h1:4HxD1lBUGUddhzgaNgrCPsFWd7cGYNpeFUgd9ZIgyM0= +github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ= +github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b h1:MQ+Q6sDy37V1wP1Yu79A5KqJutolqUGwA99UZWQDWZM= +github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b/go.mod h1:XItGUfVOxotJL8kkuk2Hj3EVow5KCugXl3wWfQ6K0AE= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcwallet v0.16.17 h1:1N6lHznRdcjDopBvcofxaIHknArkJ/EcVKgLKfGL4Dg= +github.com/btcsuite/btcwallet v0.16.17/go.mod h1:YO+W745BAH8n/Rpgj68QsLR6eLlgM4W2do4RejT0buo= +github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 h1:Rr0njWI3r341nhSPesKQ2JF+ugDSzdPoeckS75SeDZk= +github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5/go.mod h1:+tXJ3Ym0nlQc/iHSwW1qzjmPs3ev+UVWMbGgfV1OZqU= +github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 h1:YEO+Lx1ZJJAtdRrjuhXjWrYsmAk26wLTlNzxt2q0lhk= +github.com/btcsuite/btcwallet/wallet/txrules v1.2.2/go.mod h1:4v+grppsDpVn91SJv+mZT7B8hEV4nSmpREM4I8Uohws= +github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5 h1:93o5Xz9dYepBP4RMFUc9RGIFXwqP2volSWRkYJFrNtI= +github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5/go.mod h1:lQ+e9HxZ85QP7r3kdxItkiMSloSLg1PEGis5o5CXUQw= +github.com/btcsuite/btcwallet/walletdb v1.5.1 h1:HgMhDNCrtEFPC+8q0ei5DQ5U9Tl4RCspA22DEKXlopI= +github.com/btcsuite/btcwallet/walletdb v1.5.1/go.mod h1:jk/hvpLFINF0C1kfTn0bfx2GbnFT+Nvnj6eblZALfjs= +github.com/btcsuite/btcwallet/wtxmgr v1.5.6 h1:Zwvr/rrJYdOLqdBCSr4eICEstnEA+NBUvjIWLkrXaYI= +github.com/btcsuite/btcwallet/wtxmgr v1.5.6/go.mod h1:lzVbDkk/jRao2ib5kge46aLZW1yFc8RFNycdYpnsmZA= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0 h1:J9B4L7e3oqhXOcm+2IuNApwzQec85lE+QaikUcCs+dk= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101 h1:7To3pQ+pZo0i3dsWEbinPNFs5gPSBOsJtx3wTT94VBY= +github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= +github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= +github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/lightningnetwork/lnd/fn/v2 v2.0.8 h1:r2SLz7gZYQPVc3IZhU82M66guz3Zk2oY+Rlj9QN5S3g= -github.com/lightningnetwork/lnd/fn/v2 v2.0.8/go.mod h1:TOzwrhjB/Azw1V7aa8t21ufcQmdsQOQMDtxVOQWNl8s= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/decred/dcrd/lru v1.1.2 h1:KdCzlkxppuoIDGEvCGah1fZRicrDH36IipvlB1ROkFY= +github.com/decred/dcrd/lru v1.1.2/go.mod h1:gEdCVgXs1/YoBvFWt7Scgknbhwik3FgVSzlnCcXL2N8= +github.com/dhui/dktest v0.4.0 h1:z05UmuXZHO/bgj/ds2bGMBu8FI4WA+Ag/m3ghL+om7M= +github.com/dhui/dktest v0.4.0/go.mod h1:v/Dbz1LgCBOi2Uki2nUqLBGa83hWBGFMu5MrgMDCc78= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k= +github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= +github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= +github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fergusstrange/embedded-postgres v1.25.0 h1:sa+k2Ycrtz40eCRPOzI7Ry7TtkWXXJ+YRsxpKMDhxK0= +github.com/fergusstrange/embedded-postgres v1.25.0/go.mod h1:t/MLs0h9ukYM6FSt99R7InCHs1nW0ordoVCcnzmpTYw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU= +github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= +github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8= +github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= +github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/jrick/logrotate v1.1.2 h1:6ePk462NCX7TfKtNp5JJ7MbA2YIslkpfgP03TlTYMN0= +github.com/jrick/logrotate v1.1.2/go.mod h1:f9tdWggSVK3iqavGpyvegq5IhNois7KXmasU6/N96OQ= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= +github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= +github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= +github.com/lightninglabs/neutrino v0.16.1 h1:5Kz4ToxncEVkpKC6fwUjXKtFKJhuxlG3sBB3MdJTJjs= +github.com/lightninglabs/neutrino v0.16.1/go.mod h1:L+5UAccpUdyM7yDgmQySgixf7xmwBgJtOfs/IP26jCs= +github.com/lightninglabs/neutrino/cache v1.1.2 h1:C9DY/DAPaPxbFC+xNNEI/z1SJY9GS3shmlu5hIQ798g= +github.com/lightninglabs/neutrino/cache v1.1.2/go.mod h1:XJNcgdOw1LQnanGjw8Vj44CvguYA25IMKjWFZczwZuo= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9 h1:6D3LrdagJweLLdFm1JNodZsBk6iU4TTsBBFLQ4yiXfI= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9/go.mod h1:EDqJ3MuZIbMq0QI1czTIKDJ/GS8S14RXPwapHw8cw6w= +github.com/lightningnetwork/lnd v0.20.0-beta h1:ML+jgJ3UKDGJdUf0m73ZeR/szJKWVtHxpQP+yFC79b8= +github.com/lightningnetwork/lnd v0.20.0-beta/go.mod h1:8hc55AnE3mMSJ/UAEJZgmhgNCcH0yWaPg0olpxhhp4M= +github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0= +github.com/lightningnetwork/lnd/clock v1.1.1/go.mod h1:mGnAhPyjYZQJmebS7aevElXKTFDuO+uNFFfMXK1W8xQ= +github.com/lightningnetwork/lnd/fn/v2 v2.0.9 h1:ZytG4ltPac/sCyg1EJDn10RGzPIDJeyennUMRdOw7Y8= +github.com/lightningnetwork/lnd/fn/v2 v2.0.9/go.mod h1:aPUJHJ31S+Lgoo8I5SxDIjnmeCifqujaiTXKZqpav3w= +github.com/lightningnetwork/lnd/healthcheck v1.2.6 h1:1sWhqr93GdkWy4+6U7JxBfcyZIE78MhIHTJZfPx7qqI= +github.com/lightningnetwork/lnd/healthcheck v1.2.6/go.mod h1:Mu02um4CWY/zdTOvFje7WJgJcHyX2zq/FG3MhOAiGaQ= +github.com/lightningnetwork/lnd/kvdb v1.4.16 h1:9BZgWdDfjmHRHLS97cz39bVuBAqMc4/p3HX1xtUdbDI= +github.com/lightningnetwork/lnd/kvdb v1.4.16/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= +github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= +github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= +github.com/lightningnetwork/lnd/sqldb v1.0.11 h1:X8J3OvdIhJVniQG78Qsp3niErl1zdGMTPvzgiLMWOOo= +github.com/lightningnetwork/lnd/sqldb v1.0.11/go.mod h1:oOdZ7vjmAUmI9He+aFHTunnxKVefHZAfJttZdz16hSg= +github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= +github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= +github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= +github.com/lightningnetwork/lnd/tlv v1.3.2/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc= +github.com/lightningnetwork/lnd/tor v1.1.6 h1:WHUumk7WgU6BUFsqHuqszI9P6nfhMeIG+rjJBlVE6OE= +github.com/lightningnetwork/lnd/tor v1.1.6/go.mod h1:qSRB8llhAK+a6kaTPWOLLXSZc6Hg8ZC0mq1sUQ/8JfI= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796/go.mod h1:3p7ZTf9V1sNPI5H8P3NkTFF4LuwMdPl2DodF60qAKqY= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.26.0 h1:03cDLK28U6hWvCAns6NeydX3zIm4SF3ci69ulidS32Q= +github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v1.1.14 h1:rgSuzbmgz5DUJjeSnw337TxDbRuqjs6iqQck/2weR6w= +github.com/opencontainers/runc v1.1.14/go.mod h1:E4C2z+7BxR7GHXp0hAY53mek+x49X1LjPNeMTfRGvOA= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= +github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y= +github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/api/v3 v3.5.12 h1:W4sw5ZoU2Juc9gBWuLk5U6fHfNVyY1WC5g9uiXZio/c= +go.etcd.io/etcd/api/v3 v3.5.12/go.mod h1:Ot+o0SWSyT6uHhA56al1oCED0JImsRiU9Dc26+C2a+4= +go.etcd.io/etcd/client/pkg/v3 v3.5.12 h1:EYDL6pWwyOsylrQyLp2w+HkQ46ATiOvoEdMarindU2A= +go.etcd.io/etcd/client/pkg/v3 v3.5.12/go.mod h1:seTzl2d9APP8R5Y2hFL3NVlD6qC/dOT+3kvrqPyTas4= +go.etcd.io/etcd/client/v2 v2.305.12 h1:0m4ovXYo1CHaA/Mp3X/Fak5sRNIWf01wk/X1/G3sGKI= +go.etcd.io/etcd/client/v2 v2.305.12/go.mod h1:aQ/yhsxMu+Oht1FOupSr60oBvcS9cKXHrzBpDsPTf9E= +go.etcd.io/etcd/client/v3 v3.5.12 h1:v5lCPXn1pf1Uu3M4laUE2hp/geOTc5uPcYYsNe1lDxg= +go.etcd.io/etcd/client/v3 v3.5.12/go.mod h1:tSbBCakoWmmddL+BKVAJHa9km+O/E+bumDe9mSbPiqw= +go.etcd.io/etcd/pkg/v3 v3.5.12 h1:OK2fZKI5hX/+BTK76gXSTyZMrbnARyX9S643GenNGb8= +go.etcd.io/etcd/pkg/v3 v3.5.12/go.mod h1:UVwg/QIMoJncyeb/YxvJBJCE/NEwtHWashqc8A1nj/M= +go.etcd.io/etcd/raft/v3 v3.5.12 h1:7r22RufdDsq2z3STjoR7Msz6fYH8tmbkdheGfwJNRmU= +go.etcd.io/etcd/raft/v3 v3.5.12/go.mod h1:ERQuZVe79PI6vcC3DlKBukDCLja/L7YMu29B74Iwj4U= +go.etcd.io/etcd/server/v3 v3.5.12 h1:EtMjsbfyfkwZuA2JlKOiBfuGkFCekv5H178qjXypbG8= +go.etcd.io/etcd/server/v3 v3.5.12/go.mod h1:axB0oCjMy+cemo5290/CutIjoxlfA6KVYKD1w0uue10= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0 h1:PzIubN4/sjByhDRHLviCjJuweBXWFZWhghjg7cS28+M= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0/go.mod h1:Ct6zzQEuGK3WpJs2n4dn+wfJYzd/+hNnxMRTWjGn30M= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0 h1:DeFD0VgTZ+Cj6hxravYYZE2W4GlneVH81iAOPjZkzk8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0/go.mod h1:GijYcYmNpX1KazD5JmWGsi4P7dDTTTnfv1UbGn84MnU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0 h1:gvmNvqrPYovvyRmCSygkUDyL8lC5Tl845MLEwqpxhEU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0/go.mod h1:vNUq47TGFioo+ffTSnKNdob241vePmtNZnAODKapKd0= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= +golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= +google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k= +google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo= +gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= +modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= +modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= +modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/baselib/protofsm/daemon_events.go b/baselib/protofsm/daemon_events.go new file mode 100644 index 000000000..3b4ca9b4d --- /dev/null +++ b/baselib/protofsm/daemon_events.go @@ -0,0 +1,130 @@ +package protofsm + +import ( + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" +) + +// DaemonEvent is a special event that can be emitted by a state transition +// function. A state machine can use this to perform side effects, such as +// sending a message to a peer, or broadcasting a transaction. +type DaemonEvent interface { + daemonSealed() +} + +// DaemonEventSet is a set of daemon events that can be emitted by a state +// transition. +type DaemonEventSet []DaemonEvent + +// DaemonEvents is a special type constraint that enumerates all the possible +// types of daemon events. +type DaemonEvents interface { + SendMsgEvent[any] | BroadcastTxn | RegisterSpend[any] | + RegisterConf[any] +} + +// SendPredicate is a function that returns true if the target message should +// sent. +type SendPredicate = func() bool + +// SendMsgEvent is a special event that can be emitted by a state transition +// that instructs the daemon to send the contained message to the target peer. +type SendMsgEvent[Event any] struct { + // TargetPeer is the peer to send the message to. + TargetPeer btcec.PublicKey + + // Msgs is the set of messages to send to the target peer. + Msgs []lnwire.Message + + // SendWhen implements a system for a conditional send once a special + // send predicate has been met. + // + // TODO(roasbeef): contrast with usage of OnCommitFlush, etc + SendWhen fn.Option[SendPredicate] + + // PostSendEvent is an optional event that is to be emitted after the + // message has been sent. If a SendWhen is specified, then this will + // only be executed after that returns true to unblock the send. + PostSendEvent fn.Option[Event] +} + +// daemonSealed indicates that this struct is a DaemonEvent instance. +func (s *SendMsgEvent[E]) daemonSealed() {} + +// BroadcastTxn indicates the target transaction should be broadcast to the +// network. +type BroadcastTxn struct { + // Tx is the transaction to broadcast. + Tx *wire.MsgTx + + // Label is an optional label to attach to the transaction. + Label string +} + +// daemonSealed indicates that this struct is a DaemonEvent instance. +func (b *BroadcastTxn) daemonSealed() {} + +// SpendMapper is a function that's used to map a spend notification to a +// custom state machine event. +type SpendMapper[Event any] func(*chainntnfs.SpendDetail) Event + +// ConfMapper is a function that's used to map a confirmation notification to a +// custom state machine event. +type ConfMapper[Event any] func(*chainntnfs.TxConfirmation) Event + +// RegisterSpend is used to request that a certain event is sent into the state +// machine once the specified outpoint has been spent. +type RegisterSpend[Event any] struct { + // OutPoint is the outpoint on chain to watch. + OutPoint wire.OutPoint + + // PkScript is the script that we expect to be spent along with the + // outpoint. + PkScript []byte + + // HeightHint is a value used to give the chain scanner a hint on how + // far back it needs to start its search. + HeightHint uint32 + + // PostSpendEvent is a special spend mapper, that if present, will be + // used to map the protofsm spend event to a custom event. + PostSpendEvent fn.Option[SpendMapper[Event]] +} + +// daemonSealed indicates that this struct is a DaemonEvent instance. +func (r *RegisterSpend[E]) daemonSealed() {} + +// RegisterConf is used to request that a certain event is sent into the state +// machien once the specified outpoint has been spent. +type RegisterConf[Event any] struct { + // Txid is the txid of the txn we want to watch the chain for. + Txid chainhash.Hash + + // PkScript is the script that we expect to be created along with the + // outpoint. + PkScript []byte + + // HeightHint is a value used to give the chain scanner a hint on how + // far back it needs to start its search. + HeightHint uint32 + + // NumConfs is the number of confirmations that the spending + // transaction needs to dispatch an event. + NumConfs fn.Option[uint32] + + // FullBlock is a boolean that indicates whether we want the full block + // in the returned response. This is useful if callers want to create an + // SPV proof for the transaction post conf. + FullBlock bool + + // PostConfMapper is a special conf mapper, that if present, will be + // used to map the protofsm confirmation event to a custom event. + PostConfMapper fn.Option[ConfMapper[Event]] +} + +// daemonSealed indicates that this struct is a DaemonEvent instance. +func (r *RegisterConf[E]) daemonSealed() {} diff --git a/baselib/protofsm/log.go b/baselib/protofsm/log.go new file mode 100644 index 000000000..6978f1e89 --- /dev/null +++ b/baselib/protofsm/log.go @@ -0,0 +1,29 @@ +package protofsm + +import ( + "github.com/btcsuite/btclog/v2" + "github.com/lightningnetwork/lnd/build" +) + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger("PFSM", nil)) +} + +// DisableLog disables all library log output. Logging output is disabled +// by default until UseLogger is called. +func DisableLog() { + UseLogger(btclog.Disabled) +} + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/baselib/protofsm/msg_mapper.go b/baselib/protofsm/msg_mapper.go new file mode 100644 index 000000000..a00d86379 --- /dev/null +++ b/baselib/protofsm/msg_mapper.go @@ -0,0 +1,15 @@ +package protofsm + +import ( + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/msgmux" +) + +// MsgMapper is used to map incoming wire messages into a FSM event. This is +// useful to decouple the translation of an outside or wire message into an +// event type that can be understood by the FSM. +type MsgMapper[Event any] interface { + // MapMsg maps a wire message into a FSM event. If the message is not + // mappable, then an None is returned. + MapMsg(msg msgmux.PeerMsg) fn.Option[Event] +} diff --git a/baselib/protofsm/state_machine.go b/baselib/protofsm/state_machine.go new file mode 100644 index 000000000..b3e16f5fd --- /dev/null +++ b/baselib/protofsm/state_machine.go @@ -0,0 +1,705 @@ +package protofsm + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnutils" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/msgmux" +) + +const ( + // pollInterval is the interval at which we'll poll the SendWhen + // predicate if specified. + pollInterval = time.Millisecond * 100 +) + +var ( + // ErrStateMachineShutdown occurs when trying to feed an event to a + // StateMachine that has been asked to Stop. + ErrStateMachineShutdown = fmt.Errorf("StateMachine is shutting down") +) + +// EmittedEvent is a special type that can be emitted by a state transition. +// This can container internal events which are to be routed back to the state, +// or external events which are to be sent to the daemon. +type EmittedEvent[Event any] struct { + // InternalEvent is an optional internal event that is to be routed + // back to the target state. This enables state to trigger one or many + // state transitions without a new external event. + InternalEvent []Event + + // ExternalEvent is an optional external event that is to be sent to + // the daemon for dispatch. Usually, this is some form of I/O. + ExternalEvents DaemonEventSet +} + +// StateTransition is a state transition type. It denotes the next state to go +// to, and also the set of events to emit. +type StateTransition[Event any, Env Environment] struct { + // NextState is the next state to transition to. + NextState State[Event, Env] + + // NewEvents is the set of events to emit. + NewEvents fn.Option[EmittedEvent[Event]] +} + +// Environment is an abstract interface that represents the environment that +// the state machine will execute using. From the PoV of the main state machine +// executor, we just care about being able to clean up any resources that were +// allocated by the environment. +type Environment interface { + // Name returns the name of the environment. This is used to uniquely + // identify the environment of related state machines. + Name() string +} + +// State defines an abstract state along, namely its state transition function +// that takes as input an event and an environment, and returns a state +// transition (next state, and set of events to emit). As state can also either +// be terminal, or not, a terminal event causes state execution to halt. +type State[Event any, Env Environment] interface { + // ProcessEvent takes an event and an environment, and returns a new + // state transition. This will be iteratively called until either a + // terminal state is reached, or no further internal events are + // emitted. + ProcessEvent(event Event, env Env) (*StateTransition[Event, Env], error) + + // IsTerminal returns true if this state is terminal, and false + // otherwise. + IsTerminal() bool + + // String returns a human readable string that represents the state. + String() string +} + +// DaemonAdapters is a set of methods that server as adapters to bridge the +// pure world of the FSM to the real world of the daemon. These will be used to +// do things like broadcast transactions, or send messages to peers. +type DaemonAdapters interface { + // SendMessages sends the target set of messages to the target peer. + SendMessages(btcec.PublicKey, []lnwire.Message) error + + // BroadcastTransaction broadcasts a transaction with the target label. + BroadcastTransaction(*wire.MsgTx, string) error + + // RegisterConfirmationsNtfn registers an intent to be notified once + // txid reaches numConfs confirmations. We also pass in the pkScript as + // the default light client instead needs to match on scripts created + // in the block. If a nil txid is passed in, then not only should we + // match on the script, but we should also dispatch once the + // transaction containing the script reaches numConfs confirmations. + // This can be useful in instances where we only know the script in + // advance, but not the transaction containing it. + // + // TODO(roasbeef): could abstract further? + RegisterConfirmationsNtfn(txid *chainhash.Hash, pkScript []byte, + numConfs, heightHint uint32, + opts ...chainntnfs.NotifierOption) ( + *chainntnfs.ConfirmationEvent, error) + + // RegisterSpendNtfn registers an intent to be notified once the target + // outpoint is successfully spent within a transaction. The script that + // the outpoint creates must also be specified. This allows this + // interface to be implemented by BIP 158-like filtering. + RegisterSpendNtfn(outpoint *wire.OutPoint, pkScript []byte, + heightHint uint32) (*chainntnfs.SpendEvent, error) +} + +// stateQuery is used by outside callers to query the internal state of the +// state machine. +type stateQuery[Event any, Env Environment] struct { + // CurrentState is a channel that will be sent the current state of the + // state machine. + CurrentState chan State[Event, Env] +} + +// StateMachine represents an abstract FSM that is able to process new incoming +// events and drive a state machine to termination. This implementation uses +// type params to abstract over the types of events and environment. Events +// trigger new state transitions, that use the environment to perform some +// action. +// +// TODO(roasbeef): terminal check, daemon event execution, init? +type StateMachine[Event any, Env Environment] struct { + cfg StateMachineCfg[Event, Env] + + log btclog.Logger + + // events is the channel that will be used to send new events to the + // FSM. + events chan Event + + // newStateEvents is an EventDistributor that will be used to notify + // any relevant callers of new state transitions that occur. + newStateEvents *fn.EventDistributor[State[Event, Env]] + + // stateQuery is a channel that will be used by outside callers to + // query the internal state machine state. + stateQuery chan stateQuery[Event, Env] + + gm fn.GoroutineManager + quit chan struct{} + + // startOnce and stopOnce are used to ensure that the state machine is + // only started and stopped once. + startOnce sync.Once + stopOnce sync.Once + + // running is a flag that indicates if the state machine is currently + // running. + running atomic.Bool +} + +// ErrorReporter is an interface that's used to report errors that occur during +// state machine execution. +type ErrorReporter interface { + // ReportError is a method that's used to report an error that occurred + // during state machine execution. + ReportError(err error) +} + +// StateMachineCfg is a configuration struct that's used to create a new state +// machine. +type StateMachineCfg[Event any, Env Environment] struct { + // ErrorReporter is used to report errors that occur during state + // transitions. + ErrorReporter ErrorReporter + + // Daemon is a set of adapters that will be used to bridge the FSM to + // the daemon. + Daemon DaemonAdapters + + // InitialState is the initial state of the state machine. + InitialState State[Event, Env] + + // Env is the environment that the state machine will use to execute. + Env Env + + // InitEvent is an optional event that will be sent to the state + // machine as if it was emitted at the onset of the state machine. This + // can be used to set up tracking state such as a txid confirmation + // event. + InitEvent fn.Option[DaemonEvent] + + // MsgMapper is an optional message mapper that can be used to map + // normal wire messages into FSM events. + MsgMapper fn.Option[MsgMapper[Event]] + + // CustomPollInterval is an optional custom poll interval that can be + // used to set a quicker interval for tests. + CustomPollInterval fn.Option[time.Duration] +} + +// NewStateMachine creates a new state machine given a set of daemon adapters, +// an initial state, an environment, and an event to process as if emitted at +// the onset of the state machine. Such an event can be used to set up tracking +// state such as a txid confirmation event. +func NewStateMachine[Event any, Env Environment]( + cfg StateMachineCfg[Event, Env]) StateMachine[Event, Env] { + + return StateMachine[Event, Env]{ + cfg: cfg, + log: log.WithPrefix( + fmt.Sprintf("FSM(%v):", cfg.Env.Name()), + ), + events: make(chan Event, 1), + stateQuery: make(chan stateQuery[Event, Env]), + gm: *fn.NewGoroutineManager(), + newStateEvents: fn.NewEventDistributor[State[Event, Env]](), + quit: make(chan struct{}), + } +} + +// Start starts the state machine. This will spawn a goroutine that will drive +// the state machine to completion. +func (s *StateMachine[Event, Env]) Start(ctx context.Context) { + s.startOnce.Do(func() { + _ = s.gm.Go(ctx, func(ctx context.Context) { + s.driveMachine(ctx) + }) + + s.running.Store(true) + }) +} + +// Stop stops the state machine. This will block until the state machine has +// reached a stopping point. +func (s *StateMachine[Event, Env]) Stop() { + s.stopOnce.Do(func() { + close(s.quit) + s.gm.Stop() + + s.running.Store(false) + }) +} + +// SendEvent sends a new event to the state machine. +// +// TODO(roasbeef): bool if processed? +func (s *StateMachine[Event, Env]) SendEvent(ctx context.Context, event Event) { + s.log.Debugf("Sending event %T", event) + + select { + case s.events <- event: + case <-ctx.Done(): + return + case <-s.quit: + return + } +} + +// CanHandle returns true if the target message can be routed to the state +// machine. +func (s *StateMachine[Event, Env]) CanHandle(msg msgmux.PeerMsg) bool { + cfgMapper := s.cfg.MsgMapper + return fn.MapOptionZ(cfgMapper, func(mapper MsgMapper[Event]) bool { + return mapper.MapMsg(msg).IsSome() + }) +} + +// Name returns the name of the state machine's environment. +func (s *StateMachine[Event, Env]) Name() string { + return s.cfg.Env.Name() +} + +// SendMessage attempts to send a wire message to the state machine. If the +// message can be mapped using the default message mapper, then true is +// returned indicating that the message was processed. Otherwise, false is +// returned. +func (s *StateMachine[Event, Env]) SendMessage(ctx context.Context, + msg msgmux.PeerMsg) bool { + + // If we have no message mapper, then return false as we can't process + // this message. + if !s.cfg.MsgMapper.IsSome() { + return false + } + + s.log.DebugS(ctx, "Sending msg", "msg", lnutils.SpewLogClosure(msg)) + + // Otherwise, try to map the message using the default message mapper. + // If we can't extract an event, then we'll return false to indicate + // that the message wasn't processed. + var processed bool + s.cfg.MsgMapper.WhenSome(func(mapper MsgMapper[Event]) { + event := mapper.MapMsg(msg) + + event.WhenSome(func(event Event) { + s.SendEvent(ctx, event) + + processed = true + }) + }) + + return processed +} + +// CurrentState returns the current state of the state machine. +func (s *StateMachine[Event, Env]) CurrentState() (State[Event, Env], error) { + query := stateQuery[Event, Env]{ + CurrentState: make(chan State[Event, Env], 1), + } + + if !fn.SendOrQuit(s.stateQuery, query, s.quit) { + return nil, ErrStateMachineShutdown + } + + return fn.RecvOrTimeout(query.CurrentState, time.Second) +} + +// StateSubscriber represents an active subscription to be notified of new +// state transitions. +type StateSubscriber[E any, F Environment] *fn.EventReceiver[State[E, F]] + +// RegisterStateEvents registers a new event listener that will be notified of +// new state transitions. +func (s *StateMachine[Event, Env]) RegisterStateEvents() StateSubscriber[ + Event, Env] { + + subscriber := fn.NewEventReceiver[State[Event, Env]](10) + + // TODO(roasbeef): instead give the state and the input event? + + s.newStateEvents.RegisterSubscriber(subscriber) + + return subscriber +} + +// RemoveStateSub removes the target state subscriber from the set of active +// subscribers. +func (s *StateMachine[Event, Env]) RemoveStateSub(sub StateSubscriber[ + Event, Env]) { + + _ = s.newStateEvents.RemoveSubscriber(sub) +} + +// IsRunning returns true if the state machine is currently running. +func (s *StateMachine[Event, Env]) IsRunning() bool { + return s.running.Load() +} + +// executeDaemonEvent executes a daemon event, which is a special type of event +// that can be emitted as part of the state transition function of the state +// machine. An error is returned if the type of event is unknown. +func (s *StateMachine[Event, Env]) executeDaemonEvent(ctx context.Context, + event DaemonEvent) error { + + switch daemonEvent := event.(type) { + // This is a send message event, so we'll send the event, and also mind + // any preconditions as well as post-send events. + case *SendMsgEvent[Event]: + sendAndCleanUp := func() error { + s.log.DebugS(ctx, "Sending message:", + btclog.Hex6("target", daemonEvent.TargetPeer.SerializeCompressed()), + "messages", lnutils.SpewLogClosure(daemonEvent.Msgs)) + + err := s.cfg.Daemon.SendMessages( + daemonEvent.TargetPeer, daemonEvent.Msgs, + ) + if err != nil { + return fmt.Errorf("unable to send msgs: %w", + err) + } + + // If a post-send event was specified, then we'll funnel + // that back into the main state machine now as well. + //nolint:ll + return fn.MapOptionZ(daemonEvent.PostSendEvent, func(event Event) error { + launched := s.gm.Go( + ctx, func(ctx context.Context) { + s.log.DebugS(ctx, "Sending post-send event", + "event", lnutils.SpewLogClosure(event)) + + s.SendEvent(ctx, event) + }, + ) + + if !launched { + return ErrStateMachineShutdown + } + + return nil + }) + } + + canSend := func() bool { + return fn.MapOptionZ( + daemonEvent.SendWhen, + func(pred SendPredicate) bool { + return pred() + }, + ) + } + + // If this doesn't have a SendWhen predicate, or if it's already + // true, then we can just send it off right away. + if !daemonEvent.SendWhen.IsSome() || canSend() { + return sendAndCleanUp() + } + + // Otherwise, this has a SendWhen predicate, so we'll need + // launch a goroutine to poll the SendWhen, then send only once + // the predicate is true. + launched := s.gm.Go(ctx, func(ctx context.Context) { + predicateTicker := time.NewTicker( + s.cfg.CustomPollInterval.UnwrapOr(pollInterval), + ) + defer predicateTicker.Stop() + + s.log.InfoS(ctx, "Waiting for send predicate to be true") + + for { + select { + case <-predicateTicker.C: + if canSend() { + s.log.InfoS(ctx, "Send active predicate") + + err := sendAndCleanUp() + if err != nil { + s.log.ErrorS(ctx, "Unable to send message", err) + } + + return + } + + case <-ctx.Done(): + return + } + } + }) + + if !launched { + return ErrStateMachineShutdown + } + + return nil + + // If this is a broadcast transaction event, then we'll broadcast with + // the label attached. + case *BroadcastTxn: + s.log.DebugS(ctx, "Broadcasting txn", + "txid", daemonEvent.Tx.TxHash()) + + err := s.cfg.Daemon.BroadcastTransaction( + daemonEvent.Tx, daemonEvent.Label, + ) + if err != nil { + log.Errorf("unable to broadcast txn: %v", err) + } + + return nil + + // The state machine has requested a new event to be sent once a + // transaction spending a specified outpoint has confirmed. + case *RegisterSpend[Event]: + s.log.DebugS(ctx, "Registering spend", + "outpoint", daemonEvent.OutPoint) + + spendEvent, err := s.cfg.Daemon.RegisterSpendNtfn( + &daemonEvent.OutPoint, daemonEvent.PkScript, + daemonEvent.HeightHint, + ) + if err != nil { + return fmt.Errorf("unable to register spend: %w", err) + } + + launched := s.gm.Go(ctx, func(ctx context.Context) { + for { + select { + case spend, ok := <-spendEvent.Spend: + if !ok { + return + } + + // If there's a post-send event, then + // we'll send that into the current + // state now. + postSpend := daemonEvent.PostSpendEvent + postSpend.WhenSome(func(f SpendMapper[Event]) { //nolint:ll + customEvent := f(spend) + s.SendEvent(ctx, customEvent) + }) + + return + + case <-ctx.Done(): + return + } + } + }) + + if !launched { + return ErrStateMachineShutdown + } + + return nil + + // The state machine has requested a new event to be sent once a + // specified txid+pkScript pair has confirmed. + case *RegisterConf[Event]: + s.log.DebugS(ctx, "Registering conf", + "txid", daemonEvent.Txid) + + var opts []chainntnfs.NotifierOption + if daemonEvent.FullBlock { + opts = append(opts, chainntnfs.WithIncludeBlock()) + } + + numConfs := daemonEvent.NumConfs.UnwrapOr(1) + confEvent, err := s.cfg.Daemon.RegisterConfirmationsNtfn( + &daemonEvent.Txid, daemonEvent.PkScript, + numConfs, daemonEvent.HeightHint, opts..., + ) + if err != nil { + return fmt.Errorf("unable to register conf: %w", err) + } + + launched := s.gm.Go(ctx, func(ctx context.Context) { + for { + select { + //nolint:ll + case conf, ok := <-confEvent.Confirmed: + if !ok { + return + } + + // If there's a post-conf mapper, then + // we'll send that into the current + // state now. + postConfMapper := daemonEvent.PostConfMapper + postConfMapper.WhenSome(func(f ConfMapper[Event]) { + customEvent := f(conf) + s.SendEvent(ctx, customEvent) + }) + + return + + case <-ctx.Done(): + return + } + } + }) + + if !launched { + return ErrStateMachineShutdown + } + + return nil + } + + return fmt.Errorf("unknown daemon event: %T", event) +} + +// applyEvents applies a new event to the state machine. This will continue +// until no further events are emitted by the state machine. Along the way, +// we'll also ensure to execute any daemon events that are emitted. +func (s *StateMachine[Event, Env]) applyEvents(ctx context.Context, + currentState State[Event, Env], newEvent Event) (State[Event, Env], + error) { + + eventQueue := fn.NewQueue(newEvent) + + // Given the next event to handle, we'll process the event, then add + // any new emitted internal events to our event queue. This continues + // until we reach a terminal state, or we run out of internal events to + // process. + // + //nolint:ll + for nextEvent := eventQueue.Dequeue(); nextEvent.IsSome(); nextEvent = eventQueue.Dequeue() { + err := fn.MapOptionZ(nextEvent, func(event Event) error { + s.log.DebugS(ctx, "Processing event", + "event", lnutils.SpewLogClosure(event)) + + // Apply the state transition function of the current + // state given this new event and our existing env. + transition, err := currentState.ProcessEvent( + event, s.cfg.Env, + ) + if err != nil { + return err + } + + newEvents := transition.NewEvents + err = fn.MapOptionZ(newEvents, func(events EmittedEvent[Event]) error { + // With the event processed, we'll process any + // new daemon events that were emitted as part + // of this new state transition. + for _, dEvent := range events.ExternalEvents { + err := s.executeDaemonEvent( + ctx, dEvent, + ) + if err != nil { + return err + } + } + + // Next, we'll add any new emitted events to our + // event queue. + for _, inEvent := range events.InternalEvent { + s.log.DebugS(ctx, "Adding new internal event to queue", + "event", lnutils.SpewLogClosure(inEvent)) + + eventQueue.Enqueue(inEvent) + } + + return nil + }) + if err != nil { + return err + } + + s.log.InfoS(ctx, "State transition", + btclog.Fmt("from_state", "%v", currentState), + btclog.Fmt("to_state", "%v", transition.NextState)) + + // With our events processed, we'll now update our + // internal state. + currentState = transition.NextState + + // Notify our subscribers of the new state transition. + // + // TODO(roasbeef): will only give us the outer state? + // * let FSMs choose which state to emit? + s.newStateEvents.NotifySubscribers(currentState) + + return nil + }) + if err != nil { + return currentState, err + } + } + + return currentState, nil +} + +// driveMachine is the main event loop of the state machine. It accepts any new +// incoming events, and then drives the state machine forward until it reaches +// a terminal state. +func (s *StateMachine[Event, Env]) driveMachine(ctx context.Context) { + s.log.DebugS(ctx, "Starting state machine") + + currentState := s.cfg.InitialState + + // Before we start, if we have an init daemon event specified, then + // we'll handle that now. + err := fn.MapOptionZ(s.cfg.InitEvent, func(event DaemonEvent) error { + return s.executeDaemonEvent(ctx, event) + }) + if err != nil { + s.log.ErrorS(ctx, "Unable to execute init event", err) + return + } + + // We just started driving the state machine, so we'll notify our + // subscribers of this starting state. + s.newStateEvents.NotifySubscribers(currentState) + + for { + select { + // We have a new external event, so we'll drive the state + // machine forward until we either run out of internal events, + // or we reach a terminal state. + case newEvent := <-s.events: + newState, err := s.applyEvents( + ctx, currentState, newEvent, + ) + if err != nil { + s.cfg.ErrorReporter.ReportError(err) + + s.log.ErrorS(ctx, "Unable to apply event", err) + + // An error occurred, so we'll tear down the + // entire state machine as we can't proceed. + go s.Stop() + + return + } + + currentState = newState + + // An outside caller is querying our state, so we'll return the + // latest state. + case stateQuery := <-s.stateQuery: + if !fn.SendOrQuit( + stateQuery.CurrentState, currentState, s.quit, + ) { + + return + } + + case <-s.gm.Done(): + return + } + } +} diff --git a/baselib/protofsm/state_machine_test.go b/baselib/protofsm/state_machine_test.go new file mode 100644 index 000000000..ca060614f --- /dev/null +++ b/baselib/protofsm/state_machine_test.go @@ -0,0 +1,870 @@ +package protofsm + +import ( + "encoding/hex" + "fmt" + "sync/atomic" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/msgmux" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type dummyEvents interface { + dummy() +} + +type goToFin struct { +} + +func (g *goToFin) dummy() { +} + +type emitInternal struct { +} + +func (e *emitInternal) dummy() { +} + +type daemonEvents struct { +} + +func (s *daemonEvents) dummy() { +} + +type confDetailsEvent struct { + blockHash chainhash.Hash + blockHeight uint32 +} + +func (c *confDetailsEvent) dummy() { +} + +type registerConf struct { + fullBlock bool +} + +func (r *registerConf) dummy() { +} + +type spendDetailsEvent struct { + spenderTxHash chainhash.Hash + spendingHeight int32 +} + +func (s *spendDetailsEvent) dummy() { +} + +type registerSpend struct { +} + +func (r *registerSpend) dummy() { +} + +type dummyEnv struct { + mock.Mock +} + +func (d *dummyEnv) Name() string { + return "test" +} + +type dummyStateStart struct { + canSend *atomic.Bool +} + +func (d *dummyStateStart) String() string { + return "dummyStateStart" +} + +var ( + hexDecode = func(keyStr string) []byte { + keyBytes, _ := hex.DecodeString(keyStr) + return keyBytes + } + pub1, _ = btcec.ParsePubKey(hexDecode( + "02ec95e4e8ad994861b95fc5986eedaac24739e5ea3d0634db4c8ccd44cd" + + "a126ea", + )) + pub2, _ = btcec.ParsePubKey(hexDecode( + "0356167ba3e54ac542e86e906d4186aba9ca0b9df45001c62b753d33fe06" + + "f5b4e8", + )) +) + +func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv, +) (*StateTransition[dummyEvents, *dummyEnv], error) { + + switch newEvent := event.(type) { + case *goToFin: + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: &dummyStateFin{}, + }, nil + + // This state will loop back upon itself, but will also emit an event + // to head to the terminal state. + case *emitInternal: + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: &dummyStateStart{}, + NewEvents: fn.Some(EmittedEvent[dummyEvents]{ + InternalEvent: []dummyEvents{&goToFin{}}, + }), + }, nil + + // This state will proceed to the terminal state, but will emit all the + // possible daemon events. + case *daemonEvents: + // This send event can only succeed once the bool turns to + // true. After that, then we'll expect another event to take us + // to the final state. + sendEvent := &SendMsgEvent[dummyEvents]{ + TargetPeer: *pub1, + SendWhen: fn.Some(func() bool { + return d.canSend.Load() + }), + PostSendEvent: fn.Some(dummyEvents(&goToFin{})), + } + + // We'll also send out a normal send event that doesn't have + // any preconditions. + sendEvent2 := &SendMsgEvent[dummyEvents]{ + TargetPeer: *pub2, + } + + return &StateTransition[dummyEvents, *dummyEnv]{ + // We'll state in this state until the send succeeds + // based on our predicate. Then it'll transition to the + // final state. + NextState: &dummyStateStart{ + canSend: d.canSend, + }, + NewEvents: fn.Some(EmittedEvent[dummyEvents]{ + ExternalEvents: DaemonEventSet{ + sendEvent, sendEvent2, + &BroadcastTxn{ + Tx: &wire.MsgTx{}, + Label: "test", + }, + }, + }), + }, nil + + // This state will emit a RegisterConf event which uses a mapper to + // transition to the final state upon confirmation. + case *registerConf: + confMapper := func( + conf *chainntnfs.TxConfirmation) dummyEvents { + + // Map the conf details into our custom event. + return &confDetailsEvent{ + blockHash: *conf.BlockHash, + blockHeight: conf.BlockHeight, + } + } + + regConfEvent := &RegisterConf[dummyEvents]{ + Txid: chainhash.Hash{1}, + PkScript: []byte{0x01}, + HeightHint: 100, + FullBlock: newEvent.fullBlock, + PostConfMapper: fn.Some[ConfMapper[dummyEvents]]( + confMapper, + ), + } + + return &StateTransition[dummyEvents, *dummyEnv]{ + // Stay in the start state until the conf event is + // received and mapped. + NextState: &dummyStateStart{ + canSend: d.canSend, + }, + NewEvents: fn.Some(EmittedEvent[dummyEvents]{ + ExternalEvents: DaemonEventSet{ + regConfEvent, + }, + }), + }, nil + + // This event contains details from the confirmation and signals us to + // transition to the final state. + case *confDetailsEvent: + // We received the mapped confirmation details, transition to + // the confirmed state. + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: &dummyStateConfirmed{ + blockHash: newEvent.blockHash, + blockHeight: newEvent.blockHeight, + }, + }, nil + + // This state will emit a RegisterSpend event which uses a mapper to + // transition to the spent state upon spend detection. + case *registerSpend: + spendMapper := func( + spend *chainntnfs.SpendDetail) dummyEvents { + + // Map the spend details into our custom event. + return &spendDetailsEvent{ + spenderTxHash: *spend.SpenderTxHash, + spendingHeight: spend.SpendingHeight, + } + } + + regSpendEvent := &RegisterSpend[dummyEvents]{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{3}}, + PkScript: []byte{0x03}, + HeightHint: 300, + PostSpendEvent: fn.Some[SpendMapper[dummyEvents]]( + spendMapper, + ), + } + + return &StateTransition[dummyEvents, *dummyEnv]{ + // Stay in the start state until the spend event is + // received and mapped. + NextState: &dummyStateStart{ + canSend: d.canSend, + }, + NewEvents: fn.Some(EmittedEvent[dummyEvents]{ + ExternalEvents: DaemonEventSet{ + regSpendEvent, + }, + }), + }, nil + + // This event contains details from the spend notification and signals + // us to transition to the spent state. + case *spendDetailsEvent: + // We received the mapped spend details, transition to the + // spent state. + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: &dummyStateSpent{ + spenderTxHash: newEvent.spenderTxHash, + spendingHeight: newEvent.spendingHeight, + }, + }, nil + } + + return nil, fmt.Errorf("unknown event: %T", event) +} + +func (d *dummyStateStart) IsTerminal() bool { + return false +} + +type dummyStateFin struct { +} + +func (d *dummyStateFin) String() string { + return "dummyStateFin" +} + +func (d *dummyStateFin) ProcessEvent(event dummyEvents, env *dummyEnv, +) (*StateTransition[dummyEvents, *dummyEnv], error) { + + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: &dummyStateFin{}, + }, nil +} + +func (d *dummyStateFin) IsTerminal() bool { + return true +} + +type dummyStateConfirmed struct { + blockHash chainhash.Hash + blockHeight uint32 +} + +func (d *dummyStateConfirmed) String() string { + return "dummyStateConfirmed" +} + +func (d *dummyStateConfirmed) ProcessEvent(event dummyEvents, env *dummyEnv, +) (*StateTransition[dummyEvents, *dummyEnv], error) { + + // This is a terminal state, no further transitions. + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: d, + }, nil +} + +func (d *dummyStateConfirmed) IsTerminal() bool { + return true +} + +type dummyStateSpent struct { + spenderTxHash chainhash.Hash + spendingHeight int32 +} + +func (d *dummyStateSpent) String() string { + return "dummyStateSpent" +} + +func (d *dummyStateSpent) ProcessEvent(event dummyEvents, env *dummyEnv, +) (*StateTransition[dummyEvents, *dummyEnv], error) { + + // This is a terminal state, no further transitions. + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: d, + }, nil +} + +func (d *dummyStateSpent) IsTerminal() bool { + return true +} + +// assertState asserts that the state machine is currently in the expected +// state type and returns the state cast to that type. +func assertState[Event any, Env Environment, S State[Event, Env]](t *testing.T, + m *StateMachine[Event, Env], expectedState S) S { + + state, err := m.CurrentState() + require.NoError(t, err) + require.IsType(t, expectedState, state) + + // Perform the type assertion to return the concrete type. + concreteState, ok := state.(S) + require.True(t, ok, "state type assertion failed") + + return concreteState +} + +func assertStateTransitions[Event any, Env Environment]( + t *testing.T, stateSub StateSubscriber[Event, Env], + expectedStates []State[Event, Env]) { + + for _, expectedState := range expectedStates { + newState := <-stateSub.NewItemCreated.ChanOut() + + require.IsType(t, expectedState, newState) + } +} + +type dummyAdapters struct { + mock.Mock + + confChan chan *chainntnfs.TxConfirmation + spendChan chan *chainntnfs.SpendDetail +} + +func newDaemonAdapters() *dummyAdapters { + return &dummyAdapters{ + confChan: make(chan *chainntnfs.TxConfirmation, 1), + spendChan: make(chan *chainntnfs.SpendDetail, 1), + } +} + +func (d *dummyAdapters) SendMessages(pub btcec.PublicKey, + msgs []lnwire.Message) error { + + args := d.Called(pub, msgs) + + return args.Error(0) +} + +func (d *dummyAdapters) BroadcastTransaction(tx *wire.MsgTx, + label string) error { + + args := d.Called(tx, label) + + return args.Error(0) +} + +func (d *dummyAdapters) RegisterConfirmationsNtfn(txid *chainhash.Hash, + pkScript []byte, numConfs, heightHint uint32, + opts ...chainntnfs.NotifierOption, +) (*chainntnfs.ConfirmationEvent, error) { + + // Pass opts as the last argument to the mock call checker. + args := d.Called(txid, pkScript, numConfs, heightHint, opts) + + err := args.Error(0) + + return &chainntnfs.ConfirmationEvent{ + Confirmed: d.confChan, + }, err +} + +func (d *dummyAdapters) RegisterSpendNtfn(outpoint *wire.OutPoint, + pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) { + + args := d.Called(outpoint, pkScript, heightHint) + + err := args.Error(0) + + return &chainntnfs.SpendEvent{ + Spend: d.spendChan, + }, err +} + +// TestStateMachineOnInitDaemonEvent tests that the state machine will properly +// execute any init-level daemon events passed into it. +func TestStateMachineOnInitDaemonEvent(t *testing.T) { + ctx := t.Context() + + // First, we'll create our state machine given the env, and our + // starting state. + env := &dummyEnv{} + startingState := &dummyStateStart{} + + adapters := newDaemonAdapters() + + // We'll make an init event that'll send to a peer, then transition us + // to our terminal state. + initEvent := &SendMsgEvent[dummyEvents]{ + TargetPeer: *pub1, + PostSendEvent: fn.Some(dummyEvents(&goToFin{})), + } + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + InitEvent: fn.Some[DaemonEvent](initEvent), + } + stateMachine := NewStateMachine(cfg) + + // Before we start up the state machine, we'll assert that the send + // message adapter is called on start up. + adapters.On("SendMessages", *pub1, mock.Anything).Return(nil) + + // As we're triggering internal events, we'll also subscribe to the set + // of new states so we can assert as we go. + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + // Assert that we go from the starting state to the final state. The + // state machine should now also be on the final terminal state. + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateStart{}, &dummyStateFin{}, + } + assertStateTransitions(t, stateSub, expectedStates) + + // We'll now assert that after the daemon was started, the send message + // adapter was called above as specified in the init event. + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} + +// TestStateMachineInternalEvents tests that the state machine is able to add +// new internal events to the event queue for further processing during a state +// transition. +func TestStateMachineInternalEvents(t *testing.T) { + t.Parallel() + ctx := t.Context() + + // First, we'll create our state machine given the env, and our + // starting state. + env := &dummyEnv{} + startingState := &dummyStateStart{} + + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + InitEvent: fn.None[DaemonEvent](), + } + stateMachine := NewStateMachine(cfg) + + // As we're triggering internal events, we'll also subscribe to the set + // of new states so we can assert as we go. + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + // For this transition, we'll send in the emitInternal event, which'll + // send us back to the starting event, but emit an internal event. + stateMachine.SendEvent(ctx, &emitInternal{}) + + // We'll now also assert the path we took to get here to ensure the + // internal events were processed. + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateStart{}, &dummyStateStart{}, &dummyStateFin{}, + } + assertStateTransitions( + t, stateSub, expectedStates, + ) + + // We should ultimately end up in the terminal state. + assertState[dummyEvents, *dummyEnv](t, &stateMachine, &dummyStateFin{}) + + // Make sure all the env expectations were met. + env.AssertExpectations(t) +} + +// TestStateMachineDaemonEvents tests that the state machine is able to process +// daemon emitted as part of the state transition process. +func TestStateMachineDaemonEvents(t *testing.T) { + t.Parallel() + ctx := t.Context() + + // First, we'll create our state machine given the env, and our + // starting state. + env := &dummyEnv{} + + var boolTrigger atomic.Bool + startingState := &dummyStateStart{ + canSend: &boolTrigger, + } + + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + InitEvent: fn.None[DaemonEvent](), + } + stateMachine := NewStateMachine(cfg) + + // Before we start up the state machine, we'll assert that the machine + // is not running. + require.False(t, stateMachine.IsRunning()) + + // As we're triggering internal events, we'll also subscribe to the set + // of new states so we can assert as we go. + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer func() { + stateMachine.Stop() + + // After we stop the state machine, we expect it to no longer be + // running. + require.False(t, stateMachine.IsRunning()) + }() + + // The state machine should now be running. + require.True(t, stateMachine.IsRunning()) + + // As soon as we send in the daemon event, we expect the + // disable+broadcast events to be processed, as they are unconditional. + adapters.On( + "BroadcastTransaction", mock.Anything, mock.Anything, + ).Return(nil) + adapters.On("SendMessages", *pub2, mock.Anything).Return(nil) + + // We'll start off by sending in the daemon event, which'll trigger the + // state machine to execute the series of daemon events. + stateMachine.SendEvent(ctx, &daemonEvents{}) + + // We should transition back to the starting state now, after we + // started from the very same state. + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateStart{}, &dummyStateStart{}, + } + assertStateTransitions(t, stateSub, expectedStates) + + // At this point, we expect that the two methods above were called. + adapters.AssertExpectations(t) + + // However, we don't expect the SendMessages for the first peer target + // to be called yet, as the condition hasn't yet been met. + adapters.AssertNotCalled(t, "SendMessages", *pub1) + + // We'll now flip the bool to true, which should cause the SendMessages + // method to be called, and for us to transition to the final state. + boolTrigger.Store(true) + adapters.On("SendMessages", *pub1, mock.Anything).Return(nil) + + expectedStates = []State[dummyEvents, *dummyEnv]{&dummyStateFin{}} + assertStateTransitions(t, stateSub, expectedStates) + + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} + +// testStateMachineConfMapperImpl is a helper function that encapsulates the +// core logic for testing the confirmation mapping functionality of the state +// machine. It takes a boolean flag `fullBlock` to determine whether to test the +// scenario where full block details are requested in the confirmation +// notification. +func testStateMachineConfMapperImpl(t *testing.T, fullBlock bool) { + ctx := t.Context() + + // Create the state machine. + env := &dummyEnv{} + startingState := &dummyStateStart{} + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + } + stateMachine := NewStateMachine(cfg) + + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + // Define the expected arguments for the mock call. + expectedTxid := &chainhash.Hash{1} + expectedPkScript := []byte{0x01} + expectedNumConfs := uint32(1) + expectedHeightHint := uint32(100) + + // Set up the mock expectation based on the FullBlock flag. We use + // mock.MatchedBy to assert the options passed. + if fullBlock { + // Expect WithIncludeBlock() option when FullBlock is true. + adapters.On( + "RegisterConfirmationsNtfn", + expectedTxid, expectedPkScript, + expectedNumConfs, expectedHeightHint, + mock.MatchedBy( + func(opts []chainntnfs.NotifierOption) bool { + // Check if exactly one option is passed + // and it's the correct type. Unless we + // use reflect, we can introspect into + // the private fields. + return len(opts) == 1 + }, + ), + ).Return(nil) + } else { + // Expect no options when FullBlock is false. + adapters.On( + "RegisterConfirmationsNtfn", + expectedTxid, expectedPkScript, + expectedNumConfs, expectedHeightHint, + mock.MatchedBy(func(opts []chainntnfs.NotifierOption) bool { //nolint:ll + return len(opts) == 0 + }), + ).Return(nil) + } + + // Create the registerConf event with the specified FullBlock value. + regConfEvent := ®isterConf{ + fullBlock: fullBlock, + } + + // Send the event that triggers RegisterConf emission. + stateMachine.SendEvent(ctx, regConfEvent) + + // We should transition back to the starting state initially. + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateStart{}, &dummyStateStart{}, + } + assertStateTransitions(t, stateSub, expectedStates) + + // Assert the registration call was made with the correct arguments + // (including options). + adapters.AssertExpectations(t) + + // Now, simulate the confirmation event coming back from the notifier. + simulatedConf := &chainntnfs.TxConfirmation{ + BlockHash: &chainhash.Hash{2}, + BlockHeight: 123, + } + adapters.confChan <- simulatedConf + + // This should trigger the mapper and send the confDetailsEvent, + // transitioning us to the confirmed state. + expectedStates = []State[dummyEvents, *dummyEnv]{&dummyStateConfirmed{}} + assertStateTransitions(t, stateSub, expectedStates) + + // Final state assertion. + finalState := assertState(t, &stateMachine, &dummyStateConfirmed{}) + + // Assert that the details from the confirmation event were correctly + // propagated to the final state. + require.Equal(t, + *simulatedConf.BlockHash, finalState.blockHash, + ) + require.Equal(t, + simulatedConf.BlockHeight, finalState.blockHeight, + ) + + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} + +// TestStateMachineConfMapper tests the confirmation mapping functionality using +// subtests driven by the testStateMachineConfMapperImpl helper function. It +// covers scenarios both with and without requesting the full block details. +func TestStateMachineConfMapper(t *testing.T) { + t.Parallel() + + t.Run("full block false", func(t *testing.T) { + t.Parallel() + testStateMachineConfMapperImpl(t, false) + }) + + t.Run("full block true", func(t *testing.T) { + t.Parallel() + testStateMachineConfMapperImpl(t, true) + }) +} + +// TestStateMachineSpendMapper tests that the state machine is able to properly +// map the spend event into a custom event that can be used to trigger a state +// transition. +func TestStateMachineSpendMapper(t *testing.T) { + t.Parallel() + ctx := t.Context() + + // Create the state machine. + env := &dummyEnv{} + startingState := &dummyStateStart{} + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + } + stateMachine := NewStateMachine(cfg) + + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + // Expect the RegisterSpendNtfn call when we send the event. + targetOutpoint := &wire.OutPoint{Hash: chainhash.Hash{3}} + targetPkScript := []byte{0x03} + targetHeightHint := uint32(300) + adapters.On( + "RegisterSpendNtfn", targetOutpoint, targetPkScript, + targetHeightHint, + ).Return(nil) + + // Send the event that triggers RegisterSpend emission. + stateMachine.SendEvent(ctx, ®isterSpend{}) + + // We should transition back to the starting state initially. + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateStart{}, &dummyStateStart{}, + } + assertStateTransitions(t, stateSub, expectedStates) + + // Assert the registration call was made. + adapters.AssertExpectations(t) + + // Now, simulate the spend event coming back from the notifier. Populate + // it with some data to be mapped. + simulatedSpend := &chainntnfs.SpendDetail{ + SpentOutPoint: targetOutpoint, + SpenderTxHash: &chainhash.Hash{4}, + SpendingTx: &wire.MsgTx{}, + SpendingHeight: 456, + } + adapters.spendChan <- simulatedSpend + + // This should trigger the mapper and send the spendDetailsEvent, + // transitioning us to the spent state. + expectedStates = []State[dummyEvents, *dummyEnv]{&dummyStateSpent{}} + assertStateTransitions(t, stateSub, expectedStates) + + // Final state assertion. + finalState := assertState(t, &stateMachine, &dummyStateSpent{}) + + // Assert that the details from the spend event were correctly + // propagated to the final state. + require.Equal(t, + *simulatedSpend.SpenderTxHash, finalState.spenderTxHash, + ) + require.Equal(t, + simulatedSpend.SpendingHeight, finalState.spendingHeight, + ) + + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} + +type dummyMsgMapper struct { + mock.Mock +} + +func (d *dummyMsgMapper) MapMsg(wireMsg msgmux.PeerMsg) fn.Option[dummyEvents] { + args := d.Called(wireMsg) + + //nolint:forcetypeassert + return args.Get(0).(fn.Option[dummyEvents]) +} + +// TestStateMachineMsgMapper tests that given a message mapper, we can properly +// send in wire messages get mapped to FSM events. +func TestStateMachineMsgMapper(t *testing.T) { + ctx := t.Context() + + // First, we'll create our state machine given the env, and our + // starting state. + env := &dummyEnv{} + startingState := &dummyStateStart{} + adapters := newDaemonAdapters() + + // We'll also provide a message mapper that only knows how to map a + // single wire message (error). + dummyMapper := &dummyMsgMapper{} + + // The only thing we know how to map is the error message, which'll + // terminate the state machine. + wireError := msgmux.PeerMsg{ + Message: &lnwire.Error{}, + } + initMsg := msgmux.PeerMsg{ + Message: &lnwire.Init{}, + } + dummyMapper.On("MapMsg", wireError).Return( + fn.Some(dummyEvents(&goToFin{})), + ) + dummyMapper.On("MapMsg", initMsg).Return(fn.None[dummyEvents]()) + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + MsgMapper: fn.Some[MsgMapper[dummyEvents]](dummyMapper), + } + stateMachine := NewStateMachine(cfg) + + // As we're triggering internal events, we'll also subscribe to the set + // of new states so we can assert as we go. + // + // We register before calling Start to ensure we don't miss any events. + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + // First, we'll verify that the CanHandle method works as expected. + require.True(t, stateMachine.CanHandle(wireError)) + require.False(t, stateMachine.CanHandle(initMsg)) + + // Next, we'll attempt to send the wire message into the state machine. + // We should transition to the final state. + require.True(t, stateMachine.SendMessage(ctx, wireError)) + + // We should transition to the final state. + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateStart{}, &dummyStateFin{}, + } + assertStateTransitions(t, stateSub, expectedStates) + + dummyMapper.AssertExpectations(t) + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} From 6f89b0ed1b6ffec0bbbe6adbf020f24cb9ff9083 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 13:56:45 +0100 Subject: [PATCH 3/8] baselib/protofsm: add roasbeef changes from lnd PR #10346 --- baselib/protofsm/actor_wrapper.go | 23 ++ baselib/protofsm/state_machine.go | 153 ++++++++++- baselib/protofsm/state_machine_test.go | 345 +++++++++++++++++++++++++ 3 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 baselib/protofsm/actor_wrapper.go diff --git a/baselib/protofsm/actor_wrapper.go b/baselib/protofsm/actor_wrapper.go new file mode 100644 index 000000000..85666aa54 --- /dev/null +++ b/baselib/protofsm/actor_wrapper.go @@ -0,0 +1,23 @@ +package protofsm + +import ( + "fmt" + + "github.com/lightninglabs/darepo-client/baselib/actor" +) + +// ActorMessage wraps an Event, in order to create a new message that can be +// used with the actor package. +type ActorMessage[Event any] struct { + actor.BaseMessage + + // Event is the event that is being sent to the actor. + Event Event +} + +// MessageType returns the type of the message. +// +// NOTE: This implements the actor.Message interface. +func (a ActorMessage[Event]) MessageType() string { + return fmt.Sprintf("ActorMessage(%T)", a.Event) +} diff --git a/baselib/protofsm/state_machine.go b/baselib/protofsm/state_machine.go index b3e16f5fd..e08ebfdae 100644 --- a/baselib/protofsm/state_machine.go +++ b/baselib/protofsm/state_machine.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnutils" @@ -42,6 +43,12 @@ type EmittedEvent[Event any] struct { // ExternalEvent is an optional external event that is to be sent to // the daemon for dispatch. Usually, this is some form of I/O. ExternalEvents DaemonEventSet + + // Outbox is an optional set of events that are accumulated during event + // processing and returned to the caller for processing into the main + // state machine. This enables nested state machines to emit events that + // bubble up to their parent. + Outbox []Event } // StateTransition is a state transition type. It denotes the next state to go @@ -124,6 +131,18 @@ type stateQuery[Event any, Env Environment] struct { CurrentState chan State[Event, Env] } +// syncEventRequest is used to send an event to the state machine synchronously, +// waiting for the event processing to complete and returning the accumulated +// outbox events. +type syncEventRequest[Event any] struct { + // event is the event to process. + event Event + + // promise is used to signal completion and return the accumulated + // outbox events or an error. + promise actor.Promise[[]Event] +} + // StateMachine represents an abstract FSM that is able to process new incoming // events and drive a state machine to termination. This implementation uses // type params to abstract over the types of events and environment. Events @@ -140,6 +159,10 @@ type StateMachine[Event any, Env Environment] struct { // FSM. events chan Event + // syncEvents is the channel that will be used to send synchronous event + // requests to the FSM, returning the accumulated outbox events. + syncEvents chan syncEventRequest[Event] + // newStateEvents is an EventDistributor that will be used to notify // any relevant callers of new state transitions that occur. newStateEvents *fn.EventDistributor[State[Event, Env]] @@ -214,6 +237,7 @@ func NewStateMachine[Event any, Env Environment]( fmt.Sprintf("FSM(%v):", cfg.Env.Name()), ), events: make(chan Event, 1), + syncEvents: make(chan syncEventRequest[Event], 1), stateQuery: make(chan stateQuery[Event, Env]), gm: *fn.NewGoroutineManager(), newStateEvents: fn.NewEventDistributor[State[Event, Env]](), @@ -259,6 +283,84 @@ func (s *StateMachine[Event, Env]) SendEvent(ctx context.Context, event Event) { } } +// AskEvent sends a new event to the state machine using the Ask pattern +// (request-response), waiting for the event to be fully processed. It +// returns a Future that will be resolved with the accumulated outbox events +// from all state transitions triggered by this event, including nested +// internal events. The Future's Await method will return fn.Result[[]Event] +// containing either the accumulated outbox events or an error if processing +// failed. +func (s *StateMachine[Event, Env]) AskEvent(ctx context.Context, + event Event) actor.Future[[]Event] { + + s.log.Debugf("Asking event %T", event) + + // Create a promise to signal completion and return results. + promise := actor.NewPromise[[]Event]() + + req := syncEventRequest[Event]{ + event: event, + promise: promise, + } + + // Check for context cancellation or shutdown first to avoid races. + select { + case <-ctx.Done(): + promise.Complete( + fn.Errf[[]Event]("context cancelled: %w", + ctx.Err()), + ) + + return promise.Future() + + case <-s.quit: + promise.Complete(fn.Err[[]Event](ErrStateMachineShutdown)) + + return promise.Future() + + default: + } + + // Send the request to the state machine. If we can't send it due to + // context cancellation or shutdown, complete the promise with an error. + select { + // Successfully sent, the promise will be completed by driveMachine. + case s.syncEvents <- req: + + case <-ctx.Done(): + promise.Complete( + fn.Errf[[]Event]("context cancelled: %w", + ctx.Err()), + ) + + case <-s.quit: + promise.Complete(fn.Err[[]Event](ErrStateMachineShutdown)) + } + + return promise.Future() +} + +// Receive processes a message and returns a Result containing the accumulated +// outbox events from the state machine. The provided context is the actor's +// internal context, which can be used to detect actor shutdown requests. +// +// This method uses the AskEvent pattern to wait for the event to be fully +// processed and collect any outbox events emitted during state transitions. +// This enables the actor system to propagate events from nested state machines +// up through the actor hierarchy. +// +// NOTE: This implements the actor.ActorBehavior interface. +func (s *StateMachine[Event, Env]) Receive(ctx context.Context, + e ActorMessage[Event]) fn.Result[[]Event] { + + // Use AskEvent to process the event and get the outbox events back. + future := s.AskEvent(ctx, e.Event) + + // Await the result which will contain the accumulated outbox events + // from all state transitions triggered by this event. + return future.Await(ctx) +} + // CanHandle returns true if the target message can be routed to the state // machine. func (s *StateMachine[Event, Env]) CanHandle(msg msgmux.PeerMsg) bool { @@ -563,13 +665,19 @@ func (s *StateMachine[Event, Env]) executeDaemonEvent(ctx context.Context, // applyEvents applies a new event to the state machine. This will continue // until no further events are emitted by the state machine. Along the way, -// we'll also ensure to execute any daemon events that are emitted. +// we'll also ensure to execute any daemon events that are emitted. The +// function returns the final state, any accumulated outbox events, and an +// error if one occurred. func (s *StateMachine[Event, Env]) applyEvents(ctx context.Context, currentState State[Event, Env], newEvent Event) (State[Event, Env], - error) { + []Event, error) { eventQueue := fn.NewQueue(newEvent) + // outbox accumulates all outbox events from state transitions during + // the entire event processing chain. + var outbox []Event + // Given the next event to handle, we'll process the event, then add // any new emitted internal events to our event queue. This continues // until we reach a terminal state, or we run out of internal events to @@ -613,6 +721,10 @@ func (s *StateMachine[Event, Env]) applyEvents(ctx context.Context, eventQueue.Enqueue(inEvent) } + // Accumulate any outbox events from this state + // transition. + outbox = append(outbox, events.Outbox...) + return nil }) if err != nil { @@ -636,11 +748,11 @@ func (s *StateMachine[Event, Env]) applyEvents(ctx context.Context, return nil }) if err != nil { - return currentState, err + return currentState, nil, err } } - return currentState, nil + return currentState, outbox, nil } // driveMachine is the main event loop of the state machine. It accepts any new @@ -671,7 +783,7 @@ func (s *StateMachine[Event, Env]) driveMachine(ctx context.Context) { // machine forward until we either run out of internal events, // or we reach a terminal state. case newEvent := <-s.events: - newState, err := s.applyEvents( + newState, _, err := s.applyEvents( ctx, currentState, newEvent, ) if err != nil { @@ -688,6 +800,37 @@ func (s *StateMachine[Event, Env]) driveMachine(ctx context.Context) { currentState = newState + // We have a synchronous event request that expects the + // accumulated outbox events to be returned via the promise. + case syncReq := <-s.syncEvents: + newState, outbox, err := s.applyEvents( + ctx, currentState, syncReq.event, + ) + if err != nil { + s.cfg.ErrorReporter.ReportError(err) + + s.log.ErrorS(ctx, "Unable to apply sync event", + err) + + // Complete the promise with the error. + // + // TODO(roasbeef): distinguish between error + // types? state vs processing + syncReq.promise.Complete(fn.Err[[]Event](err)) + + // An error occurred, so we'll tear down the + // entire state machine as we can't proceed. + go s.Stop() + + return + } + + currentState = newState + + // Complete the promise with the accumulated outbox + // events. + syncReq.promise.Complete(fn.Ok(outbox)) + // An outside caller is querying our state, so we'll return the // latest state. case stateQuery := <-s.stateQuery: diff --git a/baselib/protofsm/state_machine_test.go b/baselib/protofsm/state_machine_test.go index ca060614f..469b76a77 100644 --- a/baselib/protofsm/state_machine_test.go +++ b/baselib/protofsm/state_machine_test.go @@ -1,6 +1,7 @@ package protofsm import ( + "context" "encoding/hex" "fmt" "sync/atomic" @@ -868,3 +869,347 @@ func TestStateMachineMsgMapper(t *testing.T) { adapters.AssertExpectations(t) env.AssertExpectations(t) } + +// outboxEvent is a test event type that gets added to the outbox. +type outboxEvent struct { + id int +} + +func (o *outboxEvent) dummy() { +} + +// emitOutbox is a test event that triggers a state to emit outbox events. +type emitOutbox struct { + numOutbox int + numInternal int + shouldGoToFin bool +} + +func (e *emitOutbox) dummy() { +} + +// dummyStateOutbox is a test state that emits outbox events during +// transitions. +type dummyStateOutbox struct { + counter int +} + +func (d *dummyStateOutbox) String() string { + return fmt.Sprintf("dummyStateOutbox(%d)", d.counter) +} + +func (d *dummyStateOutbox) ProcessEvent(event dummyEvents, env *dummyEnv, +) (*StateTransition[dummyEvents, *dummyEnv], error) { + + switch newEvent := event.(type) { + case *emitOutbox: + // Create outbox events based on the request. + outbox := make([]dummyEvents, newEvent.numOutbox) + for i := 0; i < newEvent.numOutbox; i++ { + outbox[i] = &outboxEvent{ + id: d.counter*100 + i, + } + } + + // Create internal events that will also emit outbox events. + internalEvents := make([]dummyEvents, newEvent.numInternal) + for i := 0; i < newEvent.numInternal; i++ { + internalEvents[i] = &emitOutbox{ + numOutbox: 1, + numInternal: 0, + shouldGoToFin: false, + } + } + + var nextState State[dummyEvents, *dummyEnv] + if newEvent.shouldGoToFin { + nextState = &dummyStateFin{} + } else { + nextState = &dummyStateOutbox{counter: d.counter + 1} + } + + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: nextState, + NewEvents: fn.Some(EmittedEvent[dummyEvents]{ + InternalEvent: internalEvents, + Outbox: outbox, + }), + }, nil + + case *goToFin: + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: &dummyStateFin{}, + }, nil + + case *outboxEvent: + // When processing an outbox event (shouldn't happen in normal + // flow), just stay in current state. + return &StateTransition[dummyEvents, *dummyEnv]{ + NextState: d, + }, nil + } + + return nil, fmt.Errorf("unknown event: %T", event) +} + +func (d *dummyStateOutbox) IsTerminal() bool { + return false +} + +// TestStateMachineAskEvent tests the AskEvent method and outbox event +// accumulation functionality. +func TestStateMachineAskEvent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + event dummyEvents + expectedOutboxCount int + expectError bool + }{ + { + name: "basic outbox accumulation", + event: &emitOutbox{ + numOutbox: 3, + numInternal: 0, + shouldGoToFin: false, + }, + expectedOutboxCount: 3, + expectError: false, + }, + + // 2 from top-level + 3 from internal events (1 each). + { + name: "nested internal events with outbox", + event: &emitOutbox{ + numOutbox: 2, + numInternal: 3, + shouldGoToFin: false, + }, + expectedOutboxCount: 5, + expectError: false, + }, + + { + name: "empty outbox", + event: &emitOutbox{ + numOutbox: 0, + numInternal: 0, + shouldGoToFin: false, + }, + expectedOutboxCount: 0, + expectError: false, + }, + + // 1 from top-level + 5 from internal events. + { + name: "deeply nested outbox", + event: &emitOutbox{ + numOutbox: 1, + numInternal: 5, + shouldGoToFin: false, + }, + expectedOutboxCount: 6, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + + // Create our state machine with the outbox test state. + env := &dummyEnv{} + startingState := &dummyStateOutbox{counter: 0} + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + } + stateMachine := NewStateMachine(cfg) + + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + // Wait for initial state. + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateOutbox{}, + } + assertStateTransitions(t, stateSub, expectedStates) + + // Send the event using Ask pattern. + future := stateMachine.AskEvent(ctx, tc.event) + require.NotNil(t, future) + + result := future.Await(ctx) + + if tc.expectError { + require.True(t, result.IsErr()) + } else { + require.True(t, result.IsOk()) + + // Extract the outbox events. + outbox := result.UnwrapOr(nil) + require.Len(t, outbox, tc.expectedOutboxCount) + + // Verify outbox events are of the correct type. + for _, event := range outbox { + _, ok := event.(*outboxEvent) + require.True(t, ok, + "expected outboxEvent, got %T", + event) + } + } + + adapters.AssertExpectations(t) + env.AssertExpectations(t) + }) + } +} + +// TestStateMachineOutboxWithMixedEvents tests that outbox accumulation works +// correctly when mixed with regular SendEvent calls. +func TestStateMachineOutboxWithMixedEvents(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create our state machine with the outbox test state. + env := &dummyEnv{} + startingState := &dummyStateOutbox{counter: 0} + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + } + stateMachine := NewStateMachine(cfg) + + // Subscribe to state transitions, then start the main state machine. + stateSub := stateMachine.RegisterStateEvents() + defer stateMachine.RemoveStateSub(stateSub) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + expectedStates := []State[dummyEvents, *dummyEnv]{ + &dummyStateOutbox{}, + } + assertStateTransitions(t, stateSub, expectedStates) + + // Send a regular async event first. + stateMachine.SendEvent(ctx, &emitOutbox{ + numOutbox: 1, + numInternal: 0, + shouldGoToFin: false, + }) + + // Wait for state transition from async event. + expectedStates = []State[dummyEvents, *dummyEnv]{ + &dummyStateOutbox{counter: 1}, + } + assertStateTransitions(t, stateSub, expectedStates) + + // Now send an event using Ask pattern. + future := stateMachine.AskEvent(ctx, &emitOutbox{ + numOutbox: 2, + numInternal: 1, + shouldGoToFin: false, + }) + + result := future.Await(ctx) + require.True(t, result.IsOk()) + + // We should have 3 outbox events (2 from top-level + 1 from internal). + outbox := result.UnwrapOr(nil) + require.Len(t, outbox, 3) + + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} + +// TestStateMachineAskEventContextCancellation tests that context cancellation +// is properly handled in AskEvent. +func TestStateMachineAskEventContextCancellation(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + env := &dummyEnv{} + startingState := &dummyStateOutbox{counter: 0} + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + } + stateMachine := NewStateMachine(cfg) + + stateMachine.Start(ctx) + defer stateMachine.Stop() + + // Create a context that's already cancelled. + cancelledCtx, cancel := context.WithCancel(t.Context()) + cancel() + + // Try to send an event with a cancelled context. + future := stateMachine.AskEvent(cancelledCtx, &emitOutbox{ + numOutbox: 1, + numInternal: 0, + shouldGoToFin: false, + }) + + // The future should be completed with an error. + result := future.Await(ctx) + require.True(t, result.IsErr()) + + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} + +// TestStateMachineAskEventAfterShutdown tests that AskEvent properly handles +// the case where the state machine has been shut down. +func TestStateMachineAskEventAfterShutdown(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create our state machine. + env := &dummyEnv{} + startingState := &dummyStateOutbox{counter: 0} + adapters := newDaemonAdapters() + + cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ + Daemon: adapters, + InitialState: startingState, + Env: env, + } + stateMachine := NewStateMachine(cfg) + + stateMachine.Start(ctx) + + // Stop the state machine. + stateMachine.Stop() + + // Try to send an event after shutdown. + future := stateMachine.AskEvent(ctx, &emitOutbox{ + numOutbox: 1, + numInternal: 0, + shouldGoToFin: false, + }) + + // The future should be completed with a shutdown error. + result := future.Await(ctx) + require.True(t, result.IsErr()) + require.ErrorIs(t, result.Err(), ErrStateMachineShutdown) + + adapters.AssertExpectations(t) + env.AssertExpectations(t) +} From b4b5de23fe3ede6a8aac400c60234141a21c930a Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 14:00:57 +0100 Subject: [PATCH 4/8] baselib/protofsm: refactor to seperate internal event --- baselib/protofsm/daemon_events.go | 130 --- baselib/protofsm/log.go | 29 - baselib/protofsm/msg_mapper.go | 15 - baselib/protofsm/state_machine.go | 492 ++-------- baselib/protofsm/state_machine_test.go | 1215 ------------------------ 5 files changed, 81 insertions(+), 1800 deletions(-) delete mode 100644 baselib/protofsm/daemon_events.go delete mode 100644 baselib/protofsm/log.go delete mode 100644 baselib/protofsm/msg_mapper.go delete mode 100644 baselib/protofsm/state_machine_test.go diff --git a/baselib/protofsm/daemon_events.go b/baselib/protofsm/daemon_events.go deleted file mode 100644 index 3b4ca9b4d..000000000 --- a/baselib/protofsm/daemon_events.go +++ /dev/null @@ -1,130 +0,0 @@ -package protofsm - -import ( - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" - "github.com/lightningnetwork/lnd/chainntnfs" - "github.com/lightningnetwork/lnd/fn/v2" - "github.com/lightningnetwork/lnd/lnwire" -) - -// DaemonEvent is a special event that can be emitted by a state transition -// function. A state machine can use this to perform side effects, such as -// sending a message to a peer, or broadcasting a transaction. -type DaemonEvent interface { - daemonSealed() -} - -// DaemonEventSet is a set of daemon events that can be emitted by a state -// transition. -type DaemonEventSet []DaemonEvent - -// DaemonEvents is a special type constraint that enumerates all the possible -// types of daemon events. -type DaemonEvents interface { - SendMsgEvent[any] | BroadcastTxn | RegisterSpend[any] | - RegisterConf[any] -} - -// SendPredicate is a function that returns true if the target message should -// sent. -type SendPredicate = func() bool - -// SendMsgEvent is a special event that can be emitted by a state transition -// that instructs the daemon to send the contained message to the target peer. -type SendMsgEvent[Event any] struct { - // TargetPeer is the peer to send the message to. - TargetPeer btcec.PublicKey - - // Msgs is the set of messages to send to the target peer. - Msgs []lnwire.Message - - // SendWhen implements a system for a conditional send once a special - // send predicate has been met. - // - // TODO(roasbeef): contrast with usage of OnCommitFlush, etc - SendWhen fn.Option[SendPredicate] - - // PostSendEvent is an optional event that is to be emitted after the - // message has been sent. If a SendWhen is specified, then this will - // only be executed after that returns true to unblock the send. - PostSendEvent fn.Option[Event] -} - -// daemonSealed indicates that this struct is a DaemonEvent instance. -func (s *SendMsgEvent[E]) daemonSealed() {} - -// BroadcastTxn indicates the target transaction should be broadcast to the -// network. -type BroadcastTxn struct { - // Tx is the transaction to broadcast. - Tx *wire.MsgTx - - // Label is an optional label to attach to the transaction. - Label string -} - -// daemonSealed indicates that this struct is a DaemonEvent instance. -func (b *BroadcastTxn) daemonSealed() {} - -// SpendMapper is a function that's used to map a spend notification to a -// custom state machine event. -type SpendMapper[Event any] func(*chainntnfs.SpendDetail) Event - -// ConfMapper is a function that's used to map a confirmation notification to a -// custom state machine event. -type ConfMapper[Event any] func(*chainntnfs.TxConfirmation) Event - -// RegisterSpend is used to request that a certain event is sent into the state -// machine once the specified outpoint has been spent. -type RegisterSpend[Event any] struct { - // OutPoint is the outpoint on chain to watch. - OutPoint wire.OutPoint - - // PkScript is the script that we expect to be spent along with the - // outpoint. - PkScript []byte - - // HeightHint is a value used to give the chain scanner a hint on how - // far back it needs to start its search. - HeightHint uint32 - - // PostSpendEvent is a special spend mapper, that if present, will be - // used to map the protofsm spend event to a custom event. - PostSpendEvent fn.Option[SpendMapper[Event]] -} - -// daemonSealed indicates that this struct is a DaemonEvent instance. -func (r *RegisterSpend[E]) daemonSealed() {} - -// RegisterConf is used to request that a certain event is sent into the state -// machien once the specified outpoint has been spent. -type RegisterConf[Event any] struct { - // Txid is the txid of the txn we want to watch the chain for. - Txid chainhash.Hash - - // PkScript is the script that we expect to be created along with the - // outpoint. - PkScript []byte - - // HeightHint is a value used to give the chain scanner a hint on how - // far back it needs to start its search. - HeightHint uint32 - - // NumConfs is the number of confirmations that the spending - // transaction needs to dispatch an event. - NumConfs fn.Option[uint32] - - // FullBlock is a boolean that indicates whether we want the full block - // in the returned response. This is useful if callers want to create an - // SPV proof for the transaction post conf. - FullBlock bool - - // PostConfMapper is a special conf mapper, that if present, will be - // used to map the protofsm confirmation event to a custom event. - PostConfMapper fn.Option[ConfMapper[Event]] -} - -// daemonSealed indicates that this struct is a DaemonEvent instance. -func (r *RegisterConf[E]) daemonSealed() {} diff --git a/baselib/protofsm/log.go b/baselib/protofsm/log.go deleted file mode 100644 index 6978f1e89..000000000 --- a/baselib/protofsm/log.go +++ /dev/null @@ -1,29 +0,0 @@ -package protofsm - -import ( - "github.com/btcsuite/btclog/v2" - "github.com/lightningnetwork/lnd/build" -) - -// log is a logger that is initialized with no output filters. This -// means the package will not perform any logging by default until the caller -// requests it. -var log btclog.Logger - -// The default amount of logging is none. -func init() { - UseLogger(build.NewSubLogger("PFSM", nil)) -} - -// DisableLog disables all library log output. Logging output is disabled -// by default until UseLogger is called. -func DisableLog() { - UseLogger(btclog.Disabled) -} - -// UseLogger uses a specified Logger to output package logging info. -// This should be used in preference to SetLogWriter if the caller is also -// using btclog. -func UseLogger(logger btclog.Logger) { - log = logger -} diff --git a/baselib/protofsm/msg_mapper.go b/baselib/protofsm/msg_mapper.go deleted file mode 100644 index a00d86379..000000000 --- a/baselib/protofsm/msg_mapper.go +++ /dev/null @@ -1,15 +0,0 @@ -package protofsm - -import ( - "github.com/lightningnetwork/lnd/fn/v2" - "github.com/lightningnetwork/lnd/msgmux" -) - -// MsgMapper is used to map incoming wire messages into a FSM event. This is -// useful to decouple the translation of an outside or wire message into an -// event type that can be understood by the FSM. -type MsgMapper[Event any] interface { - // MapMsg maps a wire message into a FSM event. If the message is not - // mappable, then an None is returned. - MapMsg(msg msgmux.PeerMsg) fn.Option[Event] -} diff --git a/baselib/protofsm/state_machine.go b/baselib/protofsm/state_machine.go index e08ebfdae..c166951b1 100644 --- a/baselib/protofsm/state_machine.go +++ b/baselib/protofsm/state_machine.go @@ -7,16 +7,10 @@ import ( "sync/atomic" "time" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" - "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnutils" - "github.com/lightningnetwork/lnd/lnwire" - "github.com/lightningnetwork/lnd/msgmux" ) const ( @@ -34,31 +28,27 @@ var ( // EmittedEvent is a special type that can be emitted by a state transition. // This can container internal events which are to be routed back to the state, // or external events which are to be sent to the daemon. -type EmittedEvent[Event any] struct { +type EmittedEvent[InboxEvent any, OutboxEvent any] struct { // InternalEvent is an optional internal event that is to be routed // back to the target state. This enables state to trigger one or many // state transitions without a new external event. - InternalEvent []Event - - // ExternalEvent is an optional external event that is to be sent to - // the daemon for dispatch. Usually, this is some form of I/O. - ExternalEvents DaemonEventSet + InternalEvent []InboxEvent // Outbox is an optional set of events that are accumulated during event // processing and returned to the caller for processing into the main // state machine. This enables nested state machines to emit events that // bubble up to their parent. - Outbox []Event + Outbox []OutboxEvent } // StateTransition is a state transition type. It denotes the next state to go // to, and also the set of events to emit. -type StateTransition[Event any, Env Environment] struct { +type StateTransition[InternalEvent any, OutboxEvent any, Env Environment] struct { // NextState is the next state to transition to. - NextState State[Event, Env] + NextState State[InternalEvent, OutboxEvent, Env] // NewEvents is the set of events to emit. - NewEvents fn.Option[EmittedEvent[Event]] + NewEvents fn.Option[EmittedEvent[InternalEvent, OutboxEvent]] } // Environment is an abstract interface that represents the environment that @@ -66,21 +56,19 @@ type StateTransition[Event any, Env Environment] struct { // executor, we just care about being able to clean up any resources that were // allocated by the environment. type Environment interface { - // Name returns the name of the environment. This is used to uniquely - // identify the environment of related state machines. - Name() string } // State defines an abstract state along, namely its state transition function // that takes as input an event and an environment, and returns a state // transition (next state, and set of events to emit). As state can also either // be terminal, or not, a terminal event causes state execution to halt. -type State[Event any, Env Environment] interface { +type State[InternalEvent any, OutboxEvent any, Env Environment] interface { // ProcessEvent takes an event and an environment, and returns a new // state transition. This will be iteratively called until either a // terminal state is reached, or no further internal events are // emitted. - ProcessEvent(event Event, env Env) (*StateTransition[Event, Env], error) + ProcessEvent(ctx context.Context, event InternalEvent, env Env) ( + *StateTransition[InternalEvent, OutboxEvent, Env], error) // IsTerminal returns true if this state is terminal, and false // otherwise. @@ -90,57 +78,24 @@ type State[Event any, Env Environment] interface { String() string } -// DaemonAdapters is a set of methods that server as adapters to bridge the -// pure world of the FSM to the real world of the daemon. These will be used to -// do things like broadcast transactions, or send messages to peers. -type DaemonAdapters interface { - // SendMessages sends the target set of messages to the target peer. - SendMessages(btcec.PublicKey, []lnwire.Message) error - - // BroadcastTransaction broadcasts a transaction with the target label. - BroadcastTransaction(*wire.MsgTx, string) error - - // RegisterConfirmationsNtfn registers an intent to be notified once - // txid reaches numConfs confirmations. We also pass in the pkScript as - // the default light client instead needs to match on scripts created - // in the block. If a nil txid is passed in, then not only should we - // match on the script, but we should also dispatch once the - // transaction containing the script reaches numConfs confirmations. - // This can be useful in instances where we only know the script in - // advance, but not the transaction containing it. - // - // TODO(roasbeef): could abstract further? - RegisterConfirmationsNtfn(txid *chainhash.Hash, pkScript []byte, - numConfs, heightHint uint32, - opts ...chainntnfs.NotifierOption) ( - *chainntnfs.ConfirmationEvent, error) - - // RegisterSpendNtfn registers an intent to be notified once the target - // outpoint is successfully spent within a transaction. The script that - // the outpoint creates must also be specified. This allows this - // interface to be implemented by BIP 158-like filtering. - RegisterSpendNtfn(outpoint *wire.OutPoint, pkScript []byte, - heightHint uint32) (*chainntnfs.SpendEvent, error) -} - // stateQuery is used by outside callers to query the internal state of the // state machine. -type stateQuery[Event any, Env Environment] struct { +type stateQuery[InternalEvent any, OutboxEvent any, Env Environment] struct { // CurrentState is a channel that will be sent the current state of the // state machine. - CurrentState chan State[Event, Env] + CurrentState chan State[InternalEvent, OutboxEvent, Env] } // syncEventRequest is used to send an event to the state machine synchronously, // waiting for the event processing to complete and returning the accumulated // outbox events. -type syncEventRequest[Event any] struct { +type syncEventRequest[InternalEvent any, OutboxEvent any] struct { // event is the event to process. - event Event + event InternalEvent // promise is used to signal completion and return the accumulated // outbox events or an error. - promise actor.Promise[[]Event] + promise actor.Promise[[]OutboxEvent] } // StateMachine represents an abstract FSM that is able to process new incoming @@ -150,26 +105,28 @@ type syncEventRequest[Event any] struct { // action. // // TODO(roasbeef): terminal check, daemon event execution, init? -type StateMachine[Event any, Env Environment] struct { - cfg StateMachineCfg[Event, Env] +type StateMachine[InternalEvent any, OutboxEvent any, + Env Environment] struct { + cfg StateMachineCfg[InternalEvent, OutboxEvent, Env] log btclog.Logger // events is the channel that will be used to send new events to the // FSM. - events chan Event + events chan InternalEvent // syncEvents is the channel that will be used to send synchronous event // requests to the FSM, returning the accumulated outbox events. - syncEvents chan syncEventRequest[Event] + syncEvents chan syncEventRequest[InternalEvent, OutboxEvent] // newStateEvents is an EventDistributor that will be used to notify // any relevant callers of new state transitions that occur. - newStateEvents *fn.EventDistributor[State[Event, Env]] + newStateEvents *fn.EventDistributor[State[ + InternalEvent, OutboxEvent, Env]] // stateQuery is a channel that will be used by outside callers to // query the internal state machine state. - stateQuery chan stateQuery[Event, Env] + stateQuery chan stateQuery[InternalEvent, OutboxEvent, Env] gm fn.GoroutineManager quit chan struct{} @@ -194,31 +151,20 @@ type ErrorReporter interface { // StateMachineCfg is a configuration struct that's used to create a new state // machine. -type StateMachineCfg[Event any, Env Environment] struct { +type StateMachineCfg[InternalEvent any, OutboxEvent any, Env Environment] struct { + // Logger is used for logging. + Logger btclog.Logger + // ErrorReporter is used to report errors that occur during state // transitions. ErrorReporter ErrorReporter - // Daemon is a set of adapters that will be used to bridge the FSM to - // the daemon. - Daemon DaemonAdapters - // InitialState is the initial state of the state machine. - InitialState State[Event, Env] + InitialState State[InternalEvent, OutboxEvent, Env] // Env is the environment that the state machine will use to execute. Env Env - // InitEvent is an optional event that will be sent to the state - // machine as if it was emitted at the onset of the state machine. This - // can be used to set up tracking state such as a txid confirmation - // event. - InitEvent fn.Option[DaemonEvent] - - // MsgMapper is an optional message mapper that can be used to map - // normal wire messages into FSM events. - MsgMapper fn.Option[MsgMapper[Event]] - // CustomPollInterval is an optional custom poll interval that can be // used to set a quicker interval for tests. CustomPollInterval fn.Option[time.Duration] @@ -228,26 +174,26 @@ type StateMachineCfg[Event any, Env Environment] struct { // an initial state, an environment, and an event to process as if emitted at // the onset of the state machine. Such an event can be used to set up tracking // state such as a txid confirmation event. -func NewStateMachine[Event any, Env Environment]( - cfg StateMachineCfg[Event, Env]) StateMachine[Event, Env] { - - return StateMachine[Event, Env]{ - cfg: cfg, - log: log.WithPrefix( - fmt.Sprintf("FSM(%v):", cfg.Env.Name()), - ), - events: make(chan Event, 1), - syncEvents: make(chan syncEventRequest[Event], 1), - stateQuery: make(chan stateQuery[Event, Env]), - gm: *fn.NewGoroutineManager(), - newStateEvents: fn.NewEventDistributor[State[Event, Env]](), - quit: make(chan struct{}), +func NewStateMachine[InternalEvent any, OutboxEvent any, Env Environment]( + cfg StateMachineCfg[InternalEvent, OutboxEvent, Env]) StateMachine[ + InternalEvent, OutboxEvent, Env] { + + return StateMachine[InternalEvent, OutboxEvent, Env]{ + cfg: cfg, + log: cfg.Logger, + events: make(chan InternalEvent, 1), + syncEvents: make(chan syncEventRequest[InternalEvent, OutboxEvent], 1), + stateQuery: make(chan stateQuery[InternalEvent, OutboxEvent, Env]), + gm: *fn.NewGoroutineManager(), + newStateEvents: fn.NewEventDistributor[State[ + InternalEvent, OutboxEvent, Env]](), + quit: make(chan struct{}), } } // Start starts the state machine. This will spawn a goroutine that will drive // the state machine to completion. -func (s *StateMachine[Event, Env]) Start(ctx context.Context) { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) Start(ctx context.Context) { s.startOnce.Do(func() { _ = s.gm.Go(ctx, func(ctx context.Context) { s.driveMachine(ctx) @@ -259,7 +205,7 @@ func (s *StateMachine[Event, Env]) Start(ctx context.Context) { // Stop stops the state machine. This will block until the state machine has // reached a stopping point. -func (s *StateMachine[Event, Env]) Stop() { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) Stop() { s.stopOnce.Do(func() { close(s.quit) s.gm.Stop() @@ -271,7 +217,8 @@ func (s *StateMachine[Event, Env]) Stop() { // SendEvent sends a new event to the state machine. // // TODO(roasbeef): bool if processed? -func (s *StateMachine[Event, Env]) SendEvent(ctx context.Context, event Event) { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) SendEvent(ctx context.Context, + event InternalEvent) { s.log.Debugf("Sending event %T", event) select { @@ -287,18 +234,18 @@ func (s *StateMachine[Event, Env]) SendEvent(ctx context.Context, event Event) { // (request-response), waiting for the event to be fully processed. It // returns a Future that will be resolved with the accumulated outbox events // from all state transitions triggered by this event, including nested -// internal events. The Future's Await method will return fn.Result[[]Event] +// internal events. The Future's Await method will return fn.Result[[]OutboxEvent] // containing either the accumulated outbox events or an error if processing // failed. -func (s *StateMachine[Event, Env]) AskEvent(ctx context.Context, - event Event) actor.Future[[]Event] { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) AskEvent( + ctx context.Context, event InternalEvent) actor.Future[[]OutboxEvent] { s.log.Debugf("Asking event %T", event) // Create a promise to signal completion and return results. - promise := actor.NewPromise[[]Event]() + promise := actor.NewPromise[[]OutboxEvent]() - req := syncEventRequest[Event]{ + req := syncEventRequest[InternalEvent, OutboxEvent]{ event: event, promise: promise, } @@ -307,14 +254,14 @@ func (s *StateMachine[Event, Env]) AskEvent(ctx context.Context, select { case <-ctx.Done(): promise.Complete( - fn.Errf[[]Event]("context cancelled: %w", + fn.Errf[[]OutboxEvent]("context cancelled: %w", ctx.Err()), ) return promise.Future() case <-s.quit: - promise.Complete(fn.Err[[]Event](ErrStateMachineShutdown)) + promise.Complete(fn.Err[[]OutboxEvent](ErrStateMachineShutdown)) return promise.Future() @@ -328,13 +275,11 @@ func (s *StateMachine[Event, Env]) AskEvent(ctx context.Context, case s.syncEvents <- req: case <-ctx.Done(): - promise.Complete( - fn.Errf[[]Event]("context cancelled: %w", - ctx.Err()), - ) + promise.Complete(fn.Errf[[]OutboxEvent]("context cancelled: %w", + ctx.Err())) case <-s.quit: - promise.Complete(fn.Err[[]Event](ErrStateMachineShutdown)) + promise.Complete(fn.Err[[]OutboxEvent](ErrStateMachineShutdown)) } return promise.Future() @@ -350,8 +295,8 @@ func (s *StateMachine[Event, Env]) AskEvent(ctx context.Context, // up through the actor hierarchy. // // NOTE: This implements the actor.ActorBehavior interface. -func (s *StateMachine[Event, Env]) Receive(ctx context.Context, - e ActorMessage[Event]) fn.Result[[]Event] { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) Receive( + ctx context.Context, e ActorMessage[InternalEvent]) fn.Result[[]OutboxEvent] { // Use AskEvent to process the event and get the outbox events back. future := s.AskEvent(ctx, e.Event) @@ -361,56 +306,12 @@ func (s *StateMachine[Event, Env]) Receive(ctx context.Context, return future.Await(ctx) } -// CanHandle returns true if the target message can be routed to the state -// machine. -func (s *StateMachine[Event, Env]) CanHandle(msg msgmux.PeerMsg) bool { - cfgMapper := s.cfg.MsgMapper - return fn.MapOptionZ(cfgMapper, func(mapper MsgMapper[Event]) bool { - return mapper.MapMsg(msg).IsSome() - }) -} - -// Name returns the name of the state machine's environment. -func (s *StateMachine[Event, Env]) Name() string { - return s.cfg.Env.Name() -} - -// SendMessage attempts to send a wire message to the state machine. If the -// message can be mapped using the default message mapper, then true is -// returned indicating that the message was processed. Otherwise, false is -// returned. -func (s *StateMachine[Event, Env]) SendMessage(ctx context.Context, - msg msgmux.PeerMsg) bool { - - // If we have no message mapper, then return false as we can't process - // this message. - if !s.cfg.MsgMapper.IsSome() { - return false - } - - s.log.DebugS(ctx, "Sending msg", "msg", lnutils.SpewLogClosure(msg)) - - // Otherwise, try to map the message using the default message mapper. - // If we can't extract an event, then we'll return false to indicate - // that the message wasn't processed. - var processed bool - s.cfg.MsgMapper.WhenSome(func(mapper MsgMapper[Event]) { - event := mapper.MapMsg(msg) - - event.WhenSome(func(event Event) { - s.SendEvent(ctx, event) - - processed = true - }) - }) - - return processed -} - // CurrentState returns the current state of the state machine. -func (s *StateMachine[Event, Env]) CurrentState() (State[Event, Env], error) { - query := stateQuery[Event, Env]{ - CurrentState: make(chan State[Event, Env], 1), +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) CurrentState() ( + State[InternalEvent, OutboxEvent, Env], error) { + + query := stateQuery[InternalEvent, OutboxEvent, Env]{ + CurrentState: make(chan State[InternalEvent, OutboxEvent, Env], 1), } if !fn.SendOrQuit(s.stateQuery, query, s.quit) { @@ -422,14 +323,14 @@ func (s *StateMachine[Event, Env]) CurrentState() (State[Event, Env], error) { // StateSubscriber represents an active subscription to be notified of new // state transitions. -type StateSubscriber[E any, F Environment] *fn.EventReceiver[State[E, F]] +type StateSubscriber[InternalEvent any, OutboxEvent any, Env Environment] *fn.EventReceiver[State[InternalEvent, OutboxEvent, Env]] // RegisterStateEvents registers a new event listener that will be notified of // new state transitions. -func (s *StateMachine[Event, Env]) RegisterStateEvents() StateSubscriber[ - Event, Env] { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) RegisterStateEvents() StateSubscriber[ + InternalEvent, OutboxEvent, Env] { - subscriber := fn.NewEventReceiver[State[Event, Env]](10) + subscriber := fn.NewEventReceiver[State[InternalEvent, OutboxEvent, Env]](10) // TODO(roasbeef): instead give the state and the input event? @@ -440,243 +341,32 @@ func (s *StateMachine[Event, Env]) RegisterStateEvents() StateSubscriber[ // RemoveStateSub removes the target state subscriber from the set of active // subscribers. -func (s *StateMachine[Event, Env]) RemoveStateSub(sub StateSubscriber[ - Event, Env]) { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) RemoveStateSub(sub StateSubscriber[ + InternalEvent, OutboxEvent, Env]) { _ = s.newStateEvents.RemoveSubscriber(sub) } // IsRunning returns true if the state machine is currently running. -func (s *StateMachine[Event, Env]) IsRunning() bool { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) IsRunning() bool { return s.running.Load() } -// executeDaemonEvent executes a daemon event, which is a special type of event -// that can be emitted as part of the state transition function of the state -// machine. An error is returned if the type of event is unknown. -func (s *StateMachine[Event, Env]) executeDaemonEvent(ctx context.Context, - event DaemonEvent) error { - - switch daemonEvent := event.(type) { - // This is a send message event, so we'll send the event, and also mind - // any preconditions as well as post-send events. - case *SendMsgEvent[Event]: - sendAndCleanUp := func() error { - s.log.DebugS(ctx, "Sending message:", - btclog.Hex6("target", daemonEvent.TargetPeer.SerializeCompressed()), - "messages", lnutils.SpewLogClosure(daemonEvent.Msgs)) - - err := s.cfg.Daemon.SendMessages( - daemonEvent.TargetPeer, daemonEvent.Msgs, - ) - if err != nil { - return fmt.Errorf("unable to send msgs: %w", - err) - } - - // If a post-send event was specified, then we'll funnel - // that back into the main state machine now as well. - //nolint:ll - return fn.MapOptionZ(daemonEvent.PostSendEvent, func(event Event) error { - launched := s.gm.Go( - ctx, func(ctx context.Context) { - s.log.DebugS(ctx, "Sending post-send event", - "event", lnutils.SpewLogClosure(event)) - - s.SendEvent(ctx, event) - }, - ) - - if !launched { - return ErrStateMachineShutdown - } - - return nil - }) - } - - canSend := func() bool { - return fn.MapOptionZ( - daemonEvent.SendWhen, - func(pred SendPredicate) bool { - return pred() - }, - ) - } - - // If this doesn't have a SendWhen predicate, or if it's already - // true, then we can just send it off right away. - if !daemonEvent.SendWhen.IsSome() || canSend() { - return sendAndCleanUp() - } - - // Otherwise, this has a SendWhen predicate, so we'll need - // launch a goroutine to poll the SendWhen, then send only once - // the predicate is true. - launched := s.gm.Go(ctx, func(ctx context.Context) { - predicateTicker := time.NewTicker( - s.cfg.CustomPollInterval.UnwrapOr(pollInterval), - ) - defer predicateTicker.Stop() - - s.log.InfoS(ctx, "Waiting for send predicate to be true") - - for { - select { - case <-predicateTicker.C: - if canSend() { - s.log.InfoS(ctx, "Send active predicate") - - err := sendAndCleanUp() - if err != nil { - s.log.ErrorS(ctx, "Unable to send message", err) - } - - return - } - - case <-ctx.Done(): - return - } - } - }) - - if !launched { - return ErrStateMachineShutdown - } - - return nil - - // If this is a broadcast transaction event, then we'll broadcast with - // the label attached. - case *BroadcastTxn: - s.log.DebugS(ctx, "Broadcasting txn", - "txid", daemonEvent.Tx.TxHash()) - - err := s.cfg.Daemon.BroadcastTransaction( - daemonEvent.Tx, daemonEvent.Label, - ) - if err != nil { - log.Errorf("unable to broadcast txn: %v", err) - } - - return nil - - // The state machine has requested a new event to be sent once a - // transaction spending a specified outpoint has confirmed. - case *RegisterSpend[Event]: - s.log.DebugS(ctx, "Registering spend", - "outpoint", daemonEvent.OutPoint) - - spendEvent, err := s.cfg.Daemon.RegisterSpendNtfn( - &daemonEvent.OutPoint, daemonEvent.PkScript, - daemonEvent.HeightHint, - ) - if err != nil { - return fmt.Errorf("unable to register spend: %w", err) - } - - launched := s.gm.Go(ctx, func(ctx context.Context) { - for { - select { - case spend, ok := <-spendEvent.Spend: - if !ok { - return - } - - // If there's a post-send event, then - // we'll send that into the current - // state now. - postSpend := daemonEvent.PostSpendEvent - postSpend.WhenSome(func(f SpendMapper[Event]) { //nolint:ll - customEvent := f(spend) - s.SendEvent(ctx, customEvent) - }) - - return - - case <-ctx.Done(): - return - } - } - }) - - if !launched { - return ErrStateMachineShutdown - } - - return nil - - // The state machine has requested a new event to be sent once a - // specified txid+pkScript pair has confirmed. - case *RegisterConf[Event]: - s.log.DebugS(ctx, "Registering conf", - "txid", daemonEvent.Txid) - - var opts []chainntnfs.NotifierOption - if daemonEvent.FullBlock { - opts = append(opts, chainntnfs.WithIncludeBlock()) - } - - numConfs := daemonEvent.NumConfs.UnwrapOr(1) - confEvent, err := s.cfg.Daemon.RegisterConfirmationsNtfn( - &daemonEvent.Txid, daemonEvent.PkScript, - numConfs, daemonEvent.HeightHint, opts..., - ) - if err != nil { - return fmt.Errorf("unable to register conf: %w", err) - } - - launched := s.gm.Go(ctx, func(ctx context.Context) { - for { - select { - //nolint:ll - case conf, ok := <-confEvent.Confirmed: - if !ok { - return - } - - // If there's a post-conf mapper, then - // we'll send that into the current - // state now. - postConfMapper := daemonEvent.PostConfMapper - postConfMapper.WhenSome(func(f ConfMapper[Event]) { - customEvent := f(conf) - s.SendEvent(ctx, customEvent) - }) - - return - - case <-ctx.Done(): - return - } - } - }) - - if !launched { - return ErrStateMachineShutdown - } - - return nil - } - - return fmt.Errorf("unknown daemon event: %T", event) -} - // applyEvents applies a new event to the state machine. This will continue // until no further events are emitted by the state machine. Along the way, // we'll also ensure to execute any daemon events that are emitted. The // function returns the final state, any accumulated outbox events, and an // error if one occurred. -func (s *StateMachine[Event, Env]) applyEvents(ctx context.Context, - currentState State[Event, Env], newEvent Event) (State[Event, Env], - []Event, error) { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) applyEvents( + ctx context.Context, currentState State[InternalEvent, OutboxEvent, Env], + newEvent InternalEvent) (State[InternalEvent, OutboxEvent, Env], + []OutboxEvent, error) { eventQueue := fn.NewQueue(newEvent) // outbox accumulates all outbox events from state transitions during // the entire event processing chain. - var outbox []Event + var outbox []OutboxEvent // Given the next event to handle, we'll process the event, then add // any new emitted internal events to our event queue. This continues @@ -685,33 +375,22 @@ func (s *StateMachine[Event, Env]) applyEvents(ctx context.Context, // //nolint:ll for nextEvent := eventQueue.Dequeue(); nextEvent.IsSome(); nextEvent = eventQueue.Dequeue() { - err := fn.MapOptionZ(nextEvent, func(event Event) error { + err := fn.MapOptionZ(nextEvent, func(event InternalEvent) error { s.log.DebugS(ctx, "Processing event", "event", lnutils.SpewLogClosure(event)) // Apply the state transition function of the current // state given this new event and our existing env. transition, err := currentState.ProcessEvent( - event, s.cfg.Env, + ctx, event, s.cfg.Env, ) if err != nil { return err } newEvents := transition.NewEvents - err = fn.MapOptionZ(newEvents, func(events EmittedEvent[Event]) error { - // With the event processed, we'll process any - // new daemon events that were emitted as part - // of this new state transition. - for _, dEvent := range events.ExternalEvents { - err := s.executeDaemonEvent( - ctx, dEvent, - ) - if err != nil { - return err - } - } - + err = fn.MapOptionZ(newEvents, func(events EmittedEvent[ + InternalEvent, OutboxEvent]) error { // Next, we'll add any new emitted events to our // event queue. for _, inEvent := range events.InternalEvent { @@ -758,21 +437,12 @@ func (s *StateMachine[Event, Env]) applyEvents(ctx context.Context, // driveMachine is the main event loop of the state machine. It accepts any new // incoming events, and then drives the state machine forward until it reaches // a terminal state. -func (s *StateMachine[Event, Env]) driveMachine(ctx context.Context) { +func (s *StateMachine[InternalEvent, OutboxEvent, Env]) driveMachine( + ctx context.Context) { s.log.DebugS(ctx, "Starting state machine") currentState := s.cfg.InitialState - // Before we start, if we have an init daemon event specified, then - // we'll handle that now. - err := fn.MapOptionZ(s.cfg.InitEvent, func(event DaemonEvent) error { - return s.executeDaemonEvent(ctx, event) - }) - if err != nil { - s.log.ErrorS(ctx, "Unable to execute init event", err) - return - } - // We just started driving the state machine, so we'll notify our // subscribers of this starting state. s.newStateEvents.NotifySubscribers(currentState) @@ -816,7 +486,7 @@ func (s *StateMachine[Event, Env]) driveMachine(ctx context.Context) { // // TODO(roasbeef): distinguish between error // types? state vs processing - syncReq.promise.Complete(fn.Err[[]Event](err)) + syncReq.promise.Complete(fn.Err[[]OutboxEvent](err)) // An error occurred, so we'll tear down the // entire state machine as we can't proceed. diff --git a/baselib/protofsm/state_machine_test.go b/baselib/protofsm/state_machine_test.go deleted file mode 100644 index 469b76a77..000000000 --- a/baselib/protofsm/state_machine_test.go +++ /dev/null @@ -1,1215 +0,0 @@ -package protofsm - -import ( - "context" - "encoding/hex" - "fmt" - "sync/atomic" - "testing" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" - "github.com/lightningnetwork/lnd/chainntnfs" - "github.com/lightningnetwork/lnd/fn/v2" - "github.com/lightningnetwork/lnd/lnwire" - "github.com/lightningnetwork/lnd/msgmux" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -type dummyEvents interface { - dummy() -} - -type goToFin struct { -} - -func (g *goToFin) dummy() { -} - -type emitInternal struct { -} - -func (e *emitInternal) dummy() { -} - -type daemonEvents struct { -} - -func (s *daemonEvents) dummy() { -} - -type confDetailsEvent struct { - blockHash chainhash.Hash - blockHeight uint32 -} - -func (c *confDetailsEvent) dummy() { -} - -type registerConf struct { - fullBlock bool -} - -func (r *registerConf) dummy() { -} - -type spendDetailsEvent struct { - spenderTxHash chainhash.Hash - spendingHeight int32 -} - -func (s *spendDetailsEvent) dummy() { -} - -type registerSpend struct { -} - -func (r *registerSpend) dummy() { -} - -type dummyEnv struct { - mock.Mock -} - -func (d *dummyEnv) Name() string { - return "test" -} - -type dummyStateStart struct { - canSend *atomic.Bool -} - -func (d *dummyStateStart) String() string { - return "dummyStateStart" -} - -var ( - hexDecode = func(keyStr string) []byte { - keyBytes, _ := hex.DecodeString(keyStr) - return keyBytes - } - pub1, _ = btcec.ParsePubKey(hexDecode( - "02ec95e4e8ad994861b95fc5986eedaac24739e5ea3d0634db4c8ccd44cd" + - "a126ea", - )) - pub2, _ = btcec.ParsePubKey(hexDecode( - "0356167ba3e54ac542e86e906d4186aba9ca0b9df45001c62b753d33fe06" + - "f5b4e8", - )) -) - -func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv, -) (*StateTransition[dummyEvents, *dummyEnv], error) { - - switch newEvent := event.(type) { - case *goToFin: - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: &dummyStateFin{}, - }, nil - - // This state will loop back upon itself, but will also emit an event - // to head to the terminal state. - case *emitInternal: - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: &dummyStateStart{}, - NewEvents: fn.Some(EmittedEvent[dummyEvents]{ - InternalEvent: []dummyEvents{&goToFin{}}, - }), - }, nil - - // This state will proceed to the terminal state, but will emit all the - // possible daemon events. - case *daemonEvents: - // This send event can only succeed once the bool turns to - // true. After that, then we'll expect another event to take us - // to the final state. - sendEvent := &SendMsgEvent[dummyEvents]{ - TargetPeer: *pub1, - SendWhen: fn.Some(func() bool { - return d.canSend.Load() - }), - PostSendEvent: fn.Some(dummyEvents(&goToFin{})), - } - - // We'll also send out a normal send event that doesn't have - // any preconditions. - sendEvent2 := &SendMsgEvent[dummyEvents]{ - TargetPeer: *pub2, - } - - return &StateTransition[dummyEvents, *dummyEnv]{ - // We'll state in this state until the send succeeds - // based on our predicate. Then it'll transition to the - // final state. - NextState: &dummyStateStart{ - canSend: d.canSend, - }, - NewEvents: fn.Some(EmittedEvent[dummyEvents]{ - ExternalEvents: DaemonEventSet{ - sendEvent, sendEvent2, - &BroadcastTxn{ - Tx: &wire.MsgTx{}, - Label: "test", - }, - }, - }), - }, nil - - // This state will emit a RegisterConf event which uses a mapper to - // transition to the final state upon confirmation. - case *registerConf: - confMapper := func( - conf *chainntnfs.TxConfirmation) dummyEvents { - - // Map the conf details into our custom event. - return &confDetailsEvent{ - blockHash: *conf.BlockHash, - blockHeight: conf.BlockHeight, - } - } - - regConfEvent := &RegisterConf[dummyEvents]{ - Txid: chainhash.Hash{1}, - PkScript: []byte{0x01}, - HeightHint: 100, - FullBlock: newEvent.fullBlock, - PostConfMapper: fn.Some[ConfMapper[dummyEvents]]( - confMapper, - ), - } - - return &StateTransition[dummyEvents, *dummyEnv]{ - // Stay in the start state until the conf event is - // received and mapped. - NextState: &dummyStateStart{ - canSend: d.canSend, - }, - NewEvents: fn.Some(EmittedEvent[dummyEvents]{ - ExternalEvents: DaemonEventSet{ - regConfEvent, - }, - }), - }, nil - - // This event contains details from the confirmation and signals us to - // transition to the final state. - case *confDetailsEvent: - // We received the mapped confirmation details, transition to - // the confirmed state. - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: &dummyStateConfirmed{ - blockHash: newEvent.blockHash, - blockHeight: newEvent.blockHeight, - }, - }, nil - - // This state will emit a RegisterSpend event which uses a mapper to - // transition to the spent state upon spend detection. - case *registerSpend: - spendMapper := func( - spend *chainntnfs.SpendDetail) dummyEvents { - - // Map the spend details into our custom event. - return &spendDetailsEvent{ - spenderTxHash: *spend.SpenderTxHash, - spendingHeight: spend.SpendingHeight, - } - } - - regSpendEvent := &RegisterSpend[dummyEvents]{ - OutPoint: wire.OutPoint{Hash: chainhash.Hash{3}}, - PkScript: []byte{0x03}, - HeightHint: 300, - PostSpendEvent: fn.Some[SpendMapper[dummyEvents]]( - spendMapper, - ), - } - - return &StateTransition[dummyEvents, *dummyEnv]{ - // Stay in the start state until the spend event is - // received and mapped. - NextState: &dummyStateStart{ - canSend: d.canSend, - }, - NewEvents: fn.Some(EmittedEvent[dummyEvents]{ - ExternalEvents: DaemonEventSet{ - regSpendEvent, - }, - }), - }, nil - - // This event contains details from the spend notification and signals - // us to transition to the spent state. - case *spendDetailsEvent: - // We received the mapped spend details, transition to the - // spent state. - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: &dummyStateSpent{ - spenderTxHash: newEvent.spenderTxHash, - spendingHeight: newEvent.spendingHeight, - }, - }, nil - } - - return nil, fmt.Errorf("unknown event: %T", event) -} - -func (d *dummyStateStart) IsTerminal() bool { - return false -} - -type dummyStateFin struct { -} - -func (d *dummyStateFin) String() string { - return "dummyStateFin" -} - -func (d *dummyStateFin) ProcessEvent(event dummyEvents, env *dummyEnv, -) (*StateTransition[dummyEvents, *dummyEnv], error) { - - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: &dummyStateFin{}, - }, nil -} - -func (d *dummyStateFin) IsTerminal() bool { - return true -} - -type dummyStateConfirmed struct { - blockHash chainhash.Hash - blockHeight uint32 -} - -func (d *dummyStateConfirmed) String() string { - return "dummyStateConfirmed" -} - -func (d *dummyStateConfirmed) ProcessEvent(event dummyEvents, env *dummyEnv, -) (*StateTransition[dummyEvents, *dummyEnv], error) { - - // This is a terminal state, no further transitions. - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: d, - }, nil -} - -func (d *dummyStateConfirmed) IsTerminal() bool { - return true -} - -type dummyStateSpent struct { - spenderTxHash chainhash.Hash - spendingHeight int32 -} - -func (d *dummyStateSpent) String() string { - return "dummyStateSpent" -} - -func (d *dummyStateSpent) ProcessEvent(event dummyEvents, env *dummyEnv, -) (*StateTransition[dummyEvents, *dummyEnv], error) { - - // This is a terminal state, no further transitions. - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: d, - }, nil -} - -func (d *dummyStateSpent) IsTerminal() bool { - return true -} - -// assertState asserts that the state machine is currently in the expected -// state type and returns the state cast to that type. -func assertState[Event any, Env Environment, S State[Event, Env]](t *testing.T, - m *StateMachine[Event, Env], expectedState S) S { - - state, err := m.CurrentState() - require.NoError(t, err) - require.IsType(t, expectedState, state) - - // Perform the type assertion to return the concrete type. - concreteState, ok := state.(S) - require.True(t, ok, "state type assertion failed") - - return concreteState -} - -func assertStateTransitions[Event any, Env Environment]( - t *testing.T, stateSub StateSubscriber[Event, Env], - expectedStates []State[Event, Env]) { - - for _, expectedState := range expectedStates { - newState := <-stateSub.NewItemCreated.ChanOut() - - require.IsType(t, expectedState, newState) - } -} - -type dummyAdapters struct { - mock.Mock - - confChan chan *chainntnfs.TxConfirmation - spendChan chan *chainntnfs.SpendDetail -} - -func newDaemonAdapters() *dummyAdapters { - return &dummyAdapters{ - confChan: make(chan *chainntnfs.TxConfirmation, 1), - spendChan: make(chan *chainntnfs.SpendDetail, 1), - } -} - -func (d *dummyAdapters) SendMessages(pub btcec.PublicKey, - msgs []lnwire.Message) error { - - args := d.Called(pub, msgs) - - return args.Error(0) -} - -func (d *dummyAdapters) BroadcastTransaction(tx *wire.MsgTx, - label string) error { - - args := d.Called(tx, label) - - return args.Error(0) -} - -func (d *dummyAdapters) RegisterConfirmationsNtfn(txid *chainhash.Hash, - pkScript []byte, numConfs, heightHint uint32, - opts ...chainntnfs.NotifierOption, -) (*chainntnfs.ConfirmationEvent, error) { - - // Pass opts as the last argument to the mock call checker. - args := d.Called(txid, pkScript, numConfs, heightHint, opts) - - err := args.Error(0) - - return &chainntnfs.ConfirmationEvent{ - Confirmed: d.confChan, - }, err -} - -func (d *dummyAdapters) RegisterSpendNtfn(outpoint *wire.OutPoint, - pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) { - - args := d.Called(outpoint, pkScript, heightHint) - - err := args.Error(0) - - return &chainntnfs.SpendEvent{ - Spend: d.spendChan, - }, err -} - -// TestStateMachineOnInitDaemonEvent tests that the state machine will properly -// execute any init-level daemon events passed into it. -func TestStateMachineOnInitDaemonEvent(t *testing.T) { - ctx := t.Context() - - // First, we'll create our state machine given the env, and our - // starting state. - env := &dummyEnv{} - startingState := &dummyStateStart{} - - adapters := newDaemonAdapters() - - // We'll make an init event that'll send to a peer, then transition us - // to our terminal state. - initEvent := &SendMsgEvent[dummyEvents]{ - TargetPeer: *pub1, - PostSendEvent: fn.Some(dummyEvents(&goToFin{})), - } - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - InitEvent: fn.Some[DaemonEvent](initEvent), - } - stateMachine := NewStateMachine(cfg) - - // Before we start up the state machine, we'll assert that the send - // message adapter is called on start up. - adapters.On("SendMessages", *pub1, mock.Anything).Return(nil) - - // As we're triggering internal events, we'll also subscribe to the set - // of new states so we can assert as we go. - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - // Assert that we go from the starting state to the final state. The - // state machine should now also be on the final terminal state. - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateStart{}, &dummyStateFin{}, - } - assertStateTransitions(t, stateSub, expectedStates) - - // We'll now assert that after the daemon was started, the send message - // adapter was called above as specified in the init event. - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} - -// TestStateMachineInternalEvents tests that the state machine is able to add -// new internal events to the event queue for further processing during a state -// transition. -func TestStateMachineInternalEvents(t *testing.T) { - t.Parallel() - ctx := t.Context() - - // First, we'll create our state machine given the env, and our - // starting state. - env := &dummyEnv{} - startingState := &dummyStateStart{} - - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - InitEvent: fn.None[DaemonEvent](), - } - stateMachine := NewStateMachine(cfg) - - // As we're triggering internal events, we'll also subscribe to the set - // of new states so we can assert as we go. - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - // For this transition, we'll send in the emitInternal event, which'll - // send us back to the starting event, but emit an internal event. - stateMachine.SendEvent(ctx, &emitInternal{}) - - // We'll now also assert the path we took to get here to ensure the - // internal events were processed. - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateStart{}, &dummyStateStart{}, &dummyStateFin{}, - } - assertStateTransitions( - t, stateSub, expectedStates, - ) - - // We should ultimately end up in the terminal state. - assertState[dummyEvents, *dummyEnv](t, &stateMachine, &dummyStateFin{}) - - // Make sure all the env expectations were met. - env.AssertExpectations(t) -} - -// TestStateMachineDaemonEvents tests that the state machine is able to process -// daemon emitted as part of the state transition process. -func TestStateMachineDaemonEvents(t *testing.T) { - t.Parallel() - ctx := t.Context() - - // First, we'll create our state machine given the env, and our - // starting state. - env := &dummyEnv{} - - var boolTrigger atomic.Bool - startingState := &dummyStateStart{ - canSend: &boolTrigger, - } - - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - InitEvent: fn.None[DaemonEvent](), - } - stateMachine := NewStateMachine(cfg) - - // Before we start up the state machine, we'll assert that the machine - // is not running. - require.False(t, stateMachine.IsRunning()) - - // As we're triggering internal events, we'll also subscribe to the set - // of new states so we can assert as we go. - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer func() { - stateMachine.Stop() - - // After we stop the state machine, we expect it to no longer be - // running. - require.False(t, stateMachine.IsRunning()) - }() - - // The state machine should now be running. - require.True(t, stateMachine.IsRunning()) - - // As soon as we send in the daemon event, we expect the - // disable+broadcast events to be processed, as they are unconditional. - adapters.On( - "BroadcastTransaction", mock.Anything, mock.Anything, - ).Return(nil) - adapters.On("SendMessages", *pub2, mock.Anything).Return(nil) - - // We'll start off by sending in the daemon event, which'll trigger the - // state machine to execute the series of daemon events. - stateMachine.SendEvent(ctx, &daemonEvents{}) - - // We should transition back to the starting state now, after we - // started from the very same state. - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateStart{}, &dummyStateStart{}, - } - assertStateTransitions(t, stateSub, expectedStates) - - // At this point, we expect that the two methods above were called. - adapters.AssertExpectations(t) - - // However, we don't expect the SendMessages for the first peer target - // to be called yet, as the condition hasn't yet been met. - adapters.AssertNotCalled(t, "SendMessages", *pub1) - - // We'll now flip the bool to true, which should cause the SendMessages - // method to be called, and for us to transition to the final state. - boolTrigger.Store(true) - adapters.On("SendMessages", *pub1, mock.Anything).Return(nil) - - expectedStates = []State[dummyEvents, *dummyEnv]{&dummyStateFin{}} - assertStateTransitions(t, stateSub, expectedStates) - - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} - -// testStateMachineConfMapperImpl is a helper function that encapsulates the -// core logic for testing the confirmation mapping functionality of the state -// machine. It takes a boolean flag `fullBlock` to determine whether to test the -// scenario where full block details are requested in the confirmation -// notification. -func testStateMachineConfMapperImpl(t *testing.T, fullBlock bool) { - ctx := t.Context() - - // Create the state machine. - env := &dummyEnv{} - startingState := &dummyStateStart{} - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - } - stateMachine := NewStateMachine(cfg) - - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - // Define the expected arguments for the mock call. - expectedTxid := &chainhash.Hash{1} - expectedPkScript := []byte{0x01} - expectedNumConfs := uint32(1) - expectedHeightHint := uint32(100) - - // Set up the mock expectation based on the FullBlock flag. We use - // mock.MatchedBy to assert the options passed. - if fullBlock { - // Expect WithIncludeBlock() option when FullBlock is true. - adapters.On( - "RegisterConfirmationsNtfn", - expectedTxid, expectedPkScript, - expectedNumConfs, expectedHeightHint, - mock.MatchedBy( - func(opts []chainntnfs.NotifierOption) bool { - // Check if exactly one option is passed - // and it's the correct type. Unless we - // use reflect, we can introspect into - // the private fields. - return len(opts) == 1 - }, - ), - ).Return(nil) - } else { - // Expect no options when FullBlock is false. - adapters.On( - "RegisterConfirmationsNtfn", - expectedTxid, expectedPkScript, - expectedNumConfs, expectedHeightHint, - mock.MatchedBy(func(opts []chainntnfs.NotifierOption) bool { //nolint:ll - return len(opts) == 0 - }), - ).Return(nil) - } - - // Create the registerConf event with the specified FullBlock value. - regConfEvent := ®isterConf{ - fullBlock: fullBlock, - } - - // Send the event that triggers RegisterConf emission. - stateMachine.SendEvent(ctx, regConfEvent) - - // We should transition back to the starting state initially. - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateStart{}, &dummyStateStart{}, - } - assertStateTransitions(t, stateSub, expectedStates) - - // Assert the registration call was made with the correct arguments - // (including options). - adapters.AssertExpectations(t) - - // Now, simulate the confirmation event coming back from the notifier. - simulatedConf := &chainntnfs.TxConfirmation{ - BlockHash: &chainhash.Hash{2}, - BlockHeight: 123, - } - adapters.confChan <- simulatedConf - - // This should trigger the mapper and send the confDetailsEvent, - // transitioning us to the confirmed state. - expectedStates = []State[dummyEvents, *dummyEnv]{&dummyStateConfirmed{}} - assertStateTransitions(t, stateSub, expectedStates) - - // Final state assertion. - finalState := assertState(t, &stateMachine, &dummyStateConfirmed{}) - - // Assert that the details from the confirmation event were correctly - // propagated to the final state. - require.Equal(t, - *simulatedConf.BlockHash, finalState.blockHash, - ) - require.Equal(t, - simulatedConf.BlockHeight, finalState.blockHeight, - ) - - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} - -// TestStateMachineConfMapper tests the confirmation mapping functionality using -// subtests driven by the testStateMachineConfMapperImpl helper function. It -// covers scenarios both with and without requesting the full block details. -func TestStateMachineConfMapper(t *testing.T) { - t.Parallel() - - t.Run("full block false", func(t *testing.T) { - t.Parallel() - testStateMachineConfMapperImpl(t, false) - }) - - t.Run("full block true", func(t *testing.T) { - t.Parallel() - testStateMachineConfMapperImpl(t, true) - }) -} - -// TestStateMachineSpendMapper tests that the state machine is able to properly -// map the spend event into a custom event that can be used to trigger a state -// transition. -func TestStateMachineSpendMapper(t *testing.T) { - t.Parallel() - ctx := t.Context() - - // Create the state machine. - env := &dummyEnv{} - startingState := &dummyStateStart{} - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - } - stateMachine := NewStateMachine(cfg) - - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - // Expect the RegisterSpendNtfn call when we send the event. - targetOutpoint := &wire.OutPoint{Hash: chainhash.Hash{3}} - targetPkScript := []byte{0x03} - targetHeightHint := uint32(300) - adapters.On( - "RegisterSpendNtfn", targetOutpoint, targetPkScript, - targetHeightHint, - ).Return(nil) - - // Send the event that triggers RegisterSpend emission. - stateMachine.SendEvent(ctx, ®isterSpend{}) - - // We should transition back to the starting state initially. - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateStart{}, &dummyStateStart{}, - } - assertStateTransitions(t, stateSub, expectedStates) - - // Assert the registration call was made. - adapters.AssertExpectations(t) - - // Now, simulate the spend event coming back from the notifier. Populate - // it with some data to be mapped. - simulatedSpend := &chainntnfs.SpendDetail{ - SpentOutPoint: targetOutpoint, - SpenderTxHash: &chainhash.Hash{4}, - SpendingTx: &wire.MsgTx{}, - SpendingHeight: 456, - } - adapters.spendChan <- simulatedSpend - - // This should trigger the mapper and send the spendDetailsEvent, - // transitioning us to the spent state. - expectedStates = []State[dummyEvents, *dummyEnv]{&dummyStateSpent{}} - assertStateTransitions(t, stateSub, expectedStates) - - // Final state assertion. - finalState := assertState(t, &stateMachine, &dummyStateSpent{}) - - // Assert that the details from the spend event were correctly - // propagated to the final state. - require.Equal(t, - *simulatedSpend.SpenderTxHash, finalState.spenderTxHash, - ) - require.Equal(t, - simulatedSpend.SpendingHeight, finalState.spendingHeight, - ) - - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} - -type dummyMsgMapper struct { - mock.Mock -} - -func (d *dummyMsgMapper) MapMsg(wireMsg msgmux.PeerMsg) fn.Option[dummyEvents] { - args := d.Called(wireMsg) - - //nolint:forcetypeassert - return args.Get(0).(fn.Option[dummyEvents]) -} - -// TestStateMachineMsgMapper tests that given a message mapper, we can properly -// send in wire messages get mapped to FSM events. -func TestStateMachineMsgMapper(t *testing.T) { - ctx := t.Context() - - // First, we'll create our state machine given the env, and our - // starting state. - env := &dummyEnv{} - startingState := &dummyStateStart{} - adapters := newDaemonAdapters() - - // We'll also provide a message mapper that only knows how to map a - // single wire message (error). - dummyMapper := &dummyMsgMapper{} - - // The only thing we know how to map is the error message, which'll - // terminate the state machine. - wireError := msgmux.PeerMsg{ - Message: &lnwire.Error{}, - } - initMsg := msgmux.PeerMsg{ - Message: &lnwire.Init{}, - } - dummyMapper.On("MapMsg", wireError).Return( - fn.Some(dummyEvents(&goToFin{})), - ) - dummyMapper.On("MapMsg", initMsg).Return(fn.None[dummyEvents]()) - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - MsgMapper: fn.Some[MsgMapper[dummyEvents]](dummyMapper), - } - stateMachine := NewStateMachine(cfg) - - // As we're triggering internal events, we'll also subscribe to the set - // of new states so we can assert as we go. - // - // We register before calling Start to ensure we don't miss any events. - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - // First, we'll verify that the CanHandle method works as expected. - require.True(t, stateMachine.CanHandle(wireError)) - require.False(t, stateMachine.CanHandle(initMsg)) - - // Next, we'll attempt to send the wire message into the state machine. - // We should transition to the final state. - require.True(t, stateMachine.SendMessage(ctx, wireError)) - - // We should transition to the final state. - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateStart{}, &dummyStateFin{}, - } - assertStateTransitions(t, stateSub, expectedStates) - - dummyMapper.AssertExpectations(t) - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} - -// outboxEvent is a test event type that gets added to the outbox. -type outboxEvent struct { - id int -} - -func (o *outboxEvent) dummy() { -} - -// emitOutbox is a test event that triggers a state to emit outbox events. -type emitOutbox struct { - numOutbox int - numInternal int - shouldGoToFin bool -} - -func (e *emitOutbox) dummy() { -} - -// dummyStateOutbox is a test state that emits outbox events during -// transitions. -type dummyStateOutbox struct { - counter int -} - -func (d *dummyStateOutbox) String() string { - return fmt.Sprintf("dummyStateOutbox(%d)", d.counter) -} - -func (d *dummyStateOutbox) ProcessEvent(event dummyEvents, env *dummyEnv, -) (*StateTransition[dummyEvents, *dummyEnv], error) { - - switch newEvent := event.(type) { - case *emitOutbox: - // Create outbox events based on the request. - outbox := make([]dummyEvents, newEvent.numOutbox) - for i := 0; i < newEvent.numOutbox; i++ { - outbox[i] = &outboxEvent{ - id: d.counter*100 + i, - } - } - - // Create internal events that will also emit outbox events. - internalEvents := make([]dummyEvents, newEvent.numInternal) - for i := 0; i < newEvent.numInternal; i++ { - internalEvents[i] = &emitOutbox{ - numOutbox: 1, - numInternal: 0, - shouldGoToFin: false, - } - } - - var nextState State[dummyEvents, *dummyEnv] - if newEvent.shouldGoToFin { - nextState = &dummyStateFin{} - } else { - nextState = &dummyStateOutbox{counter: d.counter + 1} - } - - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: nextState, - NewEvents: fn.Some(EmittedEvent[dummyEvents]{ - InternalEvent: internalEvents, - Outbox: outbox, - }), - }, nil - - case *goToFin: - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: &dummyStateFin{}, - }, nil - - case *outboxEvent: - // When processing an outbox event (shouldn't happen in normal - // flow), just stay in current state. - return &StateTransition[dummyEvents, *dummyEnv]{ - NextState: d, - }, nil - } - - return nil, fmt.Errorf("unknown event: %T", event) -} - -func (d *dummyStateOutbox) IsTerminal() bool { - return false -} - -// TestStateMachineAskEvent tests the AskEvent method and outbox event -// accumulation functionality. -func TestStateMachineAskEvent(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - event dummyEvents - expectedOutboxCount int - expectError bool - }{ - { - name: "basic outbox accumulation", - event: &emitOutbox{ - numOutbox: 3, - numInternal: 0, - shouldGoToFin: false, - }, - expectedOutboxCount: 3, - expectError: false, - }, - - // 2 from top-level + 3 from internal events (1 each). - { - name: "nested internal events with outbox", - event: &emitOutbox{ - numOutbox: 2, - numInternal: 3, - shouldGoToFin: false, - }, - expectedOutboxCount: 5, - expectError: false, - }, - - { - name: "empty outbox", - event: &emitOutbox{ - numOutbox: 0, - numInternal: 0, - shouldGoToFin: false, - }, - expectedOutboxCount: 0, - expectError: false, - }, - - // 1 from top-level + 5 from internal events. - { - name: "deeply nested outbox", - event: &emitOutbox{ - numOutbox: 1, - numInternal: 5, - shouldGoToFin: false, - }, - expectedOutboxCount: 6, - expectError: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ctx := t.Context() - - // Create our state machine with the outbox test state. - env := &dummyEnv{} - startingState := &dummyStateOutbox{counter: 0} - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - } - stateMachine := NewStateMachine(cfg) - - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - // Wait for initial state. - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateOutbox{}, - } - assertStateTransitions(t, stateSub, expectedStates) - - // Send the event using Ask pattern. - future := stateMachine.AskEvent(ctx, tc.event) - require.NotNil(t, future) - - result := future.Await(ctx) - - if tc.expectError { - require.True(t, result.IsErr()) - } else { - require.True(t, result.IsOk()) - - // Extract the outbox events. - outbox := result.UnwrapOr(nil) - require.Len(t, outbox, tc.expectedOutboxCount) - - // Verify outbox events are of the correct type. - for _, event := range outbox { - _, ok := event.(*outboxEvent) - require.True(t, ok, - "expected outboxEvent, got %T", - event) - } - } - - adapters.AssertExpectations(t) - env.AssertExpectations(t) - }) - } -} - -// TestStateMachineOutboxWithMixedEvents tests that outbox accumulation works -// correctly when mixed with regular SendEvent calls. -func TestStateMachineOutboxWithMixedEvents(t *testing.T) { - t.Parallel() - - ctx := t.Context() - - // Create our state machine with the outbox test state. - env := &dummyEnv{} - startingState := &dummyStateOutbox{counter: 0} - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - } - stateMachine := NewStateMachine(cfg) - - // Subscribe to state transitions, then start the main state machine. - stateSub := stateMachine.RegisterStateEvents() - defer stateMachine.RemoveStateSub(stateSub) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - expectedStates := []State[dummyEvents, *dummyEnv]{ - &dummyStateOutbox{}, - } - assertStateTransitions(t, stateSub, expectedStates) - - // Send a regular async event first. - stateMachine.SendEvent(ctx, &emitOutbox{ - numOutbox: 1, - numInternal: 0, - shouldGoToFin: false, - }) - - // Wait for state transition from async event. - expectedStates = []State[dummyEvents, *dummyEnv]{ - &dummyStateOutbox{counter: 1}, - } - assertStateTransitions(t, stateSub, expectedStates) - - // Now send an event using Ask pattern. - future := stateMachine.AskEvent(ctx, &emitOutbox{ - numOutbox: 2, - numInternal: 1, - shouldGoToFin: false, - }) - - result := future.Await(ctx) - require.True(t, result.IsOk()) - - // We should have 3 outbox events (2 from top-level + 1 from internal). - outbox := result.UnwrapOr(nil) - require.Len(t, outbox, 3) - - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} - -// TestStateMachineAskEventContextCancellation tests that context cancellation -// is properly handled in AskEvent. -func TestStateMachineAskEventContextCancellation(t *testing.T) { - t.Parallel() - - ctx := t.Context() - - env := &dummyEnv{} - startingState := &dummyStateOutbox{counter: 0} - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - } - stateMachine := NewStateMachine(cfg) - - stateMachine.Start(ctx) - defer stateMachine.Stop() - - // Create a context that's already cancelled. - cancelledCtx, cancel := context.WithCancel(t.Context()) - cancel() - - // Try to send an event with a cancelled context. - future := stateMachine.AskEvent(cancelledCtx, &emitOutbox{ - numOutbox: 1, - numInternal: 0, - shouldGoToFin: false, - }) - - // The future should be completed with an error. - result := future.Await(ctx) - require.True(t, result.IsErr()) - - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} - -// TestStateMachineAskEventAfterShutdown tests that AskEvent properly handles -// the case where the state machine has been shut down. -func TestStateMachineAskEventAfterShutdown(t *testing.T) { - t.Parallel() - - ctx := t.Context() - - // Create our state machine. - env := &dummyEnv{} - startingState := &dummyStateOutbox{counter: 0} - adapters := newDaemonAdapters() - - cfg := StateMachineCfg[dummyEvents, *dummyEnv]{ - Daemon: adapters, - InitialState: startingState, - Env: env, - } - stateMachine := NewStateMachine(cfg) - - stateMachine.Start(ctx) - - // Stop the state machine. - stateMachine.Stop() - - // Try to send an event after shutdown. - future := stateMachine.AskEvent(ctx, &emitOutbox{ - numOutbox: 1, - numInternal: 0, - shouldGoToFin: false, - }) - - // The future should be completed with a shutdown error. - result := future.Await(ctx) - require.True(t, result.IsErr()) - require.ErrorIs(t, result.Err(), ErrStateMachineShutdown) - - adapters.AssertExpectations(t) - env.AssertExpectations(t) -} From fc5fac70607e53aa4c6f9e0fcfc5c9e8597c11dd Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 14:03:41 +0100 Subject: [PATCH 5/8] baselib/protofsm: add actor native fsm --- baselib/protofsm/actor_wrapper.go | 166 ++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/baselib/protofsm/actor_wrapper.go b/baselib/protofsm/actor_wrapper.go index 85666aa54..15f3e3d4c 100644 --- a/baselib/protofsm/actor_wrapper.go +++ b/baselib/protofsm/actor_wrapper.go @@ -1,9 +1,11 @@ package protofsm import ( + "context" "fmt" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" ) // ActorMessage wraps an Event, in order to create a new message that can be @@ -13,6 +15,15 @@ type ActorMessage[Event any] struct { // Event is the event that is being sent to the actor. Event Event + + // StateQuery indicates querying for the current state of the FSM. + StateQuery bool +} + +// ActorResponse is the response type for FSM actor messages. +type ActorResponse[InternalEvent any, OutboxEvent any, Env any] struct { + // CurrentState is the current state of the FSM. + CurrentState State[InternalEvent, OutboxEvent, Env] } // MessageType returns the type of the message. @@ -21,3 +32,158 @@ type ActorMessage[Event any] struct { func (a ActorMessage[Event]) MessageType() string { return fmt.Sprintf("ActorMessage(%T)", a.Event) } + +// ActorOutboxEvent defines the interface that outbox events for the actor-based +// state machine must implement, the dispatch method can be used to deliver the +// event to the actor system or router. +type ActorOutboxEvent interface { + Dispatch(ctx context.Context, system *actor.ActorSystem) error +} + +// DeliveryMode indicates how the routed event should be delivered. +type DeliveryMode int + +const ( + // DeliveryModeTell indicates a fire-and-forget delivery mode. + DeliveryModeTell DeliveryMode = iota + // DeliveryModeAsk indicates an ask delivery mode, which waits for a + // response. + DeliveryModeAsk +) + +// RoutedOutboxEvent is a helper that delivers an outbox event to actors +// registered under a specific service key. +type RoutedOutboxEvent[M actor.Message, R any] struct { + key actor.ServiceKey[M, R] + msg M + mode DeliveryMode +} + +// NewTellOutboxEvent creates a fire-and-forget routed event. +func NewTellOutboxEvent[M actor.Message, R any](key actor.ServiceKey[M, R], msg M) RoutedOutboxEvent[M, R] { + return RoutedOutboxEvent[M, R]{ + key: key, + msg: msg, + mode: DeliveryModeTell, + } +} + +// NewAskOutboxEvent creates an ask routed event. +func NewAskOutboxEvent[M actor.Message, R any](key actor.ServiceKey[M, R], msg M) RoutedOutboxEvent[M, R] { + return RoutedOutboxEvent[M, R]{ + key: key, + msg: msg, + mode: DeliveryModeAsk, + } +} + +// Dispatch sends the event to the actor(s) registered under the service key. +func (e RoutedOutboxEvent[M, R]) Dispatch(ctx context.Context, system *actor.ActorSystem) error { + // Create a router for the service key. + router := actor.NewRouter( + system.Receptionist(), e.key, actor.NewRoundRobinStrategy[M, R](), + system.DeadLetters(), + ) + + switch e.mode { + case DeliveryModeTell: + router.Tell(ctx, e.msg) + return nil + + case DeliveryModeAsk: + res := router.Ask(ctx, e.msg).Await(ctx) + if _, err := res.Unpack(); err != nil { + return err + } + return nil + + default: + return fmt.Errorf("unknown delivery mode %v", e.mode) + } +} + +// ActorStateMachine is a wrapper around a state machine that implements the +// actor.Actor interface, the state machine is only driven by incoming messages. +type ActorStateMachine[InternalEvent any, OutboxEvent ActorOutboxEvent, Env any] struct { + sm *StateMachine[InternalEvent, OutboxEvent, Env] + system *actor.ActorSystem + currentState State[InternalEvent, OutboxEvent, Env] +} + +// TellRefEnv is an environment that can hold a tell-only actor reference. +// This let's the ActorStateMachine constructor set the reference after creation. +type TellRefEnv[InternalEvent any] interface { + SetTellOnlyRef(actor.TellOnlyRef[ActorMessage[InternalEvent]]) + GetTellOnlyRef() actor.TellOnlyRef[ActorMessage[InternalEvent]] +} + +// FullRefEnv is an environment that can hold a full actor reference. +// This let's the ActorStateMachine constructor set the reference after creation. +type FullRefEnv[InternalEvent any, OutboxEvent ActorOutboxEvent, Env any] interface { + SetActorRef(actor.ActorRef[ActorMessage[InternalEvent], ActorResponse[InternalEvent, OutboxEvent, Env]]) + GetActorRef() actor.ActorRef[ActorMessage[InternalEvent], ActorResponse[InternalEvent, OutboxEvent, Env]] +} + +// SystemActorsStateMachine registers a new state machine actor and returns its reference. +func NewSystemsActorStateMachine[InternalEvent any, OutboxEvent ActorOutboxEvent, Env Environment]( + ctx context.Context, cfg StateMachineCfg[InternalEvent, OutboxEvent, Env], system *actor.ActorSystem, + id string) actor.ActorRef[ActorMessage[InternalEvent], ActorResponse[InternalEvent, OutboxEvent, Env]] { + + machine := NewStateMachine(cfg) + sm := &ActorStateMachine[InternalEvent, OutboxEvent, Env]{ + sm: &machine, + system: system, + currentState: cfg.InitialState, + } + + ref := actor.RegisterWithSystem( + system, id, actor.NewServiceKey[ActorMessage[InternalEvent], ActorResponse[InternalEvent, OutboxEvent, Env]](id), + sm, + ) + extraInfo := "" + if envAny, ok := any(cfg.Env).(TellRefEnv[InternalEvent]); ok { + envAny.SetTellOnlyRef(ref) + extraInfo = "(tell ref env)" + } + if envAny, ok := any(cfg.Env).(FullRefEnv[InternalEvent, OutboxEvent, Env]); ok { + envAny.SetActorRef(ref) + extraInfo = "(full ref env)" + } + + cfg.Logger.DebugS(ctx, "Setting up FSM %s", extraInfo) + + return ref +} + +// Receive processes an incoming actor message and drives the state machine. +// This method implements the actor.Actor interface. any new outbox events +// generated by the state machine are dispatched to their targets within +// the actors actor system. +func (sm *ActorStateMachine[InternalEvent, OutboxEvent, Env]) Receive(ctx context.Context, + e ActorMessage[InternalEvent]) fn.Result[ActorResponse[InternalEvent, OutboxEvent, Env]] { + + // If this is a state query, return the current state. + if e.StateQuery { + return fn.Ok(ActorResponse[InternalEvent, OutboxEvent, Env]{ + CurrentState: sm.currentState, + }) + } + + newState, outBoxEvents, err := sm.sm.applyEvents(ctx, sm.currentState, e.Event) + if err != nil { + return fn.NewResult(ActorResponse[InternalEvent, OutboxEvent, Env]{}, err) + } + + sm.currentState = newState + + for _, out := range outBoxEvents { + if err := out.Dispatch(ctx, sm.system); err != nil { + sm.sm.cfg.ErrorReporter.ReportError(err) + return fn.NewResult(ActorResponse[InternalEvent, OutboxEvent, Env]{}, err) + } + } + + return fn.Ok(ActorResponse[InternalEvent, OutboxEvent, Env]{ + CurrentState: sm.currentState, + }) +} From bc1be46e649e7910eae6242cd569d7ade6b8b6f9 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 14:12:52 +0100 Subject: [PATCH 6/8] baselib: add example --- baselib/example/example_actors.go | 95 +++++++++ baselib/example/example_protofsm.go | 300 ++++++++++++++++++++++++++++ baselib/example/example_test.go | 106 ++++++++++ 3 files changed, 501 insertions(+) create mode 100644 baselib/example/example_actors.go create mode 100644 baselib/example/example_protofsm.go create mode 100644 baselib/example/example_test.go diff --git a/baselib/example/example_actors.go b/baselib/example/example_actors.go new file mode 100644 index 000000000..4824cc76e --- /dev/null +++ b/baselib/example/example_actors.go @@ -0,0 +1,95 @@ +package baselib_test + +import ( + "context" + "fmt" + + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// ============================================================================ +// Actor Services +// ============================================================================ + +// ReviewService handles document review requests. + +// ReviewServiceKey is the service key for the review service. +var ReviewServiceKey = actor.NewServiceKey[ReviewMsg, ReviewResp]("review-service") + +// ReviewMsg requests document review. +type ReviewMsg struct { + actor.BaseMessage + DocumentID string + Author string + ReplyTo actor.TellOnlyRef[protofsm.ActorMessage[DocEvent]] +} + +// MessageType returns the message type. +func (m ReviewMsg) MessageType() string { + return "ReviewRequest" +} + +// ReviewResp is the response from review service. +type ReviewResp struct { + Success bool +} + +// ReviewServiceBehavior simulates async review process. +type ReviewServiceBehavior struct{} + +// Receive processes review requests. +func (r *ReviewServiceBehavior) Receive(ctx context.Context, + msg ReviewMsg) fn.Result[ReviewResp] { + + fmt.Printf("ReviewService: Processing document %s by %s\n", + msg.DocumentID, msg.Author) + + // Send confirmation that review started. + msg.ReplyTo.Tell(ctx, protofsm.ActorMessage[DocEvent]{ + Event: EventReviewStarted{}, + }) + + // Simulate review decision (approve in this example). + msg.ReplyTo.Tell(ctx, protofsm.ActorMessage[DocEvent]{ + Event: EventApproved{ + Reviewer: "ReviewBot", + }, + }) + + return fn.Ok(ReviewResp{Success: true}) +} + +// NotificationService handles notification delivery. + +// NotifyServiceKey is the service key for the notification service. +var NotifyServiceKey = actor.NewServiceKey[NotifyMsg, NotifyResp]("notify-service") + +// NotifyMsg requests notification delivery. +type NotifyMsg struct { + actor.BaseMessage + Message string +} + +// MessageType returns the message type. +func (m NotifyMsg) MessageType() string { + return "Notify" +} + +// NotifyResp is the response from notification service. +type NotifyResp struct { + Success bool +} + +// NotificationServiceBehavior delivers notifications. +type NotificationServiceBehavior struct{} + +// Receive processes notification requests. +func (n *NotificationServiceBehavior) Receive(ctx context.Context, + msg NotifyMsg) fn.Result[NotifyResp] { + + fmt.Printf("NotificationService: %s\n", msg.Message) + + return fn.Ok(NotifyResp{Success: true}) +} diff --git a/baselib/example/example_protofsm.go b/baselib/example/example_protofsm.go new file mode 100644 index 000000000..97b2f93a9 --- /dev/null +++ b/baselib/example/example_protofsm.go @@ -0,0 +1,300 @@ +package baselib_test + +import ( + "context" + "fmt" + + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// This file contains an example document approval workflow implementation +// that demonstrates how to integrate a state machine with the actor system. +// +// The workflow models: Init -> AwaitingReview -> Approved/Rejected + +// ============================================================================ +// Event Types (Sealed Interface) +// ============================================================================ + +// DocEvent represents all possible events in the document workflow FSM. +type DocEvent interface { + isDocEventSealed() +} + +// EventSubmitDocument is sent to start the approval workflow. +type EventSubmitDocument struct { + DocumentID string + Author string +} + +func (EventSubmitDocument) isDocEventSealed() {} + +// EventReviewStarted is sent when the review service accepts the request. +type EventReviewStarted struct{} + +func (EventReviewStarted) isDocEventSealed() {} + +// EventApproved is sent when the document is approved. +type EventApproved struct { + Reviewer string +} + +func (EventApproved) isDocEventSealed() {} + +// EventRejected is sent when the document is rejected. +type EventRejected struct { + Reviewer string + Reason string +} + +func (EventRejected) isDocEventSealed() {} + +// EventResume is sent when resuming from storage. +type EventResume struct{} + +func (EventResume) isDocEventSealed() {} + +// ============================================================================ +// Outbox Events (Routed to Actor Services) +// ============================================================================ + +// DocOutboxEvent is the sealed interface for events routed to actors. +type DocOutboxEvent interface { + protofsm.ActorOutboxEvent + isDocOutboxEventSealed() +} + +// OutboxRequestReview requests document review from ReviewService. +type OutboxRequestReview struct { + protofsm.RoutedOutboxEvent[ReviewMsg, ReviewResp] +} + +// NewOutboxRequestReview creates a new review request outbox event. +func NewOutboxRequestReview(documentID string, author string, + fsmRef actor.TellOnlyRef[protofsm.ActorMessage[DocEvent]]) OutboxRequestReview { + + return OutboxRequestReview{ + RoutedOutboxEvent: protofsm.NewTellOutboxEvent( + ReviewServiceKey, + ReviewMsg{ + DocumentID: documentID, + Author: author, + ReplyTo: fsmRef, + }, + ), + } +} + +func (OutboxRequestReview) isDocOutboxEventSealed() {} + +// OutboxNotify sends notification via NotificationService. +type OutboxNotify struct { + protofsm.RoutedOutboxEvent[NotifyMsg, NotifyResp] +} + +// NewOutboxNotify creates a new notification outbox event. +func NewOutboxNotify(message string) OutboxNotify { + return OutboxNotify{ + RoutedOutboxEvent: protofsm.NewTellOutboxEvent( + NotifyServiceKey, + NotifyMsg{Message: message}, + ), + } +} + +func (OutboxNotify) isDocOutboxEventSealed() {} + +// ============================================================================ +// State Types +// ============================================================================ + +// DocState represents all possible states in the document workflow FSM. +type DocState interface { + protofsm.State[DocEvent, DocOutboxEvent, *DocEnvironment] + isDocStateSealed() +} + +// DocEnvironment holds the FSM execution context. +type DocEnvironment struct { + actorRef actor.TellOnlyRef[protofsm.ActorMessage[DocEvent]] +} + +// SetTellOnlyRef sets the actor reference for the environment. +func (e *DocEnvironment) SetTellOnlyRef(ref actor.TellOnlyRef[protofsm.ActorMessage[DocEvent]]) { + e.actorRef = ref +} + +// GetTellOnlyRef returns the actor reference from the environment. +func (e *DocEnvironment) GetTellOnlyRef() actor.TellOnlyRef[protofsm.ActorMessage[DocEvent]] { + return e.actorRef +} + +// Compile-time check for TellRefEnv. +var _ protofsm.TellRefEnv[DocEvent] = (*DocEnvironment)(nil) + +// StateInit is the initial state. +type StateInit struct{} + +func (StateInit) isDocStateSealed() {} + +func (StateInit) IsTerminal() bool { + return false +} + +func (StateInit) String() string { + return "Init" +} + +// ProcessEvent processes events in the Init state. +func (s *StateInit) ProcessEvent(ctx context.Context, event DocEvent, + env *DocEnvironment) (*protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment], error) { + + switch e := event.(type) { + case EventSubmitDocument: + fmt.Printf("Document %s submitted by %s\n", e.DocumentID, e.Author) + + nextState := &StateAwaitingReview{ + documentID: e.DocumentID, + author: e.Author, + } + + return &protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment]{ + NextState: nextState, + NewEvents: fn.Some(protofsm.EmittedEvent[DocEvent, DocOutboxEvent]{ + Outbox: []DocOutboxEvent{ + NewOutboxRequestReview( + e.DocumentID, e.Author, env.actorRef, + ), + }, + }), + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in StateInit", e) + } +} + +// StateAwaitingReview waits for review decision. +type StateAwaitingReview struct { + documentID string + author string +} + +func (StateAwaitingReview) isDocStateSealed() {} + +func (StateAwaitingReview) IsTerminal() bool { + return false +} + +func (StateAwaitingReview) String() string { + return "AwaitingReview" +} + +// ProcessEvent processes events in the AwaitingReview state. +func (s *StateAwaitingReview) ProcessEvent(ctx context.Context, event DocEvent, + env *DocEnvironment) (*protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment], error) { + + switch e := event.(type) { + case EventResume: + // Re-emit review request on resume. + return &protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment]{ + NextState: s, + NewEvents: fn.Some(protofsm.EmittedEvent[DocEvent, DocOutboxEvent]{ + Outbox: []DocOutboxEvent{ + NewOutboxRequestReview( + s.documentID, s.author, env.actorRef, + ), + }, + }), + }, nil + + case EventReviewStarted: + fmt.Printf("Review started for document %s\n", s.documentID) + + // Stay in same state, just acknowledge. + return &protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment]{ + NextState: s, + }, nil + + case EventApproved: + fmt.Printf("Document %s approved by %s\n", s.documentID, e.Reviewer) + + nextState := &StateApproved{} + notifyMsg := fmt.Sprintf( + "Document %s approved by %s", s.documentID, e.Reviewer, + ) + + return &protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment]{ + NextState: nextState, + NewEvents: fn.Some(protofsm.EmittedEvent[DocEvent, DocOutboxEvent]{ + Outbox: []DocOutboxEvent{ + NewOutboxNotify(notifyMsg), + }, + }), + }, nil + + case EventRejected: + fmt.Printf("Document %s rejected by %s: %s\n", + s.documentID, e.Reviewer, e.Reason) + + nextState := &StateRejected{} + notifyMsg := fmt.Sprintf( + "Document %s rejected by %s: %s", + s.documentID, e.Reviewer, e.Reason, + ) + + return &protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment]{ + NextState: nextState, + NewEvents: fn.Some(protofsm.EmittedEvent[DocEvent, DocOutboxEvent]{ + Outbox: []DocOutboxEvent{ + NewOutboxNotify(notifyMsg), + }, + }), + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in StateAwaitingReview", e) + } +} + +// StateApproved is a terminal state. +type StateApproved struct{} + +func (StateApproved) isDocStateSealed() {} + +func (StateApproved) IsTerminal() bool { + return true +} + +func (StateApproved) String() string { + return "Approved" +} + +// ProcessEvent processes events in the Approved state. +func (s *StateApproved) ProcessEvent(ctx context.Context, event DocEvent, + env *DocEnvironment) (*protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment], error) { + + return nil, fmt.Errorf("no events expected in terminal state") +} + +// StateRejected is a terminal state. +type StateRejected struct{} + +func (StateRejected) isDocStateSealed() {} + +func (StateRejected) IsTerminal() bool { + return true +} + +func (StateRejected) String() string { + return "Rejected" +} + +// ProcessEvent processes events in the Rejected state. +func (s *StateRejected) ProcessEvent(ctx context.Context, event DocEvent, + env *DocEnvironment) (*protofsm.StateTransition[DocEvent, DocOutboxEvent, *DocEnvironment], error) { + + return nil, fmt.Errorf("no events expected in terminal state") +} diff --git a/baselib/example/example_test.go b/baselib/example/example_test.go new file mode 100644 index 000000000..43c49eb18 --- /dev/null +++ b/baselib/example/example_test.go @@ -0,0 +1,106 @@ +package baselib_test + +import ( + "context" + "fmt" + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/baselib/protofsm" +) + +// ExampleActorStateMachine demonstrates integrating a state machine with +// actor services. The FSM emits outbox events that are routed to the +// ReviewService and NotificationService actors. +func ExampleActorStateMachine() { + ctx := context.Background() + + // Create actor system. + system := actor.NewActorSystemWithConfig(actor.SystemConfig{ + MailboxCapacity: 10, + }) + defer func() { + _ = system.Shutdown() + }() + + // Register ReviewService actor. + reviewBehavior := &ReviewServiceBehavior{} + actor.RegisterWithSystem( + system, "review-service", ReviewServiceKey, reviewBehavior, + ) + + // Register NotificationService actor. + notifyBehavior := &NotificationServiceBehavior{} + actor.RegisterWithSystem( + system, "notify-service", NotifyServiceKey, notifyBehavior, + ) + + // Create FSM environment. + env := &DocEnvironment{} + + // Create FSM config. + cfg := protofsm.StateMachineCfg[DocEvent, DocOutboxEvent, *DocEnvironment]{ + Logger: btclog.Disabled, + InitialState: &StateInit{}, + Env: env, + } + + // Spawn FSM as actor. + fsmRef := protofsm.NewSystemsActorStateMachine( + ctx, cfg, system, "document-workflow", + ) + + // Submit a document for review. + fmt.Println("=== Submitting Document ===") + resp1 := fsmRef.Ask(ctx, protofsm.ActorMessage[DocEvent]{ + Event: EventSubmitDocument{ + DocumentID: "DOC-123", + Author: "Alice", + }, + }).Await(ctx) + + if resp1.IsErr() { + fmt.Printf("Error: %v\n", resp1.Err()) + return + } + + // Give async actors time to process the full workflow: + // 1. ReviewService receives request + // 2. ReviewService sends EventReviewStarted back to FSM + // 3. ReviewService sends EventApproved back to FSM + // 4. FSM transitions to Approved and emits OutboxNotify + // 5. NotificationService receives and processes notification + fmt.Println("\n=== Processing Review ===") + time.Sleep(300 * time.Millisecond) + + // Query current state (using StateQuery flag). + fmt.Println("\n=== Checking Final State ===") + resp2 := fsmRef.Ask(ctx, protofsm.ActorMessage[DocEvent]{ + StateQuery: true, + }).Await(ctx) + + if resp2.IsOk() { + state2, _ := resp2.Unpack() + fmt.Printf("Final state: %s\n", state2.CurrentState.String()) + fmt.Printf("Is terminal: %v\n", state2.CurrentState.IsTerminal()) + } + + fmt.Println("\n=== Workflow Complete ===") + + // Output: + // === Submitting Document === + // Document DOC-123 submitted by Alice + // + // === Processing Review === + // ReviewService: Processing document DOC-123 by Alice + // Review started for document DOC-123 + // Document DOC-123 approved by ReviewBot + // NotificationService: Document DOC-123 approved by ReviewBot + // + // === Checking Final State === + // Final state: Approved + // Is terminal: true + // + // === Workflow Complete === +} From b9cb32e1f5973f35dd045c116a4951fd256be614 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 14:13:06 +0100 Subject: [PATCH 7/8] baselib: add fsm actor usage guide --- baselib/PROTOFSM_ACTOR_GUIDE.md | 870 ++++++++++++++++++++++++++++++++ 1 file changed, 870 insertions(+) create mode 100644 baselib/PROTOFSM_ACTOR_GUIDE.md diff --git a/baselib/PROTOFSM_ACTOR_GUIDE.md b/baselib/PROTOFSM_ACTOR_GUIDE.md new file mode 100644 index 000000000..90181b734 --- /dev/null +++ b/baselib/PROTOFSM_ACTOR_GUIDE.md @@ -0,0 +1,870 @@ +# Actor-Based State Machine (protofsm + actor) Usage Guide + +This guide explains how to build restart-safe, event-driven workflows using the `protofsm` and `actor` packages together. These patterns were refined through refactoring three production swap state machines. + +## Table of Contents + +1. [Core Concepts](#core-concepts) +2. [Quick Start](#quick-start) +3. [FSM Design Patterns](#fsm-design-patterns) +4. [Actor Integration Patterns](#actor-integration-patterns) +5. [Restart Safety](#restart-safety) +6. [Common Patterns](#common-patterns) +7. [Testing](#testing) +8. [Troubleshooting](#troubleshooting) + +--- + +## Core Concepts + +### The protofsm Package + +**protofsm** provides a type-safe, event-driven finite state machine: + +- **State**: Processes events and returns state transitions +- **Event**: Triggers state transitions (internal to FSM) +- **OutboxEvent**: Emitted to external actors for side effects +- **Environment**: Provides dependencies to state processors +- **StateMachine**: Event queue processor and orchestrator + +### The actor Package + +**actor** provides message-passing concurrency: + +- **Actor**: Goroutine with mailbox, processes messages sequentially +- **ActorRef**: Reference for sending messages (Tell/Ask) +- **ActorSystem**: Manages actor lifecycle and service registry +- **ServiceKey**: Typed key for actor lookup by service name + +### Integration: ActorStateMachine + +The `protofsm.ActorStateMachine` wraps a `StateMachine` as an `ActorBehavior`, enabling: +- FSM runs inside an actor (one FSM per actor) +- Outbox events automatically dispatch to other actors via service keys +- Multiple FSM actors can coexist in the same ActorSystem + +--- + +## Quick Start + +### Step 1: Define Your Events + +Use sealed interfaces to define all possible events: + +```go +// Event is the sealed interface for all FSM events. +type Event interface { + isEventSealed() +} + +// EventStart begins the workflow. +type EventStart struct { + ID string +} + +func (EventStart) isEventSealed() {} + +// EventComplete finishes the workflow. +type EventComplete struct { + Result string +} + +func (EventComplete) isEventSealed() {} + +// EventResume is sent when resuming from storage. +type EventResume struct{} + +func (EventResume) isEventSealed() {} +``` + +**Why sealed?** Type safety - only your package can define events. + +### Step 2: Define Your Outbox Events + +Outbox events are routed to actors for side effects: + +```go +// OutboxEvent is the sealed interface for outbox events. +type OutboxEvent interface { + protofsm.ActorOutboxEvent + isOutboxEventSealed() +} + +// OutboxPersist requests persistence. +type OutboxPersist struct { + protofsm.RoutedOutboxEvent[StorePersistMsg, StorePersistResp] +} + +func NewOutboxPersist(id string, data interface{}) OutboxPersist { + return OutboxPersist{ + RoutedOutboxEvent: protofsm.NewAskOutboxEvent( + StoreServiceKey, + StorePersistMsg{ID: id, Data: data}, + ), + } +} + +func (OutboxPersist) isOutboxEventSealed() {} +``` + +**Key point:** Use `RoutedOutboxEvent` to dispatch to actors via service keys. + +### Step 3: Define Your States + +Each state implements the `State` interface: + +```go +// State is the sealed interface for all FSM states. +type State interface { + protofsm.State[Event, OutboxEvent, *Environment] + isStateSealed() +} + +// StateInit is the initial state. +type StateInit struct{} + +func (StateInit) isStateSealed() {} +func (StateInit) IsTerminal() bool { return false } +func (StateInit) String() string { return "Init" } + +func (s *StateInit) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*protofsm.StateTransition[Event, OutboxEvent, *Environment], error) { + + switch e := event.(type) { + case EventStart: + nextState := &StateProcessing{id: e.ID} + + return &protofsm.StateTransition[Event, OutboxEvent, *Environment]{ + NextState: nextState, + NewEvents: fn.Some(protofsm.EmittedEvent[Event, OutboxEvent]{ + Outbox: []OutboxEvent{ + NewOutboxPersist(e.ID, nextState), + }, + }), + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in StateInit", e) + } +} +``` + +**Pattern:** Pattern-match on event type, create next state, emit outbox events. + +### Step 4: Define Your Environment + +Environment provides dependencies and receives ActorRef injection: + +```go +// Environment holds FSM execution context. +type Environment struct { + store Store + actorRef actor.TellOnlyRef[protofsm.ActorMessage[Event]] +} + +func NewEnvironment(store Store) *Environment { + return &Environment{store: store} +} + +// SetTellOnlyRef is called by protofsm to inject the FSM's actor reference. +func (e *Environment) SetTellOnlyRef(ref actor.TellOnlyRef[protofsm.ActorMessage[Event]]) { + e.actorRef = ref +} + +func (e *Environment) GetTellOnlyRef() actor.TellOnlyRef[protofsm.ActorMessage[Event]] { + return e.actorRef +} + +// Compile-time check. +var _ protofsm.TellRefEnv[Event] = (*Environment)(nil) +``` + +**Why?** Actors need to send events back to the FSM (e.g., "operation completed"). + +### Step 5: Create Actors for Side Effects + +Actors handle external operations (storage, API calls, monitoring): + +```go +const storeActorName = "store" + +var StoreServiceKey = actor.NewServiceKey[StorePersistMsg, StorePersistResp](storeActorName) + +type StorePersistMsg struct { + actor.BaseMessage + ID string + Data interface{} +} + +func (m StorePersistMsg) MessageType() string { return "StorePersist" } + +type StorePersistResp struct { + Success bool +} + +type StoreActorBehavior struct { + store Store +} + +func (s *StoreActorBehavior) Receive(ctx context.Context, + msg StorePersistMsg) fn.Result[StorePersistResp] { + + // Perform persistence. + if err := s.store.Save(msg.ID, msg.Data); err != nil { + return fn.Err[StorePersistResp](err) + } + + return fn.Ok(StorePersistResp{Success: true}) +} +``` + +**Pattern:** Actor receives message, performs side effect, returns result. + +### Step 6: Wire It All Together + +Create ActorSystem, register actors, spawn FSM actors: + +```go +func main() { + ctx := context.Background() + + // Create actor system. + system := actor.NewActorSystemWithConfig(actor.SystemConfig{ + MailboxCapacity: 100, + }) + defer system.Shutdown() + + // Register shared actors. + storeActor := &StoreActorBehavior{store: NewStore()} + actor.RegisterWithSystem(system, storeActorName, StoreServiceKey, storeActor) + + // Create FSM configuration. + env := NewEnvironment(store) + cfg := protofsm.StateMachineCfg[Event, OutboxEvent, *Environment]{ + Logger: logger, + InitialState: &StateInit{}, + Env: env, + } + + // Spawn FSM as actor. + fsmRef := protofsm.NewSystemsActorStateMachine( + ctx, cfg, system, "workflow-123", + ) + + // Send initial event. + fsmRef.Tell(ctx, protofsm.ActorMessage[Event]{ + Event: EventStart{ID: "workflow-123"}, + }) +} +``` + +--- + +## FSM Design Patterns + +### Pattern 1: Sealed Interfaces + +Always use sealed interfaces for Events, States, and OutboxEvents: + +```go +// Event is sealed via unexported method. +type Event interface { + isEventSealed() +} + +// Only types in this package can implement isEventSealed(). +``` + +**Why?** Type safety - compiler catches invalid event types. + +### Pattern 2: State Holds Data, Not Behavior + +States are data containers with transition logic: + +```go +// GOOD: State holds necessary data for transitions. +type StateProcessing struct { + id string + startTime time.Time + retries int +} + +// AVOID: State with channels, goroutines, or mutable shared state. +type StateBad struct { + id string + resultCh chan Result // ❌ Don't do this + mu sync.Mutex // ❌ States are immutable +} +``` + +**Why?** States must be serializable for restart safety. + +### Pattern 3: Emit Outbox Events for Side Effects + +Never perform side effects directly in ProcessEvent: + +```go +// GOOD: Emit outbox event for persistence. +func (s *StateInit) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*protofsm.StateTransition[Event, OutboxEvent, *Environment], error) { + + switch e := event.(type) { + case EventStart: + nextState := &StateProcessing{id: e.ID} + + return &protofsm.StateTransition[Event, OutboxEvent, *Environment]{ + NextState: nextState, + NewEvents: fn.Some(protofsm.EmittedEvent[Event, OutboxEvent]{ + Outbox: []OutboxEvent{ + NewOutboxPersist(e.ID, nextState), // ✅ Emit event + }, + }), + }, nil + } +} + +// AVOID: Direct side effects. +func (s *StateBad) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*protofsm.StateTransition[Event, OutboxEvent, *Environment], error) { + + env.store.Save("key", "value") // ❌ Don't call external systems directly + + return &protofsm.StateTransition[...]{...}, nil +} +``` + +**Why?** Keeps FSM pure, testable, and allows outbox events to be dispatched asynchronously. + +### Pattern 4: EventResume for Restart Safety + +Every non-terminal state should handle EventResume: + +```go +func (s *StateProcessing) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*protofsm.StateTransition[Event, OutboxEvent, *Environment], error) { + + switch e := event.(type) { + case EventResume: + // Re-emit pending outbox events to re-establish monitoring. + return &protofsm.StateTransition[Event, OutboxEvent, *Environment]{ + NextState: s, // Stay in same state + NewEvents: fn.Some(protofsm.EmittedEvent[Event, OutboxEvent]{ + Outbox: []OutboxEvent{ + NewOutboxMonitor(s.id, env.actorRef), + }, + }), + }, nil + + // ... other events + } +} +``` + +**Why?** When resuming from storage, pending operations must be re-established. + +--- + +## Actor Integration Patterns + +### Pattern 1: Actor Sends Events Back to FSM + +Actors receive a `TellOnlyRef` to send result events back: + +```go +type MonitorMsg struct { + actor.BaseMessage + ID string + ActorTellRef actor.TellOnlyRef[protofsm.ActorMessage[Event]] +} + +func (b *MonitorBehavior) Receive(ctx context.Context, + msg MonitorMsg) fn.Result[MonitorResp] { + + go func() { + // Perform background monitoring. + result := b.pollUntilComplete(ctx, msg.ID) + + // Send result event back to FSM. + msg.ActorTellRef.Tell(ctx, protofsm.ActorMessage[Event]{ + Event: EventComplete{Result: result}, + }) + }() + + return fn.Ok(MonitorResp{Success: true}) +} +``` + +**Pattern:** Actor starts background work, sends event when done. + +### Pattern 2: Tell vs Ask for Outbox Events + +Use `Tell` for fire-and-forget, `Ask` for request-response: + +```go +// Tell: Fire-and-forget (monitoring, notifications). +func NewOutboxMonitor(id string) OutboxMonitor { + return OutboxMonitor{ + RoutedOutboxEvent: protofsm.NewTellOutboxEvent( + MonitorServiceKey, + MonitorMsg{ID: id}, + ), + } +} + +// Ask: Wait for response (persistence, validation). +func NewOutboxPersist(id string) OutboxPersist { + return OutboxPersist{ + RoutedOutboxEvent: protofsm.NewAskOutboxEvent( + StoreServiceKey, + StorePersistMsg{ID: id}, + ), + } +} +``` + +**Guideline:** Use Ask for critical operations (persistence), Tell for non-blocking operations (monitoring). + +### Pattern 3: Service Keys for Routing + +Service keys enable location transparency: + +```go +// Define service key (global/package-level). +var StoreServiceKey = actor.NewServiceKey[StorePersistMsg, StorePersistResp]("store") + +// Register actor. +actor.RegisterWithSystem(system, "store", StoreServiceKey, storeBehavior) + +// Emit outbox event (routed automatically via service key). +NewOutboxPersist(id, data) // Uses StoreServiceKey internally +``` + +**Why?** FSM doesn't need ActorRef - service key handles routing. + +--- + +## Restart Safety + +### Key Principle: Idempotent Resumption + +When a process crashes and restarts: +1. Load persisted state from storage +2. Spawn FSM actor with loaded state as `InitialState` +3. Send `EventResume` to re-establish pending operations + +### Pattern: Dual Persistence + +Persist both state name AND state data: + +```go +type StoredWorkflow struct { + ID string + State State // The actual state object + Data interface{} // State-specific data + CreatedAt time.Time + UpdatedAt time.Time +} + +// When loading from DB, reconstruct state from name + data. +func ReconstructStateFromName(stateName string, stored *StoredWorkflow) (State, error) { + switch stateName { + case "Processing": + return &StateProcessing{ + id: stored.ID, + startTime: stored.Data.(time.Time), + }, nil + + // ... other states + } +} +``` + +### Pattern: EventResume Re-emits Outbox Events + +```go +case EventResume: + // Re-emit pending operations. + return &protofsm.StateTransition[Event, OutboxEvent, *Environment]{ + NextState: s, // Stay in same state + NewEvents: fn.Some(protofsm.EmittedEvent[Event, OutboxEvent]{ + Outbox: []OutboxEvent{ + // Re-emit monitoring (idempotent). + NewOutboxMonitor(s.id, env.actorRef), + }, + }), + }, nil +``` + +**Critical:** Actors must be idempotent - receiving the same message twice is safe. + +### Pattern: Delta Updates for Persistence + +Only persist changed fields: + +```go +type WorkflowUpdates struct { + StartTime *time.Time // Only set if changed + Retries *int // Only set if changed + State State // Always set to reflect current state +} + +// StoreActor applies deltas. +func (s *StoreActor) Receive(ctx context.Context, msg StorePersistMsg) fn.Result[StorePersistResp] { + existing, _ := s.store.Get(msg.ID) + + // Apply only non-nil fields. + if msg.Updates.StartTime != nil { + existing.StartTime = *msg.Updates.StartTime + } + if msg.Updates.Retries != nil { + existing.Retries = *msg.Updates.Retries + } + if msg.Updates.State != nil { + existing.State = msg.Updates.State + } + + s.store.Save(existing) + return fn.Ok(StorePersistResp{Success: true}) +} +``` + +--- + +## Common Patterns + +### Pattern: Background Monitoring Actor + +Actors can spawn goroutines for long-running operations: + +```go +func (b *MonitorBehavior) Receive(ctx context.Context, + msg MonitorMsg) fn.Result[MonitorResp] { + + // Return immediately (Tell pattern). + go b.monitorInBackground(ctx, msg) + return fn.Ok(MonitorResp{Success: true}) +} + +func (b *MonitorBehavior) monitorInBackground(ctx context.Context, msg MonitorMsg) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if b.checkComplete(msg.ID) { + // Send completion event to FSM. + msg.ActorTellRef.Tell(ctx, protofsm.ActorMessage[Event]{ + Event: EventComplete{}, + }) + return + } + } + } +} +``` + +**Use case:** Polling external systems, waiting for blockchain events, timeouts. + +### Pattern: Manager Spawns FSM Actors + +One manager owns the ActorSystem and spawns FSM actors: + +```go +type Manager struct { + system *actor.ActorSystem + fsmActors map[string]actor.ActorRef[protofsm.ActorMessage[Event], ...] + mu sync.RWMutex +} + +func (m *Manager) StartWorkflow(ctx context.Context, id string) error { + // Spawn FSM actor. + env := NewEnvironment(m.store) + cfg := protofsm.StateMachineCfg[Event, OutboxEvent, *Environment]{ + Logger: m.logger.WithPrefix(id), + InitialState: &StateInit{}, + Env: env, + } + + fsmRef := protofsm.NewSystemsActorStateMachine(ctx, cfg, m.system, id) + + m.mu.Lock() + m.fsmActors[id] = fsmRef + m.mu.Unlock() + + // Send initial event. + fsmRef.Tell(ctx, protofsm.ActorMessage[Event]{ + Event: EventStart{ID: id}, + }) + + return nil +} + +func (m *Manager) ResumeWorkflows(ctx context.Context) error { + workflows, _ := m.store.ListPending() + + for _, wf := range workflows { + // Spawn FSM actor with persisted state. + cfg := protofsm.StateMachineCfg[Event, OutboxEvent, *Environment]{ + Logger: m.logger.WithPrefix(wf.ID), + InitialState: wf.State, // Reconstructed from storage + Env: NewEnvironment(m.store), + } + + fsmRef := protofsm.NewSystemsActorStateMachine(ctx, cfg, m.system, wf.ID) + + m.mu.Lock() + m.fsmActors[wf.ID] = fsmRef + m.mu.Unlock() + + // Re-establish pending operations. + fsmRef.Tell(ctx, protofsm.ActorMessage[Event]{ + Event: EventResume{}, + }) + } + + return nil +} +``` + +**Pattern:** Manager = one ActorSystem, multiple FSM actors (one per workflow instance). + +### Pattern: Cleanup on Terminal States + +Remove FSM actors when workflow completes: + +```go +type Environment struct { + store Store + actorRef actor.TellOnlyRef[protofsm.ActorMessage[Event]] + cleanupFunc func(ctx context.Context, id string) +} + +// In terminal states, call cleanup. +func (s *StateCompleted) ProcessEvent(ctx context.Context, event Event, + env *Environment) (*protofsm.StateTransition[Event, OutboxEvent, *Environment], error) { + + // Cleanup FSM actor from manager registry. + if env.cleanupFunc != nil { + env.cleanupFunc(ctx, s.id) + } + + return nil, fmt.Errorf("no events expected in terminal state") +} +``` + +### Pattern: Dual Dispatch for Blocking Operations + +When you have both async actors and blocking operations (e.g., HTLC interception): + +```go +// Actor sends to BOTH: +// 1. Blocking channel (for waiting goroutine) +// 2. FSM event (for state machine completion) + +func (h *HTLCInterceptor) Receive(ctx context.Context, + msg SettleMsg) fn.Result[SettleResp] { + + // Send preimage to blocking channel. + h.claimChans[msg.PaymentHash] <- msg.Preimage + + // Send event to FSM. + msg.ActorTellRef.Tell(ctx, protofsm.ActorMessage[Event]{ + Event: EventHTLCSettled{}, + }) + + return fn.Ok(SettleResp{Success: true}) +} +``` + +--- + +## Testing + +### Unit Testing States + +Test state transitions in isolation: + +```go +func TestStateInit_EventStart(t *testing.T) { + state := &StateInit{} + env := &Environment{} + + transition, err := state.ProcessEvent( + context.Background(), + EventStart{ID: "test-123"}, + env, + ) + + require.NoError(t, err) + require.Equal(t, "Processing", transition.NextState.String()) + require.Len(t, transition.NewEvents.UnwrapOr(...).Outbox, 1) +} +``` + +### Integration Testing with Actors + +Test the full actor + FSM integration: + +```go +func TestWorkflowIntegration(t *testing.T) { + ctx := context.Background() + system := actor.NewActorSystemWithConfig(actor.SystemConfig{}) + defer system.Shutdown() + + // Register actors. + actor.RegisterWithSystem(system, "store", StoreServiceKey, &StoreActorBehavior{}) + + // Spawn FSM. + cfg := protofsm.StateMachineCfg[Event, OutboxEvent, *Environment]{ + Logger: btclog.Disabled, + InitialState: &StateInit{}, + Env: NewEnvironment(store), + } + + fsmRef := protofsm.NewSystemsActorStateMachine(ctx, cfg, system, "test-workflow") + + // Send event and verify state transition. + resp := fsmRef.Ask(ctx, protofsm.ActorMessage[Event]{ + Event: EventStart{ID: "test-123"}, + }).Await(ctx) + + require.NoError(t, resp.Err()) +} +``` + +### Testing Restart Safety + +Test that workflows resume correctly: + +```go +func TestRestart(t *testing.T) { + // 1. Start workflow, persist state. + manager1 := NewManager(store) + manager1.StartWorkflow(ctx, "wf-1") + // ... wait for state = Processing + + // 2. Stop manager (simulate crash). + manager1.Stop() + + // 3. Restart with same store. + manager2 := NewManager(store) + manager2.ResumeWorkflows(ctx) + + // 4. Verify workflow continues from persisted state. + // ... assert workflow completes successfully +} +``` + +--- + +## Troubleshooting + +### Issue: "swap not found" when persisting + +**Cause:** StoreActor calls UpdateSwap before swap exists in store. + +**Solution:** Check if swap exists, use CreateSwap for new, UpdateSwap for existing: + +```go +existing, err := s.store.Get(msg.ID) +isNew := err != nil + +if isNew { + s.store.Create(newSwap) +} else { + s.store.Update(existing) +} +``` + +### Issue: FSM stuck in non-terminal state + +**Cause:** Actor performed operation but didn't send result event to FSM. + +**Solution:** Always send result event via `ActorTellRef`: + +```go +// After completing operation: +msg.ActorTellRef.Tell(ctx, protofsm.ActorMessage[Event]{ + Event: EventOperationComplete{}, +}) +``` + +### Issue: EventResume doesn't re-establish monitoring + +**Cause:** State doesn't re-emit outbox events on EventResume. + +**Solution:** Handle EventResume in every non-terminal state: + +```go +case EventResume: + return &protofsm.StateTransition[...]{ + NextState: s, + NewEvents: fn.Some(protofsm.EmittedEvent[...]{ + Outbox: []OutboxEvent{ + NewOutboxMonitor(s.id, env.actorRef), + }, + }), + }, nil +``` + +### Issue: Method name conflicts in composed interfaces + +**Cause:** Multiple interfaces with same method name but different return types. + +**Solution:** Use package-specific method names: + +```go +// Instead of: +type InStore interface { + UpsertSwap(ctx context.Context, swap *StoredSwap) error +} + +type OutStore interface { + UpsertSwap(ctx context.Context, swap *StoredSwap) error // Conflict! +} + +// Do: +type InStore interface { + UpsertInSwap(ctx context.Context, swap *StoredSwap) error +} + +type OutStore interface { + UpsertOutSwap(ctx context.Context, swap *StoredSwap) error +} +``` + +--- + +## Best Practices Summary + +1. ✅ **Use sealed interfaces** for Events, States, OutboxEvents +2. ✅ **States are immutable data** - no channels, mutexes, or goroutines +3. ✅ **Emit outbox events for side effects** - never call external systems from ProcessEvent +4. ✅ **Always handle EventResume** in non-terminal states +5. ✅ **Actors send result events** back to FSM via ActorTellRef +6. ✅ **Use Tell for async, Ask for sync** outbox events +7. ✅ **One ActorSystem per manager**, multiple FSM actors per workflow instance +8. ✅ **Persist state after every transition** via OutboxPersist +9. ✅ **Make actors idempotent** - safe to receive same message twice +10. ✅ **Test restart scenarios** - load from storage, send EventResume, verify completion + +--- + +## Example: See It In Action + +Check `baselib/example/` for a complete document approval workflow: +- `example_protofsm.go` - FSM states and events +- `example_actors.go` - ReviewService and NotificationService actors +- `example_test.go` - Integration test with actor system + +Or study the production implementations: +- `sdk/swaps/in/` - Client in-swap (simple: fund → monitor → complete/refund) +- `swapserver/out/` - Server out-swap (complex: intercept → fund → monitor → settle HTLC) +- `swapserver/in/` - Server in-swap (multi-actor: monitor → pay invoice → claim) + +--- + +**Summary:** The actor + protofsm pattern provides restart-safe, event-driven workflows with clean separation between FSM logic (pure state transitions) and side effects (actors). Key insight: FSMs emit outbox events which are automatically routed to actors, actors send result events back to FSMs. From 473d8466a4f27ba482289fb54861d2fe07751db2 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 20 Nov 2025 14:23:36 +0100 Subject: [PATCH 8/8] baselib: actor, fix registering with system possible panic --- baselib/actor/system.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/baselib/actor/system.go b/baselib/actor/system.go index 382e62aed..7b142b56a 100644 --- a/baselib/actor/system.go +++ b/baselib/actor/system.go @@ -112,6 +112,17 @@ func RegisterWithSystem[M Message, R any](as *ActorSystem, id string, key Servic behavior ActorBehavior[M, R], ) ActorRef[M, R] { + if as.ctx.Err() != nil { + // To avoid returning nil and causing a panic, we can create and + // return a reference to a dummy actor that is already stopped. + // This ensures that any calls to the returned ref will fail + // with ErrActorTerminated. + dummyCfg := ActorConfig[M, R]{ID: id} + dummyActor := NewActor(dummyCfg) + dummyActor.Stop() + return dummyActor.Ref() + } + actorCfg := ActorConfig[M, R]{ ID: id, Behavior: behavior,