test(storage): resolve timeout race condition in stalling client mock tests - #5060
kislaykishore wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates the stalling logic in stallingStorageControlClient by adding checks to ensure stall durations are greater than zero and verifying context errors after the select blocks. The reviewer recommended refactoring this duplicated logic into a single helper function to improve maintainability and prevent potential timer resource leaks by using time.NewTimer instead of time.After.
| func (s *stallingStorageControlClient) GetStorageLayout(ctx context.Context, req *controlpb.GetStorageLayoutRequest, opts ...gax.CallOption) (*controlpb.StorageLayout, error) { | ||
| if s.stallDurationForGetStorageLayout != nil { | ||
| if s.stallDurationForGetStorageLayout != nil && *s.stallDurationForGetStorageLayout > 0 { | ||
| select { | ||
| case <-time.After(*s.stallDurationForGetStorageLayout): | ||
| case <-ctx.Done(): | ||
| return nil, ctx.Err() | ||
| } | ||
| if err := ctx.Err(); err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
| return s.wrapped.GetStorageLayout(ctx, req, opts...) | ||
| } | ||
|
|
||
| func (s *stallingStorageControlClient) DeleteFolder(ctx context.Context, req *controlpb.DeleteFolderRequest, opts ...gax.CallOption) error { | ||
| if s.stallDurationForFolderAPIs != nil { | ||
| if s.stallDurationForFolderAPIs != nil && *s.stallDurationForFolderAPIs > 0 { | ||
| select { | ||
| case <-time.After(*s.stallDurationForFolderAPIs): | ||
| case <-ctx.Done(): | ||
| return ctx.Err() | ||
| } | ||
| if err := ctx.Err(); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return s.wrapped.DeleteFolder(ctx, req, opts...) | ||
| } | ||
|
|
||
| func (s *stallingStorageControlClient) GetFolder(ctx context.Context, req *controlpb.GetFolderRequest, opts ...gax.CallOption) (*controlpb.Folder, error) { | ||
| if s.stallDurationForFolderAPIs != nil { | ||
| if s.stallDurationForFolderAPIs != nil && *s.stallDurationForFolderAPIs > 0 { | ||
| select { | ||
| case <-time.After(*s.stallDurationForFolderAPIs): | ||
| case <-ctx.Done(): | ||
| return nil, ctx.Err() | ||
| } | ||
| if err := ctx.Err(); err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
| return s.wrapped.GetFolder(ctx, req, opts...) | ||
| } | ||
|
|
||
| func (s *stallingStorageControlClient) RenameFolder(ctx context.Context, req *controlpb.RenameFolderRequest, opts ...gax.CallOption) (*control.RenameFolderOperation, error) { | ||
| if s.stallDurationForFolderAPIs != nil { | ||
| if s.stallDurationForFolderAPIs != nil && *s.stallDurationForFolderAPIs > 0 { | ||
| select { | ||
| case <-time.After(*s.stallDurationForFolderAPIs): | ||
| case <-ctx.Done(): | ||
| return nil, ctx.Err() | ||
| } | ||
| if err := ctx.Err(); err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
| return s.wrapped.RenameFolder(ctx, req, opts...) | ||
| } | ||
|
|
||
| func (s *stallingStorageControlClient) CreateFolder(ctx context.Context, req *controlpb.CreateFolderRequest, opts ...gax.CallOption) (*controlpb.Folder, error) { | ||
| if s.stallDurationForFolderAPIs != nil { | ||
| if s.stallDurationForFolderAPIs != nil && *s.stallDurationForFolderAPIs > 0 { | ||
| select { | ||
| case <-time.After(*s.stallDurationForFolderAPIs): | ||
| case <-ctx.Done(): | ||
| return nil, ctx.Err() | ||
| } | ||
| if err := ctx.Err(); err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
| return s.wrapped.CreateFolder(ctx, req, opts...) | ||
| } |
There was a problem hiding this comment.
The stalling logic is duplicated across five different methods (GetStorageLayout, DeleteFolder, GetFolder, RenameFolder, CreateFolder). Additionally, using time.After in a select block can leak the underlying timer resources if the context is cancelled before the timer fires.
We can refactor this repetitive logic into a single helper function stall that uses time.NewTimer and ensures the timer is stopped via defer timer.Stop(). This improves maintainability, readability, and resource efficiency.
func stall(ctx context.Context, duration *time.Duration) error {
if duration == nil || *duration <= 0 {
return nil
}
timer := time.NewTimer(*duration)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
return ctx.Err()
}
return ctx.Err()
}
func (s *stallingStorageControlClient) GetStorageLayout(ctx context.Context, req *controlpb.GetStorageLayoutRequest, opts ...gax.CallOption) (*controlpb.StorageLayout, error) {
if err := stall(ctx, s.stallDurationForGetStorageLayout); err != nil {
return nil, err
}
return s.wrapped.GetStorageLayout(ctx, req, opts...)
}
func (s *stallingStorageControlClient) DeleteFolder(ctx context.Context, req *controlpb.DeleteFolderRequest, opts ...gax.CallOption) error {
if err := stall(ctx, s.stallDurationForFolderAPIs); err != nil {
return err
}
return s.wrapped.DeleteFolder(ctx, req, opts...)
}
func (s *stallingStorageControlClient) GetFolder(ctx context.Context, req *controlpb.GetFolderRequest, opts ...gax.CallOption) (*controlpb.Folder, error) {
if err := stall(ctx, s.stallDurationForFolderAPIs); err != nil {
return nil, err
}
return s.wrapped.GetFolder(ctx, req, opts...)
}
func (s *stallingStorageControlClient) RenameFolder(ctx context.Context, req *controlpb.RenameFolderRequest, opts ...gax.CallOption) (*control.RenameFolderOperation, error) {
if err := stall(ctx, s.stallDurationForFolderAPIs); err != nil {
return nil, err
}
return s.wrapped.RenameFolder(ctx, req, opts...)
}
func (s *stallingStorageControlClient) CreateFolder(ctx context.Context, req *controlpb.CreateFolderRequest, opts ...gax.CallOption) (*controlpb.Folder, error) {
if err := stall(ctx, s.stallDurationForFolderAPIs); err != nil {
return nil, err
}
return s.wrapped.CreateFolder(ctx, req, opts...)
}
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #5060 +/- ##
=======================================
Coverage 83.92% 83.93%
=======================================
Files 176 176
Lines 21563 21563
=======================================
+ Hits 18096 18098 +2
+ Misses 2766 2765 -1
+ Partials 701 700 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
97f84e4 to
6d81ef6
Compare
Description
Fixes a race condition and non-deterministic behavior in
stallingStorageControlClienttimeout tests ininternal/storage/control_client_wrapper_test.go.Problem & Root Cause:
In
stallingStorageControlClient, Go'sselectstatement between<-time.After()and<-ctx.Done()caused flaky failures when both channels were ready simultaneously under heavy load or GC pauses. Because Go's select evaluates ready channels pseudo-randomly, the timer branch was sometimes chosen even though the context deadline had already expired, causing unintended fall-through to real unmocked control client methods. Additionally, zero/nil stall durations created unnecessary timer overhead.Solution / Fix Mechanism:
stallDuration != nil && *stallDuration > 0before entering stall delay.if err := ctx.Err(); err != nil { return nil, err }) so that even if time.After(*d) unblocks at the exact same instant as ctx.Done(), the subsequent ctx.Err() check guarantees that an expired context is prioritized and returned immediately.Link to the issue in case of a bug fix.
b/553889914
Testing details
make build.go test -v -race -run "TestControlClientWrapperTestSuite|TestControlClientGaxRetryWrapperTestSuite" ./internal/storage(PASS — 0 race warnings, 0 failures).Any backward incompatible change? If so, please explain.
N/A