From c4381c8ab2ce3b85f7655d29e65e6e32e2d6a5e6 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 | 37 +++++++++++++++--------- cmd/api/greenlight.http | 2 +- cmd/api/main.go | 37 ++++++++++++++++++++++++ cmd/api/routes.go | 3 ++ cmd/api/users.go | 4 ++- load_test.sh | 63 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 16 deletions(-) create mode 100755 load_test.sh diff --git a/Taskfile.yml b/Taskfile.yml index 5947dd6..1b83447 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 @@ -59,16 +59,7 @@ tasks: server:start: desc: Start the Greenlight API server 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 + - go run ./cmd/api {{.CLI_ARGS}} & audit: desc: Run quality control audits @@ -84,7 +75,25 @@ 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 + 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..2057318 --- /dev/null +++ b/load_test.sh @@ -0,0 +1,63 @@ +#!/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 +} + +# 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 +echo "=====LOAD TEST STARTED=====" +hey -d '{"email": "alice@example.com", "password": "pa55word"}' \ + -m "POST" http://localhost:4000/v1/tokens/authentication +echo "=====LOAD TEST COMPLETED=====" + +while true; do + read -p "Press enter to stop the server and exit " input + if [ -z "$input" ]; then + echo "Exiting script" + exit 0 + fi +done