From b7228c393315ae450a8d39263d7a9824cb42510e Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Sat, 29 Jun 2024 02:59:47 +0400 Subject: [PATCH] =?UTF-8?q?wip:=20=F0=9F=94=95=20temporary=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/app.go | 2 + internal/cli/perftest/command.go | 203 +++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 internal/cli/perftest/command.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 37482bf..14fa860 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -11,6 +11,7 @@ import ( "gh.tarampamp.am/error-pages/internal/appmeta" "gh.tarampamp.am/error-pages/internal/cli/healthcheck" + "gh.tarampamp.am/error-pages/internal/cli/perftest" "gh.tarampamp.am/error-pages/internal/cli/serve" "gh.tarampamp.am/error-pages/internal/logger" ) @@ -77,6 +78,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen Commands: []*cli.Command{ serve.NewCommand(log), healthcheck.NewCommand(log, healthcheck.NewHTTPHealthChecker()), + perftest.NewCommand(log), }, Version: fmt.Sprintf("%s (%s)", appmeta.Version(), runtime.Version()), Flags: []cli.Flag{ // global flags diff --git a/internal/cli/perftest/command.go b/internal/cli/perftest/command.go new file mode 100644 index 0000000..733ec88 --- /dev/null +++ b/internal/cli/perftest/command.go @@ -0,0 +1,203 @@ +package perftest + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/urfave/cli/v3" + + "gh.tarampamp.am/error-pages/internal/cli/shared" + "gh.tarampamp.am/error-pages/internal/logger" +) + +// NewCommand creates `perftest` command. +func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit + var ( + portFlag = shared.ListenPortFlag + durationFlag = cli.DurationFlag{ + Name: "duration", + Aliases: []string{"d"}, + Usage: "duration of the test", + Value: 10 * time.Second, //nolint:mnd + Validator: func(d time.Duration) error { + if d <= time.Second { + return errors.New("duration can't be less than 1 second") + } + + return nil + }, + } + threadsFlag = cli.UintFlag{ + Name: "threads", + Aliases: []string{"t"}, + Usage: "number of threads", + Value: max(2, uint64(runtime.NumCPU()/2)), //nolint:mnd + Validator: func(u uint64) error { + if u == 0 { + return errors.New("threads number can't be zero") + } + + return nil + }, + } + ) + + return &cli.Command{ + Name: "perftest", + Aliases: []string{"perf", "test"}, + Hidden: true, + Usage: "Simple performance (load) test for the HTTP server", + Action: func(ctx context.Context, c *cli.Command) error { + var ( + perfCtx, cancel = context.WithTimeout(ctx, c.Duration(durationFlag.Name)) + startedAt = time.Now() + + wg sync.WaitGroup + success atomic.Uint64 + failed atomic.Uint64 + ) + + defer func() { + cancel() + + log.Info("Summary", + logger.Uint64("success", success.Load()), + logger.Uint64("failed", failed.Load()), + logger.Duration("duration", time.Since(startedAt)), + logger.Float64("RPS", float64(success.Load()+failed.Load())/time.Since(startedAt).Seconds()), + logger.Float64("errors rate", float64(failed.Load())/float64(success.Load()+failed.Load())*100), + ) + }() + + log.Info("Running test", + logger.Uint64("threads", c.Uint(threadsFlag.Name)), + logger.Duration("duration", c.Duration(durationFlag.Name)), + ) + + var httpClient = &http.Client{ + Transport: &http.Transport{MaxConnsPerHost: max(2, int(c.Uint(threadsFlag.Name))-1)}, //nolint:mnd + Timeout: c.Duration(durationFlag.Name) + time.Second, + } + + for i := uint64(0); i < c.Uint(threadsFlag.Name); i++ { + wg.Add(1) + + go func(log *logger.Logger) { + defer wg.Done() + + if perfCtx.Err() != nil { + return + } + + var req, rErr = makeRequest(perfCtx, uint16(c.Uint(portFlag.Name))) + if rErr != nil { + log.Error("failed to create a new request", logger.Error(rErr)) + + return + } + + for { + var sentAt = time.Now() + + var resp, respErr = httpClient.Do(req) + if resp != nil { + if _, err := io.Copy(io.Discard, resp.Body); err != nil && !errIsDone(err) { + log.Error("failed to read response body", logger.Error(err)) + } + + if err := resp.Body.Close(); err != nil && !errIsDone(err) { + log.Error("failed to close response body", logger.Error(err)) + } + } + + if respErr != nil { + if errIsDone(respErr) { + return + } + + log.Error("request failed", logger.Error(respErr)) + failed.Add(1) + + continue + } + + log.Debug("Response received", + logger.String("status", resp.Status), + logger.Duration("duration", time.Since(sentAt)), + logger.Int64("size", resp.ContentLength), + logger.Uint64("success", success.Load()), + logger.Uint64("failed", failed.Load()), + ) + + success.Add(1) + } + }(log.Named(fmt.Sprintf("thread-%d", i))) + } + + wg.Wait() + + return nil + }, + Flags: []cli.Flag{ + &portFlag, + &durationFlag, + &threadsFlag, + }, + } +} + +// randomIntBetween returns a random integer between min and max. +func randomIntBetween(min, max int) int { return min + rand.Intn(max-min) } //nolint:gosec + +// makeRequest creates a new HTTP request for the performance test. +func makeRequest(ctx context.Context, port uint16) (*http.Request, error) { + var req, rErr = http.NewRequestWithContext(ctx, + http.MethodGet, + fmt.Sprintf( + "http://127.0.0.1:%d/%d.html?rnd=%d", // for load testing purposes only + port, + randomIntBetween(400, 418), //nolint:mnd + randomIntBetween(1, 999_999_999), //nolint:mnd + ), + http.NoBody, + ) + + if rErr != nil { + return nil, rErr + } + + req.Header.Set("Connection", "keep-alive") + req.Header.Set("User-Agent", "perftest") + req.Header.Set("X-Namespace", fmt.Sprintf("namespace-%d", randomIntBetween(1, 999_999_999))) //nolint:mnd + req.Header.Set("X-Request-ID", fmt.Sprintf("req-id-%d", randomIntBetween(1, 999_999_999))) //nolint:mnd + + var contentType string + + switch randomIntBetween(1, 4) { //nolint:mnd + case 1: + contentType = "application/json" + case 2: //nolint:mnd + contentType = "application/xml" + case 3: //nolint:mnd + contentType = "text/html" + default: + contentType = "text/plain" + } + + req.Header.Set("Content-Type", contentType) + + return req, nil +} + +// errIsDone checks if the error is a context.DeadlineExceeded or context.Canceled. +func errIsDone(err error) bool { + return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) +}