Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
66ce116
db: add durable mailbox schema and SQLC queries
Roasbeef Dec 17, 2025
5f1f68d
db: implement ActorDeliveryStore for mailbox persistence
Roasbeef Dec 17, 2025
b7cba3a
baselib/actor: add TLVMessage codec for message serialization
Roasbeef Dec 17, 2025
817852c
baselib/actor: add Delivery abstraction with lease operations
Roasbeef Dec 17, 2025
6b06337
baselib/actor: add DurableMailbox with lease-based delivery
Roasbeef Dec 17, 2025
05a2dae
baselib/actor: add AskResponse for durable request-response
Roasbeef Dec 17, 2025
76313f7
baselib/actor: add DurableActor with deduplication and retry
Roasbeef Dec 17, 2025
97385fc
baselib/actor: add RestartMessage for crash recovery
Roasbeef Dec 17, 2025
0464d62
baselib/actor: add OutboxPublisher CDC and MapRef adapter
Roasbeef Dec 17, 2025
21f0250
internal/actortest: add e2e integration tests
Roasbeef Dec 17, 2025
4f3d6f6
docs: add durable actor architecture documentation
Roasbeef Dec 17, 2025
701a51c
multi: fix lint on rebased durability
bhandras Jan 16, 2026
abdad2a
db: renumber durable mailbox migration
bhandras Jan 17, 2026
ee105d6
baselib/actor: harden Delivery with mutex and deferred promise comple…
Roasbeef Feb 6, 2026
c3655da
baselib/actor: nack DurableAsk on outbox failure and defer tx promise
Roasbeef Feb 6, 2026
9f9741b
baselib/actor: harden mailbox with poison dead-lettering and outbox d…
Roasbeef Feb 6, 2026
0e153ad
db/sqlc: regenerate queries after mailbox ON CONFLICT change
Roasbeef Feb 6, 2026
36211ee
baselib/actor: add tests for durability review fixes
Roasbeef Feb 6, 2026
494d497
baselib/actor: fix tx path deferPromise propagation and DurableAsk retry
Roasbeef Feb 6, 2026
29b4706
multi: check Tell error return values in wallet, round, and vtxo
Roasbeef Feb 6, 2026
0086c81
baselib/actor: reorder SaveAskResult after lease validation in Ack
Roasbeef Feb 10, 2026
6035f5b
multi: use BIGINT for timestamps and add outbox claim lease
Roasbeef Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions baselib/actor/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ type envelope[M Message, R any] struct {
message M
promise Promise[R]
callerCtx context.Context

// callbackActorID is set for DurableAsk to route the response.
// The response will be delivered to this actor's mailbox via outbox.
callbackActorID string

// correlationID links DurableAsk requests to their responses.
// The caller uses this to match responses to original requests.
correlationID string

// delivery is set by DurableMailbox to pass the Delivery object to the
// DurableActor without using a global map. This is nil for regular
// (non-durable) actors.
delivery any
}

// Actor represents a concrete actor implementation. It encapsulates a behavior,
Expand Down Expand Up @@ -300,12 +313,11 @@ 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.
// Tell sends a message without waiting for a response. Returns an error if
// the message could not be enqueued.
//
//nolint:lll
func (ref *actorRefImpl[M, R]) Tell(ctx context.Context, msg M) {
func (ref *actorRefImpl[M, R]) Tell(ctx context.Context, msg M) error {
log.TraceS(ctx, "Sending Tell message",
"actor_id", ref.actor.id,
"msg_type", msg.MessageType())
Expand All @@ -319,24 +331,33 @@ func (ref *actorRefImpl[M, R]) Tell(ctx context.Context, msg M) {
}
ok := ref.actor.mailbox.Send(ctx, env)

// If the send failed, determine whether to route to DLO. We only send
// to the DLO when the failure was due to actor termination or mailbox
// closure (actor-side failures). If the caller's context was cancelled,
// the message is intentionally dropped to preserve prior semantics
// where caller-aborted messages are not revived via the DLO.
// If the send failed, determine the error and whether to route to DLO.
if !ok {
if ctx.Err() == nil || ref.actor.ctx.Err() != nil {
// Check if actor is terminated.
if ref.actor.ctx.Err() != nil {
log.DebugS(ctx, "Tell failed, routing to DLO",
"actor_id", ref.actor.id,
"msg_type", msg.MessageType())

ref.trySendToDLO(msg)
} else {

return ErrActorTerminated
}

// Check if caller's context was cancelled.
if ctx.Err() != nil {
log.TraceS(ctx, "Tell failed, caller cancelled",
"actor_id", ref.actor.id,
"msg_type", msg.MessageType())

return ctx.Err()
}

// Mailbox full or other failure.
return ErrMailboxFull
}

return nil
}

// Ask sends a message and returns a Future for the response. The Future will be
Expand Down
182 changes: 182 additions & 0 deletions baselib/actor/ask_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package actor

import (
"fmt"
"io"

"github.com/lightningnetwork/lnd/tlv"
)

// AskResponseMsgType is the TLV type identifier for AskResponse messages.
// This is a well-known type used by the DurableAsk pattern.
const AskResponseMsgType tlv.Type = 0xFFFF // 65535 - reserved for system messages
Comment thread
bhandras marked this conversation as resolved.

// TLV record type constants for AskResponse fields.
const (
askResponseCorrelationIDType tlv.Type = 1
askResponseResultBlobType tlv.Type = 2
askResponseErrorTextType tlv.Type = 3
)

// AskResponse is a durable response message for DurableAsk requests.
// When an actor processes a message with callback metadata, it writes an
// AskResponse to its outbox targeting the callback actor. The OutboxPublisher
// then delivers this response to the caller's durable mailbox.
//
// This is the core mechanism for crash-safe Ask semantics: the response
// survives both caller and target crashes because it flows through the
// durable outbox/mailbox infrastructure.
//
// The ResultBlob contains a fully-encoded TLVMessage (with type ID prefix),
// allowing the caller to use their MessageCodec to decode the typed result.
// This enables generic AskResponse handling while preserving type safety.
type AskResponse struct {
BaseMessage

// CorrelationID links this response to the original DurableAsk request.
// The caller uses this to match responses to pending requests.
CorrelationID string

// ResultBlob contains the codec-encoded result (includes TLV type ID).
// Use DecodeResult() with a MessageCodec to get the typed result.
// Empty if the request failed with an error.
ResultBlob tlv.Blob

// ErrorText contains the error message if the request failed.
// Empty string if the request succeeded.
ErrorText string
}

// MessageType returns a human-readable type name for logging.
func (m AskResponse) MessageType() string {
return "actor.AskResponse"
}

// TLVType returns the unique TLV type identifier for this message.
func (m AskResponse) TLVType() tlv.Type {
return AskResponseMsgType
}

// Encode serializes the message to the provided writer.
func (m AskResponse) Encode(w io.Writer) error {
correlationID := []byte(m.CorrelationID)
resultBlob := m.ResultBlob
errorText := []byte(m.ErrorText)

records := []tlv.Record{
tlv.MakePrimitiveRecord(
askResponseCorrelationIDType, &correlationID,
),
tlv.MakePrimitiveRecord(
askResponseResultBlobType, &resultBlob,
),
tlv.MakePrimitiveRecord(
askResponseErrorTextType, &errorText,
),
}

stream, err := tlv.NewStream(records...)
if err != nil {
return err
}

return stream.Encode(w)
}

// Decode deserializes the message from the provided reader.
func (m *AskResponse) Decode(r io.Reader) error {
var (
correlationID []byte
resultBlob []byte
errorText []byte
)

records := []tlv.Record{
tlv.MakePrimitiveRecord(
askResponseCorrelationIDType, &correlationID,
),
tlv.MakePrimitiveRecord(
askResponseResultBlobType, &resultBlob,
),
tlv.MakePrimitiveRecord(
askResponseErrorTextType, &errorText,
),
}

stream, err := tlv.NewStream(records...)
if err != nil {
return err
}

if _, err := stream.DecodeWithParsedTypes(r); err != nil {
return err
}

m.CorrelationID = string(correlationID)
m.ResultBlob = resultBlob
m.ErrorText = string(errorText)

return nil
}

// IsError returns true if this response represents an error.
func (m AskResponse) IsError() bool {
return m.ErrorText != ""
}

// DecodeResult decodes the result blob using the provided codec.
// Returns an error if the response is an error or if decoding fails.
func (m AskResponse) DecodeResult(codec *MessageCodec) (TLVMessage, error) {
if m.IsError() {
return nil, fmt.Errorf("ask failed: %s", m.ErrorText)
}

if len(m.ResultBlob) == 0 {
return nil, nil
}

return codec.Decode(m.ResultBlob)
}

// NewAskResponseSuccess creates a successful AskResponse with a raw result blob.
// Use NewAskResponseWithResult to encode a TLVMessage result.
func NewAskResponseSuccess(correlationID string, resultBlob tlv.Blob) *AskResponse {
return &AskResponse{
CorrelationID: correlationID,
ResultBlob: resultBlob,
ErrorText: "",
}
}

// NewAskResponseWithResult creates a successful AskResponse by encoding the
// result using the provided codec. This is the preferred way to create
// responses as it ensures the result is properly encoded for decoding.
func NewAskResponseWithResult(
correlationID string,
codec *MessageCodec,
result TLVMessage,
) (*AskResponse, error) {

resultBlob, err := codec.Encode(result)
if err != nil {
return nil, fmt.Errorf("encode result: %w", err)
}

return &AskResponse{
CorrelationID: correlationID,
ResultBlob: resultBlob,
ErrorText: "",
}, nil
}

// NewAskResponseError creates an error AskResponse with the given error text.
func NewAskResponseError(correlationID string, errorText string) *AskResponse {
return &AskResponse{
CorrelationID: correlationID,
ResultBlob: nil,
ErrorText: errorText,
}
}

// Compile-time interface check.
var _ TLVMessage = (*AskResponse)(nil)
Loading
Loading