From 9d2d080fccd86087c11ceffa5c94b16b0dff7be9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Mar 2026 01:32:41 -0600 Subject: [PATCH] Add Gatling load test suite for overpass.deflock.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four simulation scenarios to validate server performance (~512 Overpass compute slots) before switching app users to the self-hosted instance: - Baseline: deterministic single-user zoom progression (36 requests) - Concurrent: ramp 1→50 users to find degradation inflection point - Stress: spike to 500 users to exceed server capacity - Burst: realistic app sessions (10-20 requests each) in waves Queries use the exact same per-profile tag filters as the app's NodeProfile.getDefaults() (all 11 built-in profiles). Pure query logic lives in a shared source set with 22 ScalaTest unit tests. Includes GitHub Actions workflow with scenario picker dropdown, matrix strategy for parallel runs, and PR comment with report download links. Dev container with pinned Coursier (SHA256 verified) for IDE support. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/load-test.yml | 151 +++++++++++ .github/workflows/pr.yml | 40 +++ .gitignore | 2 +- load-tests/.devcontainer/Dockerfile | 24 ++ load-tests/.devcontainer/devcontainer.json | 28 ++ load-tests/.gitignore | 5 + load-tests/CLAUDE.md | 64 +++++ load-tests/README.md | 177 +++++++++++++ load-tests/build.gradle.kts | 51 ++++ load-tests/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + load-tests/gradlew | 248 ++++++++++++++++++ load-tests/gradlew.bat | 93 +++++++ load-tests/settings.gradle.kts | 2 + load-tests/src/gatling/resources/gatling.conf | 19 ++ .../src/gatling/resources/logback-test.xml | 23 ++ .../scala/deflock/BurstSimulation.scala | 75 ++++++ .../scala/deflock/ConcurrentSimulation.scala | 56 ++++ .../scala/deflock/OverpassRequests.scala | 54 ++++ .../scala/deflock/OverpassSimulation.scala | 75 ++++++ .../scala/deflock/StressSimulation.scala | 72 +++++ .../shared/scala/deflock/OverpassQuery.scala | 91 +++++++ .../src/shared/scala/deflock/TestData.scala | 133 ++++++++++ .../scala/deflock/OverpassQuerySpec.scala | 56 ++++ .../src/test/scala/deflock/TestDataSpec.scala | 127 +++++++++ 25 files changed, 1672 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/load-test.yml create mode 100644 load-tests/.devcontainer/Dockerfile create mode 100644 load-tests/.devcontainer/devcontainer.json create mode 100644 load-tests/.gitignore create mode 100644 load-tests/CLAUDE.md create mode 100644 load-tests/README.md create mode 100644 load-tests/build.gradle.kts create mode 100644 load-tests/gradle/wrapper/gradle-wrapper.jar create mode 100644 load-tests/gradle/wrapper/gradle-wrapper.properties create mode 100755 load-tests/gradlew create mode 100644 load-tests/gradlew.bat create mode 100644 load-tests/settings.gradle.kts create mode 100644 load-tests/src/gatling/resources/gatling.conf create mode 100644 load-tests/src/gatling/resources/logback-test.xml create mode 100644 load-tests/src/gatling/scala/deflock/BurstSimulation.scala create mode 100644 load-tests/src/gatling/scala/deflock/ConcurrentSimulation.scala create mode 100644 load-tests/src/gatling/scala/deflock/OverpassRequests.scala create mode 100644 load-tests/src/gatling/scala/deflock/OverpassSimulation.scala create mode 100644 load-tests/src/gatling/scala/deflock/StressSimulation.scala create mode 100644 load-tests/src/shared/scala/deflock/OverpassQuery.scala create mode 100644 load-tests/src/shared/scala/deflock/TestData.scala create mode 100644 load-tests/src/test/scala/deflock/OverpassQuerySpec.scala create mode 100644 load-tests/src/test/scala/deflock/TestDataSpec.scala diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 00000000..6f595891 --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,151 @@ +# Manual-trigger workflow to run Gatling load tests against overpass.deflock.org. +# +# Trigger from the Actions tab → "Load Test" → "Run workflow" → pick a scenario. +# The HTML report is uploaded as a downloadable artifact (retained 30 days). +# +# When "all" is selected, all 4 simulations run in parallel on separate runners, +# hitting the server simultaneously for distributed load. A summary job then +# collects all reports and posts a PR comment with download links. + +name: Load Test + +on: + workflow_dispatch: + inputs: + scenario: + description: 'Test scenario to run' + required: true + default: 'baseline' + type: choice + options: + - baseline + - concurrent + - stress + - burst + - all + +concurrency: + group: load-test + cancel-in-progress: true + +jobs: + # Build the matrix dynamically based on the selected scenario. + # This avoids the `matrix` context parsing issue in job-level `if`. + resolve-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - id: set-matrix + run: | + if [ "${{ inputs.scenario }}" = "all" ]; then + echo 'matrix={"include":[{"name":"baseline","class":"deflock.OverpassSimulation"},{"name":"concurrent","class":"deflock.ConcurrentSimulation"},{"name":"stress","class":"deflock.StressSimulation"},{"name":"burst","class":"deflock.BurstSimulation"}]}' >> "$GITHUB_OUTPUT" + elif [ "${{ inputs.scenario }}" = "baseline" ]; then + echo 'matrix={"include":[{"name":"baseline","class":"deflock.OverpassSimulation"}]}' >> "$GITHUB_OUTPUT" + elif [ "${{ inputs.scenario }}" = "concurrent" ]; then + echo 'matrix={"include":[{"name":"concurrent","class":"deflock.ConcurrentSimulation"}]}' >> "$GITHUB_OUTPUT" + elif [ "${{ inputs.scenario }}" = "stress" ]; then + echo 'matrix={"include":[{"name":"stress","class":"deflock.StressSimulation"}]}' >> "$GITHUB_OUTPUT" + elif [ "${{ inputs.scenario }}" = "burst" ]; then + echo 'matrix={"include":[{"name":"burst","class":"deflock.BurstSimulation"}]}' >> "$GITHUB_OUTPUT" + fi + + load-test: + needs: resolve-matrix + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.resolve-matrix.outputs.matrix) }} + steps: + - uses: actions/checkout@v5 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + # Caches Gradle wrapper and dependencies between runs + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Gatling simulation (${{ matrix.name }}) + working-directory: load-tests + run: ./gradlew gatlingRun --simulation ${{ matrix.class }} --non-interactive + + - name: Upload Gatling report + if: always() + uses: actions/upload-artifact@v4 + with: + name: gatling-report-${{ matrix.name }} + path: load-tests/build/reports/gatling/ + retention-days: 30 + + # Post a summary comment on the PR (if triggered from a PR branch) with + # links to download each report artifact. + summary: + needs: load-test + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Find associated PR + id: find-pr + run: | + # Search for a PR with this branch as the head ref. Check the + # current repo first, then the parent (upstream) repo if this is a fork. + PR=$(gh pr list --repo "${{ github.repository }}" --head "${{ github.ref_name }}" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -z "$PR" ]; then + PARENT=$(gh api "repos/${{ github.repository }}" --jq '.parent.full_name // empty' 2>/dev/null || echo "") + if [ -n "$PARENT" ]; then + PR=$(gh pr list --repo "$PARENT" --head "${{ github.ref_name }}" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$PR" ]; then + echo "pr_repo=$PARENT" >> "$GITHUB_OUTPUT" + fi + fi + else + echo "pr_repo=${{ github.repository }}" >> "$GITHUB_OUTPUT" + fi + echo "pr_number=$PR" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Post PR comment with report links + if: steps.find-pr.outputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + SCENARIO="${{ inputs.scenario }}" + RUN_ID="${{ github.run_id }}" + + # Build artifact links from the matrix + ARTIFACTS="" + if [ "$SCENARIO" = "all" ] || [ "$SCENARIO" = "baseline" ]; then + ARTIFACTS="$ARTIFACTS\n| Baseline | [Download]($RUN_URL/artifacts) |" + fi + if [ "$SCENARIO" = "all" ] || [ "$SCENARIO" = "concurrent" ]; then + ARTIFACTS="$ARTIFACTS\n| Concurrent | [Download]($RUN_URL/artifacts) |" + fi + if [ "$SCENARIO" = "all" ] || [ "$SCENARIO" = "stress" ]; then + ARTIFACTS="$ARTIFACTS\n| Stress | [Download]($RUN_URL/artifacts) |" + fi + if [ "$SCENARIO" = "all" ] || [ "$SCENARIO" = "burst" ]; then + ARTIFACTS="$ARTIFACTS\n| Burst | [Download]($RUN_URL/artifacts) |" + fi + + BODY=$(cat < Download the artifact ZIP → extract → open \`index.html\` for interactive charts. + EOF + ) + + gh pr comment "${{ steps.find-pr.outputs.pr_number }}" --repo "${{ steps.find-pr.outputs.pr_repo }}" --body "$BODY" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3b894d35..1656dcca 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -26,6 +26,46 @@ jobs: - name: Test run: flutter test + load-test-compile: + name: Load Test Compile & Unit Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 2 + + - name: Check for load-test changes + id: changes + run: | + if git diff --name-only HEAD~1 HEAD | grep -q '^load-tests/'; then + echo "changed=true" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "pull_request" ]; then + BASE=${{ github.event.pull_request.base.sha }} + if git diff --name-only "$BASE" HEAD | grep -q '^load-tests/'; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up JDK 21 + if: steps.changes.outputs.changed == 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Setup Gradle + if: steps.changes.outputs.changed == 'true' + uses: gradle/actions/setup-gradle@v4 + + - name: Compile & test + if: steps.changes.outputs.changed == 'true' + working-directory: load-tests + run: ./gradlew compileGatlingScala test + build-debug-apk: name: Build Debug APK runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index db2e32aa..5417a608 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,4 @@ build_keys.conf linux/ macos/ web/ -windows/ \ No newline at end of file +windows/ diff --git a/load-tests/.devcontainer/Dockerfile b/load-tests/.devcontainer/Dockerfile new file mode 100644 index 00000000..a7ca5004 --- /dev/null +++ b/load-tests/.devcontainer/Dockerfile @@ -0,0 +1,24 @@ +# Dev container image for running Gatling load tests. +# +# Based on Microsoft's Java 21 dev container, which includes: +# - JDK 21 (Eclipse Temurin) +# - Gradle (via the wrapper in the project) +# - Standard dev tools (git, curl, etc.) +# +# We add Coursier (the Scala package manager) for Scala tooling support +# in VS Code via the Metals extension. + +FROM mcr.microsoft.com/devcontainers/java:21 + +# Install Coursier for Scala tooling (used by the Metals VS Code extension). +# Note: the Scala compiler itself is managed by Gradle via the Gatling plugin, +# so we only need Coursier for IDE support. +# +# Pinned to a specific version with checksum verification to prevent +# supply chain attacks via mutable "latest" release URLs. +ARG COURSIER_VERSION=v2.1.24 +ARG COURSIER_SHA256=1517a0b6c4b9608dc45da8f34bc8290707ed50104ee92662f57808d2c012be54 +RUN curl -fL "https://github.com/coursier/coursier/releases/download/${COURSIER_VERSION}/cs-x86_64-pc-linux.gz" \ + | gzip -d > /usr/local/bin/cs \ + && echo "${COURSIER_SHA256} /usr/local/bin/cs" | sha256sum -c - \ + && chmod +x /usr/local/bin/cs diff --git a/load-tests/.devcontainer/devcontainer.json b/load-tests/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f9f5d2f4 --- /dev/null +++ b/load-tests/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// Dev container for the Gatling load test project. +// +// This provides a ready-to-go JVM + Scala environment without needing +// to install anything locally. Open the load-tests/ folder in VS Code +// and select "Dev Containers: Reopen in Container". +// +// Includes: +// - JDK 21 (Temurin) +// - Scala via Coursier +// - VS Code extensions: Scala Metals (IDE support) + Gradle (build tasks) +{ + "name": "Deflock Load Tests", + "build": { + "dockerfile": "Dockerfile" + }, + "workspaceFolder": "/workspaces/deflock-app/load-tests", + "customizations": { + "vscode": { + "extensions": [ + "scalameta.metals", + "vscjava.vscode-gradle" + ] + } + }, + // Pre-download all Gradle + Gatling dependencies so the first + // `./gradlew gatlingRun` is fast. + "postCreateCommand": "./gradlew dependencies" +} diff --git a/load-tests/.gitignore b/load-tests/.gitignore new file mode 100644 index 00000000..686141c9 --- /dev/null +++ b/load-tests/.gitignore @@ -0,0 +1,5 @@ +# Gradle build outputs (includes Gatling HTML reports in build/reports/gatling/) +build/ + +# Gradle cache +.gradle/ diff --git a/load-tests/CLAUDE.md b/load-tests/CLAUDE.md new file mode 100644 index 00000000..e24e293e --- /dev/null +++ b/load-tests/CLAUDE.md @@ -0,0 +1,64 @@ +# Load Tests + +Gatling load tests for `overpass.deflock.org`. Scala 2.13, Gradle build, JDK 21. + +## Architecture + +Three source sets to avoid circular dependencies: + +- **`src/shared/`** — Pure Scala logic (no Gatling dependency). `OverpassQuery` (query builder, tag filters, timeouts) and `TestData` (cities, viewports, feeders). +- **`src/gatling/`** — Gatling simulations. Depends on `shared`. Contains `OverpassRequests` (HTTP request def) and four simulations. +- **`src/test/`** — ScalaTest unit tests. Depends on `shared`. Tests pure logic without Gatling. + +The `shared` source set exists because the Gatling Gradle plugin creates a circular dependency if `test` depends on `gatling` output directly. + +## Key files + +| File | Purpose | +|---|---| +| `src/shared/scala/deflock/OverpassQuery.scala` | Query builder, tag filters (must match app), timeouts | +| `src/shared/scala/deflock/TestData.scala` | Cities, viewports, feeders (`randomFeeder`, `weightedZoomFeeder`) | +| `src/gatling/scala/deflock/OverpassRequests.scala` | Gatling HTTP request definition, `feederForZoom` | +| `src/gatling/scala/deflock/OverpassSimulation.scala` | Baseline: 1 user, all cities x all zooms, deterministic | +| `src/gatling/scala/deflock/ConcurrentSimulation.scala` | Ramp to 50 users, find degradation point | +| `src/gatling/scala/deflock/StressSimulation.scala` | Spike to 500 users, exceed server capacity | +| `src/gatling/scala/deflock/BurstSimulation.scala` | Realistic app sessions in waves | +| `build.gradle.kts` | Gradle config with `shared` source set, ScalaTest deps | + +## Commands + +```bash +./gradlew gatlingRun # baseline +./gradlew gatlingRun --simulation deflock.ConcurrentSimulation # concurrent +./gradlew gatlingRun --simulation deflock.StressSimulation # stress +./gradlew gatlingRun --simulation deflock.BurstSimulation # burst +./gradlew test # unit tests +./gradlew compileGatlingScala # compile check +``` + +Do NOT use `gatlingRun-deflock.ClassName` syntax — it doesn't work with the Gatling Gradle plugin v3.15.0. Use `--simulation` flag instead. + +## Tag filter parity + +`OverpassQuery.tagFilters` must exactly match the app's `NodeProfile.getDefaults()` in `lib/models/node_profile.dart`. There are 11 built-in profiles. Empty tag values (e.g., `camera:mount: ''`) are filtered out, matching what `OverpassService._buildQuery()` does in `lib/services/overpass_service.dart`. + +When profiles change in the app, update `tagFilters` to match. + +## Conventions + +- Baseline simulation must be deterministic (no randomization) for reproducible results. +- Concurrent/stress/burst simulations use randomized feeders for realistic traffic. +- `ThreadLocalRandom` (not `scala.util.Random`) for feeders used by concurrent simulations. +- Gatling session keys are constants in `OverpassQuery` (`CityName`, `ZoomLevel`, `QueryBody`). +- User-Agent headers identify load test traffic: `DeFlock/LoadTest-{Scenario}`. +- Client timeout = server timeout + 5s so we receive server-side timeout responses. + +## GitHub Actions + +The `Load Test` workflow (`.github/workflows/load-test.yml`) has a scenario picker dropdown. Reports are uploaded as artifacts. A summary job posts a comment on the PR with download links. + +Trigger via Actions tab or API: +```bash +gh api repos/{owner}/{repo}/actions/workflows/{id}/dispatches \ + -f ref=feat/load-tests -f 'inputs[scenario]=baseline' +``` diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 00000000..9dfd8700 --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,177 @@ +# Deflock Load Tests + +Gatling load tests for validating [`overpass.deflock.org`](https://overpass.deflock.org) performance before rolling it out as the primary Overpass API endpoint for all Deflock app users. + +## What is this? + +The Deflock app fetches surveillance camera data from the [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) every time a user pans or zooms the map. We've deployed our own Overpass instance at `overpass.deflock.org` to reduce dependence on the public endpoint. These load tests validate that our instance can handle realistic traffic patterns before we switch users over to it. + +The tests use [Gatling](https://gatling.io), an open-source load testing framework. Gatling simulates virtual users sending HTTP requests and produces detailed HTML reports with latency percentiles, error rates, and throughput metrics. + +## Quick start + +### Prerequisites + +- **JDK 21+** — install via [SDKMAN](https://sdkman.io) (`sdk install java 21-tem`) or your package manager +- **No other tools needed** — Gradle and Scala are handled automatically by the included wrapper and build config + +Or use the included [dev container](#dev-container) to skip local setup entirely. + +### Run the tests + +```bash +cd load-tests +./gradlew gatlingRun # baseline (default) +./gradlew gatlingRun --simulation deflock.ConcurrentSimulation # concurrent users +./gradlew gatlingRun --simulation deflock.StressSimulation # stress test +./gradlew gatlingRun --simulation deflock.BurstSimulation # burst traffic +``` + +When finished, Gatling prints a `file://` URL to the HTML report — open it in your browser. Reports land in `build/reports/gatling//index.html`. + +### Run via GitHub Actions + +1. Go to the **Actions** tab in GitHub +2. Select the **"Load Test"** workflow +3. Click **"Run workflow"** and pick a scenario from the dropdown: + - **baseline** — single-user zoom progression (~2 minutes) + - **concurrent** — ramp to 50 users over 4 minutes + - **stress** — spike to 500 users over 5 minutes + - **burst** — realistic wave pattern over ~3 minutes + - **all** — run all 4 in parallel on separate runners +4. When complete, download the **gatling-report-{name}** artifact (retained for 30 days) + +## Scenarios + +### Baseline (`OverpassSimulation`) + +A single virtual user walks through zoom levels z15→z10, querying every city at each level (6 zooms × 6 cities = 36 deterministic requests). Measures how response time scales with viewport size. + +- **Duration:** ~2 minutes +- **Assertions:** p99 < 30s, errors < 5% + +### Concurrent (`ConcurrentSimulation`) + +Ramps from 1 to 50 users over 2 minutes. Each user loops forever with random city/zoom picks and 500ms pauses until the 4-minute max duration. Designed to find the degradation inflection point — where does p95 start climbing? + +- **Duration:** 4 minutes (max) +- **Assertions:** p95 < 45s, errors < 20% +- **Look for:** inflection point in "Response time percentiles over time" chart + +### Stress (`StressSimulation`) + +Three phases totaling 500 users: warmup ramp (100 over 30s), spike (200 at once), sustained ramp (200 over 30s). Uses 100ms pauses and shared connections. Designed to exceed the server's ~512 Overpass compute slots. + +- **Duration:** 5 minutes (max) +- **Assertions:** none meaningful (just `requestsPerSec > 0`) +- **Expected failures:** 429s (slot exhaustion), 502/503s (nginx), timeouts +- **Look for:** when errors start, throughput plateau/collapse, bimodal latency + +### Burst (`BurstSimulation`) + +Models real app usage: each user does 10-20 requests (a map browsing session) then exits. Users arrive in waves (20→50→100→80) with gaps between waves. Uses weighted zoom feeder (80% z13-z15) matching real user behavior. + +- **Duration:** ~3 minutes +- **Assertions:** p95 < 30s, errors < 10% +- **Look for:** wave pattern in active users chart, recovery between bursts + +### Test data + +Six US cities were chosen for high surveillance camera density in their downtown areas: + +| City | Center coordinates | Landmark | +|---|---|---| +| Denver | 39.75, -105.00 | 16th St Mall / Union Station | +| Los Angeles | 34.05, -118.25 | Pershing Square, DTLA | +| San Francisco | 37.79, -122.40 | Financial District | +| New York | 40.75, -73.98 | Midtown / 42nd & 6th Ave | +| Boston | 42.36, -71.06 | Downtown Crossing | +| Chicago | 41.88, -87.63 | State & Madison, The Loop | + +### Zoom levels and viewport sizes + +Each zoom level corresponds to a different viewport size on a typical mobile phone screen (~400x800px, portrait): + +| Zoom | Area covered | Lat x Lng span | +|---|---|---| +| 15 | A few city blocks (~1.5 x 3 km) | 0.026 x 0.017 deg | +| 14 | A neighborhood (~3 x 6 km) | 0.053 x 0.034 deg | +| 13 | A district (~6 x 12 km) | 0.105 x 0.069 deg | +| 12 | A mid-size city (~12 x 23 km) | 0.210 x 0.140 deg | +| 11 | A large city (~23 x 47 km) | 0.420 x 0.270 deg | +| 10 | A metro region (~47 x 93 km) | 0.840 x 0.550 deg | + +## Interpreting the report + +The Gatling HTML report includes several views. Here's what to look for: + +### Key metrics + +- **p50 (median) latency** — what a typical user experiences +- **p95 latency** — should be under 10s for a good user experience +- **p99 latency** — should be under 30s (the assertion threshold) +- **Error rate** — should be 0% under single-user load + +### Report sections + +- **Response time distribution** — histogram showing how many requests fell into each latency bucket +- **Response time percentiles over time** — trend lines for p50/p75/p95/p99 throughout the test +- **Requests per second** — throughput over the test duration +- **Individual request details** — click any request name (e.g., "Overpass z15 - Denver") to see its specific metrics + +### What "good" looks like + +From our baseline runs, typical single-user performance is: + +| Zoom | Expected latency | +|---|---| +| z15 (blocks) | ~400-600ms | +| z13-z14 (neighborhood) | ~600-1000ms | +| z10-z11 (city/metro) | ~1000-1600ms | + +## Project structure + +``` +load-tests/ +├── .devcontainer/ # VS Code dev container (JDK 21 + Scala) +│ ├── devcontainer.json +│ └── Dockerfile +├── build.gradle.kts # Build config (Gatling + Scala + test plugins) +├── settings.gradle.kts # Gradle project name +├── gradlew / gradlew.bat # Gradle wrapper (no global install needed) +├── gradle/wrapper/ # Gradle wrapper jar + config +├── src/ +│ ├── shared/scala/deflock/ # Pure logic (no Gatling dependency) +│ │ ├── TestData.scala # City data, viewports, feeders, buildFeedEntry +│ │ └── OverpassQuery.scala# Query builder, timeouts, tag filters, constants +│ ├── gatling/ +│ │ ├── scala/deflock/ # Gatling simulations (depend on shared) +│ │ │ ├── OverpassSimulation.scala # Baseline: single-user zoom progression +│ │ │ ├── ConcurrentSimulation.scala # Ramp to 50 users +│ │ │ ├── StressSimulation.scala # Spike to 500 users +│ │ │ ├── BurstSimulation.scala # Realistic wave pattern +│ │ │ └── OverpassRequests.scala # Gatling HTTP request def + feederForZoom +│ │ └── resources/ +│ │ ├── gatling.conf # Gatling charting config +│ │ └── logback-test.xml +│ └── test/scala/deflock/ # Unit tests for shared logic (ScalaTest) +│ ├── TestDataSpec.scala +│ └── OverpassQuerySpec.scala +└── build/reports/gatling/ # Generated HTML reports (.gitignored) +``` + +The `shared` source set contains pure Scala logic with no Gatling dependency. Both the `gatling` source set (simulations) and the `test` source set (unit tests) depend on it. This avoids a circular dependency that would occur if tests depended directly on the Gatling source set. + +Run unit tests with `./gradlew test`. + +## Dev container + +If you don't want to install JDK locally, the included dev container provides a ready-to-go environment: + +1. Open the `load-tests/` folder in VS Code +2. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +3. Press `Ctrl+Shift+P` → "Dev Containers: Reopen in Container" +4. Wait for the container to build (first time takes a few minutes) +5. Open a terminal and run `./gradlew gatlingRun` + +The container includes JDK 21, Scala (via [Coursier](https://get-coursier.io)), and VS Code extensions for Scala (Metals) and Gradle. diff --git a/load-tests/build.gradle.kts b/load-tests/build.gradle.kts new file mode 100644 index 00000000..bfd4ea7a --- /dev/null +++ b/load-tests/build.gradle.kts @@ -0,0 +1,51 @@ +// Gatling load test build configuration. +// +// Gatling (https://gatling.io) is a load testing framework that simulates +// virtual users sending HTTP requests and produces HTML performance reports. +// +// The `scala` plugin compiles our Scala simulation files. +// The `io.gatling.gradle` plugin adds the `gatlingRun` task and manages +// Gatling + Scala library dependencies automatically. +// +// Run simulations: ./gradlew gatlingRun +// Run unit tests: ./gradlew test +// Reports: build/reports/gatling/ + +plugins { + scala + id("io.gatling.gradle") version "3.15.0" +} + +repositories { + mavenCentral() +} + +// --- Source sets --- +// "shared" contains pure Scala logic (TestData, OverpassQuery) with no Gatling +// dependency. Both the "gatling" source set (simulations) and "test" source set +// (unit tests) depend on it. This avoids the circular dependency that would +// occur if tests depended directly on the gatling source set (since the Gatling +// plugin makes gatling extend test). + +sourceSets { + create("shared") +} + +dependencies { + // shared source set needs Scala stdlib (provided transitively by Gatling, + // but shared compiles independently) + "sharedImplementation"("org.scala-lang:scala-library:2.13.16") + + // Gatling simulations use shared logic + "gatlingImplementation"(sourceSets["shared"].output) + + // Unit tests use shared logic + ScalaTest + testImplementation(sourceSets["shared"].output) + testImplementation("org.scalatest:scalatest_2.13:3.2.19") + testImplementation("org.scalatestplus:junit-5-11_2.13:3.2.19.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/load-tests/gradle/wrapper/gradle-wrapper.jar b/load-tests/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 zCZTgG2Pr3n8|ec9kIu&uC|d?k4-cQ4#}Z`qDX5Y2mhC(jR1Ms;UG4Ho$DE|+SeJ@{ zJQQhAXj|<)*t3KiOWTuh{Wd^mS{u{&ERV)OpZwiQ%#1->r9p zSK_^*U~=?ywH~4IUxb}{0J!SmL!z2Tzq_PpetoC^_az1JFg0=gMcQADuOP%3=H1hH zH_=dG(PD;d*037Ov5G1924U#Zns?~fs+eh1%-bWqa%ssm3=nio1r3J<4G0IBETtr? zycs~0JIOn;MecYG=~OQsYHIrf?~A5>_ob%8+uOrVA+VCJw}{lygrBBdY1k<8B^wf6 zl|<%N$7)fOZX$%y>4ueco_Gb1H@B%XrKVwrn6hUOecnc^PU0rFuCB5=*2;|u-`o(@ zL*tr4bnQzXYLc4XqFbv5sK0}A)`}`8iM8ehtj#Oc5DrE;0VxbPmL@BUa_BQwa$EW~sU#-LP0?sGmqfUGhGWcciGZ*4(}u3z=@b>Ow9DQe7lcO3K}BG3j(t& zH10>sK!&4Q5-=gN@Nxj6{|*nuyqw7KZJ1?p)NUJ?U0bOigGdsOk}Iz&9PmN_5=W*Z9M zy^pA`&dX0oo6?CSuhE~(pYbLuTPp1a1Fa@e3Lu&mmgd$;D}&g-i=D-{sv?J9kIr9r zrX&Z)aFGK^kNY{LxrotP0}k*;uN12i_2a_JJhKwh zBt{D-JRxC$8U+-`u1xD>gJ^H4lbW;7spI-=H506i=ncdK;xq*L6f7jVz$XGMg5aQk zHRJY&$@g}i_SP##iC?lR?ltnWUTT-UDlq(*BTQaYNkg zNG#sNoo{WmP+Vl}U~?+T?g25b$E-7iwhu=VVgw3JdFXm~ba+LC4p>CP3~rNTiNBl7 zL{RfLLepNPEtZj}yL_#R{(^MqIlG)c0Va}>U|9Pl&B_3tV;Ps{r)WqBznD7FcTlP4 z`JQe2DvGhmeeHGGX39zGyOOxZ3tq~Dft(BQ;mDXwwJi?sBtxo$Gf1SS2w*eQ0p&RVMNVi@d zY8v4J0(n}%6*Rw(g~l@sUuxpiJ*Y}7TzBQyU+>-qWm*InUeGt@)T9g^0J#z4){Lw* zT;69if~U9DXBR9fgVPlYy7aDhJU)gDC?_GHQtwa6QXNaah7-CzA|Fx-lH7d@N9>38 zX(F&fd3w7AkZ+ha8-gKfX%@_~<#HDs?kBg5zW>V3%Xw5jwPs6uni{7r zd`EfPYrA*SU;xDtm@E>5TrJKlg5o=h;NSXk)pt4K)GbpP0xkUg>2o|oG=`UnX7^Un zb&@8d6Fj1cBWW^c(K#Csc8xEBa4KfHY>8Lp^77-lhzgWr9kR9_p+g|-9r?VSv?qA%^1O;cqgke)%AqHlR$B{!Y1Mq zj|)Ecg?{_!>kGDAwGa7%cwSUb{BcayJihkv$}ql+yu=O}jVvAFdC{Hjh$4}u+$mx% z5V$sUiGCX%D3A>bKwY8HR)Gv*lisI4q^3vJ*nDwj|mtr!0r!~+Qoe2cw^jPCXkT7tI*01|w@ z&gPC`?O1w7hQ%=&bcHi7(fqhY3${~JepA7y@^aLwHpew^Yk$;R4v{ASHjXjXtaTc_ zuz5*nXB&PrcyWx#gQ%?HyxawmS+Wu(7ssvB1UMh!1$to&o(mv_f=9~!9@VsJCGxpu z`>g5Sp=xDhpsiCy^y>=fI0DON$&pb7o7^d{@@&hj3!6PUd=vA;G;#7&8ChamsE{`^ zY8pDra8Jntp62Ivi)Y`*XbpM60s06v@Rz^-g)TW_F@B!~y7!4AJ>37mAuz!(!C+xQ zSR61?u!{N|qHWOeR%$RXRL~vpN0SGri7-klNHEJuivbi=0qSbdV4&ghf4i|7?$>z( zI{qH?i}`~a7GyB6|8pZRq982+P*r1+m-t&(%U5#ZWFQd-(CXKLHeN@y(c z;wqq1hzE@q1b$GG0VQ_)`{MeylBlVfy%UHR=;Z98>T3M&;{0i?+0T-Bck?I)AUQrz zeF**_iGu$JlCpLnFv`D9?q6R51jKPM{Rd6!0FF#KP=O|b3iQX*TqXSjO?gXaXAmLr zU#g&%@+XpjVArlGkfaPKk^PUSnMLsjlK<9nH*zxl^V2-jGC$4+HGE%?F3%4|y9>HN z|FJgz*HW$VwU8$RNtuBf(2vdZhW3x;R6%eoJM(|2zvKebxCh$s5J-*fhZ75B_yeUs zFTrToFiB^SNH?gV2>l?G&h!UD>UP%uKh1L;Er59!q&NoZRe$VEf?5Ar^&iUad&2gQ z&WE`E%lTg=_3XQT@gJOjkAi-Hbbqrl{(pA<>_GH4O8+xI^=IAhS#v+$vmgOK=>C!~_xFg-pLM>6kUfy=zL|u~KkNJ< z$L?p*?;%(Ze6w%%M(zjE|4dH&5$)_}mG3z{KUQ6s!Y@_+kInPH;kAC&{T^5HKmqz@ z@+!aA{YNIy&r;uKTz=r6e6v>d-%9<%_4R!+-iN^8H#0N(rQbiu-u&}-|2`q@k1agM zdHkW_1&%VDD_|I;NpK*OZfAjAb z`Ttl8km0{|{F`kWKWltH$^Ech;G2y`{7&N^%H;d0$cGv7Z^oJNOSiwAFaP<=em}wX z<8AA6<}bbeZc_7S=ii6PALi)3nOXL)o&Uj%-OnQ52M&L%(%ZaWiu^(R{b!Bu2WJl< h$Zw`p^gE5e2}ml*LW4$nU|{5+pXG<~Ugg7I{||-5t(pJ; literal 0 HcmV?d00001 diff --git a/load-tests/gradle/wrapper/gradle-wrapper.properties b/load-tests/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..dbc3ce4a --- /dev/null +++ b/load-tests/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/load-tests/gradlew b/load-tests/gradlew new file mode 100755 index 00000000..d06d3135 --- /dev/null +++ b/load-tests/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b5fe9efed6cae7b9f2fbdb2d380fb69af16bb752/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/load-tests/gradlew.bat b/load-tests/gradlew.bat new file mode 100644 index 00000000..bd8a8c05 --- /dev/null +++ b/load-tests/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/load-tests/settings.gradle.kts b/load-tests/settings.gradle.kts new file mode 100644 index 00000000..7bb784cb --- /dev/null +++ b/load-tests/settings.gradle.kts @@ -0,0 +1,2 @@ +// Gradle project name — only affects build output naming, not functionality. +rootProject.name = "deflock-load-tests" diff --git a/load-tests/src/gatling/resources/gatling.conf b/load-tests/src/gatling/resources/gatling.conf new file mode 100644 index 00000000..a35bec22 --- /dev/null +++ b/load-tests/src/gatling/resources/gatling.conf @@ -0,0 +1,19 @@ +// Gatling configuration. +// See https://docs.gatling.io/reference/script/core/configuration/ for all options. +// +// The Gradle plugin handles most settings (like report output directory). +// This file only overrides charting thresholds for the HTML report. + +gatling { + charting { + // These thresholds define the color bands in the response time + // distribution chart in the HTML report: + // Green: < lowerBound (under 1 second — great) + // Yellow: lowerBound to higherBound (1-5 seconds — acceptable) + // Red: > higherBound (over 5 seconds — needs attention) + indicators { + lowerBound = 1000 + higherBound = 5000 + } + } +} diff --git a/load-tests/src/gatling/resources/logback-test.xml b/load-tests/src/gatling/resources/logback-test.xml new file mode 100644 index 00000000..4eade1fe --- /dev/null +++ b/load-tests/src/gatling/resources/logback-test.xml @@ -0,0 +1,23 @@ + + + + + + %d{HH:mm:ss.SSS} [%-5level] %logger{36} - %msg%n + + + + + + + diff --git a/load-tests/src/gatling/scala/deflock/BurstSimulation.scala b/load-tests/src/gatling/scala/deflock/BurstSimulation.scala new file mode 100644 index 00000000..30b7b577 --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/BurstSimulation.scala @@ -0,0 +1,75 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ +import java.util.concurrent.ThreadLocalRandom + +/** + * Realistic burst simulation modeling actual Deflock app usage patterns. + * + * Each virtual user represents one app session: the user opens the app, + * pans/zooms the map 10-20 times (each interaction triggers an Overpass + * query), then closes the app. Pauses between requests are 200-800ms, + * matching the time it takes to pan/zoom on a phone. + * + * Users arrive in waves, simulating the bursty nature of real traffic + * (e.g., morning commutes, lunch breaks): + * Wave 1: 20 users over 15s (light morning traffic) + * Gap: 10s + * Wave 2: 50 users over 15s (mid-morning pickup) + * Gap: 10s + * Wave 3: 100 users over 20s (lunch rush) + * Gap: 10s + * Wave 4: 80 users over 30s (sustained afternoon) + * + * Uses the weighted zoom feeder (z13-z15 heavy) since most real users + * are zoomed in to neighborhood level. + * + * == What to look for in the report == + * + * - Response time should stay relatively stable during waves 1-2, then + * may degrade during wave 3 (the peak). + * - The "Active users over time" chart should show the wave pattern. + * - Compare error rates between waves to see if the server recovers + * between bursts. + * + * == Running == + * {{{ + * cd load-tests + * ./gradlew gatlingRun --simulation deflock.BurstSimulation + * }}} + */ +class BurstSimulation extends Simulation { + + val httpProtocol = http + .baseUrl("https://overpass.deflock.org") + .userAgentHeader("DeFlock/LoadTest-Burst (+https://deflock.org)") + .acceptHeader("application/json") + + // Each user does 10-20 requests (a realistic app session), then exits. + val burstScenario = scenario("Burst app sessions") + .repeat(session => 10 + ThreadLocalRandom.current().nextInt(11)) { + feed(TestData.weightedZoomFeeder) + .exec(OverpassRequests.overpassRequest) + .pause(200.milliseconds, 800.milliseconds) + } + + setUp( + burstScenario.inject( + rampUsers(20).during(15.seconds), // Wave 1: light traffic + nothingFor(10.seconds), + rampUsers(50).during(15.seconds), // Wave 2: mid-morning + nothingFor(10.seconds), + rampUsers(100).during(20.seconds), // Wave 3: lunch rush + nothingFor(10.seconds), + rampUsers(80).during(30.seconds) // Wave 4: sustained afternoon + ) + ).protocols(httpProtocol) + .maxDuration(10.minutes) + .assertions( + global.responseTime.percentile(95).lt(30000), + global.failedRequests.percent.lt(10.0) + ) +} diff --git a/load-tests/src/gatling/scala/deflock/ConcurrentSimulation.scala b/load-tests/src/gatling/scala/deflock/ConcurrentSimulation.scala new file mode 100644 index 00000000..dcc0c1dc --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/ConcurrentSimulation.scala @@ -0,0 +1,56 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +/** + * Concurrent user simulation to find the degradation point. + * + * Ramps from 1 to 50 virtual users over 2 minutes. Each user loops + * forever (pick a random city/zoom, send the query, pause 500ms, repeat) + * until maxDuration (4 minutes total) is reached. + * + * The server has ~512 Overpass compute slots behind nginx. This simulation + * should reveal where latency starts climbing (slot contention) without + * pushing into outright failure territory. + * + * == What to look for in the report == + * + * - The "Response time percentiles over time" chart should show a clear + * inflection point where p95 starts climbing — that's the concurrency + * level where the server begins queuing requests. + * - The "Active users over time" chart maps directly to user count. + * - Compare p50 at 10 users vs 40 users to see degradation magnitude. + * + * == Running == + * {{{ + * cd load-tests + * ./gradlew gatlingRun --simulation deflock.ConcurrentSimulation + * }}} + */ +class ConcurrentSimulation extends Simulation { + + val httpProtocol = http + .baseUrl("https://overpass.deflock.org") + .userAgentHeader("DeFlock/LoadTest-Concurrent (+https://deflock.org)") + .acceptHeader("application/json") + + val concurrentScenario = scenario("Concurrent users") + .forever { + feed(TestData.randomFeeder) + .exec(OverpassRequests.overpassRequest) + .pause(500.milliseconds) + } + + setUp( + concurrentScenario.inject(rampUsers(50).during(2.minutes)) + ).protocols(httpProtocol) + .maxDuration(4.minutes) + .assertions( + // Lenient thresholds — we're exploring capacity, not enforcing SLA + global.responseTime.percentile(95).lt(45000), + global.failedRequests.percent.lt(20.0) + ) +} diff --git a/load-tests/src/gatling/scala/deflock/OverpassRequests.scala b/load-tests/src/gatling/scala/deflock/OverpassRequests.scala new file mode 100644 index 00000000..a2b0ec2c --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/OverpassRequests.scala @@ -0,0 +1,54 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +/** + * Gatling-specific Overpass API request definitions and feeders. + * + * Pure logic (query building, constants, timeouts) lives in OverpassQuery + * (shared source set). This object adds the Gatling HTTP DSL wrappers and + * the feederForZoom method that uses Gatling's .random extension. + * + * The request format mirrors the Deflock app (POST to /api/interpreter + * with form-encoded Overpass QL) using the same per-profile tag filters. + * See OverpassQuery.tagFilters for details. + */ +object OverpassRequests { + + /** + * Create a Gatling feeder that picks a random city for a given zoom level. + * + * A "feeder" in Gatling is a data source that injects variables into the + * virtual user's session before each request. This one pre-computes the + * Overpass query body so it's built once at startup, not on every request. + * + * @param viewport The zoom level and its corresponding viewport dimensions + * @return A Gatling feeder that randomly selects a city and provides session + * variables: cityName, zoomLevel, and the pre-built queryBody + */ + def feederForZoom(viewport: ZoomViewport) = + TestData.cities.map(TestData.buildFeedEntry(_, viewport)).toIndexedSeq.random + + /** + * The HTTP request definition that Gatling will execute. + * + * Uses Gatling's #{...} Expression Language syntax to inject session + * variables at request time. These variables are populated by the feeders + * in TestData — see feederForZoom() and the infinite feeders. + * + * The request name (e.g., "Overpass z15 - Denver") appears in the Gatling + * HTML report, making it easy to compare performance across zoom levels + * and cities. + * + * Checks: + * - HTTP 200 status (Overpass returns 200 even for empty results) + * - Response body contains an "elements" array (valid Overpass JSON) + */ + val overpassRequest = http("Overpass z#{zoomLevel} - #{cityName}") + .post("/api/interpreter") + .formParam("data", "#{queryBody}") + .requestTimeout(OverpassQuery.clientTimeout) + .check(status.is(200)) + .check(jsonPath("$.elements").exists) +} diff --git a/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala b/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala new file mode 100644 index 00000000..851837ee --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala @@ -0,0 +1,75 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +/** + * Gatling simulation for load-testing the Deflock Overpass API endpoint. + * + * This simulation validates the performance of overpass.deflock.org before + * it becomes the primary endpoint for all Deflock app users. It sends + * Overpass queries using the same per-profile tag filters as the app + * (see OverpassQuery.tagFilters and lib/models/node_profile.dart). + * + * == How it works == + * + * A single virtual user walks through zoom levels from tightest (z15, a few + * city blocks) to widest (z10, a metro region). At each zoom level, it + * queries every city deterministically — no randomization — so results are + * reproducible and city-to-city variance is visible in the report. + * + * This progression reveals how response time scales with viewport size — + * larger viewports return more surveillance nodes, producing bigger responses. + * + * == Running == + * + * {{{ + * cd load-tests + * ./gradlew gatlingRun + * }}} + * + * The HTML report will be in build/reports/gatling/ — open index.html. + * + */ +class OverpassSimulation extends Simulation { + + // Target our self-hosted Overpass instance (not the public OSMF one). + // The User-Agent identifies load test traffic in server logs. + val httpProtocol = http + .baseUrl("https://overpass.deflock.org") + .userAgentHeader("DeFlock/LoadTest (+https://deflock.org)") + .acceptHeader("application/json") + + // Walk through zoom levels from tightest (z15) to widest (z10). + // At each zoom level, query every city deterministically so results + // are reproducible and per-city variance is visible in the report. + // + // The nested flatMap produces a chain of (zoom × city) steps. For + // 6 zoom levels × 6 cities = 36 total requests with 500ms pauses. + val baselineScenario = scenario("Single-user zoom progression") + .exec( + TestData.zoomViewports.flatMap { viewport => + TestData.cities.map { city => + exec(_.setAll(TestData.buildFeedEntry(city, viewport))) + .exec(OverpassRequests.overpassRequest) + .pause(500.milliseconds) + } + }.reduce(_.exec(_)) + ) + + // --- Test setup --- + // atOnceUsers(1): inject exactly 1 virtual user immediately (no ramp-up). + // This is a baseline test — we want clean, isolated measurements before + // adding concurrency in future scenarios. + setUp( + baselineScenario.inject(atOnceUsers(1)) + ).protocols(httpProtocol) + .assertions( + // p99 response time under 30 seconds (generous for Overpass) + global.responseTime.percentile(99).lt(30000), + // Less than 5% of requests should fail + global.failedRequests.percent.lt(5.0) + ) +} diff --git a/load-tests/src/gatling/scala/deflock/StressSimulation.scala b/load-tests/src/gatling/scala/deflock/StressSimulation.scala new file mode 100644 index 00000000..43db2560 --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/StressSimulation.scala @@ -0,0 +1,72 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +/** + * Stress simulation designed to exceed the server's 512 Overpass compute slots. + * + * Three phases totaling 500 virtual users: + * 1. Warmup: ramp to 100 users over 30s + * 2. Spike: inject 200 users at once + * 3. Sustained: ramp another 200 users over 30s + * + * Each user fires requests with only 100ms pauses — deliberately aggressive + * to saturate nginx connections and exhaust Overpass slots. + * + * `shareConnections` is enabled so that virtual users share the HTTP + * connection pool. Without this, 500 users would try to open 500 separate + * TCP connections to the server, and the test would fail on the client side + * (socket exhaustion) before stressing the server. + * + * == Expected failure modes (in order) == + * + * 1. Overpass slot exhaustion → 429 or "rate_limited" responses + * 2. nginx max connections → 502/503 gateway errors + * 3. Disk I/O saturation → timeouts on wide-zoom queries + * + * == What to look for in the report == + * + * - Error rate timeline: when do errors start, and what type? + * - Response time distribution: is there a bimodal pattern (fast cache hits + * vs slow/failed queries)? + * - Requests/sec: does throughput plateau or collapse? + * + * == Running == + * {{{ + * cd load-tests + * ./gradlew gatlingRun --simulation deflock.StressSimulation + * }}} + */ +class StressSimulation extends Simulation { + + val httpProtocol = http + .baseUrl("https://overpass.deflock.org") + .userAgentHeader("DeFlock/LoadTest-Stress (+https://deflock.org)") + .acceptHeader("application/json") + .shareConnections + + val stressScenario = scenario("Stress test") + .forever { + feed(TestData.randomFeeder) + .exec(OverpassRequests.overpassRequest) + .pause(100.milliseconds) + } + + setUp( + stressScenario.inject( + rampUsers(100).during(30.seconds), // Phase 1: warmup + nothingFor(1.second), // Brief gap so phases are visible in report + atOnceUsers(200), // Phase 2: spike + rampUsers(200).during(30.seconds) // Phase 3: sustained pressure + ) + ).protocols(httpProtocol) + .maxDuration(5.minutes) + .assertions( + // No meaningful SLA — this test is designed to break things. + // Just verify the test itself ran and produced data. + global.requestsPerSec.gt(0.0) + ) +} diff --git a/load-tests/src/shared/scala/deflock/OverpassQuery.scala b/load-tests/src/shared/scala/deflock/OverpassQuery.scala new file mode 100644 index 00000000..52470e48 --- /dev/null +++ b/load-tests/src/shared/scala/deflock/OverpassQuery.scala @@ -0,0 +1,91 @@ +package deflock + +import scala.concurrent.duration._ + +/** + * Pure Overpass API query logic — no Gatling dependency. + * + * Contains the query builder, tag filters, timeouts, and feeder session + * key constants. The Gatling HTTP request definition that uses these lives + * in OverpassRequests (in the gatling source set). + */ +object OverpassQuery { + + // --- Timeouts --- + // The Overpass QL query tells the server to abort after this many seconds. + // This matches kOverpassQueryTimeout in the app (lib/dev_config.dart). + val serverTimeoutSeconds = 45 + + // The HTTP client timeout is slightly longer than the server timeout so that + // we always receive the server's own timeout error response (a 200 with a + // "remark" field) rather than the client aborting the connection first. + val clientTimeout: FiniteDuration = (serverTimeoutSeconds + 5).seconds + + // --- Overpass tag filters --- + // One entry per built-in NodeProfile in the app (lib/models/node_profile.dart). + // Each string is the concatenated tag filters for that profile, with empty + // values (e.g., camera:mount='') filtered out — exactly as the app's + // OverpassService._buildQuery() does. Keep this list in sync with + // NodeProfile.getDefaults(). + val tagFilters: Seq[String] = Seq( + // generic-alpr + """["man_made"="surveillance"]["surveillance:type"="ALPR"]""", + // flock + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="ALPR"]["surveillance:zone"="traffic"]["camera:type"="fixed"]["manufacturer"="Flock Safety"]["manufacturer:wikidata"="Q108485435"]""", + // motorola + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="ALPR"]["surveillance:zone"="traffic"]["camera:type"="fixed"]["manufacturer"="Motorola Solutions"]["manufacturer:wikidata"="Q634815"]""", + // genetec + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="ALPR"]["surveillance:zone"="traffic"]["camera:type"="fixed"]["manufacturer"="Genetec"]["manufacturer:wikidata"="Q30295174"]""", + // leonardo + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="ALPR"]["surveillance:zone"="traffic"]["camera:type"="fixed"]["manufacturer"="Leonardo"]["manufacturer:wikidata"="Q910379"]""", + // neology + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="ALPR"]["surveillance:zone"="traffic"]["camera:type"="fixed"]["manufacturer"="Neology, Inc."]""", + // rekor + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="ALPR"]["surveillance:zone"="traffic"]["camera:type"="fixed"]["manufacturer"="Rekor"]""", + // axis + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="ALPR"]["surveillance:zone"="traffic"]["camera:type"="fixed"]["manufacturer"="Axis Communications"]["manufacturer:wikidata"="Q2347731"]""", + // generic-gunshot + """["man_made"="surveillance"]["surveillance:type"="gunshot_detector"]""", + // shotspotter + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="gunshot_detector"]["surveillance:brand"="ShotSpotter"]["surveillance:brand:wikidata"="Q107740188"]""", + // flock-raven + """["man_made"="surveillance"]["surveillance"="public"]["surveillance:type"="gunshot_detector"]["brand"="Flock Safety"]["brand:wikidata"="Q108485435"]""" + ) + + // --- Feeder session keys --- + // These constants are the variable names injected into each virtual user's + // session by the feeders in TestData. Using constants here (instead of raw + // strings) prevents typos that would silently break at runtime. + val CityName = "cityName" + val ZoomLevel = "zoomLevel" + val QueryBody = "queryBody" + + /** + * Build an Overpass QL query string for the given bounding box. + * + * The query structure mirrors the app's OverpassService._buildQuery(): + * union node clauses in a bbox, then fetch parent ways/relations. Uses + * the same per-profile tag filters as the app (see tagFilters above). + * + * Overpass bbox format is (south, west, north, east) — note this is + * different from many mapping libraries that use (west, south, east, north). + * + * @return A complete Overpass QL query string ready to POST + */ + def buildQuery(south: Double, west: Double, north: Double, east: Double): String = { + val nodeClauses = tagFilters.map { tags => + s" node$tags($south,$west,$north,$east);" + }.mkString("\n") + + s"""[out:json][timeout:$serverTimeoutSeconds]; + |( + |$nodeClauses + |); + |out body; + |( + | way(bn); + | rel(bn); + |); + |out skel;""".stripMargin + } +} diff --git a/load-tests/src/shared/scala/deflock/TestData.scala b/load-tests/src/shared/scala/deflock/TestData.scala new file mode 100644 index 00000000..9b63fa85 --- /dev/null +++ b/load-tests/src/shared/scala/deflock/TestData.scala @@ -0,0 +1,133 @@ +package deflock + +import java.util.concurrent.ThreadLocalRandom + +/** + * Center coordinates for a city's downtown area. + * + * These are the starting points for building map viewport bounding boxes. + * Each coordinate was verified against map data and chosen for its high + * density of surveillance infrastructure (cameras, ALPR, etc.), which + * produces realistic Overpass API response sizes. + * + * @param name Human-readable city name (appears in Gatling report labels) + * @param lat Latitude of the downtown center point + * @param lng Longitude of the downtown center point + */ +case class CityCenter(name: String, lat: Double, lng: Double) + +/** + * The dimensions of a map viewport at a given zoom level. + * + * These represent what a user sees on their phone screen at each zoom level. + * Larger viewports (lower zoom) fetch more data from the Overpass API, so + * we use these to measure how response time scales with area. + * + * @param zoom OSM/Slippy map zoom level (10 = metro region, 15 = a few blocks) + * @param latSpan Height of the viewport in degrees of latitude + * @param lngSpan Width of the viewport in degrees of longitude + */ +case class ZoomViewport(zoom: Int, latSpan: Double, lngSpan: Double) + +object TestData { + + /** + * US cities with verified downtown coordinates targeting high-surveillance areas. + * + * Each city was chosen because its downtown has significant camera density, + * producing realistic query results. The coordinates point to specific + * well-known locations in each city's central business district. + */ + val cities: Seq[CityCenter] = Seq( + CityCenter("Denver", 39.7478, -104.9995), // 16th St Mall / Union Station + CityCenter("Los Angeles", 34.0483, -118.2530), // Pershing Square, DTLA + CityCenter("San Francisco", 37.7946, -122.3999), // Financial District / Market & Montgomery + CityCenter("New York", 40.7549, -73.9840), // Midtown / 42nd & 6th Ave + CityCenter("Boston", 42.3567, -71.0588), // Downtown Crossing + CityCenter("Chicago", 41.8783, -87.6258) // State & Madison, The Loop + ) + + /** + * Map viewport sizes for zoom levels 10 through 15. + * + * Calculated for a ~400x800px mobile screen (portrait orientation) at ~40 deg N + * latitude using standard OSM/Slippy map tile math (Mercator projection, + * 256px tiles). Each zoom level doubles the tile count, halving the viewport span. + * + * | Zoom | Approx area covered | Example | + * |------|-----------------------|------------------------------| + * | 15 | ~1.5 x 3 km | A few city blocks | + * | 14 | ~3 x 6 km | A neighborhood | + * | 13 | ~6 x 12 km | A district | + * | 12 | ~12 x 23 km | A mid-size city | + * | 11 | ~23 x 47 km | A large city extent | + * | 10 | ~47 x 93 km | A metro region | + * + * Ordered from tightest to widest so the simulation can walk through them + * and show the performance impact of increasing viewport size. + */ + val zoomViewports: Seq[ZoomViewport] = Seq( + ZoomViewport(15, 0.026, 0.017), + ZoomViewport(14, 0.053, 0.034), + ZoomViewport(13, 0.105, 0.069), + ZoomViewport(12, 0.210, 0.140), + ZoomViewport(11, 0.420, 0.270), + ZoomViewport(10, 0.840, 0.550) + ) + + /** + * Build the Gatling session variables for a city/viewport combination. + * + * Centers the viewport on the city's downtown coordinates and pre-builds + * the Overpass query body. All feeders delegate to this method. + */ + def buildFeedEntry(city: CityCenter, viewport: ZoomViewport): Map[String, Any] = { + val south = city.lat - viewport.latSpan / 2 + val north = city.lat + viewport.latSpan / 2 + val west = city.lng - viewport.lngSpan / 2 + val east = city.lng + viewport.lngSpan / 2 + + Map( + OverpassQuery.CityName -> city.name, + OverpassQuery.ZoomLevel -> viewport.zoom, + OverpassQuery.QueryBody -> OverpassQuery.buildQuery(south, west, north, east) + ) + } + + /** + * Infinite feeder that picks a random city and random zoom level each call. + * + * Unlike feederForZoom (which fixes a zoom level), this is useful for + * concurrent and stress simulations where zoom progression doesn't matter — + * we just want a stream of realistic, varied requests. + * + * Produces the same session keys (cityName, zoomLevel, queryBody) so it + * works with the existing overpassRequest definition. + */ + val randomFeeder: Iterator[Map[String, Any]] = Iterator.continually { + val rng = ThreadLocalRandom.current() + buildFeedEntry(cities(rng.nextInt(cities.length)), zoomViewports(rng.nextInt(zoomViewports.length))) + } + + /** + * Infinite feeder weighted toward zoom levels 13-15 (~80% of picks). + * + * Models real app usage: most users are zoomed in to neighborhood/district + * level (z13-z15). Only ~20% of requests come from zoomed-out views + * (z10-z12) where users are browsing before zooming in. + * + * Weight distribution (matches zoomViewports order, tightest to widest): + * z15: 30%, z14: 25%, z13: 25%, z12: 10%, z11: 5%, z10: 5% + */ + val weightedZoomFeeder: Iterator[Map[String, Any]] = { + // Weights parallel zoomViewports order (z15 first, z10 last). + val weights = Seq(6, 5, 5, 2, 1, 1) + val weightedPool: IndexedSeq[ZoomViewport] = + zoomViewports.zip(weights).flatMap { case (vp, count) => Seq.fill(count)(vp) }.toIndexedSeq + + Iterator.continually { + val rng = ThreadLocalRandom.current() + buildFeedEntry(cities(rng.nextInt(cities.length)), weightedPool(rng.nextInt(weightedPool.length))) + } + } +} diff --git a/load-tests/src/test/scala/deflock/OverpassQuerySpec.scala b/load-tests/src/test/scala/deflock/OverpassQuerySpec.scala new file mode 100644 index 00000000..e9af1356 --- /dev/null +++ b/load-tests/src/test/scala/deflock/OverpassQuerySpec.scala @@ -0,0 +1,56 @@ +package deflock + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class OverpassQuerySpec extends AnyFlatSpec with Matchers { + + "buildQuery" should "include the server timeout" in { + val query = OverpassQuery.buildQuery(39.0, -105.0, 40.0, -104.0) + query should include (s"[timeout:${OverpassQuery.serverTimeoutSeconds}]") + } + + it should "request JSON output" in { + val query = OverpassQuery.buildQuery(39.0, -105.0, 40.0, -104.0) + query should include ("[out:json]") + } + + it should "include the bounding box in Overpass order (south,west,north,east)" in { + val query = OverpassQuery.buildQuery(39.0, -105.0, 40.0, -104.0) + query should include ("(39.0,-105.0,40.0,-104.0)") + } + + it should "include all configured tag filters" in { + val query = OverpassQuery.buildQuery(39.0, -105.0, 40.0, -104.0) + OverpassQuery.tagFilters.foreach { filter => + query should include (filter) + } + } + + it should "include parent way and relation lookups" in { + val query = OverpassQuery.buildQuery(39.0, -105.0, 40.0, -104.0) + query should include ("way(bn)") + query should include ("rel(bn)") + } + + it should "end with out skel for parent geometry" in { + val query = OverpassQuery.buildQuery(39.0, -105.0, 40.0, -104.0) + query should endWith ("out skel;") + } + + "tagFilters" should "have one entry per app profile (11 built-in profiles)" in { + OverpassQuery.tagFilters should have size 11 + } + + it should "all start with man_made=surveillance" in { + every(OverpassQuery.tagFilters) should include ("""["man_made"="surveillance"]""") + } + + "serverTimeoutSeconds" should "be positive" in { + OverpassQuery.serverTimeoutSeconds should be > 0 + } + + "clientTimeout" should "be longer than server timeout" in { + OverpassQuery.clientTimeout.toSeconds should be > OverpassQuery.serverTimeoutSeconds.toLong + } +} diff --git a/load-tests/src/test/scala/deflock/TestDataSpec.scala b/load-tests/src/test/scala/deflock/TestDataSpec.scala new file mode 100644 index 00000000..8138bd60 --- /dev/null +++ b/load-tests/src/test/scala/deflock/TestDataSpec.scala @@ -0,0 +1,127 @@ +package deflock + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class TestDataSpec extends AnyFlatSpec with Matchers { + + // --- Test data constants --- + + "cities" should "contain 6 entries" in { + TestData.cities should have length 6 + } + + it should "have unique names" in { + val names = TestData.cities.map(_.name) + names.distinct should have length names.length.toLong + } + + it should "have valid coordinates (US mainland)" in { + TestData.cities.foreach { city => + city.lat should (be >= 24.0 and be <= 50.0) + city.lng should (be >= -125.0 and be <= -66.0) + } + } + + "zoomViewports" should "cover zoom levels 10-15" in { + TestData.zoomViewports.map(_.zoom).sorted shouldBe Seq(10, 11, 12, 13, 14, 15) + } + + it should "have spans that grow as zoom decreases (wider view)" in { + val sortedByZoom = TestData.zoomViewports.sortBy(_.zoom) + sortedByZoom.sliding(2).foreach { case Seq(wider, tighter) => + wider.latSpan should be > tighter.latSpan + wider.lngSpan should be > tighter.lngSpan + } + } + + it should "have roughly 2x span increase per zoom level" in { + val sortedByZoom = TestData.zoomViewports.sortBy(_.zoom) + sortedByZoom.sliding(2).foreach { case Seq(wider, tighter) => + val latRatio = wider.latSpan / tighter.latSpan + latRatio should (be >= 1.8 and be <= 2.2) + } + } + + // --- buildFeedEntry --- + + "buildFeedEntry" should "produce all required session keys" in { + val city = CityCenter("Test", 40.0, -74.0) + val viewport = ZoomViewport(15, 0.026, 0.017) + val entry = TestData.buildFeedEntry(city, viewport) + + entry should contain key OverpassQuery.CityName + entry should contain key OverpassQuery.ZoomLevel + entry should contain key OverpassQuery.QueryBody + } + + it should "set cityName and zoomLevel from inputs" in { + val city = CityCenter("Denver", 39.7478, -104.9995) + val viewport = ZoomViewport(13, 0.105, 0.069) + val entry = TestData.buildFeedEntry(city, viewport) + + entry(OverpassQuery.CityName) shouldBe "Denver" + entry(OverpassQuery.ZoomLevel) shouldBe 13 + } + + it should "center the bounding box on the city coordinates" in { + val city = CityCenter("Test", 40.0, -74.0) + val viewport = ZoomViewport(15, 0.026, 0.017) + val entry = TestData.buildFeedEntry(city, viewport) + val query = entry(OverpassQuery.QueryBody).asInstanceOf[String] + + // south = 40.0 - 0.013 = 39.987, north = 40.0 + 0.013 = 40.013 + // west = -74.0 - 0.0085 = -74.0085, east = -74.0 + 0.0085 = -73.9915 + query should include ("39.987") + query should include ("40.013") + query should include ("-74.0085") + query should include ("-73.9915") + } + + // --- randomFeeder --- + + "randomFeeder" should "produce valid entries on each call" in { + val entries = (1 to 20).map(_ => TestData.randomFeeder.next()) + + entries.foreach { entry => + entry should contain key OverpassQuery.CityName + entry should contain key OverpassQuery.ZoomLevel + entry should contain key OverpassQuery.QueryBody + TestData.cities.map(_.name) should contain (entry(OverpassQuery.CityName)) + TestData.zoomViewports.map(_.zoom) should contain (entry(OverpassQuery.ZoomLevel)) + } + } + + it should "produce varied results (not always the same)" in { + val entries = (1 to 50).map(_ => TestData.randomFeeder.next()) + val uniqueCities = entries.map(_(OverpassQuery.CityName)).distinct + val uniqueZooms = entries.map(_(OverpassQuery.ZoomLevel)).distinct + + uniqueCities.length should be > 1 + uniqueZooms.length should be > 1 + } + + // --- weightedZoomFeeder --- + + "weightedZoomFeeder" should "produce valid entries" in { + val entries = (1 to 20).map(_ => TestData.weightedZoomFeeder.next()) + + entries.foreach { entry => + entry should contain key OverpassQuery.CityName + entry should contain key OverpassQuery.ZoomLevel + entry should contain key OverpassQuery.QueryBody + } + } + + it should "favor zoom levels 13-15 over 10-12" in { + val entries = (1 to 1000).map(_ => TestData.weightedZoomFeeder.next()) + val zooms = entries.map(_(OverpassQuery.ZoomLevel).asInstanceOf[Int]) + + val closeZooms = zooms.count(z => z >= 13 && z <= 15) + val farZooms = zooms.count(z => z >= 10 && z <= 12) + + // 80% should be z13-z15, 20% z10-z12. Allow some variance. + closeZooms.toDouble / entries.length should be > 0.65 + farZooms.toDouble / entries.length should be < 0.35 + } +}