Skip to content

Commit 4cd81de

Browse files
committed
feat(load-testing): integrate Locust for on-demand load testing and add related scripts
- Added Locust as a dependency in `pyproject.toml` for load testing capabilities. - Introduced `run-load-test` script to facilitate running load tests with customizable parameters. - Created `load/semantic_search.py` for defining the load test scenario targeting the semantic search endpoint. - Implemented GitHub Actions workflow for automated load testing. - Added end-to-end smoke test for semantic search in `tests/e2e/test_semantic_search.py` to validate deployment functionality.
1 parent 65eb582 commit 4cd81de

7 files changed

Lines changed: 315 additions & 0 deletions

File tree

.github/workflows/load_test.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: on-demand-load-test
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
load-test:
8+
runs-on: ubuntu-latest
9+
10+
steps:
11+
- name: Checkout repository
12+
uses: actions/checkout@v4
13+
14+
- name: Set up Python
15+
uses: actions/setup-python@v5
16+
with:
17+
python-version: '3.10'
18+
19+
- name: Install dependencies
20+
run: |
21+
python -m pip install --upgrade pip
22+
pip install poetry
23+
poetry install --no-interaction --no-root
24+
pip install locust
25+
26+
- name: Run Locust load test (headless)
27+
env:
28+
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
29+
run: |
30+
locust -f load/semantic_search.py --headless -u 200 -r 20 -t 5m --host "$STAGING_BASE_URL" | cat

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,36 @@ poetry run pytest -v # verbose
8989
poetry run pytest --cov=app # with coverage (needs pytest-cov)
9090
```
9191

92+
### End-to-End Smoke Test (staging)
93+
Provide the base URL of a running deployment via the `STAGING_BASE_URL` env var and run the *e2e* marked tests:
94+
95+
```bash
96+
STAGING_BASE_URL=https://staging.killrvideo.com \
97+
poetry run pytest tests/e2e -m e2e -q
98+
```
99+
100+
The test performs a single semantic search request and validates the JSON schema.
101+
102+
### On-Demand Load Testing
103+
A lightweight Locust scenario ships with the repo. Use the `run-load-test` helper (registered as a Poetry script) to drive a burst of semantic searches against any environment:
104+
105+
```bash
106+
# 200 users, ramping at 20/s for 5 minutes
107+
poetry run run-load-test https://staging.killrvideo.com \
108+
--users 200 --spawn-rate 20 --duration 5m
109+
```
110+
111+
Flags:
112+
* `URL` (positional) – base URL to test
113+
* `--users` – concurrent users (default 200)
114+
* `--spawn-rate` – users spawned per second (default 20)
115+
* `--duration` – test length (Locust time string, default `5m`)
116+
117+
Behind the scenes this wraps:
118+
```bash
119+
locust -f load/semantic_search.py --headless -u <users> -r <spawn> -t <duration> --host <URL>
120+
```
121+
92122
---
93123

94124
## Project Structure

load/semantic_search.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Locust load-test file exercising the semantic search endpoint.
2+
3+
Run standalone, e.g.:
4+
5+
locust -f load/semantic_search.py --headless -u 200 -r 20 -t 5m \
6+
--host https://staging.killrvideo.com
7+
8+
The parameters above spin up 200 concurrent users with a hatch rate of 20
9+
users/second, roughly mapping to ~20 requests per second steady-state given
10+
our simple user scenario. Adjust figures to match your capacity planning.
11+
12+
The *host* URL is provided at runtime via the ``--host`` flag or the
13+
``STAGING_BASE_URL`` environment variable.
14+
"""
15+
16+
from locust import HttpUser, task, between
17+
18+
19+
class SemanticSearchUser(HttpUser): # noqa: D401 – Locust user class
20+
# Short random wait to reach ~20 RPS with 200 users
21+
wait_time = between(0.1, 0.3)
22+
23+
@task
24+
def search(self): # noqa: D401
25+
# Static query keeps the test deterministic; real test could randomise.
26+
self.client.get(
27+
"/api/v1/search/videos", params={"query": "cats", "mode": "semantic"}
28+
)

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ ruff = "^0.11.12"
2626
email-validator = "^2.2.0"
2727
pyyaml = {version = "^6.0", optional = false}
2828
python-dotenv = "^1.1.0"
29+
locust = "^2.26.1"
2930

3031
[build-system]
3132
requires = ["poetry-core"]
@@ -40,3 +41,4 @@ lint.extend-ignore = ["E402", "E702"]
4041

4142
[tool.poetry.scripts]
4243
gen-openapi = "scripts.generate_openapi:main"
44+
run-load-test = "scripts.run_load_test:main"

scripts/enable_vector_flag.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
"""Toggle the *VECTOR_SEARCH_ENABLED* flag and optionally trigger DB migration.
4+
5+
Usage (module mode):
6+
7+
python -m scripts.enable_vector_flag # Enable flag, run migration
8+
python -m scripts.enable_vector_flag --disable # Disable flag
9+
10+
The script works by rewriting the project's ``.env`` file in place. If the
11+
flag line is missing it is appended. A best-effort attempt is then made to
12+
run the migrations via ``scripts.migrate`` (no-op if the module is absent).
13+
"""
14+
15+
import argparse
16+
import logging
17+
import pathlib
18+
import subprocess
19+
import sys
20+
import importlib
21+
22+
23+
logger = logging.getLogger(__name__)
24+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
25+
26+
# Project root is two levels up from this file (scripts/enable_vector_flag.py)
27+
_PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
28+
_ENV_FILE = _PROJECT_ROOT / ".env"
29+
30+
31+
def _rewrite_env_file(enable: bool) -> None: # noqa: D401
32+
"""Update the .env file in-place to reflect *enable* state."""
33+
34+
if not _ENV_FILE.exists():
35+
raise FileNotFoundError(
36+
".env file not found. Aborting toggle of VECTOR_SEARCH_ENABLED."
37+
)
38+
39+
lines = _ENV_FILE.read_text().splitlines(keepends=False)
40+
flag_written = False
41+
for i, line in enumerate(lines):
42+
if line.strip().startswith("VECTOR_SEARCH_ENABLED"):
43+
lines[i] = f"VECTOR_SEARCH_ENABLED={'true' if enable else 'false'}"
44+
flag_written = True
45+
break
46+
47+
if not flag_written:
48+
# Append newline if file doesn't end with one
49+
if lines and lines[-1] and not lines[-1].endswith("\n"):
50+
lines[-1] += "\n"
51+
lines.append(f"VECTOR_SEARCH_ENABLED={'true' if enable else 'false'}")
52+
53+
_ENV_FILE.write_text("\n".join(lines) + "\n")
54+
logger.info("VECTOR_SEARCH_ENABLED=%s written to %s", enable, _ENV_FILE)
55+
56+
57+
def _run_migrations() -> None: # noqa: D401
58+
"""Attempt to execute migrations via scripts.migrate if present."""
59+
60+
try:
61+
migrate_mod = importlib.import_module("scripts.migrate")
62+
except ModuleNotFoundError:
63+
logger.warning("scripts.migrate not found – skipping migration step.")
64+
return
65+
66+
if hasattr(migrate_mod, "main"):
67+
logger.info("Running DB migrations via scripts.migrate.main() …")
68+
migrate_mod.main() # type: ignore[attr-defined]
69+
elif hasattr(migrate_mod, "run"):
70+
logger.info("Running DB migrations via scripts.migrate.run() …")
71+
migrate_mod.run() # type: ignore[attr-defined]
72+
else:
73+
# Fallback to module execution in subprocess to preserve CLI semantics
74+
logger.info("Running 'python -m scripts.migrate' as subprocess …")
75+
subprocess.run([sys.executable, "-m", "scripts.migrate"], check=False)
76+
77+
78+
def main() -> None: # noqa: D401 – entry point
79+
parser = argparse.ArgumentParser(description="Toggle VECTOR_SEARCH_ENABLED flag")
80+
parser.add_argument(
81+
"--disable",
82+
action="store_true",
83+
help="Disable vector search instead of enabling it",
84+
)
85+
parser.add_argument(
86+
"--skip-migration",
87+
action="store_true",
88+
help="Do not attempt to run DB migrations after toggling",
89+
)
90+
91+
args = parser.parse_args()
92+
enable = not args.disable
93+
94+
_rewrite_env_file(enable)
95+
96+
if not args.skip_migration and enable:
97+
_run_migrations()
98+
99+
100+
if __name__ == "__main__": # pragma: no cover
101+
main()

scripts/run_load_test.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
"""Convenience wrapper to launch the semantic-search Locust test headless.
4+
5+
Example usage::
6+
7+
- Positional URL (recommended):
8+
9+
python -m scripts.run_load_test https://staging.killrvideo.com \
10+
--users 200 --spawn-rate 20 --duration 5m
11+
12+
- Legacy flag form (still supported):
13+
14+
python -m scripts.run_load_test --host https://staging.killrvideo.com
15+
"""
16+
17+
import argparse
18+
import subprocess
19+
import sys
20+
from pathlib import Path
21+
22+
23+
DEFAULT_USERS = 200
24+
DEFAULT_SPAWN_RATE = 20
25+
DEFAULT_DURATION = "5m"
26+
27+
28+
def main() -> None: # noqa: D401 – entry point
29+
parser = argparse.ArgumentParser(description="Run Locust load test (headless)")
30+
parser.add_argument(
31+
"url",
32+
metavar="URL",
33+
help="Base URL of the KillrVideo deployment (e.g. https://staging.killrvideo.com)",
34+
)
35+
# Backwards-compat optional flag (not shown in usage)
36+
parser.add_argument("--host", dest="_host_legacy", help=argparse.SUPPRESS)
37+
parser.add_argument("--users", type=int, default=DEFAULT_USERS, help="Number of concurrent users (default: 200)")
38+
parser.add_argument("--spawn-rate", type=int, default=DEFAULT_SPAWN_RATE, help="User hatch rate per second (default: 20)")
39+
parser.add_argument("--duration", default=DEFAULT_DURATION, help="Test duration (Locust time format, default: 5m)")
40+
parser.add_argument(
41+
"--locust-file",
42+
default=str(Path("load/semantic_search.py")),
43+
help="Path to the Locust test file (default: load/semantic_search.py)",
44+
)
45+
46+
args = parser.parse_args()
47+
48+
cmd = [
49+
"locust",
50+
"-f",
51+
args.locust_file,
52+
"--headless",
53+
"-u",
54+
str(args.users),
55+
"-r",
56+
str(args.spawn_rate),
57+
"-t",
58+
args.duration,
59+
"--host",
60+
args._host_legacy if args._host_legacy else args.url,
61+
]
62+
63+
print("Running:", " ".join(cmd))
64+
try:
65+
subprocess.run(cmd, check=True)
66+
except subprocess.CalledProcessError as exc:
67+
sys.exit(exc.returncode)
68+
69+
70+
if __name__ == "__main__": # pragma: no cover
71+
main()

tests/e2e/test_semantic_search.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
"""End-to-end smoke test hitting the *staging* KillrVideo deployment.
4+
5+
The test is **skipped automatically** unless the environment variable
6+
``STAGING_BASE_URL`` is provided. This allows the regular CI unit-test
7+
matrix (which spins up an isolated in-process FastAPI app) to pass
8+
without requiring external connectivity.
9+
10+
When executed with the variable set, the test performs a single semantic
11+
search request and asserts a successful HTTP 200 response plus the
12+
presence of the expected JSON keys.
13+
"""
14+
15+
import os
16+
17+
import pytest
18+
import httpx
19+
20+
21+
STAGING_BASE_URL = os.getenv("STAGING_BASE_URL")
22+
23+
pytestmark = [
24+
pytest.mark.e2e,
25+
pytest.mark.skipif(
26+
not STAGING_BASE_URL, reason="STAGING_BASE_URL env var not configured"
27+
),
28+
]
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_semantic_search_smoke(): # noqa: D401 – simple smoke test
33+
"""Perform a single semantic-mode search and validate basic schema."""
34+
35+
async with httpx.AsyncClient(base_url=STAGING_BASE_URL, timeout=10) as client:
36+
resp = await client.get(
37+
"/api/v1/search/videos", params={"query": "cats", "mode": "semantic"}
38+
)
39+
40+
# --- Assertions -------------------------------------------------------
41+
assert resp.status_code == 200, resp.text
42+
43+
payload = resp.json()
44+
45+
# Basic structural checks – we don't lock-step on exact schema here to
46+
# remain resilient to future extensions, but we do want the core keys.
47+
assert isinstance(payload, dict)
48+
assert "data" in payload, "Missing 'data' key in response"
49+
assert "pagination" in payload, "Missing 'pagination' key in response"
50+
51+
# Ensure we actually received *some* results in staging (sanity guard).
52+
assert isinstance(payload["data"], list)
53+
# We don't enforce a minimum count (could be empty) but the type must hold.

0 commit comments

Comments
 (0)