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
21 changes: 21 additions & 0 deletions operations/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package operations

import "sync/atomic"

var disableIdempotency atomic.Bool

// SetIdempotencyDisabled configures whether execution idempotency (report reuse) is disabled.
// When true, ExecuteOperation, ExecuteOperationN, and ExecuteSequence always run
// fresh regardless of prior successful reports.
func SetIdempotencyDisabled(disabled bool) {
disableIdempotency.Store(disabled)
}

// IdempotencyDisabled reports whether execution idempotency is disabled.
Comment on lines +5 to +14
func IdempotencyDisabled() bool {
return disableIdempotency.Load()
}

func shouldReusePreviousReport(forceExecute bool) bool {
return !forceExecute && !IdempotencyDisabled()
}
2 changes: 1 addition & 1 deletion operations/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Executor:
- Executes operations with configurable retry policies
- Handles operation failures and recovery strategies
- Supports input hooks for dynamic parameter adjustment
- Operations reuse previous successful reports by default; ExecuteOperation accepts WithForceExecute to bypass that reuse
- Operations reuse previous successful reports by default; use operations.SetIdempotencyDisabled(true) to disable reuse globally, or WithForceExecute on a single ExecuteOperation call

Sequence:
- Orchestrates multiple operations in dependency order
Expand Down
48 changes: 28 additions & 20 deletions operations/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func WithOperationNRetryConfig[IN, DEP any](config RetryConfig[IN, DEP]) Execute

// 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.
// previous successful run found in the Reports, unless IdempotencyDisabled or WithForceExecute is set.
// If previous unsuccessful execution was found, the execution will not be skipped.
//
// Note:
Expand Down Expand Up @@ -150,7 +150,7 @@ func ExecuteOperation[IN, OUT, DEP any](
for _, opt := range opts {
opt(executeConfig)
}
if !executeConfig.forceExecute {
if shouldReusePreviousReport(executeConfig.forceExecute) {
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)
Expand Down Expand Up @@ -186,7 +186,8 @@ func ExecuteOperation[IN, OUT, DEP any](

// ExecuteOperationN executes the given operation multiple n times with the given input and dependencies.
// Execution will return the previous successful execution results and skip execution if there were
// previous successful runs found in the Reports. Options are ExecuteOperationNOption (retry only).
// previous successful runs found in the Reports, unless IdempotencyDisabled is set.
// Options are ExecuteOperationNOption (retry only).
// executionSeriesID is used to identify the multiple executions as a single unit.
// It is important to use a unique executionSeriesID for different sets of multiple executions.
func ExecuteOperationN[IN, OUT, DEP any](
Expand All @@ -204,15 +205,20 @@ func ExecuteOperationN[IN, OUT, DEP any](
opt(nConfig)
}

results, ok := loadSuccessfulExecutionSeriesReports[IN, OUT](b, operation.def, input, seriesID)
resultsLen := uint(len(results))
if ok {
// if there are more reports than n, we return only the first n reports
if resultsLen >= n {
b.Logger.Infow("Operations already executed in an execution series. Returning previous results", "id", operation.def.ID,
"version", operation.def.Version, "description", operation.def.Description, "executionSeriesID", seriesID)
var results []Report[IN, OUT]
resultsLen := uint(0)
if shouldReusePreviousReport(false) {
var ok bool
results, ok = loadSuccessfulExecutionSeriesReports[IN, OUT](b, operation.def, input, seriesID)
Comment thread
graham-chainlink marked this conversation as resolved.
resultsLen = uint(len(results))
if ok {
// if there are more reports than n, we return only the first n reports
if resultsLen >= n {
b.Logger.Infow("Operations already executed in an execution series. Returning previous results", "id", operation.def.ID,
"version", operation.def.Version, "description", operation.def.Description, "executionSeriesID", seriesID)

return results[:n], nil
return results[:n], nil
}
}
}
remainingTimesToRun := n - resultsLen
Expand Down Expand Up @@ -296,7 +302,7 @@ func executeWithRetry[IN, OUT, DEP any](
// the operations that were executed as part of this sequence.
// The latter is useful when we want to return all the executed reports to the changeset output.
// Execution will return the previous successful execution result and skip execution if there was a
// previous successful run found in the Reports.
// previous successful run found in the Reports, unless IdempotencyDisabled is set.
// If previous unsuccessful execution was found, the execution will not be skipped.
//
// Note:
Expand All @@ -314,15 +320,17 @@ func ExecuteSequence[IN, OUT, DEP any](
return SequenceReport[IN, OUT]{}, fmt.Errorf("sequence %s input: %w", sequence.def.ID, ErrNotSerializable)
}

if previousReport, ok := loadPreviousSuccessfulReport[IN, OUT](b, sequence.def, input); ok {
executionReports, err := b.reporter.GetExecutionReports(previousReport.ID)
if err != nil {
return SequenceReport[IN, OUT]{}, err
}
b.Logger.Infow("Sequence already executed. Returning previous result", "id", sequence.def.ID,
"version", sequence.def.Version, "description", sequence.def.Description)
if shouldReusePreviousReport(false) {
if previousReport, ok := loadPreviousSuccessfulReport[IN, OUT](b, sequence.def, input); ok {
executionReports, err := b.reporter.GetExecutionReports(previousReport.ID)
if err != nil {
return SequenceReport[IN, OUT]{}, err
}
b.Logger.Infow("Sequence already executed. Returning previous result", "id", sequence.def.ID,
"version", sequence.def.Version, "description", sequence.def.Description)

return SequenceReport[IN, OUT]{previousReport, executionReports}, nil
return SequenceReport[IN, OUT]{previousReport, executionReports}, nil
}
}

b.Logger.Infow("Executing sequence", "id", sequence.def.ID,
Expand Down
64 changes: 64 additions & 0 deletions operations/execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,70 @@ func Test_ExecuteOperation_WithPreviousRun(t *testing.T) {
assert.Equal(t, 2, handlerWithErrorCalledTimes)
}

func Test_SetIdempotencyDisabled(t *testing.T) { //nolint:paralleltest // mutates global idempotency config
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
},
)

version := semver.MustParse("1.0.0")
sequence := NewSequence("seq-plus1", version, "plus 1",
func(b Bundle, deps any, input int) (int, error) {
res, err := ExecuteOperation(b, op, nil, input)
if err != nil {
return 0, err
}

return res.Output, nil
},
)

bundle := NewBundle(t.Context, logger.Test(t), NewMemoryReporter())

_, err := ExecuteOperation(bundle, op, nil, 1)
require.NoError(t, err)
require.Equal(t, 1, handlerCalledTimes)

_, err = ExecuteOperation(bundle, op, nil, 1)
require.NoError(t, err)
require.Equal(t, 1, handlerCalledTimes, "second run should reuse report by default")

SetIdempotencyDisabled(true)
require.True(t, IdempotencyDisabled())
t.Cleanup(func() { SetIdempotencyDisabled(false) })

Comment on lines +269 to +272
_, err = ExecuteOperation(bundle, op, nil, 1)
require.NoError(t, err)
require.Equal(t, 2, handlerCalledTimes, "execution idempotency disabled should force operation execution")

_, err = ExecuteSequence(bundle, sequence, nil, 1)
require.NoError(t, err)
require.Equal(t, 3, handlerCalledTimes, "execution idempotency disabled should force sequence execution")

Comment on lines +279 to +280
nHandlerCalledTimes := 0
nOp := NewOperation("plus1-n", semver.MustParse("1.0.0"), "test operation n",
func(b Bundle, deps any, input int) (output int, err error) {
nHandlerCalledTimes++
return input + 1, nil
},
)
reporter := NewMemoryReporter()
for i := range 2 {
report := NewReport(nOp.def, 1, 2, nil)
report.ExecutionSeries = &ExecutionSeries{ID: "series-1", Order: uint(i)} // #nosec G115
require.NoError(t, reporter.AddReport(genericReport(report)))
}
nBundle := NewBundle(t.Context, logger.Test(t), reporter)

reports, err := ExecuteOperationN(nBundle, nOp, nil, 1, "series-1", 2)
require.NoError(t, err)
require.Len(t, reports, 2)
require.Equal(t, 2, nHandlerCalledTimes, "execution idempotency disabled should run all N executions")
}

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

Expand Down
Loading