Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/gentle-moles-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"chainlink-deployments-framework": minor
---

feat(operations-api): introduce WithAlwaysExecute

When set, this will force Operation to always execute and ignore previous successful reports/cache.
19 changes: 14 additions & 5 deletions operations/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Executor:
- Executes operations with configurable retry policies
- Handles operation failures and recovery strategies
- Supports input hooks for dynamic parameter adjustment
- Reuses previous successful reports by default, with an option to force fresh execution

Sequence:
- Orchestrates multiple operations in dependency order
Expand All @@ -39,14 +40,22 @@ Reporter:

# Basic Usage

// Define an operation
// Define an operation.
op := operations.NewOperation(
operations.OperationDef{ID: "deploy-contract", Version: "1.0.0"},
"deploy-contract",
semver.MustParse("1.0.0"),
"deploy contract operation",
handler,
)

// Execute the operation
bundle := operations.NewBundle(logger, reporter)
result, err := operations.ExecuteOperation(bundle, op, input, deps)
// Execute the operation. By default, previous successful reports are reused.
bundle := operations.NewBundle(context.Background, logger, reporter)
result, err := operations.ExecuteOperation(bundle, op, deps, input)

// Force execution and ignore previous successful reports.
result, err = operations.ExecuteOperation(bundle, op, deps, input, operations.WithAlwaysExecute[InputType, DepsType]())

// Execute a sequence.
_, err = operations.ExecuteSequence(bundle, sequence, deps, input)
*/
package operations
27 changes: 19 additions & 8 deletions operations/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ var ErrNotSerializable = errors.New("data cannot be safely written to disk witho
// ExecuteConfig is the configuration for the ExecuteOperation function.
type ExecuteConfig[IN, DEP any] struct {
retryConfig RetryConfig[IN, DEP]
// ignorePreviousReports controls whether execution should skip report cache reuse.
ignorePreviousReports bool
}

type ExecuteOption[IN, DEP any] func(*ExecuteConfig[IN, DEP])
Expand Down Expand Up @@ -77,6 +79,13 @@ func WithRetryConfig[IN, DEP any](config RetryConfig[IN, DEP]) ExecuteOption[IN,
}
}

// WithAlwaysExecute is an ExecuteOption that forces execution and ignores prior successful reports.
func WithAlwaysExecute[IN, DEP any]() ExecuteOption[IN, DEP] {
return func(c *ExecuteConfig[IN, DEP]) {
c.ignorePreviousReports = true
}
}

// ExecuteOperation executes an operation with the given input and dependencies.
// Execution will return the previous successful execution result and skip execution if there was a
// previous successful run found in the Reports.
Expand Down Expand Up @@ -105,19 +114,20 @@ func ExecuteOperation[IN, OUT, DEP any](
return Report[IN, OUT]{}, fmt.Errorf("operation %s input: %w", operation.def.ID, ErrNotSerializable)
}

if previousReport, ok := loadPreviousSuccessfulReport[IN, OUT](b, operation.def, input); ok {
b.Logger.Infow("Operation already executed. Returning previous result", "id", operation.def.ID,
"version", operation.def.Version, "description", operation.def.Description)

return previousReport, nil
}

executeConfig := &ExecuteConfig[IN, DEP]{
retryConfig: newDisabledRetryConfig[IN, DEP](),
}
for _, opt := range opts {
opt(executeConfig)
}
if !executeConfig.ignorePreviousReports {
if previousReport, ok := loadPreviousSuccessfulReport[IN, OUT](b, operation.def, input); ok {
b.Logger.Infow("Operation already executed. Returning previous result", "id", operation.def.ID,
"version", operation.def.Version, "description", operation.def.Description)

return previousReport, nil
}
}

var output OUT
var err error
Expand Down Expand Up @@ -355,7 +365,8 @@ func loadPreviousSuccessfulReport[IN, OUT any](
return Report[IN, OUT]{}, false
}

for _, report := range prevReports {
for i := len(prevReports) - 1; i >= 0; i-- {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now that there can be multiple reports of the same ID, we want to return the most recent ones instead of the oldest one

report := prevReports[i]
// Check if operation/sequence was run previously and return the report if successful
reportHash, err := constructUniqueHashFrom(b.reportHashCache, report.Def, report.Input)
if err != nil {
Expand Down
55 changes: 53 additions & 2 deletions operations/execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,28 +178,47 @@ func Test_ExecuteOperation_WithPreviousRun(t *testing.T) {
require.Nil(t, res.Err)
assert.Equal(t, 2, res.Output)
assert.Equal(t, 1, handlerCalledTimes)
firstRunID := res.ID

// rerun should return previous report
res, err = ExecuteOperation(bundle, op, nil, 1)
require.NoError(t, err)
require.Nil(t, res.Err)
assert.Equal(t, 2, res.Output)
assert.Equal(t, 1, handlerCalledTimes)
assert.Equal(t, firstRunID, res.ID)

// rerun with always-execute option should execute again and add a new report
res, err = ExecuteOperation(bundle, op, nil, 1, WithAlwaysExecute[int, any]())
require.NoError(t, err)
require.Nil(t, res.Err)
assert.Equal(t, 2, res.Output)
assert.Equal(t, 2, handlerCalledTimes)
ignoreRunID := res.ID
assert.NotEqual(t, firstRunID, ignoreRunID)

// rerun without ignore should return the latest successful report
res, err = ExecuteOperation(bundle, op, nil, 1)
require.NoError(t, err)
require.Nil(t, res.Err)
assert.Equal(t, 2, res.Output)
assert.Equal(t, 2, handlerCalledTimes)
assert.Equal(t, ignoreRunID, res.ID)

// new run with different input, should perform execution
res, err = ExecuteOperation(bundle, op, nil, 3)
require.NoError(t, err)
require.Nil(t, res.Err)
assert.Equal(t, 4, res.Output)
assert.Equal(t, 2, handlerCalledTimes)
assert.Equal(t, 3, handlerCalledTimes)

// new run with different op, should perform execution
op = NewOperation("plus1-v2", semver.MustParse("2.0.0"), "test operation", handler)
res, err = ExecuteOperation(bundle, op, nil, 1)
require.NoError(t, err)
require.Nil(t, res.Err)
assert.Equal(t, 2, res.Output)
assert.Equal(t, 3, handlerCalledTimes)
assert.Equal(t, 4, handlerCalledTimes)

// new run with op that returns error
res, err = ExecuteOperation(bundle, opWithError, nil, 1)
Expand All @@ -216,6 +235,36 @@ func Test_ExecuteOperation_WithPreviousRun(t *testing.T) {
assert.Equal(t, 2, handlerWithErrorCalledTimes)
}

func Test_ExecuteOperation_WithPreviousRun_UsesMostRecentSuccessfulReport(t *testing.T) {
t.Parallel()

handlerCalledTimes := 0
op := NewOperation("plus1", semver.MustParse("1.0.0"), "test operation",
func(b Bundle, deps any, input int) (output int, err error) {
handlerCalledTimes++
return input + 1, nil
},
)

oldestSuccessful := NewReport(op.def, 1, 2, nil)
mostRecentSuccessful := NewReport(op.def, 1, 5, nil)
newestFailed := NewReport(op.def, 1, 0, errors.New("failed report should not be reused"))

reporter := NewMemoryReporter(WithReports([]Report[any, any]{
genericReport(oldestSuccessful),
genericReport(mostRecentSuccessful),
genericReport(newestFailed),
}))
bundle := NewBundle(t.Context, logger.Test(t), reporter)

res, err := ExecuteOperation(bundle, op, nil, 1)
require.NoError(t, err)
require.Nil(t, res.Err)
assert.Equal(t, mostRecentSuccessful.ID, res.ID)
assert.Equal(t, 5, res.Output)
assert.Equal(t, 0, handlerCalledTimes)
}

func Test_ExecuteOperation_Unserializable_Data(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -390,6 +439,7 @@ func Test_ExecuteSequence_WithPreviousRun(t *testing.T) {
assert.Equal(t, 2, res.Output)
assert.Len(t, res.ExecutionReports, 2) // 1 seq report + 1 op report
assert.Equal(t, 1, handlerCalledTimes)
firstRunID := res.ID

// rerun should return previous report
res, err = ExecuteSequence(bundle, sequence, nil, 1)
Expand All @@ -398,6 +448,7 @@ func Test_ExecuteSequence_WithPreviousRun(t *testing.T) {
assert.Equal(t, 2, res.Output)
assert.Len(t, res.ExecutionReports, 2) // 1 seq report + 1 op report
assert.Equal(t, 1, handlerCalledTimes)
assert.Equal(t, firstRunID, res.ID)

// new run with different input, should perform execution
res, err = ExecuteSequence(bundle, sequence, nil, 3)
Expand Down
Loading