Skip to content

Commit 428f060

Browse files
[CLD-2622]: feat(operations): introduce WithSequenceIdempotencyKey (#1019)
Similar to WithIdempotencyKey for ExecuteOperation, we want to introduce the same option for Sequence as well. JIRA: https://smartcontract-it.atlassian.net/browse/CLD-2622
1 parent 9664409 commit 428f060

4 files changed

Lines changed: 113 additions & 5 deletions

File tree

.changeset/seven-boxes-poke.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(operations): introduce WithSequenceIdempotencyKey

operations/execute.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ type ExecuteConfig[IN, DEP any] struct {
2121

2222
type ExecuteOption[IN, DEP any] func(*ExecuteConfig[IN, DEP])
2323

24+
// ExecuteSequenceConfig holds options for ExecuteSequence.
25+
type ExecuteSequenceConfig[IN, DEP any] struct {
26+
// idempotencyKey scopes report reuse beyond sequence definition and input (set by WithSequenceIdempotencyKey).
27+
idempotencyKey string
28+
}
29+
30+
// ExecuteSequenceOption configures ExecuteSequence.
31+
type ExecuteSequenceOption[IN, DEP any] func(*ExecuteSequenceConfig[IN, DEP])
32+
2433
// ExecuteOperationNConfig holds options for ExecuteOperationN.
2534
type ExecuteOperationNConfig[IN, DEP any] struct {
2635
retryConfig RetryConfig[IN, DEP]
@@ -98,13 +107,20 @@ func WithForceExecute[IN, DEP any]() ExecuteOption[IN, DEP] {
98107

99108
// WithIdempotencyKey is an ExecuteOption that adds an extra component to the idempotency hash.
100109
// The hash will then be built from the operation definition, input, and this key.
101-
// Use it when the same operation input can legitimately produce different results, so a later run should not reuse an earlier result.
110+
// Use it when the same input can legitimately produce different results, so a later run should not reuse an earlier result.
102111
func WithIdempotencyKey[IN, DEP any](idempotencyKey string) ExecuteOption[IN, DEP] {
103112
return func(c *ExecuteConfig[IN, DEP]) {
104113
c.idempotencyKey = idempotencyKey
105114
}
106115
}
107116

117+
// WithSequenceIdempotencyKey is an ExecuteSequenceOption with the same semantics as WithIdempotencyKey.
118+
func WithSequenceIdempotencyKey[IN, DEP any](idempotencyKey string) ExecuteSequenceOption[IN, DEP] {
119+
return func(c *ExecuteSequenceConfig[IN, DEP]) {
120+
c.idempotencyKey = idempotencyKey
121+
}
122+
}
123+
108124
// WithOperationNRetry is an ExecuteOperationNOption that enables the default retry for each run in ExecuteOperationN.
109125
func WithOperationNRetry[IN, DEP any]() ExecuteOperationNOption[IN, DEP] {
110126
return func(c *ExecuteOperationNConfig[IN, DEP]) {
@@ -315,18 +331,27 @@ func executeWithRetry[IN, OUT, DEP any](
315331
// Sequences or Operations that were skipped will not be added to the reporter.
316332
// The ExecutionReports do not include Sequences or Operations that were skipped.
317333
//
334+
// Options:
335+
// ExecuteSequence accepts ExecuteSequenceOption values (for example, WithSequenceIdempotencyKey).
336+
//
318337
// Input & Output:
319338
// The input and output must be JSON serializable. If the input is not serializable, it will return an error.
320339
// To be serializable, the input and output must be json.marshalable, or it must implement json.Marshaler and json.Unmarshaler.
321340
// IsSerializable can be used to check if the input or output is serializable.
322341
func ExecuteSequence[IN, OUT, DEP any](
323342
b Bundle, sequence *Sequence[IN, OUT, DEP], deps DEP, input IN,
343+
opts ...ExecuteSequenceOption[IN, DEP],
324344
) (SequenceReport[IN, OUT], error) {
325345
if !IsSerializable(b.Logger, input) {
326346
return SequenceReport[IN, OUT]{}, fmt.Errorf("sequence %s input: %w", sequence.def.ID, ErrNotSerializable)
327347
}
328348

329-
if previousReport, ok := loadPreviousSuccessfulReport[IN, OUT](b, sequence.def, input, ""); ok {
349+
sequenceConfig := &ExecuteSequenceConfig[IN, DEP]{}
350+
for _, opt := range opts {
351+
opt(sequenceConfig)
352+
}
353+
354+
if previousReport, ok := loadPreviousSuccessfulReport[IN, OUT](b, sequence.def, input, sequenceConfig.idempotencyKey); ok {
330355
executionReports, err := b.reporter.GetExecutionReports(previousReport.ID)
331356
if err != nil {
332357
return SequenceReport[IN, OUT]{}, err
@@ -369,6 +394,7 @@ func ExecuteSequence[IN, OUT, DEP any](
369394
err,
370395
childReports...,
371396
)
397+
report.IdempotencyKey = sequenceConfig.idempotencyKey
372398

373399
if err = b.reporter.AddReport(genericReport(report)); err != nil {
374400
return SequenceReport[IN, OUT]{}, err

operations/execute_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,66 @@ func Test_ExecuteOperation_ReportJSON_IdempotencyKey(t *testing.T) {
300300
})
301301
}
302302

303+
func Test_ExecuteSequence_ReportJSON_IdempotencyKey(t *testing.T) {
304+
t.Parallel()
305+
306+
version := semver.MustParse("1.0.0")
307+
op := NewOperation("plus1", version, "plus 1",
308+
func(b Bundle, deps any, input int) (int, error) {
309+
return input + 1, nil
310+
},
311+
)
312+
sequence := NewSequence("seq-plus1", version, "plus 1",
313+
func(b Bundle, deps any, input int) (int, error) {
314+
res, err := ExecuteOperation(b, op, nil, input)
315+
if err != nil {
316+
return 0, err
317+
}
318+
319+
return res.Output, nil
320+
},
321+
)
322+
323+
t.Run("includes idempotencyKey when set", func(t *testing.T) {
324+
t.Parallel()
325+
326+
const idempotencyKey = "chain-42161"
327+
bundle := NewBundle(t.Context, logger.Test(t), NewMemoryReporter())
328+
329+
res, err := ExecuteSequence(bundle, sequence, nil, 1, WithSequenceIdempotencyKey[int, any](idempotencyKey))
330+
require.NoError(t, err)
331+
332+
stored, err := bundle.reporter.GetReport(res.ID)
333+
require.NoError(t, err)
334+
assert.Equal(t, idempotencyKey, stored.IdempotencyKey)
335+
336+
raw, err := json.Marshal(stored)
337+
require.NoError(t, err)
338+
339+
var payload map[string]json.RawMessage
340+
require.NoError(t, json.Unmarshal(raw, &payload))
341+
require.Contains(t, payload, "idempotencyKey")
342+
assert.JSONEq(t, `"`+idempotencyKey+`"`, string(payload["idempotencyKey"]))
343+
})
344+
345+
t.Run("omits idempotencyKey when unset", func(t *testing.T) {
346+
t.Parallel()
347+
348+
bundle := NewBundle(t.Context, logger.Test(t), NewMemoryReporter())
349+
350+
res, err := ExecuteSequence(bundle, sequence, nil, 1)
351+
require.NoError(t, err)
352+
353+
stored, err := bundle.reporter.GetReport(res.ID)
354+
require.NoError(t, err)
355+
assert.Empty(t, stored.IdempotencyKey)
356+
357+
raw, err := json.Marshal(stored)
358+
require.NoError(t, err)
359+
assert.NotContains(t, string(raw), "idempotencyKey")
360+
})
361+
}
362+
303363
func Test_ExecuteOperation_WithPreviousRun_UsesMostRecentSuccessfulReport(t *testing.T) {
304364
t.Parallel()
305365

@@ -523,6 +583,22 @@ func Test_ExecuteSequence_WithPreviousRun(t *testing.T) {
523583
assert.Len(t, res.ExecutionReports, 2) // 1 seq report + 1 op report
524584
assert.Equal(t, 2, handlerCalledTimes)
525585

586+
// same input with a different idempotency key should execute again
587+
res, err = ExecuteSequence(bundle, sequence, nil, 1, WithSequenceIdempotencyKey[int, any]("other-key"))
588+
require.NoError(t, err)
589+
require.Nil(t, res.Err)
590+
assert.Equal(t, 2, res.Output)
591+
assert.Equal(t, 3, handlerCalledTimes)
592+
idempotencyKeyRunID := res.ID
593+
assert.NotEqual(t, firstRunID, idempotencyKeyRunID)
594+
595+
// same input and idempotency key should reuse that sequence report
596+
res, err = ExecuteSequence(bundle, sequence, nil, 1, WithSequenceIdempotencyKey[int, any]("other-key"))
597+
require.NoError(t, err)
598+
require.Nil(t, res.Err)
599+
assert.Equal(t, idempotencyKeyRunID, res.ID)
600+
assert.Equal(t, 3, handlerCalledTimes)
601+
526602
// new run with different sequence but same operation, should perform execution
527603
sequence = NewSequence("seq-plus1-v2", semver.MustParse("2.0.0"), "plus 1", handler)
528604
res, err = ExecuteSequence(bundle, sequence, nil, 1)
@@ -531,7 +607,7 @@ func Test_ExecuteSequence_WithPreviousRun(t *testing.T) {
531607
assert.Equal(t, 2, res.Output)
532608
// only 1 because the op was not executed due to previous execution found
533609
assert.Len(t, res.ExecutionReports, 1)
534-
assert.Equal(t, 3, handlerCalledTimes)
610+
assert.Equal(t, 4, handlerCalledTimes)
535611

536612
// new run with sequence that returns error
537613
res, err = ExecuteSequence(bundle, sequenceWithError, nil, 1)

operations/report.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ type Report[IN, OUT any] struct {
2424
ChildOperationReports []string `json:"childOperationReports"`
2525
// ExecutionSeries is used to track the execution of an operation that was executed multiple times
2626
ExecutionSeries *ExecutionSeries `json:"executionSeries,omitempty"`
27-
// IdempotencyKey is an additional component of the idempotency hash. This is used when the same operation input
28-
// can legitimately produce different results by providing different idempotency keys. Set via WithIdempotencyKey on ExecuteOperation.
27+
// IdempotencyKey is an additional component of the idempotency hash. This is used when the same input
28+
// can legitimately produce different results by providing different idempotency keys.
29+
// Set via WithIdempotencyKey on ExecuteOperation or WithSequenceIdempotencyKey on ExecuteSequence.
2930
IdempotencyKey string `json:"idempotencyKey,omitempty"`
3031
}
3132

0 commit comments

Comments
 (0)