From dcd2289aa3042eb667d931637dfa80943f3f332c Mon Sep 17 00:00:00 2001 From: Jean Barkhuysen Date: Wed, 10 Jun 2026 10:44:49 -0600 Subject: [PATCH 1/3] feat(wait): implement AnyMultiStrategy: ForAny equivalent to ForAll. Fixes #3717. --- docs/features/wait/all.md | 12 ++ docs/features/wait/any.md | 17 ++ docs/features/wait/introduction.md | 3 +- docs/features/wait/multi.md | 25 --- mkdocs.yml | 3 +- wait/any.go | 128 +++++++++++++++ wait/any_test.go | 256 +++++++++++++++++++++++++++++ wait/wait_examples_test.go | 50 ++++++ wait/walk.go | 38 +++-- 9 files changed, 491 insertions(+), 41 deletions(-) create mode 100644 docs/features/wait/all.md create mode 100644 docs/features/wait/any.md delete mode 100644 docs/features/wait/multi.md create mode 100644 wait/any.go create mode 100644 wait/any_test.go create mode 100644 wait/wait_examples_test.go diff --git a/docs/features/wait/all.md b/docs/features/wait/all.md new file mode 100644 index 0000000000..a0e452b2f5 --- /dev/null +++ b/docs/features/wait/all.md @@ -0,0 +1,12 @@ +# All Wait strategy + +The All multi wait strategy holds a list of wait strategies. The execution of each strategy is first added, first executed. + +Available Options: + +- `WithDeadline` - the deadline for when all strategies must complete by, default is none. +- `WithStartupTimeoutDefault` - the startup timeout default to be used for each Strategy if not defined in seconds, default is 60 seconds. + + +[ForAll Example](../../../wait/wait_examples_test.go) inside_block:ExampleForAll + diff --git a/docs/features/wait/any.md b/docs/features/wait/any.md new file mode 100644 index 0000000000..63f5be23cb --- /dev/null +++ b/docs/features/wait/any.md @@ -0,0 +1,17 @@ +# Any Wait strategy + +The Any multi wait strategy holds a list of wait strategies. The execution of +each strategy is asynchronous: all run in their own goroutine. If any one +succeeds, the Wait will finish with success (no error) and the remaining +running wait strategies will be cancelled. If any one fails, the Wait will +finish with an error and the remaining running wait strategies will be +cancelled. + +Available Options: + +- `WithDeadline` - the deadline for when all strategies must complete by, default is none. +- `WithStartupTimeoutDefault` - the startup timeout default to be used for each Strategy if not defined in seconds, default is 60 seconds. + + +[ForAny Example](../../../wait/wait_examples_test.go) inside_block:ExampleForAny + diff --git a/docs/features/wait/introduction.md b/docs/features/wait/introduction.md index fea4f704be..ffe8fa0bae 100644 --- a/docs/features/wait/introduction.md +++ b/docs/features/wait/introduction.md @@ -13,9 +13,10 @@ Below you can find a list of the available wait strategies that you can use: - [HostPort](./host_port.md) - [HTTP](./http.md) - [Log](./log.md) -- [Multi](./multi.md) - [SQL](./sql.md) - [TLS](./tls.md) +- [ForAll](./all.md) +- [ForAny](./any.md) ## Startup timeout and Poll interval diff --git a/docs/features/wait/multi.md b/docs/features/wait/multi.md deleted file mode 100644 index d5f809d6c2..0000000000 --- a/docs/features/wait/multi.md +++ /dev/null @@ -1,25 +0,0 @@ -# Multi Wait strategy - -The Multi wait strategy holds a list of wait strategies. The execution of each strategy is first added, first executed. - -Available Options: - -- `WithDeadline` - the deadline for when all strategies must complete by, default is none. -- `WithStartupTimeoutDefault` - the startup timeout default to be used for each Strategy if not defined in seconds, default is 60 seconds. - -```golang -req := ContainerRequest{ - Image: "mysql:8.0.36", - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ - "MYSQL_ROOT_PASSWORD": "password", - "MYSQL_DATABASE": "database", - }, - wait.ForAll( - wait.ForLog("port: 3306 MySQL Community Server - GPL"), // Timeout: 120s (from ForAll.WithStartupTimeoutDefault) - wait.ForExposedPort().WithStartupTimeout(180*time.Second), // Timeout: 180s - wait.ForListeningPort("3306/tcp").WithStartupTimeout(10*time.Second), // Timeout: 10s - ).WithStartupTimeoutDefault(120*time.Second). // Applies default StartupTimeout when not explicitly defined - WithDeadline(360*time.Second) // Applies deadline for all Wait Strategies -} -``` diff --git a/mkdocs.yml b/mkdocs.yml index f2435810ab..ebd76b4647 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,10 +55,11 @@ nav: - HostPort: features/wait/host_port.md - HTTP: features/wait/http.md - Log: features/wait/log.md - - Multi: features/wait/multi.md - SQL: features/wait/sql.md - TLS: features/wait/tls.md - Walk: features/wait/walk.md + - All: features/wait/all.md + - Any: features/wait/any.md - features/files_and_mounts.md - features/follow_logs.md - features/garbage_collector.md diff --git a/wait/any.go b/wait/any.go new file mode 100644 index 0000000000..15d9c15002 --- /dev/null +++ b/wait/any.go @@ -0,0 +1,128 @@ +package wait + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "time" +) + +// Implement interface +var ( + _ Strategy = (*AnyMultiStrategy)(nil) + _ StrategyTimeout = (*AnyMultiStrategy)(nil) +) + +type AnyMultiStrategy struct { + // all Strategies should have a startupTimeout to avoid waiting infinitely + timeout *time.Duration + deadline *time.Duration + + // additional properties + Strategies []Strategy +} + +// WithStartupTimeoutDefault sets the default timeout for all inner wait strategies. +func (ms *AnyMultiStrategy) WithStartupTimeoutDefault(timeout time.Duration) *AnyMultiStrategy { + ms.timeout = &timeout + return ms +} + +// WithDeadline sets a time.Duration which limits all wait strategies. +func (ms *AnyMultiStrategy) WithDeadline(deadline time.Duration) *AnyMultiStrategy { + ms.deadline = &deadline + return ms +} + +// ForAny returns a WaitStrategy that waits for any of the supplied conditions +// to become true (after which it cancels the remaining ones). +// +// Failures are not permitted: any strategy which fails will have its error +// immediately returned. +func ForAny(strategies ...Strategy) *AnyMultiStrategy { + return &AnyMultiStrategy{ + Strategies: strategies, + } +} + +func (ms *AnyMultiStrategy) Timeout() *time.Duration { + return ms.timeout +} + +// String returns a human-readable description of the wait strategy. +func (ms *AnyMultiStrategy) String() string { + if len(ms.Strategies) == 0 { + return "any of: (none)" + } + + var strategies []string + for _, strategy := range ms.Strategies { + if strategy == nil || reflect.ValueOf(strategy).IsNil() { + continue + } + if s, ok := strategy.(fmt.Stringer); ok { + strategies = append(strategies, s.String()) + } else { + strategies = append(strategies, fmt.Sprintf("%T", strategy)) + } + } + + // Always include "any of:" prefix to make it clear this is a AnyMultiStrategy + // even when there's only one strategy after filtering out nils. + return "any of: [" + strings.Join(strategies, ", ") + "]" +} + +func (ms *AnyMultiStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error { + if len(ms.Strategies) == 0 { + return errors.New("no wait strategy supplied") + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() // All remaining strategies will stop when this fires. + + if ms.deadline != nil { + ctx, cancel = context.WithTimeout(ctx, *ms.deadline) + defer cancel() + } + + resCh := make(chan error, len(ms.Strategies)) + var valid int + + for _, strategy := range ms.Strategies { + if strategy == nil || reflect.ValueOf(strategy).IsNil() { + // A module could be appending strategies after part of the container initialization, + // and use wait.ForAny on a not initialized strategy. + // In this case, we just skip the nil strategy. + continue + } + valid++ + + strategyCtx := ctx + // Set default Timeout when strategy implements StrategyTimeout + if st, ok := strategy.(StrategyTimeout); ok { + if ms.Timeout() != nil && st.Timeout() == nil { + strategyCtx, cancel = context.WithTimeout(ctx, *ms.Timeout()) + defer cancel() + } + } + go func() { resCh <- strategy.WaitUntilReady(strategyCtx, target) }() + } + + if valid == 0 { + return nil + } + + for { + select { + case err := <-resCh: + if err != nil { + return err + } + return nil + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for strategies: %w", ctx.Err()) + } + } +} diff --git a/wait/any_test.go b/wait/any_test.go new file mode 100644 index 0000000000..8ef232dbb4 --- /dev/null +++ b/wait/any_test.go @@ -0,0 +1,256 @@ +package wait + +import ( + "bytes" + "context" + "errors" + "io" + "sync/atomic" + "testing" + "testing/synctest" + "time" +) + +func TestAnyMultiStrategy_WaitsForAny(t *testing.T) { + // synctest makes the time.Sleep below "instant". + synctest.Test(t, func(t *testing.T) { + var s1Done, s2Done atomic.Bool + s1Release := make(chan struct{}) + s1 := ForNop(func(ctx context.Context, _ StrategyTarget) error { + defer func() { s1Done.Store(true) }() + + // Releases only when we tell it to. + select { + case <-ctx.Done(): + return ctx.Err() + case <-s1Release: + return nil + } + }) + s2 := ForNop(func(ctx context.Context, _ StrategyTarget) error { + defer func() { s2Done.Store(true) }() + <-ctx.Done() + return ctx.Err() + }) + + res := make(chan error) + go func() { + res <- ForAny(s1, s2).WaitUntilReady(t.Context(), NopStrategyTarget{}) + }() + + time.Sleep(time.Second) + if s1Done.Load() || s2Done.Load() { + t.Fatalf("no waiting should be done: s1=%v, s2=%v", s1Done.Load(), s2Done.Load()) + } + + close(s1Release) + + select { + case <-t.Context().Done(): + t.Fatal(t.Context().Err()) + case err := <-res: + if err != nil { + t.Fatalf("expected no error, but got: %v", err) + } + } + + if !s1Done.Load() { + t.Fatal("s1 should be done, but it is not") + } + }) +} + +func TestAnyMultiStrategy_FailuresNotPermitted(t *testing.T) { + // When one strategy fails, we return its failure and stop. + s1 := ForNop(func(ctx context.Context, _ StrategyTarget) error { + <-ctx.Done() + return ctx.Err() + }) + s2 := ForNop(func(context.Context, StrategyTarget) error { + return errors.New("s2 errored!") + }) + + res := make(chan error) + go func() { + res <- ForAny(s1, s2).WaitUntilReady(t.Context(), NopStrategyTarget{}) + }() + + select { + case <-t.Context().Done(): + t.Fatal(t.Context().Err()) + case err := <-res: + if err == nil { + t.Fatalf("expected error, but got none: %v", err) + } + } +} + +func TestAnyMultiStrategy_WaitUntilReady(t *testing.T) { + t.Parallel() + type args struct { + ctx context.Context + target StrategyTarget + } + tests := []struct { + name string + strategy Strategy + args args + wantErr bool + }{ + { + name: "returns error when no WaitStrategies are passed", + strategy: ForAny(), + args: args{ + ctx: context.Background(), + target: NopStrategyTarget{}, + }, + wantErr: true, + }, + { + name: "returns WaitStrategy error", + strategy: ForAny( + ForNop( + func(_ context.Context, _ StrategyTarget) error { + return errors.New("intentional failure") + }, + ), + ), + args: args{ + ctx: context.Background(), + target: NopStrategyTarget{}, + }, + wantErr: true, + }, + { + name: "WithDeadline sets context Deadline for WaitStrategy", + strategy: ForAny( + ForNop( + func(ctx context.Context, _ StrategyTarget) error { + if _, set := ctx.Deadline(); !set { + return errors.New("expected context.Deadline to be set") + } + return nil + }, + ), + ForLog("docker"), + ).WithDeadline(1 * time.Second), + args: args{ + ctx: context.Background(), + target: NopStrategyTarget{ + ReaderCloser: io.NopCloser(bytes.NewReader([]byte("docker"))), + }, + }, + wantErr: false, + }, + { + name: "WithStartupTimeoutDefault skips setting context.Deadline when WaitStrategy.Timeout is defined", + strategy: ForAny( + ForNop( + func(ctx context.Context, _ StrategyTarget) error { + if _, set := ctx.Deadline(); set { + return errors.New("expected context.Deadline not to be set") + } + return nil + }, + ).WithStartupTimeout(2*time.Second), + ForLog("docker"), + ).WithStartupTimeoutDefault(1 * time.Second), + args: args{ + ctx: context.Background(), + target: NopStrategyTarget{ + ReaderCloser: io.NopCloser(bytes.NewReader([]byte("docker"))), + }, + }, + wantErr: false, + }, + { + name: "WithStartupTimeoutDefault sets context.Deadline for nil WaitStrategy.Timeout", + strategy: ForAny( + ForNop( + func(ctx context.Context, _ StrategyTarget) error { + if _, set := ctx.Deadline(); !set { + return errors.New("expected context.Deadline to be set") + } + return nil + }, + ), + ForLog("docker"), + ).WithStartupTimeoutDefault(1 * time.Second), + args: args{ + ctx: context.Background(), + target: NopStrategyTarget{ + ReaderCloser: io.NopCloser(bytes.NewReader([]byte("docker"))), + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.strategy.WaitUntilReady(tt.args.ctx, tt.args.target) + if tt.wantErr { + if err == nil { + t.Fatal("expected err but there was none") + } + } else { + if err != nil { + t.Fatal("expected no err, but there was one") + } + } + }) + } +} + +func TestAnyMultiStrategy_handleNils(t *testing.T) { + t.Run("nil-strategy", func(t *testing.T) { + strategy := ForAny(nil) + if err := strategy.WaitUntilReady(context.Background(), NopStrategyTarget{}); err != nil { + t.Fatal(err) + } + }) + + t.Run("nil-strategy-in-the-middle", func(t *testing.T) { + strategy := ForAny(nil, ForLog("docker")) + if err := strategy.WaitUntilReady(context.Background(), NopStrategyTarget{ + ReaderCloser: io.NopCloser(bytes.NewReader([]byte("docker"))), + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("nil-strategy-last", func(t *testing.T) { + strategy := ForAny(ForLog("docker"), nil) + if err := strategy.WaitUntilReady(context.Background(), NopStrategyTarget{ + ReaderCloser: io.NopCloser(bytes.NewReader([]byte("docker"))), + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("nil-type-implements-strategy", func(t *testing.T) { + var nilStrategy Strategy + + strategy := ForAny(ForLog("docker"), nilStrategy) + if err := strategy.WaitUntilReady(context.Background(), NopStrategyTarget{ + ReaderCloser: io.NopCloser(bytes.NewReader([]byte("docker"))), + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("nil-concrete-value-implements-strategy", func(t *testing.T) { + // Create a nil pointer to a type that implements Strategy + var nilPointerStrategy *nilWaitStrategy + // When we assign it to the interface, the type information is preserved + // but the concrete value is nil + var strategyInterface Strategy = nilPointerStrategy + + strategy := ForAny(ForLog("docker"), strategyInterface) + if err := strategy.WaitUntilReady(context.Background(), NopStrategyTarget{ + ReaderCloser: io.NopCloser(bytes.NewReader([]byte("docker"))), + }); err != nil { + t.Fatal(err) + } + }) +} diff --git a/wait/wait_examples_test.go b/wait/wait_examples_test.go new file mode 100644 index 0000000000..df0bc10e8b --- /dev/null +++ b/wait/wait_examples_test.go @@ -0,0 +1,50 @@ +package wait_test + +import ( + "context" + "time" + + "github.com/testcontainers/testcontainers-go/wait" +) + +func ExampleForAny() { + // The following are run in parallel. Any that fail will fail the entire + // ForAny. Any that succeed will succeed the ForAny. Either case cancels the + // remaining waiting strategies. + strategy := wait.ForAny( + wait.ForLog("port: 3306 MySQL Community Server - GPL"), // Timeout: 120s + wait.ForExposedPort().WithStartupTimeout(180*time.Second), // Timeout: 180s + wait.ForListeningPort("3306/tcp").WithStartupTimeout(10*time.Second), // Timeout: 10s + ) + + // You can apply a default StartupTimeout for any inner strategy that didn't define it. + strategy = strategy.WithStartupTimeoutDefault(120 * time.Second) + + // You can apply an overall deadline for all the strategies to complete within. + strategy = strategy.WithDeadline(360 * time.Second) + + // Call WaitUntilReady, or pass the strategy to a function that uses it, to + // start waiting. + _ = strategy.WaitUntilReady(context.Background(), &wait.NopStrategyTarget{}) +} + +func ExampleForAll() { + // The following are run in series. Any that fail will fail the entire + // ForAll, and the remaining waiting strategies will be cancelled. All must + // succeed for the ForAll to succeed. + strategy := wait.ForAll( + wait.ForLog("port: 3306 MySQL Community Server - GPL"), // Timeout: 120s + wait.ForExposedPort().WithStartupTimeout(180*time.Second), // Timeout: 180s + wait.ForListeningPort("3306/tcp").WithStartupTimeout(10*time.Second), // Timeout: 10s + ) + + // You can apply a default StartupTimeout for any inner strategy that didn't define it. + strategy = strategy.WithStartupTimeoutDefault(120 * time.Second) + + // You can apply an overall deadline for all the strategies to complete within. + strategy = strategy.WithDeadline(360 * time.Second) + + // Call WaitUntilReady, or pass the strategy to a function that uses it, to + // start waiting. + _ = strategy.WaitUntilReady(context.Background(), &wait.NopStrategyTarget{}) +} diff --git a/wait/walk.go b/wait/walk.go index 98f5755e14..04cb1badc3 100644 --- a/wait/walk.go +++ b/wait/walk.go @@ -59,23 +59,33 @@ func walk(root *Strategy, visit VisitFunc) error { return err } - if s, ok := (*root).(*MultiStrategy); ok { - var i int - for range s.Strategies { - if err := walk(&s.Strategies[i], visit); err != nil { - if errors.Is(err, ErrVisitRemove) { - s.Strategies = slices.Delete(s.Strategies, i, i+1) - if errors.Is(err, VisitStop) { - return VisitStop - } - continue - } + switch s := (*root).(type) { + case *MultiStrategy: + if err := walkAndMutate(&s.Strategies, visit); err != nil { + return err + } + case *AnyMultiStrategy: + if err := walkAndMutate(&s.Strategies, visit); err != nil { + return err + } + } - return err + return nil +} + +func walkAndMutate(strategies *[]Strategy, visit VisitFunc) error { + for i := 0; i < len(*strategies); { + if err := walk(&(*strategies)[i], visit); err != nil { + if errors.Is(err, ErrVisitRemove) { + *strategies = slices.Delete(*strategies, i, i+1) + if errors.Is(err, VisitStop) { + return VisitStop + } + continue } - i++ + return err } + i++ } - return nil } From 36881acfd7b09632e0db53a57588d027debdc3f4 Mon Sep 17 00:00:00 2001 From: mdelapenya Date: Mon, 15 Jun 2026 10:09:04 +0200 Subject: [PATCH 2/3] fix: lint Signed-off-by: mdelapenya --- wait/any_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wait/any_test.go b/wait/any_test.go index 8ef232dbb4..25313a97e8 100644 --- a/wait/any_test.go +++ b/wait/any_test.go @@ -7,8 +7,9 @@ import ( "io" "sync/atomic" "testing" - "testing/synctest" "time" + + "testing/synctest" ) func TestAnyMultiStrategy_WaitsForAny(t *testing.T) { From 8346e14d23eafbeb0b03defad434819b2330b3c7 Mon Sep 17 00:00:00 2001 From: mdelapenya Date: Mon, 15 Jun 2026 10:23:47 +0200 Subject: [PATCH 3/3] fix: formatting Signed-off-by: mdelapenya --- commons-test.mk | 2 +- wait/any_test.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/commons-test.mk b/commons-test.mk index 50f8a2e996..a3381c5d69 100644 --- a/commons-test.mk +++ b/commons-test.mk @@ -15,7 +15,7 @@ $(GOBIN)/mockery: $(call go_install,github.com/vektra/mockery/v2@v2.53.4) $(GOBIN)/gci: - $(call go_install,github.com/daixiang0/gci@v0.13.5) + $(call go_install,github.com/daixiang0/gci@v0.14.0) .PHONY: install install: $(GOBIN)/golangci-lint $(GOBIN)/gotestsum $(GOBIN)/mockery diff --git a/wait/any_test.go b/wait/any_test.go index 25313a97e8..8ef232dbb4 100644 --- a/wait/any_test.go +++ b/wait/any_test.go @@ -7,9 +7,8 @@ import ( "io" "sync/atomic" "testing" - "time" - "testing/synctest" + "time" ) func TestAnyMultiStrategy_WaitsForAny(t *testing.T) {