Skip to content

Commit 530f40c

Browse files
committed
Add await attempt timeout
1 parent 4f2da02 commit 530f40c

8 files changed

Lines changed: 210 additions & 31 deletions

File tree

.github/workflows/run-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ env:
2323
PR_BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
2424
DOCKER_COMPOSE_FILE: ./develop/github/docker-compose.yml
2525
TEMPORAL_VERSION_CHECK_DISABLED: 1
26+
TEMPORAL_AWAIT_ATTEMPT_TIMEOUT: 15s
2627
MAX_TEST_ATTEMPTS: 3
2728
SHARD_COUNT: 3 # NOTE: must match shard count in optimize-test-sharding.yml
2829

common/testing/await/config.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package await
2+
3+
import (
4+
"os"
5+
"time"
6+
7+
"go.temporal.io/server/common/debug"
8+
)
9+
10+
const attemptTimeoutEnvVar = "TEMPORAL_AWAIT_ATTEMPT_TIMEOUT"
11+
12+
type config struct {
13+
totalTimeout time.Duration
14+
pollInterval time.Duration
15+
attemptTimeout time.Duration
16+
timeoutMsg string
17+
}
18+
19+
func newConfig() config {
20+
return config{
21+
attemptTimeout: envDuration(attemptTimeoutEnvVar, 10*time.Second) * debug.TimeoutMultiplier,
22+
}
23+
}
24+
25+
func legacyConfig(timeout, pollInterval time.Duration, timeoutMsg string) config {
26+
cfg := newConfig()
27+
cfg.totalTimeout = timeout
28+
cfg.pollInterval = pollInterval
29+
cfg.timeoutMsg = timeoutMsg
30+
return cfg
31+
}
32+
33+
func envDuration(name string, fallback time.Duration) time.Duration {
34+
if s := os.Getenv(name); s != "" {
35+
if d, err := time.ParseDuration(s); err == nil && d > 0 {
36+
return d
37+
}
38+
}
39+
return fallback
40+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package await
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
"go.temporal.io/server/common/debug"
9+
)
10+
11+
func TestConfig_OverrideAttemptTimeout(t *testing.T) {
12+
t.Setenv(attemptTimeoutEnvVar, "250ms")
13+
14+
cfg := newConfig()
15+
require.Equal(t, 250*time.Millisecond*debug.TimeoutMultiplier, cfg.attemptTimeout)
16+
}

common/testing/await/report.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,39 @@ type attemptFailure struct {
2020
errors []string
2121
}
2222

23-
// reportTimeout reports the timeout failure plus collected attempt errors.
24-
func reportTimeout(tb testing.TB, failures []attemptFailure, funcName, timeoutMsg string, effectiveTimeout time.Duration, polls int) {
25-
reportAttemptErrors(tb, failures)
23+
type timeoutReport struct {
24+
effectiveTimeout time.Duration
25+
attempts int
26+
attemptTimeouts int
27+
failures []attemptFailure
28+
}
29+
30+
func (r *timeoutReport) nextPoll() {
31+
r.attempts++
32+
}
33+
34+
func (r *timeoutReport) recordErrors(errors []string) {
35+
if len(errors) > 0 {
36+
r.failures = append(r.failures, attemptFailure{attempt: r.attempts, errors: errors})
37+
}
38+
}
39+
40+
func (r *timeoutReport) recordAttemptTimeout() {
41+
r.attemptTimeouts++
42+
}
43+
44+
func (r timeoutReport) reportAttemptErrors(tb testing.TB) {
45+
reportAttemptErrors(tb, r.failures)
46+
}
47+
48+
func (r timeoutReport) reportTimeout(tb testing.TB, funcName, timeoutMsg string) {
49+
r.reportAttemptErrors(tb)
2650
if timeoutMsg != "" {
27-
tb.Fatalf("%s: %s (not satisfied after %v, %d polls)", funcName, timeoutMsg, effectiveTimeout, polls)
51+
tb.Fatalf("%s: %s (not satisfied after %v)\ndetails:\n attempts = %d\n attempt timeouts = %d",
52+
funcName, timeoutMsg, r.effectiveTimeout, r.attempts, r.attemptTimeouts)
2853
} else {
29-
tb.Fatalf("%s: condition not satisfied after %v (%d polls)", funcName, effectiveTimeout, polls)
54+
tb.Fatalf("%s: condition not satisfied after %v\ndetails:\n attempts = %d\n attempt timeouts = %d",
55+
funcName, r.effectiveTimeout, r.attempts, r.attemptTimeouts)
3056
}
3157
}
3258

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package await
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"sync"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestReportTimeoutIncludesAttemptTimeouts(t *testing.T) {
14+
tb := newReportRecordingTB()
15+
16+
timeoutReport{
17+
effectiveTimeout: time.Second,
18+
attempts: 3,
19+
attemptTimeouts: 2,
20+
}.reportTimeout(tb, "Require", "")
21+
22+
require.Equal(t, strings.Join([]string{
23+
"Require: condition not satisfied after 1s",
24+
"details:",
25+
" attempts = 3",
26+
" attempt timeouts = 2",
27+
}, "\n"), tb.fatals())
28+
}
29+
30+
func TestReportTimeoutOmitsAttemptTimeoutsWhenZero(t *testing.T) {
31+
tb := newReportRecordingTB()
32+
33+
timeoutReport{
34+
effectiveTimeout: time.Second,
35+
attempts: 3,
36+
}.reportTimeout(tb, "Require", "")
37+
38+
require.Equal(t, strings.Join([]string{
39+
"Require: condition not satisfied after 1s",
40+
"details:",
41+
" attempts = 3",
42+
" attempt timeouts = 0",
43+
}, "\n"), tb.fatals())
44+
}
45+
46+
type reportRecordingTB struct {
47+
testing.TB
48+
mu sync.Mutex
49+
fatalMessages []string
50+
}
51+
52+
func newReportRecordingTB() *reportRecordingTB {
53+
return &reportRecordingTB{}
54+
}
55+
56+
func (r *reportRecordingTB) Helper() {}
57+
58+
func (r *reportRecordingTB) Fatalf(format string, args ...any) {
59+
r.mu.Lock()
60+
defer r.mu.Unlock()
61+
r.fatalMessages = append(r.fatalMessages, fmt.Sprintf(format, args...))
62+
}
63+
64+
func (r *reportRecordingTB) fatals() string {
65+
r.mu.Lock()
66+
defer r.mu.Unlock()
67+
return strings.Join(r.fatalMessages, "\n")
68+
}

common/testing/await/require_ctx.go

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,21 @@ func hardDeadlockTimeout() time.Duration {
5555
// test failure. Use t.Context() inside the callback to honor the timeout.
5656
func Require(ctx context.Context, tb testing.TB, condition func(*T), timeout, pollInterval time.Duration) {
5757
tb.Helper()
58-
run(ctx, tb, condition, timeout, pollInterval, "", "Require", requireMisuseHint, true)
58+
run(ctx, tb, condition, legacyConfig(timeout, pollInterval, ""), "Require", requireMisuseHint, true)
5959
}
6060

6161
// Requiref is like [Require] but adds a formatted message to the timeout
6262
// failure.
6363
func Requiref(ctx context.Context, tb testing.TB, condition func(*T), timeout, pollInterval time.Duration, msg string, args ...any) {
6464
tb.Helper()
65-
run(ctx, tb, condition, timeout, pollInterval, fmt.Sprintf(msg, args...), "Requiref", requireMisuseHint, true)
65+
run(ctx, tb, condition, legacyConfig(timeout, pollInterval, fmt.Sprintf(msg, args...)), "Requiref", requireMisuseHint, true)
6666
}
6767

6868
func run(
6969
parentCtx context.Context,
7070
tb testing.TB,
7171
condition func(*T),
72-
timeout,
73-
pollInterval time.Duration,
74-
timeoutMsg string,
72+
cfg config,
7573
funcName string,
7674
misuseHint string,
7775
cancellable bool,
@@ -89,7 +87,7 @@ func run(
8987
return
9088
}
9189

92-
deadline := time.Now().Add(timeout)
90+
deadline := time.Now().Add(cfg.totalTimeout)
9391

9492
// Cap at the parent context's deadline if it's earlier than our timeout.
9593
if parentDeadline, hasDeadline := parentCtx.Deadline(); hasDeadline && parentDeadline.Before(deadline) {
@@ -108,22 +106,21 @@ func run(
108106
awaitCtx, awaitCancel := context.WithDeadline(parentCtx, deadline)
109107
defer awaitCancel()
110108

111-
var failures []attemptFailure
112-
polls := 0
109+
report := timeoutReport{effectiveTimeout: effectiveTimeout}
113110

114111
for {
115112
// Parent context was canceled while we were sleeping (not our deadline).
116113
if err := awaitCtx.Err(); err != nil && !deadlineReached(deadline) {
117-
reportAttemptErrors(tb, failures)
114+
report.reportAttemptErrors(tb)
118115
tb.Fatalf("%s: context canceled before condition was satisfied: %v", funcName, err)
119116
return
120117
}
121118

122-
polls++
119+
report.nextPoll()
123120

124-
// Fresh context per attempt, scoped to the run-level ctx. runAttempt
125-
// owns the soft timeout and the corresponding cancel.
126-
attemptCtx, attemptCancel := context.WithCancel(awaitCtx)
121+
// Per-attempt context: bounded by the configured attempt timeout and
122+
// further capped by the overall awaitCtx.
123+
attemptCtx, attemptCancel := context.WithTimeout(awaitCtx, cfg.attemptTimeout)
127124
t := &T{tb: tb, ctx: attemptCtx}
128125

129126
// Run attempt.
@@ -133,18 +130,24 @@ func run(
133130
panic(res.panicVal) // propagate to caller
134131
}
135132
if res.deadlocked {
136-
reportAttemptErrors(tb, failures)
133+
report.reportAttemptErrors(tb)
137134
if cancellable {
138-
tb.Fatalf("%s: condition still running %v past context cancellation — does it honor t.Context()? (%d polls)",
139-
funcName, hardDeadlockTimeout(), polls)
135+
tb.Fatalf("%s: condition still running %v past context cancellation — does it honor t.Context()? (%d attempts)",
136+
funcName, hardDeadlockTimeout(), report.attempts)
140137
} else {
141-
tb.Fatalf("%s: condition still running %v past deadline (%d polls)",
142-
funcName, hardDeadlockTimeout(), polls)
138+
tb.Fatalf("%s: condition still running %v past deadline (%d attempts)",
139+
funcName, hardDeadlockTimeout(), report.attempts)
143140
}
144141
return
145142
}
146-
if len(t.errors) > 0 {
147-
failures = append(failures, attemptFailure{attempt: polls, errors: t.errors})
143+
report.recordErrors(t.errors)
144+
145+
// Attempt-timeout expiry: attemptCtx is done but awaitCtx is not.
146+
// Record nothing special - the attempt's recorded errors (if any)
147+
// already describe what went wrong; otherwise we just retry.
148+
attemptHitOwnTimeout := attemptCtx.Err() == context.DeadlineExceeded && awaitCtx.Err() == nil
149+
if attemptHitOwnTimeout {
150+
report.recordAttemptTimeout()
148151
}
149152

150153
// Check misuse where the real test failed instead of just the attempt.
@@ -155,24 +158,24 @@ func run(
155158

156159
// Parent context was canceled during the attempt (not our deadline).
157160
if err := awaitCtx.Err(); err != nil && !deadlineReached(deadline) {
158-
reportAttemptErrors(tb, failures)
161+
report.reportAttemptErrors(tb)
159162
tb.Fatalf("%s: context canceled before condition was satisfied: %v", funcName, err)
160163
return
161164
}
162165

163166
// Our deadline expired.
164167
if deadlineReached(deadline) {
165-
reportTimeout(tb, failures, funcName, timeoutMsg, effectiveTimeout, polls)
168+
report.reportTimeout(tb, funcName, cfg.timeoutMsg)
166169
return
167170
}
168171

169172
// Success: attempt completed without failures.
170-
if !res.stopped && !t.Failed() {
173+
if !res.stopped && !t.Failed() && !attemptHitOwnTimeout {
171174
return
172175
}
173176

174177
// Wait for pollInterval, or context is canceled or deadline is reached.
175-
sleep(awaitCtx, deadline, pollInterval)
178+
sleep(awaitCtx, deadline, cfg.pollInterval)
176179
}
177180
}
178181

common/testing/await/require_ctx_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,31 @@ func TestRequire_FailureScenarios(t *testing.T) {
367367
})
368368
}
369369

370+
func TestRequire_AttemptTimeoutRetriesUntilAwaitTimeout(t *testing.T) {
371+
t.Setenv("TEMPORAL_AWAIT_ATTEMPT_TIMEOUT", "50ms")
372+
373+
ctx := testcontext.New(t)
374+
var attempts atomic.Int32
375+
var firstAttemptRemaining time.Duration
376+
377+
tb := newRecordingTB()
378+
tb.run(func() {
379+
await.Require(ctx, tb, func(t *await.T) {
380+
if attempts.Add(1) == 1 {
381+
deadline, ok := t.Context().Deadline()
382+
require.True(t, ok)
383+
firstAttemptRemaining = time.Until(deadline)
384+
}
385+
<-t.Context().Done()
386+
}, time.Second, 100*time.Millisecond)
387+
})
388+
389+
require.True(t, tb.Failed())
390+
require.Contains(t, tb.fatals(), "not satisfied after")
391+
require.Less(t, firstAttemptRemaining, 200*time.Millisecond)
392+
require.Greater(t, attempts.Load(), int32(1))
393+
}
394+
370395
func TestRequire_SoftDeadlockLogsAndCancels(t *testing.T) {
371396
// not using T.Parallel() so it can use t.Setenv to override the deadlock timeouts
372397
t.Setenv("TEMPORAL_AWAIT_SOFT_DEADLOCK_TIMEOUT", "50ms")

common/testing/await/require_true.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func RequireTrue(tb testing.TB, condition func() bool, timeout, pollInterval tim
2121
if !condition() {
2222
t.Fail()
2323
}
24-
}, timeout, pollInterval, "", "RequireTrue", requireTrueMisuseHint, false)
24+
}, legacyConfig(timeout, pollInterval, ""), "RequireTrue", requireTrueMisuseHint, false)
2525
}
2626

2727
// RequireTruef is like [RequireTrue] but accepts a format string that is included
@@ -32,5 +32,5 @@ func RequireTruef(tb testing.TB, condition func() bool, timeout, pollInterval ti
3232
if !condition() {
3333
t.Fail()
3434
}
35-
}, timeout, pollInterval, fmt.Sprintf(msg, args...), "RequireTruef", requireTrueMisuseHint, false)
35+
}, legacyConfig(timeout, pollInterval, fmt.Sprintf(msg, args...)), "RequireTruef", requireTrueMisuseHint, false)
3636
}

0 commit comments

Comments
 (0)