Skip to content

Commit 8371a5a

Browse files
feat(operation): support execution of dynamic operation
To enhance the flexibility of Operations API, we want to support dynamic execution of Operation. Imagine this snippet of code: ``` inputs := []any{"input1", 42} deps := []any{Deps1{}, Deps2{}} defs := []Definition{ {ID: "string-op", Version: semver.MustParse("1.0.0")}, {ID: "int-op", Version: semver.MustParse("1.0.0")}, } // dynamically retrieve and execute operations on different inputs for i, def := range defs { // registry contains a list of operations retrievedOp, _ := registry.Retrieve(def) // dependency and input are dynamic/different per Operation report, _ := ExecuteOperation(b, retrievedOp, deps[i], inputs[i]) } ``` Based on the definition, we can decide on run time what operations to execute and what input and deps to pass in to that operation. This can be quite powerful to allow users to dynamically construct a series of operation under a sequence. JIRA: https://smartcontract-it.atlassian.net/browse/CLD-354
1 parent 8dcced1 commit 8371a5a

File tree

5 files changed

+310
-7
lines changed

5 files changed

+310
-7
lines changed

.changeset/moody-pots-find.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
feat: support dynamic execution of operation

operations/operation.go

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package operations
22

33
import (
44
"context"
5+
"errors"
56
"sync"
67

78
"github.com/Masterminds/semver/v3"
@@ -16,17 +17,36 @@ type Bundle struct {
1617
GetContext func() context.Context
1718
reporter Reporter
1819
// internal use only, for storing the hash of the report to avoid repeat sha256 computation.
19-
reportHashCache *sync.Map
20+
reportHashCache *sync.Map
21+
OperationRegistry OperationRegistry
22+
}
23+
24+
// BundleOption is a functional option for configuring a Bundle
25+
type BundleOption func(*Bundle)
26+
27+
// WithOperationRegistry sets a custom OperationRegistry for the Bundle
28+
func WithOperationRegistry(registry OperationRegistry) BundleOption {
29+
return func(b *Bundle) {
30+
b.OperationRegistry = registry
31+
}
2032
}
2133

2234
// NewBundle creates and returns a new Bundle.
23-
func NewBundle(getContext func() context.Context, logger logger.Logger, reporter Reporter) Bundle {
24-
return Bundle{
25-
Logger: logger,
26-
GetContext: getContext,
27-
reporter: reporter,
28-
reportHashCache: &sync.Map{},
35+
func NewBundle(getContext func() context.Context, logger logger.Logger, reporter Reporter, opts ...BundleOption) Bundle {
36+
b := Bundle{
37+
Logger: logger,
38+
GetContext: getContext,
39+
reporter: reporter,
40+
reportHashCache: &sync.Map{},
41+
OperationRegistry: NewOperationRegistry(),
2942
}
43+
44+
// Apply all provided options
45+
for _, opt := range opts {
46+
opt(&b)
47+
}
48+
49+
return b
3050
}
3151

3252
// OperationHandler is the function signature of an operation handler.
@@ -66,6 +86,11 @@ func (o *Operation[IN, OUT, DEP]) Description() string {
6686
return o.def.Description
6787
}
6888

89+
// Def returns the operation definition.
90+
func (o *Operation[IN, OUT, DEP]) Def() Definition {
91+
return o.def
92+
}
93+
6994
// execute runs the operation by calling the OperationHandler.
7095
func (o *Operation[IN, OUT, DEP]) execute(b Bundle, deps DEP, input IN) (output OUT, err error) {
7196
b.Logger.Infow("Executing operation",
@@ -74,6 +99,38 @@ func (o *Operation[IN, OUT, DEP]) execute(b Bundle, deps DEP, input IN) (output
7499
return o.handler(b, deps, input)
75100
}
76101

102+
// AsUntyped converts the operation to an untyped operation.
103+
// This is useful for storing operations in a slice or passing them around without type constraints.
104+
// Warning: The input and output types will be converted to `any`, so type safety is lost.
105+
func (o *Operation[IN, OUT, DEP]) AsUntyped() *Operation[any, any, any] {
106+
return &Operation[any, any, any]{
107+
def: Definition{
108+
ID: o.def.ID,
109+
Version: o.def.Version,
110+
Description: o.def.Description,
111+
},
112+
handler: func(b Bundle, deps any, input any) (any, error) {
113+
var typedInput IN
114+
if input != nil {
115+
var ok bool
116+
if typedInput, ok = input.(IN); !ok {
117+
return nil, errors.New("input type mismatch")
118+
}
119+
}
120+
121+
var typedDeps DEP
122+
if deps != nil {
123+
var ok bool
124+
if typedDeps, ok = deps.(DEP); !ok {
125+
return nil, errors.New("dependencies type mismatch")
126+
}
127+
}
128+
129+
return o.execute(b, typedDeps, typedInput)
130+
},
131+
}
132+
}
133+
77134
// NewOperation creates a new operation.
78135
// Version can be created using semver.MustParse("1.0.0") or semver.New("1.0.0").
79136
// Note: The handler should only perform maximum 1 side effect.

operations/operation_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func Test_NewOperation(t *testing.T) {
3232
assert.Equal(t, "sum", op.ID())
3333
assert.Equal(t, version.String(), op.Version())
3434
assert.Equal(t, description, op.Description())
35+
assert.Equal(t, op.def, op.Def())
3536
res, err := op.handler(Bundle{}, OpDeps{}, OpInput{1, 2})
3637
require.NoError(t, err)
3738
assert.Equal(t, 3, res)
@@ -82,3 +83,71 @@ func Test_Operation_WithEmptyInput(t *testing.T) {
8283
require.NoError(t, err)
8384
assert.Equal(t, 1, out)
8485
}
86+
87+
func Test_Operation_AsUntyped(t *testing.T) {
88+
t.Parallel()
89+
90+
// Create a typed operation
91+
version := semver.MustParse("1.0.0")
92+
description := "test operation"
93+
handler1 := func(b Bundle, deps OpDeps, input OpInput) (output int, err error) {
94+
return input.A + input.B, nil
95+
}
96+
typedOp := NewOperation("sum", version, description, handler1)
97+
98+
// Convert to untyped operation
99+
untypedOp := typedOp.AsUntyped()
100+
bundle := NewBundle(context.Background, logger.Test(t), nil)
101+
102+
// Test definition fields are preserved
103+
assert.Equal(t, "sum", untypedOp.ID())
104+
assert.Equal(t, version.String(), untypedOp.Version())
105+
assert.Equal(t, description, untypedOp.Description())
106+
107+
// Table-driven test cases
108+
tests := []struct {
109+
name string
110+
deps any
111+
input any
112+
wantResult any
113+
wantErr bool
114+
errContains string
115+
}{
116+
{
117+
name: "valid input and dependencies",
118+
deps: OpDeps{},
119+
input: OpInput{A: 3, B: 4},
120+
wantResult: 7,
121+
wantErr: false,
122+
},
123+
{
124+
name: "invalid input type",
125+
deps: OpDeps{},
126+
input: struct{ C int }{C: 5},
127+
wantErr: true,
128+
errContains: "input type mismatch",
129+
},
130+
{
131+
name: "invalid dependencies type",
132+
deps: "invalid",
133+
input: OpInput{A: 1, B: 2},
134+
wantErr: true,
135+
errContains: "dependencies type mismatch",
136+
},
137+
}
138+
139+
for _, tt := range tests {
140+
t.Run(tt.name, func(t *testing.T) {
141+
t.Parallel()
142+
result, err := untypedOp.handler(bundle, tt.deps, tt.input)
143+
144+
if tt.wantErr {
145+
require.Error(t, err)
146+
assert.Contains(t, err.Error(), tt.errContains)
147+
} else {
148+
require.NoError(t, err)
149+
assert.Equal(t, tt.wantResult, result)
150+
}
151+
})
152+
}
153+
}

operations/registry.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package operations
2+
3+
import "errors"
4+
5+
// OperationRegistry is a store for operations that allows retrieval based on their definitions.
6+
type OperationRegistry struct {
7+
ops []*Operation[any, any, any]
8+
}
9+
10+
// NewOperationRegistry creates a new OperationRegistry with the provided untyped operations.
11+
func NewOperationRegistry(ops ...*Operation[any, any, any]) OperationRegistry {
12+
return OperationRegistry{
13+
ops: ops,
14+
}
15+
}
16+
17+
// Retrieve retrieves an operation from the store based on its definition.
18+
// It returns an error if the operation is not found.
19+
// The definition must match the operation's ID and version.
20+
func (s OperationRegistry) Retrieve(def Definition) (*Operation[any, any, any], error) {
21+
for _, op := range s.ops {
22+
if op.ID() == def.ID && op.Version() == def.Version.String() {
23+
return op, nil
24+
}
25+
}
26+
27+
return nil, errors.New("operation not found in registry")
28+
}

operations/registry_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package operations
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/Masterminds/semver/v3"
9+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// ExampleOperationRegistry demonstrates how to create and use an OperationRegistry
15+
// with operations being executed dynamically with different input/output types.
16+
func ExampleOperationRegistry() {
17+
type Deps1 struct{}
18+
type Deps2 struct{}
19+
20+
// Create operations with different input/output types
21+
stringOp := NewOperation(
22+
"string-op",
23+
semver.MustParse("1.0.0"),
24+
"Echo string operation",
25+
func(e Bundle, deps Deps1, input string) (string, error) {
26+
return input, nil
27+
},
28+
)
29+
30+
intOp := NewOperation(
31+
"int-op",
32+
semver.MustParse("1.0.0"),
33+
"Echo integer operation",
34+
func(e Bundle, deps Deps2, input int) (int, error) {
35+
return input, nil
36+
},
37+
)
38+
// Create registry with untyped operations
39+
registry := NewOperationRegistry(stringOp.AsUntyped(), intOp.AsUntyped())
40+
41+
// Create execution environment
42+
b := NewBundle(context.Background, logger.Nop(), NewMemoryReporter(), WithOperationRegistry(registry))
43+
44+
inputs := []any{"input1", 42}
45+
deps := []any{Deps1{}, Deps2{}}
46+
defs := []Definition{
47+
stringOp.Def(),
48+
intOp.Def(),
49+
}
50+
51+
// dynamically retrieve and execute operations on different inputs
52+
for i, def := range defs {
53+
retrievedOp, err := registry.Retrieve(def)
54+
if err != nil {
55+
fmt.Println("error retrieving operation:", err)
56+
continue
57+
}
58+
59+
report, err := ExecuteOperation(b, retrievedOp, deps[i], inputs[i])
60+
if err != nil {
61+
fmt.Println("error executing operation:", err)
62+
continue
63+
}
64+
65+
fmt.Println("operation output:", report.Output)
66+
}
67+
68+
// Output:
69+
// operation output: input1
70+
// operation output: 42
71+
}
72+
73+
func TestOperationRegistry(t *testing.T) {
74+
t.Parallel()
75+
76+
// Test fixture setup
77+
op1 := NewOperation(
78+
"test-op-1",
79+
semver.MustParse("1.0.0"),
80+
"Operation 1",
81+
func(e Bundle, deps OpDeps, input string) (string, error) { return input, nil },
82+
)
83+
op2 := NewOperation(
84+
"test-op-2",
85+
semver.MustParse("2.0.0"),
86+
"Operation 2",
87+
func(e Bundle, deps OpDeps, input int) (int, error) { return input * 2, nil },
88+
)
89+
90+
tests := []struct {
91+
name string
92+
operations []*Operation[any, any, OpDeps]
93+
lookup Definition
94+
wantErr bool
95+
wantErrMsg string
96+
wantID string
97+
wantVersion string
98+
}{
99+
{
100+
name: "retrieval by exact match - first operation",
101+
lookup: Definition{ID: "test-op-1", Version: semver.MustParse("1.0.0")},
102+
wantErr: false,
103+
wantID: "test-op-1",
104+
wantVersion: "1.0.0",
105+
},
106+
{
107+
name: "retrieval by exact match - second operation",
108+
lookup: Definition{ID: "test-op-2", Version: semver.MustParse("2.0.0")},
109+
wantErr: false,
110+
wantID: "test-op-2",
111+
wantVersion: "2.0.0",
112+
},
113+
{
114+
name: "operation not found - non-existent ID",
115+
lookup: Definition{ID: "non-existent", Version: semver.MustParse("1.0.0")},
116+
wantErr: true,
117+
wantErrMsg: "operation not found in registry",
118+
},
119+
{
120+
name: "operation not found - wrong version",
121+
lookup: Definition{ID: "test-op-1", Version: semver.MustParse("3.0.0")},
122+
wantErr: true,
123+
wantErrMsg: "operation not found in registry",
124+
},
125+
}
126+
127+
for _, tt := range tests {
128+
t.Run(tt.name, func(t *testing.T) {
129+
t.Parallel()
130+
131+
registry := NewOperationRegistry(op1.AsUntyped(), op2.AsUntyped())
132+
retrievedOp, err := registry.Retrieve(tt.lookup)
133+
134+
if tt.wantErr {
135+
require.Error(t, err)
136+
assert.ErrorContains(t, err, tt.wantErrMsg)
137+
} else {
138+
require.NoError(t, err)
139+
assert.Equal(t, tt.wantID, retrievedOp.ID())
140+
assert.Equal(t, tt.wantVersion, retrievedOp.Version())
141+
}
142+
})
143+
}
144+
}

0 commit comments

Comments
 (0)