github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/pkg/loadtest/loader.go (about)

     1  package loadtest
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"os"
    11  	"time"
    12  
    13  	"github.com/deepmap/oapi-codegen/pkg/securityprovider"
    14  	"github.com/google/uuid"
    15  	"github.com/jedib0t/go-pretty/v6/text"
    16  	"github.com/schollz/progressbar/v3"
    17  	"github.com/treeverse/lakefs/pkg/api/apigen"
    18  	"github.com/treeverse/lakefs/pkg/api/apiutil"
    19  	"github.com/treeverse/lakefs/pkg/auth/model"
    20  	"github.com/treeverse/lakefs/pkg/logging"
    21  	"github.com/treeverse/lakefs/pkg/version"
    22  	vegeta "github.com/tsenart/vegeta/v12/lib"
    23  )
    24  
    25  type Loader struct {
    26  	Reader       *io.PipeReader
    27  	Writer       *io.PipeWriter
    28  	Config       Config
    29  	NewRepoName  string
    30  	Metrics      map[string]*vegeta.Metrics
    31  	TotalMetrics *vegeta.Metrics
    32  }
    33  
    34  type Config struct {
    35  	FreqPerSecond    int
    36  	Duration         time.Duration
    37  	MaxWorkers       uint64
    38  	RepoName         string
    39  	StorageNamespace string
    40  	KeepRepo         bool
    41  	Credentials      model.Credential
    42  	ServerAddress    string
    43  	ShowProgress     bool
    44  }
    45  
    46  var (
    47  	ErrCreateClient           = errors.New("failed to create lakeFS client")
    48  	ErrTestErrors             = errors.New("got errors during loadtest, see output for details")
    49  	ErrRepositoryCreateFailed = errors.New("repository create failed")
    50  	ErrRepositoryDeleteFailed = errors.New("repository delete failed")
    51  )
    52  
    53  func NewLoader(config Config) *Loader {
    54  	reader, writer := io.Pipe()
    55  	res := &Loader{
    56  		Config: config,
    57  		Reader: reader,
    58  		Writer: writer,
    59  	}
    60  	return res
    61  }
    62  
    63  func (t *Loader) Run() error {
    64  	apiClient, err := t.getClient()
    65  	if err != nil {
    66  		return err
    67  	}
    68  	repoName, err := t.createRepo(apiClient)
    69  	if err != nil {
    70  		return err
    71  	}
    72  	stopCh := make(chan struct{})
    73  	if t.Config.ShowProgress {
    74  		progressBar(t.Config.Duration)
    75  	}
    76  	out := new(SimpleScenario).Play(t.Config.ServerAddress, repoName, stopCh)
    77  	errs := t.streamRequests(out)
    78  	hasErrors := t.doAttack()
    79  	close(stopCh)
    80  	_ = t.Writer.Close()
    81  	_ = t.Reader.Close()
    82  	if t.Config.RepoName == "" && !t.Config.KeepRepo {
    83  		ctx := context.Background()
    84  		resp, err := apiClient.DeleteRepositoryWithResponse(ctx, t.NewRepoName, &apigen.DeleteRepositoryParams{})
    85  		if err != nil {
    86  			return err
    87  		}
    88  		if resp.HTTPResponse.StatusCode != http.StatusNoContent {
    89  			return fmt.Errorf("%w: %s (%d)", ErrRepositoryDeleteFailed, resp.HTTPResponse.Status, resp.HTTPResponse.StatusCode)
    90  		}
    91  	}
    92  	for err := range errs {
    93  		if errors.Is(err, io.ErrClosedPipe) {
    94  			continue
    95  		}
    96  		logging.ContextUnavailable().WithError(err).Error("error during request pipeline")
    97  		return err
    98  	}
    99  	err = printResults(t.Metrics, t.TotalMetrics)
   100  	if err != nil {
   101  		return err
   102  	}
   103  	if hasErrors {
   104  		return ErrTestErrors
   105  	}
   106  	return nil
   107  }
   108  
   109  func (t *Loader) createRepo(apiClient apigen.ClientWithResponsesInterface) (string, error) {
   110  	if t.Config.RepoName != "" {
   111  		// using an existing repo, no need to create one
   112  		return t.Config.RepoName, nil
   113  	}
   114  	t.NewRepoName = uuid.New().String()
   115  	ctx := context.Background()
   116  	resp, err := apiClient.CreateRepositoryWithResponse(ctx, &apigen.CreateRepositoryParams{}, apigen.CreateRepositoryJSONRequestBody{
   117  		DefaultBranch:    apiutil.Ptr("main"),
   118  		Name:             t.NewRepoName,
   119  		StorageNamespace: t.Config.StorageNamespace,
   120  	})
   121  	if err != nil {
   122  		return "", fmt.Errorf("failed to create lakeFS repository '%s' (%s): %w", t.NewRepoName, t.Config.StorageNamespace, err)
   123  	}
   124  	if resp.HTTPResponse.StatusCode != http.StatusCreated {
   125  		return "", fmt.Errorf("%w: %s (%d)", ErrRepositoryCreateFailed, resp.HTTPResponse.Status, resp.HTTPResponse.StatusCode)
   126  	}
   127  	return t.NewRepoName, nil
   128  }
   129  
   130  func (t *Loader) getClient() (apigen.ClientWithResponsesInterface, error) {
   131  	if t.Config.RepoName != "" {
   132  		// using an existing repo, no need to create a client
   133  		return nil, nil
   134  	}
   135  	basicAuthProvider, err := securityprovider.NewSecurityProviderBasicAuth(t.Config.Credentials.AccessKeyID, t.Config.Credentials.SecretAccessKey)
   136  	if err != nil {
   137  		return nil, err
   138  	}
   139  
   140  	serverEndpoint := t.Config.ServerAddress + apiutil.BaseURL
   141  	apiClient, err := apigen.NewClientWithResponses(
   142  		serverEndpoint,
   143  		apigen.WithRequestEditorFn(basicAuthProvider.Intercept),
   144  		apigen.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
   145  			req.Header.Set("User-Agent", "lakefs-loadtest/"+version.Version)
   146  			return nil
   147  		}),
   148  	)
   149  	if err != nil {
   150  		return nil, ErrCreateClient
   151  	}
   152  	return apiClient, nil
   153  }
   154  
   155  func (t *Loader) doAttack() (hasErrors bool) {
   156  	targeter := vegeta.NewJSONTargeter(t.Reader, nil,
   157  		http.Header{"Authorization": []string{"Basic " + getAuth(&t.Config.Credentials)}})
   158  	attacker := vegeta.NewAttacker(vegeta.MaxWorkers(t.Config.MaxWorkers))
   159  	t.Metrics = make(map[string]*vegeta.Metrics)
   160  	t.TotalMetrics = new(vegeta.Metrics)
   161  	rate := vegeta.Rate{Freq: t.Config.FreqPerSecond, Per: time.Second}
   162  	for res := range attacker.Attack(targeter, rate, t.Config.Duration, "lakeFS loadtest test") {
   163  		typ := GetRequestType(*res)
   164  		if len(res.Error) > 0 {
   165  			logging.ContextUnavailable().Debugf("Error in request type %s, error: %s, status: %d", typ, res.Error, res.Code)
   166  			hasErrors = true
   167  		}
   168  		typeMetrics := t.Metrics[typ]
   169  		if typeMetrics == nil {
   170  			typeMetrics = new(vegeta.Metrics)
   171  			t.Metrics[typ] = typeMetrics
   172  		}
   173  		typeMetrics.Add(res)
   174  		t.TotalMetrics.Add(res)
   175  	}
   176  	return
   177  }
   178  
   179  func progressBar(duration time.Duration) {
   180  	durationInSec := int(duration.Seconds())
   181  	progress := progressbar.NewOptions(durationInSec, progressbar.OptionSetPredictTime(false), progressbar.OptionFullWidth())
   182  	go func() {
   183  		for i := 0; i < durationInSec; i++ {
   184  			_ = progress.Add(1)
   185  			time.Sleep(time.Second)
   186  		}
   187  		_ = progress.Clear()
   188  	}()
   189  }
   190  
   191  func printResults(metrics map[string]*vegeta.Metrics, metricsTotal *vegeta.Metrics) error {
   192  	for requestType, typeMetrics := range metrics {
   193  		typeMetrics.Close()
   194  		fmt.Println(text.FgYellow.Sprintf("Results for request type: %s", requestType))
   195  
   196  		err := vegeta.NewTextReporter(typeMetrics).Report(os.Stdout)
   197  		if err != nil {
   198  			fmt.Println("Error trying to write report")
   199  			return err
   200  		}
   201  		fmt.Println()
   202  	}
   203  	fmt.Println(text.FgYellow.Sprintf("Results for ALL requests combined:"))
   204  	metricsTotal.Close()
   205  	err := vegeta.NewTextReporter(metricsTotal).Report(os.Stdout)
   206  	if err != nil {
   207  		fmt.Println("Error trying to write report")
   208  		return err
   209  	}
   210  	return nil
   211  }
   212  
   213  func (t *Loader) streamRequests(in <-chan vegeta.Target) <-chan error {
   214  	errs := make(chan error, 1)
   215  	encoder := vegeta.NewJSONTargetEncoder(t.Writer)
   216  	go func() {
   217  		defer close(errs)
   218  		for tgt := range in {
   219  			tgt := tgt // pin
   220  			err := encoder.Encode(&tgt)
   221  			if err != nil {
   222  				errs <- err
   223  				return
   224  			}
   225  		}
   226  	}()
   227  	return errs
   228  }
   229  
   230  func getAuth(credentials *model.Credential) string {
   231  	return base64.StdEncoding.EncodeToString([]byte(credentials.AccessKeyID + ":" + credentials.SecretAccessKey))
   232  }