Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions conformance/tests/mesh/grpcroute-weight.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,21 @@ var MeshGRPCRouteWeight = suite.ConformanceTest{
"echo-v2": 0.3,
}

sender := weight.NewFunctionBasedSender(func() (string, error) {
uniqueExpected := expected
if err := http.AddEntropy(&uniqueExpected); err != nil {
return "", fmt.Errorf("error adding entropy: %w", err)
}
_, cRes, err := client.CaptureRequestResponseAndCompare(t, uniqueExpected)
sender := weight.NewBatchFunctionBasedSender(func(count int) ([]string, error) {
responses, err := client.RequestBatch(t, expected, count)
if err != nil {
return "", fmt.Errorf("failed gRPC mesh request: %w", err)
return nil, fmt.Errorf("failed batch gRPC mesh request: %w", err)
}

hostnames := make([]string, len(responses))
for i, resp := range responses {
hostnames[i] = resp.Hostname
}
return cRes.Hostname, nil
return hostnames, nil
})

for i := 0; i < weight.MaxTestRetries; i++ {
if err := weight.TestWeightedDistribution(sender, expectedWeights); err != nil {
if err := weight.TestWeightedDistributionBatch(sender, expectedWeights); err != nil {
t.Logf("Traffic distribution test failed (%d/%d): %s", i+1, weight.MaxTestRetries, err)
} else {
return
Expand Down
21 changes: 11 additions & 10 deletions conformance/tests/mesh/httproute-weight.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,21 @@ var MeshHTTPRouteWeight = suite.ConformanceTest{
"echo-v2": 0.3,
}

sender := weight.NewFunctionBasedSender(func() (string, error) {
uniqueExpected := expected
if err := http.AddEntropy(&uniqueExpected); err != nil {
return "", fmt.Errorf("error adding entropy: %w", err)
}
_, cRes, err := client.CaptureRequestResponseAndCompare(t, uniqueExpected)
sender := weight.NewBatchFunctionBasedSender(func(count int) ([]string, error) {
responses, err := client.RequestBatch(t, expected, count)
if err != nil {
return "", fmt.Errorf("failed mesh request: %w", err)
return nil, fmt.Errorf("failed batch mesh request: %w", err)
}

hostnames := make([]string, len(responses))
for i, resp := range responses {
hostnames[i] = resp.Hostname
}
return cRes.Hostname, nil
return hostnames, nil
})

for i := 0; i < weight.MaxTestRetries; i++ {
if err := weight.TestWeightedDistribution(sender, expectedWeights); err != nil {
for i := range weight.MaxTestRetries {
if err := weight.TestWeightedDistributionBatch(sender, expectedWeights); err != nil {
t.Logf("Traffic distribution test failed (%d/%d): %s", i+1, weight.MaxTestRetries, err)
} else {
return
Expand Down
23 changes: 23 additions & 0 deletions conformance/utils/echo/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,29 @@ func ParseResponse(output string) Response {
return out
}

// parseMultipleResponses parses output containing multiple responses separated by blank lines
func parseMultipleResponses(output string) []Response {
// Split by double newline which typically separates individual responses
// in batch mode output
responseSections := strings.Split(output, "\n\n")

var responses []Response
for _, section := range responseSections {
section = strings.TrimSpace(section)
if section == "" {
continue
}
// Parse each section as a separate response
resp := ParseResponse(section)
// Only add responses that have meaningful content (at least a hostname or code)
if resp.Hostname != "" || resp.Code != "" {
responses = append(responses, resp)
}
}

return responses
}

// HeaderType is a helper enum for retrieving Headers from a Response.
type HeaderType string

Expand Down
27 changes: 27 additions & 0 deletions conformance/utils/echo/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ func (m *MeshPod) MakeRequestAndExpectEventuallyConsistentResponse(t *testing.T,
}

func makeRequest(t *testing.T, exp *http.ExpectedResponse) []string {
return makeRequestWithCount(t, exp, 0)
}

func makeRequestWithCount(t *testing.T, exp *http.ExpectedResponse, count int) []string {
if exp.Request.Host == "" {
exp.Request.Host = "echo"
}
Expand Down Expand Up @@ -112,6 +116,9 @@ func makeRequest(t *testing.T, exp *http.ExpectedResponse) []string {
for k, v := range r.Headers {
args = append(args, "-H", fmt.Sprintf("%v:%v", k, v))
}
if count > 0 {
args = append(args, fmt.Sprintf("--count=%d", count))
}
return args
}

Expand Down Expand Up @@ -275,3 +282,23 @@ func (m *MeshPod) CaptureRequestResponseAndCompare(t *testing.T, exp http.Expect
}
return req, resp, nil
}

// RequestBatch executes a batch of requests using the --count flag and returns all responses
func (m *MeshPod) RequestBatch(t *testing.T, exp http.ExpectedResponse, count int) ([]Response, error) {
req := makeRequestWithCount(t, &exp, count)

resp, err := m.request(req)
if err != nil {
return nil, fmt.Errorf("batch request failed: %w", err)
}

// Split the output by response boundaries
// Each response is separated by a blank line in the output
responses := parseMultipleResponses(resp.RawContent)

if len(responses) != count {
tlog.Logf(t, "Warning: expected %d responses but got %d", count, len(responses))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you clarify why this is not an error? if you send 500 requests and gets 499 back, shouldn't this be a problem?

Copy link
Member

@rikatz rikatz Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is expected, as I can see you have a "tolerance" configuration below, maybe the tolerance should be part of the MeshPod instance (so each test have a different tolerance) or please add a comment here raising that the caller of RequestBatch expects a tolerance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thank you! Changed it to return an error. I made it a warning initially due to uncertainty about parsing edge cases, but you're right that missing responses should fail the test immediately. The tolerance is only for validating the weight distribution (±5%), not for handling missing responses.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks! I will come back to this review soon this week!

}

return responses, nil
}
14 changes: 14 additions & 0 deletions conformance/utils/weight/senders.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,17 @@ func (s *FunctionBasedSender) SendRequest() (string, error) {
func NewFunctionBasedSender(sendFunc func() (string, error)) RequestSender {
return &FunctionBasedSender{sendFunc: sendFunc}
}

// BatchFunctionBasedSender implements BatchRequestSender using a function
type BatchFunctionBasedSender struct {
sendBatchFunc func(count int) ([]string, error)
}

func (s *BatchFunctionBasedSender) SendBatchRequest(count int) ([]string, error) {
return s.sendBatchFunc(count)
}

// NewBatchFunctionBasedSender creates a BatchRequestSender from a function
func NewBatchFunctionBasedSender(sendBatchFunc func(count int) ([]string, error)) BatchRequestSender {
return &BatchFunctionBasedSender{sendBatchFunc: sendBatchFunc}
}
74 changes: 74 additions & 0 deletions conformance/utils/weight/weight.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ func extractBackendName(podName string) string {
return strings.Join(parts[:len(parts)-2], "-")
}

// BatchRequestSender defines an interface for sending batch requests
type BatchRequestSender interface {
SendBatchRequest(count int) ([]string, error)
}

// TestWeightedDistribution tests that requests are distributed according to expected weights
func TestWeightedDistribution(sender RequestSender, expectedWeights map[string]float64) error {
const (
Expand Down Expand Up @@ -135,6 +140,75 @@ func TestWeightedDistribution(sender RequestSender, expectedWeights map[string]f
return errors.Join(errs...)
}

// TestWeightedDistributionBatch tests that requests are distributed according to expected weights
// using batch request execution for improved performance
func TestWeightedDistributionBatch(sender BatchRequestSender, expectedWeights map[string]float64) error {
const (
tolerancePercentage = 0.05
totalRequests = 500
)

// Execute all requests in a single batch
podNames, err := sender.SendBatchRequest(totalRequests)
if err != nil {
return fmt.Errorf("error while sending batch request: %w", err)
}

if len(podNames) != totalRequests {
return fmt.Errorf("expected %d responses but got %d", totalRequests, len(podNames))
}

// Count the distribution
seen := make(map[string]float64, len(expectedWeights))
for _, podName := range podNames {
backendName := extractBackendName(podName)

if _, exists := expectedWeights[backendName]; exists {
seen[backendName]++
} else {
return fmt.Errorf("request was handled by an unexpected pod %q (extracted backend: %q)", podName, backendName)
}
}

// Count how many backends should receive traffic (weight > 0)
expectedActiveBackends := 0
for _, weight := range expectedWeights {
if weight > 0.0 {
expectedActiveBackends++
}
}

var errs []error
if len(seen) != expectedActiveBackends {
errs = append(errs, fmt.Errorf("expected %d backends to receive traffic, but got %d", expectedActiveBackends, len(seen)))
}

for wantBackend, wantPercent := range expectedWeights {
gotCount, ok := seen[wantBackend]

if !ok && wantPercent != 0.0 {
errs = append(errs, fmt.Errorf("expect traffic to hit backend %q - but none was received", wantBackend))
continue
}

gotPercent := gotCount / float64(totalRequests)

if math.Abs(gotPercent-wantPercent) > tolerancePercentage {
errs = append(errs, fmt.Errorf("backend %q weighted traffic of %v not within tolerance %v (+/-%f)",
wantBackend,
gotPercent,
wantPercent,
tolerancePercentage,
))
}
}

slices.SortFunc(errs, func(a, b error) int {
return cmp.Compare(a.Error(), b.Error())
})
return errors.Join(errs...)
}

// Entropy utilities

// addRandomDelay adds a random delay up to the specified limit in milliseconds
Expand Down