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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions .github/workflows/load-test.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
## Load Test Results — \`$SCENARIO\`

| Scenario | Report |
|----------|--------|
$(echo -e "$ARTIFACTS")

[View full run]($RUN_URL)

> 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"
40 changes: 40 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,4 @@ build_keys.conf
linux/
macos/
web/
windows/
windows/
24 changes: 24 additions & 0 deletions load-tests/.devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions load-tests/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"
}
5 changes: 5 additions & 0 deletions load-tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Gradle build outputs (includes Gatling HTML reports in build/reports/gatling/)
build/

# Gradle cache
.gradle/
64 changes: 64 additions & 0 deletions load-tests/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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'
```
Loading
Loading