Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# API load / smoke harness
|
||||
|
||||
Minimal stdlib Go harness for three critical read paths. Prefer this when **k6 / hey / vegeta** are not installed (none are required or vendored in this repo).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Name | Method | Path | Auth |
|
||||
|------|--------|------|------|
|
||||
| healthz | GET | `/healthz` | none |
|
||||
| feeds | GET | `/api/v1/feeds?limit=10&offset=0` | Bearer API key |
|
||||
| products | GET | `/api/v1/products?limit=10&offset=0` | Bearer API key |
|
||||
|
||||
Default base URL matches `HTTP_ADDR` (`:28471` → `http://127.0.0.1:28471`). Demo key: see `docs/demo-user.md` (`dk_demo_local_descrybe_test_key_v1`).
|
||||
|
||||
`RateLimitV1Process` applies only to heavy **POST** mutations (process / feed sync / export generate), not these GETs. DB and laptop CPU are still the real limits.
|
||||
|
||||
## RPS assumptions
|
||||
|
||||
| Profile | Flags | Intent | Aggregate RPS |
|
||||
|---------|-------|--------|---------------|
|
||||
| **smoke** (default) | `-c 2 -d 5s -rate 6` | Availability + latency sample; safe on local Postgres | **~6 RPS** capped (not “as fast as possible”) |
|
||||
| **load** (manual) | e.g. `-mode load -c 8 -d 30s -rate 40` | Staging / dedicated API only | Tens of RPS; watch p95 and Postgres |
|
||||
| **unlimited** | `-mode load -rate 0` | Intentional soak only | Uncapped — **do not** use against shared laptop DB |
|
||||
| **capacity** | external tool | Real soak / SLO | k6/hey/vegeta on staging |
|
||||
|
||||
ASSUMPTION: smoke is a correctness/latency probe, not a capacity claim. Concurrency alone does not cap RPS — **`-rate` does**. Smoke refuses `-rate 0`.
|
||||
|
||||
## Smoke (local)
|
||||
|
||||
API must already be running (`scripts/run-api.ps1` or equivalent).
|
||||
|
||||
```powershell
|
||||
cd f:\laragon\www\_MY\descrybe-v2\scripts\api-load-smoke
|
||||
go run .
|
||||
```
|
||||
|
||||
Overrides:
|
||||
|
||||
```powershell
|
||||
$env:API_BASE = "http://127.0.0.1:28471"
|
||||
$env:API_KEY = "dk_demo_local_descrybe_test_key_v1"
|
||||
cd f:\laragon\www\_MY\descrybe-v2\scripts\api-load-smoke
|
||||
go run . -c 2 -d 5s -rate 6 -mode smoke
|
||||
```
|
||||
|
||||
Exit code `1` if any non-cancelled request fails (`-fail-on-error=false` to only print stats).
|
||||
|
||||
## Larger tests
|
||||
|
||||
Still this harness (gentle step-up):
|
||||
|
||||
```powershell
|
||||
cd f:\laragon\www\_MY\descrybe-v2\scripts\api-load-smoke
|
||||
go run . -mode load -c 8 -d 30s -rate 40 -base http://127.0.0.1:28471
|
||||
```
|
||||
|
||||
Or external tools (install separately; point at **staging**):
|
||||
|
||||
```bash
|
||||
# hey — fixed request count
|
||||
hey -n 200 -c 8 -H "Authorization: Bearer $API_KEY" "$BASE/api/v1/products?limit=10"
|
||||
|
||||
# vegeta — rate explicitly
|
||||
echo "GET $BASE/healthz" | vegeta attack -duration=30s -rate=20 | vegeta report
|
||||
|
||||
# k6 — script your own scenarios; keep rate modest on shared DBs
|
||||
```
|
||||
|
||||
Related: `docs/api-surface-smoke.md` (functional matrix), `docs/perf-notes.md` (list-path notes), `scripts/v1-process-smoke` (legacy items create/process + poll).
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/descrybe/descrybe-v2/scripts/api-load-smoke
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,275 @@
|
||||
// Command api-load-smoke: short, low-concurrency HTTP smoke against critical API paths.
|
||||
//
|
||||
// Default profile is intentionally gentle (local laptop / shared Postgres).
|
||||
// It is NOT a capacity test — see README.md for larger profiles and RPS assumptions.
|
||||
//
|
||||
// Usage (API listening — default HTTP_ADDR :28471):
|
||||
//
|
||||
// cd scripts/api-load-smoke && go run .
|
||||
// go run . -base http://127.0.0.1:28471 -c 2 -d 5s -rate 6
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const demoAPIKey = "dk_demo_local_descrybe_test_key_v1"
|
||||
|
||||
// Smoke defaults — capped aggregate RPS so local Postgres is not hammered.
|
||||
const (
|
||||
defaultConcurrency = 2
|
||||
defaultDuration = 5 * time.Second
|
||||
defaultTimeout = 10 * time.Second
|
||||
defaultRateRPS = 6.0 // aggregate across all workers/endpoints
|
||||
defaultLimitQuery = "limit=10&offset=0"
|
||||
)
|
||||
|
||||
type endpoint struct {
|
||||
name string
|
||||
path string
|
||||
auth bool
|
||||
}
|
||||
|
||||
type stats struct {
|
||||
ok atomic.Int64
|
||||
fail atomic.Int64
|
||||
latMu sync.Mutex
|
||||
latNanos []int64
|
||||
}
|
||||
|
||||
func (s *stats) record(ok bool, d time.Duration) {
|
||||
if ok {
|
||||
s.ok.Add(1)
|
||||
} else {
|
||||
s.fail.Add(1)
|
||||
}
|
||||
s.latMu.Lock()
|
||||
s.latNanos = append(s.latNanos, d.Nanoseconds())
|
||||
s.latMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *stats) snapshot() (ok, fail int64, min, avg, p95, max time.Duration) {
|
||||
ok, fail = s.ok.Load(), s.fail.Load()
|
||||
s.latMu.Lock()
|
||||
defer s.latMu.Unlock()
|
||||
n := len(s.latNanos)
|
||||
if n == 0 {
|
||||
return ok, fail, 0, 0, 0, 0
|
||||
}
|
||||
cp := append([]int64(nil), s.latNanos...)
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
|
||||
var sum int64
|
||||
for _, v := range cp {
|
||||
sum += v
|
||||
}
|
||||
min = time.Duration(cp[0])
|
||||
max = time.Duration(cp[n-1])
|
||||
avg = time.Duration(sum / int64(n))
|
||||
idx := (n * 95) / 100
|
||||
if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
p95 = time.Duration(cp[idx])
|
||||
return ok, fail, min, avg, p95, max
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := flag.String("base", envOr("API_BASE", "http://127.0.0.1:28471"), "API base URL (no trailing slash)")
|
||||
key := flag.String("key", envOr("API_KEY", demoAPIKey), "API key for /api/v1 feeds+products (Bearer)")
|
||||
c := flag.Int("c", defaultConcurrency, "worker concurrency (smoke: 2; load: raise carefully)")
|
||||
d := flag.Duration("d", defaultDuration, "test duration (smoke: 5s)")
|
||||
rate := flag.Float64("rate", defaultRateRPS, "max aggregate requests/sec (0 = unlimited; smoke default 6)")
|
||||
timeout := flag.Duration("timeout", defaultTimeout, "per-request timeout")
|
||||
mode := flag.String("mode", "smoke", "smoke|load (banner + safety hint; still honor -c/-d/-rate)")
|
||||
failOnErr := flag.Bool("fail-on-error", true, "exit 1 if any request fails")
|
||||
flag.Parse()
|
||||
|
||||
if *c < 1 {
|
||||
fmt.Fprintln(os.Stderr, "-c must be >= 1")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *d <= 0 {
|
||||
fmt.Fprintln(os.Stderr, "-d must be > 0")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *rate < 0 {
|
||||
fmt.Fprintln(os.Stderr, "-rate must be >= 0")
|
||||
os.Exit(2)
|
||||
}
|
||||
if strings.EqualFold(*mode, "smoke") && *rate == 0 {
|
||||
fmt.Fprintln(os.Stderr, "refusing -rate 0 in smoke mode (would DDOS local stack); use -mode load -rate 0 intentionally")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(*base), "/")
|
||||
endpoints := []endpoint{
|
||||
{name: "healthz", path: "/healthz", auth: false},
|
||||
{name: "feeds", path: "/api/v1/feeds?" + defaultLimitQuery, auth: true},
|
||||
{name: "products", path: "/api/v1/products?" + defaultLimitQuery, auth: true},
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: *timeout}
|
||||
st := make([]*stats, len(endpoints))
|
||||
for i := range st {
|
||||
st[i] = &stats{}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *d)
|
||||
defer cancel()
|
||||
|
||||
tokens := startRateGate(ctx, *rate)
|
||||
|
||||
fmt.Printf("api-load-smoke mode=%s base=%s c=%d d=%s rate=%.1f\n", *mode, baseURL, *c, d.String(), *rate)
|
||||
fmt.Println("ASSUMPTION: local smoke defaults to ~6 aggregate RPS (c=2, 5s); raise -rate/-c only on dedicated/staging.")
|
||||
if strings.EqualFold(*mode, "load") {
|
||||
fmt.Println("NOTE: prefer staging. GET lists are not under RateLimitV1Process (mutations only); DB is still the bottleneck.")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
for w := 0; w < *c; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
i := 0
|
||||
for {
|
||||
if !waitToken(ctx, tokens) {
|
||||
return
|
||||
}
|
||||
ep := endpoints[i%len(endpoints)]
|
||||
si := st[i%len(endpoints)]
|
||||
i++
|
||||
ok, lat, cancelled := doRequest(ctx, client, baseURL, *key, ep)
|
||||
if cancelled {
|
||||
return
|
||||
}
|
||||
si.record(ok, lat)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
var totalOK, totalFail int64
|
||||
fmt.Println()
|
||||
fmt.Printf("%-10s %8s %8s %10s %10s %10s %10s\n", "endpoint", "ok", "fail", "min", "avg", "p95", "max")
|
||||
for i, ep := range endpoints {
|
||||
ok, fail, min, avg, p95, max := st[i].snapshot()
|
||||
totalOK += ok
|
||||
totalFail += fail
|
||||
fmt.Printf("%-10s %8d %8d %10s %10s %10s %10s\n",
|
||||
ep.name, ok, fail, fmtDur(min), fmtDur(avg), fmtDur(p95), fmtDur(max))
|
||||
}
|
||||
total := totalOK + totalFail
|
||||
rps := 0.0
|
||||
if elapsed > 0 {
|
||||
rps = float64(total) / elapsed.Seconds()
|
||||
}
|
||||
fmt.Printf("\ntotal_ok=%d total_fail=%d elapsed=%s approx_rps=%.1f\n", totalOK, totalFail, elapsed.Round(time.Millisecond), rps)
|
||||
|
||||
if totalFail > 0 && *failOnErr {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// startRateGate returns nil for unlimited; otherwise a token channel paced at rate RPS.
|
||||
func startRateGate(ctx context.Context, rate float64) <-chan struct{} {
|
||||
if rate <= 0 {
|
||||
return nil
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
interval := time.Duration(float64(time.Second) / rate)
|
||||
if interval < time.Millisecond {
|
||||
interval = time.Millisecond
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
// First token immediately so smoke does not wait a full interval.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- struct{}{}:
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- struct{}{}:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func waitToken(ctx context.Context, tokens <-chan struct{}) bool {
|
||||
if tokens == nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-tokens:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func doRequest(ctx context.Context, client *http.Client, base, key string, ep endpoint) (ok bool, lat time.Duration, cancelled bool) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+ep.path, nil)
|
||||
if err != nil {
|
||||
return false, 0, ctx.Err() != nil
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if ep.auth {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
t0 := time.Now()
|
||||
res, err := client.Do(req)
|
||||
lat = time.Since(t0)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return false, lat, true
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s error: %v\n", ep.name, err)
|
||||
return false, lat, false
|
||||
}
|
||||
defer res.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
fmt.Fprintf(os.Stderr, "%s status=%d\n", ep.name, res.StatusCode)
|
||||
return false, lat, false
|
||||
}
|
||||
return true, lat, false
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func fmtDur(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "-"
|
||||
}
|
||||
if d < time.Millisecond {
|
||||
return d.Round(time.Microsecond).String()
|
||||
}
|
||||
return d.Round(time.Millisecond).String()
|
||||
}
|
||||
Reference in New Issue
Block a user