Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 58 additions & 0 deletions actor/transform.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package actor

import "context"

// MapInputRef wraps a TellOnlyRef and transforms incoming messages before
// forwarding them to the target ref. This allows adapting a ref that expects
// message type Out to accept message type In, eliminating the need for
// intermediate adapter actors.
//
// This is particularly useful for notification patterns where a source actor
// sends events of a specific type, but consumers want to receive events in
// their own domain-specific type.
//
// Example usage:
//
// // roundActorRef accepts round.ConfirmationEvent
// // chainsource sends chainsource.ConfirmationEvent
// adaptedRef := actor.NewMapInputRef(
// roundActorRef,
// func(cs chainsource.ConfirmationEvent) round.ConfirmationEvent {
// return round.ConfirmationEvent{
// TxID: cs.Txid,
// BlockHeight: cs.BlockHeight,
// // ... transform fields
// }
// },
// )
// // Now adaptedRef can be used as TellOnlyRef[chainsource.ConfirmationEvent]
type MapInputRef[In Message, Out Message] struct {
targetRef TellOnlyRef[Out]
mapFn func(In) Out
}

// NewMapInputRef creates a new message-transforming wrapper around a
// TellOnlyRef. The mapFn function is called for each message to transform it
// from type In to type Out before forwarding to targetRef.
func NewMapInputRef[In Message, Out Message](
targetRef TellOnlyRef[Out], mapFn func(In) Out) *MapInputRef[In, Out] {

return &MapInputRef[In, Out]{
targetRef: targetRef,
mapFn: mapFn,
}
}

// Tell transforms the incoming message using the map function and forwards it
// to the target ref. If the context is cancelled before the message can be sent
// to the target actor's mailbox, the message may be dropped.
func (m *MapInputRef[In, Out]) Tell(ctx context.Context, msg In) {
transformed := m.mapFn(msg)
m.targetRef.Tell(ctx, transformed)
}

// ID returns a unique identifier for this actor. The ID includes the
// "map-input-" prefix to indicate this is a transformation wrapper.
func (m *MapInputRef[In, Out]) ID() string {
return "map-input-" + m.targetRef.ID()
}
201 changes: 201 additions & 0 deletions actor/transform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package actor

import (
"context"
"testing"

"github.com/stretchr/testify/require"
)

// testMessageA is a test message type for transformation testing.
type testMessageA struct {
BaseMessage
Value int
Text string
}

// MessageType returns the message type identifier.
func (m testMessageA) MessageType() string {
return "testMessageA"
}

// testMessageB is another test message type for transformation testing.
type testMessageB struct {
BaseMessage
DoubledValue int
UpperText string
}

// MessageType returns the message type identifier.
func (m testMessageB) MessageType() string {
return "testMessageB"
}

// mockTellOnlyRef is a mock implementation of TellOnlyRef for testing.
type mockTellOnlyRef[M Message] struct {
id string
received []M
}

func (m *mockTellOnlyRef[M]) Tell(ctx context.Context, msg M) {
m.received = append(m.received, msg)
}

func (m *mockTellOnlyRef[M]) ID() string {
return m.id
}

// TestMapInputRefBasicTransformation tests that messages are correctly
// transformed when sent through a MapInputRef.
func TestMapInputRefBasicTransformation(t *testing.T) {
t.Parallel()

// Create a mock target ref that expects testMessageB.
targetRef := &mockTellOnlyRef[testMessageB]{
id: "test-target",
}

// Create a transformation function from A to B.
transformFn := func(a testMessageA) testMessageB {
return testMessageB{
DoubledValue: a.Value * 2,
UpperText: a.Text + "-TRANSFORMED",
}
}

// Create the MapInputRef that accepts testMessageA.
adaptedRef := NewMapInputRef(targetRef, transformFn)

// Send a message of type A.
ctx := context.Background()
inputMsg := testMessageA{
Value: 42,
Text: "hello",
}
adaptedRef.Tell(ctx, inputMsg)

// Verify the target received the transformed message.
require.Len(t, targetRef.received, 1)
received := targetRef.received[0]
require.Equal(t, 84, received.DoubledValue)
require.Equal(t, "hello-TRANSFORMED", received.UpperText)
}

// TestMapInputRefMultipleMessages tests that multiple messages are all
// correctly transformed.
func TestMapInputRefMultipleMessages(t *testing.T) {
t.Parallel()

targetRef := &mockTellOnlyRef[testMessageB]{
id: "test-target",
}

transformFn := func(a testMessageA) testMessageB {
return testMessageB{
DoubledValue: a.Value * 2,
UpperText: a.Text,
}
}

adaptedRef := NewMapInputRef(targetRef, transformFn)
ctx := context.Background()

// Send multiple messages.
messages := []testMessageA{
{Value: 1, Text: "one"},
{Value: 2, Text: "two"},
{Value: 3, Text: "three"},
}

for _, msg := range messages {
adaptedRef.Tell(ctx, msg)
}

// Verify all messages were transformed and received.
require.Len(t, targetRef.received, 3)
require.Equal(t, 2, targetRef.received[0].DoubledValue)
require.Equal(t, "one", targetRef.received[0].UpperText)
require.Equal(t, 4, targetRef.received[1].DoubledValue)
require.Equal(t, "two", targetRef.received[1].UpperText)
require.Equal(t, 6, targetRef.received[2].DoubledValue)
require.Equal(t, "three", targetRef.received[2].UpperText)
}

// TestMapInputRefID tests that the ID method returns a prefixed version of
// the target ref's ID.
func TestMapInputRefID(t *testing.T) {
t.Parallel()

targetRef := &mockTellOnlyRef[testMessageB]{
id: "my-target-actor",
}

transformFn := func(a testMessageA) testMessageB {
return testMessageB{}
}

adaptedRef := NewMapInputRef(targetRef, transformFn)

// Verify the ID includes the target ID with a prefix.
expectedID := "map-input-my-target-actor"
require.Equal(t, expectedID, adaptedRef.ID())
}

// TestMapInputRefTypeSafety tests that the generic type constraints ensure
// compile-time type safety.
func TestMapInputRefTypeSafety(t *testing.T) {
t.Parallel()

// This test verifies that the type system works correctly. If this
// compiles, it proves type safety is maintained.
targetRef := &mockTellOnlyRef[testMessageB]{
id: "test-target",
}

// Create a MapInputRef[A, B].
var adaptedRef TellOnlyRef[testMessageA] = NewMapInputRef(
targetRef,
func(a testMessageA) testMessageB {
return testMessageB{
DoubledValue: a.Value,
}
},
)

// Verify we can use it as a TellOnlyRef[testMessageA].
ctx := context.Background()
adaptedRef.Tell(ctx, testMessageA{Value: 10})

// The fact that this compiles and runs proves type safety.
require.Len(t, targetRef.received, 1)
}

// TestMapInputRefIdentityTransform tests that MapInputRef works when the
// input and output types are the same (identity transformation).
func TestMapInputRefIdentityTransform(t *testing.T) {
t.Parallel()

targetRef := &mockTellOnlyRef[testMessageA]{
id: "test-target",
}

// Identity transformation: A -> A with modified value.
transformFn := func(a testMessageA) testMessageA {
a.Value = a.Value + 100
return a
}

adaptedRef := NewMapInputRef(targetRef, transformFn)

ctx := context.Background()
inputMsg := testMessageA{
Value: 5,
Text: "test",
}
adaptedRef.Tell(ctx, inputMsg)

// Verify the transformation was applied.
require.Len(t, targetRef.received, 1)
require.Equal(t, 105, targetRef.received[0].Value)
require.Equal(t, "test", targetRef.received[0].Text)
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ require (
pgregory.net/rapid v1.2.0
)

require github.com/lightningnetwork/lnd/actor v0.0.1-alpha

require (
dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
Expand Down Expand Up @@ -213,6 +215,9 @@ replace github.com/gogo/protobuf => github.com/gogo/protobuf v1.3.2
// allows us to specify that as an option.
replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display

// Use the local actor module for development.
replace github.com/lightningnetwork/lnd/actor => ./actor

// If you change this please also update docs/INSTALL.md and GO_VERSION in
// Makefile (then run `make lint` to see where else it needs to be updated as
// well).
Expand Down
23 changes: 23 additions & 0 deletions protofsm/actor_wrapper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package protofsm

import (
"fmt"

"github.com/lightningnetwork/lnd/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)
}
Loading
Loading