Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions go-memory-load-mysql/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Go build outputs
/bin/
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test artifacts
*.test
*.out
coverage.txt
coverage.html

# Go vendor
vendor/

# IDE
.idea/
.vscode/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Docker
**/.git
2 changes: 2 additions & 0 deletions go-memory-load-mysql/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
APP_PORT=8080
MYSQL_DSN=app_user:app_password@tcp(localhost:3306)/orderdb?parseTime=true
18 changes: 18 additions & 0 deletions go-memory-load-mysql/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM golang:1.26-alpine AS build

WORKDIR /app

COPY go.mod go.sum* ./
RUN go mod download

COPY . .
RUN go build -o /bin/api ./cmd/api

FROM alpine:3.22

WORKDIR /app
COPY --from=build /bin/api /app/api

EXPOSE 8080

CMD ["/app/api"]
76 changes: 76 additions & 0 deletions go-memory-load-mysql/cmd/api/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Package main is the entry point for the load-test MySQL API server.
package main

import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"loadtestmysqlapi/internal/config"
"loadtestmysqlapi/internal/database"
"loadtestmysqlapi/internal/httpapi"
"loadtestmysqlapi/internal/store"
)

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))

cfg, err := config.Load()
if err != nil {
logger.Error("load config", "error", err)
os.Exit(1)
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

db, err := database.Open(ctx, cfg.MySQLDSN)
if err != nil {
logger.Error("connect mysql", "error", err)
os.Exit(1)
}
defer db.Close()
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defer db.Close() ignores the returned error. Other samples in this repo annotate this with //nolint:errcheck (or use _ = db.Close()), which avoids errcheck lint failures and makes the choice explicit.

Suggested change
defer db.Close()
defer func() {
_ = db.Close()
}()

Copilot uses AI. Check for mistakes.

if err := database.EnsureRuntimeSchema(ctx, db); err != nil {
logger.Error("ensure schema", "error", err)
os.Exit(1)
}

st := store.New(db)

handler := httpapi.New(st, logger)

server := &http.Server{
Addr: ":" + cfg.Port,
Handler: handler,
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}

go func() {
logger.Info("api listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("listen and serve", "error", err)
stop()
}
}()

<-ctx.Done()
logger.Info("shutdown signal received")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown", "error", err)
os.Exit(1)
}
}
46 changes: 46 additions & 0 deletions go-memory-load-mysql/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
services:
db:
image: mysql:8.0
container_name: load-test-mysql-db
environment:
MYSQL_DATABASE: orderdb
MYSQL_USER: app_user
MYSQL_PASSWORD: app_password
MYSQL_ROOT_PASSWORD: rootpassword
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "app_user", "--password=app_password"]
interval: 5s
timeout: 5s
retries: 20

api:
build:
context: .
container_name: load-test-mysql-api
environment:
APP_PORT: "8080"
MYSQL_DSN: "app_user:app_password@tcp(db:3306)/orderdb?parseTime=true&multiStatements=true&interpolateParams=true"
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy

k6:
image: grafana/k6:0.49.0
profiles: ["loadtest"]
environment:
BASE_URL: http://api:8080
volumes:
- ./loadtest:/scripts:ro
depends_on:
api:
condition: service_started
entrypoint: ["k6"]

volumes:
mysql_data:
7 changes: 7 additions & 0 deletions go-memory-load-mysql/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module loadtestmysqlapi

go 1.26

require github.com/go-sql-driver/mysql v1.9.2

require filippo.io/edwards25519 v1.1.0 // indirect
4 changes: 4 additions & 0 deletions go-memory-load-mysql/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
32 changes: 32 additions & 0 deletions go-memory-load-mysql/internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Package config loads runtime configuration from environment variables.
package config

import (
"fmt"
"os"
)

// Config holds all runtime configuration for the MySQL load-test API.
type Config struct {
Port string
MySQLDSN string
}

// Load reads configuration from environment variables and returns Config.
// Required: MYSQL_DSN.
func Load() (Config, error) {
dsn := os.Getenv("MYSQL_DSN")
if dsn == "" {
return Config{}, fmt.Errorf("MYSQL_DSN environment variable is required")
}

port := os.Getenv("APP_PORT")
if port == "" {
port = "8080"
}

return Config{
Port: port,
MySQLDSN: dsn,
}, nil
}
115 changes: 115 additions & 0 deletions go-memory-load-mysql/internal/database/mysql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Package database provides MySQL connection and schema helpers.
package database

import (
"context"
"database/sql"
"fmt"
"time"

_ "github.com/go-sql-driver/mysql" // register mysql driver
)

// Open creates a *sql.DB, verifies connectivity with retries, and applies the
// runtime schema. It returns the open DB handle; the caller must call db.Close().
Comment on lines +13 to +14
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment for Open says it "applies the runtime schema", but the function only opens the DB and waits for it to be ready (schema is applied later via EnsureRuntimeSchema). Update the comment to match behavior to avoid misleading callers.

Suggested change
// Open creates a *sql.DB, verifies connectivity with retries, and applies the
// runtime schema. It returns the open DB handle; the caller must call db.Close().
// Open creates a *sql.DB, configures it, and verifies connectivity with retries.
// It returns the open DB handle; the caller must call db.Close(). Apply the
// runtime schema separately with EnsureRuntimeSchema.

Copilot uses AI. Check for mistakes.
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)

// Retry loop — MySQL can take a few seconds to become ready.
const maxAttempts = 20
for attempt := 1; attempt <= maxAttempts; attempt++ {
if pingErr := db.PingContext(ctx); pingErr == nil {
break
} else if attempt == maxAttempts {
db.Close()
return nil, fmt.Errorf("mysql did not become ready after %d attempts: %w", maxAttempts, pingErr)
}
select {
case <-ctx.Done():
db.Close()
Comment on lines +32 to +37
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several db.Close() calls ignore the returned error. With errcheck enabled in this repo, prefer defer db.Close() //nolint:errcheck or _ = db.Close() (and similarly in the retry failure paths) to avoid lint failures and make intent explicit.

Suggested change
db.Close()
return nil, fmt.Errorf("mysql did not become ready after %d attempts: %w", maxAttempts, pingErr)
}
select {
case <-ctx.Done():
db.Close()
_ = db.Close()
return nil, fmt.Errorf("mysql did not become ready after %d attempts: %w", maxAttempts, pingErr)
}
select {
case <-ctx.Done():
_ = db.Close()

Copilot uses AI. Check for mistakes.
return nil, ctx.Err()
case <-time.After(2 * time.Second):
}
Comment on lines +26 to +40
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open() calls db.PingContext(ctx) directly in the retry loop without a per-attempt timeout; if a ping blocks, a single attempt can stall until the parent context is canceled. Consider using a short context.WithTimeout per attempt (similar to the Postgres sample) and canceling it each loop.

Copilot uses AI. Check for mistakes.
}

return db, nil
}

// EnsureRuntimeSchema creates all tables and indexes if they do not already exist.
func EnsureRuntimeSchema(ctx context.Context, db *sql.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS customers (
id CHAR(36) NOT NULL PRIMARY KEY,
email VARCHAR(320) NOT NULL,
full_name VARCHAR(255) NOT NULL,
segment VARCHAR(64) NOT NULL,
created_at DATETIME(3) NOT NULL,
UNIQUE KEY uq_customers_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

`CREATE TABLE IF NOT EXISTS products (
id CHAR(36) NOT NULL PRIMARY KEY,
sku VARCHAR(128) NOT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(128) NOT NULL,
price_cents INT NOT NULL,
inventory_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL,
UNIQUE KEY uq_products_sku (sku)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

`CREATE TABLE IF NOT EXISTS orders (
id CHAR(36) NOT NULL PRIMARY KEY,
customer_id CHAR(36) NOT NULL,
customer_email VARCHAR(320) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_segment VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
total_cents INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL,
KEY idx_orders_customer_created (customer_id, created_at),
KEY idx_orders_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

`CREATE TABLE IF NOT EXISTS order_items (
id CHAR(36) NOT NULL PRIMARY KEY,
order_id CHAR(36) NOT NULL,
product_id CHAR(36) NOT NULL,
sku VARCHAR(128) NOT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(128) NOT NULL,
quantity INT NOT NULL,
unit_price_cents INT NOT NULL,
line_total_cents INT NOT NULL,
KEY idx_order_items_order (order_id),
KEY idx_order_items_product (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

`CREATE TABLE IF NOT EXISTS large_payloads (
id CHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
payload LONGTEXT NOT NULL,
payload_size_bytes INT NOT NULL,
sha256 CHAR(64) NOT NULL,
created_at DATETIME(3) NOT NULL,
KEY idx_large_payloads_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}

for _, stmt := range statements {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
}

return nil
}
Loading
Loading