Skip to content

Commit 499b27d

Browse files
qexkrbeuque74
andauthored
feat: add RETRY_NOW state to enable looping over a step (#190)
RETRY_NOW state can be used inside a step condition, to loop over the same step, and re-execute it multiple time. To fully control the looping, it's possible to configure the max_retry value to -1, in order to generate controlled infinite loop. Signed-off-by: Alexandre Szymocha <[email protected]> Signed-off-by: Romain Beuque <[email protected]> Co-authored-by: Romain Beuque <[email protected]>
1 parent f2cea76 commit 499b27d

File tree

5 files changed

+148
-10
lines changed

5 files changed

+148
-10
lines changed

engine/engine.go

+12-2
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,9 @@ func resolve(dbp zesty.DBProvider, res *resolution.Resolution, t *task.Task, sm
396396
executedSteps[s.Name] = true
397397
}
398398

399-
debugLogger.Debugf("Engine: resolve() %s loop, step %s result: %s", res.PublicID, s.Name, s.State)
399+
debugLogger.Debugf("Engine: resolve() %s loop, step %s (#%d) result: %s", res.PublicID, s.Name, s.TryCount, s.State)
400400

401-
// uptate done step count
401+
// update done step count
402402
// ignore foreach iterations for global done count
403403
if s.IsFinal() && !s.IsChild() {
404404
t.StepsDone++
@@ -821,6 +821,16 @@ func availableSteps(modifiedSteps map[string]bool, res *resolution.Resolution, e
821821
candidateSteps[s] = struct{}{}
822822
}
823823
}
824+
825+
// force RETRY_NOW steps to be evaluated again
826+
for name, ok := range executedSteps {
827+
// second check is necessary when dealing with Foreach children
828+
if ok && res.Steps[name] != nil && res.Steps[name].State == step.StateRetryNow {
829+
candidateSteps[name] = struct{}{}
830+
delete(executedSteps, name)
831+
}
832+
}
833+
824834
// looping on just created steps from an EXPANDED step, to verify if they are eligible
825835
// (in case we had modifiedSteps at the same time)
826836
for _, s := range expandedSteps {

engine/engine_test.go

+38
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,44 @@ func TestMetadata(t *testing.T) {
578578
assert.Equal(t, "NOTFOUND", notfoundState)
579579
}
580580

581+
func TestRetryNow(t *testing.T) {
582+
expectedResult := "0 sep 1 sep 1 sep 2 sep 3 sep 5 sep 8 sep 13 sep 21 sep 34 sep 55 sep 89 sep 144"
583+
res, err := createResolution("retryNowState.yaml", map[string]interface{}{
584+
"N": 12.0,
585+
"separator": " sep ",
586+
}, nil)
587+
assert.Nil(t, err)
588+
assert.NotNil(t, res)
589+
590+
res, err = runResolution(res)
591+
assert.NotNil(t, res)
592+
assert.Nil(t, err)
593+
assert.Equal(t, resolution.StateDone, res.State)
594+
595+
assert.Equal(t, step.StateDone, res.Steps["fibonacci"].State)
596+
assert.Equal(t, step.StateDone, res.Steps["join"].State)
597+
598+
output := res.Steps["join"].Output.(map[string]interface{})
599+
assert.Equal(t, expectedResult, output["str"])
600+
}
601+
602+
func TestRetryNowMaxRetry(t *testing.T) {
603+
expected := "42"
604+
res, err := createResolution("retryNowMaxRetry.yaml", map[string]interface{}{}, nil)
605+
assert.Nil(t, err)
606+
assert.NotNil(t, res)
607+
608+
res, err = runResolution(res)
609+
assert.NotNil(t, res)
610+
assert.Nil(t, err)
611+
assert.Equal(t, resolution.StateBlockedFatal, res.State)
612+
613+
assert.Equal(t, step.StateFatalError, res.Steps["infinite"].State)
614+
assert.Equal(t, res.Steps["infinite"].TryCount, res.Steps["infinite"].MaxRetries+1)
615+
616+
assert.Equal(t, expected, res.Steps["infinite"].Output)
617+
}
618+
581619
func TestForeach(t *testing.T) {
582620
res, err := createResolution("foreach.yaml", map[string]interface{}{
583621
"list": []interface{}{"a", "b", "c"},

engine/step/step.go

+24-8
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const (
4040
StateCrashed = "CRASHED"
4141
StatePrune = "PRUNE"
4242
StateToRetry = "TO_RETRY"
43+
StateRetryNow = "RETRY_NOW"
4344
StateAfterrunError = "AFTERRUN_ERROR"
4445

4546
// steps that carry a foreach list of arguments
@@ -50,12 +51,14 @@ const (
5051
stepRefThis = utask.This
5152

5253
defaultMaxRetries = 10000
54+
55+
maxExecutionDelay = time.Duration(20) * time.Second
5356
)
5457

5558
var (
56-
builtinStates = []string{StateTODO, StateRunning, StateDone, StateClientError, StateServerError, StateFatalError, StateCrashed, StatePrune, StateToRetry, StateAfterrunError, StateAny, StateExpanded}
57-
stepConditionValidStates = []string{StateDone, StatePrune, StateToRetry, StateFatalError, StateClientError}
58-
runnableStates = []string{StateTODO, StateServerError, StateClientError, StateFatalError, StateCrashed, StateToRetry, StateAfterrunError, StateExpanded} // everything but RUNNING, DONE, PRUNE
59+
builtinStates = []string{StateTODO, StateRunning, StateDone, StateClientError, StateServerError, StateFatalError, StateCrashed, StatePrune, StateToRetry, StateRetryNow, StateAfterrunError, StateAny, StateExpanded}
60+
stepConditionValidStates = []string{StateDone, StatePrune, StateToRetry, StateRetryNow, StateFatalError, StateClientError}
61+
runnableStates = []string{StateTODO, StateServerError, StateClientError, StateFatalError, StateCrashed, StateToRetry, StateRetryNow, StateAfterrunError, StateExpanded} // everything but RUNNING, DONE, PRUNE
5962
retriableStates = []string{StateServerError, StateToRetry, StateAfterrunError}
6063
)
6164

@@ -90,10 +93,11 @@ type Step struct {
9093
State string `json:"state,omitempty"`
9194
// hints about ETA latency, async, for retrier to define strategy
9295
// how often VS how many times
93-
RetryPattern string `json:"retry_pattern,omitempty"` // seconds, minutes, hours
94-
TryCount int `json:"try_count,omitempty"`
95-
MaxRetries int `json:"max_retries,omitempty"`
96-
LastRun time.Time `json:"last_run,omitempty"`
96+
RetryPattern string `json:"retry_pattern,omitempty"` // seconds, minutes, hours
97+
TryCount int `json:"try_count,omitempty"`
98+
MaxRetries int `json:"max_retries,omitempty"`
99+
LastRun time.Time `json:"last_run,omitempty"`
100+
ExecutionDelay time.Duration `json:"execution_delay,omitempty"`
97101

98102
// flow control
99103
Dependencies []string `json:"dependencies,omitempty"`
@@ -321,7 +325,10 @@ func Run(st *Step, baseConfig map[string]json.RawMessage, stepValues *values.Val
321325
if st.MaxRetries == 0 {
322326
st.MaxRetries = defaultMaxRetries
323327
}
324-
if st.TryCount > st.MaxRetries {
328+
329+
// we can set "max_retries" to a negative number to have full control
330+
// over the repetition of a step with a check condition using RETRY_NOW
331+
if st.MaxRetries > 0 && st.TryCount > st.MaxRetries {
325332
st.State = StateFatalError
326333
st.Error = fmt.Sprintf("Step reached max retries %d: %s", st.MaxRetries, st.Error)
327334
go noopStep(st, stepChan)
@@ -372,6 +379,8 @@ func Run(st *Step, baseConfig map[string]json.RawMessage, stepValues *values.Val
372379
go func() {
373380
defer wg.Done()
374381

382+
time.Sleep(st.ExecutionDelay)
383+
375384
// Wait the prehook execution is done
376385
preHookWg.Wait()
377386

@@ -533,6 +542,13 @@ func (st *Step) ValidAndNormalize(name string, baseConfigs map[string]json.RawMe
533542
}
534543
}
535544

545+
// valid execution delay
546+
if st.ExecutionDelay < 0 || st.ExecutionDelay > maxExecutionDelay {
547+
return errors.NewNotValid(nil,
548+
fmt.Sprintf("execution_delay: expected %s to be a duration between 0s and %s",
549+
st.ExecutionDelay, maxExecutionDelay))
550+
}
551+
536552
// valid retry pattern, accept empty
537553
switch st.RetryPattern {
538554
case "", RetrySeconds, RetryMinutes, RetryHours:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: loopConditionMaxRetry
2+
description: stop an infinite loop using max_retry
3+
title_format: "[test] infinite loop condition with max_retry"
4+
5+
auto_runnable: true
6+
7+
steps:
8+
infinite:
9+
description: counts to infinity
10+
max_retries: 41
11+
conditions:
12+
- type: check
13+
if:
14+
- value: 0
15+
operator: EQ
16+
expected: 0
17+
then:
18+
this: RETRY_NOW
19+
action:
20+
type: echo
21+
configuration:
22+
output: '{{ add (default 0 .step.this.output) 1 }}'
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: retryNowState
2+
description: generate and format a list of the first N fibonacci numbers
3+
title_format: "[test] RETRY_NOW state"
4+
5+
inputs:
6+
- name: 'N'
7+
description: how many numbers to generate
8+
type: number
9+
regex: '\d+'
10+
- name: separator
11+
description: what to put between the numbers
12+
type: string
13+
default: ', '
14+
15+
steps:
16+
fibonacci:
17+
description: generate the list
18+
max_retries: -1
19+
conditions:
20+
- type: check
21+
if:
22+
- value: '{{ len (fromJson .step.this.output.list) }}'
23+
operator: LE
24+
expected: '{{ .input.N }}'
25+
then:
26+
this: RETRY_NOW
27+
action:
28+
type: echo
29+
configuration:
30+
output:
31+
tail0: '{{ default "0" .step.this.output.tail1 }}'
32+
tail1: '{{ default "1" (add .step.this.output.tail0 .step.this.output.tail1) }}'
33+
list: '{{ append (fromJson (default "[]" .step.this.output.list)) (default "0" .step.this.output.tail1) | toJson }}'
34+
35+
join:
36+
description: join the numbers using the separator
37+
dependencies:
38+
- fibonacci
39+
conditions:
40+
- type: check
41+
if:
42+
- value: '{{ len (fromJson .step.this.output.list) }}'
43+
operator: GT
44+
expected: 1
45+
then:
46+
this: RETRY_NOW
47+
action:
48+
type: echo
49+
configuration:
50+
output:
51+
list: '{{ default .step.fibonacci.output.list .step.this.output.list | fromJson | rest | toJson }}'
52+
str: '{{ default (.step.fibonacci.output.list | fromJson | first) .step.this.output.str }}{{ .input.separator }}{{ default .step.fibonacci.output.list .step.this.output.list | fromJson | rest | first }}'

0 commit comments

Comments
 (0)