From 1330f8a325895fed8fc430504a3173454f7952ed Mon Sep 17 00:00:00 2001 From: Malhar Khimsaria <96malhar@gmail.com> Date: Thu, 7 Dec 2023 13:41:44 -0800 Subject: [PATCH] Add load testing task and expose database metrics --- Taskfile.yml | 38 +++++++++++++------- cmd/api/greenlight.http | 2 +- cmd/api/main.go | 37 +++++++++++++++++++ cmd/api/routes.go | 3 ++ cmd/api/users.go | 4 ++- load_test.sh | 79 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 15 deletions(-) create mode 100755 load_test.sh diff --git a/Taskfile.yml b/Taskfile.yml index 5947dd6..0fca3ec 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,6 +1,6 @@ version: '3' -dotenv: [env/.dev.env] +dotenv: [ env/.dev.env ] tasks: docker:postgres:start: @@ -42,14 +42,14 @@ tasks: db:migrations:up: desc: Runs up migrations cmds: - - echo "Running up migrations..." - - migrate -path ./migrations -database ${GREENLIGHT_DB_DSN} up + - echo "Running up migrations..." + - migrate -path ./migrations -database ${GREENLIGHT_DB_DSN} up db:migrations:down: desc: Runs down migrations cmds: - echo "Running down migrations..." - - migrate -path ./migrations -database ${GREENLIGHT_DB_DSN} down + - echo "y" | migrate -path ./migrations -database ${GREENLIGHT_DB_DSN} down server:help: desc: Show help for the Greenlight API server @@ -61,15 +61,6 @@ tasks: cmds: - go run ./cmd/api {{.CLI_ARGS}} - smoketest: - desc: Run the smoke test - dotenv: [env/.smoketest.env] - cmds: - - defer: go run ./cmd/db teardown - - go run ./cmd/db setup - - migrate -path ./migrations -database $GREENLIGHT_DB_DSN up - - ./smoke_test.sh - audit: desc: Run quality control audits cmds: @@ -84,7 +75,28 @@ tasks: - echo "Checking vulnerabilities..." - govulncheck ./... + smoketest: + desc: Run the smoke test + dotenv: [ env/.smoketest.env ] + cmds: + - defer: go run ./cmd/db teardown + - go run ./cmd/db setup + - migrate -path ./migrations -database $GREENLIGHT_DB_DSN up + - ./smoke_test.sh + test: desc: Run all tests cmds: - go test -json -v --coverprofile=coverage.out ./... 2>&1 | gotestfmt -hide "successful-tests, empty-packages" + + loadtest: + desc: Run the load test + summary: Creates a fresh database, runs migrations, and runs the load test. + The command uses 'Hey' to perform the load test. It registers a new user and then repeatedly + invokes the /v1/tokens/authentication endpoint to generate authentication tokens. + dotenv: [ env/.smoketest.env ] + cmds: + - defer: go run ./cmd/db teardown + - go run ./cmd/db setup + - migrate -path ./migrations -database $GREENLIGHT_DB_DSN up + - ./load_test.sh diff --git a/cmd/api/greenlight.http b/cmd/api/greenlight.http index f327d9a..9f3dc6d 100644 --- a/cmd/api/greenlight.http +++ b/cmd/api/greenlight.http @@ -38,4 +38,4 @@ Content-Type: application/json POST localhost:4000/v1/tokens/authentication Content-Type: application/json -{"email":"bob@gmail.com", "password":"5ecret1234"} +{"email":"alice@example.com", "password":"pa55word"} diff --git a/cmd/api/main.go b/cmd/api/main.go index 2a3963d..dc8365b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,12 +2,14 @@ package main import ( "context" + "expvar" "flag" "github.com/96malhar/greenlight/internal/data" "github.com/96malhar/greenlight/internal/email" "github.com/jackc/pgx/v5/pgxpool" "log/slog" "os" + "runtime" "strings" "sync" "time" @@ -85,6 +87,8 @@ func main() { modelStore: data.NewModelStore(db), } + monitorMetrics(db) + err = app.serve() if err != nil { logger.Error(err.Error()) @@ -129,6 +133,7 @@ func openDB(cfg config) (*pgxpool.Pool, error) { } pgxConf.MaxConnIdleTime = cfg.db.maxIdleTime pgxConf.MaxConns = int32(cfg.db.maxOpenConns) + db, err := pgxpool.NewWithConfig(context.Background(), pgxConf) if err != nil { return nil, err @@ -144,3 +149,35 @@ func openDB(cfg config) (*pgxpool.Pool, error) { return db, nil } + +func monitorMetrics(pool *pgxpool.Pool) { + expvar.NewString("version").Set(version) + + // Publish the number of active goroutines. + expvar.Publish("goroutines", expvar.Func(func() any { + return runtime.NumGoroutine() + })) + + //Publish the database connection pool statistics. + expvar.Publish("database", expvar.Func(func() any { + st := pool.Stat() + return map[string]any{ + "maximum_pool_size": st.MaxConns(), + "current_pool_size": st.TotalConns(), + "current_idle_conns": st.IdleConns(), + "current_constructing_conns": st.ConstructingConns(), + "current_acquired_conns": st.AcquiredConns(), + "total_acquired_conns": st.AcquireCount(), + "total_starved_acquired_conns": st.EmptyAcquireCount(), + "total_acquire_cancelled": st.CanceledAcquireCount(), + "total_acquired_duration": st.AcquireDuration().Seconds(), + "total_idle_closed": st.MaxIdleDestroyCount(), + "total_lifetime_closed": st.MaxLifetimeDestroyCount(), + } + })) + + // Publish the current Unix timestamp. + expvar.Publish("timestamp", expvar.Func(func() any { + return time.Now().Unix() + })) +} diff --git a/cmd/api/routes.go b/cmd/api/routes.go index 639ef44..a9a27c5 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -1,7 +1,9 @@ package main import ( + "expvar" "github.com/go-chi/chi/v5" + "net/http" ) // routes returns a new chi router containing the application routes. @@ -28,6 +30,7 @@ func (app *application) routes() *chi.Mux { }) r.Post("/v1/tokens/authentication", app.createAuthenticationTokenHandler) + r.Method(http.MethodGet, "/debug/vars", expvar.Handler()) return r } diff --git a/cmd/api/users.go b/cmd/api/users.go index be8eba6..1a16e85 100644 --- a/cmd/api/users.go +++ b/cmd/api/users.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "github.com/96malhar/greenlight/internal/data" "github.com/96malhar/greenlight/internal/validator" "net/http" @@ -73,7 +74,8 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque } err = app.mailer.Send(user.Email, "user_welcome.tmpl", userData) if err != nil { - app.logger.Error(err.Error()) + msg := fmt.Sprintf("Failed to send welcome email for new user (%s). Err = %s", user.Email, err.Error()) + app.logger.Error(msg) } }) diff --git a/load_test.sh b/load_test.sh new file mode 100755 index 0000000..3e1aa13 --- /dev/null +++ b/load_test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +cleanup() { + # Stop the web server if it is still running + echo "Stopping server with PID: $serverPid" + kill "$serverPid" + + # Clean up the binary + rm -rf ./bin +} + + +setup() { + # Build the Go binary + go build -o ./bin/server ./cmd/api + + # Start the web server in the background and discard all output + ./bin/server -limiter-enabled=false > /dev/null & + + # Capture the PID of the server process so we can kill it later + serverPid=$! + echo "Server PID: $serverPid" + + # wait for the server to start + sleep 2 + + # Register a new user + response=$(curl -X POST --location "http://localhost:4000/v1/users" \ + -H "Content-Type: application/json" \ + -d '{"name":"Alice", "email":"alice@example.com", "password":"pa55word"}' -D -) + + # check that the response code is 202 Accepted + status=$(echo "$response" | head -n 1 | cut -d$' ' -f2) + if [[ "$status" -ne 202 ]]; then + echo "Load test failed. Unexpected response code." + echo "Expected: 202" + echo "Actual : $status" + exit 1 + fi +} + +runLoadTest() { + echo "=====LOAD TEST STARTED=====" + hey -d '{"email": "alice@example.com", "password": "pa55word"}' \ + -m "POST" http://localhost:4000/v1/tokens/authentication + echo "=====LOAD TEST COMPLETED=====" +} + +# Register cleanup function on exit or error +trap cleanup EXIT ERR + +# Create global variable called PID +export serverPid=0 + +# Call the setup function +setup + +# start load test +runLoadTest + +while true; do + echo "Choose an option:" + echo "1. Exit the script" + echo "2. Re-run the load test" + read -r -p "Enter your choice (1 or 2): " choice + case $choice in + 1) + echo "Stopping the server and exiting the script" + exit 0 + ;; + 2) + echo "Re-running load test" + runLoadTest + ;; + *) + echo "Invalid choice, please choose 1 or 2." + ;; + esac +done