Skip to content

Commit 0435b2d

Browse files
test: add parse/export/search performance benchmarks and CI artifacts (#76)
* test: add parse/export/search performance benchmarks and CI artifacts * chore: pin pytest-benchmark 5.2.3 and clarify json.dumps in bench fixtures Pin pytest-benchmark to 5.2.3 after verifying compatibility with pytest 9.0 and the benchmark suite. Annotate benchmark JSONL serialization to document that json.dumps is intentional for file I/O, not Flask jsonify. * refactor(benchmarks): harden corpus helpers in conftest Parse the JSONL template once per write_jsonl call and deepcopy entries in the loop. Use exist_ok=True when creating bench-project in seed_search_corpus. * fix(benchmarks): harden memory test and ruff test glob Reset tracemalloc peak before measuring large-file parse, assert non-empty message count, extend E402 per-file-ignores to tests/**, and clarify README that benchmark tests live under tests/benchmarks/. * fix(benchmarks): address PR #76 review feedback from @timon0305 Drop unnecessary actions: write on benchmarks CI job; assert explicit search hit count on list response; document 10x memory ceiling and v1 template limitations in README; note NoopSink export rounds are stateless.
1 parent db3c48b commit 0435b2d

12 files changed

Lines changed: 289 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,37 @@ jobs:
204204
esac
205205
npm install --no-save "${PKG}@${ROLLUP_VERSION}"
206206
- run: npm test
207+
208+
benchmarks:
209+
name: Performance benchmarks (informational)
210+
runs-on: ubuntu-latest
211+
permissions:
212+
contents: read
213+
steps:
214+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
215+
with:
216+
persist-credentials: false
217+
218+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
219+
with:
220+
python-version: "3.12"
221+
cache: pip
222+
cache-dependency-path: |
223+
requirements.txt
224+
requirements-dev.txt
225+
226+
- name: Install dev dependencies
227+
run: pip install -r requirements-dev.txt
228+
229+
- name: Run benchmarks
230+
run: >
231+
pytest tests/benchmarks/
232+
--benchmark-only
233+
--benchmark-json=benchmark-results.json
234+
--benchmark-columns=min,max,mean,stddev,rounds
235+
-o addopts=
236+
237+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
238+
with:
239+
name: benchmark-results
240+
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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Performance benchmarks
2+
3+
Test files live under `tests/benchmarks/`; this directory holds only documentation and the informational `baselines.json` snapshot.
4+
5+
Repeatable local measurements for parse, bulk export, and search hot paths.
6+
7+
## Run locally
8+
9+
```bash
10+
pip install -r requirements-dev.txt
11+
pytest tests/benchmarks/ --benchmark-only -o addopts= -v
12+
```
13+
14+
## Memory check
15+
16+
```bash
17+
pytest tests/benchmarks/test_parse_memory.py -v -o addopts=
18+
```
19+
20+
The memory test also runs as part of the normal `pytest` suite (timing benchmarks are skipped via `--benchmark-skip` in `pyproject.toml`).
21+
22+
## Scenarios
23+
24+
| Group | What |
25+
|-------|------|
26+
| parse | `parse_session` on 10 / 500 / 5000+ line JSONL |
27+
| export | `run_bulk_export` over 10 / 50 / 100 sessions |
28+
| search | `GET /api/search` over a 50-session synthetic corpus |
29+
30+
Large JSONL files (5000+ lines) are generated at test session scope under pytest's temp directory — not committed to git.
31+
32+
Corpora repeat one row from `tests/fixtures/session_with_tools.jsonl`, so parse/export numbers measure steady-state throughput on a narrow schema slice — not full parser branch coverage. Treat as v1 baselines, not exhaustive perf proof.
33+
34+
The memory test (`test_parse_memory.py`) is intentionally **not** skipped by `--benchmark-skip`; it runs in the main `pytest` job and builds the session-scoped 5000-line fixture once per session.
35+
36+
## CI
37+
38+
The `benchmarks` workflow job uploads `benchmark-results.json` as a downloadable artifact. There is no regression gate yet.
39+
40+
## Refresh baselines
41+
42+
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: 5 additions & 2 deletions
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 = [
@@ -31,4 +34,4 @@ combine-as-imports = true
3134
# CLI bootstrap: sys.path must be set before local imports.
3235
"scripts/export.py" = ["E402"]
3336
# Tests mirror the same path bootstrap before importing app/utils.
34-
"tests/*.py" = ["E402"]
37+
"tests/**/*.py" = ["E402"]

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ pytest-cov>=5.0
66
ruff>=0.9.0
77
pip-audit>=2.7.0
88
hypothesis>=6.100.0
9+
pytest-benchmark==5.2.3

tests/benchmarks/__init__.py

Whitespace-only changes.

tests/benchmarks/conftest.py

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