Skip to content

Commit 5570633

Browse files
authored
Running a module respects the timeout on the provided context (#1999)
* Running a module respects the timeout on the provided context * AI feedback * Fix module timeout detection and rename test module build so it's ignored. Remove shadowing of variable * Updated the wrong file before for the test binary name...
1 parent b1fd6cb commit 5570633

3 files changed

Lines changed: 117 additions & 9 deletions

File tree

pkg/workflows/wasm/host/module.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ var (
5050
defaultMaxLogCountNodeMode = 10_000
5151
ResponseBufferTooSmall = "response buffer too small"
5252

53-
defaultMaxUserMetricPayloadBytes = uint32(4096) // 4 KB
54-
defaultMaxUserMetricNameLength = uint32(128)
55-
defaultMaxUserMetricLabelsPerMetric = uint32(10)
56-
defaultMaxUserMetricLabelValueLength = uint32(256)
53+
defaultMaxUserMetricPayloadBytes = uint32(4096) // 4 KB
54+
defaultMaxUserMetricNameLength = uint32(128)
55+
defaultMaxUserMetricLabelsPerMetric = uint32(10)
56+
defaultMaxUserMetricLabelValueLength = uint32(256)
5757
)
5858

5959
type DeterminismConfig struct {
@@ -82,7 +82,7 @@ type ModuleConfig struct {
8282
MaxLogCountDONMode uint32
8383
MaxLogCountNodeMode uint32
8484

85-
EnableUserMetricsLimiter limits.GateLimiter
85+
EnableUserMetricsLimiter limits.GateLimiter
8686
MaxUserMetricPayloadBytes uint32
8787
MaxUserMetricPayloadLimiter limits.BoundLimiter[config.Size] // supersedes MaxUserMetricPayloadBytes if set
8888
MaxUserMetricNameLength uint32
@@ -453,15 +453,15 @@ func linkNoDAG(m *module, store *wasmtime.Store, exec *execution[*sdkpb.Executio
453453
return nil, fmt.Errorf("error wrapping await_secrets func: %w", err)
454454
}
455455

456-
if err := linker.FuncWrap(
456+
if err = linker.FuncWrap(
457457
"env",
458458
"log",
459459
exec.log,
460460
); err != nil {
461461
return nil, fmt.Errorf("error wrapping log func: %w", err)
462462
}
463463

464-
if err := linker.FuncWrap(
464+
if err = linker.FuncWrap(
465465
"env",
466466
"emit_metric",
467467
exec.emitMetric,
@@ -623,7 +623,16 @@ func runWasm[I, O proto.Message](
623623

624624
var o O
625625

626-
ctxWithTimeout, cancel := context.WithTimeout(ctx, *m.cfg.Timeout)
626+
// No reason to run the WASM longer if the outer ctx will cancel.
627+
ctxDeadline, hasDeadline := ctx.Deadline()
628+
var ctxWithTimeout context.Context
629+
var cancel func()
630+
if hasDeadline && ctxDeadline.Before(time.Now().Add(*m.cfg.Timeout)) {
631+
ctxWithTimeout, cancel = context.WithCancel(ctx)
632+
} else {
633+
ctxWithTimeout, cancel = context.WithTimeout(ctx, *m.cfg.Timeout)
634+
}
635+
627636
defer cancel()
628637

629638
store := wasmtime.NewStore(m.engine)
@@ -731,7 +740,7 @@ func runWasm[I, O proto.Message](
731740
// Note - there is no other reliable signal on the error that can be used to infer it is due to epoch deadline
732741
// being reached, so if an error is returned after the deadline it is assumed it is due to that and return
733742
// context.DeadlineExceeded.
734-
if err != nil && executionDuration >= *m.cfg.Timeout-m.cfg.TickInterval { // As start could be called just before epoch update 1 tick interval is deducted to account for this
743+
if err != nil && ((executionDuration >= *m.cfg.Timeout-m.cfg.TickInterval) || ctx.Err() != nil) { // As start could be called just before epoch update 1 tick interval is deducted to account for this
735744
m.cfg.Logger.Errorw("start function returned error after deadline reached, returning deadline exceeded error", "errFromStartFunction", err)
736745
return o, context.DeadlineExceeded
737746
}

pkg/workflows/wasm/host/wasm_nodag_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,104 @@ func Test_Sleep_Timeout(t *testing.T) {
6060
require.Less(t, duration.Seconds(), 3.0, "execution should be interrupted quickly")
6161
}
6262

63+
func Test_Execute_CtxTimeout(t *testing.T) {
64+
t.Parallel()
65+
// different build location so it doesn't clash with the other test when building
66+
binary := createTestBinary(sleepBinaryCmd, sleepBinaryLocation2, true, t)
67+
t.Run("timeout from module is first", func(t *testing.T) {
68+
t.Parallel()
69+
mc := defaultNoDAGModCfg(t)
70+
timeout := time.Second
71+
mc.Timeout = &timeout
72+
m, err := NewModule(t.Context(), mc, binary)
73+
require.NoError(t, err)
74+
75+
m.v2ImportName = "test"
76+
m.Start()
77+
defer m.Close()
78+
79+
mockExecutionHelper := NewMockExecutionHelper(t)
80+
mockExecutionHelper.EXPECT().GetWorkflowExecutionID().Return("id")
81+
mockExecutionHelper.EXPECT().GetNodeTime().RunAndReturn(func() time.Time {
82+
return time.Now()
83+
})
84+
85+
req := &sdk.ExecuteRequest{
86+
Request: &sdk.ExecuteRequest_Trigger{},
87+
}
88+
89+
start := time.Now()
90+
timeoutCtx, cancel := context.WithTimeout(t.Context(), time.Minute)
91+
defer cancel()
92+
_, err = m.Execute(timeoutCtx, req, mockExecutionHelper)
93+
duration := time.Since(start)
94+
require.ErrorIs(t, err, context.DeadlineExceeded)
95+
require.Less(t, duration.Seconds(), 3.0, "execution should be interrupted quickly")
96+
})
97+
98+
t.Run("no context timeout", func(t *testing.T) {
99+
t.Parallel()
100+
101+
mc := defaultNoDAGModCfg(t)
102+
timeout := time.Second
103+
mc.Timeout = &timeout
104+
m, err := NewModule(t.Context(), mc, binary)
105+
require.NoError(t, err)
106+
107+
m.v2ImportName = "test"
108+
m.Start()
109+
defer m.Close()
110+
111+
mockExecutionHelper := NewMockExecutionHelper(t)
112+
mockExecutionHelper.EXPECT().GetWorkflowExecutionID().Return("id")
113+
mockExecutionHelper.EXPECT().GetNodeTime().RunAndReturn(func() time.Time {
114+
return time.Now()
115+
})
116+
117+
req := &sdk.ExecuteRequest{
118+
Request: &sdk.ExecuteRequest_Trigger{},
119+
}
120+
121+
start := time.Now()
122+
_, err = m.Execute(t.Context(), req, mockExecutionHelper)
123+
duration := time.Since(start)
124+
require.ErrorIs(t, err, context.DeadlineExceeded)
125+
require.Less(t, duration.Seconds(), 3.0, "execution should be interrupted quickly")
126+
})
127+
128+
t.Run("timeout from context is first", func(t *testing.T) {
129+
t.Parallel()
130+
131+
mc := defaultNoDAGModCfg(t)
132+
timeout := time.Minute
133+
mc.Timeout = &timeout
134+
m, err := NewModule(t.Context(), mc, binary)
135+
require.NoError(t, err)
136+
137+
m.v2ImportName = "test"
138+
m.Start()
139+
defer m.Close()
140+
141+
mockExecutionHelper := NewMockExecutionHelper(t)
142+
mockExecutionHelper.EXPECT().GetWorkflowExecutionID().Return("id")
143+
mockExecutionHelper.EXPECT().GetNodeTime().RunAndReturn(func() time.Time {
144+
return time.Now()
145+
})
146+
147+
req := &sdk.ExecuteRequest{
148+
Request: &sdk.ExecuteRequest_Trigger{},
149+
}
150+
151+
start := time.Now()
152+
timeoutCtx, cancel := context.WithTimeout(t.Context(), time.Second)
153+
defer cancel()
154+
_, err = m.Execute(timeoutCtx, req, mockExecutionHelper)
155+
duration := time.Since(start)
156+
require.ErrorIs(t, err, context.DeadlineExceeded)
157+
require.Less(t, duration.Seconds(), 3.0, "execution should be interrupted quickly")
158+
})
159+
}
160+
63161
func Test_NoDag_Run(t *testing.T) {
64162
t.Parallel()
65163

pkg/workflows/wasm/host/wasm_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const (
3535
oomBinaryLocation = "test/oom/cmd/testmodule.wasm"
3636
oomBinaryCmd = "test/oom/cmd"
3737
sleepBinaryLocation = "test/sleep/cmd/testmodule.wasm"
38+
sleepBinaryLocation2 = "test/sleep/cmd/testmodule_2.wasm" // used to avoid a build race between tests
3839
sleepBinaryCmd = "test/sleep/cmd"
3940
filesBinaryLocation = "test/files/cmd/testmodule.wasm"
4041
filesBinaryCmd = "test/files/cmd"

0 commit comments

Comments
 (0)