diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 235eb45..3283130 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -64,7 +64,7 @@ jobs: if [ "$files" != "__DIFF_FAILED__" ]; then echo "changed files:"; echo "$files" go=false; ui=false - if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|go\.work)'; then go=true; fi + if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi if echo "$files" | grep -qE '^ui/'; then ui=true; fi # A workflow or deploy change re-runs everything as a safety net. if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi @@ -112,15 +112,15 @@ jobs: fi - name: vet - run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... + run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/... - name: build - run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... + run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/... - name: test env: BACKEND_DICT_DIR: ${{ github.workspace }}/dawg - run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... + run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/... integration: needs: changes diff --git a/CLAUDE.md b/CLAUDE.md index ede6bc0..ba32697 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,8 +126,9 @@ backend/ # module scrabble/backend docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md gateway/ ui/ pkg/ # added by their stages platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API -backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile # multi-stage distroless (Stage 16) -deploy/ # docker-compose + caddy + otelcol/prometheus/tempo/grafana (Stage 16) +loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2) +backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2) +deploy/ # docker-compose + caddy + otelcol/prometheus/tempo/grafana (+ cAdvisor/postgres_exporter, R2) ``` ## Build & test diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 86ef2e2..cb071f6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -225,6 +225,38 @@ services: - grafana-data:/var/lib/grafana networks: [internal] + # cAdvisor exports per-container resource metrics (CPU / memory / network / disk) + # for the R2/R7 stress runs' resource baseline. Prometheus scrapes it at :8080 + # over the internal network. It needs read access to the host's cgroup and + # container state; --docker_only trims non-container cgroup series. + cadvisor: + container_name: scrabble-cadvisor + image: gcr.io/cadvisor/cadvisor:v0.49.1 + restart: unless-stopped + privileged: true + command: ["--docker_only=true", "--housekeeping_interval=15s"] + devices: + - /dev/kmsg + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + networks: [internal] + + # postgres_exporter exports Postgres server metrics (connections, cache hit ratio, + # transactions, database size). Prometheus scrapes it at :9187. The DSN reuses the + # contour Postgres credentials; sslmode=disable on the internal network. + postgres_exporter: + container_name: scrabble-postgres-exporter + image: prometheuscommunity/postgres-exporter:v0.16.0 + restart: unless-stopped + depends_on: [postgres] + environment: + DATA_SOURCE_NAME: postgresql://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable + networks: [internal] + networks: internal: name: scrabble-internal diff --git a/deploy/grafana/dashboards/resources.json b/deploy/grafana/dashboards/resources.json new file mode 100644 index 0000000..425f88d --- /dev/null +++ b/deploy/grafana/dashboards/resources.json @@ -0,0 +1,84 @@ +{ + "uid": "scrabble-resources", + "title": "Scrabble — Resources", + "tags": ["scrabble"], + "timezone": "", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "type": "stat", + "title": "Postgres connections", + "description": "Backends connected to the scrabble database (postgres_exporter).", + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(pg_stat_database_numbackends{datname=\"scrabble\"})" }] + }, + { + "type": "stat", + "title": "Postgres cache hit ratio", + "description": "blks_hit / (blks_hit + blks_read) over 5m.", + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 }, + "fieldConfig": { "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(pg_stat_database_blks_hit{datname=\"scrabble\"}[5m])) / clamp_min(sum(rate(pg_stat_database_blks_hit{datname=\"scrabble\"}[5m])) + sum(rate(pg_stat_database_blks_read{datname=\"scrabble\"}[5m])), 1)" }] + }, + { + "type": "stat", + "title": "Postgres commits/s", + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(pg_stat_database_xact_commit{datname=\"scrabble\"}[5m]))" }] + }, + { + "type": "stat", + "title": "Database size", + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "max(pg_database_size_bytes{datname=\"scrabble\"})" }] + }, + { + "type": "timeseries", + "title": "Container CPU (cores) by container", + "description": "cAdvisor container_cpu_usage_seconds_total rate, per scrabble-* container (the load harness appears when run as --name scrabble-loadtest). Verify the metric name against live Prometheus if empty.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(container_cpu_usage_seconds_total{name=~\"scrabble-.+\"}[5m])) by (name)", "legendFormat": "{{name}}" }] + }, + { + "type": "timeseries", + "title": "Container memory (working set) by container", + "description": "cAdvisor container_memory_working_set_bytes, per scrabble-* container.", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "max(container_memory_working_set_bytes{name=~\"scrabble-.+\"}) by (name)", "legendFormat": "{{name}}" }] + }, + { + "type": "timeseries", + "title": "Container network I/O by container", + "description": "cAdvisor receive (+) and transmit (-) byte rates per scrabble-* container.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, + "fieldConfig": { "defaults": { "unit": "Bps" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "sum(rate(container_network_receive_bytes_total{name=~\"scrabble-.+\"}[5m])) by (name)", "legendFormat": "rx {{name}}" }, + { "refId": "B", "expr": "-sum(rate(container_network_transmit_bytes_total{name=~\"scrabble-.+\"}[5m])) by (name)", "legendFormat": "tx {{name}}" } + ] + }, + { + "type": "timeseries", + "title": "Postgres transactions/s", + "description": "Commit and rollback rates on the scrabble database (postgres_exporter).", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "sum(rate(pg_stat_database_xact_commit{datname=\"scrabble\"}[5m]))", "legendFormat": "commit" }, + { "refId": "B", "expr": "sum(rate(pg_stat_database_xact_rollback{datname=\"scrabble\"}[5m]))", "legendFormat": "rollback" } + ] + } + ] +} diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml index af5313c..1cecf13 100644 --- a/deploy/prometheus/prometheus.yml +++ b/deploy/prometheus/prometheus.yml @@ -12,3 +12,12 @@ scrape_configs: - job_name: prometheus static_configs: - targets: ["localhost:9090"] + # Container resource metrics (CPU/memory/network/disk) for every contour + # container, for the R2/R7 stress runs' resource baseline. + - job_name: cadvisor + static_configs: + - targets: ["cadvisor:8080"] + # Postgres server metrics (connections, cache hit ratio, transactions, db size). + - job_name: postgres_exporter + static_configs: + - targets: ["postgres_exporter:9187"] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1a922cd..6b6e0f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -541,7 +541,11 @@ promotions) is future work and would deliver short markdown messages (text + lin metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana** (provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth) are stood up with the deploy (`deploy/`, Stage 16); the default exporter stays - `none`, so CI needs no collector. + `none`, so CI needs no collector. The contour also runs **cAdvisor** (per-container + CPU/memory/network) and **postgres_exporter** (connections, cache-hit ratio, + transactions, db size), scraped by Prometheus and surfaced on the **Scrabble — + Resources** Grafana dashboard (R2), so the pre-release stress runs capture a resource + baseline; these export directly in Prometheus format (not through the collector). - Per-request server-side timing via gin middleware from day one (the access log carries method, route, status, latency and the active trace id). A client-measured RTT piggybacked on the next request is a later enhancement. diff --git a/docs/TESTING.md b/docs/TESTING.md index 0244883..2319a8e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -103,6 +103,20 @@ tests or touching CI. `otlp` now accepted, an unsupported exporter rejected) and the guest-reaper knobs. Postgres-backed `inttest` drives the **guest reaper** end to end (an abandoned guest is reaped; a too-young guest, a seated guest and a durable account are kept). +- **Load test & resource baseline** *(R2)* — a reusable `loadtest/` module + (`scrabble/loadtest`) is the pre-release stress harness. It **seeds** a large account + population with pre-created sessions directly in Postgres (token hashes matching + `backend/internal/session`), **drives** virtual players through the edge protocol — + real games assembled via invitations, **mid-ranked** legal moves generated locally by + the embedded `scrabble-solver` (the edge carries no board, so the client replays + history) — plus a fraction of nudge/chat/check-word/draft/profile/stats ops, and a + **gateway-hammer** that verifies the rate limiter. Its own Go unit tests cover the pure + pieces (token hashing, board replay vs. `board.Parse`, rack reconstruction, mid-rank + selection, the report); the DAWG-backed move test runs under `BACKEND_DICT_DIR` (as the + engine tests do). It is **not** part of the per-PR suite's behavioural assertions: it + runs ad hoc as a one-shot container against the contour, producing a trip report (bugs + + a resource baseline) read off the **cAdvisor + postgres_exporter** Grafana dashboard + added to the contour in R2. See [`../loadtest/README.md`](../loadtest/README.md). ## Principles diff --git a/go.work b/go.work index 44632fa..9eaad93 100644 --- a/go.work +++ b/go.work @@ -4,6 +4,7 @@ use ./backend use ( ./gateway + ./loadtest ./pkg ./platform/telegram ) @@ -19,3 +20,8 @@ use ( // like scrabble-solver it cannot be fetched as a versioned dependency; the // replace points the v0.0.0 require at the in-repo module directory. replace scrabble/pkg v0.0.0 => ./pkg + +// scrabble/gateway is required by the loadtest harness for the generated edge +// Connect client and proto envelope (gateway/proto/edge). Same dot-free reason as +// scrabble/pkg — the replace points its v0.0.0 require at the in-repo directory. +replace scrabble/gateway v0.0.0 => ./gateway diff --git a/go.work.sum b/go.work.sum index 2829bbd..70cec45 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,38 +1,78 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 h1:ntN6m4cOB+4FelleO2nkAIZp8WSc+v25neetzfdUuuw= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.45.0 h1:iHt15nA4iYhfde5bDQAcLAat9BAh7B5ksPRNRa4UI7s= github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= +github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= +github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk= github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-kit/log v0.1.0 h1:DGJh0Sm43HbOeYDNnVZFl8BvcYVvjD5bqYJvp0REbwQ= +github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps= github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk= github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw= github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4= @@ -40,59 +80,118 @@ github.com/iliadenisov/dafsa v1.1.0/go.mod h1:mG6Y0DdfRrqdXGqTEMb9Zx0Fl0NkP3ZDYe github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= +github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM= github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus= +github.com/moby/sys/mount v0.3.4 h1:yn5jq4STPztkkzSKpZkLcmjue+bZJ0u2AuQY1iNI1Ww= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/reexec v0.1.0 h1:RrBi8e0EBTLEgfruBOFcxtElzRGTEUkeIFaVXgU7wok= github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= +github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= +github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY= +github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= +github.com/vertica/vertica-sql-go v1.3.6 h1:uDJPdBivsI5EwfX3NMDWZaQlVs9zTnVyxE/nYhr3bY0= github.com/vertica/vertica-sql-go v1.3.6/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/volatiletech/inflect v0.0.1 h1:2a6FcMQyhmPZcLa+uet3VJ8gLn/9svWhJxJYwvE8KsU= github.com/volatiletech/inflect v0.0.1/go.mod h1:IBti31tG6phkHitLlr5j7shC5SOo//x0AjDzaJU1PLA= +github.com/volatiletech/null/v8 v8.1.2 h1:kiTiX1PpwvuugKwfvUNX/SU/5A2KGZMXfGD0DUHdKEI= github.com/volatiletech/null/v8 v8.1.2/go.mod h1:98DbwNoKEpRrYtGjWFctievIfm4n4MxG0A6EBUcoS5g= +github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5sbWC4nMk= github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY= +github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk= github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180 h1:avIdi8eGXjKbn1WLokNR1Ofnz1k8t7tJ88YQLD/iCi8= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0 h1:8c/M2B5W5xJd968DCedPv7DvQHuKzzhGGsO6J9x2+5U= github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/zenazn/goji v0.9.0 h1:RSQQAbXGArQ0dIDEq+PI6WqN6if+5KHu6x2Cx/GXLTQ= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= +gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg= gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec h1:RlWgLqCMMIYYEVcAR5MDsuHlVkaIPDAF+5Dehzg8L5A= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/loadtest/Dockerfile b/loadtest/Dockerfile new file mode 100644 index 0000000..acb4787 --- /dev/null +++ b/loadtest/Dockerfile @@ -0,0 +1,43 @@ +# Multi-stage build for the R2 load harness. Mirrors backend/Dockerfile: a +# golang-alpine builder yields a static binary on distroless nonroot, with the +# dictionary DAWGs baked in from the scrabble-dictionary release (the harness runs +# the same solver as the backend, so it needs the same dictionary). The published +# scrabble-solver module is fetched from Gitea (GOPRIVATE), so the build stage needs +# git and network. +# +# The harness is not a contour service; build and run it ad hoc, from the repo root +# so go.work, pkg/, gateway/ and loadtest/ are in the Docker context: +# docker build -f loadtest/Dockerfile -t scrabble-loadtest . +# docker run --rm --network scrabble-internal -e POSTGRES_PASSWORD=... scrabble-loadtest run + +# --- dictionary artifact ----------------------------------------------------- +FROM alpine:3.20 AS dawg +ARG DICT_VERSION=v1.0.0 +RUN apk add --no-cache curl tar +RUN mkdir -p /dawg \ + && curl -fsSL -o /tmp/dawg.tar.gz \ + "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \ + && tar xzf /tmp/dawg.tar.gz -C /dawg + +# --- build ------------------------------------------------------------------- +FROM golang:1.26.3-alpine AS build +WORKDIR /src +# git: the published solver module is fetched from Gitea directly (GOPRIVATE). +RUN apk add --no-cache git +ENV GOPRIVATE=gitea.iliadenisov.ru/* + +COPY go.work go.work.sum ./ +COPY pkg ./pkg +COPY gateway ./gateway +COPY loadtest ./loadtest + +# Reduce the workspace to what the harness needs: loadtest + gateway (edge proto) + pkg. +RUN go work edit -dropuse=./backend -dropuse=./platform/telegram +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/loadtest ./loadtest/cmd/loadtest + +# --- runtime ----------------------------------------------------------------- +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/loadtest /usr/local/bin/loadtest +COPY --from=dawg /dawg /opt/dawg +ENV LOADTEST_DAWG_DIR=/opt/dawg +ENTRYPOINT ["/usr/local/bin/loadtest"] diff --git a/loadtest/README.md b/loadtest/README.md new file mode 100644 index 0000000..96f639c --- /dev/null +++ b/loadtest/README.md @@ -0,0 +1,94 @@ +# loadtest — R2 stress harness + +Reusable load harness for the pre-release stress pass (`PRERELEASE.md` R2/R7). It +seeds a large account population with pre-created sessions, drives virtual players +through the **gateway edge protocol** in realistic games, hammers the rate limiter, +and prints a trip-report summary. It stays in the repo for repeats. + +## What it does + +1. **Seed** (direct Postgres, schema `backend`): inserts `--durable` durable accounts + (each with a confirmed email identity) + `--guest` guest accounts and an active + `sessions` row per account, then hands the plaintext bearer tokens to the driver. + Token hashes match `backend/internal/session` (`hex(sha256(token))`), so the seeded + sessions resolve. Every row is tagged with the `lt:` marker for cleanup. +2. **Drive** (edge protocol over h2c): assembles real 2–4 player games via the + invitation flow (`invitation.create` → `invitation.accept`, no robots), then runs + each player's turn loop — poll `game.state`, replay `game.history`, generate a legal + **mid-ranked** move with the embedded `scrabble-solver`, and `game.submit_play` + (or pass/exchange). A fraction of turns exercise nudge / chat / check-word / draft / + profile-update / stats. Each player also holds a live `Subscribe` stream. The + moderate ramp is **50 → 200 → 500** concurrent players, ~12 min per step. +3. **Hammer**: drives `games.list` from one account far above the per-user rate limit + to verify the limiter holds (`rate_limited` results) and measure its cost. +4. **Report**: per-operation latency percentiles, throughput, result-code breakdown, + live-event tally and the aggregate error rate. + +The driver runs the solver **locally** because the edge protocol carries no board: the +client reconstructs it from decoded history (the same invariant as the UI). + +## Connection model + +The harness reaches Postgres and the gateway directly, so run it as a one-shot +container on the contour's docker network (this bypasses the host→gateway hairpin): + +```sh +# from the repo root +docker build -f loadtest/Dockerfile -t scrabble-loadtest . + +docker run --rm --name scrabble-loadtest --network scrabble-internal \ + -e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \ + scrabble-loadtest run +``` + +Defaults assume the contour service names: `postgres:5432` and `gateway:8081`. The +DAWGs are baked into the image (`/opt/dawg`, pinned to the dictionary release). Run with +`--name scrabble-loadtest` so the harness's own CPU/memory show up as a `scrabble-*` +series in cAdvisor (keeping it separable from the system under test). Capture the +resource baseline from the Grafana **Scrabble — Resources** dashboard +(cAdvisor + postgres_exporter) while the run is in progress. + +## Commands & flags + +``` +loadtest run [flags] seed, drive the ramp + hammer, print the report +loadtest cleanup [flags] delete everything the harness seeded (matched by the lt: marker) +``` + +Key `run` flags (env in parentheses): + +| flag | default | meaning | +|------|---------|---------| +| `--gateway` (`LOADTEST_GATEWAY_URL`) | `http://gateway:8081` | gateway base URL | +| `--dsn` (`LOADTEST_DSN`) | from `POSTGRES_*` | backend Postgres DSN (schema `backend`) | +| `--dawg` (`LOADTEST_DAWG_DIR`) | `/dawg` (image: `/opt/dawg`) | committed `*.dawg` directory | +| `--durable` / `--guest` | `10000` / `1000` | accounts to seed | +| `--steps` | `50,200,500` | concurrent-player ramp steps | +| `--step-dur` | `12m` | hold time per step | +| `--games-per-player` | `0` (random 3–5) | target concurrent games per player | +| `--tick` | `800ms` | per-player op cadence (keeps a player under the per-user limit) | +| `--secondary-prob` | `0.08` | chance per tick of a non-move op | +| `--hammer-workers` / `--hammer-dur` | `20` / `15s` | gateway-hammer (0 workers disables) | +| `--reset` / `--cleanup` | `false` | delete harness rows before / after the run | + +`run` re-seeds every time (plaintext tokens are never stored), so pass `--reset` to +clear a prior run's rows first. The authoritative hard reset of the contour remains the +DB wipe (`DROP SCHEMA backend CASCADE` + backend restart). + +## Build & test + +```sh +go build ./loadtest/... +go vet ./loadtest/... +BACKEND_DICT_DIR=../scrabble-solver/dawg go test -count=1 ./loadtest/... +``` + +The DAWG-backed `moves` test runs only when `BACKEND_DICT_DIR` is set (as the engine +tests use); the pure logic (hashing, board replay, rack build, move selection, report) +runs unconditionally. + +## Caveat + +The harness shares the host CPU with the contour, so the early-pass resource baseline +is read with the harness's own container series in mind; a cleaner number on separate +hardware is an R7 goal. The moderate ramp keeps the generator from being the bottleneck. diff --git a/loadtest/cmd/loadtest/main.go b/loadtest/cmd/loadtest/main.go new file mode 100644 index 0000000..43b0fa0 --- /dev/null +++ b/loadtest/cmd/loadtest/main.go @@ -0,0 +1,193 @@ +// Command loadtest is the R2 reusable load harness. It seeds a large account +// population with pre-created sessions directly in the backend Postgres, then drives +// virtual players through the gateway edge protocol (real games assembled via +// invitations, legal moves generated locally by the embedded solver), and a +// gateway-hammer that verifies the rate limiter. It prints a trip-report summary. +// +// Run it as a one-shot container on the contour's docker network so it reaches +// postgres:5432 and gateway:8081 directly: +// +// docker run --rm --network scrabble-internal \ +// -e POSTGRES_PASSWORD=... -v /path/to/dawg:/dawg scrabble-loadtest run +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "scrabble/loadtest/internal/edge" + "scrabble/loadtest/internal/moves" + "scrabble/loadtest/internal/report" + "scrabble/loadtest/internal/scenario" + "scrabble/loadtest/internal/seed" +) + +func main() { + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var err error + switch os.Args[1] { + case "run": + err = cmdRun(ctx, log, os.Args[2:]) + case "cleanup": + err = cmdCleanup(ctx, log, os.Args[2:]) + default: + usage() + os.Exit(2) + } + if err != nil { + log.Error("loadtest failed", "cmd", os.Args[1], "err", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: loadtest [flags]") + fmt.Fprintln(os.Stderr, " run seed accounts, drive the realistic ramp + gateway-hammer, print the report") + fmt.Fprintln(os.Stderr, " cleanup delete everything the harness seeded (matched by marker)") +} + +func cmdRun(ctx context.Context, log *slog.Logger, args []string) error { + fs := flag.NewFlagSet("run", flag.ExitOnError) + gateway := fs.String("gateway", env("LOADTEST_GATEWAY_URL", "http://gateway:8081"), "gateway base URL") + dsn := fs.String("dsn", env("LOADTEST_DSN", defaultDSN()), "backend Postgres DSN") + dawgDir := fs.String("dawg", env("LOADTEST_DAWG_DIR", "/dawg"), "directory holding the committed *.dawg files") + durable := fs.Int("durable", 10000, "durable accounts to seed") + guest := fs.Int("guest", 1000, "guest accounts to seed") + stepsStr := fs.String("steps", "50,200,500", "comma-separated concurrent-player ramp steps") + stepDur := fs.Duration("step-dur", 12*time.Minute, "hold time per ramp step") + gpp := fs.Int("games-per-player", 0, "target concurrent games per player (0 => random 3..5)") + tick := fs.Duration("tick", 800*time.Millisecond, "per-player operation cadence") + secProb := fs.Float64("secondary-prob", 0.08, "chance per tick of a non-move operation") + hammerWorkers := fs.Int("hammer-workers", 20, "gateway-hammer concurrent callers (0 disables)") + hammerDur := fs.Duration("hammer-dur", 15*time.Second, "gateway-hammer duration") + reset := fs.Bool("reset", false, "delete prior harness rows before seeding") + doCleanup := fs.Bool("cleanup", false, "delete harness rows after the run") + if err := fs.Parse(args); err != nil { + return err + } + steps, err := parseSteps(*stepsStr) + if err != nil { + return err + } + + reg, err := moves.Open(*dawgDir) + if err != nil { + return err + } + defer reg.Close() + + sd, err := seed.New(ctx, *dsn) + if err != nil { + return err + } + defer sd.Close() + + if *reset { + n, err := sd.Cleanup(ctx) + if err != nil { + return err + } + log.Info("reset", "accounts_removed", n) + } + + log.Info("seeding", "durable", *durable, "guest", *guest) + pool, err := sd.Seed(ctx, *durable, *guest) + if err != nil { + return err + } + log.Info("seeded", "durable", len(pool.Durables), "guest", len(pool.Guests)) + + rec := report.New() + drv := scenario.NewDriver(edge.New(*gateway), reg, rec, log) + cfg := scenario.RealisticConfig{ + Steps: steps, StepDur: *stepDur, GamesPerPlayer: *gpp, + Tick: *tick, SecondaryProb: *secProb, + } + if err := drv.RunRealistic(ctx, pool, cfg); err != nil && !errors.Is(err, context.Canceled) { + return err + } + + if *hammerWorkers > 0 && ctx.Err() == nil && len(pool.Durables) > 0 { + drv.Hammer(ctx, pool.Durables[0], scenario.HammerConfig{Workers: *hammerWorkers, Duration: *hammerDur}) + } + + fmt.Println("\n==== R2 load-test report ====") + fmt.Println(rec.Summary()) + + if *doCleanup { + n, err := sd.Cleanup(context.WithoutCancel(ctx)) + if err != nil { + return err + } + log.Info("cleanup", "accounts_removed", n) + } + return nil +} + +func cmdCleanup(ctx context.Context, log *slog.Logger, args []string) error { + fs := flag.NewFlagSet("cleanup", flag.ExitOnError) + dsn := fs.String("dsn", env("LOADTEST_DSN", defaultDSN()), "backend Postgres DSN") + if err := fs.Parse(args); err != nil { + return err + } + sd, err := seed.New(ctx, *dsn) + if err != nil { + return err + } + defer sd.Close() + n, err := sd.Cleanup(ctx) + if err != nil { + return err + } + log.Info("cleanup", "accounts_removed", n) + return nil +} + +// defaultDSN builds the backend Postgres DSN from the standard POSTGRES_* env the +// contour uses, pinning the backend schema. +func defaultDSN() string { + return fmt.Sprintf("postgres://%s:%s@%s:5432/%s?sslmode=disable&search_path=backend", + env("POSTGRES_USER", "scrabble"), os.Getenv("POSTGRES_PASSWORD"), + env("POSTGRES_HOST", "postgres"), env("POSTGRES_DB", "scrabble")) +} + +// env returns the environment variable key or def when it is unset/empty. +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// parseSteps parses a comma-separated list of positive ramp step sizes. +func parseSteps(s string) ([]int, error) { + parts := strings.Split(s, ",") + steps := make([]int, 0, len(parts)) + for _, p := range parts { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil || n <= 0 { + return nil, fmt.Errorf("invalid ramp steps %q", s) + } + steps = append(steps, n) + } + if len(steps) == 0 { + return nil, fmt.Errorf("no ramp steps") + } + return steps, nil +} diff --git a/loadtest/go.mod b/loadtest/go.mod new file mode 100644 index 0000000..847ef76 --- /dev/null +++ b/loadtest/go.mod @@ -0,0 +1,16 @@ +module scrabble/loadtest + +go 1.26.3 + +require ( + connectrpc.com/connect v1.19.2 + gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 + github.com/google/flatbuffers v23.5.26+incompatible + github.com/google/uuid v1.6.0 + github.com/iliadenisov/dafsa v1.1.0 + github.com/jackc/pgx/v5 v5.9.2 + golang.org/x/net v0.53.0 + google.golang.org/protobuf v1.36.11 + scrabble/gateway v0.0.0 + scrabble/pkg v0.0.0 +) diff --git a/loadtest/internal/edge/client.go b/loadtest/internal/edge/client.go new file mode 100644 index 0000000..a40ec8d --- /dev/null +++ b/loadtest/internal/edge/client.go @@ -0,0 +1,136 @@ +// Package edge is the load harness's client of the gateway edge protocol: the +// Connect Execute envelope carrying FlatBuffers payloads, plus the Subscribe live +// stream, over h2c. It exposes typed wrappers for the operations the driver +// exercises, decoding responses into plain Go structs so the scenario layer never +// touches FlatBuffers directly. +package edge + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "time" + + "connectrpc.com/connect" + "golang.org/x/net/http2" + + edgev1 "scrabble/gateway/proto/edge/v1" + "scrabble/gateway/proto/edge/v1/edgev1connect" +) + +// Message types the driver uses, mirroring gateway/internal/transcode's catalog. +const ( + msgSubmitPlay = "game.submit_play" + msgPass = "game.pass" + msgExchange = "game.exchange" + msgState = "game.state" + msgHistory = "game.history" + msgGamesList = "games.list" + msgCheckWord = "game.check_word" + msgNudge = "chat.nudge" + msgChatPost = "chat.post" + msgDraftSave = "draft.save" + msgDraftGet = "draft.get" + msgProfileGet = "profile.get" + msgProfileUpd = "profile.update" + msgStatsGet = "stats.get" + msgInvCreate = "invitation.create" + msgInvAccept = "invitation.accept" + msgInvList = "invitation.list" + msgEnqueue = "lobby.enqueue" +) + +// Client speaks the edge protocol to a single gateway base URL over h2c. It is safe +// for concurrent use by many virtual players (the underlying http2.Transport pools +// and multiplexes connections). +type Client struct { + rpc edgev1connect.GatewayClient +} + +// New builds a Client for baseURL (for example http://gateway:8081). The transport +// speaks HTTP/2 cleartext (h2c) to match the gateway, dialling plaintext TCP rather +// than TLS. +func New(baseURL string) *Client { + hc := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }, + }, + } + return &Client{rpc: edgev1connect.NewGatewayClient(hc, baseURL)} +} + +// Result is the decoded Execute envelope: Code is "ok" or a stable domain error +// code (a non-ok Code is a domain outcome, not a transport failure); Payload is the +// FlatBuffers response body (empty on error). +type Result struct { + Code string + Payload []byte +} + +// execute runs one operation as token (empty for an unauthenticated op). A transport +// or connection error is returned as err; a domain rejection is reported in +// Result.Code with a nil err. +func (c *Client) execute(ctx context.Context, token, msgType string, payload []byte) (Result, error) { + req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: msgType, Payload: payload}) + if token != "" { + req.Header().Set("Authorization", "Bearer "+token) + } + resp, err := c.rpc.Execute(ctx, req) + if err != nil { + return Result{Code: connectCode(err)}, err + } + return Result{Code: resp.Msg.ResultCode, Payload: resp.Msg.Payload}, nil +} + +// connectCode renders a transport error as a short code for the report (e.g. +// "rate_limited" for HTTP 429, "unavailable", "deadline"), so the gateway-hammer can +// tally limiter rejections without inspecting full errors. +func connectCode(err error) string { + switch connect.CodeOf(err) { + case connect.CodeResourceExhausted: + return "rate_limited" + case connect.CodeUnauthenticated: + return "unauthenticated" + case connect.CodeUnavailable: + return "unavailable" + case connect.CodeDeadlineExceeded: + return "deadline" + default: + return "transport_error" + } +} + +// Event is one decoded live event: its kind and raw FlatBuffers payload (the driver +// reacts to kind alone — your_turn / match_found drive a state fetch). +type Event struct { + Kind string +} + +// Subscribe opens the live-event stream as token and invokes onEvent for each event +// until the context is cancelled or the stream ends. It blocks; run it in its own +// goroutine. Stream errors are returned for the caller to count and (optionally) +// reconnect. +func (c *Client) Subscribe(ctx context.Context, token string, onEvent func(Event)) error { + req := connect.NewRequest(&edgev1.SubscribeRequest{}) + req.Header().Set("Authorization", "Bearer "+token) + stream, err := c.rpc.Subscribe(ctx, req) + if err != nil { + return err + } + defer stream.Close() + for stream.Receive() { + if onEvent != nil { + onEvent(Event{Kind: stream.Msg().Kind}) + } + } + return stream.Err() +} + +// pollInterval bounds how often a player re-checks one game's state; exported for the +// scenario's pacing math so a virtual player stays under the per-user rate limit. +const DefaultPollInterval = 3 * time.Second diff --git a/loadtest/internal/edge/decode.go b/loadtest/internal/edge/decode.go new file mode 100644 index 0000000..008ffa9 --- /dev/null +++ b/loadtest/internal/edge/decode.go @@ -0,0 +1,186 @@ +package edge + +import ( + fb "scrabble/pkg/fbs/scrabblefb" +) + +// Game is the decoded non-private game summary the driver needs to decide a turn. +type Game struct { + ID string + Variant string + DictVer string + Status string + Players int + ToMove int + MoveCount int + Seats []string // account ids in seat order +} + +// Active reports whether the game is still in progress. +func (g Game) Active() bool { return g.Status == "active" } + +// SeatOf returns the seat index of accountID, or -1 if it is not seated. +func (g Game) SeatOf(accountID string) int { + for i, id := range g.Seats { + if id == accountID { + return i + } + } + return -1 +} + +// State is a player's private view: the shared game plus their seat, rack (alphabet +// indices; 255 a blank) and bag size. +type State struct { + Game Game + Seat int + Rack []byte + BagLen int +} + +// Tile is one placed tile from a decoded history record (concrete letter, blank flag). +type Tile struct { + Row, Col int + Letter string + Blank bool +} + +// Move is one decoded history record (a committed play carries Tiles; pass/exchange +// carry only Action). +type Move struct { + Action string + Dir string + Tiles []Tile +} + +// Invitation is the decoded subset the assembler matches on. +type Invitation struct { + ID string + InviterID string + Status string + GameID string +} + +func decodeGameView(gv *fb.GameView) Game { + g := Game{ + ID: string(gv.Id()), + Variant: string(gv.Variant()), + DictVer: string(gv.DictVersion()), + Status: string(gv.Status()), + Players: int(gv.Players()), + ToMove: int(gv.ToMove()), + MoveCount: int(gv.MoveCount()), + } + n := gv.SeatsLength() + g.Seats = make([]string, n) + var sv fb.SeatView + for j := 0; j < n; j++ { + if gv.Seats(&sv, j) { + g.Seats[sv.Seat()] = string(sv.AccountId()) + } + } + return g +} + +// decodeState reads a StateView payload. +func decodeState(payload []byte) State { + sv := fb.GetRootAsStateView(payload, 0) + var gv fb.GameView + st := State{ + Seat: int(sv.Seat()), + BagLen: int(sv.BagLen()), + Rack: append([]byte(nil), sv.RackBytes()...), + } + if g := sv.Game(&gv); g != nil { + st.Game = decodeGameView(g) + } + return st +} + +// decodeHistory reads a History payload into the decoded move journal. +func decodeHistory(payload []byte) []Move { + h := fb.GetRootAsHistory(payload, 0) + n := h.MovesLength() + moves := make([]Move, 0, n) + var mr fb.MoveRecord + for j := 0; j < n; j++ { + if !h.Moves(&mr, j) { + continue + } + m := Move{Action: string(mr.Action()), Dir: string(mr.Dir())} + tn := mr.TilesLength() + m.Tiles = make([]Tile, 0, tn) + var tr fb.TileRecord + for k := 0; k < tn; k++ { + if mr.Tiles(&tr, k) { + m.Tiles = append(m.Tiles, Tile{ + Row: int(tr.Row()), Col: int(tr.Col()), + Letter: string(tr.Letter()), Blank: tr.Blank(), + }) + } + } + moves = append(moves, m) + } + return moves +} + +// decodeMoveResultGame reads a MoveResult payload and returns its post-move game. +func decodeMoveResultGame(payload []byte) Game { + mr := fb.GetRootAsMoveResult(payload, 0) + var gv fb.GameView + if g := mr.Game(&gv); g != nil { + return decodeGameView(g) + } + return Game{} +} + +// decodeGameList reads a GameList payload. +func decodeGameList(payload []byte) []Game { + gl := fb.GetRootAsGameList(payload, 0) + n := gl.GamesLength() + games := make([]Game, 0, n) + var gv fb.GameView + for j := 0; j < n; j++ { + if gl.Games(&gv, j) { + games = append(games, decodeGameView(&gv)) + } + } + return games +} + +// decodeInvitationList reads an InvitationList payload into the matched subset. +func decodeInvitationList(payload []byte) []Invitation { + il := fb.GetRootAsInvitationList(payload, 0) + n := il.InvitationsLength() + out := make([]Invitation, 0, n) + var inv fb.Invitation + var ref fb.AccountRef + for j := 0; j < n; j++ { + if !il.Invitations(&inv, j) { + continue + } + iv := Invitation{ + ID: string(inv.Id()), + Status: string(inv.Status()), + GameID: string(inv.GameId()), + } + if r := inv.Inviter(&ref); r != nil { + iv.InviterID = string(r.AccountId()) + } + out = append(out, iv) + } + return out +} + +// decodeMatch reads a MatchResult payload. +func decodeMatch(payload []byte) (matched bool, game Game) { + mr := fb.GetRootAsMatchResult(payload, 0) + if !mr.Matched() { + return false, Game{} + } + var gv fb.GameView + if g := mr.Game(&gv); g != nil { + return true, decodeGameView(g) + } + return true, Game{} +} diff --git a/loadtest/internal/edge/encode.go b/loadtest/internal/edge/encode.go new file mode 100644 index 0000000..e68633c --- /dev/null +++ b/loadtest/internal/edge/encode.go @@ -0,0 +1,184 @@ +package edge + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + fb "scrabble/pkg/fbs/scrabblefb" +) + +// PlayTile is one tile to place, addressed by alphabet index (255 marks a blank's +// carrier letter together with Blank=true), as the submit-play request carries it. +type PlayTile struct { + Row, Col int + Letter byte + Blank bool +} + +// gameAction builds a GameActionRequest payload (just a game id): pass, nudge, +// history, draft.get. +func gameAction(gameID string) []byte { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID) + fb.GameActionRequestStart(b) + fb.GameActionRequestAddGameId(b, gid) + b.Finish(fb.GameActionRequestEnd(b)) + return b.FinishedBytes() +} + +// stateReq builds a StateRequest payload. includeAlphabet asks the backend to embed +// the variant alphabet table (the driver sets it once per variant). +func stateReq(gameID string, includeAlphabet bool) []byte { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID) + fb.StateRequestStart(b) + fb.StateRequestAddGameId(b, gid) + fb.StateRequestAddIncludeAlphabet(b, includeAlphabet) + b.Finish(fb.StateRequestEnd(b)) + return b.FinishedBytes() +} + +// submitPlay builds a SubmitPlayRequest payload. dir is "H" or "V"; tiles are the +// newly-placed tiles in main-word order. +func submitPlay(gameID, dir string, tiles []PlayTile) []byte { + b := flatbuffers.NewBuilder(256) + gid := b.CreateString(gameID) + d := b.CreateString(dir) + offs := make([]flatbuffers.UOffsetT, len(tiles)) + for i, t := range tiles { + fb.PlayTileStart(b) + fb.PlayTileAddRow(b, int32(t.Row)) + fb.PlayTileAddCol(b, int32(t.Col)) + fb.PlayTileAddLetter(b, t.Letter) + fb.PlayTileAddBlank(b, t.Blank) + offs[i] = fb.PlayTileEnd(b) + } + fb.SubmitPlayRequestStartTilesVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + tilesVec := b.EndVector(len(offs)) + fb.SubmitPlayRequestStart(b) + fb.SubmitPlayRequestAddGameId(b, gid) + fb.SubmitPlayRequestAddDir(b, d) + fb.SubmitPlayRequestAddTiles(b, tilesVec) + b.Finish(fb.SubmitPlayRequestEnd(b)) + return b.FinishedBytes() +} + +// exchange builds an ExchangeRequest payload swapping the listed rack tiles (alphabet +// indices; 255 a blank). +func exchange(gameID string, tiles []byte) []byte { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID) + vec := b.CreateByteVector(tiles) + fb.ExchangeRequestStart(b) + fb.ExchangeRequestAddGameId(b, gid) + fb.ExchangeRequestAddTiles(b, vec) + b.Finish(fb.ExchangeRequestEnd(b)) + return b.FinishedBytes() +} + +// checkWord builds a CheckWordRequest payload (alphabet indices for the word). +func checkWord(gameID string, word []byte) []byte { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID) + vec := b.CreateByteVector(word) + fb.CheckWordRequestStart(b) + fb.CheckWordRequestAddGameId(b, gid) + fb.CheckWordRequestAddWord(b, vec) + b.Finish(fb.CheckWordRequestEnd(b)) + return b.FinishedBytes() +} + +// chatPost builds a ChatPostRequest payload. +func chatPost(gameID, body string) []byte { + b := flatbuffers.NewBuilder(128) + gid := b.CreateString(gameID) + bd := b.CreateString(body) + fb.ChatPostRequestStart(b) + fb.ChatPostRequestAddGameId(b, gid) + fb.ChatPostRequestAddBody(b, bd) + b.Finish(fb.ChatPostRequestEnd(b)) + return b.FinishedBytes() +} + +// draftSave builds a DraftRequest payload carrying the opaque composition JSON. +func draftSave(gameID, jsonStr string) []byte { + b := flatbuffers.NewBuilder(128) + gid := b.CreateString(gameID) + j := b.CreateString(jsonStr) + fb.DraftRequestStart(b) + fb.DraftRequestAddGameId(b, gid) + fb.DraftRequestAddJson(b, j) + b.Finish(fb.DraftRequestEnd(b)) + return b.FinishedBytes() +} + +// updateProfile builds an UpdateProfileRequest payload. It resends the marker display +// name and sane defaults so the account stays findable by the seeder's Cleanup. +func updateProfile(displayName, lang string) []byte { + b := flatbuffers.NewBuilder(192) + name := b.CreateString(displayName) + pl := b.CreateString(lang) + tz := b.CreateString("UTC") + as := b.CreateString("00:00") + ae := b.CreateString("07:00") + fb.UpdateProfileRequestStart(b) + fb.UpdateProfileRequestAddDisplayName(b, name) + fb.UpdateProfileRequestAddPreferredLanguage(b, pl) + fb.UpdateProfileRequestAddTimeZone(b, tz) + fb.UpdateProfileRequestAddAwayStart(b, as) + fb.UpdateProfileRequestAddAwayEnd(b, ae) + fb.UpdateProfileRequestAddBlockChat(b, false) + fb.UpdateProfileRequestAddBlockFriendRequests(b, false) + fb.UpdateProfileRequestAddNotificationsInAppOnly(b, true) + b.Finish(fb.UpdateProfileRequestEnd(b)) + return b.FinishedBytes() +} + +// createInvitation builds a CreateInvitationRequest payload. turnTimeoutSecs 0 asks +// the backend for its default; dropoutTiles "remove" is the standard policy. +func createInvitation(inviteeIDs []string, variant string, turnTimeoutSecs int) []byte { + b := flatbuffers.NewBuilder(256) + idOffs := make([]flatbuffers.UOffsetT, len(inviteeIDs)) + for i, id := range inviteeIDs { + idOffs[i] = b.CreateString(id) + } + fb.CreateInvitationRequestStartInviteeIdsVector(b, len(idOffs)) + for i := len(idOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(idOffs[i]) + } + ids := b.EndVector(len(idOffs)) + variantOff := b.CreateString(variant) + dropout := b.CreateString("remove") + fb.CreateInvitationRequestStart(b) + fb.CreateInvitationRequestAddInviteeIds(b, ids) + fb.CreateInvitationRequestAddVariant(b, variantOff) + fb.CreateInvitationRequestAddTurnTimeoutSecs(b, int32(turnTimeoutSecs)) + fb.CreateInvitationRequestAddHintsAllowed(b, true) + fb.CreateInvitationRequestAddHintsPerPlayer(b, 1) + fb.CreateInvitationRequestAddDropoutTiles(b, dropout) + b.Finish(fb.CreateInvitationRequestEnd(b)) + return b.FinishedBytes() +} + +// invitationAction builds an InvitationActionRequest payload (accept / decline / +// cancel by id). +func invitationAction(invitationID string) []byte { + b := flatbuffers.NewBuilder(64) + id := b.CreateString(invitationID) + fb.InvitationActionRequestStart(b) + fb.InvitationActionRequestAddInvitationId(b, id) + b.Finish(fb.InvitationActionRequestEnd(b)) + return b.FinishedBytes() +} + +// enqueueReq builds an EnqueueRequest payload (join the per-variant auto-match pool). +func enqueueReq(variant string) []byte { + b := flatbuffers.NewBuilder(64) + v := b.CreateString(variant) + fb.EnqueueRequestStart(b) + fb.EnqueueRequestAddVariant(b, v) + b.Finish(fb.EnqueueRequestEnd(b)) + return b.FinishedBytes() +} diff --git a/loadtest/internal/edge/ops.go b/loadtest/internal/edge/ops.go new file mode 100644 index 0000000..7aa5d9d --- /dev/null +++ b/loadtest/internal/edge/ops.go @@ -0,0 +1,136 @@ +package edge + +import "context" + +// The typed operations below each build a request, run Execute and decode the +// response. They return the decoded value (where any), the domain result code +// ("ok" or a stable error code) and a transport error. The scenario layer times the +// call and records the code; a non-"ok" code with a nil error is a domain rejection +// (for example "not_your_turn"), not a failure of the harness. + +// State fetches the caller's private view of a game. +func (c *Client) State(ctx context.Context, token, gameID string) (State, string, error) { + r, err := c.execute(ctx, token, msgState, stateReq(gameID, false)) + if err != nil || r.Code != "ok" { + return State{}, r.Code, err + } + return decodeState(r.Payload), r.Code, nil +} + +// History fetches a game's decoded move journal (the board-replay source). +func (c *Client) History(ctx context.Context, token, gameID string) ([]Move, string, error) { + r, err := c.execute(ctx, token, msgHistory, gameAction(gameID)) + if err != nil || r.Code != "ok" { + return nil, r.Code, err + } + return decodeHistory(r.Payload), r.Code, nil +} + +// SubmitPlay commits a play and returns the post-move game. +func (c *Client) SubmitPlay(ctx context.Context, token, gameID, dir string, tiles []PlayTile) (Game, string, error) { + r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, dir, tiles)) + if err != nil || r.Code != "ok" { + return Game{}, r.Code, err + } + return decodeMoveResultGame(r.Payload), r.Code, nil +} + +// Pass forfeits the turn and returns the post-move game. +func (c *Client) Pass(ctx context.Context, token, gameID string) (Game, string, error) { + r, err := c.execute(ctx, token, msgPass, gameAction(gameID)) + if err != nil || r.Code != "ok" { + return Game{}, r.Code, err + } + return decodeMoveResultGame(r.Payload), r.Code, nil +} + +// Exchange swaps the listed rack tiles and returns the post-move game. +func (c *Client) Exchange(ctx context.Context, token, gameID string, tiles []byte) (Game, string, error) { + r, err := c.execute(ctx, token, msgExchange, exchange(gameID, tiles)) + if err != nil || r.Code != "ok" { + return Game{}, r.Code, err + } + return decodeMoveResultGame(r.Payload), r.Code, nil +} + +// Nudge prods the opponent whose turn it is. +func (c *Client) Nudge(ctx context.Context, token, gameID string) (string, error) { + r, err := c.execute(ctx, token, msgNudge, gameAction(gameID)) + return r.Code, err +} + +// ChatPost posts a per-game chat line. +func (c *Client) ChatPost(ctx context.Context, token, gameID, body string) (string, error) { + r, err := c.execute(ctx, token, msgChatPost, chatPost(gameID, body)) + return r.Code, err +} + +// CheckWord looks a word up in the game's pinned dictionary. +func (c *Client) CheckWord(ctx context.Context, token, gameID string, word []byte) (string, error) { + r, err := c.execute(ctx, token, msgCheckWord, checkWord(gameID, word)) + return r.Code, err +} + +// DraftSave stores the player's client-side composition. +func (c *Client) DraftSave(ctx context.Context, token, gameID, jsonStr string) (string, error) { + r, err := c.execute(ctx, token, msgDraftSave, draftSave(gameID, jsonStr)) + return r.Code, err +} + +// DraftGet retrieves the player's stored composition. +func (c *Client) DraftGet(ctx context.Context, token, gameID string) (string, error) { + r, err := c.execute(ctx, token, msgDraftGet, gameAction(gameID)) + return r.Code, err +} + +// ProfileUpdate overwrites the profile, resending the marker display name. +func (c *Client) ProfileUpdate(ctx context.Context, token, displayName, lang string) (string, error) { + r, err := c.execute(ctx, token, msgProfileUpd, updateProfile(displayName, lang)) + return r.Code, err +} + +// Stats reads the caller's lifetime statistics. +func (c *Client) Stats(ctx context.Context, token string) (string, error) { + r, err := c.execute(ctx, token, msgStatsGet, nil) + return r.Code, err +} + +// GamesList lists the caller's games (active and finished). +func (c *Client) GamesList(ctx context.Context, token string) ([]Game, string, error) { + r, err := c.execute(ctx, token, msgGamesList, nil) + if err != nil || r.Code != "ok" { + return nil, r.Code, err + } + return decodeGameList(r.Payload), r.Code, nil +} + +// CreateInvitation proposes a 2-4 player friend game to the named invitees. +func (c *Client) CreateInvitation(ctx context.Context, token string, inviteeIDs []string, variant string) (string, error) { + r, err := c.execute(ctx, token, msgInvCreate, createInvitation(inviteeIDs, variant, 0)) + return r.Code, err +} + +// AcceptInvitation accepts an invitation by id (the completing accept starts the game). +func (c *Client) AcceptInvitation(ctx context.Context, token, invitationID string) (string, error) { + r, err := c.execute(ctx, token, msgInvAccept, invitationAction(invitationID)) + return r.Code, err +} + +// ListInvitations lists the caller's open invitations. +func (c *Client) ListInvitations(ctx context.Context, token string) ([]Invitation, string, error) { + r, err := c.execute(ctx, token, msgInvList, nil) + if err != nil || r.Code != "ok" { + return nil, r.Code, err + } + return decodeInvitationList(r.Payload), r.Code, nil +} + +// Enqueue joins the per-variant auto-match pool and reports any immediate pairing. +func (c *Client) Enqueue(ctx context.Context, token, variant string) (bool, Game, string, error) { + r, err := c.execute(ctx, token, msgEnqueue, enqueueReq(variant)) + if err != nil || r.Code != "ok" { + return false, Game{}, r.Code, err + } + matched, game := decodeMatch(r.Payload) + return matched, game, r.Code, nil +} diff --git a/loadtest/internal/moves/moves.go b/loadtest/internal/moves/moves.go new file mode 100644 index 0000000..33b959d --- /dev/null +++ b/loadtest/internal/moves/moves.go @@ -0,0 +1,186 @@ +// Package moves turns a game's public history and the caller's private rack into a +// legal turn, by reconstructing the board and running the embedded scrabble-solver +// locally (the edge protocol carries no board — the client replays history). It +// picks a mid-ranked move so games progress realistically rather than optimally. +package moves + +import ( + "fmt" + "math/rand" + "path/filepath" + + "gitea.iliadenisov.ru/developer/scrabble-solver/board" + "gitea.iliadenisov.ru/developer/scrabble-solver/rack" + "gitea.iliadenisov.ru/developer/scrabble-solver/rules" + "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" + dawg "github.com/iliadenisov/dafsa" + + "scrabble/loadtest/internal/edge" +) + +// blankIndex is the rack/exchange sentinel for a blank tile on the wire (Stage 13). +const blankIndex = 255 + +// variantSpec maps an edge variant label to its ruleset constructor and committed +// DAWG filename (the descriptive names kept by R1). +type variantSpec struct { + ruleset func() *rules.Ruleset + dawg string +} + +var specs = map[string]variantSpec{ + "scrabble_en": {rules.English, "en_sowpods.dawg"}, + "scrabble_ru": {rules.RussianScrabble, "ru_scrabble.dawg"}, + "erudit_ru": {rules.Erudit, "ru_erudit.dawg"}, +} + +// Variants returns the edge variant labels the harness drives, in catalogue order. +func Variants() []string { return []string{"scrabble_en", "scrabble_ru", "erudit_ru"} } + +// engine is one loaded variant: its ruleset and a solver over its DAWG. +type engine struct { + rs *rules.Ruleset + finder dawg.Finder + solver *scrabble.Solver +} + +// Registry holds a solver per variant, built from the committed DAWGs in dir. It is +// safe for concurrent use: every Pick builds its own board and rack, and the solver +// holds only read-only state (the same way the backend shares one solver per variant +// across concurrent games). +type Registry struct { + engines map[string]*engine +} + +// Open loads every variant's DAWG from dir and builds a solver over each. dir holds +// the committed dawg files (the sibling scrabble-solver checkout's dawg/, or the +// dictionary release artifact). +func Open(dir string) (*Registry, error) { + r := &Registry{engines: make(map[string]*engine)} + for label, spec := range specs { + rs := spec.ruleset() + finder, err := dawg.Load(filepath.Join(dir, spec.dawg)) + if err != nil { + r.Close() + return nil, fmt.Errorf("moves: load %s dawg %s from %s: %w", label, spec.dawg, dir, err) + } + r.engines[label] = &engine{rs: rs, finder: finder, solver: scrabble.NewSolver(rs, finder)} + } + return r, nil +} + +// Close releases every loaded DAWG. +func (r *Registry) Close() { + for _, e := range r.engines { + if e.finder != nil { + _ = e.finder.Close() + } + } +} + +// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir +// ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap). +type Action struct { + Kind string + Dir string + Tiles []edge.PlayTile + Exchange []byte +} + +// Pick reconstructs the board for variant from history, builds the rack from the +// alphabet-index rack, generates the legal plays and returns a mid-ranked one. With +// no legal play it exchanges (when the bag holds a full rack) or passes. rng makes +// the choice deterministic per caller; pass each virtual player its own *rand.Rand +// (rand.Rand is not safe for concurrent use). +func (r *Registry) Pick(variant string, history []edge.Move, rackIdx []byte, bagLen int, rng *rand.Rand) (Action, error) { + e, ok := r.engines[variant] + if !ok { + return Action{}, fmt.Errorf("moves: unknown variant %q", variant) + } + b, err := replayBoard(e.rs, history) + if err != nil { + return Action{}, err + } + legal := e.solver.GenerateMoves(b, buildRack(e.rs, rackIdx), scrabble.Both) + if len(legal) == 0 { + return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil + } + m := midRanked(legal, rng) + return Action{Kind: "play", Dir: dirString(m.Dir), Tiles: toPlayTiles(m.Tiles)}, nil +} + +// toPlayTiles maps the solver's newly-placed tiles to the edge submit-play tiles +// (addressed by alphabet index, carrying the blank flag). +func toPlayTiles(placements []scrabble.Placement) []edge.PlayTile { + tiles := make([]edge.PlayTile, len(placements)) + for i, p := range placements { + tiles[i] = edge.PlayTile{Row: p.Row, Col: p.Col, Letter: p.Letter, Blank: p.Blank} + } + return tiles +} + +// replayBoard mirrors backend engine.ReplayBoard using only the solver's public API: +// each play record's letters are re-indexed through the alphabet and applied to an +// empty board. Non-play records are ignored. +func replayBoard(rs *rules.Ruleset, history []edge.Move) (*board.Board, error) { + b := board.New(rs.Rows, rs.Cols) + for _, rec := range history { + if rec.Action != "play" { + continue + } + ps := make([]scrabble.Placement, len(rec.Tiles)) + for i, t := range rec.Tiles { + idx, err := rs.Alphabet.Index(t.Letter) + if err != nil { + return nil, fmt.Errorf("moves: replay letter %q at (%d,%d): %w", t.Letter, t.Row, t.Col, err) + } + ps[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank} + } + scrabble.Apply(b, scrabble.Move{Tiles: ps}) + } + return b, nil +} + +// buildRack turns the alphabet-index rack (255 a blank) into a solver Rack. +func buildRack(rs *rules.Ruleset, rackIdx []byte) rack.Rack { + rk := rack.New(rs.Alphabet.Size()) + for _, idx := range rackIdx { + if idx == blankIndex { + rk.AddBlank() + } else { + rk.Add(idx) + } + } + return rk +} + +// midRanked returns a move from the middle third of the score-ranked list +// (GenerateMoves returns highest-first), spreading the pick within that band with +// rng. A tiny list yields its lowest-scoring move. +func midRanked(moves []scrabble.Move, rng *rand.Rand) scrabble.Move { + n := len(moves) + if n <= 2 { + return moves[n-1] + } + lo, hi := n/3, 2*n/3 + if hi <= lo { + hi = lo + 1 + } + return moves[lo+rng.Intn(hi-lo)] +} + +// noPlay chooses an exchange (when the bag can refill a full rack) or a pass. +func noPlay(rackIdx []byte, canExchange bool) Action { + if canExchange && len(rackIdx) > 0 { + return Action{Kind: "exchange", Exchange: append([]byte(nil), rackIdx...)} + } + return Action{Kind: "pass"} +} + +// dirString renders a solver direction as the "H"/"V" the edge submit-play expects. +func dirString(d scrabble.Direction) string { + if d == scrabble.Vertical { + return "V" + } + return "H" +} diff --git a/loadtest/internal/moves/moves_test.go b/loadtest/internal/moves/moves_test.go new file mode 100644 index 0000000..54327dc --- /dev/null +++ b/loadtest/internal/moves/moves_test.go @@ -0,0 +1,157 @@ +package moves + +import ( + "math/rand" + "os" + "strings" + "testing" + + "gitea.iliadenisov.ru/developer/scrabble-solver/board" + "gitea.iliadenisov.ru/developer/scrabble-solver/rules" + "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" + + "scrabble/loadtest/internal/edge" +) + +// TestReplayBoardMatchesParse checks that replaying decoded history reproduces the +// exact board (positions, letters and blank flags) that board.Parse builds from the +// equivalent text grid, and that non-play records are ignored. +func TestReplayBoardMatchesParse(t *testing.T) { + rs := rules.English() + history := []edge.Move{ + {Action: "pass"}, // must be ignored + {Action: "play", Tiles: []edge.Tile{ + {Row: 7, Col: 7, Letter: "c"}, + {Row: 7, Col: 8, Letter: "a"}, + {Row: 7, Col: 9, Letter: "t"}, + }}, + {Action: "play", Tiles: []edge.Tile{ + {Row: 7, Col: 10, Letter: "s", Blank: true}, // a blank standing for s + }}, + } + got, err := replayBoard(rs, history) + if err != nil { + t.Fatalf("replayBoard: %v", err) + } + + rows := make([]string, rs.Rows) + for i := range rows { + rows[i] = strings.Repeat(".", rs.Cols) + } + // row 7: cols 0-6 empty, cat at 7-9, an uppercase S (blank) at 10. + rows[7] = strings.Repeat(".", 7) + "cat" + "S" + strings.Repeat(".", rs.Cols-11) + want, err := board.Parse(rows, rs.Alphabet) + if err != nil { + t.Fatalf("board.Parse: %v", err) + } + for r := 0; r < rs.Rows; r++ { + for c := 0; c < rs.Cols; c++ { + if got.At(r, c) != want.At(r, c) { + t.Fatalf("cell (%d,%d): replay = %#x, parse = %#x", r, c, got.At(r, c), want.At(r, c)) + } + } + } +} + +// TestBuildRack checks the alphabet-index rack (255 a blank) is reconstructed faithfully. +func TestBuildRack(t *testing.T) { + rs := rules.English() + rk := buildRack(rs, []byte{0, 0, 2, blankIndex}) // a a c blank + if rk.Count(0) != 2 { + t.Errorf("count(a) = %d, want 2", rk.Count(0)) + } + if rk.Count(2) != 1 { + t.Errorf("count(c) = %d, want 1", rk.Count(2)) + } + if rk.Blanks() != 1 { + t.Errorf("blanks = %d, want 1", rk.Blanks()) + } + if rk.Total() != 4 { + t.Errorf("total = %d, want 4", rk.Total()) + } +} + +// TestMidRanked checks the pick always lands in the middle third of a ranked list and +// that tiny lists yield their lowest-scoring move. +func TestMidRanked(t *testing.T) { + ms := make([]scrabble.Move, 9) // scores 100..92, index i has score 100-i + for i := range ms { + ms[i] = scrabble.Move{Score: 100 - i} + } + rng := rand.New(rand.NewSource(1)) + for n := 0; n < 100; n++ { + idx := 100 - midRanked(ms, rng).Score // recover the index from the score + if idx < 3 || idx >= 6 { + t.Fatalf("picked index %d outside middle third [3,6)", idx) + } + } + if got := midRanked([]scrabble.Move{{Score: 5}}, rng).Score; got != 5 { + t.Errorf("n=1 pick score = %d, want 5", got) + } + if got := midRanked([]scrabble.Move{{Score: 9}, {Score: 4}}, rng).Score; got != 4 { + t.Errorf("n=2 pick score = %d, want 4 (lower-scoring)", got) + } +} + +// TestToPlayTiles checks the solver-placement to edge-tile mapping, including blanks. +func TestToPlayTiles(t *testing.T) { + tiles := toPlayTiles([]scrabble.Placement{ + {Row: 1, Col: 2, Letter: 5}, + {Row: 1, Col: 3, Letter: 255, Blank: true}, + }) + want := []edge.PlayTile{ + {Row: 1, Col: 2, Letter: 5}, + {Row: 1, Col: 3, Letter: 255, Blank: true}, + } + if len(tiles) != len(want) { + t.Fatalf("len = %d, want %d", len(tiles), len(want)) + } + for i := range want { + if tiles[i] != want[i] { + t.Errorf("tile %d = %+v, want %+v", i, tiles[i], want[i]) + } + } +} + +// TestPickUnknownVariant rejects a variant the registry does not hold. +func TestPickUnknownVariant(t *testing.T) { + reg := &Registry{engines: map[string]*engine{}} + if _, err := reg.Pick("nope", nil, nil, 0, rand.New(rand.NewSource(1))); err == nil { + t.Fatal("want error for an unknown variant") + } +} + +// TestPickWithDawg drives the full path against the committed DAWGs when they are +// available (BACKEND_DICT_DIR, as the engine tests use); it generates a first-move +// play from a productive rack. +func TestPickWithDawg(t *testing.T) { + dir := os.Getenv("BACKEND_DICT_DIR") + if dir == "" { + t.Skip("BACKEND_DICT_DIR not set; skipping DAWG-backed test") + } + reg, err := Open(dir) + if err != nil { + t.Fatalf("Open(%s): %v", dir, err) + } + defer reg.Close() + + rng := rand.New(rand.NewSource(1)) + rack := []byte{2, 0, 19, 18, 4, 17, 13} // c a t s e r n — a productive English rack + act, err := reg.Pick("scrabble_en", nil, rack, 90, rng) + if err != nil { + t.Fatalf("Pick: %v", err) + } + switch act.Kind { + case "play": + if len(act.Tiles) == 0 { + t.Error("play action has no tiles") + } + if act.Dir != "H" && act.Dir != "V" { + t.Errorf("dir = %q, want H or V", act.Dir) + } + case "exchange", "pass": + // acceptable when the rack has no legal first move + default: + t.Errorf("unexpected action kind %q", act.Kind) + } +} diff --git a/loadtest/internal/report/report.go b/loadtest/internal/report/report.go new file mode 100644 index 0000000..1133c62 --- /dev/null +++ b/loadtest/internal/report/report.go @@ -0,0 +1,204 @@ +// Package report collects per-operation latency, result-code and live-event counts +// across all virtual players and renders a text summary for the R2 trip report. It +// is safe for concurrent use. Latencies go into fixed buckets (a Prometheus-style +// histogram) so percentiles cost no per-sample memory at load-test scale. +package report + +import ( + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// boundsMs are the upper bounds (milliseconds) of the latency histogram buckets; a +// trailing overflow bucket catches anything slower. +var boundsMs = []float64{1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000} + +type opStat struct { + count int64 + sumNs int64 + maxNs int64 + codes map[string]int64 + buckets []int64 // len(boundsMs)+1; last is the overflow bucket +} + +func newOpStat() *opStat { + return &opStat{codes: map[string]int64{}, buckets: make([]int64, len(boundsMs)+1)} +} + +func (s *opStat) record(code string, d time.Duration) { + s.count++ + s.sumNs += int64(d) + if int64(d) > s.maxNs { + s.maxNs = int64(d) + } + s.codes[code]++ + ms := float64(d) / float64(time.Millisecond) + i := sort.SearchFloat64s(boundsMs, ms) + s.buckets[i]++ +} + +// quantile estimates the q-th percentile (05000". +func (s *opStat) quantile(q float64) string { + if s.count == 0 { + return "-" + } + target := int64(q*float64(s.count) + 0.5) + if target < 1 { + target = 1 + } + var cum int64 + for i, n := range s.buckets { + cum += n + if cum >= target { + if i == len(boundsMs) { + return ">5000" + } + return fmt.Sprintf("%g", boundsMs[i]) + } + } + return ">5000" +} + +// Recorder accumulates the run's measurements. +type Recorder struct { + mu sync.Mutex + ops map[string]*opStat + events map[string]int64 + streamErrs int64 + start time.Time +} + +// New returns an empty Recorder with the run clock started. +func New() *Recorder { + return &Recorder{ops: map[string]*opStat{}, events: map[string]int64{}, start: time.Now()} +} + +// Record logs one operation call: its name, domain/transport code and latency. +func (r *Recorder) Record(op, code string, d time.Duration) { + r.mu.Lock() + defer r.mu.Unlock() + s := r.ops[op] + if s == nil { + s = newOpStat() + r.ops[op] = s + } + s.record(code, d) +} + +// Event logs one received live event of the given kind. +func (r *Recorder) Event(kind string) { + r.mu.Lock() + defer r.mu.Unlock() + r.events[kind]++ +} + +// StreamErr logs one Subscribe stream error (a drop the player reconnects from). +func (r *Recorder) StreamErr() { + r.mu.Lock() + defer r.mu.Unlock() + r.streamErrs++ +} + +// Totals returns the aggregate call count and the count of non-"ok" results, for the +// pass/fail summary. +func (r *Recorder) Totals() (calls, nonOK int64) { + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.ops { + calls += s.count + for code, n := range s.codes { + if code != "ok" { + nonOK += n + } + } + } + return calls, nonOK +} + +// Summary renders the human-readable run report: a per-operation table (count, +// throughput, p50/p90/p99/max latency, code breakdown), the live-event tally and the +// aggregate error rate. +func (r *Recorder) Summary() string { + r.mu.Lock() + defer r.mu.Unlock() + + elapsed := time.Since(r.start).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + names := make([]string, 0, len(r.ops)) + for op := range r.ops { + names = append(names, op) + } + sort.Strings(names) + + var b strings.Builder + fmt.Fprintf(&b, "elapsed: %.0fs\n\n", elapsed) + fmt.Fprintf(&b, "%-20s %8s %8s %7s %7s %7s %7s %s\n", + "operation", "count", "req/s", "p50ms", "p90ms", "p99ms", "maxms", "codes") + var totalCalls, totalNonOK int64 + for _, op := range names { + s := r.ops[op] + totalCalls += s.count + var nonOK int64 + for code, n := range s.codes { + if code != "ok" { + nonOK += n + } + } + totalNonOK += nonOK + fmt.Fprintf(&b, "%-20s %8d %8.1f %7s %7s %7s %7.0f %s\n", + op, s.count, float64(s.count)/elapsed, + s.quantile(0.50), s.quantile(0.90), s.quantile(0.99), + float64(s.maxNs)/float64(time.Millisecond), codeBreakdown(s.codes)) + } + + fmt.Fprintf(&b, "\ntotal calls: %d, throughput: %.1f req/s\n", totalCalls, float64(totalCalls)/elapsed) + rate := 0.0 + if totalCalls > 0 { + rate = 100 * float64(totalNonOK) / float64(totalCalls) + } + fmt.Fprintf(&b, "non-ok results: %d (%.2f%%)\n", totalNonOK, rate) + + if len(r.events) > 0 { + fmt.Fprintf(&b, "\nlive events:\n") + ekeys := make([]string, 0, len(r.events)) + for k := range r.events { + ekeys = append(ekeys, k) + } + sort.Strings(ekeys) + for _, k := range ekeys { + fmt.Fprintf(&b, " %-16s %d\n", k, r.events[k]) + } + } + fmt.Fprintf(&b, "stream errors: %d\n", r.streamErrs) + return b.String() +} + +// codeBreakdown renders a stat's code counts as "ok:1234 not_your_turn:5 ...", +// highest-count first. +func codeBreakdown(codes map[string]int64) string { + type kv struct { + code string + n int64 + } + pairs := make([]kv, 0, len(codes)) + for c, n := range codes { + pairs = append(pairs, kv{c, n}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].n != pairs[j].n { + return pairs[i].n > pairs[j].n + } + return pairs[i].code < pairs[j].code + }) + parts := make([]string, len(pairs)) + for i, p := range pairs { + parts[i] = fmt.Sprintf("%s:%d", p.code, p.n) + } + return strings.Join(parts, " ") +} diff --git a/loadtest/internal/report/report_test.go b/loadtest/internal/report/report_test.go new file mode 100644 index 0000000..1fa2b7b --- /dev/null +++ b/loadtest/internal/report/report_test.go @@ -0,0 +1,53 @@ +package report + +import ( + "strings" + "testing" + "time" +) + +// TestRecorderTotalsAndSummary checks call/error tallying and that the rendered +// summary surfaces operations, codes, events and stream errors. +func TestRecorderTotalsAndSummary(t *testing.T) { + r := New() + r.Record("game.state", "ok", 5*time.Millisecond) + r.Record("game.state", "ok", 7*time.Millisecond) + r.Record("game.submit_play", "not_your_turn", 3*time.Millisecond) + r.Record("hammer:games.list", "rate_limited", time.Millisecond) + r.Event("your_turn") + r.Event("your_turn") + r.StreamErr() + + calls, nonOK := r.Totals() + if calls != 4 { + t.Errorf("calls = %d, want 4", calls) + } + if nonOK != 2 { + t.Errorf("nonOK = %d, want 2", nonOK) + } + + s := r.Summary() + for _, want := range []string{"game.state", "not_your_turn", "rate_limited", "your_turn", "stream errors: 1"} { + if !strings.Contains(s, want) { + t.Errorf("summary missing %q\n---\n%s", want, s) + } + } +} + +// TestOpStatQuantile checks the bucketed percentile estimate lands on the right bucket +// bound. +func TestOpStatQuantile(t *testing.T) { + s := newOpStat() + for i := 0; i < 90; i++ { + s.record("ok", 10*time.Millisecond) + } + for i := 0; i < 10; i++ { + s.record("ok", 1000*time.Millisecond) + } + if got := s.quantile(0.50); got != "10" { + t.Errorf("p50 = %s, want 10", got) + } + if got := s.quantile(0.99); got != "1000" { + t.Errorf("p99 = %s, want 1000", got) + } +} diff --git a/loadtest/internal/scenario/assemble.go b/loadtest/internal/scenario/assemble.go new file mode 100644 index 0000000..33584c6 --- /dev/null +++ b/loadtest/internal/scenario/assemble.go @@ -0,0 +1,170 @@ +package scenario + +import ( + "context" + "fmt" + "math/rand" + "time" + + "scrabble/loadtest/internal/edge" + "scrabble/loadtest/internal/moves" + "scrabble/loadtest/internal/seed" +) + +// Game is one assembled match: its id, variant and members in seat order (Members[0] +// is the inviter, seat 0). +type Game struct { + ID string + Variant string + Members []seed.Account +} + +// seatOf returns the seat index of accountID in the game, or -1. +func (g *Game) seatOf(accountID string) int { + for i, m := range g.Members { + if m.ID.String() == accountID { + return i + } + } + return -1 +} + +// assembleCohort forms games among a cohort of active players via the invitation +// flow, aiming for gamesPerPlayer (3-5) concurrent games per player with 2-4 players +// each. It returns the games it managed to start. Failures are logged and skipped so +// a partial assembly still drives load. +func (d *Driver) assembleCohort(ctx context.Context, cohort []seed.Account, gamesPerPlayer int, rng *rand.Rand) []*Game { + if len(cohort) < 2 { + return nil + } + gamesOf := make(map[string]int, len(cohort)) + var games []*Game + for i := range cohort { + inviter := cohort[i] + target := 3 + rng.Intn(3) // 3..5 + if gamesPerPlayer > 0 { + target = gamesPerPlayer + } + for gamesOf[inviter.ID.String()] < target { + members := pickMembers(cohort, inviter, rng) + if len(members) < 2 { + break + } + variant := moves.Variants()[rng.Intn(len(moves.Variants()))] + g, err := d.assemble(ctx, members, variant) + if err != nil { + d.log.Debug("assemble game", "err", err) + break + } + games = append(games, g) + for _, m := range members { + gamesOf[m.ID.String()]++ + } + } + } + return games +} + +// pickMembers builds a 2-4 player group led by inviter, drawing distinct others from +// the cohort at random. +func pickMembers(cohort []seed.Account, inviter seed.Account, rng *rand.Rand) []seed.Account { + size := 2 + rng.Intn(3) // 2..4 + members := []seed.Account{inviter} + seen := map[string]bool{inviter.ID.String(): true} + for attempts := 0; len(members) < size && attempts < 4*size; attempts++ { + cand := cohort[rng.Intn(len(cohort))] + if seen[cand.ID.String()] { + continue + } + seen[cand.ID.String()] = true + members = append(members, cand) + } + return members +} + +// assemble runs the invitation flow for one game: the inviter (members[0]) invites +// the rest, each invitee accepts the pending invitation, and the completing accept +// starts the game, which is then located in the inviter's game list. +func (d *Driver) assemble(ctx context.Context, members []seed.Account, variant string) (*Game, error) { + inviter := members[0] + inviteeIDs := make([]string, len(members)-1) + for i, m := range members[1:] { + inviteeIDs[i] = m.ID.String() + } + + t0 := time.Now() + code, err := d.edge.CreateInvitation(ctx, inviter.Token, inviteeIDs, variant) + d.rec.Record("invitation.create", code, time.Since(t0)) + if err != nil || code != "ok" { + return nil, fmt.Errorf("invitation.create: %s", code) + } + + for _, invitee := range members[1:] { + t0 = time.Now() + list, lc, err := d.edge.ListInvitations(ctx, invitee.Token) + d.rec.Record("invitation.list", lc, time.Since(t0)) + if err != nil || lc != "ok" { + return nil, fmt.Errorf("invitation.list: %s", lc) + } + invID := findPending(list, inviter.ID.String()) + if invID == "" { + return nil, fmt.Errorf("no pending invitation from %s", inviter.ID) + } + t0 = time.Now() + ac, err := d.edge.AcceptInvitation(ctx, invitee.Token, invID) + d.rec.Record("invitation.accept", ac, time.Since(t0)) + if err != nil || ac != "ok" { + return nil, fmt.Errorf("invitation.accept: %s", ac) + } + } + + t0 = time.Now() + games, gc, err := d.edge.GamesList(ctx, inviter.Token) + d.rec.Record("games.list", gc, time.Since(t0)) + if err != nil || gc != "ok" { + return nil, fmt.Errorf("games.list: %s", gc) + } + ids := make([]string, len(members)) + for i, m := range members { + ids[i] = m.ID.String() + } + gameID := findGame(games, ids) + if gameID == "" { + return nil, fmt.Errorf("started game not found for %d members", len(members)) + } + return &Game{ID: gameID, Variant: variant, Members: members}, nil +} + +// findPending returns the id of a pending invitation from inviterID, or "". +func findPending(list []edge.Invitation, inviterID string) string { + for _, inv := range list { + if inv.InviterID == inviterID && inv.Status == "pending" { + return inv.ID + } + } + return "" +} + +// findGame returns the id of the active game whose seat set equals memberIDs, or "". +func findGame(games []edge.Game, memberIDs []string) string { + want := make(map[string]bool, len(memberIDs)) + for _, id := range memberIDs { + want[id] = true + } + for _, g := range games { + if !g.Active() || len(g.Seats) != len(memberIDs) { + continue + } + match := true + for _, s := range g.Seats { + if !want[s] { + match = false + break + } + } + if match { + return g.ID + } + } + return "" +} diff --git a/loadtest/internal/scenario/hammer.go b/loadtest/internal/scenario/hammer.go new file mode 100644 index 0000000..18e51a6 --- /dev/null +++ b/loadtest/internal/scenario/hammer.go @@ -0,0 +1,45 @@ +package scenario + +import ( + "context" + "sync" + "time" + + "scrabble/loadtest/internal/seed" +) + +// HammerConfig parameterises the gateway-hammer: how many concurrent callers and for +// how long to deliberately exceed the per-user rate limit from a single account. +type HammerConfig struct { + Workers int + Duration time.Duration +} + +// DefaultHammer returns a hammer that comfortably exceeds the 300/min per-user limit. +func DefaultHammer() HammerConfig { + return HammerConfig{Workers: 20, Duration: 15 * time.Second} +} + +// Hammer drives games.list from a single account far above the per-user rate limit to +// verify the limiter holds — rejections surface as the "rate_limited" code — and to +// measure its cost. Every call is recorded under "hammer:games.list" so the report +// shows the ok/rate_limited split and the rejection latency separately from the +// realistic traffic. +func (d *Driver) Hammer(ctx context.Context, acc seed.Account, cfg HammerConfig) { + runCtx, cancel := context.WithTimeout(ctx, cfg.Duration) + defer cancel() + d.log.Info("gateway-hammer", "workers", cfg.Workers, "duration", cfg.Duration) + var wg sync.WaitGroup + for w := 0; w < cfg.Workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for runCtx.Err() == nil { + t0 := time.Now() + _, code, _ := d.edge.GamesList(runCtx, acc.Token) + d.rec.Record("hammer:games.list", code, time.Since(t0)) + } + }() + } + wg.Wait() +} diff --git a/loadtest/internal/scenario/scenario.go b/loadtest/internal/scenario/scenario.go new file mode 100644 index 0000000..82a6716 --- /dev/null +++ b/loadtest/internal/scenario/scenario.go @@ -0,0 +1,241 @@ +// Package scenario drives virtual players against the gateway edge protocol: it +// assembles real games through the invitation flow, then runs each player's turn +// loop (poll state, replay history, generate a legal move with the embedded solver, +// submit it) plus a fraction of secondary operations. It exposes the moderate +// realistic ramp agreed for the R2 early pass and a separate gateway-hammer. +package scenario + +import ( + "context" + "log/slog" + "math/rand" + "sync" + "time" + + "scrabble/loadtest/internal/edge" + "scrabble/loadtest/internal/moves" + "scrabble/loadtest/internal/report" + "scrabble/loadtest/internal/seed" +) + +// Driver ties the edge client, the local move generator and the run recorder +// together. All three are safe for concurrent use by many player goroutines. +type Driver struct { + edge *edge.Client + moves *moves.Registry + rec *report.Recorder + log *slog.Logger +} + +// NewDriver builds a Driver. +func NewDriver(c *edge.Client, m *moves.Registry, rec *report.Recorder, log *slog.Logger) *Driver { + return &Driver{edge: c, moves: m, rec: rec, log: log} +} + +// RealisticConfig parameterises the under-the-limit ramp. +type RealisticConfig struct { + Steps []int // concurrent active players per step (cumulative) + StepDur time.Duration // hold time per step + GamesPerPlayer int // target concurrent games per player; 0 => random 3..5 + Tick time.Duration // per-player operation cadence (keeps a player under the per-user limit) + SecondaryProb float64 // chance per tick of a non-move operation +} + +// DefaultRealistic returns the moderate ramp agreed for the R2 early pass: 50 -> 200 +// -> 500 concurrent players, ~12 minutes per step, ~1 op/s per player. +func DefaultRealistic() RealisticConfig { + return RealisticConfig{ + Steps: []int{50, 200, 500}, + StepDur: 12 * time.Minute, + Tick: 800 * time.Millisecond, + SecondaryProb: 0.08, + } +} + +// RunRealistic runs the staged ramp. Each step activates more players (drawn from the +// seeded pool), assembles a cohort of games for them and starts their turn loops; the +// loops run until the whole ramp ends. Players from earlier steps keep playing, so +// load is cumulative. +func (d *Driver) RunRealistic(ctx context.Context, pool *seed.Pool, cfg RealisticConfig) error { + players := shuffledPool(pool) + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + var wg sync.WaitGroup + activated := 0 + for si, target := range cfg.Steps { + if target > len(players) { + target = len(players) + } + cohort := players[activated:target] + activated = target + if len(cohort) >= 2 { + rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(si))) + games := d.assembleCohort(runCtx, cohort, cfg.GamesPerPlayer, rng) + byPlayer := gamesByPlayer(games) + d.log.Info("ramp step", "step", si+1, "active", activated, "cohort", len(cohort), "games", len(games)) + for pi := range cohort { + p := cohort[pi] + wg.Add(1) + go func(p seed.Account, pg []*Game, sd int64) { + defer wg.Done() + d.playerLoop(runCtx, p, pg, cfg, rand.New(rand.NewSource(sd))) + }(p, byPlayer[p.ID.String()], time.Now().UnixNano()+int64(pi)) + } + } else { + d.log.Warn("ramp step skipped: cohort too small", "step", si+1, "cohort", len(cohort)) + } + select { + case <-time.After(cfg.StepDur): + case <-ctx.Done(): + cancel() + wg.Wait() + return ctx.Err() + } + } + cancel() + wg.Wait() + return nil +} + +// playerLoop runs one virtual player: a live-event subscription (loads the push hub, +// counts events) plus a round-robin turn loop over the player's games. +func (d *Driver) playerLoop(ctx context.Context, p seed.Account, games []*Game, cfg RealisticConfig, rng *rand.Rand) { + go d.subscribeLoop(ctx, p) + if len(games) == 0 { + <-ctx.Done() + return + } + ticker := time.NewTicker(cfg.Tick) + defer ticker.Stop() + gi := 0 + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + g := games[gi%len(games)] + gi++ + if rng.Float64() < cfg.SecondaryProb { + d.secondaryOp(ctx, p, g, rng) + continue + } + d.playTurn(ctx, p, g, rng) + } + } +} + +// subscribeLoop holds the player's live-event stream open, counting events and +// reconnecting with a brief backoff after a drop, until the run ends. +func (d *Driver) subscribeLoop(ctx context.Context, p seed.Account) { + for ctx.Err() == nil { + err := d.edge.Subscribe(ctx, p.Token, func(e edge.Event) { d.rec.Event(e.Kind) }) + if ctx.Err() != nil { + return + } + if err != nil { + d.rec.StreamErr() + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +// playTurn plays one turn in g when it is the player's move: fetch state, replay +// history, pick a legal move and submit it (or exchange / pass). +func (d *Driver) playTurn(ctx context.Context, p seed.Account, g *Game, rng *rand.Rand) { + seat := g.seatOf(p.ID.String()) + if seat < 0 { + return + } + t0 := time.Now() + st, code, err := d.edge.State(ctx, p.Token, g.ID) + d.rec.Record("game.state", code, time.Since(t0)) + if err != nil || code != "ok" || !st.Game.Active() || st.Game.ToMove != seat { + return + } + + t0 = time.Now() + hist, hc, err := d.edge.History(ctx, p.Token, g.ID) + d.rec.Record("game.history", hc, time.Since(t0)) + if err != nil || hc != "ok" { + return + } + + action, err := d.moves.Pick(g.Variant, hist, st.Rack, st.BagLen, rng) + if err != nil { + d.log.Debug("pick move", "variant", g.Variant, "err", err) + return + } + switch action.Kind { + case "play": + t0 = time.Now() + _, c, _ := d.edge.SubmitPlay(ctx, p.Token, g.ID, action.Dir, action.Tiles) + d.rec.Record("game.submit_play", c, time.Since(t0)) + case "exchange": + t0 = time.Now() + _, c, _ := d.edge.Exchange(ctx, p.Token, g.ID, action.Exchange) + d.rec.Record("game.exchange", c, time.Since(t0)) + default: + t0 = time.Now() + _, c, _ := d.edge.Pass(ctx, p.Token, g.ID) + d.rec.Record("game.pass", c, time.Since(t0)) + } +} + +// secondaryOp exercises one of the non-move edge operations the plan calls out, so +// the run touches nudge / chat / check-word / draft / profile / stats too. +func (d *Driver) secondaryOp(ctx context.Context, p seed.Account, g *Game, rng *rand.Rand) { + t0 := time.Now() + switch rng.Intn(7) { + case 0: + c, _ := d.edge.Nudge(ctx, p.Token, g.ID) + d.rec.Record("chat.nudge", c, time.Since(t0)) + case 1: + c, _ := d.edge.ChatPost(ctx, p.Token, g.ID, "gg") + d.rec.Record("chat.post", c, time.Since(t0)) + case 2: + c, _ := d.edge.CheckWord(ctx, p.Token, g.ID, []byte{0, 1, 2}) + d.rec.Record("game.check_word", c, time.Since(t0)) + case 3: + c, _ := d.edge.DraftSave(ctx, p.Token, g.ID, `{"rack_order":[],"board_tiles":[]}`) + d.rec.Record("draft.save", c, time.Since(t0)) + case 4: + c, _ := d.edge.DraftGet(ctx, p.Token, g.ID) + d.rec.Record("draft.get", c, time.Since(t0)) + case 5: + lang := "en" + if rng.Intn(2) == 1 { + lang = "ru" + } + c, _ := d.edge.ProfileUpdate(ctx, p.Token, p.Name, lang) + d.rec.Record("profile.update", c, time.Since(t0)) + default: + c, _ := d.edge.Stats(ctx, p.Token) + d.rec.Record("stats.get", c, time.Since(t0)) + } +} + +// shuffledPool returns every seeded account in random order, so an active set is a +// representative mix of durable and guest accounts. +func shuffledPool(pool *seed.Pool) []seed.Account { + all := pool.All() + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] }) + return all +} + +// gamesByPlayer indexes the assembled games by each member's account id. +func gamesByPlayer(games []*Game) map[string][]*Game { + m := make(map[string][]*Game) + for _, g := range games { + for _, mem := range g.Members { + id := mem.ID.String() + m[id] = append(m[id], g) + } + } + return m +} diff --git a/loadtest/internal/seed/seed.go b/loadtest/internal/seed/seed.go new file mode 100644 index 0000000..f20014d --- /dev/null +++ b/loadtest/internal/seed/seed.go @@ -0,0 +1,177 @@ +package seed + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Marker prefixes every display_name the harness writes. Cleanup matches on it, so +// the harness only ever deletes its own rows and never touches real accounts. +const Marker = "lt:" + +// Schema-qualified targets so the seeder does not depend on the connection's +// search_path (the backend pins search_path=backend; we qualify explicitly). +var ( + accountsTbl = pgx.Identifier{"backend", "accounts"} + identitiesTbl = pgx.Identifier{"backend", "identities"} + sessionsTbl = pgx.Identifier{"backend", "sessions"} +) + +// Account is one seeded player: its account id, marker display name and the +// plaintext bearer token the driver presents in the Authorization header. Guest +// marks a guest (no identity, accrues no statistics). Name is retained so a +// profile.update can resend the marker display name and keep the row findable by +// Cleanup. +type Account struct { + ID uuid.UUID + Name string + Token string + Guest bool +} + +// Pool is the seeded population, split by durability. +type Pool struct { + Guests []Account + Durables []Account +} + +// All returns every seeded account, durables first. +func (p *Pool) All() []Account { + out := make([]Account, 0, len(p.Durables)+len(p.Guests)) + out = append(out, p.Durables...) + out = append(out, p.Guests...) + return out +} + +// Seeder writes and removes the harness population over a pgx pool against the +// backend Postgres schema. +type Seeder struct{ pool *pgxpool.Pool } + +// New connects to dsn (the backend Postgres) and verifies the connection. +func New(ctx context.Context, dsn string) (*Seeder, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("seed: connect: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("seed: ping: %w", err) + } + return &Seeder{pool: pool}, nil +} + +// Close releases the pool. +func (s *Seeder) Close() { s.pool.Close() } + +// Seed inserts nDurable durable accounts (each with a confirmed email identity) and +// nGuest guest accounts, an active session per account, and returns the population +// with the plaintext tokens. Rows go in over COPY in foreign-key order (accounts, +// then identities and sessions). Every row carries Marker in its display name / +// external id so Cleanup can find them. +func (s *Seeder) Seed(ctx context.Context, nDurable, nGuest int) (*Pool, error) { + pool := &Pool{ + Durables: make([]Account, 0, nDurable), + Guests: make([]Account, 0, nGuest), + } + var acctRows, identRows, sessRows [][]any + + add := func(guest bool, i int) error { + aid, err := uuid.NewV7() + if err != nil { + return err + } + sid, err := uuid.NewV7() + if err != nil { + return err + } + token, hash, err := GenerateToken() + if err != nil { + return err + } + lang := "en" + if i%2 == 1 { + lang = "ru" + } + kind := "d" + if guest { + kind = "g" + } + name := fmt.Sprintf("%s%s-%06d", Marker, kind, i) + acctRows = append(acctRows, []any{aid, name, guest, lang}) + sessRows = append(sessRows, []any{sid, aid, hash, "active"}) + if !guest { + iid, err := uuid.NewV7() + if err != nil { + return err + } + ext := fmt.Sprintf("%s%s@loadtest.invalid", Marker, aid) + identRows = append(identRows, []any{iid, aid, "email", ext, true}) + } + acc := Account{ID: aid, Name: name, Token: token, Guest: guest} + if guest { + pool.Guests = append(pool.Guests, acc) + } else { + pool.Durables = append(pool.Durables, acc) + } + return nil + } + + for i := 0; i < nDurable; i++ { + if err := add(false, i); err != nil { + return nil, err + } + } + for i := 0; i < nGuest; i++ { + if err := add(true, i); err != nil { + return nil, err + } + } + + if _, err := s.pool.CopyFrom(ctx, accountsTbl, + []string{"account_id", "display_name", "is_guest", "preferred_language"}, + pgx.CopyFromRows(acctRows)); err != nil { + return nil, fmt.Errorf("seed: copy accounts: %w", err) + } + if len(identRows) > 0 { + if _, err := s.pool.CopyFrom(ctx, identitiesTbl, + []string{"identity_id", "account_id", "kind", "external_id", "confirmed"}, + pgx.CopyFromRows(identRows)); err != nil { + return nil, fmt.Errorf("seed: copy identities: %w", err) + } + } + if _, err := s.pool.CopyFrom(ctx, sessionsTbl, + []string{"session_id", "account_id", "token_hash", "status"}, + pgx.CopyFromRows(sessRows)); err != nil { + return nil, fmt.Errorf("seed: copy sessions: %w", err) + } + return pool, nil +} + +// Cleanup removes everything the harness created: first the games any harness +// account is seated in (cascading game_players / game_moves / complaints / chat), +// then the harness accounts (cascading identities, sessions, stats, invitations, +// drafts and the rest). It is scoped by Marker, so it is safe to run against a +// contour that also holds real data. The authoritative hard reset remains the +// contour DB wipe (DROP SCHEMA backend CASCADE + backend restart). It returns the +// number of accounts removed. +func (s *Seeder) Cleanup(ctx context.Context) (int, error) { + if _, err := s.pool.Exec(ctx, ` + DELETE FROM backend.games + WHERE game_id IN ( + SELECT p.game_id FROM backend.game_players p + JOIN backend.accounts a ON a.account_id = p.account_id + WHERE a.display_name LIKE $1 + )`, Marker+"%"); err != nil { + return 0, fmt.Errorf("seed: cleanup games: %w", err) + } + tag, err := s.pool.Exec(ctx, + `DELETE FROM backend.accounts WHERE display_name LIKE $1`, Marker+"%") + if err != nil { + return 0, fmt.Errorf("seed: cleanup accounts: %w", err) + } + return int(tag.RowsAffected()), nil +} diff --git a/loadtest/internal/seed/token.go b/loadtest/internal/seed/token.go new file mode 100644 index 0000000..5fed53a --- /dev/null +++ b/loadtest/internal/seed/token.go @@ -0,0 +1,33 @@ +// Package seed creates accounts, identities and sessions directly in the backend +// Postgres schema so the load driver can authenticate as many pre-provisioned +// players without paying the per-IP cost of the auth edge operations. It owns the +// inverse operation too (cleanup of everything it created). +package seed + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" +) + +// GenerateToken mints an opaque bearer token and its stored hash. token is the +// plaintext handed to the client; hash is what the seeder writes to +// sessions.token_hash. The transformation matches backend/internal/session so a +// resolve of token recomputes the same hash and finds the seeded row. +func GenerateToken() (token, hash string, err error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", "", err + } + token = base64.RawURLEncoding.EncodeToString(buf) + return token, HashToken(token), nil +} + +// HashToken returns the hex-encoded SHA-256 of token. It is the exact hash the +// backend session resolver computes (backend/internal/session/token.go), kept in +// lockstep so seeded sessions validate. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/loadtest/internal/seed/token_test.go b/loadtest/internal/seed/token_test.go new file mode 100644 index 0000000..cf47cfa --- /dev/null +++ b/loadtest/internal/seed/token_test.go @@ -0,0 +1,44 @@ +package seed + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +// TestHashTokenMatchesSHA256Hex pins HashToken to the exact transformation the +// backend session resolver uses (hex-encoded SHA-256), the invariant that makes a +// seeded session resolve. +func TestHashTokenMatchesSHA256Hex(t *testing.T) { + const token = "an-example-bearer-token" + sum := sha256.Sum256([]byte(token)) + want := hex.EncodeToString(sum[:]) + if got := HashToken(token); got != want { + t.Fatalf("HashToken(%q) = %s, want %s", token, got, want) + } +} + +// TestGenerateTokenRoundTrip checks that a minted token hashes to the stored hash and +// that tokens are unique. +func TestGenerateTokenRoundTrip(t *testing.T) { + token, hash, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if token == "" || hash == "" { + t.Fatal("empty token or hash") + } + if len(hash) != 64 { + t.Fatalf("hash length = %d, want 64 hex chars", len(hash)) + } + if got := HashToken(token); got != hash { + t.Fatalf("hash mismatch: GenerateToken returned %s, HashToken(token) = %s", hash, got) + } + token2, _, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken (2nd): %v", err) + } + if token2 == token { + t.Fatal("two generated tokens are identical") + } +}