go_loadgen

package module
v0.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Dec 2, 2025 License: Apache-2.0 Imports: 9 Imported by: 0

README

Go Loadgen

This is a small library that provides utilities for load testing. It is protocol-agnostic, you can use it to test any HTTP, gRPC, or other services. It is based on a simple API : to test a service, you need to define a client, a data provider, and a collector. Go Loadgen then takes care of executing the workload and collecting the results. It also provides a simple workload-pattern generator that can generate a workload based on a configuration if you want to create a workload with many different phases which have different RPS.

Use it in your project

go get github.com/luccadibe/go-loadgen

Motivation

I used a lot of k6 in the past for load testing, but when I tried to run longer workloads, the resource usage was too high, and it felt like a waste, especially because I didn't need all of the features that k6 provides.

ghz didn't fit my needs because it stores all results in memory and writes them to disk in the end. This library provides you full flexibility to implement your own collector.

Features

  • Protocol-agnostic
  • Type-safe using go generics
  • Support for constant and variable RPS
  • Support for workload-pattern generation with weighted time allocation
  • No external dependencies

Example

Say you want to test a gRPC server's "/increment" endpoint with a variable RPS. You can do it like this:


// First, we define our data model: a request and a response.
type endpointRequest struct {
    // We need to send a delta to the endpoint to change the counter
    Delta int32 `json:"delta"`
}

type endpointResponse struct {
    // We'd like to track the latency of the request
	Latency time.Duration `json:"latency"`
    // We'd like to track the counter value
    Counter int32         `json:"counter"`
    // We'd like to track any errors that occur
    Error   string        `json:"error,omitempty"`
}

// Then, we define our client and data provider. 
// It must implement the Client interface.
type myClient struct{}

// CallEndpoint will be called by executors using data (endpointRequest) provided by our data provider.
func (c *myClient) CallEndpoint(ctx context.Context, req endpointRequest) endpointResponse {
    // We can track the latency of the request
	startTime := time.Now()
	body, err := json.Marshal(req)
	if err != nil {
		return endpointResponse{Error: err.Error()}
	}
    // We can send an http request to a server.
	resp, err := http.Post("http://localhost:8080/increment", "application/json", bytes.NewBuffer(body))
	if err != nil {
		return endpointResponse{Error: err.Error()}
	}
	defer resp.Body.Close()

	var respBody endpointResponse
	if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
		return endpointResponse{Error: err.Error()}
	}
	respBody.Latency = time.Since(startTime)
	return respBody
}

// Our data provider will provide the data to be sent to the server in each request. 
// It must be thread safe.
type myDataProvider struct{}

func (d *myDataProvider) GetData() endpointRequest {
    // We'll increment the counter by 1 each time
	return endpointRequest{Delta: 1}
}

func main() {
// Then, we define our collector. For this we can use the CSVCollector. 
// We can also provide a flush interval, which will be used to flush the collector 
// to the disk every flushInterval.
	collector, err := go_loadgen.NewCSVCollector[endpointResponse]("results.csv", 1*time.Second)
	if err != nil {
		log.Fatalf("Failed to create collector: %v", err)
	}
	defer collector.Close()

	// With all of this, we can create a new EndpointWorkload.
	ew, err := go_loadgen.NewEndpointWorkload(
			"increment",
			&go_loadgen.Config{
				GenerateWorkload: false,
				MaxDuration:      20 * time.Second,
				Phases: []go_loadgen.TestPhase{
					{
						Name:      "increment",
						// constant RPS
						Type:      "constant",
						StartTime: 0,
						Duration:  10 * time.Second,
						StartRPS:  1,
						Step:      1,
					},
					{
						Name:      "increment",
						// variable RPS. Increments by 10 every second
						Type:      "variable",
						StartTime: 10 * time.Second,
						Duration:  10 * time.Second,
						StartRPS:  10,
						EndRPS:    100,
						Step:      10,
					},
				},
			},
			&myClient{},
			&myDataProvider{},
			collector,
		)

		if err != nil {
			log.Fatalf("Failed to create endpoint workload: %v", err)
		}
		// The workload will run until the max duration is reached or the workload is stopped. 
		// All of the results will be collected and written to the CSV file.
		ew.Run()
}

A simple library like this gives me flexibility to test any service and avoid re writing the same executor code each time.

Workload Pattern Generation

If you want to create more complex workloads with randomized phases, you can use the workload pattern generation feature. This is useful when you want to simulate variable traffic patterns without having to define each phase manually.

// Using the same client and data provider from the previous example
func main() {
	collector, err := go_loadgen.NewCSVCollector[endpointResponse]("results.csv", 1*time.Second)
	if err != nil {
		log.Fatalf("Failed to create collector: %v", err)
	}
	// Don't forget to close the collector when you're done
	defer collector.Close()

	// Create a workload with pattern generation enabled
	ew, err := go_loadgen.NewEndpointWorkload(
		"increment",
		&go_loadgen.Config{
			// Enable workload generation
			GenerateWorkload: true,
			MaxDuration:      60 * time.Second,
			Timeout:          10,
			// Define patterns instead of specific phases
			Patterns: []*go_loadgen.PhasePattern{
				{
					Name:               "increment",
					// Generate between 3 and 8 phases
					PhaseCount:         go_loadgen.IntRange{Min: 3, Max: 8},
					// 60% chance of constant RPS phases
					ConstantLikelihood: 0.6,
					// 40% chance of variable RPS phases
					RampingLikelihood:  0.4,
					// This pattern takes up 100% of the workload time (default behavior)
					Weight: 1.0,
					Parameters: go_loadgen.PhaseParameters{
						// Start RPS between 5 and 20
						StartRPS: go_loadgen.IntRange{Min: 5, Max: 20},
						// End RPS between 30 and 100
						EndRPS:   go_loadgen.IntRange{Min: 30, Max: 100},
						// Step size between 1 and 5
						Step:     go_loadgen.IntRange{Min: 1, Max: 5},
					},
				},
			},
		},
		&myClient{},
		&myDataProvider{},
		collector,
	)

	if err != nil {
		log.Fatalf("Failed to create endpoint workload: %v", err)
	}

	// The generator will create a randomized workload based on your patterns
	// Each run will produce different phases within your specified parameters
	ew.Run()
}

Pattern Weighting

You can control how much time each pattern takes up in your workload using the Weight field. Weights must sum to 1.0, or if not specified (or all set to 0.0), patterns will be weighted equally.

Patterns: []*go_loadgen.PhasePattern{
	{
		Name:               "heavy_load",
		Weight:              0.7, // 70% of total workload time
		PhaseCount:          go_loadgen.IntRange{Min: 2, Max: 4},
		ConstantLikelihood:  0.8,
		RampingLikelihood:   0.2,
		Parameters: go_loadgen.PhaseParameters{
			StartRPS: go_loadgen.IntRange{Min: 10, Max: 50},
			EndRPS:   go_loadgen.IntRange{Min: 50, Max: 100},
			Step:     go_loadgen.IntRange{Min: 5, Max: 10},
		},
	},
	{
		Name:               "light_load",
		Weight:              0.3, // 30% of total workload time
		PhaseCount:          go_loadgen.IntRange{Min: 1, Max: 2},
		ConstantLikelihood:  1.0,
		RampingLikelihood:   0.0,
		Parameters: go_loadgen.PhaseParameters{
			StartRPS: go_loadgen.IntRange{Min: 1, Max: 5},
			EndRPS:   go_loadgen.IntRange{Min: 5, Max: 10},
			Step:     go_loadgen.IntRange{Min: 1, Max: 2},
		},
	},
},

This will create a workload where the "heavy_load" pattern takes up 70% of the total time, and "light_load" takes up 30% of the time.

Note: Patterns are executed in the order they appear in the slice. The "heavy_load" pattern will always execute before "light_load" in this example.

If you want to run the examples, you can use the justfile:

just run-example http server
# in another terminal
just run-example http client

Contributing

This is my first public library, so any feedback or contribution is welcome. Please feel free to open an issue or submit a pull request.

License

Apache License 2.0 - see the LICENSE file for details.

Documentation

Overview

Package go_loadgen provides utilities for load testing that is protocol-agnostic.

This library allows you to test any HTTP, gRPC, or other services based on a simple API: to test a service, you need to define a client, a data provider, and a collector. Go Loadgen then takes care of executing the workload and collecting the results. It also provides a simple workload-pattern generator that can generate a workload based on a configuration if you want to create a workload with many different phases which have different RPS. The pattern generator supports weighted time allocation, allowing you to control how much time each pattern takes up in your workload.

Motivation

This library was created as an alternative to k6 for longer workloads where resource usage was too high, and as an alternative to ghz which stores all results in memory and writes them to disk at the end. This library provides full flexibility to implement your own collector.

Features

  • Protocol-agnostic
  • Type-safe using go generics
  • Support for constant and variable RPS
  • Support for workload-pattern generation with weighted time allocation

Example

Say you want to test a gRPC server's "/increment" endpoint with a variable RPS:

// First, define your data model: a request and a response.
type endpointRequest struct {
    // We need to send a delta to the endpoint to change the counter
    Delta int32 `json:"delta"`
}

type endpointResponse struct {
    // We'd like to track the latency of the request
	Latency time.Duration `json:"latency"`
    // We'd like to track the counter value
    Counter int32         `json:"counter"`
    // We'd like to track any errors that occur
    Error   string        `json:"error,omitempty"`
}

// Then, define your client and data provider. Your client will be used to send
// the request to the server, it must implement the Client interface.
type myClient struct{}

// CallEndpoint will be called by executors using data (endpointRequest) provided
// by our data provider.
func (c *myClient) CallEndpoint(ctx context.Context, req endpointRequest) endpointResponse {
    // We can track the latency of the request
	startTime := time.Now()
	body, err := json.Marshal(req)
	if err != nil {
		return endpointResponse{Error: err.Error()}
	}
    // We can send an http request to a server.
	resp, err := http.Post("http://localhost:8080/increment", "application/json", bytes.NewBuffer(body))
	if err != nil {
		return endpointResponse{Error: err.Error()}
	}
	defer resp.Body.Close()

	var respBody endpointResponse
	if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
		return endpointResponse{Error: err.Error()}
	}
	respBody.Latency = time.Since(startTime)
	return respBody
}

// Your data provider will provide the data to be sent to the server in each request.
// It must be thread safe.
type myDataProvider struct{}

func (d *myDataProvider) GetData() endpointRequest {
    // We'll increment the counter by 1 each time
	return endpointRequest{Delta: 1}
}

// Then, define your collector. For this we can use the CSVCollector. We can also
// provide a flush interval, which will be used to flush the collector to the disk
// every flushInterval.
collector, err := go_loadgen.NewCSVCollector[endpointResponse]("results.csv", 1*time.Second)
if err != nil {
    log.Fatalf("Failed to create collector: %v", err)
}
defer collector.Close()

// With all of this, we can create a new EndpointWorkload.
ew, err := go_loadgen.NewEndpointWorkload(
		"increment",
		&go_loadgen.Config{
			GenerateWorkload: false,
			MaxDuration:      20 * time.Second,
			Phases: []go_loadgen.TestPhase{
				{
					Name:      "increment",
                    // constant RPS
					Type:      "constant",
					StartTime: 0,
					Duration:  10 * time.Second,
					StartRPS:  1,
					Step:      1,
				},
				{
					Name:      "increment",
                    // variable RPS. Increments by 10 every second
					Type:      "variable",
					StartTime: 10 * time.Second,
					Duration:  10 * time.Second,
					StartRPS:  10,
					EndRPS:    100,
					Step:      10,
				},
			},
		},
		&myClient{},
		&myDataProvider{},
		collector,
	)

    if err != nil {
        log.Fatalf("Failed to create endpoint workload: %v", err)
    }
    // The workload will run until the max duration is reached or the workload is stopped.
    // All of the results will be collected and written to the CSV file.
    ew.Run()

This simple library gives you flexibility to test any service and avoid rewriting the same executor code each time.

Index

Constants

View Source
const MIN_INTERVAL = 10 * time.Millisecond

Variables

This section is empty.

Functions

This section is empty.

Types

type CSVCollector

type CSVCollector[R CSVSerializable] struct {
	// contains filtered or unexported fields
}

CSVCollector can collect results and write them to a CSV file. It requires result types to implement CSVSerializable. It will write the headers on the first collect and then every flushInterval. Note that headers will be rewritten if a new collector is created.

func NewCSVCollector

func NewCSVCollector[R CSVSerializable](filePath string, flushInterval time.Duration) (*CSVCollector[R], error)

NewCSVCollector creates a new CSV collector and starts a goroutine to flush the collector every flushInterval.

func (*CSVCollector[R]) Close

func (c *CSVCollector[R]) Close()

Close flushes the CSV collector and closes the file.

func (*CSVCollector[R]) Collect

func (c *CSVCollector[R]) Collect(result R)

Collect collects a result and writes it to the CSV file.

func (*CSVCollector[R]) RunFlush

func (c *CSVCollector[R]) RunFlush(ctx context.Context)

RunFlush flushes the CSV collector every flushInterval.

type CSVSerializable

type CSVSerializable interface {
	// CSVHeaders returns the headers that should be used for a CSV file.
	CSVHeaders() []string
	// CSVRecord returns the record that should be used to store the struct as a row in a CSV file.
	CSVRecord() []string
}

CSVSerializable is a struct that can be serialized to CSV

type Client

type Client[C any, R any] interface {
	// CallEndpoint should send a request using the provided data to the endpoint and return a result.
	CallEndpoint(ctx context.Context, req C) R
}

A Client is a generic interface that can be used to call an endpoint.

type Collector

type Collector[R any] interface {
	// Collect should collect the result from a client.
	Collect(result R)
	Close()
}

A Collector is a generic interface that can be used to collect results from a client. Users are free to implement their own mechanism for saving results to disk. Executors will call Collector.Collect() concurrently , and Collector.Close() will be called after all results are collected.

type Config

type Config struct {
	GenerateWorkload bool            `yaml:"generate_workload"`
	Seed             int64           `yaml:"seed,omitempty"`
	MaxDuration      time.Duration   `yaml:"max_duration"`
	Patterns         []*PhasePattern `yaml:"patterns"`
	Phases           []TestPhase     `yaml:"phases,omitempty"`
}

type ConstantExecutor

type ConstantExecutor[C any, R any] struct {
	// contains filtered or unexported fields
}

A ConstantExecutor is a LoadExecutor that executes a workload of TestPhases with a constant RPS.

func NewConstantExecutor

func NewConstantExecutor[C any, R any](
	client Client[C, R],
	collector Collector[R],
	dataProvider DataProvider[C],
) *ConstantExecutor[C, R]

NewConstantExecutor creates a new ConstantExecutor.

func (*ConstantExecutor[C, R]) Execute

func (e *ConstantExecutor[C, R]) Execute(ctx context.Context, phase TestPhase)

Execute executes a workload of TestPhases with a constant RPS.

func (*ConstantExecutor[C, R]) Stop

func (e *ConstantExecutor[C, R]) Stop()

Stop stops the ConstantExecutor.

type DataProvider

type DataProvider[C any] interface {
	// GetData should return a data object for a request. It must be thread safe.
	GetData() C
}

A DataProvider is a generic interface that can be used to get data for a request. Users are free to implement their own mechanism for getting data. Executors will call DataProvider.GetData() concurrently.

type EndpointWorkload

type EndpointWorkload[C any, R any] struct {
	Name         string
	Config       *Config
	Client       Client[C, R]
	DataProvider DataProvider[C]
	Collector    Collector[R]
}

An EndpointWorkload can execute a workload of TestPhases on a provided client. Users must provide their own implementation of Client, DataProvider, and Collector, which all use the same input and output types.

func NewEndpointWorkload

func NewEndpointWorkload[C any, R any](name string, config *Config, client Client[C, R], dataProvider DataProvider[C], collector Collector[R]) (*EndpointWorkload[C, R], error)

NewEndpointWorkload creates a new EndpointWorkload. If GenerateWorkload is true, the workload will be generated using the provided patterns. If GenerateWorkload is false, the workload will be executed using the TestPhases in the Config.

func (*EndpointWorkload[C, R]) Run

func (e *EndpointWorkload[C, R]) Run()

Run executes the workload according to the TestPhases in the Config.

type IntRange

type IntRange struct {
	Min int `yaml:"min"`
	Max int `yaml:"max"`
}

type LoadExecutor

type LoadExecutor interface {
	Execute(ctx context.Context, phase TestPhase)
	Stop()
}

A LoadExecutor is a generic interface that can be used to execute a workload of TestPhases.

type PhaseParameters

type PhaseParameters struct {
	StartRPS IntRange `yaml:"start_rps"`
	EndRPS   IntRange `yaml:"end_rps"`
	Step     IntRange `yaml:"step"`
}

type PhasePattern

type PhasePattern struct {
	Name               string   `yaml:"name"`
	PhaseCount         IntRange `yaml:"phase_count"`
	ConstantLikelihood float64  `yaml:"constant_likelihood"` // 0.0-1.0
	RampingLikelihood  float64  `yaml:"ramping_likelihood"`  // 0.0-1.0
	// What percentage of the total workload time this pattern should take up. 0.0-1.0
	// Note: all patterns must sum to 1.0. If not provided, all patterns will be weighted equally.
	Weight float64 `yaml:"weight"`
	// Knobs to affect the generated workload phase
	Parameters PhaseParameters `yaml:"parameters"`
	// contains filtered or unexported fields
}

PhasePattern is a template for a workload phase. It is used to generate a workload of TestPhases.

type RampingExecutor

type RampingExecutor[C any, R any] struct {
	// contains filtered or unexported fields
}

A RampingExecutor is a LoadExecutor that executes a workload of TestPhases with variable RPS.

func NewRampingExecutor

func NewRampingExecutor[C any, R any](
	client Client[C, R],
	collector Collector[R],
	dataProvider DataProvider[C],
) *RampingExecutor[C, R]

NewRampingExecutor creates a new RampingExecutor.

func (*RampingExecutor[C, R]) Execute

func (e *RampingExecutor[C, R]) Execute(ctx context.Context, phase TestPhase)

Execute executes a workload of TestPhases with a variable RPS.

func (*RampingExecutor[C, R]) Stop

func (e *RampingExecutor[C, R]) Stop()

Stop stops the RampingExecutor.

type TestPhase

type TestPhase struct {
	Name string `yaml:"name"`
	// "constant" | "variable"
	Type string `yaml:"type"`
	// Starting time of the phase, relative to workload start
	StartTime time.Duration `yaml:"start_time"`
	// Total duration of the phase
	Duration time.Duration `yaml:"duration"`
	// Starting RPS for the phase. If constant, this is also the constant RPS.
	StartRPS int `yaml:"start_rps"`
	// Maximum end RPS for the phase, which may not be reached if max duration is reached
	// or step is too small.
	EndRPS int `yaml:"end_rps,omitempty"`
	// Step increment for the RPS. If variable, this is the increment per second.
	Step int `yaml:"step,omitempty"`
}

type WorkloadPatternGenerator

type WorkloadPatternGenerator struct {
	// contains filtered or unexported fields
}

func NewWorkloadPatternGenerator

func NewWorkloadPatternGenerator(seed int64, maxDuration time.Duration, patterns []*PhasePattern) *WorkloadPatternGenerator

func (*WorkloadPatternGenerator) GenerateWorkload

func (g *WorkloadPatternGenerator) GenerateWorkload() ([]TestPhase, error)

Generates a workload for the given patterns.

Directories

Path Synopsis
examples
http/client command
http/server command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL