Skip to content

Commit 6283017

Browse files
test: add parse/export/search performance benchmarks and CI artifacts
1 parent 8083f3e commit 6283017

12 files changed

Lines changed: 269 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,38 @@ jobs:
189189

190190
- run: npm ci
191191
- run: npm test
192+
193+
benchmarks:
194+
name: Performance benchmarks (informational)
195+
runs-on: ubuntu-latest
196+
permissions:
197+
contents: read
198+
actions: write
199+
steps:
200+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
201+
with:
202+
persist-credentials: false
203+
204+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
205+
with:
206+
python-version: "3.12"
207+
cache: pip
208+
cache-dependency-path: |
209+
requirements.txt
210+
requirements-dev.txt
211+
212+
- name: Install dev dependencies
213+
run: pip install -r requirements-dev.txt
214+
215+
- name: Run benchmarks
216+
run: >
217+
pytest tests/benchmarks/
218+
--benchmark-only
219+
--benchmark-json=benchmark-results.json
220+
--benchmark-columns=min,max,mean,stddev,rounds
221+
-o addopts=
222+
223+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
224+
with:
225+
name: benchmark-results
226+
path: benchmark-results.json

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ pytest tests/test_api_integration.py -v
6666
pytest tests/test_search.py -v
6767
pytest tests/test_api_routes.py -v
6868
pytest tests/test_error_codes.py -v
69+
pytest tests/benchmarks/ --benchmark-only -o addopts= -v # performance baselines (see benchmarks/README.md)
6970
```
7071

7172
### JavaScript (vitest)

benchmarks/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Performance benchmarks
2+
3+
Repeatable local measurements for parse, bulk export, and search hot paths.
4+
5+
## Run locally
6+
7+
```bash
8+
pip install -r requirements-dev.txt
9+
pytest tests/benchmarks/ --benchmark-only -o addopts= -v
10+
```
11+
12+
## Memory check
13+
14+
```bash
15+
pytest tests/benchmarks/test_parse_memory.py -v
16+
```
17+
18+
The memory test also runs as part of the normal `pytest` suite (timing benchmarks are skipped via `--benchmark-skip` in `pyproject.toml`).
19+
20+
## Scenarios
21+
22+
| Group | What |
23+
|-------|------|
24+
| parse | `parse_session` on 10 / 500 / 5000+ line JSONL |
25+
| export | `run_bulk_export` over 10 / 50 / 100 sessions |
26+
| search | `GET /api/search` over a 50-session synthetic corpus |
27+
28+
Large JSONL files (5000+ lines) are generated at test session scope under pytest's temp directory — not committed to git.
29+
30+
## CI
31+
32+
The `benchmarks` workflow job uploads `benchmark-results.json` as a downloadable artifact. There is no regression gate yet.
33+
34+
## Refresh baselines
35+
36+
After intentional performance work, copy key means from a local run into `baselines.json` with a date and machine note. This file is informational only; CI does not compare against it.

benchmarks/baselines.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"_note": "Informational snapshot only — CI does not gate on these values.",
3+
"updated": null,
4+
"machine": null,
5+
"groups": {
6+
"parse": {},
7+
"export": {},
8+
"search": {}
9+
}
10+
}

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ packages = ["api", "utils", "models"]
55
exclude = ["tests/"]
66

77
[tool.pytest.ini_options]
8-
addopts = "--cov=api --cov=utils --cov-report=term-missing --cov-report=xml:coverage.xml"
8+
addopts = "--cov=api --cov=utils --cov-report=term-missing --cov-report=xml:coverage.xml --benchmark-skip"
99
testpaths = ["tests"]
10+
markers = [
11+
"benchmark: performance benchmarks (pytest-benchmark)",
12+
]
1013

1114
[tool.coverage.run]
1215
omit = [

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ types-Flask==1.1.6
55
pytest-cov>=5.0
66
ruff>=0.9.0
77
pip-audit>=2.7.0
8+
pytest-benchmark>=4.0.0

tests/benchmarks/__init__.py

Whitespace-only changes.

tests/benchmarks/conftest.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Synthetic corpora for parse/export/search performance benchmarks."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from app import create_app
11+
12+
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures"
13+
TEMPLATE_LINE = (FIXTURES / "session_with_tools.jsonl").read_text(encoding="utf-8").splitlines()[0]
14+
15+
16+
def write_jsonl(path: Path, line_count: int) -> Path:
17+
"""Write a JSONL session file with *line_count* rows derived from the template fixture."""
18+
with path.open("w", encoding="utf-8") as f:
19+
for i in range(line_count):
20+
entry = json.loads(TEMPLATE_LINE)
21+
entry["timestamp"] = f"2026-06-12T10:{i % 60:02d}:00Z"
22+
if i % 3 == 1:
23+
msg = entry.setdefault("message", {})
24+
if isinstance(msg, dict) and "content" in msg:
25+
msg["content"] = [{"type": "text", "text": f"benchmark token {i} searchable"}]
26+
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
27+
return path
28+
29+
30+
def seed_search_corpus(
31+
base_dir: Path,
32+
*,
33+
session_count: int = 50,
34+
lines_per_session: int = 20,
35+
) -> Path:
36+
"""Create a multi-session project tree under *base_dir* for search benchmarks."""
37+
project = base_dir / "bench-project"
38+
project.mkdir(parents=True)
39+
for i in range(session_count):
40+
write_jsonl(project / f"session_{i:04d}.jsonl", lines_per_session)
41+
return base_dir
42+
43+
44+
@pytest.fixture(scope="session")
45+
def parse_small_file(tmp_path_factory: pytest.TempPathFactory) -> Path:
46+
root = tmp_path_factory.mktemp("bench")
47+
return write_jsonl(root / "small.jsonl", 10)
48+
49+
50+
@pytest.fixture(scope="session")
51+
def parse_medium_file(tmp_path_factory: pytest.TempPathFactory) -> Path:
52+
root = tmp_path_factory.mktemp("bench")
53+
return write_jsonl(root / "medium.jsonl", 500)
54+
55+
56+
@pytest.fixture(scope="session")
57+
def parse_large_file(tmp_path_factory: pytest.TempPathFactory) -> Path:
58+
root = tmp_path_factory.mktemp("bench")
59+
return write_jsonl(root / "large.jsonl", 5000)
60+
61+
62+
@pytest.fixture
63+
def export_corpus(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
64+
"""Project dir with N session files. Parametrize N via indirect fixture."""
65+
count = request.param
66+
project = tmp_path / "bench-project"
67+
project.mkdir()
68+
for i in range(count):
69+
write_jsonl(project / f"session_{i:04d}.jsonl", 20)
70+
return project
71+
72+
73+
@pytest.fixture
74+
def bench_client_search_corpus(tmp_path: Path):
75+
"""Flask test client backed by a 50-session synthetic project tree."""
76+
seed_search_corpus(tmp_path)
77+
app = create_app(base_dir=str(tmp_path))
78+
app.config["TESTING"] = True
79+
return app.test_client()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Benchmark run_bulk_export over 10, 50, and 100 session corpora."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from utils.export_engine import NoopSink, run_bulk_export
10+
11+
12+
@pytest.mark.benchmark(group="export")
13+
@pytest.mark.parametrize(
14+
"export_corpus",
15+
[10, 50, 100],
16+
indirect=True,
17+
ids=["sessions-10", "sessions-50", "sessions-100"],
18+
)
19+
def test_bulk_export_session_count(
20+
benchmark,
21+
export_corpus: Path,
22+
) -> None:
23+
projects = [{"name": "bench-project", "path": str(export_corpus), "display_name": "Bench"}]
24+
25+
def _run() -> object:
26+
return run_bulk_export(
27+
projects=projects,
28+
since="all",
29+
rules=[],
30+
last_export_sessions={},
31+
sink=NoopSink(),
32+
fmt="md",
33+
path_layout="api",
34+
manifest_style="api",
35+
)
36+
37+
result = benchmark(_run)
38+
assert result.exported_session_count > 0
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Benchmark parse_session on small, medium, and large JSONL corpora."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from utils.jsonl_parser import parse_session
10+
11+
12+
@pytest.mark.benchmark(group="parse")
13+
def test_parse_session_small(benchmark, parse_small_file: Path) -> None:
14+
benchmark(parse_session, str(parse_small_file))
15+
16+
17+
@pytest.mark.benchmark(group="parse")
18+
def test_parse_session_medium(benchmark, parse_medium_file: Path) -> None:
19+
benchmark(parse_session, str(parse_medium_file))
20+
21+
22+
@pytest.mark.benchmark(group="parse")
23+
def test_parse_session_large(benchmark, parse_large_file: Path) -> None:
24+
benchmark(parse_session, str(parse_large_file))

0 commit comments

Comments
 (0)